diff --git a/author_menu.go b/author_menu.go
new file mode 100644
index 0000000..024c61f
--- /dev/null
+++ b/author_menu.go
@@ -0,0 +1,281 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+
+ "git.leaktechnologies.dev/stu/VideoTools/internal/logging"
+ "git.leaktechnologies.dev/stu/VideoTools/internal/utils"
+)
+
+type dvdMenuButton struct {
+ Label string
+ Command string
+ X0 int
+ Y0 int
+ X1 int
+ Y1 int
+}
+
+func buildDVDMenuAssets(ctx context.Context, workDir, title, region, aspect string, chapters []authorChapter, logFn func(string)) (string, []dvdMenuButton, error) {
+ width, height := dvdMenuDimensions(region)
+ buttons := buildDVDMenuButtons(chapters, width, height)
+ if len(buttons) == 0 {
+ return "", nil, nil
+ }
+
+ bgPath := filepath.Join(workDir, "menu_bg.png")
+ overlayPath := filepath.Join(workDir, "menu_overlay.png")
+ highlightPath := filepath.Join(workDir, "menu_highlight.png")
+ selectPath := filepath.Join(workDir, "menu_select.png")
+ menuMpg := filepath.Join(workDir, "menu.mpg")
+ menuSpu := filepath.Join(workDir, "menu_spu.mpg")
+ spumuxXML := filepath.Join(workDir, "menu_spu.xml")
+
+ if logFn != nil {
+ logFn("Building DVD menu assets...")
+ }
+
+ if err := buildMenuBackground(ctx, bgPath, title, buttons, width, height); err != nil {
+ return "", nil, err
+ }
+ if err := buildMenuOverlays(ctx, overlayPath, highlightPath, selectPath, buttons, width, height); err != nil {
+ return "", nil, err
+ }
+ if err := buildMenuMPEG(ctx, bgPath, menuMpg, region, aspect); err != nil {
+ return "", nil, err
+ }
+ if err := writeSpumuxXML(spumuxXML, overlayPath, highlightPath, selectPath, buttons); err != nil {
+ return "", nil, err
+ }
+ if err := runSpumux(ctx, spumuxXML, menuMpg, menuSpu, logFn); err != nil {
+ return "", nil, err
+ }
+ if logFn != nil {
+ logFn(fmt.Sprintf("DVD menu created: %s", filepath.Base(menuSpu)))
+ }
+ return menuSpu, buttons, nil
+}
+
+func dvdMenuDimensions(region string) (int, int) {
+ if strings.ToLower(region) == "pal" {
+ return 720, 576
+ }
+ return 720, 480
+}
+
+func buildDVDMenuButtons(chapters []authorChapter, width, height int) []dvdMenuButton {
+ buttons := []dvdMenuButton{
+ {
+ Label: "Play",
+ Command: "jump title 1;",
+ },
+ }
+
+ maxChapters := 8
+ if len(chapters) < maxChapters {
+ maxChapters = len(chapters)
+ }
+ for i := 0; i < maxChapters; i++ {
+ label := fmt.Sprintf("Chapter %d", i+1)
+ if title := strings.TrimSpace(chapters[i].Title); title != "" {
+ label = fmt.Sprintf("Chapter %d: %s", i+1, utils.ShortenMiddle(title, 34))
+ }
+ buttons = append(buttons, dvdMenuButton{
+ Label: label,
+ Command: fmt.Sprintf("jump title 1 chapter %d;", i+1),
+ })
+ }
+
+ startY := 180
+ rowHeight := 34
+ boxHeight := 28
+ x0 := 86
+ x1 := width - 86
+ for i := range buttons {
+ y0 := startY + i*rowHeight
+ buttons[i].X0 = x0
+ buttons[i].X1 = x1
+ buttons[i].Y0 = y0
+ buttons[i].Y1 = y0 + boxHeight
+ }
+ return buttons
+}
+
+func buildMenuBackground(ctx context.Context, outputPath, title string, buttons []dvdMenuButton, width, height int) error {
+ logoPath := findVTLogoPath()
+ if logoPath == "" {
+ return fmt.Errorf("VT logo not found for menu rendering")
+ }
+
+ safeTitle := utils.ShortenMiddle(strings.TrimSpace(title), 40)
+ if safeTitle == "" {
+ safeTitle = "DVD Menu"
+ }
+
+ bgColor := "0x0f172a"
+ headerColor := "0x1f2937"
+ textColor := "white"
+ accentColor := "0x7c3aed"
+
+ filterParts := []string{
+ fmt.Sprintf("drawbox=x=0:y=0:w=%d:h=72:color=%s:t=fill", width, headerColor),
+ fmt.Sprintf("drawtext=font='DejaVu Sans Mono':fontcolor=%s:fontsize=28:x=36:y=20:text='%s'", textColor, escapeDrawtextText("VideoTools DVD")),
+ fmt.Sprintf("drawtext=font='DejaVu Sans Mono':fontcolor=%s:fontsize=18:x=36:y=80:text='%s'", textColor, escapeDrawtextText(safeTitle)),
+ fmt.Sprintf("drawbox=x=36:y=108:w=%d:h=2:color=%s:t=fill", width-72, accentColor),
+ fmt.Sprintf("drawtext=font='DejaVu Sans Mono':fontcolor=%s:fontsize=16:x=36:y=122:text='%s'", textColor, escapeDrawtextText("Select a title or chapter to play")),
+ }
+
+ for i, btn := range buttons {
+ label := escapeDrawtextText(btn.Label)
+ y := 184 + i*34
+ filterParts = append(filterParts, fmt.Sprintf("drawtext=font='DejaVu Sans Mono':fontcolor=%s:fontsize=20:x=110:y=%d:text='%s'", textColor, y, label))
+ }
+
+ filterChain := strings.Join(filterParts, ",")
+
+ args := []string{
+ "-y",
+ "-f", "lavfi",
+ "-i", fmt.Sprintf("color=c=%s:s=%dx%d", bgColor, width, height),
+ "-i", logoPath,
+ "-filter_complex", fmt.Sprintf("[0:v]%s[bg];[1:v]scale=72:-1[logo];[bg][logo]overlay=W-w-36:18", filterChain),
+ "-frames:v", "1",
+ outputPath,
+ }
+ return runCommandWithLogger(ctx, utils.GetFFmpegPath(), args, nil)
+}
+
+func buildMenuOverlays(ctx context.Context, overlayPath, highlightPath, selectPath string, buttons []dvdMenuButton, width, height int) error {
+ if err := buildMenuOverlay(ctx, overlayPath, buttons, width, height, "0x000000@0.0"); err != nil {
+ return err
+ }
+ if err := buildMenuOverlay(ctx, highlightPath, buttons, width, height, "0xf59e0b@0.35"); err != nil {
+ return err
+ }
+ if err := buildMenuOverlay(ctx, selectPath, buttons, width, height, "0xf59e0b@0.65"); err != nil {
+ return err
+ }
+ return nil
+}
+
+func buildMenuOverlay(ctx context.Context, outputPath string, buttons []dvdMenuButton, width, height int, boxColor string) error {
+ filterParts := []string{}
+ for _, btn := range buttons {
+ filterParts = append(filterParts, fmt.Sprintf("drawbox=x=%d:y=%d:w=%d:h=%d:color=%s:t=fill",
+ btn.X0, btn.Y0, btn.X1-btn.X0, btn.Y1-btn.Y0, boxColor))
+ }
+ filterChain := strings.Join(filterParts, ",")
+ if filterChain == "" {
+ filterChain = "null"
+ }
+
+ args := []string{
+ "-y",
+ "-f", "lavfi",
+ "-i", fmt.Sprintf("color=c=black@0.0:s=%dx%d", width, height),
+ "-vf", filterChain,
+ "-frames:v", "1",
+ outputPath,
+ }
+ return runCommandWithLogger(ctx, utils.GetFFmpegPath(), args, nil)
+}
+
+func buildMenuMPEG(ctx context.Context, bgPath, outputPath, region, aspect string) error {
+ scale := "720:480"
+ if strings.ToLower(region) == "pal" {
+ scale = "720:576"
+ }
+ args := []string{
+ "-y",
+ "-loop", "1",
+ "-i", bgPath,
+ "-t", "30",
+ "-r", "30000/1001",
+ "-vf", fmt.Sprintf("scale=%s,format=yuv420p", scale),
+ "-c:v", "mpeg2video",
+ "-b:v", "3000k",
+ "-maxrate", "5000k",
+ "-bufsize", "1835k",
+ "-g", "15",
+ "-pix_fmt", "yuv420p",
+ "-aspect", aspect,
+ "-f", "dvd",
+ outputPath,
+ }
+ return runCommandWithLogger(ctx, utils.GetFFmpegPath(), args, nil)
+}
+
+func writeSpumuxXML(path, overlayPath, highlightPath, selectPath string, buttons []dvdMenuButton) error {
+ var b strings.Builder
+ b.WriteString("\n")
+ b.WriteString(" \n")
+ b.WriteString(fmt.Sprintf(" \n",
+ escapeXMLAttr(overlayPath),
+ escapeXMLAttr(highlightPath),
+ escapeXMLAttr(selectPath),
+ ))
+ for i, btn := range buttons {
+ b.WriteString(fmt.Sprintf(" \n",
+ i+1, btn.X0, btn.Y0, btn.X1, btn.Y1))
+ }
+ b.WriteString(" \n")
+ b.WriteString(" \n")
+ b.WriteString("\n")
+ return os.WriteFile(path, []byte(b.String()), 0o644)
+}
+
+func runSpumux(ctx context.Context, spumuxXML, inputMpg, outputMpg string, logFn func(string)) error {
+ args := []string{"-m", "dvd", spumuxXML}
+ if logFn != nil {
+ logFn(fmt.Sprintf(">> spumux -m dvd %s < %s > %s", spumuxXML, filepath.Base(inputMpg), filepath.Base(outputMpg)))
+ }
+ cmd := exec.CommandContext(ctx, "spumux", args...)
+ inputFile, err := os.Open(inputMpg)
+ if err != nil {
+ return fmt.Errorf("open spumux input: %w", err)
+ }
+ defer inputFile.Close()
+ cmd.Stdin = inputFile
+ outFile, err := os.Create(outputMpg)
+ if err != nil {
+ return fmt.Errorf("create spumux output: %w", err)
+ }
+ defer outFile.Close()
+ cmd.Stdout = outFile
+ var stderr strings.Builder
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ logging.Debug(logging.CatSystem, "spumux stderr: %s", stderr.String())
+ return fmt.Errorf("spumux failed: %w", err)
+ }
+ return nil
+}
+
+func findVTLogoPath() string {
+ search := []string{
+ filepath.Join("assets", "logo", "VT_Icon.png"),
+ }
+ if exe, err := os.Executable(); err == nil {
+ dir := filepath.Dir(exe)
+ search = append(search, filepath.Join(dir, "assets", "logo", "VT_Icon.png"))
+ }
+ for _, p := range search {
+ if _, err := os.Stat(p); err == nil {
+ return p
+ }
+ }
+ return ""
+}
+
+func escapeDrawtextText(text string) string {
+ escaped := strings.ReplaceAll(text, "\\", "\\\\")
+ escaped = strings.ReplaceAll(escaped, ":", "\\:")
+ escaped = strings.ReplaceAll(escaped, "'", "\\'")
+ escaped = strings.ReplaceAll(escaped, "%", "\\%")
+ return escaped
+}
diff --git a/author_module.go b/author_module.go
index b325a59..820ec45 100644
--- a/author_module.go
+++ b/author_module.go
@@ -1523,9 +1523,6 @@ func (s *appState) promptAuthorOutput(paths []string, region, aspect, title stri
func authorWarnings(state *appState) []string {
var warnings []string
- if state.authorCreateMenu {
- warnings = append(warnings, "DVD menus are not implemented yet; the disc will play titles directly.")
- }
if len(state.authorSubtitles) > 0 {
warnings = append(warnings, "Subtitle tracks are not authored yet; they will be ignored.")
}
@@ -1740,6 +1737,7 @@ func (s *appState) addAuthorToQueue(paths []string, region, aspect, title, outpu
"authorTitle": s.authorTitle,
"authorRegion": s.authorRegion,
"authorAspect": s.authorAspectRatio,
+ "createMenu": s.authorCreateMenu,
"chapterSource": s.authorChapterSource,
"subtitleTracks": append([]string{}, s.authorSubtitles...),
"additionalAudios": append([]string{}, s.authorAudioTracks...),
@@ -1804,7 +1802,7 @@ func (s *appState) addAuthorVideoTSToQueue(videoTSPath, title, outputPath string
return nil
}
-func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, region, aspect, title, outputPath string, makeISO bool, clips []authorClip, chapters []authorChapter, treatAsChapters bool, logFn func(string), progressFn func(float64)) error {
+func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, region, aspect, title, outputPath string, makeISO bool, clips []authorClip, chapters []authorChapter, treatAsChapters bool, createMenu bool, logFn func(string), progressFn func(float64)) error {
tempRoot := authorTempRoot(outputPath)
if err := os.MkdirAll(tempRoot, 0755); err != nil {
return fmt.Errorf("failed to create temp root: %w", err)
@@ -1983,8 +1981,17 @@ func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, reg
}
}
+ var menuMpg string
+ var menuButtons []dvdMenuButton
+ if createMenu {
+ menuMpg, menuButtons, err = buildDVDMenuAssets(ctx, workDir, title, region, aspect, chapters, logFn)
+ if err != nil {
+ return err
+ }
+ }
+
xmlPath := filepath.Join(workDir, "dvd.xml")
- if err := writeDVDAuthorXML(xmlPath, mpgPaths, region, aspect, chapters); err != nil {
+ if err := writeDVDAuthorXML(xmlPath, mpgPaths, region, aspect, chapters, menuMpg, menuButtons); err != nil {
return err
}
@@ -2128,7 +2135,7 @@ func (s *appState) executeAuthorJob(ctx context.Context, job *queue.Job, progres
if videoTSPath := strings.TrimSpace(toString(cfg["videoTSPath"])); videoTSPath != "" {
outputPath := toString(cfg["outputPath"])
title := toString(cfg["title"])
- if err := ensureAuthorDependencies(true); err != nil {
+ if err := ensureAuthorDependencies(true, false); err != nil {
return err
}
@@ -2207,8 +2214,9 @@ func (s *appState) executeAuthorJob(ctx context.Context, job *queue.Job, progres
outputPath := toString(cfg["outputPath"])
makeISO, _ := cfg["makeISO"].(bool)
treatAsChapters, _ := cfg["treatAsChapters"].(bool)
+ createMenu := toBool(cfg["createMenu"])
- if err := ensureAuthorDependencies(makeISO); err != nil {
+ if err := ensureAuthorDependencies(makeISO, createMenu); err != nil {
return err
}
@@ -2286,7 +2294,7 @@ func (s *appState) executeAuthorJob(ctx context.Context, job *queue.Job, progres
}, false)
}
- err := s.runAuthoringPipeline(ctx, paths, region, aspect, title, outputPath, makeISO, clips, chapters, treatAsChapters, appendLog, updateProgress)
+ err := s.runAuthoringPipeline(ctx, paths, region, aspect, title, outputPath, makeISO, clips, chapters, treatAsChapters, createMenu, appendLog, updateProgress)
if err != nil {
friendly := authorFriendlyError(err)
appendLog("ERROR: " + friendly)
@@ -2429,7 +2437,7 @@ func buildAuthorFFmpegArgs(inputPath, outputPath, region, aspect string, progres
return args
}
-func writeDVDAuthorXML(path string, mpgPaths []string, region, aspect string, chapters []authorChapter) error {
+func writeDVDAuthorXML(path string, mpgPaths []string, region, aspect string, chapters []authorChapter, menuMpg string, menuButtons []dvdMenuButton) error {
format := strings.ToLower(region)
if format != "pal" {
format = "ntsc"
@@ -2437,7 +2445,20 @@ func writeDVDAuthorXML(path string, mpgPaths []string, region, aspect string, ch
var b strings.Builder
b.WriteString("\n")
- b.WriteString(" \n")
+ if menuMpg != "" && len(menuButtons) > 0 {
+ b.WriteString(" \n")
+ b.WriteString(" \n")
+ b.WriteString(" \n")
+ b.WriteString(fmt.Sprintf(" \n", escapeXMLAttr(menuMpg)))
+ for _, btn := range menuButtons {
+ b.WriteString(fmt.Sprintf(" \n", btn.Command))
+ }
+ b.WriteString(" \n")
+ b.WriteString(" \n")
+ b.WriteString(" \n")
+ } else {
+ b.WriteString(" \n")
+ }
b.WriteString(" \n")
b.WriteString(" \n")
b.WriteString(fmt.Sprintf(" \n", format, aspect))
@@ -2469,13 +2490,18 @@ func escapeXMLAttr(value string) string {
return strings.ReplaceAll(escaped, "\"", """)
}
-func ensureAuthorDependencies(makeISO bool) error {
+func ensureAuthorDependencies(makeISO bool, createMenu bool) error {
if err := ensureExecutable(utils.GetFFmpegPath(), "ffmpeg"); err != nil {
return err
}
if _, err := exec.LookPath("dvdauthor"); err != nil {
return fmt.Errorf("dvdauthor not found in PATH")
}
+ if createMenu {
+ if _, err := exec.LookPath("spumux"); err != nil {
+ return fmt.Errorf("spumux not found in PATH")
+ }
+ }
if makeISO {
if _, _, err := buildISOCommand("output.iso", "output", "VIDEO_TOOLS"); err != nil {
return err