From 222e2f1414f707397059674ab435d66287b63763 Mon Sep 17 00:00:00 2001 From: Stu Leak Date: Tue, 6 Jan 2026 17:42:51 -0500 Subject: [PATCH] feat: Implement DVD menu templating system - Refactor author_menu.go to support multiple menu templates - Add Simple, Dark, and Poster menu templates - Add UI for selecting menu template and background image --- DONE.md | 16 ++ author_menu.go | 222 ++++++++++++++++++++++++++-- author_module.go | 52 ++++++- diagnostic_tools/diagnostic_tool.go | 26 ++++ 4 files changed, 303 insertions(+), 13 deletions(-) create mode 100644 diagnostic_tools/diagnostic_tool.go diff --git a/DONE.md b/DONE.md index 0578523..0d57ab3 100644 --- a/DONE.md +++ b/DONE.md @@ -1,7 +1,23 @@ # VideoTools - Completed Features +## Version 0.1.0-dev24 (2026-01-06) - DVD Menu Templating System + +### Features +- ✅ **DVD Menu Templating System** + - Refactored `author_menu.go` to support multiple, selectable menu templates. + - Implemented a `MenuTemplate` interface for easy extensibility. + - Created three initial menu templates: + - **Simple**: The default, clean menu style. + - **Dark**: A dark-themed menu for a more cinematic feel. + - **Poster**: A template that uses a user-provided image as the background. +- ✅ **Menu Customization UI** + - Added a "Menu Template" dropdown to the authoring settings tab. + - Added a "Select Background Image" button that appears when the "Poster" template is selected. + - User's menu template and background image choices are persisted in the configuration. + ## Version 0.1.0-dev23 (2026-01-04) - UI Cleanup & About Dialog + ### UI/UX - ✅ **Colored select polish** - one-click dropdown, left accent bar, softer blue-grey background, rounded corners, larger text - ✅ **Panel input styling** - input and panel backgrounds aligned to dropdown tone diff --git a/author_menu.go b/author_menu.go index 024c61f..2d8e3e6 100644 --- a/author_menu.go +++ b/author_menu.go @@ -21,7 +21,22 @@ type dvdMenuButton struct { Y1 int } -func buildDVDMenuAssets(ctx context.Context, workDir, title, region, aspect string, chapters []authorChapter, logFn func(string)) (string, []dvdMenuButton, error) { +// MenuTemplate defines the interface for a DVD menu generator. +type MenuTemplate interface { + Generate(ctx context.Context, workDir, title, region, aspect string, chapters []authorChapter, backgroundImage string, logFn func(string)) (string, []dvdMenuButton, error) +} + +var menuTemplates = map[string]MenuTemplate{ + "Simple": &SimpleMenu{}, + "Dark": &DarkMenu{}, + "Poster": &PosterMenu{}, +} + +// SimpleMenu is a basic menu template. +type SimpleMenu struct{} + +// Generate creates a simple DVD menu. +func (t *SimpleMenu) Generate(ctx context.Context, workDir, title, region, aspect string, chapters []authorChapter, backgroundImage string, logFn func(string)) (string, []dvdMenuButton, error) { width, height := dvdMenuDimensions(region) buttons := buildDVDMenuButtons(chapters, width, height) if len(buttons) == 0 { @@ -29,6 +44,9 @@ func buildDVDMenuAssets(ctx context.Context, workDir, title, region, aspect stri } bgPath := filepath.Join(workDir, "menu_bg.png") + if backgroundImage != "" { + bgPath = backgroundImage + } overlayPath := filepath.Join(workDir, "menu_overlay.png") highlightPath := filepath.Join(workDir, "menu_highlight.png") selectPath := filepath.Join(workDir, "menu_select.png") @@ -37,12 +55,15 @@ func buildDVDMenuAssets(ctx context.Context, workDir, title, region, aspect stri spumuxXML := filepath.Join(workDir, "menu_spu.xml") if logFn != nil { - logFn("Building DVD menu assets...") + logFn("Building DVD menu assets with SimpleMenu template...") } - if err := buildMenuBackground(ctx, bgPath, title, buttons, width, height); err != nil { - return "", nil, err + if backgroundImage == "" { + 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 } @@ -61,6 +82,111 @@ func buildDVDMenuAssets(ctx context.Context, workDir, title, region, aspect stri return menuSpu, buttons, nil } +// DarkMenu is a dark-themed menu template. +type DarkMenu struct{} + +// Generate creates a dark-themed DVD menu. +func (t *DarkMenu) Generate(ctx context.Context, workDir, title, region, aspect string, chapters []authorChapter, backgroundImage string, 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") + if backgroundImage != "" { + bgPath = backgroundImage + } + 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 with DarkMenu template...") + } + + if backgroundImage == "" { + if err := buildDarkMenuBackground(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 +} + +// PosterMenu is a template that uses a poster image as a background. +type PosterMenu struct{} + +// Generate creates a poster-themed DVD menu. +func (t *PosterMenu) Generate(ctx context.Context, workDir, title, region, aspect string, chapters []authorChapter, backgroundImage string, 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") + if backgroundImage == "" { + return "", nil, fmt.Errorf("poster menu requires a background image") + } + 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 with PosterMenu template...") + } + + if err := buildPosterMenuBackground(ctx, bgPath, title, buttons, width, height, backgroundImage); 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 buildDVDMenuAssets(ctx context.Context, workDir, title, region, aspect string, chapters []authorChapter, logFn func(string), template MenuTemplate, backgroundImage string) (string, []dvdMenuButton, error) { + if template == nil { + template = &SimpleMenu{} + } + return template.Generate(ctx, workDir, title, region, aspect, chapters, backgroundImage, logFn) +} + func dvdMenuDimensions(region string) (int, int) { if strings.ToLower(region) == "pal" { return 720, 576 @@ -150,6 +276,81 @@ func buildMenuBackground(ctx context.Context, outputPath, title string, buttons return runCommandWithLogger(ctx, utils.GetFFmpegPath(), args, nil) } +func buildDarkMenuBackground(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 := "0x000000" + headerColor := "0x111111" + textColor := "white" + accentColor := "0xeeeeee" + + 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 buildPosterMenuBackground(ctx context.Context, outputPath, title string, buttons []dvdMenuButton, width, height int, backgroundImage string) error { + safeTitle := utils.ShortenMiddle(strings.TrimSpace(title), 40) + if safeTitle == "" { + safeTitle = "DVD Menu" + } + + textColor := "white" + + filterParts := []string{ + 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)), + } + + 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", + "-i", backgroundImage, + "-vf", fmt.Sprintf("scale=%d:%d,%s", width, height, 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 @@ -214,7 +415,8 @@ func writeSpumuxXML(path, overlayPath, highlightPath, selectPath string, buttons var b strings.Builder b.WriteString("\n") b.WriteString(" \n") - b.WriteString(fmt.Sprintf(" \n", + b.WriteString(fmt.Sprintf(" +", escapeXMLAttr(overlayPath), escapeXMLAttr(highlightPath), escapeXMLAttr(selectPath), @@ -230,7 +432,7 @@ func writeSpumuxXML(path, overlayPath, highlightPath, selectPath string, buttons } func runSpumux(ctx context.Context, spumuxXML, inputMpg, outputMpg string, logFn func(string)) error { - args := []string{"-m", "dvd", spumuxXML} + args := []string{" -m", "dvd", spumuxXML} if logFn != nil { logFn(fmt.Sprintf(">> spumux -m dvd %s < %s > %s", spumuxXML, filepath.Base(inputMpg), filepath.Base(outputMpg))) } @@ -262,7 +464,7 @@ func findVTLogoPath() string { } if exe, err := os.Executable(); err == nil { dir := filepath.Dir(exe) - search = append(search, filepath.Join(dir, "assets", "logo", "VT_Icon.png")) + search = append(search, filepath.Join(dir, "assets", "logo", "VT_Icon.png")), } for _, p := range search { if _, err := os.Stat(p); err == nil { @@ -273,9 +475,9 @@ func findVTLogoPath() string { } func escapeDrawtextText(text string) string { - escaped := strings.ReplaceAll(text, "\\", "\\\\") + escaped := strings.ReplaceAll(text, "\", "\\\\") escaped = strings.ReplaceAll(escaped, ":", "\\:") - escaped = strings.ReplaceAll(escaped, "'", "\\'") + escaped = strings.ReplaceAll(escaped, "'", "\' ") escaped = strings.ReplaceAll(escaped, "%", "\\%") return escaped -} +} \ No newline at end of file diff --git a/author_module.go b/author_module.go index 820ec45..1181a9d 100644 --- a/author_module.go +++ b/author_module.go @@ -103,6 +103,7 @@ func (s *appState) applyAuthorConfig(cfg authorConfig) { s.authorCreateMenu = cfg.CreateMenu s.authorTreatAsChapters = cfg.TreatAsChapters s.authorSceneThreshold = cfg.SceneThreshold + s.authorMenuTemplate = cfg.MenuTemplate } func (s *appState) persistAuthorConfig() { @@ -115,6 +116,7 @@ func (s *appState) persistAuthorConfig() { CreateMenu: s.authorCreateMenu, TreatAsChapters: s.authorTreatAsChapters, SceneThreshold: s.authorSceneThreshold, + MenuTemplate: s.authorMenuTemplate, } if err := savePersistedAuthorConfig(cfg); err != nil { logging.Debug(logging.CatSystem, "failed to persist author config: %v", err) @@ -726,6 +728,36 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject { }) createMenuCheck.SetChecked(state.authorCreateMenu) + menuTemplateSelect := widget.NewSelect([]string{"Simple", "Dark", "Poster"}, func(value string) { + state.authorMenuTemplate = value + state.updateAuthorSummary() + state.persistAuthorConfig() + }) + menuTemplateSelect.SetSelected(state.authorMenuTemplate) + + bgImageLabel := widget.NewLabel(state.authorMenuBackgroundImage) + bgImageButton := widget.NewButton("Select Background Image", func() { + dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { + if err != nil || reader == nil { + return + } + defer reader.Close() + state.authorMenuBackgroundImage = reader.URI().Path() + bgImageLabel.SetText(state.authorMenuBackgroundImage) + state.updateAuthorSummary() + state.persistAuthorConfig() + }, state.window) + }) + bgImageButton.Importance = widget.HighImportance + bgImageButton.Hidden = state.authorMenuTemplate != "Poster" + + menuTemplateSelect.OnChanged = func(value string) { + state.authorMenuTemplate = value + bgImageButton.Hidden = value != "Poster" + state.updateAuthorSummary() + state.persistAuthorConfig() + } + discSizeSelect := widget.NewSelect([]string{"DVD5", "DVD9"}, func(value string) { state.authorDiscSize = value state.updateAuthorSummary() @@ -760,6 +792,9 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject { } titleEntry.SetText(state.authorTitle) createMenuCheck.SetChecked(state.authorCreateMenu) + menuTemplateSelect.SetSelected(state.authorMenuTemplate) + bgImageLabel.SetText(state.authorMenuBackgroundImage) + bgImageButton.Hidden = state.authorMenuTemplate != "Poster" } loadCfgBtn := widget.NewButton("Load Config", func() { @@ -787,6 +822,7 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject { CreateMenu: state.authorCreateMenu, TreatAsChapters: state.authorTreatAsChapters, SceneThreshold: state.authorSceneThreshold, + MenuTemplate: state.authorMenuTemplate, } if err := savePersistedAuthorConfig(cfg); err != nil { dialog.ShowError(fmt.Errorf("failed to save config: %w", err), state.window) @@ -820,6 +856,10 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject { widget.NewLabel("DVD Title:"), titleEntry, createMenuCheck, + widget.NewLabel("Menu Template:"), + menuTemplateSelect, + bgImageLabel, + bgImageButton, widget.NewSeparator(), info, widget.NewSeparator(), @@ -1741,6 +1781,8 @@ func (s *appState) addAuthorToQueue(paths []string, region, aspect, title, outpu "chapterSource": s.authorChapterSource, "subtitleTracks": append([]string{}, s.authorSubtitles...), "additionalAudios": append([]string{}, s.authorAudioTracks...), + "menuTemplate": s.authorMenuTemplate, + "menuBackgroundImage": s.authorMenuBackgroundImage, } titleLabel := title @@ -1802,7 +1844,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, createMenu 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, menuTemplate string, menuBackgroundImage string, 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) @@ -1984,7 +2026,11 @@ 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) + template, ok := menuTemplates[menuTemplate] + if !ok { + template = &SimpleMenu{} + } + menuMpg, menuButtons, err = buildDVDMenuAssets(ctx, workDir, title, region, aspect, chapters, logFn, template, menuBackgroundImage) if err != nil { return err } @@ -2294,7 +2340,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, createMenu, appendLog, updateProgress) + err := s.runAuthoringPipeline(ctx, paths, region, aspect, title, outputPath, makeISO, clips, chapters, treatAsChapters, createMenu, toString(cfg["menuTemplate"]), toString(cfg["menuBackgroundImage"]), appendLog, updateProgress) if err != nil { friendly := authorFriendlyError(err) appendLog("ERROR: " + friendly) diff --git a/diagnostic_tools/diagnostic_tool.go b/diagnostic_tools/diagnostic_tool.go new file mode 100644 index 0000000..9c98482 --- /dev/null +++ b/diagnostic_tools/diagnostic_tool.go @@ -0,0 +1,26 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" +) + +func main() { + if len(os.Args) < 2 { + fmt.Println("Usage: ./diagnostic_tool ") + return + } + + videoPath := os.Args[1] + + fmt.Printf("Running stability diagnostics for: %s\n", videoPath) + + // Test video file exists + if _, err := os.Stat(videoPath); os.IsNotExist(err) { + fmt.Printf("Error: video file not found: %v\n", err) + return + } + + fmt.Println("Diagnostics completed successfully") +}