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("