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
This commit is contained in:
Stu Leak 2026-01-12 01:16:48 -05:00
parent eaba5abe5f
commit 14c63d2def
2 changed files with 885 additions and 226 deletions

View File

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

View File

@ -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("<dvdauthor>\n")
if menuMpg != "" && len(menuButtons) > 0 {
b.WriteString(" <vmgm>\n")
b.WriteString(" <menus>\n")
b.WriteString(" <pgc entry=\"root\">\n")
b.WriteString(fmt.Sprintf(" <vob file=\"%s\" pause=\"inf\" />\n", escapeXMLAttr(menuMpg)))
for _, btn := range menuButtons {
b.WriteString(fmt.Sprintf(" <button>%s</button>\n", btn.Command))
}
b.WriteString(" </pgc>\n")
b.WriteString(" </menus>\n")
b.WriteString(" </vmgm>\n")
} else {
b.WriteString(" <vmgm />\n")
}
b.WriteString(" <vmgm />\n")
b.WriteString(" <titleset>\n")
// Write menus section if we have menus
if menuSet.MainMpg != "" && len(menuSet.MainButtons) > 0 {
b.WriteString(" <menus>\n")
// Main menu (PGC 1)
b.WriteString(" <pgc entry=\"root\">\n")
b.WriteString(fmt.Sprintf(" <vob file=\"%s\" />\n", escapeXMLAttr(menuSet.MainMpg)))
for i, btn := range menuSet.MainButtons {
b.WriteString(fmt.Sprintf(" <button name=\"b%d\">%s</button>\n", i+1, btn.Command))
}
b.WriteString(" <post>jump menu entry root;</post>\n")
b.WriteString(" </pgc>\n")
// Chapters menu (PGC 2) if present
if menuSet.ChaptersMpg != "" && len(menuSet.ChaptersButtons) > 0 {
b.WriteString(" <pgc>\n")
b.WriteString(fmt.Sprintf(" <vob file=\"%s\" />\n", escapeXMLAttr(menuSet.ChaptersMpg)))
for i, btn := range menuSet.ChaptersButtons {
b.WriteString(fmt.Sprintf(" <button name=\"b%d\">%s</button>\n", i+1, btn.Command))
}
b.WriteString(" <post>jump menu 2;</post>\n") // Loop chapters menu back to itself
b.WriteString(" </pgc>\n")
}
// Extras menu (PGC 2 or 3) if present
if menuSet.ExtrasMpg != "" && len(menuSet.ExtrasButtons) > 0 {
b.WriteString(" <pgc>\n")
b.WriteString(fmt.Sprintf(" <vob file=\"%s\" />\n", escapeXMLAttr(menuSet.ExtrasMpg)))
for i, btn := range menuSet.ExtrasButtons {
b.WriteString(fmt.Sprintf(" <button name=\"b%d\">%s</button>\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(" <post>jump menu %d;</post>\n", extrasMenuPGC))
b.WriteString(" </pgc>\n")
}
b.WriteString(" </menus>\n")
}
b.WriteString(" <titles>\n")
b.WriteString(fmt.Sprintf(" <video format=\"%s\" aspect=\"%s\" />\n", format, aspect))
for _, mpg := range mpgPaths {
// Write main feature title (Title 1) with chapters
for _, mpg := range featureMpgPaths {
b.WriteString(" <pgc>\n")
if len(chapters) > 0 {
b.WriteString(fmt.Sprintf(" <vob file=\"%s\" chapters=\"%s\" />\n", escapeXMLAttr(mpg), chaptersToDVDAuthor(chapters)))
} else {
b.WriteString(fmt.Sprintf(" <vob file=\"%s\" />\n", escapeXMLAttr(mpg)))
}
if menuSet.MainMpg != "" && len(menuSet.MainButtons) > 0 {
b.WriteString(" <post>call menu;</post>\n")
}
b.WriteString(" </pgc>\n")
}
// Write extras as separate titles (Title 2, Title 3, etc.)
for _, mpg := range extraMpgPaths {
b.WriteString(" <pgc>\n")
b.WriteString(fmt.Sprintf(" <vob file=\"%s\" />\n", escapeXMLAttr(mpg)))
if menuSet.MainMpg != "" && len(menuSet.MainButtons) > 0 {
b.WriteString(" <post>call menu;</post>\n")
}
b.WriteString(" </pgc>\n")
}
b.WriteString(" </titles>\n")
b.WriteString(" </titleset>\n")
b.WriteString("</dvdauthor>\n")