Add DVD menu tab with theme and logo controls
This commit is contained in:
parent
6ab73b859f
commit
46d8bd0f93
251
author_menu.go
251
author_menu.go
|
|
@ -21,9 +21,27 @@ type dvdMenuButton struct {
|
|||
Y1 int
|
||||
}
|
||||
|
||||
type menuTheme struct {
|
||||
Name string
|
||||
BackgroundColor string
|
||||
HeaderColor string
|
||||
TextColor string
|
||||
AccentColor string
|
||||
FontName string
|
||||
FontPath string
|
||||
}
|
||||
|
||||
type menuLogoOptions struct {
|
||||
Enabled bool
|
||||
Path string
|
||||
Position string
|
||||
Scale float64
|
||||
Margin int
|
||||
}
|
||||
|
||||
// MenuTemplate defines the interface for a DVD menu generator.
|
||||
type MenuTemplate interface {
|
||||
Generate(ctx context.Context, workDir, title, region, aspect string, chapters []authorChapter, backgroundImage string, logFn func(string)) (string, []dvdMenuButton, error)
|
||||
Generate(ctx context.Context, workDir, title, region, aspect string, chapters []authorChapter, backgroundImage string, theme *menuTheme, logo menuLogoOptions, logFn func(string)) (string, []dvdMenuButton, error)
|
||||
}
|
||||
|
||||
var menuTemplates = map[string]MenuTemplate{
|
||||
|
|
@ -32,11 +50,23 @@ var menuTemplates = map[string]MenuTemplate{
|
|||
"Poster": &PosterMenu{},
|
||||
}
|
||||
|
||||
var menuThemes = map[string]*menuTheme{
|
||||
"VideoTools": {
|
||||
Name: "VideoTools",
|
||||
BackgroundColor: "0x0f172a",
|
||||
HeaderColor: "0x1f2937",
|
||||
TextColor: "0xE1EEFF",
|
||||
AccentColor: "0x7c3aed",
|
||||
FontName: "IBM Plex Mono",
|
||||
FontPath: findMenuFontPath(),
|
||||
},
|
||||
}
|
||||
|
||||
// SimpleMenu is a basic menu template.
|
||||
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, 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)
|
||||
buttons := buildDVDMenuButtons(chapters, width, height)
|
||||
if len(buttons) == 0 {
|
||||
|
|
@ -59,12 +89,12 @@ func (t *SimpleMenu) Generate(ctx context.Context, workDir, title, region, aspec
|
|||
}
|
||||
|
||||
if backgroundImage == "" {
|
||||
if err := buildMenuBackground(ctx, bgPath, title, buttons, width, height); err != nil {
|
||||
if err := buildMenuBackground(ctx, bgPath, title, buttons, width, height, resolveMenuTheme(theme), logo); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := buildMenuOverlays(ctx, overlayPath, highlightPath, selectPath, buttons, width, height); err != nil {
|
||||
if err := buildMenuOverlays(ctx, overlayPath, highlightPath, selectPath, buttons, width, height, resolveMenuTheme(theme)); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if err := buildMenuMPEG(ctx, bgPath, menuMpg, region, aspect); err != nil {
|
||||
|
|
@ -86,7 +116,7 @@ func (t *SimpleMenu) Generate(ctx context.Context, workDir, title, region, aspec
|
|||
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, 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)
|
||||
buttons := buildDVDMenuButtons(chapters, width, height)
|
||||
if len(buttons) == 0 {
|
||||
|
|
@ -109,12 +139,12 @@ func (t *DarkMenu) Generate(ctx context.Context, workDir, title, region, aspect
|
|||
}
|
||||
|
||||
if backgroundImage == "" {
|
||||
if err := buildDarkMenuBackground(ctx, bgPath, title, buttons, width, height); err != nil {
|
||||
if err := buildDarkMenuBackground(ctx, bgPath, title, buttons, width, height, resolveMenuTheme(theme), logo); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := buildMenuOverlays(ctx, overlayPath, highlightPath, selectPath, buttons, width, height); err != nil {
|
||||
if err := buildMenuOverlays(ctx, overlayPath, highlightPath, selectPath, buttons, width, height, resolveMenuTheme(theme)); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if err := buildMenuMPEG(ctx, bgPath, menuMpg, region, aspect); err != nil {
|
||||
|
|
@ -136,7 +166,7 @@ func (t *DarkMenu) Generate(ctx context.Context, workDir, title, region, aspect
|
|||
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, 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)
|
||||
buttons := buildDVDMenuButtons(chapters, width, height)
|
||||
if len(buttons) == 0 {
|
||||
|
|
@ -158,11 +188,11 @@ func (t *PosterMenu) Generate(ctx context.Context, workDir, title, region, aspec
|
|||
logFn("Building DVD menu assets with PosterMenu template...")
|
||||
}
|
||||
|
||||
if err := buildPosterMenuBackground(ctx, bgPath, title, buttons, width, height, backgroundImage); err != nil {
|
||||
if err := buildPosterMenuBackground(ctx, bgPath, title, buttons, width, height, backgroundImage, resolveMenuTheme(theme), logo); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if err := buildMenuOverlays(ctx, overlayPath, highlightPath, selectPath, buttons, width, height); err != nil {
|
||||
if err := buildMenuOverlays(ctx, overlayPath, highlightPath, selectPath, buttons, width, height, resolveMenuTheme(theme)); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if err := buildMenuMPEG(ctx, bgPath, menuMpg, region, aspect); err != nil {
|
||||
|
|
@ -180,11 +210,11 @@ 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) (string, []dvdMenuButton, error) {
|
||||
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) {
|
||||
if template == nil {
|
||||
template = &SimpleMenu{}
|
||||
}
|
||||
return template.Generate(ctx, workDir, title, region, aspect, chapters, backgroundImage, logFn)
|
||||
return template.Generate(ctx, workDir, title, region, aspect, chapters, backgroundImage, resolveMenuTheme(theme), logo, logFn)
|
||||
}
|
||||
|
||||
func dvdMenuDimensions(region string) (int, int) {
|
||||
|
|
@ -232,55 +262,53 @@ func buildDVDMenuButtons(chapters []authorChapter, width, height int) []dvdMenuB
|
|||
return buttons
|
||||
}
|
||||
|
||||
func buildMenuBackground(ctx context.Context, outputPath, title string, buttons []dvdMenuButton, width, height int) error {
|
||||
logoPath := findVTLogoPath()
|
||||
if logoPath == "" {
|
||||
return fmt.Errorf("VT logo not found for menu rendering")
|
||||
}
|
||||
func buildMenuBackground(ctx context.Context, outputPath, title string, buttons []dvdMenuButton, width, height int, theme *menuTheme, logo menuLogoOptions) error {
|
||||
theme = resolveMenuTheme(theme)
|
||||
|
||||
safeTitle := utils.ShortenMiddle(strings.TrimSpace(title), 40)
|
||||
if safeTitle == "" {
|
||||
safeTitle = "DVD Menu"
|
||||
}
|
||||
|
||||
bgColor := "0x0f172a"
|
||||
headerColor := "0x1f2937"
|
||||
textColor := "white"
|
||||
accentColor := "0x7c3aed"
|
||||
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=font='DejaVu Sans Mono':fontcolor=%s:fontsize=28:x=36:y=20:text='%s'", textColor, escapeDrawtextText("VideoTools DVD")),
|
||||
fmt.Sprintf("drawtext=font='DejaVu Sans Mono':fontcolor=%s:fontsize=18:x=36:y=80:text='%s'", 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=font='DejaVu Sans Mono':fontcolor=%s:fontsize=16:x=36:y=122:text='%s'", 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=font='DejaVu Sans Mono':fontcolor=%s:fontsize=20:x=110:y=%d:text='%s'", 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),
|
||||
"-i", logoPath,
|
||||
"-filter_complex", fmt.Sprintf("[0:v]%s[bg];[1:v]scale=72:-1[logo];[bg][logo]overlay=W-w-36:18", filterChain),
|
||||
"-frames:v", "1",
|
||||
outputPath,
|
||||
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 := fmt.Sprintf("scale=iw*%.2f:ih*%.2f", resolveMenuLogoScale(logo), resolveMenuLogoScale(logo))
|
||||
args = append(args, "-i", logoPath)
|
||||
filterExpr = fmt.Sprintf("[0:v]%s[bg];[1:v]%s[logo];[bg][logo]overlay=%s", filterChain, scaleExpr, posExpr)
|
||||
}
|
||||
}
|
||||
args = append(args, "-filter_complex", filterExpr, "-frames:v", "1", outputPath)
|
||||
return runCommandWithLogger(ctx, utils.GetFFmpegPath(), args, nil)
|
||||
}
|
||||
|
||||
func buildDarkMenuBackground(ctx context.Context, outputPath, title string, buttons []dvdMenuButton, width, height int) error {
|
||||
logoPath := findVTLogoPath()
|
||||
if logoPath == "" {
|
||||
return fmt.Errorf("VT logo not found for menu rendering")
|
||||
}
|
||||
func buildDarkMenuBackground(ctx context.Context, outputPath, title string, buttons []dvdMenuButton, width, height int, theme *menuTheme, logo menuLogoOptions) error {
|
||||
theme = resolveMenuTheme(theme)
|
||||
|
||||
safeTitle := utils.ShortenMiddle(strings.TrimSpace(title), 40)
|
||||
if safeTitle == "" {
|
||||
|
|
@ -289,76 +317,89 @@ func buildDarkMenuBackground(ctx context.Context, outputPath, title string, butt
|
|||
|
||||
bgColor := "0x000000"
|
||||
headerColor := "0x111111"
|
||||
textColor := "white"
|
||||
accentColor := "0xeeeeee"
|
||||
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=font='DejaVu Sans Mono':fontcolor=%s:fontsize=28:x=36:y=20:text='%s'", textColor, escapeDrawtextText("VideoTools DVD")),
|
||||
fmt.Sprintf("drawtext=font='DejaVu Sans Mono':fontcolor=%s:fontsize=18:x=36:y=80:text='%s'", 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=font='DejaVu Sans Mono':fontcolor=%s:fontsize=16:x=36:y=122:text='%s'", 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=font='DejaVu Sans Mono':fontcolor=%s:fontsize=20:x=110:y=%d:text='%s'", 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),
|
||||
"-i", logoPath,
|
||||
"-filter_complex", fmt.Sprintf("[0:v]%s[bg];[1:v]scale=72:-1[logo];[bg][logo]overlay=W-w-36:18", filterChain),
|
||||
"-frames:v", "1",
|
||||
outputPath,
|
||||
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 := fmt.Sprintf("scale=iw*%.2f:ih*%.2f", resolveMenuLogoScale(logo), resolveMenuLogoScale(logo))
|
||||
args = append(args, "-i", logoPath)
|
||||
filterExpr = fmt.Sprintf("[0:v]%s[bg];[1:v]%s[logo];[bg][logo]overlay=%s", filterChain, scaleExpr, posExpr)
|
||||
}
|
||||
}
|
||||
args = append(args, "-filter_complex", filterExpr, "-frames:v", "1", outputPath)
|
||||
return runCommandWithLogger(ctx, utils.GetFFmpegPath(), args, nil)
|
||||
}
|
||||
|
||||
func buildPosterMenuBackground(ctx context.Context, outputPath, title string, buttons []dvdMenuButton, width, height int, backgroundImage string) error {
|
||||
func buildPosterMenuBackground(ctx context.Context, outputPath, title string, buttons []dvdMenuButton, width, height int, backgroundImage string, theme *menuTheme, logo menuLogoOptions) error {
|
||||
theme = resolveMenuTheme(theme)
|
||||
safeTitle := utils.ShortenMiddle(strings.TrimSpace(title), 40)
|
||||
if safeTitle == "" {
|
||||
safeTitle = "DVD Menu"
|
||||
}
|
||||
|
||||
textColor := "white"
|
||||
textColor := theme.TextColor
|
||||
fontArg := menuFontArg(theme)
|
||||
|
||||
filterParts := []string{
|
||||
fmt.Sprintf("drawtext=font='DejaVu Sans Mono':fontcolor=%s:fontsize=28:x=36:y=20:text='%s'", textColor, escapeDrawtextText("VideoTools DVD")),
|
||||
fmt.Sprintf("drawtext=font='DejaVu Sans Mono':fontcolor=%s:fontsize=18:x=36:y=80:text='%s'", 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=font='DejaVu Sans Mono':fontcolor=%s:fontsize=20:x=110:y=%d:text='%s'", 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,
|
||||
"-vf", fmt.Sprintf("scale=%d:%d,%s", width, height, filterChain),
|
||||
"-frames:v", "1",
|
||||
outputPath,
|
||||
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 := fmt.Sprintf("scale=iw*%.2f:ih*%.2f", resolveMenuLogoScale(logo), resolveMenuLogoScale(logo))
|
||||
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)
|
||||
}
|
||||
}
|
||||
args = append(args, "-filter_complex", filterExpr, "-frames:v", "1", outputPath)
|
||||
return runCommandWithLogger(ctx, utils.GetFFmpegPath(), args, nil)
|
||||
}
|
||||
|
||||
func buildMenuOverlays(ctx context.Context, overlayPath, highlightPath, selectPath string, buttons []dvdMenuButton, width, height int) error {
|
||||
func buildMenuOverlays(ctx context.Context, overlayPath, highlightPath, selectPath string, buttons []dvdMenuButton, width, height int, theme *menuTheme) error {
|
||||
theme = resolveMenuTheme(theme)
|
||||
accent := theme.AccentColor
|
||||
if err := buildMenuOverlay(ctx, overlayPath, buttons, width, height, "0x000000@0.0"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := buildMenuOverlay(ctx, highlightPath, buttons, width, height, "0xf59e0b@0.35"); err != nil {
|
||||
if err := buildMenuOverlay(ctx, highlightPath, buttons, width, height, fmt.Sprintf("%s@0.35", accent)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := buildMenuOverlay(ctx, selectPath, buttons, width, height, "0xf59e0b@0.65"); err != nil {
|
||||
if err := buildMenuOverlay(ctx, selectPath, buttons, width, height, fmt.Sprintf("%s@0.65", accent)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
|
@ -473,6 +514,84 @@ func findVTLogoPath() string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func findMenuFontPath() string {
|
||||
search := []string{
|
||||
filepath.Join("assets", "fonts", "IBMPlexMono-Regular.ttf"),
|
||||
}
|
||||
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 {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveMenuTheme(theme *menuTheme) *menuTheme {
|
||||
if theme == nil {
|
||||
return menuThemes["VideoTools"]
|
||||
}
|
||||
if theme.Name == "" {
|
||||
return menuThemes["VideoTools"]
|
||||
}
|
||||
if resolved, ok := menuThemes[theme.Name]; ok {
|
||||
return resolved
|
||||
}
|
||||
return menuThemes["VideoTools"]
|
||||
}
|
||||
|
||||
func menuFontArg(theme *menuTheme) string {
|
||||
if theme != nil && theme.FontPath != "" {
|
||||
return fmt.Sprintf("fontfile='%s'", theme.FontPath)
|
||||
}
|
||||
if theme != nil && theme.FontName != "" {
|
||||
return fmt.Sprintf("font='%s'", theme.FontName)
|
||||
}
|
||||
return "font='DejaVu Sans Mono'"
|
||||
}
|
||||
|
||||
func resolveMenuLogoPath(logo menuLogoOptions) string {
|
||||
if strings.TrimSpace(logo.Path) != "" {
|
||||
return logo.Path
|
||||
}
|
||||
return filepath.Join("assets", "logo", "VT_Logo.png")
|
||||
}
|
||||
|
||||
func resolveMenuLogoScale(logo menuLogoOptions) float64 {
|
||||
if logo.Scale <= 0 {
|
||||
return 1.0
|
||||
}
|
||||
if logo.Scale < 0.2 {
|
||||
return 0.2
|
||||
}
|
||||
if logo.Scale > 2.0 {
|
||||
return 2.0
|
||||
}
|
||||
return logo.Scale
|
||||
}
|
||||
|
||||
func resolveMenuLogoPosition(logo menuLogoOptions, width, height int) string {
|
||||
margin := logo.Margin
|
||||
if margin < 0 {
|
||||
margin = 0
|
||||
}
|
||||
switch logo.Position {
|
||||
case "Top Left":
|
||||
return fmt.Sprintf("%d:%d", margin, margin)
|
||||
case "Bottom Left":
|
||||
return fmt.Sprintf("%d:H-h-%d", margin, margin)
|
||||
case "Bottom Right":
|
||||
return fmt.Sprintf("W-w-%d:H-h-%d", margin, margin)
|
||||
case "Center":
|
||||
return "(W-w)/2:(H-h)/2"
|
||||
default:
|
||||
return fmt.Sprintf("W-w-%d:%d", margin, margin)
|
||||
}
|
||||
}
|
||||
|
||||
func escapeDrawtextText(text string) string {
|
||||
escaped := strings.ReplaceAll(text, "\\", "\\\\")
|
||||
escaped = strings.ReplaceAll(escaped, ":", "\\:")
|
||||
|
|
|
|||
385
author_module.go
385
author_module.go
|
|
@ -9,6 +9,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
|
@ -31,26 +32,42 @@ 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"`
|
||||
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"`
|
||||
MenuLogoEnabled bool `json:"menuLogoEnabled"`
|
||||
MenuLogoPath string `json:"menuLogoPath"`
|
||||
MenuLogoPosition string `json:"menuLogoPosition"`
|
||||
MenuLogoScale float64 `json:"menuLogoScale"`
|
||||
MenuLogoMargin int `json:"menuLogoMargin"`
|
||||
TreatAsChapters bool `json:"treatAsChapters"`
|
||||
SceneThreshold float64 `json:"sceneThreshold"`
|
||||
}
|
||||
|
||||
func defaultAuthorConfig() authorConfig {
|
||||
return authorConfig{
|
||||
OutputType: "dvd",
|
||||
Region: "AUTO",
|
||||
AspectRatio: "AUTO",
|
||||
DiscSize: "DVD5",
|
||||
Title: "",
|
||||
CreateMenu: false,
|
||||
TreatAsChapters: false,
|
||||
SceneThreshold: 0.3,
|
||||
OutputType: "dvd",
|
||||
Region: "AUTO",
|
||||
AspectRatio: "AUTO",
|
||||
DiscSize: "DVD5",
|
||||
Title: "",
|
||||
CreateMenu: false,
|
||||
MenuTemplate: "Simple",
|
||||
MenuTheme: "VideoTools",
|
||||
MenuBackgroundImage: "",
|
||||
MenuLogoEnabled: true,
|
||||
MenuLogoPath: "",
|
||||
MenuLogoPosition: "Top Right",
|
||||
MenuLogoScale: 1.0,
|
||||
MenuLogoMargin: 24,
|
||||
TreatAsChapters: false,
|
||||
SceneThreshold: 0.3,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -76,6 +93,21 @@ func loadPersistedAuthorConfig() (authorConfig, error) {
|
|||
if cfg.DiscSize == "" {
|
||||
cfg.DiscSize = "DVD5"
|
||||
}
|
||||
if cfg.MenuTemplate == "" {
|
||||
cfg.MenuTemplate = "Simple"
|
||||
}
|
||||
if cfg.MenuTheme == "" {
|
||||
cfg.MenuTheme = "VideoTools"
|
||||
}
|
||||
if cfg.MenuLogoPosition == "" {
|
||||
cfg.MenuLogoPosition = "Top Right"
|
||||
}
|
||||
if cfg.MenuLogoScale == 0 {
|
||||
cfg.MenuLogoScale = 1.0
|
||||
}
|
||||
if cfg.MenuLogoMargin == 0 {
|
||||
cfg.MenuLogoMargin = 24
|
||||
}
|
||||
if cfg.SceneThreshold <= 0 {
|
||||
cfg.SceneThreshold = 0.3
|
||||
}
|
||||
|
|
@ -101,21 +133,36 @@ func (s *appState) applyAuthorConfig(cfg authorConfig) {
|
|||
s.authorDiscSize = cfg.DiscSize
|
||||
s.authorTitle = cfg.Title
|
||||
s.authorCreateMenu = cfg.CreateMenu
|
||||
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.authorTreatAsChapters = cfg.TreatAsChapters
|
||||
s.authorSceneThreshold = cfg.SceneThreshold
|
||||
// MenuTemplate field doesn't exist in authorConfig struct - remove this line
|
||||
}
|
||||
|
||||
func (s *appState) persistAuthorConfig() {
|
||||
cfg := authorConfig{
|
||||
OutputType: s.authorOutputType,
|
||||
Region: s.authorRegion,
|
||||
AspectRatio: s.authorAspectRatio,
|
||||
DiscSize: s.authorDiscSize,
|
||||
Title: s.authorTitle,
|
||||
CreateMenu: s.authorCreateMenu,
|
||||
TreatAsChapters: s.authorTreatAsChapters,
|
||||
SceneThreshold: s.authorSceneThreshold,
|
||||
OutputType: s.authorOutputType,
|
||||
Region: s.authorRegion,
|
||||
AspectRatio: s.authorAspectRatio,
|
||||
DiscSize: s.authorDiscSize,
|
||||
Title: s.authorTitle,
|
||||
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,
|
||||
TreatAsChapters: s.authorTreatAsChapters,
|
||||
SceneThreshold: s.authorSceneThreshold,
|
||||
}
|
||||
if err := savePersistedAuthorConfig(cfg); err != nil {
|
||||
logging.Debug(logging.CatSystem, "failed to persist author config: %v", err)
|
||||
|
|
@ -143,6 +190,21 @@ func buildAuthorView(state *appState) fyne.CanvasObject {
|
|||
if state.authorDiscSize == "" {
|
||||
state.authorDiscSize = "DVD5"
|
||||
}
|
||||
if state.authorMenuTemplate == "" {
|
||||
state.authorMenuTemplate = "Simple"
|
||||
}
|
||||
if state.authorMenuTheme == "" {
|
||||
state.authorMenuTheme = "VideoTools"
|
||||
}
|
||||
if state.authorMenuLogoPosition == "" {
|
||||
state.authorMenuLogoPosition = "Top Right"
|
||||
}
|
||||
if state.authorMenuLogoScale == 0 {
|
||||
state.authorMenuLogoScale = 1.0
|
||||
}
|
||||
if state.authorMenuLogoMargin == 0 {
|
||||
state.authorMenuLogoMargin = 24
|
||||
}
|
||||
|
||||
authorColor := moduleColor("author")
|
||||
|
||||
|
|
@ -180,6 +242,7 @@ func buildAuthorView(state *appState) fyne.CanvasObject {
|
|||
container.NewTabItem("Videos", buildVideoClipsTab(state)),
|
||||
container.NewTabItem("Chapters", buildChaptersTab(state)),
|
||||
container.NewTabItem("Subtitles", buildSubtitlesTab(state)),
|
||||
container.NewTabItem("Menu", buildAuthorMenuTab(state)),
|
||||
container.NewTabItem("Settings", buildAuthorSettingsTab(state)),
|
||||
container.NewTabItem("Generate", buildAuthorDiscTab(state)),
|
||||
)
|
||||
|
|
@ -720,43 +783,6 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject {
|
|||
state.persistAuthorConfig()
|
||||
}
|
||||
|
||||
createMenuCheck := widget.NewCheck("Create DVD Menu", func(checked bool) {
|
||||
state.authorCreateMenu = checked
|
||||
state.updateAuthorSummary()
|
||||
state.persistAuthorConfig()
|
||||
})
|
||||
createMenuCheck.SetChecked(state.authorCreateMenu)
|
||||
|
||||
menuTemplateSelect := widget.NewSelect([]string{"Simple", "Dark", "Poster"}, func(value string) {
|
||||
state.authorMenuTemplate = value
|
||||
state.updateAuthorSummary()
|
||||
state.persistAuthorConfig()
|
||||
})
|
||||
menuTemplateSelect.SetSelected(state.authorMenuTemplate)
|
||||
|
||||
bgImageLabel := widget.NewLabel(state.authorMenuBackgroundImage)
|
||||
bgImageButton := widget.NewButton("Select Background Image", func() {
|
||||
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
|
||||
if err != nil || reader == nil {
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
state.authorMenuBackgroundImage = reader.URI().Path()
|
||||
bgImageLabel.SetText(state.authorMenuBackgroundImage)
|
||||
state.updateAuthorSummary()
|
||||
state.persistAuthorConfig()
|
||||
}, state.window)
|
||||
})
|
||||
bgImageButton.Importance = widget.HighImportance
|
||||
bgImageButton.Hidden = state.authorMenuTemplate != "Poster"
|
||||
|
||||
menuTemplateSelect.OnChanged = func(value string) {
|
||||
state.authorMenuTemplate = value
|
||||
bgImageButton.Hidden = value != "Poster"
|
||||
state.updateAuthorSummary()
|
||||
state.persistAuthorConfig()
|
||||
}
|
||||
|
||||
discSizeSelect := widget.NewSelect([]string{"DVD5", "DVD9"}, func(value string) {
|
||||
state.authorDiscSize = value
|
||||
state.updateAuthorSummary()
|
||||
|
|
@ -790,10 +816,6 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject {
|
|||
discSizeSelect.SetSelected(state.authorDiscSize)
|
||||
}
|
||||
titleEntry.SetText(state.authorTitle)
|
||||
createMenuCheck.SetChecked(state.authorCreateMenu)
|
||||
menuTemplateSelect.SetSelected(state.authorMenuTemplate)
|
||||
bgImageLabel.SetText(state.authorMenuBackgroundImage)
|
||||
bgImageButton.Hidden = state.authorMenuTemplate != "Poster"
|
||||
}
|
||||
|
||||
loadCfgBtn := widget.NewButton("Load Config", func() {
|
||||
|
|
@ -813,14 +835,22 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject {
|
|||
|
||||
saveCfgBtn := widget.NewButton("Save Config", func() {
|
||||
cfg := authorConfig{
|
||||
OutputType: state.authorOutputType,
|
||||
Region: state.authorRegion,
|
||||
AspectRatio: state.authorAspectRatio,
|
||||
DiscSize: state.authorDiscSize,
|
||||
Title: state.authorTitle,
|
||||
CreateMenu: state.authorCreateMenu,
|
||||
TreatAsChapters: state.authorTreatAsChapters,
|
||||
SceneThreshold: state.authorSceneThreshold,
|
||||
OutputType: state.authorOutputType,
|
||||
Region: state.authorRegion,
|
||||
AspectRatio: state.authorAspectRatio,
|
||||
DiscSize: state.authorDiscSize,
|
||||
Title: state.authorTitle,
|
||||
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,
|
||||
TreatAsChapters: state.authorTreatAsChapters,
|
||||
SceneThreshold: state.authorSceneThreshold,
|
||||
}
|
||||
if err := savePersistedAuthorConfig(cfg); err != nil {
|
||||
dialog.ShowError(fmt.Errorf("failed to save config: %w", err), state.window)
|
||||
|
|
@ -853,11 +883,6 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject {
|
|||
discSizeSelect,
|
||||
widget.NewLabel("DVD Title:"),
|
||||
titleEntry,
|
||||
createMenuCheck,
|
||||
widget.NewLabel("Menu Template:"),
|
||||
menuTemplateSelect,
|
||||
bgImageLabel,
|
||||
bgImageButton,
|
||||
widget.NewSeparator(),
|
||||
info,
|
||||
widget.NewSeparator(),
|
||||
|
|
@ -867,6 +892,156 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject {
|
|||
return container.NewPadded(controls)
|
||||
}
|
||||
|
||||
func buildAuthorMenuTab(state *appState) fyne.CanvasObject {
|
||||
createMenuCheck := widget.NewCheck("Enable DVD Menus", func(checked bool) {
|
||||
state.authorCreateMenu = checked
|
||||
state.updateAuthorSummary()
|
||||
state.persistAuthorConfig()
|
||||
})
|
||||
createMenuCheck.SetChecked(state.authorCreateMenu)
|
||||
|
||||
menuThemeSelect := widget.NewSelect([]string{"VideoTools"}, func(value string) {
|
||||
state.authorMenuTheme = value
|
||||
state.updateAuthorSummary()
|
||||
state.persistAuthorConfig()
|
||||
})
|
||||
if state.authorMenuTheme == "" {
|
||||
state.authorMenuTheme = "VideoTools"
|
||||
}
|
||||
menuThemeSelect.SetSelected(state.authorMenuTheme)
|
||||
|
||||
menuTemplateSelect := widget.NewSelect([]string{"Simple", "Dark", "Poster"}, func(value string) {
|
||||
state.authorMenuTemplate = value
|
||||
state.updateAuthorSummary()
|
||||
state.persistAuthorConfig()
|
||||
})
|
||||
if state.authorMenuTemplate == "" {
|
||||
state.authorMenuTemplate = "Simple"
|
||||
}
|
||||
menuTemplateSelect.SetSelected(state.authorMenuTemplate)
|
||||
|
||||
bgImageLabel := widget.NewLabel(state.authorMenuBackgroundImage)
|
||||
bgImageLabel.Wrapping = fyne.TextWrapWord
|
||||
bgImageButton := widget.NewButton("Select Background Image", func() {
|
||||
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
|
||||
if err != nil || reader == nil {
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
state.authorMenuBackgroundImage = reader.URI().Path()
|
||||
bgImageLabel.SetText(state.authorMenuBackgroundImage)
|
||||
state.updateAuthorSummary()
|
||||
state.persistAuthorConfig()
|
||||
}, state.window)
|
||||
})
|
||||
bgImageButton.Importance = widget.HighImportance
|
||||
bgImageButton.Hidden = state.authorMenuTemplate != "Poster"
|
||||
bgImageLabel.Hidden = state.authorMenuTemplate != "Poster"
|
||||
|
||||
menuTemplateSelect.OnChanged = func(value string) {
|
||||
state.authorMenuTemplate = value
|
||||
showPoster := value == "Poster"
|
||||
bgImageButton.Hidden = !showPoster
|
||||
bgImageLabel.Hidden = !showPoster
|
||||
state.updateAuthorSummary()
|
||||
state.persistAuthorConfig()
|
||||
}
|
||||
|
||||
logoEnableCheck := widget.NewCheck("Embed Logo", func(checked bool) {
|
||||
state.authorMenuLogoEnabled = checked
|
||||
state.updateAuthorSummary()
|
||||
state.persistAuthorConfig()
|
||||
})
|
||||
logoEnableCheck.SetChecked(state.authorMenuLogoEnabled)
|
||||
|
||||
logoLabel := widget.NewLabel(state.authorMenuLogoPath)
|
||||
logoLabel.Wrapping = fyne.TextWrapWord
|
||||
logoPickButton := widget.NewButton("Select Logo", func() {
|
||||
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
|
||||
if err != nil || reader == nil {
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
state.authorMenuLogoPath = reader.URI().Path()
|
||||
logoLabel.SetText(state.authorMenuLogoPath)
|
||||
state.updateAuthorSummary()
|
||||
state.persistAuthorConfig()
|
||||
}, state.window)
|
||||
})
|
||||
logoPickButton.Importance = widget.MediumImportance
|
||||
|
||||
logoPositionSelect := widget.NewSelect([]string{
|
||||
"Top Left",
|
||||
"Top Right",
|
||||
"Bottom Left",
|
||||
"Bottom Right",
|
||||
"Center",
|
||||
}, func(value string) {
|
||||
state.authorMenuLogoPosition = value
|
||||
state.persistAuthorConfig()
|
||||
})
|
||||
if state.authorMenuLogoPosition == "" {
|
||||
state.authorMenuLogoPosition = "Top Right"
|
||||
}
|
||||
logoPositionSelect.SetSelected(state.authorMenuLogoPosition)
|
||||
|
||||
if state.authorMenuLogoScale == 0 {
|
||||
state.authorMenuLogoScale = 1.0
|
||||
}
|
||||
scaleLabel := widget.NewLabel(fmt.Sprintf("Logo Scale: %.0f%%", state.authorMenuLogoScale*100))
|
||||
scaleSlider := widget.NewSlider(0.2, 2.0)
|
||||
scaleSlider.Step = 0.05
|
||||
scaleSlider.Value = state.authorMenuLogoScale
|
||||
scaleSlider.OnChanged = func(v float64) {
|
||||
state.authorMenuLogoScale = v
|
||||
scaleLabel.SetText(fmt.Sprintf("Logo Scale: %.0f%%", v*100))
|
||||
state.persistAuthorConfig()
|
||||
}
|
||||
|
||||
if state.authorMenuLogoMargin == 0 {
|
||||
state.authorMenuLogoMargin = 24
|
||||
}
|
||||
marginLabel := widget.NewLabel(fmt.Sprintf("Logo Margin: %dpx", state.authorMenuLogoMargin))
|
||||
marginSlider := widget.NewSlider(0, 60)
|
||||
marginSlider.Step = 2
|
||||
marginSlider.Value = float64(state.authorMenuLogoMargin)
|
||||
marginSlider.OnChanged = func(v float64) {
|
||||
state.authorMenuLogoMargin = int(math.Round(v))
|
||||
marginLabel.SetText(fmt.Sprintf("Logo Margin: %dpx", state.authorMenuLogoMargin))
|
||||
state.persistAuthorConfig()
|
||||
}
|
||||
|
||||
info := widget.NewLabel("DVD menus use the VideoTools theme and IBM Plex Mono. Menu settings apply only to disc authoring.")
|
||||
info.Wrapping = fyne.TextWrapWord
|
||||
|
||||
controls := container.NewVBox(
|
||||
widget.NewLabel("DVD Menu Settings:"),
|
||||
widget.NewSeparator(),
|
||||
createMenuCheck,
|
||||
widget.NewLabel("Theme:"),
|
||||
menuThemeSelect,
|
||||
widget.NewLabel("Template:"),
|
||||
menuTemplateSelect,
|
||||
bgImageLabel,
|
||||
bgImageButton,
|
||||
widget.NewSeparator(),
|
||||
logoEnableCheck,
|
||||
widget.NewLabel("Logo Path:"),
|
||||
logoLabel,
|
||||
logoPickButton,
|
||||
widget.NewLabel("Logo Position:"),
|
||||
logoPositionSelect,
|
||||
scaleLabel,
|
||||
scaleSlider,
|
||||
marginLabel,
|
||||
marginSlider,
|
||||
widget.NewSeparator(),
|
||||
info,
|
||||
)
|
||||
|
||||
return container.NewPadded(controls)
|
||||
}
|
||||
|
||||
func buildAuthorDiscTab(state *appState) fyne.CanvasObject {
|
||||
generateBtn := widget.NewButton("GENERATE DVD", func() {
|
||||
if len(state.authorClips) == 0 && state.authorFile == nil {
|
||||
|
|
@ -1781,6 +1956,12 @@ func (s *appState) addAuthorToQueue(paths []string, region, aspect, title, outpu
|
|||
"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,
|
||||
}
|
||||
|
||||
titleLabel := title
|
||||
|
|
@ -1842,7 +2023,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, 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, menuLogoEnabled bool, menuLogoPath, menuLogoPosition string, menuLogoScale float64, menuLogoMargin int, 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)
|
||||
|
|
@ -2028,7 +2209,25 @@ func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, reg
|
|||
if !ok {
|
||||
template = &SimpleMenu{}
|
||||
}
|
||||
menuMpg, menuButtons, err = buildDVDMenuAssets(ctx, workDir, title, region, aspect, chapters, logFn, template, menuBackgroundImage)
|
||||
menuMpg, menuButtons, err = buildDVDMenuAssets(
|
||||
ctx,
|
||||
workDir,
|
||||
title,
|
||||
region,
|
||||
aspect,
|
||||
chapters,
|
||||
logFn,
|
||||
template,
|
||||
menuBackgroundImage,
|
||||
&menuTheme{Name: menuTheme},
|
||||
menuLogoOptions{
|
||||
Enabled: menuLogoEnabled,
|
||||
Path: menuLogoPath,
|
||||
Position: menuLogoPosition,
|
||||
Scale: menuLogoScale,
|
||||
Margin: menuLogoMargin,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -2338,7 +2537,29 @@ func (s *appState) executeAuthorJob(ctx context.Context, job *queue.Job, progres
|
|||
}, false)
|
||||
}
|
||||
|
||||
err := s.runAuthoringPipeline(ctx, paths, region, aspect, title, outputPath, makeISO, clips, chapters, treatAsChapters, createMenu, toString(cfg["menuTemplate"]), toString(cfg["menuBackgroundImage"]), appendLog, updateProgress)
|
||||
err := s.runAuthoringPipeline(
|
||||
ctx,
|
||||
paths,
|
||||
region,
|
||||
aspect,
|
||||
title,
|
||||
outputPath,
|
||||
makeISO,
|
||||
clips,
|
||||
chapters,
|
||||
treatAsChapters,
|
||||
createMenu,
|
||||
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"])),
|
||||
appendLog,
|
||||
updateProgress,
|
||||
)
|
||||
if err != nil {
|
||||
friendly := authorFriendlyError(err)
|
||||
appendLog("ERROR: " + friendly)
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ type UnifiedPlayer struct {
|
|||
muted bool
|
||||
fullscreen bool
|
||||
previewMode bool
|
||||
paused bool // Playback paused state
|
||||
|
||||
// Video info
|
||||
videoInfo *VideoInfo
|
||||
|
|
@ -136,12 +137,8 @@ func (p *UnifiedPlayer) Load(path string, offset time.Duration) error {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO: wire up FFmpeg process startup and pipe handling.
|
||||
p.state = StateStopped
|
||||
if p.stateCallback != nil {
|
||||
p.stateCallback(p.state)
|
||||
}
|
||||
return nil
|
||||
// Start FFmpeg process for unified A/V output
|
||||
return p.startVideoProcess()
|
||||
}
|
||||
|
||||
// SeekToTime seeks to a specific time without restarting processes
|
||||
|
|
@ -209,6 +206,19 @@ func (p *UnifiedPlayer) GetDuration() time.Duration {
|
|||
return p.duration
|
||||
}
|
||||
|
||||
// GetFrameImage reads and returns the current video frame as an RGBA image
|
||||
// This is the main method for getting video frames to display in the UI
|
||||
func (p *UnifiedPlayer) GetFrameImage() (*image.RGBA, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.state != StatePlaying || p.paused {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return p.readVideoFrame()
|
||||
}
|
||||
|
||||
// GetFrameRate returns the video frame rate
|
||||
func (p *UnifiedPlayer) GetFrameRate() float64 {
|
||||
p.mu.RLock()
|
||||
|
|
@ -400,16 +410,115 @@ func (p *UnifiedPlayer) Stop() error {
|
|||
_ = p.cmd.Process.Kill()
|
||||
}
|
||||
p.state = StateStopped
|
||||
p.paused = false
|
||||
if p.stateCallback != nil {
|
||||
p.stateCallback(p.state)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Play starts or resumes video playback
|
||||
func (p *UnifiedPlayer) Play() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.state == StateStopped {
|
||||
// Need to load first
|
||||
return fmt.Errorf("no video loaded")
|
||||
}
|
||||
|
||||
p.paused = false
|
||||
p.state = StatePlaying
|
||||
p.syncClock = time.Now()
|
||||
|
||||
logging.Debug(logging.CatPlayer, "UnifiedPlayer: Play() called, state=%v", p.state)
|
||||
|
||||
if p.stateCallback != nil {
|
||||
p.stateCallback(p.state)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pause pauses video playback
|
||||
func (p *UnifiedPlayer) Pause() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.state != StatePlaying {
|
||||
return nil // Already paused or stopped
|
||||
}
|
||||
|
||||
p.paused = true
|
||||
p.state = StatePaused
|
||||
|
||||
logging.Debug(logging.CatPlayer, "UnifiedPlayer: Pause() called, state=%v", p.state)
|
||||
|
||||
if p.stateCallback != nil {
|
||||
p.stateCallback(p.state)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsPaused returns whether playback is paused
|
||||
func (p *UnifiedPlayer) IsPaused() bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.paused
|
||||
}
|
||||
|
||||
// IsPlaying returns whether playback is active
|
||||
func (p *UnifiedPlayer) IsPlaying() bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.state == StatePlaying && !p.paused
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
// startVideoProcess starts the video processing goroutine
|
||||
// startVideoProcess starts the video processing goroutine and FFmpeg process
|
||||
func (p *UnifiedPlayer) startVideoProcess() error {
|
||||
// Build FFmpeg command for unified A/V output
|
||||
args := []string{
|
||||
"-hide_banner", "-loglevel", "error",
|
||||
"-ss", fmt.Sprintf("%.3f", p.currentTime.Seconds()),
|
||||
"-i", p.currentPath,
|
||||
// Video stream to pipe 4
|
||||
"-map", "0:v:0",
|
||||
"-f", "rawvideo",
|
||||
"-pix_fmt", "rgb24",
|
||||
"-r", "24", // We'll detect actual framerate
|
||||
"pipe:4",
|
||||
// Audio stream to pipe 5
|
||||
"-map", "0:a:0",
|
||||
"-ac", "2",
|
||||
"-ar", "48000",
|
||||
"-f", "s16le",
|
||||
"pipe:5",
|
||||
}
|
||||
|
||||
// Add hardware acceleration if available
|
||||
if p.config.HardwareAccel {
|
||||
if args = p.addHardwareAcceleration(args); args != nil {
|
||||
logging.Debug(logging.CatPlayer, "Hardware acceleration enabled: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
// Create FFmpeg command
|
||||
cmd := utils.CreateCommandRaw(utils.GetFFmpegPath(), args...)
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = p.videoPipeWriter
|
||||
cmd.Stderr = nil // We'll handle errors through logging
|
||||
|
||||
// Start FFmpeg process
|
||||
if err := cmd.Start(); err != nil {
|
||||
logging.Error(logging.CatPlayer, "Failed to start FFmpeg: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Store command reference
|
||||
p.cmd = cmd
|
||||
|
||||
// Start video frame reading goroutine
|
||||
go func() {
|
||||
frameDuration := time.Second / time.Duration(p.frameRate)
|
||||
frameTime := p.syncClock
|
||||
|
|
@ -493,27 +602,51 @@ func (p *UnifiedPlayer) readAudioStream() {
|
|||
|
||||
// readVideoStream reads video frames from the video pipe
|
||||
func (p *UnifiedPlayer) readVideoFrame() (*image.RGBA, error) {
|
||||
// Read RGB24 frame data
|
||||
frameSize := p.windowW * p.windowH * 3 // RGB24 = 3 bytes per pixel
|
||||
frameData := make([]byte, frameSize)
|
||||
n, err := p.videoPipeReader.Read(frameData)
|
||||
|
||||
if err != nil && err.Error() != "EOF" {
|
||||
return nil, fmt.Errorf("video read error: %w", err)
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
// Check if paused - skip reading frames while paused
|
||||
if p.paused {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Get frame from pool
|
||||
img := p.frameBuffer.Get().(*image.RGBA)
|
||||
img.Pix = make([]uint8, frameSize)
|
||||
img.Stride = p.windowW * 3
|
||||
img.Rect = image.Rect(0, 0, p.windowW, p.windowH)
|
||||
// Read RGB24 frame data from FFmpeg pipe
|
||||
frameSize := p.windowW * p.windowH * 3 // RGB24 = 3 bytes per pixel
|
||||
frameData := make([]byte, frameSize)
|
||||
|
||||
// Copy RGB data to image
|
||||
copy(img.Pix, frameData[:frameSize])
|
||||
// Read full frame - io.ReadFull ensures we get the complete frame
|
||||
n, err := io.ReadFull(p.videoPipeReader, frameData)
|
||||
if err != nil {
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
return nil, nil // End of stream
|
||||
}
|
||||
return nil, fmt.Errorf("video read error: %w", err)
|
||||
}
|
||||
|
||||
if n != frameSize {
|
||||
return nil, fmt.Errorf("incomplete frame: got %d bytes, expected %d", n, frameSize)
|
||||
}
|
||||
|
||||
// Create RGBA image (Fyne requires RGBA, not RGB)
|
||||
img := image.NewRGBA(image.Rect(0, 0, p.windowW, p.windowH))
|
||||
|
||||
// Convert RGB24 to RGBA (add alpha channel)
|
||||
for y := 0; y < p.windowH; y++ {
|
||||
for x := 0; x < p.windowW; x++ {
|
||||
srcIdx := (y*p.windowW + x) * 3
|
||||
dstIdx := (y*p.windowW + x) * 4
|
||||
|
||||
img.Pix[dstIdx+0] = frameData[srcIdx+0] // R
|
||||
img.Pix[dstIdx+1] = frameData[srcIdx+1] // G
|
||||
img.Pix[dstIdx+2] = frameData[srcIdx+2] // B
|
||||
img.Pix[dstIdx+3] = 255 // A (fully opaque)
|
||||
}
|
||||
}
|
||||
|
||||
// Update frame counter
|
||||
p.currentFrame++
|
||||
|
||||
// Notify time callback
|
||||
if p.timeCallback != nil {
|
||||
p.timeCallback(p.currentTime)
|
||||
}
|
||||
|
||||
return img, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ func (p *UnifiedPlayerAdapter) Play() {
|
|||
}
|
||||
|
||||
if p.paused {
|
||||
// Start playback if not already started
|
||||
// Load video if not already loaded
|
||||
if p.current == 0 {
|
||||
err := p.player.Load(p.path, 0)
|
||||
if err != nil {
|
||||
|
|
@ -121,9 +121,15 @@ func (p *UnifiedPlayerAdapter) Play() {
|
|||
}
|
||||
}
|
||||
|
||||
// Start playback in UnifiedPlayer
|
||||
if err := p.player.Play(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
p.paused = false
|
||||
p.startTime = time.Now().Add(-time.Duration(p.current * float64(time.Second)))
|
||||
p.startUpdateLoop()
|
||||
p.startFrameDisplayLoop()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -132,6 +138,9 @@ func (p *UnifiedPlayerAdapter) Pause() {
|
|||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.player != nil {
|
||||
p.player.Pause()
|
||||
}
|
||||
p.paused = true
|
||||
p.stopUpdateLoop()
|
||||
}
|
||||
|
|
@ -304,6 +313,39 @@ func (p *UnifiedPlayerAdapter) stopUpdateLoop() {
|
|||
}
|
||||
}
|
||||
|
||||
// startFrameDisplayLoop starts the loop that reads frames and displays them
|
||||
func (p *UnifiedPlayerAdapter) startFrameDisplayLoop() {
|
||||
if p.player == nil || p.img == nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
// Display at frame rate
|
||||
frameDuration := time.Second / time.Duration(p.fps)
|
||||
ticker := time.NewTicker(frameDuration)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.mu.Lock()
|
||||
if !p.paused && p.player != nil {
|
||||
// Get frame from UnifiedPlayer
|
||||
frame, err := p.player.GetFrameImage()
|
||||
if err == nil && frame != nil {
|
||||
// Update the Fyne canvas image
|
||||
p.img.Image = frame
|
||||
p.img.Refresh()
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// GetVideoFrame returns the current video frame for display
|
||||
func (p *UnifiedPlayerAdapter) GetVideoFrame() *image.RGBA {
|
||||
p.mu.Lock()
|
||||
|
|
@ -313,16 +355,18 @@ func (p *UnifiedPlayerAdapter) GetVideoFrame() *image.RGBA {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Create a placeholder frame for now
|
||||
// In full implementation, this would get frame from UnifiedPlayer
|
||||
rect := image.Rect(0, 0, p.targetW, p.targetH)
|
||||
frame := image.NewRGBA(rect)
|
||||
|
||||
// Fill with black background
|
||||
for y := 0; y < p.targetH; y++ {
|
||||
for x := 0; x < p.targetW; x++ {
|
||||
frame.SetRGBA(x, y, color.RGBA{0, 0, 0, 255})
|
||||
// Get real frame from UnifiedPlayer
|
||||
frame, err := p.player.GetFrameImage()
|
||||
if err != nil || frame == nil {
|
||||
// Return black frame on error
|
||||
rect := image.Rect(0, 0, p.targetW, p.targetH)
|
||||
blackFrame := image.NewRGBA(rect)
|
||||
for y := 0; y < p.targetH; y++ {
|
||||
for x := 0; x < p.targetW; x++ {
|
||||
blackFrame.SetRGBA(x, y, color.RGBA{0, 0, 0, 255})
|
||||
}
|
||||
}
|
||||
return blackFrame
|
||||
}
|
||||
|
||||
return frame
|
||||
|
|
|
|||
18
main.go
18
main.go
|
|
@ -597,6 +597,17 @@ func (s *appState) showAbout() {
|
|||
copyRow := container.NewBorder(nil, nil, nil, copyBtn, btcLabel)
|
||||
addressLabel := widget.NewLabel(btcAddress)
|
||||
|
||||
// X (Twitter) account
|
||||
xURL := "https://x.com/VT_VideoTools"
|
||||
xLabel := widget.NewLabel("X: @VT_VideoTools")
|
||||
xBtn := widget.NewButton("Open", func() {
|
||||
if err := openURL(xURL); err != nil {
|
||||
dialog.ShowError(fmt.Errorf("failed to open X profile: %w", err), s.window)
|
||||
}
|
||||
})
|
||||
xBtn.Importance = widget.LowImportance
|
||||
xRow := container.NewHBox(xLabel, xBtn)
|
||||
|
||||
mainContent := container.NewVBox(
|
||||
versionText,
|
||||
devText,
|
||||
|
|
@ -604,6 +615,7 @@ func (s *appState) showAbout() {
|
|||
widget.NewLabel("Support Development"),
|
||||
copyRow,
|
||||
addressLabel,
|
||||
xRow,
|
||||
feedbackLabel,
|
||||
)
|
||||
|
||||
|
|
@ -1128,6 +1140,12 @@ type appState struct {
|
|||
authorCreateMenu bool // Whether to create DVD menu
|
||||
authorMenuTemplate string // "Simple", "Dark", "Poster"
|
||||
authorMenuBackgroundImage string // Path to a user-selected background image
|
||||
authorMenuTheme string // "VideoTools"
|
||||
authorMenuLogoEnabled bool
|
||||
authorMenuLogoPath string // Path to menu logo image
|
||||
authorMenuLogoPosition string // "Top Left", "Top Right", "Bottom Left", "Bottom Right", "Center"
|
||||
authorMenuLogoScale float64
|
||||
authorMenuLogoMargin int
|
||||
authorTitle string // DVD title
|
||||
authorSubtitles []string // Subtitle file paths
|
||||
authorAudioTracks []string // Additional audio tracks
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user