From 14c63d2def1beed37075a8db83c51e98808954ba Mon Sep 17 00:00:00 2001 From: Stu Leak Date: Mon, 12 Jan 2026 01:16:48 -0500 Subject: [PATCH] Implement complete DVD chapters and extras menu system - Add dual logo support (title logo + studio logo) for DVD menus - Implement chapters menu with chapter navigation - Implement extras menu for bonus content - Add "Mark as Extra" checkbox for clips in UI - Extras automatically appear in separate menu when marked - Filter extras from chapters menu in real-time - Encode extras as separate DVD titles (Title 2, 3, etc.) - Update menu navigation with proper PGC looping - Fix text escaping in DVD menus (alphanumeric only) - Remove "future DVD menus" placeholder text from UI Menu structure: - Main menu: Play, Chapters (if >1), Extras (if present) - Chapters menu: Lists all feature chapters + Back button - Extras menu: Lists all extras as separate titles + Back button - All menus loop and return to main menu after playback --- author_menu.go | 542 ++++++++++++++++++++++++++++++++++++++------ author_module.go | 569 ++++++++++++++++++++++++++++++++++------------- 2 files changed, 885 insertions(+), 226 deletions(-) diff --git a/author_menu.go b/author_menu.go index d3b3d37..c83ffff 100644 --- a/author_menu.go +++ b/author_menu.go @@ -32,6 +32,11 @@ type MenuTheme struct { } type menuLogoOptions struct { + TitleLogo menuLogo + StudioLogo menuLogo +} + +type menuLogo struct { Enabled bool Path string Position string @@ -68,7 +73,7 @@ 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, theme *MenuTheme, logo menuLogoOptions, logFn func(string)) (string, []dvdMenuButton, error) { width, height := dvdMenuDimensions(region) - buttons := buildDVDMenuButtons(chapters, width, height) + buttons := buildDVDMenuButtons(chapters, false, width, height) // hasExtras=false for template compatibility if len(buttons) == 0 { return "", nil, nil } @@ -118,7 +123,7 @@ 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, theme *MenuTheme, logo menuLogoOptions, logFn func(string)) (string, []dvdMenuButton, error) { width, height := dvdMenuDimensions(region) - buttons := buildDVDMenuButtons(chapters, width, height) + buttons := buildDVDMenuButtons(chapters, false, width, height) // hasExtras=false for template compatibility if len(buttons) == 0 { return "", nil, nil } @@ -168,7 +173,7 @@ 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, theme *MenuTheme, logo menuLogoOptions, logFn func(string)) (string, []dvdMenuButton, error) { width, height := dvdMenuDimensions(region) - buttons := buildDVDMenuButtons(chapters, width, height) + buttons := buildDVDMenuButtons(chapters, false, width, height) // hasExtras=false for template compatibility if len(buttons) == 0 { return "", nil, nil } @@ -210,11 +215,179 @@ func (t *PosterMenu) Generate(ctx context.Context, workDir, title, region, aspec return menuSpu, buttons, nil } -func buildDVDMenuAssets(ctx context.Context, workDir, title, region, aspect string, chapters []authorChapter, logFn func(string), template MenuTemplate, backgroundImage string, theme *MenuTheme, logo menuLogoOptions) (string, []dvdMenuButton, error) { +type dvdMenuSet struct { + MainMpg string + MainButtons []dvdMenuButton + ChaptersMpg string + ChaptersButtons []dvdMenuButton + ExtrasMpg string + ExtrasButtons []dvdMenuButton +} + +func buildDVDMenuAssets(ctx context.Context, workDir, title, region, aspect string, chapters []authorChapter, extras []extraItem, logFn func(string), template MenuTemplate, backgroundImage string, theme *MenuTheme, logo menuLogoOptions) (dvdMenuSet, error) { if template == nil { template = &SimpleMenu{} } - return template.Generate(ctx, workDir, title, region, aspect, chapters, backgroundImage, resolveMenuTheme(theme), logo, logFn) + + // Determine main menu buttons based on chapters and extras + width, height := dvdMenuDimensions(region) + hasExtras := len(extras) > 0 + mainButtons := buildDVDMenuButtons(chapters, hasExtras, width, height) + + // Generate main menu MPEG set + mainMpg, err := buildMainMenuMPEGSet(ctx, workDir, title, region, aspect, mainButtons, backgroundImage, theme, logo, logFn) + if err != nil { + return dvdMenuSet{}, err + } + + result := dvdMenuSet{ + MainMpg: mainMpg, + MainButtons: mainButtons, + } + + // Generate chapters menu if there are multiple chapters + if len(chapters) > 1 { + chaptersMenuMpg, chaptersButtons, err := buildChaptersMenuMPEGSet(ctx, workDir, title, region, aspect, chapters, theme, logFn) + if err != nil { + return dvdMenuSet{}, err + } + result.ChaptersMpg = chaptersMenuMpg + result.ChaptersButtons = chaptersButtons + } + + // Generate extras menu if there are extras + if len(extras) > 0 { + extrasMenuMpg, extrasButtons, err := buildExtrasMenuMPEGSet(ctx, workDir, title, region, aspect, extras, theme, logFn) + if err != nil { + return dvdMenuSet{}, err + } + result.ExtrasMpg = extrasMenuMpg + result.ExtrasButtons = extrasButtons + } + + return result, nil +} + +func buildMainMenuMPEGSet(ctx context.Context, workDir, title, region, aspect string, buttons []dvdMenuButton, backgroundImage string, theme *MenuTheme, logo menuLogoOptions, logFn func(string)) (string, error) { + width, height := dvdMenuDimensions(region) + + 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 SimpleMenu template...") + } + + if backgroundImage == "" { + if err := buildMenuBackground(ctx, bgPath, title, buttons, width, height, resolveMenuTheme(theme), logo, logFn); err != nil { + return "", err + } + } + + if err := buildMenuOverlays(ctx, overlayPath, highlightPath, selectPath, buttons, width, height, resolveMenuTheme(theme), logFn); err != nil { + return "", err + } + if err := buildMenuMPEG(ctx, bgPath, menuMpg, region, aspect, logFn); err != nil { + return "", err + } + if err := writeSpumuxXML(spumuxXML, overlayPath, highlightPath, selectPath, buttons); err != nil { + return "", err + } + if err := runSpumux(ctx, spumuxXML, menuMpg, menuSpu, logFn); err != nil { + return "", err + } + if logFn != nil { + logFn(fmt.Sprintf("DVD menu created: %s", filepath.Base(menuSpu))) + } + return menuSpu, nil +} + +func buildExtrasMenuMPEGSet(ctx context.Context, workDir, title, region, aspect string, extras []extraItem, theme *MenuTheme, logFn func(string)) (string, []dvdMenuButton, error) { + width, height := dvdMenuDimensions(region) + buttons := buildExtrasMenuButtons(extras, width, height) + if len(buttons) == 0 { + return "", nil, nil + } + + bgPath := filepath.Join(workDir, "extras_menu_bg.png") + overlayPath := filepath.Join(workDir, "extras_menu_overlay.png") + highlightPath := filepath.Join(workDir, "extras_menu_highlight.png") + selectPath := filepath.Join(workDir, "extras_menu_select.png") + menuMpg := filepath.Join(workDir, "extras_menu.mpg") + menuSpu := filepath.Join(workDir, "extras_menu_spu.mpg") + spumuxXML := filepath.Join(workDir, "extras_menu_spu.xml") + + if logFn != nil { + logFn("Building extras menu assets...") + } + + if err := buildExtrasMenuBackground(ctx, bgPath, title, buttons, width, height, resolveMenuTheme(theme), logFn); err != nil { + return "", nil, err + } + if err := buildMenuOverlays(ctx, overlayPath, highlightPath, selectPath, buttons, width, height, resolveMenuTheme(theme), logFn); err != nil { + return "", nil, err + } + if err := buildMenuMPEG(ctx, bgPath, menuMpg, region, aspect, logFn); 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("Extras menu created: %s", filepath.Base(menuSpu))) + } + return menuSpu, buttons, nil +} + +func buildChaptersMenuMPEGSet(ctx context.Context, workDir, title, region, aspect string, chapters []authorChapter, theme *MenuTheme, logFn func(string)) (string, []dvdMenuButton, error) { + width, height := dvdMenuDimensions(region) + buttons := buildChapterMenuButtons(chapters, width, height) + if len(buttons) == 0 { + return "", nil, nil + } + + bgPath := filepath.Join(workDir, "chapters_menu_bg.png") + overlayPath := filepath.Join(workDir, "chapters_menu_overlay.png") + highlightPath := filepath.Join(workDir, "chapters_menu_highlight.png") + selectPath := filepath.Join(workDir, "chapters_menu_select.png") + menuMpg := filepath.Join(workDir, "chapters_menu.mpg") + menuSpu := filepath.Join(workDir, "chapters_menu_spu.mpg") + spumuxXML := filepath.Join(workDir, "chapters_menu_spu.xml") + + if logFn != nil { + logFn("Building chapters menu assets...") + } + + if err := buildChaptersMenuBackground(ctx, bgPath, title, buttons, width, height, resolveMenuTheme(theme), logFn); err != nil { + return "", nil, err + } + if err := buildMenuOverlays(ctx, overlayPath, highlightPath, selectPath, buttons, width, height, resolveMenuTheme(theme), logFn); err != nil { + return "", nil, err + } + if err := buildMenuMPEG(ctx, bgPath, menuMpg, region, aspect, logFn); 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("Chapters menu created: %s", filepath.Base(menuSpu))) + } + return menuSpu, buttons, nil } func dvdMenuDimensions(region string) (int, int) { @@ -224,7 +397,7 @@ func dvdMenuDimensions(region string) (int, int) { return 720, 480 } -func buildDVDMenuButtons(chapters []authorChapter, width, height int) []dvdMenuButton { +func buildDVDMenuButtons(chapters []authorChapter, hasExtras bool, width, height int) []dvdMenuButton { buttons := []dvdMenuButton{ { Label: "Play", @@ -232,21 +405,27 @@ func buildDVDMenuButtons(chapters []authorChapter, width, height int) []dvdMenuB }, } - 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)) - } + // Add Chapters button if there are multiple chapters + if len(chapters) > 1 { buttons = append(buttons, dvdMenuButton{ - Label: label, - Command: fmt.Sprintf("jump title 1 chapter %d;", i+1), + Label: "Chapters", + Command: "jump menu 2;", // Jump to chapters menu (second PGC) }) } + // Add Extras button if extras are present + if hasExtras { + extrasMenuIndex := 2 + if len(chapters) > 1 { + extrasMenuIndex = 3 // Chapters menu is PGC 2, so extras is PGC 3 + } + buttons = append(buttons, dvdMenuButton{ + Label: "Extras", + Command: fmt.Sprintf("jump menu %d;", extrasMenuIndex), + }) + } + + // Position buttons startY := 180 rowHeight := 34 boxHeight := 28 @@ -262,6 +441,81 @@ func buildDVDMenuButtons(chapters []authorChapter, width, height int) []dvdMenuB return buttons } +func buildChapterMenuButtons(chapters []authorChapter, width, height int) []dvdMenuButton { + buttons := []dvdMenuButton{} + + // Add a button for each chapter + for i, ch := range chapters { + buttons = append(buttons, dvdMenuButton{ + Label: ch.Title, + Command: fmt.Sprintf("jump title 1 chapter %d;", i+1), + }) + } + + // Add Back button at the end + buttons = append(buttons, dvdMenuButton{ + Label: "Back", + Command: "jump menu 1;", // Jump back to main menu (first PGC) + }) + + // Position buttons - allow more buttons to fit + startY := 120 + rowHeight := 32 + boxHeight := 26 + x0 := 60 + x1 := width - 60 + + 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 +} + +type extraItem struct { + Title string + TitleNum int // DVD title number for this extra +} + +func buildExtrasMenuButtons(extras []extraItem, width, height int) []dvdMenuButton { + buttons := []dvdMenuButton{} + + // Add a button for each extra + for _, extra := range extras { + buttons = append(buttons, dvdMenuButton{ + Label: extra.Title, + Command: fmt.Sprintf("jump title %d;", extra.TitleNum), + }) + } + + // Add Back button at the end + buttons = append(buttons, dvdMenuButton{ + Label: "Back", + Command: "jump menu 1;", // Jump back to main menu (first PGC) + }) + + // Position buttons - allow more buttons to fit + startY := 120 + rowHeight := 32 + boxHeight := 26 + x0 := 60 + x1 := width - 60 + + 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, theme *MenuTheme, logo menuLogoOptions, logFn func(string)) error { theme = resolveMenuTheme(theme) @@ -278,31 +532,51 @@ func buildMenuBackground(ctx context.Context, outputPath, title string, buttons filterParts := []string{ fmt.Sprintf("drawbox=x=0:y=0:w=%d:h=72:color=%s:t=fill", width, headerColor), - fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=28:x=36:y=20:text='%s'", fontArg, textColor, escapeDrawtextText("VideoTools DVD")), - fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=18:x=36:y=80:text='%s'", fontArg, textColor, escapeDrawtextText(safeTitle)), + fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=28:x=36:y=20:text=%s", fontArg, textColor, escapeDrawtextText("VideoTools DVD")), + fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=18:x=36:y=80:text=%s", fontArg, textColor, escapeDrawtextText(safeTitle)), fmt.Sprintf("drawbox=x=36:y=108:w=%d:h=2:color=%s:t=fill", width-72, accentColor), - fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=16:x=36:y=122:text='%s'", fontArg, textColor, escapeDrawtextText("Select a title or chapter to play")), + fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=16:x=36:y=122:text=%s", fontArg, 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=%s:fontcolor=%s:fontsize=20:x=110:y=%d:text='%s'", fontArg, textColor, y, label)) + filterParts = append(filterParts, fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=20:x=110:y=%d:text=%s", fontArg, textColor, y, label)) } filterChain := strings.Join(filterParts, ",") args := []string{"-y", "-f", "lavfi", "-i", fmt.Sprintf("color=c=%s:s=%dx%d", bgColor, width, height)} filterExpr := fmt.Sprintf("[0:v]%s[bg]", filterChain) - if logo.Enabled { - logoPath := resolveMenuLogoPath(logo) - if logoPath != "" { - posExpr := resolveMenuLogoPosition(logo, width, height) - scaleExpr := resolveMenuLogoScaleExpr(logo, width, height) - args = append(args, "-i", logoPath) - filterExpr = fmt.Sprintf("[0:v]%s[bg];[1:v]%s[logo];[bg][logo]overlay=%s", filterChain, scaleExpr, posExpr) + + // Handle title logo and studio logo overlays + inputIndex := 1 + baseLayer := "[bg]" + + // Add title logo if enabled + if logo.TitleLogo.Enabled { + titleLogoPath := resolveMenuLogoPath(logo.TitleLogo) + if titleLogoPath != "" { + posExpr := resolveMenuLogoPosition(logo.TitleLogo, width, height) + scaleExpr := resolveMenuLogoScaleExpr(logo.TitleLogo, width, height) + args = append(args, "-i", titleLogoPath) + filterExpr = fmt.Sprintf("%s;[%d:v]%s[titlelogo];%s[titlelogo]overlay=%s[tmp%d]", filterExpr, inputIndex, scaleExpr, baseLayer, posExpr, inputIndex) + baseLayer = fmt.Sprintf("[tmp%d]", inputIndex) + inputIndex++ } } + + // Add studio logo if enabled + if logo.StudioLogo.Enabled { + studioLogoPath := resolveMenuLogoPath(logo.StudioLogo) + if studioLogoPath != "" { + posExpr := resolveMenuLogoPosition(logo.StudioLogo, width, height) + scaleExpr := resolveMenuLogoScaleExpr(logo.StudioLogo, width, height) + args = append(args, "-i", studioLogoPath) + filterExpr = fmt.Sprintf("%s;[%d:v]%s[studiologo];%s[studiologo]overlay=%s", filterExpr, inputIndex, scaleExpr, baseLayer, posExpr) + } + } + args = append(args, "-filter_complex", filterExpr, "-frames:v", "1", outputPath) return runCommandWithLogger(ctx, utils.GetFFmpegPath(), args, logFn) } @@ -323,31 +597,51 @@ func buildDarkMenuBackground(ctx context.Context, outputPath, title string, butt filterParts := []string{ fmt.Sprintf("drawbox=x=0:y=0:w=%d:h=72:color=%s:t=fill", width, headerColor), - fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=28:x=36:y=20:text='%s'", fontArg, textColor, escapeDrawtextText("VideoTools DVD")), - fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=18:x=36:y=80:text='%s'", fontArg, textColor, escapeDrawtextText(safeTitle)), + fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=28:x=36:y=20:text=%s", fontArg, textColor, escapeDrawtextText("VideoTools DVD")), + fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=18:x=36:y=80:text=%s", fontArg, textColor, escapeDrawtextText(safeTitle)), fmt.Sprintf("drawbox=x=36:y=108:w=%d:h=2:color=%s:t=fill", width-72, accentColor), - fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=16:x=36:y=122:text='%s'", fontArg, textColor, escapeDrawtextText("Select a title or chapter to play")), + fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=16:x=36:y=122:text=%s", fontArg, 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=%s:fontcolor=%s:fontsize=20:x=110:y=%d:text='%s'", fontArg, textColor, y, label)) + filterParts = append(filterParts, fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=20:x=110:y=%d:text=%s", fontArg, textColor, y, label)) } filterChain := strings.Join(filterParts, ",") args := []string{"-y", "-f", "lavfi", "-i", fmt.Sprintf("color=c=%s:s=%dx%d", bgColor, width, height)} filterExpr := fmt.Sprintf("[0:v]%s[bg]", filterChain) - if logo.Enabled { - logoPath := resolveMenuLogoPath(logo) - if logoPath != "" { - posExpr := resolveMenuLogoPosition(logo, width, height) - scaleExpr := resolveMenuLogoScaleExpr(logo, width, height) - args = append(args, "-i", logoPath) - filterExpr = fmt.Sprintf("[0:v]%s[bg];[1:v]%s[logo];[bg][logo]overlay=%s", filterChain, scaleExpr, posExpr) + + // Handle title logo and studio logo overlays + inputIndex := 1 + baseLayer := "[bg]" + + // Add title logo if enabled + if logo.TitleLogo.Enabled { + titleLogoPath := resolveMenuLogoPath(logo.TitleLogo) + if titleLogoPath != "" { + posExpr := resolveMenuLogoPosition(logo.TitleLogo, width, height) + scaleExpr := resolveMenuLogoScaleExpr(logo.TitleLogo, width, height) + args = append(args, "-i", titleLogoPath) + filterExpr = fmt.Sprintf("%s;[%d:v]%s[titlelogo];%s[titlelogo]overlay=%s[tmp%d]", filterExpr, inputIndex, scaleExpr, baseLayer, posExpr, inputIndex) + baseLayer = fmt.Sprintf("[tmp%d]", inputIndex) + inputIndex++ } } + + // Add studio logo if enabled + if logo.StudioLogo.Enabled { + studioLogoPath := resolveMenuLogoPath(logo.StudioLogo) + if studioLogoPath != "" { + posExpr := resolveMenuLogoPosition(logo.StudioLogo, width, height) + scaleExpr := resolveMenuLogoScaleExpr(logo.StudioLogo, width, height) + args = append(args, "-i", studioLogoPath) + filterExpr = fmt.Sprintf("%s;[%d:v]%s[studiologo];%s[studiologo]overlay=%s", filterExpr, inputIndex, scaleExpr, baseLayer, posExpr) + } + } + args = append(args, "-filter_complex", filterExpr, "-frames:v", "1", outputPath) return runCommandWithLogger(ctx, utils.GetFFmpegPath(), args, logFn) } @@ -363,33 +657,139 @@ func buildPosterMenuBackground(ctx context.Context, outputPath, title string, bu fontArg := menuFontArg(theme) filterParts := []string{ - fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=28:x=36:y=20:text='%s'", fontArg, textColor, escapeDrawtextText("VideoTools DVD")), - fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=18:x=36:y=80:text='%s'", fontArg, textColor, escapeDrawtextText(safeTitle)), + fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=28:x=36:y=20:text=%s", fontArg, textColor, escapeDrawtextText("VideoTools DVD")), + fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=18:x=36:y=80:text=%s", fontArg, textColor, escapeDrawtextText(safeTitle)), } for i, btn := range buttons { label := escapeDrawtextText(btn.Label) y := 184 + i*34 - filterParts = append(filterParts, fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=20:x=110:y=%d:text='%s'", fontArg, textColor, y, label)) + filterParts = append(filterParts, fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=20:x=110:y=%d:text=%s", fontArg, textColor, y, label)) } filterChain := strings.Join(filterParts, ",") args := []string{"-y", "-i", backgroundImage} filterExpr := fmt.Sprintf("[0:v]scale=%d:%d,%s[bg]", width, height, filterChain) - if logo.Enabled { - logoPath := resolveMenuLogoPath(logo) - if logoPath != "" { - posExpr := resolveMenuLogoPosition(logo, width, height) - scaleExpr := resolveMenuLogoScaleExpr(logo, width, height) - args = append(args, "-i", logoPath) - filterExpr = fmt.Sprintf("[0:v]scale=%d:%d,%s[bg];[1:v]%s[logo];[bg][logo]overlay=%s", width, height, filterChain, scaleExpr, posExpr) + + // Handle title logo and studio logo overlays + inputIndex := 1 + baseLayer := "[bg]" + + // Add title logo if enabled + if logo.TitleLogo.Enabled { + titleLogoPath := resolveMenuLogoPath(logo.TitleLogo) + if titleLogoPath != "" { + posExpr := resolveMenuLogoPosition(logo.TitleLogo, width, height) + scaleExpr := resolveMenuLogoScaleExpr(logo.TitleLogo, width, height) + args = append(args, "-i", titleLogoPath) + filterExpr = fmt.Sprintf("%s;[%d:v]%s[titlelogo];%s[titlelogo]overlay=%s[tmp%d]", filterExpr, inputIndex, scaleExpr, baseLayer, posExpr, inputIndex) + baseLayer = fmt.Sprintf("[tmp%d]", inputIndex) + inputIndex++ } } + + // Add studio logo if enabled + if logo.StudioLogo.Enabled { + studioLogoPath := resolveMenuLogoPath(logo.StudioLogo) + if studioLogoPath != "" { + posExpr := resolveMenuLogoPosition(logo.StudioLogo, width, height) + scaleExpr := resolveMenuLogoScaleExpr(logo.StudioLogo, width, height) + args = append(args, "-i", studioLogoPath) + filterExpr = fmt.Sprintf("%s;[%d:v]%s[studiologo];%s[studiologo]overlay=%s", filterExpr, inputIndex, scaleExpr, baseLayer, posExpr) + } + } + args = append(args, "-filter_complex", filterExpr, "-frames:v", "1", outputPath) return runCommandWithLogger(ctx, utils.GetFFmpegPath(), args, logFn) } +func buildExtrasMenuBackground(ctx context.Context, outputPath, title string, buttons []dvdMenuButton, width, height int, theme *MenuTheme, logFn func(string)) error { + theme = resolveMenuTheme(theme) + + safeTitle := utils.ShortenMiddle(strings.TrimSpace(title), 40) + if safeTitle == "" { + safeTitle = "DVD Menu" + } + + bgColor := theme.BackgroundColor + headerColor := theme.HeaderColor + textColor := theme.TextColor + accentColor := theme.AccentColor + fontArg := menuFontArg(theme) + + filterParts := []string{ + fmt.Sprintf("drawbox=x=0:y=0:w=%d:h=72:color=%s:t=fill", width, headerColor), + fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=28:x=36:y=20:text=%s", fontArg, textColor, escapeDrawtextText("Extras")), + fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=16:x=36:y=52:text=%s", fontArg, textColor, escapeDrawtextText(safeTitle)), + fmt.Sprintf("drawbox=x=36:y=80:w=%d:h=2:color=%s:t=fill", width-72, accentColor), + fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=14:x=36:y=94:text=%s", fontArg, textColor, escapeDrawtextText("Select an extra to play")), + } + + for i, btn := range buttons { + label := escapeDrawtextText(btn.Label) + // Truncate long names for display + if len(label) > 50 { + label = label[:47] + "..." + } + y := 120 + i*32 + fontSize := 18 + if btn.Label == "Back" { + fontSize = 20 // Make Back button slightly larger + } + filterParts = append(filterParts, fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=%d:x=80:y=%d:text=%s", fontArg, textColor, fontSize, y, label)) + } + + filterChain := strings.Join(filterParts, ",") + + args := []string{"-y", "-f", "lavfi", "-i", fmt.Sprintf("color=c=%s:s=%dx%d", bgColor, width, height)} + args = append(args, "-filter_complex", fmt.Sprintf("[0:v]%s", filterChain), "-frames:v", "1", outputPath) + return runCommandWithLogger(ctx, utils.GetFFmpegPath(), args, logFn) +} + +func buildChaptersMenuBackground(ctx context.Context, outputPath, title string, buttons []dvdMenuButton, width, height int, theme *MenuTheme, logFn func(string)) error { + theme = resolveMenuTheme(theme) + + safeTitle := utils.ShortenMiddle(strings.TrimSpace(title), 40) + if safeTitle == "" { + safeTitle = "DVD Menu" + } + + bgColor := theme.BackgroundColor + headerColor := theme.HeaderColor + textColor := theme.TextColor + accentColor := theme.AccentColor + fontArg := menuFontArg(theme) + + filterParts := []string{ + fmt.Sprintf("drawbox=x=0:y=0:w=%d:h=72:color=%s:t=fill", width, headerColor), + fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=28:x=36:y=20:text=%s", fontArg, textColor, escapeDrawtextText("Chapter Selection")), + fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=16:x=36:y=52:text=%s", fontArg, textColor, escapeDrawtextText(safeTitle)), + fmt.Sprintf("drawbox=x=36:y=80:w=%d:h=2:color=%s:t=fill", width-72, accentColor), + fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=14:x=36:y=94:text=%s", fontArg, textColor, escapeDrawtextText("Select a chapter to play")), + } + + for i, btn := range buttons { + label := escapeDrawtextText(btn.Label) + // Truncate long chapter names for display + if len(label) > 50 { + label = label[:47] + "..." + } + y := 120 + i*32 + fontSize := 18 + if btn.Label == "Back" { + fontSize = 20 // Make Back button slightly larger + } + filterParts = append(filterParts, fmt.Sprintf("drawtext=%s:fontcolor=%s:fontsize=%d:x=80:y=%d:text=%s", fontArg, textColor, fontSize, y, label)) + } + + filterChain := strings.Join(filterParts, ",") + + args := []string{"-y", "-f", "lavfi", "-i", fmt.Sprintf("color=c=%s:s=%dx%d", bgColor, width, height)} + args = append(args, "-filter_complex", fmt.Sprintf("[0:v]%s", filterChain), "-frames:v", "1", outputPath) + return runCommandWithLogger(ctx, utils.GetFFmpegPath(), args, logFn) +} + func buildMenuOverlays(ctx context.Context, overlayPath, highlightPath, selectPath string, buttons []dvdMenuButton, width, height int, theme *MenuTheme, logFn func(string)) error { theme = resolveMenuTheme(theme) accent := theme.AccentColor @@ -456,7 +856,7 @@ func writeSpumuxXML(path, overlayPath, highlightPath, selectPath string, buttons var b strings.Builder b.WriteString("\n") b.WriteString(" \n") - b.WriteString(fmt.Sprintf(" ", + b.WriteString(fmt.Sprintf(" \n", escapeXMLAttr(overlayPath), escapeXMLAttr(highlightPath), escapeXMLAttr(selectPath), @@ -472,7 +872,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))) } @@ -515,14 +915,18 @@ func findVTLogoPath() string { } func findMenuFontPath() string { - search := []string{ - filepath.Join("assets", "fonts", "IBMPlexMono-Regular.ttf"), + // Get absolute path to working directory + wd, err := os.Getwd() + if err == nil { + p := filepath.Join(wd, "assets", "fonts", "IBMPlexMono-Regular.ttf") + if _, err := os.Stat(p); err == nil { + return p + } } + // Try executable directory if exe, err := os.Executable(); err == nil { dir := filepath.Dir(exe) - search = append(search, filepath.Join(dir, "assets", "fonts", "IBMPlexMono-Regular.ttf")) - } - for _, p := range search { + p := filepath.Join(dir, "assets", "fonts", "IBMPlexMono-Regular.ttf") if _, err := os.Stat(p); err == nil { return p } @@ -572,14 +976,14 @@ func menuFontArg(theme *MenuTheme) string { return "font=monospace" } -func resolveMenuLogoPath(logo menuLogoOptions) string { +func resolveMenuLogoPath(logo menuLogo) string { if strings.TrimSpace(logo.Path) != "" { return logo.Path } return filepath.Join("assets", "logo", "VT_Logo.png") } -func resolveMenuLogoScale(logo menuLogoOptions) float64 { +func resolveMenuLogoScale(logo menuLogo) float64 { if logo.Scale <= 0 { return 1.0 } @@ -592,14 +996,15 @@ func resolveMenuLogoScale(logo menuLogoOptions) float64 { return logo.Scale } -func resolveMenuLogoScaleExpr(logo menuLogoOptions, width, height int) string { +func resolveMenuLogoScaleExpr(logo menuLogo, width, height int) string { scale := resolveMenuLogoScale(logo) maxW := float64(width) * 0.25 maxH := float64(height) * 0.25 - return fmt.Sprintf("scale=w=min(iw*%.2f,%.0f):h=min(ih*%.2f,%.0f):force_original_aspect_ratio=decrease", scale, maxW, scale, maxH) + // Use simpler scale syntax without w=/h= named parameters + return fmt.Sprintf("scale='min(iw*%.2f,%.0f)':'min(ih*%.2f,%.0f)':force_original_aspect_ratio=decrease", scale, maxW, scale, maxH) } -func resolveMenuLogoPosition(logo menuLogoOptions, width, height int) string { +func resolveMenuLogoPosition(logo menuLogo, width, height int) string { margin := logo.Margin if margin < 0 { margin = 0 @@ -619,9 +1024,14 @@ func resolveMenuLogoPosition(logo menuLogoOptions, width, height int) string { } func escapeDrawtextText(text string) string { - escaped := strings.ReplaceAll(text, "\\", "\\\\") - escaped = strings.ReplaceAll(escaped, ":", "\\:") - escaped = strings.ReplaceAll(escaped, "'", "\\'") - escaped = strings.ReplaceAll(escaped, "%", "\\%") - return escaped + // Strip ALL special characters - only keep letters, numbers, and spaces + var result strings.Builder + for _, r := range text { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == ' ' { + result.WriteRune(r) + } + } + // Clean up multiple spaces + cleaned := strings.Join(strings.Fields(result.String()), " ") + return cleaned } diff --git a/author_module.go b/author_module.go index 39b4500..03b0a25 100644 --- a/author_module.go +++ b/author_module.go @@ -34,25 +34,30 @@ import ( ) type authorConfig struct { - OutputType string `json:"outputType"` - Region string `json:"region"` - AspectRatio string `json:"aspectRatio"` - DiscSize string `json:"discSize"` - Title string `json:"title"` - CreateMenu bool `json:"createMenu"` - MenuTemplate string `json:"menuTemplate"` - MenuTheme string `json:"menuTheme"` - MenuBackgroundImage string `json:"menuBackgroundImage"` - MenuLogoEnabled bool `json:"menuLogoEnabled"` - MenuLogoPath string `json:"menuLogoPath"` - MenuLogoPosition string `json:"menuLogoPosition"` - MenuLogoScale float64 `json:"menuLogoScale"` - MenuLogoMargin int `json:"menuLogoMargin"` - MenuStructure string `json:"menuStructure"` - MenuExtrasEnabled bool `json:"menuExtrasEnabled"` - MenuChapterThumbSrc string `json:"menuChapterThumbSrc"` - TreatAsChapters bool `json:"treatAsChapters"` - SceneThreshold float64 `json:"sceneThreshold"` + OutputType string `json:"outputType"` + Region string `json:"region"` + AspectRatio string `json:"aspectRatio"` + DiscSize string `json:"discSize"` + Title string `json:"title"` + CreateMenu bool `json:"createMenu"` + MenuTemplate string `json:"menuTemplate"` + MenuTheme string `json:"menuTheme"` + MenuBackgroundImage string `json:"menuBackgroundImage"` + MenuTitleLogoEnabled bool `json:"menuTitleLogoEnabled"` + MenuTitleLogoPath string `json:"menuTitleLogoPath"` + MenuTitleLogoPosition string `json:"menuTitleLogoPosition"` + MenuTitleLogoScale float64 `json:"menuTitleLogoScale"` + MenuTitleLogoMargin int `json:"menuTitleLogoMargin"` + MenuStudioLogoEnabled bool `json:"menuStudioLogoEnabled"` + MenuStudioLogoPath string `json:"menuStudioLogoPath"` + MenuStudioLogoPosition string `json:"menuStudioLogoPosition"` + MenuStudioLogoScale float64 `json:"menuStudioLogoScale"` + MenuStudioLogoMargin int `json:"menuStudioLogoMargin"` + MenuStructure string `json:"menuStructure"` + MenuExtrasEnabled bool `json:"menuExtrasEnabled"` + MenuChapterThumbSrc string `json:"menuChapterThumbSrc"` + TreatAsChapters bool `json:"treatAsChapters"` + SceneThreshold float64 `json:"sceneThreshold"` } func defaultAuthorConfig() authorConfig { @@ -62,16 +67,21 @@ func defaultAuthorConfig() authorConfig { AspectRatio: "AUTO", DiscSize: "DVD5", Title: "", - CreateMenu: false, - MenuTemplate: "Simple", - MenuTheme: "VideoTools", - MenuBackgroundImage: "", - MenuLogoEnabled: true, - MenuLogoPath: "", - MenuLogoPosition: "Top Right", - MenuLogoScale: 1.0, - MenuLogoMargin: 24, - MenuStructure: "Feature + Chapters", + CreateMenu: false, + MenuTemplate: "Simple", + MenuTheme: "VideoTools", + MenuBackgroundImage: "", + MenuTitleLogoEnabled: false, + MenuTitleLogoPath: "", + MenuTitleLogoPosition: "Center", + MenuTitleLogoScale: 1.0, + MenuTitleLogoMargin: 24, + MenuStudioLogoEnabled: true, + MenuStudioLogoPath: "", + MenuStudioLogoPosition: "Top Right", + MenuStudioLogoScale: 1.0, + MenuStudioLogoMargin: 24, + MenuStructure: "Feature + Chapters", MenuExtrasEnabled: false, MenuChapterThumbSrc: "Auto", TreatAsChapters: false, @@ -107,14 +117,23 @@ func loadPersistedAuthorConfig() (authorConfig, error) { if cfg.MenuTheme == "" { cfg.MenuTheme = "VideoTools" } - if cfg.MenuLogoPosition == "" { - cfg.MenuLogoPosition = "Top Right" + if cfg.MenuTitleLogoPosition == "" { + cfg.MenuTitleLogoPosition = "Center" } - if cfg.MenuLogoScale == 0 { - cfg.MenuLogoScale = 1.0 + if cfg.MenuTitleLogoScale == 0 { + cfg.MenuTitleLogoScale = 1.0 } - if cfg.MenuLogoMargin == 0 { - cfg.MenuLogoMargin = 24 + if cfg.MenuTitleLogoMargin == 0 { + cfg.MenuTitleLogoMargin = 24 + } + if cfg.MenuStudioLogoPosition == "" { + cfg.MenuStudioLogoPosition = "Top Right" + } + if cfg.MenuStudioLogoScale == 0 { + cfg.MenuStudioLogoScale = 1.0 + } + if cfg.MenuStudioLogoMargin == 0 { + cfg.MenuStudioLogoMargin = 24 } if cfg.MenuStructure == "" { cfg.MenuStructure = "Feature + Chapters" @@ -150,11 +169,16 @@ func (s *appState) applyAuthorConfig(cfg authorConfig) { s.authorMenuTemplate = cfg.MenuTemplate s.authorMenuTheme = cfg.MenuTheme s.authorMenuBackgroundImage = cfg.MenuBackgroundImage - s.authorMenuLogoEnabled = cfg.MenuLogoEnabled - s.authorMenuLogoPath = cfg.MenuLogoPath - s.authorMenuLogoPosition = cfg.MenuLogoPosition - s.authorMenuLogoScale = cfg.MenuLogoScale - s.authorMenuLogoMargin = cfg.MenuLogoMargin + s.authorMenuTitleLogoEnabled = cfg.MenuTitleLogoEnabled + s.authorMenuTitleLogoPath = cfg.MenuTitleLogoPath + s.authorMenuTitleLogoPosition = cfg.MenuTitleLogoPosition + s.authorMenuTitleLogoScale = cfg.MenuTitleLogoScale + s.authorMenuTitleLogoMargin = cfg.MenuTitleLogoMargin + s.authorMenuStudioLogoEnabled = cfg.MenuStudioLogoEnabled + s.authorMenuStudioLogoPath = cfg.MenuStudioLogoPath + s.authorMenuStudioLogoPosition = cfg.MenuStudioLogoPosition + s.authorMenuStudioLogoScale = cfg.MenuStudioLogoScale + s.authorMenuStudioLogoMargin = cfg.MenuStudioLogoMargin s.authorMenuStructure = cfg.MenuStructure s.authorMenuExtrasEnabled = cfg.MenuExtrasEnabled s.authorMenuChapterThumbSrc = cfg.MenuChapterThumbSrc @@ -172,13 +196,18 @@ func (s *appState) persistAuthorConfig() { CreateMenu: s.authorCreateMenu, MenuTemplate: s.authorMenuTemplate, MenuTheme: s.authorMenuTheme, - MenuBackgroundImage: s.authorMenuBackgroundImage, - MenuLogoEnabled: s.authorMenuLogoEnabled, - MenuLogoPath: s.authorMenuLogoPath, - MenuLogoPosition: s.authorMenuLogoPosition, - MenuLogoScale: s.authorMenuLogoScale, - MenuLogoMargin: s.authorMenuLogoMargin, - MenuStructure: s.authorMenuStructure, + MenuBackgroundImage: s.authorMenuBackgroundImage, + MenuTitleLogoEnabled: s.authorMenuTitleLogoEnabled, + MenuTitleLogoPath: s.authorMenuTitleLogoPath, + MenuTitleLogoPosition: s.authorMenuTitleLogoPosition, + MenuTitleLogoScale: s.authorMenuTitleLogoScale, + MenuTitleLogoMargin: s.authorMenuTitleLogoMargin, + MenuStudioLogoEnabled: s.authorMenuStudioLogoEnabled, + MenuStudioLogoPath: s.authorMenuStudioLogoPath, + MenuStudioLogoPosition: s.authorMenuStudioLogoPosition, + MenuStudioLogoScale: s.authorMenuStudioLogoScale, + MenuStudioLogoMargin: s.authorMenuStudioLogoMargin, + MenuStructure: s.authorMenuStructure, MenuExtrasEnabled: s.authorMenuExtrasEnabled, MenuChapterThumbSrc: s.authorMenuChapterThumbSrc, TreatAsChapters: s.authorTreatAsChapters, @@ -216,14 +245,23 @@ func buildAuthorView(state *appState) fyne.CanvasObject { if state.authorMenuTheme == "" { state.authorMenuTheme = "VideoTools" } - if state.authorMenuLogoPosition == "" { - state.authorMenuLogoPosition = "Top Right" + if state.authorMenuTitleLogoPosition == "" { + state.authorMenuTitleLogoPosition = "Center" } - if state.authorMenuLogoScale == 0 { - state.authorMenuLogoScale = 1.0 + if state.authorMenuTitleLogoScale == 0 { + state.authorMenuTitleLogoScale = 1.0 } - if state.authorMenuLogoMargin == 0 { - state.authorMenuLogoMargin = 24 + if state.authorMenuTitleLogoMargin == 0 { + state.authorMenuTitleLogoMargin = 24 + } + if state.authorMenuStudioLogoPosition == "" { + state.authorMenuStudioLogoPosition = "Top Right" + } + if state.authorMenuStudioLogoScale == 0 { + state.authorMenuStudioLogoScale = 1.0 + } + if state.authorMenuStudioLogoMargin == 0 { + state.authorMenuStudioLogoMargin = 24 } if state.authorMenuStructure == "" { state.authorMenuStructure = "Feature + Chapters" @@ -350,16 +388,26 @@ func buildVideoClipsTab(state *appState) fyne.CanvasObject { titleEntry.OnChanged = func(val string) { state.authorClips[idx].ChapterTitle = val if state.authorTreatAsChapters { - state.authorChapters = chaptersFromClips(state.authorClips) + state.authorChapters = chaptersFromClips(featureClipsOnly(state.authorClips)) state.authorChapterSource = "clips" state.updateAuthorSummary() } } - // Note about chapter names for future menu support - noteLabel := widget.NewLabel("(For future DVD menus)") - noteLabel.TextStyle = fyne.TextStyle{Italic: true} - noteLabel.Alignment = fyne.TextAlignLeading + extraCheck := widget.NewCheck("Mark as Extra", func(checked bool) { + state.authorClips[idx].IsExtra = checked + // Refresh chapters to exclude/include this clip + if state.authorTreatAsChapters { + state.authorChapters = chaptersFromClips(featureClipsOnly(state.authorClips)) + state.authorChapterSource = "clips" + if state.authorChaptersRefresh != nil { + state.authorChaptersRefresh() + } + } + state.updateAuthorSummary() + state.persistAuthorConfig() + }) + extraCheck.SetChecked(clip.IsExtra) removeBtn := widget.NewButton("Remove", func() { state.authorClips = append(state.authorClips[:idx], state.authorClips[idx+1:]...) @@ -373,7 +421,7 @@ func buildVideoClipsTab(state *appState) fyne.CanvasObject { nil, nil, container.NewVBox(durationLabel, removeBtn), - container.NewVBox(nameLabel, titleEntry, noteLabel), + container.NewVBox(nameLabel, titleEntry, extraCheck), ) cardBg := canvas.NewRectangle(utils.MustHex("#171C2A")) cardBg.CornerRadius = 6 @@ -427,7 +475,7 @@ func buildVideoClipsTab(state *appState) fyne.CanvasObject { chapterToggle := widget.NewCheck("Treat videos as chapters", func(checked bool) { state.authorTreatAsChapters = checked if checked { - state.authorChapters = chaptersFromClips(state.authorClips) + state.authorChapters = chaptersFromClips(featureClipsOnly(state.authorClips)) state.authorChapterSource = "clips" } else if state.authorChapterSource == "clips" { state.authorChapterSource = "" @@ -470,17 +518,12 @@ func buildVideoClipsTab(state *appState) fyne.CanvasObject { state.persistAuthorConfig() } - // Note about chapter names - chapterNote := widget.NewLabel("Chapter names are saved for future DVD menu support") - chapterNote.TextStyle = fyne.TextStyle{Italic: true} - controls := container.NewBorder( container.NewVBox( widget.NewLabel("DVD Title:"), dvdTitleEntry, widget.NewSeparator(), widget.NewLabel("Videos:"), - chapterNote, ), container.NewVBox(chapterToggle, container.NewHBox(addBtn, clearBtn, addQueueBtn, compileBtn)), nil, @@ -508,7 +551,7 @@ func buildChaptersTab(state *appState) fyne.CanvasObject { sourceLabel.SetText("") if len(state.authorChapters) == 0 { if state.authorTreatAsChapters && len(state.authorClips) > 1 { - state.authorChapters = chaptersFromClips(state.authorClips) + state.authorChapters = chaptersFromClips(featureClipsOnly(state.authorClips)) state.authorChapterSource = "clips" } } @@ -869,13 +912,18 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject { CreateMenu: state.authorCreateMenu, MenuTemplate: state.authorMenuTemplate, MenuTheme: state.authorMenuTheme, - MenuBackgroundImage: state.authorMenuBackgroundImage, - MenuLogoEnabled: state.authorMenuLogoEnabled, - MenuLogoPath: state.authorMenuLogoPath, - MenuLogoPosition: state.authorMenuLogoPosition, - MenuLogoScale: state.authorMenuLogoScale, - MenuLogoMargin: state.authorMenuLogoMargin, - MenuStructure: state.authorMenuStructure, + MenuBackgroundImage: state.authorMenuBackgroundImage, + MenuTitleLogoEnabled: state.authorMenuTitleLogoEnabled, + MenuTitleLogoPath: state.authorMenuTitleLogoPath, + MenuTitleLogoPosition: state.authorMenuTitleLogoPosition, + MenuTitleLogoScale: state.authorMenuTitleLogoScale, + MenuTitleLogoMargin: state.authorMenuTitleLogoMargin, + MenuStudioLogoEnabled: state.authorMenuStudioLogoEnabled, + MenuStudioLogoPath: state.authorMenuStudioLogoPath, + MenuStudioLogoPosition: state.authorMenuStudioLogoPosition, + MenuStudioLogoScale: state.authorMenuStudioLogoScale, + MenuStudioLogoMargin: state.authorMenuStudioLogoMargin, + MenuStructure: state.authorMenuStructure, MenuExtrasEnabled: state.authorMenuExtrasEnabled, MenuChapterThumbSrc: state.authorMenuChapterThumbSrc, TreatAsChapters: state.authorTreatAsChapters, @@ -1027,7 +1075,7 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject { } logoEnableCheck := widget.NewCheck("Embed Logo", nil) - logoEnableCheck.SetChecked(state.authorMenuLogoEnabled) + logoEnableCheck.SetChecked(state.authorMenuStudioLogoEnabled) logoFileEntry := widget.NewEntry() logoFileEntry.Disable() @@ -1060,22 +1108,22 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject { } logoDisplayName := func() string { - if strings.TrimSpace(state.authorMenuLogoPath) == "" { + if strings.TrimSpace(state.authorMenuStudioLogoPath) == "" { return "VT_Logo.png (default)" } - return filepath.Base(state.authorMenuLogoPath) + return filepath.Base(state.authorMenuStudioLogoPath) } updateLogoPreview := func() { logoFileEntry.SetText(logoDisplayName()) - if !state.authorMenuLogoEnabled { + if !state.authorMenuStudioLogoEnabled { logoPreviewBox.Hide() logoPreviewLabel.SetText("Logo disabled") logoPreviewSize.SetText("") return } - path := state.authorMenuLogoPath + path := state.authorMenuStudioLogoPath if strings.TrimSpace(path) == "" { path = filepath.Join("assets", "logo", "VT_Logo.png") } @@ -1108,7 +1156,7 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject { menuW, menuH := menuPreviewSize() maxW := int(float64(menuW) * 0.25) maxH := int(float64(menuH) * 0.25) - scale := state.authorMenuLogoScale + scale := state.authorMenuStudioLogoScale targetW := int(math.Round(float64(cfg.Width) * scale)) targetH := int(math.Round(float64(cfg.Height) * scale)) if targetW > maxW || targetH > maxH { @@ -1127,7 +1175,7 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject { return } defer reader.Close() - state.authorMenuLogoPath = reader.URI().Path() + state.authorMenuStudioLogoPath = reader.URI().Path() logoFileEntry.SetText(logoDisplayName()) updateLogoPreview() updateBrandingTitle() @@ -1137,7 +1185,7 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject { }) logoPickButton.Importance = widget.MediumImportance logoClearButton := widget.NewButton("Clear", func() { - state.authorMenuLogoPath = "" + state.authorMenuStudioLogoPath = "" logoFileEntry.SetText(logoDisplayName()) logoEnableCheck.SetChecked(false) updateLogoPreview() @@ -1153,14 +1201,14 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject { "Bottom Right", "Center", }, func(value string) { - state.authorMenuLogoPosition = value + state.authorMenuStudioLogoPosition = value updateBrandingTitle() state.persistAuthorConfig() }) - if state.authorMenuLogoPosition == "" { - state.authorMenuLogoPosition = "Top Right" + if state.authorMenuStudioLogoPosition == "" { + state.authorMenuStudioLogoPosition = "Top Right" } - logoPositionSelect.SetSelected(state.authorMenuLogoPosition) + logoPositionSelect.SetSelected(state.authorMenuStudioLogoPosition) scaleOptions := []string{"50%", "75%", "100%", "125%", "150%", "200%"} scaleValueByLabel := map[string]float64{ @@ -1179,29 +1227,29 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject { 1.5: "150%", 2.0: "200%", } - if state.authorMenuLogoScale == 0 { - state.authorMenuLogoScale = 1.0 + if state.authorMenuStudioLogoScale == 0 { + state.authorMenuStudioLogoScale = 1.0 } logoScaleSelect := widget.NewSelect(scaleOptions, func(value string) { if scale, ok := scaleValueByLabel[value]; ok { - state.authorMenuLogoScale = scale + state.authorMenuStudioLogoScale = scale updateLogoPreview() updateBrandingTitle() state.persistAuthorConfig() } }) - scaleLabel := scaleLabelByValue[state.authorMenuLogoScale] + scaleLabel := scaleLabelByValue[state.authorMenuStudioLogoScale] if scaleLabel == "" { scaleLabel = "100%" - state.authorMenuLogoScale = 1.0 + state.authorMenuStudioLogoScale = 1.0 } logoScaleSelect.SetSelected(scaleLabel) - if state.authorMenuLogoMargin == 0 { - state.authorMenuLogoMargin = 24 + if state.authorMenuStudioLogoMargin == 0 { + state.authorMenuStudioLogoMargin = 24 } marginEntry := widget.NewEntry() - marginEntry.SetText(strconv.Itoa(state.authorMenuLogoMargin)) + marginEntry.SetText(strconv.Itoa(state.authorMenuStudioLogoMargin)) updatingMargin := false updateMargin := func(value int, updateEntry bool) { if value < 0 { @@ -1210,7 +1258,7 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject { if value > 60 { value = 60 } - state.authorMenuLogoMargin = value + state.authorMenuStudioLogoMargin = value if updateEntry { updatingMargin = true marginEntry.SetText(strconv.Itoa(value)) @@ -1226,17 +1274,17 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject { return } if v, err := strconv.Atoi(value); err == nil { - if v == state.authorMenuLogoMargin { + if v == state.authorMenuStudioLogoMargin { return } updateMargin(v, true) } } marginMinus := widget.NewButton("-", func() { - updateMargin(state.authorMenuLogoMargin-2, true) + updateMargin(state.authorMenuStudioLogoMargin-2, true) }) marginPlus := widget.NewButton("+", func() { - updateMargin(state.authorMenuLogoMargin+2, true) + updateMargin(state.authorMenuStudioLogoMargin+2, true) }) safeAreaNote := widget.NewLabel("Logos are constrained to DVD safe areas.") @@ -1398,7 +1446,7 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject { thumbSourceSelect, ) - logoControlsEnabled := enabled && state.authorMenuLogoEnabled + logoControlsEnabled := enabled && state.authorMenuStudioLogoEnabled setEnabled(logoControlsEnabled, logoPickButton, logoClearButton, @@ -1411,22 +1459,22 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject { } updateBrandingTitle = func() { - if !state.authorMenuLogoEnabled { + if !state.authorMenuStudioLogoEnabled { brandingItem.Title = "Branding: Disabled" brandingAccordion.Refresh() return } - scaleText := scaleLabelByValue[state.authorMenuLogoScale] + scaleText := scaleLabelByValue[state.authorMenuStudioLogoScale] if scaleText == "" { scaleText = "100%" } name := logoDisplayName() - brandingItem.Title = fmt.Sprintf("Branding: %s (%s, %s)", name, state.authorMenuLogoPosition, scaleText) + brandingItem.Title = fmt.Sprintf("Branding: %s (%s, %s)", name, state.authorMenuStudioLogoPosition, scaleText) brandingAccordion.Refresh() } logoEnableCheck.OnChanged = func(checked bool) { - state.authorMenuLogoEnabled = checked + state.authorMenuStudioLogoEnabled = checked updateLogoPreview() updateBrandingTitle() updateMenuControls(state.authorCreateMenu) @@ -1743,6 +1791,16 @@ func (s *appState) loadVideoTSChapters(videoTSPath string) { } } +func featureClipsOnly(clips []authorClip) []authorClip { + var features []authorClip + for _, clip := range clips { + if !clip.IsExtra { + features = append(features, clip) + } + } + return features +} + func chaptersFromClips(clips []authorClip) []authorChapter { if len(clips) == 0 { return nil @@ -2325,6 +2383,7 @@ func (s *appState) addAuthorToQueue(paths []string, region, aspect, title, outpu "displayName": clip.DisplayName, "duration": clip.Duration, "chapterTitle": clip.ChapterTitle, + "isExtra": clip.IsExtra, }) } chapters := make([]map[string]interface{}, 0, len(s.authorChapters)) @@ -2355,15 +2414,20 @@ 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, - "menuTheme": s.authorMenuTheme, - "menuLogoEnabled": s.authorMenuLogoEnabled, - "menuLogoPath": s.authorMenuLogoPath, - "menuLogoPosition": s.authorMenuLogoPosition, - "menuLogoScale": s.authorMenuLogoScale, - "menuLogoMargin": s.authorMenuLogoMargin, - "menuStructure": s.authorMenuStructure, + "menuTemplate": s.authorMenuTemplate, + "menuBackgroundImage": s.authorMenuBackgroundImage, + "menuTheme": s.authorMenuTheme, + "menuTitleLogoEnabled": s.authorMenuTitleLogoEnabled, + "menuTitleLogoPath": s.authorMenuTitleLogoPath, + "menuTitleLogoPosition": s.authorMenuTitleLogoPosition, + "menuTitleLogoScale": s.authorMenuTitleLogoScale, + "menuTitleLogoMargin": s.authorMenuTitleLogoMargin, + "menuStudioLogoEnabled": s.authorMenuStudioLogoEnabled, + "menuStudioLogoPath": s.authorMenuStudioLogoPath, + "menuStudioLogoPosition": s.authorMenuStudioLogoPosition, + "menuStudioLogoScale": s.authorMenuStudioLogoScale, + "menuStudioLogoMargin": s.authorMenuStudioLogoMargin, + "menuStructure": s.authorMenuStructure, "menuExtrasEnabled": s.authorMenuExtrasEnabled, "menuChapterThumbSrc": s.authorMenuChapterThumbSrc, } @@ -2453,7 +2517,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, menuTemplate string, menuBackgroundImage string, menuTheme string, menuLogoEnabled bool, menuLogoPath, menuLogoPosition string, menuLogoScale float64, menuLogoMargin int, 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, menuTheme string, logos menuLogoOptions, 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) @@ -2487,6 +2551,24 @@ func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, reg return err } + // Separate paths into features and extras based on clips + var featurePaths []string + var extraPaths []string + + if len(clips) > 0 { + // We have clip metadata, use it to separate features and extras + for _, clip := range clips { + if clip.IsExtra { + extraPaths = append(extraPaths, clip.Path) + } else { + featurePaths = append(featurePaths, clip.Path) + } + } + } else { + // No clip metadata, treat all as features + featurePaths = paths + } + var totalDuration float64 for _, path := range paths { src, err := probeVideo(path) @@ -2504,8 +2586,9 @@ func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, reg progressForOtherStep := otherStepsProgressShare / otherStepsCount var accumulatedProgress float64 - var mpgPaths []string - for i, path := range paths { + // Encode features first + var featureMpgPaths []string + for i, path := range featurePaths { if logFn != nil { logFn(fmt.Sprintf("Encoding %d/%d: %s", i+1, len(paths), filepath.Base(path))) } @@ -2558,12 +2641,70 @@ func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, reg return fmt.Errorf("remux failed: %w", err) } os.Remove(outPath) - mpgPaths = append(mpgPaths, remuxPath) + featureMpgPaths = append(featureMpgPaths, remuxPath) + } + + // Encode extras as separate titles + var extraMpgPaths []string + for i, path := range extraPaths { + if logFn != nil { + logFn(fmt.Sprintf("Encoding Extra %d/%d: %s", i+1, len(extraPaths), filepath.Base(path))) + } + outPath := filepath.Join(workDir, fmt.Sprintf("extra_%02d.mpg", i+1)) + src, err := probeVideo(path) + if err != nil { + return fmt.Errorf("failed to probe extra %s: %w", filepath.Base(path), err) + } + + clipProgressShare := 0.0 + if totalDuration > 0 { + clipProgressShare = (src.Duration / totalDuration) * encodingProgressShare + } + + ffmpegProgressFn := func(stepPct float64) { + overallPct := accumulatedProgress + (stepPct / 100.0 * clipProgressShare) + if progressFn != nil { + progressFn(overallPct) + } + } + + args := buildAuthorFFmpegArgs(path, outPath, region, aspect, src.IsProgressive()) + if logFn != nil { + logFn(fmt.Sprintf(">> ffmpeg %s", strings.Join(args, " "))) + } + + if err := runAuthorFFmpeg(ctx, args, src.Duration, logFn, ffmpegProgressFn); err != nil { + return err + } + + accumulatedProgress += clipProgressShare + if progressFn != nil { + progressFn(accumulatedProgress) + } + + remuxPath := filepath.Join(workDir, fmt.Sprintf("extra_%02d_remux.mpg", i+1)) + remuxArgs := []string{ + "-fflags", "+genpts", + "-i", outPath, + "-c", "copy", + "-f", "dvd", + "-muxrate", "10080000", + "-packetsize", "2048", + "-y", remuxPath, + } + if logFn != nil { + logFn(fmt.Sprintf(">> ffmpeg %s (remuxing for DVD compliance)", strings.Join(remuxArgs, " "))) + } + if err := runCommandWithLogger(ctx, utils.GetFFmpegPath(), remuxArgs, logFn); err != nil { + return fmt.Errorf("remux failed: %w", err) + } + os.Remove(outPath) + extraMpgPaths = append(extraMpgPaths, remuxPath) } // Generate clips from paths if clips is empty (fallback for when job didn't save clips) - if len(clips) == 0 && len(paths) > 1 { - for _, path := range paths { + if len(clips) == 0 && len(featurePaths) > 1 { + for _, path := range featurePaths { src, err := probeVideo(path) duration := 0.0 displayName := filepath.Base(path) @@ -2590,17 +2731,52 @@ func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, reg } } + // Separate clips into features and extras + var featureClips []authorClip + var extraClips []authorClip + for _, clip := range clips { + if clip.IsExtra { + extraClips = append(extraClips, clip) + } else { + featureClips = append(featureClips, clip) + } + } + + // Filter chapters to remove any that correspond to extra clips + // This handles the case where chapters were generated from all clips before separation + if len(chapters) > 0 && len(extraClips) > 0 { + filteredChapters := []authorChapter{} + for _, ch := range chapters { + // Check if this chapter title matches any extra clip + isExtra := false + for _, extra := range extraClips { + if ch.Title == extra.ChapterTitle { + isExtra = true + break + } + } + if !isExtra { + filteredChapters = append(filteredChapters, ch) + } + } + chapters = filteredChapters + if logFn != nil && len(filteredChapters) < len(chapters) { + logFn(fmt.Sprintf("Filtered out %d extra chapters, keeping %d feature chapters", len(chapters)-len(filteredChapters), len(filteredChapters))) + } + } + // Generate chapters from clips if available (for professional DVD navigation) - if len(chapters) == 0 && len(clips) > 1 { - chapters = chaptersFromClips(clips) + // Only use non-extra clips for chapters + if len(chapters) == 0 && len(featureClips) > 1 { + chapters = chaptersFromClips(featureClips) if logFn != nil { logFn(fmt.Sprintf("Generated %d chapter markers from video clips", len(chapters))) } } // Try to extract embedded chapters from single file - if len(chapters) == 0 && len(mpgPaths) == 1 { - if embed, err := extractChaptersFromFile(paths[0]); err == nil && len(embed) > 0 { + if len(chapters) == 0 && len(featureMpgPaths) == 1 { + if embed, err := extractChaptersFromFile(featurePaths[0]); err == nil && len(embed) > 0 { chapters = embed if logFn != nil { logFn(fmt.Sprintf("Extracted %d embedded chapters from source", len(chapters))) @@ -2608,55 +2784,69 @@ func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, reg } } - // For professional DVD: always concatenate multiple files into one title with chapters - if len(mpgPaths) > 1 { + // For professional DVD: always concatenate multiple feature files into one title with chapters + if len(featureMpgPaths) > 1 { concatPath := filepath.Join(workDir, "titles_joined.mpg") if logFn != nil { - logFn(fmt.Sprintf("Combining %d videos into single title with chapter markers...", len(mpgPaths))) + logFn(fmt.Sprintf("Combining %d videos into single title with chapter markers...", len(featureMpgPaths))) } - if err := concatDVDMpg(mpgPaths, concatPath); err != nil { + if err := concatDVDMpg(featureMpgPaths, concatPath); err != nil { return fmt.Errorf("failed to concatenate videos: %w", err) } - mpgPaths = []string{concatPath} + featureMpgPaths = []string{concatPath} } // Log details about encoded MPG files if logFn != nil { - logFn(fmt.Sprintf("Created %d MPEG file(s):", len(mpgPaths))) - for i, mpg := range mpgPaths { + totalMpgs := len(featureMpgPaths) + len(extraMpgPaths) + logFn(fmt.Sprintf("Created %d MPEG file(s):", totalMpgs)) + for i, mpg := range featureMpgPaths { if info, err := os.Stat(mpg); err == nil { logFn(fmt.Sprintf(" %d. %s (%d bytes)", i+1, filepath.Base(mpg), info.Size())) } else { logFn(fmt.Sprintf(" %d. %s (stat failed: %v)", i+1, filepath.Base(mpg), err)) } } + for i, mpg := range extraMpgPaths { + if info, err := os.Stat(mpg); err == nil { + logFn(fmt.Sprintf(" %d. %s (EXTRA) (%d bytes)", len(featureMpgPaths)+i+1, filepath.Base(mpg), info.Size())) + } else { + logFn(fmt.Sprintf(" %d. %s (EXTRA) (stat failed: %v)", len(featureMpgPaths)+i+1, filepath.Base(mpg), err)) + } + } } - var menuMpg string - var menuButtons []dvdMenuButton + // Build extras list for menu (title numbers start after main feature) + // Extras menu appears automatically when clips are marked as extras + var extras []extraItem + if len(extraClips) > 0 { + for i, clip := range extraClips { + extras = append(extras, extraItem{ + Title: clip.ChapterTitle, + TitleNum: i + 2, // Title 1 is main feature, extras start at title 2 + }) + } + } + + var menuSet dvdMenuSet if createMenu { template, ok := menuTemplates[menuTemplate] if !ok { template = &SimpleMenu{} } - menuMpg, menuButtons, err = buildDVDMenuAssets( + menuSet, err = buildDVDMenuAssets( ctx, workDir, title, region, aspect, chapters, + extras, logFn, template, menuBackgroundImage, &MenuTheme{Name: menuTheme}, - menuLogoOptions{ - Enabled: menuLogoEnabled, - Path: menuLogoPath, - Position: menuLogoPosition, - Scale: menuLogoScale, - Margin: menuLogoMargin, - }, + logos, ) if err != nil { return err @@ -2664,7 +2854,7 @@ func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, reg } xmlPath := filepath.Join(workDir, "dvd.xml") - if err := writeDVDAuthorXML(xmlPath, mpgPaths, region, aspect, chapters, menuMpg, menuButtons); err != nil { + if err := writeDVDAuthorXML(xmlPath, featureMpgPaths, extraMpgPaths, region, aspect, chapters, menuSet); err != nil { return err } @@ -2902,6 +3092,7 @@ func (s *appState) executeAuthorJob(ctx context.Context, job *queue.Job, progres DisplayName: toString(m["displayName"]), Duration: toFloat(m["duration"]), ChapterTitle: toString(m["chapterTitle"]), + IsExtra: toBool(m["isExtra"]), }) } } @@ -2982,11 +3173,22 @@ func (s *appState) executeAuthorJob(ctx context.Context, job *queue.Job, progres toString(cfg["menuTemplate"]), toString(cfg["menuBackgroundImage"]), toString(cfg["menuTheme"]), - toBool(cfg["menuLogoEnabled"]), - toString(cfg["menuLogoPath"]), - toString(cfg["menuLogoPosition"]), - toFloat(cfg["menuLogoScale"]), - int(toFloat(cfg["menuLogoMargin"])), + menuLogoOptions{ + TitleLogo: menuLogo{ + Enabled: toBool(cfg["menuTitleLogoEnabled"]), + Path: toString(cfg["menuTitleLogoPath"]), + Position: toString(cfg["menuTitleLogoPosition"]), + Scale: toFloat(cfg["menuTitleLogoScale"]), + Margin: int(toFloat(cfg["menuTitleLogoMargin"])), + }, + StudioLogo: menuLogo{ + Enabled: toBool(cfg["menuStudioLogoEnabled"]), + Path: toString(cfg["menuStudioLogoPath"]), + Position: toString(cfg["menuStudioLogoPosition"]), + Scale: toFloat(cfg["menuStudioLogoScale"]), + Margin: int(toFloat(cfg["menuStudioLogoMargin"])), + }, + }, appendLog, updateProgress, ) @@ -3132,7 +3334,7 @@ func buildAuthorFFmpegArgs(inputPath, outputPath, region, aspect string, progres return args } -func writeDVDAuthorXML(path string, mpgPaths []string, region, aspect string, chapters []authorChapter, menuMpg string, menuButtons []dvdMenuButton) error { +func writeDVDAuthorXML(path string, featureMpgPaths []string, extraMpgPaths []string, region, aspect string, chapters []authorChapter, menuSet dvdMenuSet) error { format := strings.ToLower(region) if format != "pal" { format = "ntsc" @@ -3140,32 +3342,79 @@ func writeDVDAuthorXML(path string, mpgPaths []string, region, aspect string, ch var b strings.Builder 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") + + // Write menus section if we have menus + if menuSet.MainMpg != "" && len(menuSet.MainButtons) > 0 { + b.WriteString(" \n") + + // Main menu (PGC 1) + b.WriteString(" \n") + b.WriteString(fmt.Sprintf(" \n", escapeXMLAttr(menuSet.MainMpg))) + for i, btn := range menuSet.MainButtons { + b.WriteString(fmt.Sprintf(" \n", i+1, btn.Command)) + } + b.WriteString(" jump menu entry root;\n") + b.WriteString(" \n") + + // Chapters menu (PGC 2) if present + if menuSet.ChaptersMpg != "" && len(menuSet.ChaptersButtons) > 0 { + b.WriteString(" \n") + b.WriteString(fmt.Sprintf(" \n", escapeXMLAttr(menuSet.ChaptersMpg))) + for i, btn := range menuSet.ChaptersButtons { + b.WriteString(fmt.Sprintf(" \n", i+1, btn.Command)) + } + b.WriteString(" jump menu 2;\n") // Loop chapters menu back to itself + b.WriteString(" \n") + } + + // Extras menu (PGC 2 or 3) if present + if menuSet.ExtrasMpg != "" && len(menuSet.ExtrasButtons) > 0 { + b.WriteString(" \n") + b.WriteString(fmt.Sprintf(" \n", escapeXMLAttr(menuSet.ExtrasMpg))) + for i, btn := range menuSet.ExtrasButtons { + b.WriteString(fmt.Sprintf(" \n", i+1, btn.Command)) + } + // Loop extras menu back to itself (PGC 3 if chapters exist, PGC 2 otherwise) + extrasMenuPGC := 2 + if menuSet.ChaptersMpg != "" { + extrasMenuPGC = 3 + } + b.WriteString(fmt.Sprintf(" jump menu %d;\n", extrasMenuPGC)) + b.WriteString(" \n") + } + + b.WriteString(" \n") + } + b.WriteString(" \n") b.WriteString(fmt.Sprintf(" \n") b.WriteString(" \n") b.WriteString("\n")