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