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
This commit is contained in:
Stu Leak 2026-01-06 17:42:51 -05:00
parent 12610e19b3
commit 222e2f1414
4 changed files with 303 additions and 13 deletions

16
DONE.md
View File

@ -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

View File

@ -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("<subpictures>\n")
b.WriteString(" <stream>\n")
b.WriteString(fmt.Sprintf(" <spu start=\"00:00:00.00\" end=\"00:00:30.00\" image=\"%s\" highlight=\"%s\" select=\"%s\" force=\"yes\">\n",
b.WriteString(fmt.Sprintf(" <spu start=\"00:00:00.00\" end=\"00:00:30.00\" image=\"%s\" highlight=\"%s\" select=\"%s\" force=\"yes\">
",
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
}
}

View File

@ -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)

View File

@ -0,0 +1,26 @@
package main
import (
"fmt"
"os"
"path/filepath"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: ./diagnostic_tool <video_path>")
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")
}