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 { type menuLogoOptions struct {
TitleLogo menuLogo
StudioLogo menuLogo
}
type menuLogo struct {
Enabled bool Enabled bool
Path string Path string
Position string Position string
@ -68,7 +73,7 @@ type SimpleMenu struct{}
// Generate creates a simple DVD menu. // 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) { 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) width, height := dvdMenuDimensions(region)
buttons := buildDVDMenuButtons(chapters, width, height) buttons := buildDVDMenuButtons(chapters, false, width, height) // hasExtras=false for template compatibility
if len(buttons) == 0 { if len(buttons) == 0 {
return "", nil, nil return "", nil, nil
} }
@ -118,7 +123,7 @@ type DarkMenu struct{}
// Generate creates a dark-themed DVD menu. // 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) { 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) width, height := dvdMenuDimensions(region)
buttons := buildDVDMenuButtons(chapters, width, height) buttons := buildDVDMenuButtons(chapters, false, width, height) // hasExtras=false for template compatibility
if len(buttons) == 0 { if len(buttons) == 0 {
return "", nil, nil return "", nil, nil
} }
@ -168,7 +173,7 @@ type PosterMenu struct{}
// Generate creates a poster-themed DVD menu. // 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) { 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) width, height := dvdMenuDimensions(region)
buttons := buildDVDMenuButtons(chapters, width, height) buttons := buildDVDMenuButtons(chapters, false, width, height) // hasExtras=false for template compatibility
if len(buttons) == 0 { if len(buttons) == 0 {
return "", nil, nil return "", nil, nil
} }
@ -210,11 +215,179 @@ func (t *PosterMenu) Generate(ctx context.Context, workDir, title, region, aspec
return menuSpu, buttons, nil 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 { if template == nil {
template = &SimpleMenu{} 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) { func dvdMenuDimensions(region string) (int, int) {
@ -224,7 +397,7 @@ func dvdMenuDimensions(region string) (int, int) {
return 720, 480 return 720, 480
} }
func buildDVDMenuButtons(chapters []authorChapter, width, height int) []dvdMenuButton { func buildDVDMenuButtons(chapters []authorChapter, hasExtras bool, width, height int) []dvdMenuButton {
buttons := []dvdMenuButton{ buttons := []dvdMenuButton{
{ {
Label: "Play", Label: "Play",
@ -232,21 +405,27 @@ func buildDVDMenuButtons(chapters []authorChapter, width, height int) []dvdMenuB
}, },
} }
maxChapters := 8 // Add Chapters button if there are multiple chapters
if len(chapters) < maxChapters { if len(chapters) > 1 {
maxChapters = len(chapters)
}
for i := 0; i < maxChapters; i++ {
label := fmt.Sprintf("Chapter %d", i+1)
if title := strings.TrimSpace(chapters[i].Title); title != "" {
label = fmt.Sprintf("Chapter %d: %s", i+1, utils.ShortenMiddle(title, 34))
}
buttons = append(buttons, dvdMenuButton{ buttons = append(buttons, dvdMenuButton{
Label: label, Label: "Chapters",
Command: fmt.Sprintf("jump title 1 chapter %d;", i+1), 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 startY := 180
rowHeight := 34 rowHeight := 34
boxHeight := 28 boxHeight := 28
@ -262,6 +441,81 @@ func buildDVDMenuButtons(chapters []authorChapter, width, height int) []dvdMenuB
return buttons 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 { func buildMenuBackground(ctx context.Context, outputPath, title string, buttons []dvdMenuButton, width, height int, theme *MenuTheme, logo menuLogoOptions, logFn func(string)) error {
theme = resolveMenuTheme(theme) theme = resolveMenuTheme(theme)
@ -278,31 +532,51 @@ func buildMenuBackground(ctx context.Context, outputPath, title string, buttons
filterParts := []string{ filterParts := []string{
fmt.Sprintf("drawbox=x=0:y=0:w=%d:h=72:color=%s:t=fill", width, headerColor), 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=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=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("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 { for i, btn := range buttons {
label := escapeDrawtextText(btn.Label) label := escapeDrawtextText(btn.Label)
y := 184 + i*34 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, ",") filterChain := strings.Join(filterParts, ",")
args := []string{"-y", "-f", "lavfi", "-i", fmt.Sprintf("color=c=%s:s=%dx%d", bgColor, width, height)} 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) filterExpr := fmt.Sprintf("[0:v]%s[bg]", filterChain)
if logo.Enabled {
logoPath := resolveMenuLogoPath(logo) // Handle title logo and studio logo overlays
if logoPath != "" { inputIndex := 1
posExpr := resolveMenuLogoPosition(logo, width, height) baseLayer := "[bg]"
scaleExpr := resolveMenuLogoScaleExpr(logo, width, height)
args = append(args, "-i", logoPath) // Add title logo if enabled
filterExpr = fmt.Sprintf("[0:v]%s[bg];[1:v]%s[logo];[bg][logo]overlay=%s", filterChain, scaleExpr, posExpr) 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) args = append(args, "-filter_complex", filterExpr, "-frames:v", "1", outputPath)
return runCommandWithLogger(ctx, utils.GetFFmpegPath(), args, logFn) return runCommandWithLogger(ctx, utils.GetFFmpegPath(), args, logFn)
} }
@ -323,31 +597,51 @@ func buildDarkMenuBackground(ctx context.Context, outputPath, title string, butt
filterParts := []string{ filterParts := []string{
fmt.Sprintf("drawbox=x=0:y=0:w=%d:h=72:color=%s:t=fill", width, headerColor), 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=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=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("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 { for i, btn := range buttons {
label := escapeDrawtextText(btn.Label) label := escapeDrawtextText(btn.Label)
y := 184 + i*34 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, ",") filterChain := strings.Join(filterParts, ",")
args := []string{"-y", "-f", "lavfi", "-i", fmt.Sprintf("color=c=%s:s=%dx%d", bgColor, width, height)} 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) filterExpr := fmt.Sprintf("[0:v]%s[bg]", filterChain)
if logo.Enabled {
logoPath := resolveMenuLogoPath(logo) // Handle title logo and studio logo overlays
if logoPath != "" { inputIndex := 1
posExpr := resolveMenuLogoPosition(logo, width, height) baseLayer := "[bg]"
scaleExpr := resolveMenuLogoScaleExpr(logo, width, height)
args = append(args, "-i", logoPath) // Add title logo if enabled
filterExpr = fmt.Sprintf("[0:v]%s[bg];[1:v]%s[logo];[bg][logo]overlay=%s", filterChain, scaleExpr, posExpr) 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) args = append(args, "-filter_complex", filterExpr, "-frames:v", "1", outputPath)
return runCommandWithLogger(ctx, utils.GetFFmpegPath(), args, logFn) return runCommandWithLogger(ctx, utils.GetFFmpegPath(), args, logFn)
} }
@ -363,33 +657,139 @@ func buildPosterMenuBackground(ctx context.Context, outputPath, title string, bu
fontArg := menuFontArg(theme) fontArg := menuFontArg(theme)
filterParts := []string{ 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=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=18:x=36:y=80:text=%s", fontArg, textColor, escapeDrawtextText(safeTitle)),
} }
for i, btn := range buttons { for i, btn := range buttons {
label := escapeDrawtextText(btn.Label) label := escapeDrawtextText(btn.Label)
y := 184 + i*34 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, ",") filterChain := strings.Join(filterParts, ",")
args := []string{"-y", "-i", backgroundImage} args := []string{"-y", "-i", backgroundImage}
filterExpr := fmt.Sprintf("[0:v]scale=%d:%d,%s[bg]", width, height, filterChain) filterExpr := fmt.Sprintf("[0:v]scale=%d:%d,%s[bg]", width, height, filterChain)
if logo.Enabled {
logoPath := resolveMenuLogoPath(logo) // Handle title logo and studio logo overlays
if logoPath != "" { inputIndex := 1
posExpr := resolveMenuLogoPosition(logo, width, height) baseLayer := "[bg]"
scaleExpr := resolveMenuLogoScaleExpr(logo, width, height)
args = append(args, "-i", logoPath) // Add title logo if enabled
filterExpr = fmt.Sprintf("[0:v]scale=%d:%d,%s[bg];[1:v]%s[logo];[bg][logo]overlay=%s", width, height, filterChain, scaleExpr, posExpr) 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) args = append(args, "-filter_complex", filterExpr, "-frames:v", "1", outputPath)
return runCommandWithLogger(ctx, utils.GetFFmpegPath(), args, logFn) 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 { func buildMenuOverlays(ctx context.Context, overlayPath, highlightPath, selectPath string, buttons []dvdMenuButton, width, height int, theme *MenuTheme, logFn func(string)) error {
theme = resolveMenuTheme(theme) theme = resolveMenuTheme(theme)
accent := theme.AccentColor accent := theme.AccentColor
@ -456,7 +856,7 @@ func writeSpumuxXML(path, overlayPath, highlightPath, selectPath string, buttons
var b strings.Builder var b strings.Builder
b.WriteString("<subpictures>\n") b.WriteString("<subpictures>\n")
b.WriteString(" <stream>\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(overlayPath),
escapeXMLAttr(highlightPath), escapeXMLAttr(highlightPath),
escapeXMLAttr(selectPath), 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 { 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 { if logFn != nil {
logFn(fmt.Sprintf(">> spumux -m dvd %s < %s > %s", spumuxXML, filepath.Base(inputMpg), filepath.Base(outputMpg))) 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 { func findMenuFontPath() string {
search := []string{ // Get absolute path to working directory
filepath.Join("assets", "fonts", "IBMPlexMono-Regular.ttf"), 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 { if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe) dir := filepath.Dir(exe)
search = append(search, filepath.Join(dir, "assets", "fonts", "IBMPlexMono-Regular.ttf")) p := filepath.Join(dir, "assets", "fonts", "IBMPlexMono-Regular.ttf")
}
for _, p := range search {
if _, err := os.Stat(p); err == nil { if _, err := os.Stat(p); err == nil {
return p return p
} }
@ -572,14 +976,14 @@ func menuFontArg(theme *MenuTheme) string {
return "font=monospace" return "font=monospace"
} }
func resolveMenuLogoPath(logo menuLogoOptions) string { func resolveMenuLogoPath(logo menuLogo) string {
if strings.TrimSpace(logo.Path) != "" { if strings.TrimSpace(logo.Path) != "" {
return logo.Path return logo.Path
} }
return filepath.Join("assets", "logo", "VT_Logo.png") return filepath.Join("assets", "logo", "VT_Logo.png")
} }
func resolveMenuLogoScale(logo menuLogoOptions) float64 { func resolveMenuLogoScale(logo menuLogo) float64 {
if logo.Scale <= 0 { if logo.Scale <= 0 {
return 1.0 return 1.0
} }
@ -592,14 +996,15 @@ func resolveMenuLogoScale(logo menuLogoOptions) float64 {
return logo.Scale return logo.Scale
} }
func resolveMenuLogoScaleExpr(logo menuLogoOptions, width, height int) string { func resolveMenuLogoScaleExpr(logo menuLogo, width, height int) string {
scale := resolveMenuLogoScale(logo) scale := resolveMenuLogoScale(logo)
maxW := float64(width) * 0.25 maxW := float64(width) * 0.25
maxH := float64(height) * 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 margin := logo.Margin
if margin < 0 { if margin < 0 {
margin = 0 margin = 0
@ -619,9 +1024,14 @@ func resolveMenuLogoPosition(logo menuLogoOptions, width, height int) string {
} }
func escapeDrawtextText(text string) string { func escapeDrawtextText(text string) string {
escaped := strings.ReplaceAll(text, "\\", "\\\\") // Strip ALL special characters - only keep letters, numbers, and spaces
escaped = strings.ReplaceAll(escaped, ":", "\\:") var result strings.Builder
escaped = strings.ReplaceAll(escaped, "'", "\\'") for _, r := range text {
escaped = strings.ReplaceAll(escaped, "%", "\\%") if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == ' ' {
return escaped 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 { type authorConfig struct {
OutputType string `json:"outputType"` OutputType string `json:"outputType"`
Region string `json:"region"` Region string `json:"region"`
AspectRatio string `json:"aspectRatio"` AspectRatio string `json:"aspectRatio"`
DiscSize string `json:"discSize"` DiscSize string `json:"discSize"`
Title string `json:"title"` Title string `json:"title"`
CreateMenu bool `json:"createMenu"` CreateMenu bool `json:"createMenu"`
MenuTemplate string `json:"menuTemplate"` MenuTemplate string `json:"menuTemplate"`
MenuTheme string `json:"menuTheme"` MenuTheme string `json:"menuTheme"`
MenuBackgroundImage string `json:"menuBackgroundImage"` MenuBackgroundImage string `json:"menuBackgroundImage"`
MenuLogoEnabled bool `json:"menuLogoEnabled"` MenuTitleLogoEnabled bool `json:"menuTitleLogoEnabled"`
MenuLogoPath string `json:"menuLogoPath"` MenuTitleLogoPath string `json:"menuTitleLogoPath"`
MenuLogoPosition string `json:"menuLogoPosition"` MenuTitleLogoPosition string `json:"menuTitleLogoPosition"`
MenuLogoScale float64 `json:"menuLogoScale"` MenuTitleLogoScale float64 `json:"menuTitleLogoScale"`
MenuLogoMargin int `json:"menuLogoMargin"` MenuTitleLogoMargin int `json:"menuTitleLogoMargin"`
MenuStructure string `json:"menuStructure"` MenuStudioLogoEnabled bool `json:"menuStudioLogoEnabled"`
MenuExtrasEnabled bool `json:"menuExtrasEnabled"` MenuStudioLogoPath string `json:"menuStudioLogoPath"`
MenuChapterThumbSrc string `json:"menuChapterThumbSrc"` MenuStudioLogoPosition string `json:"menuStudioLogoPosition"`
TreatAsChapters bool `json:"treatAsChapters"` MenuStudioLogoScale float64 `json:"menuStudioLogoScale"`
SceneThreshold float64 `json:"sceneThreshold"` 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 { func defaultAuthorConfig() authorConfig {
@ -62,16 +67,21 @@ func defaultAuthorConfig() authorConfig {
AspectRatio: "AUTO", AspectRatio: "AUTO",
DiscSize: "DVD5", DiscSize: "DVD5",
Title: "", Title: "",
CreateMenu: false, CreateMenu: false,
MenuTemplate: "Simple", MenuTemplate: "Simple",
MenuTheme: "VideoTools", MenuTheme: "VideoTools",
MenuBackgroundImage: "", MenuBackgroundImage: "",
MenuLogoEnabled: true, MenuTitleLogoEnabled: false,
MenuLogoPath: "", MenuTitleLogoPath: "",
MenuLogoPosition: "Top Right", MenuTitleLogoPosition: "Center",
MenuLogoScale: 1.0, MenuTitleLogoScale: 1.0,
MenuLogoMargin: 24, MenuTitleLogoMargin: 24,
MenuStructure: "Feature + Chapters", MenuStudioLogoEnabled: true,
MenuStudioLogoPath: "",
MenuStudioLogoPosition: "Top Right",
MenuStudioLogoScale: 1.0,
MenuStudioLogoMargin: 24,
MenuStructure: "Feature + Chapters",
MenuExtrasEnabled: false, MenuExtrasEnabled: false,
MenuChapterThumbSrc: "Auto", MenuChapterThumbSrc: "Auto",
TreatAsChapters: false, TreatAsChapters: false,
@ -107,14 +117,23 @@ func loadPersistedAuthorConfig() (authorConfig, error) {
if cfg.MenuTheme == "" { if cfg.MenuTheme == "" {
cfg.MenuTheme = "VideoTools" cfg.MenuTheme = "VideoTools"
} }
if cfg.MenuLogoPosition == "" { if cfg.MenuTitleLogoPosition == "" {
cfg.MenuLogoPosition = "Top Right" cfg.MenuTitleLogoPosition = "Center"
} }
if cfg.MenuLogoScale == 0 { if cfg.MenuTitleLogoScale == 0 {
cfg.MenuLogoScale = 1.0 cfg.MenuTitleLogoScale = 1.0
} }
if cfg.MenuLogoMargin == 0 { if cfg.MenuTitleLogoMargin == 0 {
cfg.MenuLogoMargin = 24 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 == "" { if cfg.MenuStructure == "" {
cfg.MenuStructure = "Feature + Chapters" cfg.MenuStructure = "Feature + Chapters"
@ -150,11 +169,16 @@ func (s *appState) applyAuthorConfig(cfg authorConfig) {
s.authorMenuTemplate = cfg.MenuTemplate s.authorMenuTemplate = cfg.MenuTemplate
s.authorMenuTheme = cfg.MenuTheme s.authorMenuTheme = cfg.MenuTheme
s.authorMenuBackgroundImage = cfg.MenuBackgroundImage s.authorMenuBackgroundImage = cfg.MenuBackgroundImage
s.authorMenuLogoEnabled = cfg.MenuLogoEnabled s.authorMenuTitleLogoEnabled = cfg.MenuTitleLogoEnabled
s.authorMenuLogoPath = cfg.MenuLogoPath s.authorMenuTitleLogoPath = cfg.MenuTitleLogoPath
s.authorMenuLogoPosition = cfg.MenuLogoPosition s.authorMenuTitleLogoPosition = cfg.MenuTitleLogoPosition
s.authorMenuLogoScale = cfg.MenuLogoScale s.authorMenuTitleLogoScale = cfg.MenuTitleLogoScale
s.authorMenuLogoMargin = cfg.MenuLogoMargin 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.authorMenuStructure = cfg.MenuStructure
s.authorMenuExtrasEnabled = cfg.MenuExtrasEnabled s.authorMenuExtrasEnabled = cfg.MenuExtrasEnabled
s.authorMenuChapterThumbSrc = cfg.MenuChapterThumbSrc s.authorMenuChapterThumbSrc = cfg.MenuChapterThumbSrc
@ -172,13 +196,18 @@ func (s *appState) persistAuthorConfig() {
CreateMenu: s.authorCreateMenu, CreateMenu: s.authorCreateMenu,
MenuTemplate: s.authorMenuTemplate, MenuTemplate: s.authorMenuTemplate,
MenuTheme: s.authorMenuTheme, MenuTheme: s.authorMenuTheme,
MenuBackgroundImage: s.authorMenuBackgroundImage, MenuBackgroundImage: s.authorMenuBackgroundImage,
MenuLogoEnabled: s.authorMenuLogoEnabled, MenuTitleLogoEnabled: s.authorMenuTitleLogoEnabled,
MenuLogoPath: s.authorMenuLogoPath, MenuTitleLogoPath: s.authorMenuTitleLogoPath,
MenuLogoPosition: s.authorMenuLogoPosition, MenuTitleLogoPosition: s.authorMenuTitleLogoPosition,
MenuLogoScale: s.authorMenuLogoScale, MenuTitleLogoScale: s.authorMenuTitleLogoScale,
MenuLogoMargin: s.authorMenuLogoMargin, MenuTitleLogoMargin: s.authorMenuTitleLogoMargin,
MenuStructure: s.authorMenuStructure, MenuStudioLogoEnabled: s.authorMenuStudioLogoEnabled,
MenuStudioLogoPath: s.authorMenuStudioLogoPath,
MenuStudioLogoPosition: s.authorMenuStudioLogoPosition,
MenuStudioLogoScale: s.authorMenuStudioLogoScale,
MenuStudioLogoMargin: s.authorMenuStudioLogoMargin,
MenuStructure: s.authorMenuStructure,
MenuExtrasEnabled: s.authorMenuExtrasEnabled, MenuExtrasEnabled: s.authorMenuExtrasEnabled,
MenuChapterThumbSrc: s.authorMenuChapterThumbSrc, MenuChapterThumbSrc: s.authorMenuChapterThumbSrc,
TreatAsChapters: s.authorTreatAsChapters, TreatAsChapters: s.authorTreatAsChapters,
@ -216,14 +245,23 @@ func buildAuthorView(state *appState) fyne.CanvasObject {
if state.authorMenuTheme == "" { if state.authorMenuTheme == "" {
state.authorMenuTheme = "VideoTools" state.authorMenuTheme = "VideoTools"
} }
if state.authorMenuLogoPosition == "" { if state.authorMenuTitleLogoPosition == "" {
state.authorMenuLogoPosition = "Top Right" state.authorMenuTitleLogoPosition = "Center"
} }
if state.authorMenuLogoScale == 0 { if state.authorMenuTitleLogoScale == 0 {
state.authorMenuLogoScale = 1.0 state.authorMenuTitleLogoScale = 1.0
} }
if state.authorMenuLogoMargin == 0 { if state.authorMenuTitleLogoMargin == 0 {
state.authorMenuLogoMargin = 24 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 == "" { if state.authorMenuStructure == "" {
state.authorMenuStructure = "Feature + Chapters" state.authorMenuStructure = "Feature + Chapters"
@ -350,16 +388,26 @@ func buildVideoClipsTab(state *appState) fyne.CanvasObject {
titleEntry.OnChanged = func(val string) { titleEntry.OnChanged = func(val string) {
state.authorClips[idx].ChapterTitle = val state.authorClips[idx].ChapterTitle = val
if state.authorTreatAsChapters { if state.authorTreatAsChapters {
state.authorChapters = chaptersFromClips(state.authorClips) state.authorChapters = chaptersFromClips(featureClipsOnly(state.authorClips))
state.authorChapterSource = "clips" state.authorChapterSource = "clips"
state.updateAuthorSummary() state.updateAuthorSummary()
} }
} }
// Note about chapter names for future menu support extraCheck := widget.NewCheck("Mark as Extra", func(checked bool) {
noteLabel := widget.NewLabel("(For future DVD menus)") state.authorClips[idx].IsExtra = checked
noteLabel.TextStyle = fyne.TextStyle{Italic: true} // Refresh chapters to exclude/include this clip
noteLabel.Alignment = fyne.TextAlignLeading 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() { removeBtn := widget.NewButton("Remove", func() {
state.authorClips = append(state.authorClips[:idx], state.authorClips[idx+1:]...) state.authorClips = append(state.authorClips[:idx], state.authorClips[idx+1:]...)
@ -373,7 +421,7 @@ func buildVideoClipsTab(state *appState) fyne.CanvasObject {
nil, nil,
nil, nil,
container.NewVBox(durationLabel, removeBtn), container.NewVBox(durationLabel, removeBtn),
container.NewVBox(nameLabel, titleEntry, noteLabel), container.NewVBox(nameLabel, titleEntry, extraCheck),
) )
cardBg := canvas.NewRectangle(utils.MustHex("#171C2A")) cardBg := canvas.NewRectangle(utils.MustHex("#171C2A"))
cardBg.CornerRadius = 6 cardBg.CornerRadius = 6
@ -427,7 +475,7 @@ func buildVideoClipsTab(state *appState) fyne.CanvasObject {
chapterToggle := widget.NewCheck("Treat videos as chapters", func(checked bool) { chapterToggle := widget.NewCheck("Treat videos as chapters", func(checked bool) {
state.authorTreatAsChapters = checked state.authorTreatAsChapters = checked
if checked { if checked {
state.authorChapters = chaptersFromClips(state.authorClips) state.authorChapters = chaptersFromClips(featureClipsOnly(state.authorClips))
state.authorChapterSource = "clips" state.authorChapterSource = "clips"
} else if state.authorChapterSource == "clips" { } else if state.authorChapterSource == "clips" {
state.authorChapterSource = "" state.authorChapterSource = ""
@ -470,17 +518,12 @@ func buildVideoClipsTab(state *appState) fyne.CanvasObject {
state.persistAuthorConfig() 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( controls := container.NewBorder(
container.NewVBox( container.NewVBox(
widget.NewLabel("DVD Title:"), widget.NewLabel("DVD Title:"),
dvdTitleEntry, dvdTitleEntry,
widget.NewSeparator(), widget.NewSeparator(),
widget.NewLabel("Videos:"), widget.NewLabel("Videos:"),
chapterNote,
), ),
container.NewVBox(chapterToggle, container.NewHBox(addBtn, clearBtn, addQueueBtn, compileBtn)), container.NewVBox(chapterToggle, container.NewHBox(addBtn, clearBtn, addQueueBtn, compileBtn)),
nil, nil,
@ -508,7 +551,7 @@ func buildChaptersTab(state *appState) fyne.CanvasObject {
sourceLabel.SetText("") sourceLabel.SetText("")
if len(state.authorChapters) == 0 { if len(state.authorChapters) == 0 {
if state.authorTreatAsChapters && len(state.authorClips) > 1 { if state.authorTreatAsChapters && len(state.authorClips) > 1 {
state.authorChapters = chaptersFromClips(state.authorClips) state.authorChapters = chaptersFromClips(featureClipsOnly(state.authorClips))
state.authorChapterSource = "clips" state.authorChapterSource = "clips"
} }
} }
@ -869,13 +912,18 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject {
CreateMenu: state.authorCreateMenu, CreateMenu: state.authorCreateMenu,
MenuTemplate: state.authorMenuTemplate, MenuTemplate: state.authorMenuTemplate,
MenuTheme: state.authorMenuTheme, MenuTheme: state.authorMenuTheme,
MenuBackgroundImage: state.authorMenuBackgroundImage, MenuBackgroundImage: state.authorMenuBackgroundImage,
MenuLogoEnabled: state.authorMenuLogoEnabled, MenuTitleLogoEnabled: state.authorMenuTitleLogoEnabled,
MenuLogoPath: state.authorMenuLogoPath, MenuTitleLogoPath: state.authorMenuTitleLogoPath,
MenuLogoPosition: state.authorMenuLogoPosition, MenuTitleLogoPosition: state.authorMenuTitleLogoPosition,
MenuLogoScale: state.authorMenuLogoScale, MenuTitleLogoScale: state.authorMenuTitleLogoScale,
MenuLogoMargin: state.authorMenuLogoMargin, MenuTitleLogoMargin: state.authorMenuTitleLogoMargin,
MenuStructure: state.authorMenuStructure, MenuStudioLogoEnabled: state.authorMenuStudioLogoEnabled,
MenuStudioLogoPath: state.authorMenuStudioLogoPath,
MenuStudioLogoPosition: state.authorMenuStudioLogoPosition,
MenuStudioLogoScale: state.authorMenuStudioLogoScale,
MenuStudioLogoMargin: state.authorMenuStudioLogoMargin,
MenuStructure: state.authorMenuStructure,
MenuExtrasEnabled: state.authorMenuExtrasEnabled, MenuExtrasEnabled: state.authorMenuExtrasEnabled,
MenuChapterThumbSrc: state.authorMenuChapterThumbSrc, MenuChapterThumbSrc: state.authorMenuChapterThumbSrc,
TreatAsChapters: state.authorTreatAsChapters, TreatAsChapters: state.authorTreatAsChapters,
@ -1027,7 +1075,7 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject {
} }
logoEnableCheck := widget.NewCheck("Embed Logo", nil) logoEnableCheck := widget.NewCheck("Embed Logo", nil)
logoEnableCheck.SetChecked(state.authorMenuLogoEnabled) logoEnableCheck.SetChecked(state.authorMenuStudioLogoEnabled)
logoFileEntry := widget.NewEntry() logoFileEntry := widget.NewEntry()
logoFileEntry.Disable() logoFileEntry.Disable()
@ -1060,22 +1108,22 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject {
} }
logoDisplayName := func() string { logoDisplayName := func() string {
if strings.TrimSpace(state.authorMenuLogoPath) == "" { if strings.TrimSpace(state.authorMenuStudioLogoPath) == "" {
return "VT_Logo.png (default)" return "VT_Logo.png (default)"
} }
return filepath.Base(state.authorMenuLogoPath) return filepath.Base(state.authorMenuStudioLogoPath)
} }
updateLogoPreview := func() { updateLogoPreview := func() {
logoFileEntry.SetText(logoDisplayName()) logoFileEntry.SetText(logoDisplayName())
if !state.authorMenuLogoEnabled { if !state.authorMenuStudioLogoEnabled {
logoPreviewBox.Hide() logoPreviewBox.Hide()
logoPreviewLabel.SetText("Logo disabled") logoPreviewLabel.SetText("Logo disabled")
logoPreviewSize.SetText("") logoPreviewSize.SetText("")
return return
} }
path := state.authorMenuLogoPath path := state.authorMenuStudioLogoPath
if strings.TrimSpace(path) == "" { if strings.TrimSpace(path) == "" {
path = filepath.Join("assets", "logo", "VT_Logo.png") path = filepath.Join("assets", "logo", "VT_Logo.png")
} }
@ -1108,7 +1156,7 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject {
menuW, menuH := menuPreviewSize() menuW, menuH := menuPreviewSize()
maxW := int(float64(menuW) * 0.25) maxW := int(float64(menuW) * 0.25)
maxH := int(float64(menuH) * 0.25) maxH := int(float64(menuH) * 0.25)
scale := state.authorMenuLogoScale scale := state.authorMenuStudioLogoScale
targetW := int(math.Round(float64(cfg.Width) * scale)) targetW := int(math.Round(float64(cfg.Width) * scale))
targetH := int(math.Round(float64(cfg.Height) * scale)) targetH := int(math.Round(float64(cfg.Height) * scale))
if targetW > maxW || targetH > maxH { if targetW > maxW || targetH > maxH {
@ -1127,7 +1175,7 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject {
return return
} }
defer reader.Close() defer reader.Close()
state.authorMenuLogoPath = reader.URI().Path() state.authorMenuStudioLogoPath = reader.URI().Path()
logoFileEntry.SetText(logoDisplayName()) logoFileEntry.SetText(logoDisplayName())
updateLogoPreview() updateLogoPreview()
updateBrandingTitle() updateBrandingTitle()
@ -1137,7 +1185,7 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject {
}) })
logoPickButton.Importance = widget.MediumImportance logoPickButton.Importance = widget.MediumImportance
logoClearButton := widget.NewButton("Clear", func() { logoClearButton := widget.NewButton("Clear", func() {
state.authorMenuLogoPath = "" state.authorMenuStudioLogoPath = ""
logoFileEntry.SetText(logoDisplayName()) logoFileEntry.SetText(logoDisplayName())
logoEnableCheck.SetChecked(false) logoEnableCheck.SetChecked(false)
updateLogoPreview() updateLogoPreview()
@ -1153,14 +1201,14 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject {
"Bottom Right", "Bottom Right",
"Center", "Center",
}, func(value string) { }, func(value string) {
state.authorMenuLogoPosition = value state.authorMenuStudioLogoPosition = value
updateBrandingTitle() updateBrandingTitle()
state.persistAuthorConfig() state.persistAuthorConfig()
}) })
if state.authorMenuLogoPosition == "" { if state.authorMenuStudioLogoPosition == "" {
state.authorMenuLogoPosition = "Top Right" state.authorMenuStudioLogoPosition = "Top Right"
} }
logoPositionSelect.SetSelected(state.authorMenuLogoPosition) logoPositionSelect.SetSelected(state.authorMenuStudioLogoPosition)
scaleOptions := []string{"50%", "75%", "100%", "125%", "150%", "200%"} scaleOptions := []string{"50%", "75%", "100%", "125%", "150%", "200%"}
scaleValueByLabel := map[string]float64{ scaleValueByLabel := map[string]float64{
@ -1179,29 +1227,29 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject {
1.5: "150%", 1.5: "150%",
2.0: "200%", 2.0: "200%",
} }
if state.authorMenuLogoScale == 0 { if state.authorMenuStudioLogoScale == 0 {
state.authorMenuLogoScale = 1.0 state.authorMenuStudioLogoScale = 1.0
} }
logoScaleSelect := widget.NewSelect(scaleOptions, func(value string) { logoScaleSelect := widget.NewSelect(scaleOptions, func(value string) {
if scale, ok := scaleValueByLabel[value]; ok { if scale, ok := scaleValueByLabel[value]; ok {
state.authorMenuLogoScale = scale state.authorMenuStudioLogoScale = scale
updateLogoPreview() updateLogoPreview()
updateBrandingTitle() updateBrandingTitle()
state.persistAuthorConfig() state.persistAuthorConfig()
} }
}) })
scaleLabel := scaleLabelByValue[state.authorMenuLogoScale] scaleLabel := scaleLabelByValue[state.authorMenuStudioLogoScale]
if scaleLabel == "" { if scaleLabel == "" {
scaleLabel = "100%" scaleLabel = "100%"
state.authorMenuLogoScale = 1.0 state.authorMenuStudioLogoScale = 1.0
} }
logoScaleSelect.SetSelected(scaleLabel) logoScaleSelect.SetSelected(scaleLabel)
if state.authorMenuLogoMargin == 0 { if state.authorMenuStudioLogoMargin == 0 {
state.authorMenuLogoMargin = 24 state.authorMenuStudioLogoMargin = 24
} }
marginEntry := widget.NewEntry() marginEntry := widget.NewEntry()
marginEntry.SetText(strconv.Itoa(state.authorMenuLogoMargin)) marginEntry.SetText(strconv.Itoa(state.authorMenuStudioLogoMargin))
updatingMargin := false updatingMargin := false
updateMargin := func(value int, updateEntry bool) { updateMargin := func(value int, updateEntry bool) {
if value < 0 { if value < 0 {
@ -1210,7 +1258,7 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject {
if value > 60 { if value > 60 {
value = 60 value = 60
} }
state.authorMenuLogoMargin = value state.authorMenuStudioLogoMargin = value
if updateEntry { if updateEntry {
updatingMargin = true updatingMargin = true
marginEntry.SetText(strconv.Itoa(value)) marginEntry.SetText(strconv.Itoa(value))
@ -1226,17 +1274,17 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject {
return return
} }
if v, err := strconv.Atoi(value); err == nil { if v, err := strconv.Atoi(value); err == nil {
if v == state.authorMenuLogoMargin { if v == state.authorMenuStudioLogoMargin {
return return
} }
updateMargin(v, true) updateMargin(v, true)
} }
} }
marginMinus := widget.NewButton("-", func() { marginMinus := widget.NewButton("-", func() {
updateMargin(state.authorMenuLogoMargin-2, true) updateMargin(state.authorMenuStudioLogoMargin-2, true)
}) })
marginPlus := widget.NewButton("+", func() { marginPlus := widget.NewButton("+", func() {
updateMargin(state.authorMenuLogoMargin+2, true) updateMargin(state.authorMenuStudioLogoMargin+2, true)
}) })
safeAreaNote := widget.NewLabel("Logos are constrained to DVD safe areas.") safeAreaNote := widget.NewLabel("Logos are constrained to DVD safe areas.")
@ -1398,7 +1446,7 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject {
thumbSourceSelect, thumbSourceSelect,
) )
logoControlsEnabled := enabled && state.authorMenuLogoEnabled logoControlsEnabled := enabled && state.authorMenuStudioLogoEnabled
setEnabled(logoControlsEnabled, setEnabled(logoControlsEnabled,
logoPickButton, logoPickButton,
logoClearButton, logoClearButton,
@ -1411,22 +1459,22 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject {
} }
updateBrandingTitle = func() { updateBrandingTitle = func() {
if !state.authorMenuLogoEnabled { if !state.authorMenuStudioLogoEnabled {
brandingItem.Title = "Branding: Disabled" brandingItem.Title = "Branding: Disabled"
brandingAccordion.Refresh() brandingAccordion.Refresh()
return return
} }
scaleText := scaleLabelByValue[state.authorMenuLogoScale] scaleText := scaleLabelByValue[state.authorMenuStudioLogoScale]
if scaleText == "" { if scaleText == "" {
scaleText = "100%" scaleText = "100%"
} }
name := logoDisplayName() 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() brandingAccordion.Refresh()
} }
logoEnableCheck.OnChanged = func(checked bool) { logoEnableCheck.OnChanged = func(checked bool) {
state.authorMenuLogoEnabled = checked state.authorMenuStudioLogoEnabled = checked
updateLogoPreview() updateLogoPreview()
updateBrandingTitle() updateBrandingTitle()
updateMenuControls(state.authorCreateMenu) 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 { func chaptersFromClips(clips []authorClip) []authorChapter {
if len(clips) == 0 { if len(clips) == 0 {
return nil return nil
@ -2325,6 +2383,7 @@ func (s *appState) addAuthorToQueue(paths []string, region, aspect, title, outpu
"displayName": clip.DisplayName, "displayName": clip.DisplayName,
"duration": clip.Duration, "duration": clip.Duration,
"chapterTitle": clip.ChapterTitle, "chapterTitle": clip.ChapterTitle,
"isExtra": clip.IsExtra,
}) })
} }
chapters := make([]map[string]interface{}, 0, len(s.authorChapters)) 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, "chapterSource": s.authorChapterSource,
"subtitleTracks": append([]string{}, s.authorSubtitles...), "subtitleTracks": append([]string{}, s.authorSubtitles...),
"additionalAudios": append([]string{}, s.authorAudioTracks...), "additionalAudios": append([]string{}, s.authorAudioTracks...),
"menuTemplate": s.authorMenuTemplate, "menuTemplate": s.authorMenuTemplate,
"menuBackgroundImage": s.authorMenuBackgroundImage, "menuBackgroundImage": s.authorMenuBackgroundImage,
"menuTheme": s.authorMenuTheme, "menuTheme": s.authorMenuTheme,
"menuLogoEnabled": s.authorMenuLogoEnabled, "menuTitleLogoEnabled": s.authorMenuTitleLogoEnabled,
"menuLogoPath": s.authorMenuLogoPath, "menuTitleLogoPath": s.authorMenuTitleLogoPath,
"menuLogoPosition": s.authorMenuLogoPosition, "menuTitleLogoPosition": s.authorMenuTitleLogoPosition,
"menuLogoScale": s.authorMenuLogoScale, "menuTitleLogoScale": s.authorMenuTitleLogoScale,
"menuLogoMargin": s.authorMenuLogoMargin, "menuTitleLogoMargin": s.authorMenuTitleLogoMargin,
"menuStructure": s.authorMenuStructure, "menuStudioLogoEnabled": s.authorMenuStudioLogoEnabled,
"menuStudioLogoPath": s.authorMenuStudioLogoPath,
"menuStudioLogoPosition": s.authorMenuStudioLogoPosition,
"menuStudioLogoScale": s.authorMenuStudioLogoScale,
"menuStudioLogoMargin": s.authorMenuStudioLogoMargin,
"menuStructure": s.authorMenuStructure,
"menuExtrasEnabled": s.authorMenuExtrasEnabled, "menuExtrasEnabled": s.authorMenuExtrasEnabled,
"menuChapterThumbSrc": s.authorMenuChapterThumbSrc, "menuChapterThumbSrc": s.authorMenuChapterThumbSrc,
} }
@ -2453,7 +2517,7 @@ func (s *appState) addAuthorVideoTSToQueue(videoTSPath, title, outputPath string
return nil 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) tempRoot := authorTempRoot(outputPath)
if err := os.MkdirAll(tempRoot, 0755); err != nil { if err := os.MkdirAll(tempRoot, 0755); err != nil {
return fmt.Errorf("failed to create temp root: %w", err) 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 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 var totalDuration float64
for _, path := range paths { for _, path := range paths {
src, err := probeVideo(path) src, err := probeVideo(path)
@ -2504,8 +2586,9 @@ func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, reg
progressForOtherStep := otherStepsProgressShare / otherStepsCount progressForOtherStep := otherStepsProgressShare / otherStepsCount
var accumulatedProgress float64 var accumulatedProgress float64
var mpgPaths []string // Encode features first
for i, path := range paths { var featureMpgPaths []string
for i, path := range featurePaths {
if logFn != nil { if logFn != nil {
logFn(fmt.Sprintf("Encoding %d/%d: %s", i+1, len(paths), filepath.Base(path))) 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) return fmt.Errorf("remux failed: %w", err)
} }
os.Remove(outPath) 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) // Generate clips from paths if clips is empty (fallback for when job didn't save clips)
if len(clips) == 0 && len(paths) > 1 { if len(clips) == 0 && len(featurePaths) > 1 {
for _, path := range paths { for _, path := range featurePaths {
src, err := probeVideo(path) src, err := probeVideo(path)
duration := 0.0 duration := 0.0
displayName := filepath.Base(path) 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) // Generate chapters from clips if available (for professional DVD navigation)
if len(chapters) == 0 && len(clips) > 1 { // Only use non-extra clips for chapters
chapters = chaptersFromClips(clips) if len(chapters) == 0 && len(featureClips) > 1 {
chapters = chaptersFromClips(featureClips)
if logFn != nil { if logFn != nil {
logFn(fmt.Sprintf("Generated %d chapter markers from video clips", len(chapters))) logFn(fmt.Sprintf("Generated %d chapter markers from video clips", len(chapters)))
} }
} }
// Try to extract embedded chapters from single file // Try to extract embedded chapters from single file
if len(chapters) == 0 && len(mpgPaths) == 1 { if len(chapters) == 0 && len(featureMpgPaths) == 1 {
if embed, err := extractChaptersFromFile(paths[0]); err == nil && len(embed) > 0 { if embed, err := extractChaptersFromFile(featurePaths[0]); err == nil && len(embed) > 0 {
chapters = embed chapters = embed
if logFn != nil { if logFn != nil {
logFn(fmt.Sprintf("Extracted %d embedded chapters from source", len(chapters))) 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 // For professional DVD: always concatenate multiple feature files into one title with chapters
if len(mpgPaths) > 1 { if len(featureMpgPaths) > 1 {
concatPath := filepath.Join(workDir, "titles_joined.mpg") concatPath := filepath.Join(workDir, "titles_joined.mpg")
if logFn != nil { 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) return fmt.Errorf("failed to concatenate videos: %w", err)
} }
mpgPaths = []string{concatPath} featureMpgPaths = []string{concatPath}
} }
// Log details about encoded MPG files // Log details about encoded MPG files
if logFn != nil { if logFn != nil {
logFn(fmt.Sprintf("Created %d MPEG file(s):", len(mpgPaths))) totalMpgs := len(featureMpgPaths) + len(extraMpgPaths)
for i, mpg := range mpgPaths { logFn(fmt.Sprintf("Created %d MPEG file(s):", totalMpgs))
for i, mpg := range featureMpgPaths {
if info, err := os.Stat(mpg); err == nil { if info, err := os.Stat(mpg); err == nil {
logFn(fmt.Sprintf(" %d. %s (%d bytes)", i+1, filepath.Base(mpg), info.Size())) logFn(fmt.Sprintf(" %d. %s (%d bytes)", i+1, filepath.Base(mpg), info.Size()))
} else { } else {
logFn(fmt.Sprintf(" %d. %s (stat failed: %v)", i+1, filepath.Base(mpg), err)) 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 // Build extras list for menu (title numbers start after main feature)
var menuButtons []dvdMenuButton // 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 { if createMenu {
template, ok := menuTemplates[menuTemplate] template, ok := menuTemplates[menuTemplate]
if !ok { if !ok {
template = &SimpleMenu{} template = &SimpleMenu{}
} }
menuMpg, menuButtons, err = buildDVDMenuAssets( menuSet, err = buildDVDMenuAssets(
ctx, ctx,
workDir, workDir,
title, title,
region, region,
aspect, aspect,
chapters, chapters,
extras,
logFn, logFn,
template, template,
menuBackgroundImage, menuBackgroundImage,
&MenuTheme{Name: menuTheme}, &MenuTheme{Name: menuTheme},
menuLogoOptions{ logos,
Enabled: menuLogoEnabled,
Path: menuLogoPath,
Position: menuLogoPosition,
Scale: menuLogoScale,
Margin: menuLogoMargin,
},
) )
if err != nil { if err != nil {
return err return err
@ -2664,7 +2854,7 @@ func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, reg
} }
xmlPath := filepath.Join(workDir, "dvd.xml") 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 return err
} }
@ -2902,6 +3092,7 @@ func (s *appState) executeAuthorJob(ctx context.Context, job *queue.Job, progres
DisplayName: toString(m["displayName"]), DisplayName: toString(m["displayName"]),
Duration: toFloat(m["duration"]), Duration: toFloat(m["duration"]),
ChapterTitle: toString(m["chapterTitle"]), 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["menuTemplate"]),
toString(cfg["menuBackgroundImage"]), toString(cfg["menuBackgroundImage"]),
toString(cfg["menuTheme"]), toString(cfg["menuTheme"]),
toBool(cfg["menuLogoEnabled"]), menuLogoOptions{
toString(cfg["menuLogoPath"]), TitleLogo: menuLogo{
toString(cfg["menuLogoPosition"]), Enabled: toBool(cfg["menuTitleLogoEnabled"]),
toFloat(cfg["menuLogoScale"]), Path: toString(cfg["menuTitleLogoPath"]),
int(toFloat(cfg["menuLogoMargin"])), 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, appendLog,
updateProgress, updateProgress,
) )
@ -3132,7 +3334,7 @@ func buildAuthorFFmpegArgs(inputPath, outputPath, region, aspect string, progres
return args 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) format := strings.ToLower(region)
if format != "pal" { if format != "pal" {
format = "ntsc" format = "ntsc"
@ -3140,32 +3342,79 @@ func writeDVDAuthorXML(path string, mpgPaths []string, region, aspect string, ch
var b strings.Builder var b strings.Builder
b.WriteString("<dvdauthor>\n") b.WriteString("<dvdauthor>\n")
if menuMpg != "" && len(menuButtons) > 0 { b.WriteString(" <vmgm />\n")
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(" <titleset>\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(" <titles>\n")
b.WriteString(fmt.Sprintf(" <video format=\"%s\" aspect=\"%s\" />\n", format, aspect)) 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") b.WriteString(" <pgc>\n")
if len(chapters) > 0 { if len(chapters) > 0 {
b.WriteString(fmt.Sprintf(" <vob file=\"%s\" chapters=\"%s\" />\n", escapeXMLAttr(mpg), chaptersToDVDAuthor(chapters))) b.WriteString(fmt.Sprintf(" <vob file=\"%s\" chapters=\"%s\" />\n", escapeXMLAttr(mpg), chaptersToDVDAuthor(chapters)))
} else { } else {
b.WriteString(fmt.Sprintf(" <vob file=\"%s\" />\n", escapeXMLAttr(mpg))) 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(" </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(" </titles>\n")
b.WriteString(" </titleset>\n") b.WriteString(" </titleset>\n")
b.WriteString("</dvdauthor>\n") b.WriteString("</dvdauthor>\n")