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:
parent
eaba5abe5f
commit
14c63d2def
542
author_menu.go
542
author_menu.go
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
569
author_module.go
569
author_module.go
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user