From 852e2cd5c1b94e85ece89ecd3182811fa206b8bc Mon Sep 17 00:00:00 2001 From: Stu Leak Date: Sun, 4 Jan 2026 18:44:21 -0500 Subject: [PATCH] Add VT-styled DVD menu generation --- author_menu.go | 281 +++++++++++++++++++++++++++++++++++++++++++++++ author_module.go | 48 ++++++-- 2 files changed, 318 insertions(+), 11 deletions(-) create mode 100644 author_menu.go 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", 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("