VideoTools/author_module.go
Stu Leak f655c0199d fix(author): sanitize output filenames at job creation time
Added explicit sanitization of output paths when creating authoring jobs
to ensure special characters are removed before any filesystem operations.

The sanitization now happens in two places:
1. addAuthorToQueue: Sanitizes the output path for regular authoring jobs
2. addAuthorVideoTSToQueue: Sanitizes the output path for VIDEO_TS->ISO jobs

This ensures that:
- OutputFile field in Job struct never contains special characters
- Log filenames are sanitized (using the OutputFile basename)
- All filesystem operations use clean filenames

Before: /path/ifuckedmybestfriend'sgirlfriend.iso
After:  /path/ifuckedmybestfriendsgirlfriend.iso

The display title shown to users still contains the original text,
but all actual file operations use sanitized names.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 23:49:19 -05:00

3488 lines
96 KiB
Go

package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"image"
"image/color"
"io"
"math"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
"git.leaktechnologies.dev/stu/VideoTools/internal/logging"
"git.leaktechnologies.dev/stu/VideoTools/internal/queue"
"git.leaktechnologies.dev/stu/VideoTools/internal/ui"
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
)
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"`
}
func defaultAuthorConfig() authorConfig {
return authorConfig{
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,
MenuStructure: "Feature + Chapters",
MenuExtrasEnabled: false,
MenuChapterThumbSrc: "Auto",
TreatAsChapters: false,
SceneThreshold: 0.3,
}
}
func loadPersistedAuthorConfig() (authorConfig, error) {
var cfg authorConfig
path := moduleConfigPath("author")
data, err := os.ReadFile(path)
if err != nil {
return cfg, err
}
if err := json.Unmarshal(data, &cfg); err != nil {
return cfg, err
}
if cfg.OutputType == "" {
cfg.OutputType = "dvd"
}
if cfg.Region == "" {
cfg.Region = "AUTO"
}
if cfg.AspectRatio == "" {
cfg.AspectRatio = "AUTO"
}
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.MenuStructure == "" {
cfg.MenuStructure = "Feature + Chapters"
}
if cfg.MenuChapterThumbSrc == "" {
cfg.MenuChapterThumbSrc = "Auto"
}
if cfg.SceneThreshold <= 0 {
cfg.SceneThreshold = 0.3
}
return cfg, nil
}
func savePersistedAuthorConfig(cfg authorConfig) error {
path := moduleConfigPath("author")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
func (s *appState) applyAuthorConfig(cfg authorConfig) {
s.authorOutputType = cfg.OutputType
s.authorRegion = cfg.Region
s.authorAspectRatio = cfg.AspectRatio
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.authorMenuStructure = cfg.MenuStructure
s.authorMenuExtrasEnabled = cfg.MenuExtrasEnabled
s.authorMenuChapterThumbSrc = cfg.MenuChapterThumbSrc
s.authorTreatAsChapters = cfg.TreatAsChapters
s.authorSceneThreshold = cfg.SceneThreshold
}
func (s *appState) persistAuthorConfig() {
cfg := authorConfig{
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,
MenuStructure: s.authorMenuStructure,
MenuExtrasEnabled: s.authorMenuExtrasEnabled,
MenuChapterThumbSrc: s.authorMenuChapterThumbSrc,
TreatAsChapters: s.authorTreatAsChapters,
SceneThreshold: s.authorSceneThreshold,
}
if err := savePersistedAuthorConfig(cfg); err != nil {
logging.Debug(logging.CatSystem, "failed to persist author config: %v", err)
}
}
func buildAuthorView(state *appState) fyne.CanvasObject {
state.stopPreview()
state.lastModule = state.active
state.active = "author"
if cfg, err := loadPersistedAuthorConfig(); err == nil {
state.applyAuthorConfig(cfg)
}
if state.authorOutputType == "" {
state.authorOutputType = "dvd"
}
if state.authorRegion == "" {
state.authorRegion = "AUTO"
}
if state.authorAspectRatio == "" {
state.authorAspectRatio = "AUTO"
}
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
}
if state.authorMenuStructure == "" {
state.authorMenuStructure = "Feature + Chapters"
}
if state.authorMenuChapterThumbSrc == "" {
state.authorMenuChapterThumbSrc = "Auto"
}
authorColor := moduleColor("author")
backBtn := widget.NewButton("< BACK", func() {
state.showMainMenu()
})
backBtn.Importance = widget.LowImportance
queueBtn := widget.NewButton("View Queue", func() {
state.showQueue()
})
state.queueBtn = queueBtn
state.updateQueueButtonLabel()
clearCompletedBtn := widget.NewButton("⌫", func() {
state.clearCompletedJobs()
})
clearCompletedBtn.Importance = widget.LowImportance
cancelBtn := widget.NewButton("Cancel Job", func() {
if state.jobQueue != nil {
if job := state.jobQueue.CurrentRunning(); job != nil && job.Type == queue.JobTypeAuthor {
state.jobQueue.Cancel(job.ID)
}
}
})
cancelBtn.Importance = widget.DangerImportance
state.authorCancelBtn = cancelBtn
state.updateAuthorCancelButton()
topBar := ui.TintedBar(authorColor, container.NewHBox(backBtn, layout.NewSpacer(), cancelBtn, clearCompletedBtn, queueBtn))
bottomBar := moduleFooter(authorColor, layout.NewSpacer(), state.statsBar)
tabs := container.NewAppTabs(
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)),
)
tabs.SetTabLocation(container.TabLocationTop)
return container.NewBorder(topBar, bottomBar, nil, nil, tabs)
}
func buildVideoClipsTab(state *appState) fyne.CanvasObject {
state.authorVideoTSPath = strings.TrimSpace(state.authorVideoTSPath)
list := container.NewVBox()
listScroll := container.NewVScroll(list)
var rebuildList func()
var emptyOverlay *fyne.Container
rebuildList = func() {
list.Objects = nil
// Show VIDEO_TS folder if loaded
if state.authorVideoTSPath != "" {
if emptyOverlay != nil {
emptyOverlay.Hide()
}
videoTSLabel := widget.NewLabel("VIDEO_TS Folder:")
videoTSLabel.TextStyle = fyne.TextStyle{Bold: true}
pathLabel := widget.NewLabel(state.authorVideoTSPath)
pathLabel.Wrapping = fyne.TextWrapBreak
removeBtn := widget.NewButton("Remove", func() {
state.authorVideoTSPath = ""
state.authorOutputType = "dvd"
rebuildList()
state.updateAuthorSummary()
})
removeBtn.Importance = widget.MediumImportance
infoLabel := widget.NewLabel("This VIDEO_TS folder will be burned directly to ISO without re-encoding.")
infoLabel.TextStyle = fyne.TextStyle{Italic: true}
infoLabel.Wrapping = fyne.TextWrapWord
row := container.NewBorder(
nil,
nil,
nil,
removeBtn,
container.NewVBox(videoTSLabel, pathLabel, infoLabel),
)
cardBg := canvas.NewRectangle(utils.MustHex("#171C2A"))
cardBg.CornerRadius = 6
cardBg.SetMinSize(fyne.NewSize(0, videoTSLabel.MinSize().Height+pathLabel.MinSize().Height+infoLabel.MinSize().Height+20))
list.Add(container.NewPadded(container.NewMax(cardBg, row)))
list.Refresh()
return
}
if len(state.authorClips) == 0 {
if emptyOverlay != nil {
emptyOverlay.Show()
}
list.Refresh()
return
}
if emptyOverlay != nil {
emptyOverlay.Hide()
}
for i, clip := range state.authorClips {
idx := i
nameLabel := widget.NewLabel(clip.DisplayName)
nameLabel.TextStyle = fyne.TextStyle{Bold: true}
durationLabel := widget.NewLabel(fmt.Sprintf("%.2fs", clip.Duration))
durationLabel.TextStyle = fyne.TextStyle{Italic: true}
durationLabel.Alignment = fyne.TextAlignTrailing
titleEntry := widget.NewEntry()
titleEntry.SetPlaceHolder(fmt.Sprintf("Chapter %d", idx+1))
titleEntry.SetText(clip.ChapterTitle)
titleEntry.OnChanged = func(val string) {
state.authorClips[idx].ChapterTitle = val
if state.authorTreatAsChapters {
state.authorChapters = chaptersFromClips(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
removeBtn := widget.NewButton("Remove", func() {
state.authorClips = append(state.authorClips[:idx], state.authorClips[idx+1:]...)
rebuildList()
state.updateAuthorSummary()
})
removeBtn.Importance = widget.MediumImportance
row := container.NewBorder(
nil,
nil,
nil,
container.NewVBox(durationLabel, removeBtn),
container.NewVBox(nameLabel, titleEntry, noteLabel),
)
cardBg := canvas.NewRectangle(utils.MustHex("#171C2A"))
cardBg.CornerRadius = 6
cardBg.SetMinSize(fyne.NewSize(0, nameLabel.MinSize().Height+durationLabel.MinSize().Height+12))
list.Add(container.NewPadded(container.NewMax(cardBg, row)))
}
list.Refresh()
}
addBtn := widget.NewButton("Add Files", func() {
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
if err != nil || reader == nil {
return
}
defer reader.Close()
state.addAuthorFiles([]string{reader.URI().Path()})
rebuildList()
}, state.window)
})
addBtn.Importance = widget.HighImportance
clearBtn := widget.NewButton("Clear All", func() {
state.authorClips = []authorClip{}
state.authorChapters = nil
state.authorChapterSource = ""
state.authorVideoTSPath = ""
state.authorTitle = ""
rebuildList()
state.updateAuthorSummary()
})
clearBtn.Importance = widget.MediumImportance
addQueueBtn := widget.NewButton("Add to Queue", func() {
if len(state.authorClips) == 0 {
dialog.ShowInformation("No Clips", "Please add video clips first", state.window)
return
}
state.startAuthorGeneration(false)
})
addQueueBtn.Importance = widget.MediumImportance
compileBtn := widget.NewButton("COMPILE TO DVD", func() {
if len(state.authorClips) == 0 {
dialog.ShowInformation("No Clips", "Please add video clips first", state.window)
return
}
state.startAuthorGeneration(true)
})
compileBtn.Importance = widget.HighImportance
chapterToggle := widget.NewCheck("Treat videos as chapters", func(checked bool) {
state.authorTreatAsChapters = checked
if checked {
state.authorChapters = chaptersFromClips(state.authorClips)
state.authorChapterSource = "clips"
} else if state.authorChapterSource == "clips" {
state.authorChapterSource = ""
state.authorChapters = nil
}
state.updateAuthorSummary()
state.persistAuthorConfig()
if state.authorChaptersRefresh != nil {
state.authorChaptersRefresh()
}
})
chapterToggle.SetChecked(state.authorTreatAsChapters)
dropTarget := ui.NewDroppable(listScroll, func(items []fyne.URI) {
var paths []string
for _, uri := range items {
if uri.Scheme() == "file" {
paths = append(paths, uri.Path())
}
}
if len(paths) > 0 {
state.addAuthorFiles(paths)
rebuildList()
}
})
emptyLabel := widget.NewLabel("Drag and drop video files or VIDEO_TS folder here\nor click 'Add Files' to select videos")
emptyLabel.Alignment = fyne.TextAlignCenter
emptyOverlay = container.NewCenter(emptyLabel)
listArea := container.NewMax(dropTarget, emptyOverlay)
// DVD Title entry (synced with Settings tab)
dvdTitleEntry := widget.NewEntry()
dvdTitleEntry.SetPlaceHolder("DVD Title")
dvdTitleEntry.SetText(state.authorTitle)
dvdTitleEntry.OnChanged = func(value string) {
state.authorTitle = value
state.updateAuthorSummary()
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,
nil,
listArea,
)
rebuildList()
return container.NewPadded(controls)
}
func buildChaptersTab(state *appState) fyne.CanvasObject {
var fileLabel *widget.Label
if state.authorFile != nil {
fileLabel = widget.NewLabel(fmt.Sprintf("File: %s", filepath.Base(state.authorFile.Path)))
fileLabel.TextStyle = fyne.TextStyle{Bold: true}
} else {
fileLabel = widget.NewLabel("Select a single video file or use clips from Videos tab")
}
chapterList := container.NewVBox()
sourceLabel := widget.NewLabel("")
refreshChapters := func() {
chapterList.Objects = nil
sourceLabel.SetText("")
if len(state.authorChapters) == 0 {
if state.authorTreatAsChapters && len(state.authorClips) > 1 {
state.authorChapters = chaptersFromClips(state.authorClips)
state.authorChapterSource = "clips"
}
}
if len(state.authorChapters) == 0 {
chapterList.Add(widget.NewLabel("No chapters detected yet"))
return
}
switch state.authorChapterSource {
case "clips":
sourceLabel.SetText("Source: Video clips (treat as chapters)")
case "embedded":
sourceLabel.SetText("Source: Embedded chapters")
case "scenes":
sourceLabel.SetText("Source: Scene detection")
case "videots":
sourceLabel.SetText("Source: VIDEO_TS chapters")
default:
sourceLabel.SetText("Source: Chapters")
}
for i, ch := range state.authorChapters {
title := ch.Title
if title == "" {
title = fmt.Sprintf("Chapter %d", i+1)
}
chapterList.Add(widget.NewLabel(fmt.Sprintf("%02d. %s (%s)", i+1, title, formatChapterTime(ch.Timestamp))))
}
}
state.authorChaptersRefresh = refreshChapters
selectBtn := widget.NewButton("Select Video", func() {
dialog.ShowFileOpen(func(uc fyne.URIReadCloser, err error) {
if err != nil || uc == nil {
return
}
defer uc.Close()
path := uc.URI().Path()
src, err := probeVideo(path)
if err != nil {
dialog.ShowError(fmt.Errorf("failed to load video: %w", err), state.window)
return
}
state.authorFile = src
fileLabel.SetText(fmt.Sprintf("File: %s", filepath.Base(src.Path)))
// Clear the custom title so it can be re-derived from the new content.
// This addresses the user's request for the title to "reset".
state.authorTitle = ""
state.updateAuthorSummary()
// Update the UI for the title entry if the settings tab is currently visible.
if state.active == "author" && state.window.Canvas() != nil {
app := fyne.CurrentApp()
if app != nil && app.Driver() != nil {
app.Driver().DoFromGoroutine(func() {
state.showAuthorView() // Rebuild the module to refresh titleEntry
}, false)
}
}
state.loadEmbeddedChapters(path)
refreshChapters()
}, state.window)
})
thresholdLabel := widget.NewLabel(fmt.Sprintf("Detection Sensitivity: %.2f", state.authorSceneThreshold))
thresholdSlider := widget.NewSlider(0.1, 0.9)
thresholdSlider.Value = state.authorSceneThreshold
thresholdSlider.Step = 0.05
thresholdSlider.OnChanged = func(v float64) {
state.authorSceneThreshold = v
thresholdLabel.SetText(fmt.Sprintf("Detection Sensitivity: %.2f", v))
state.persistAuthorConfig()
}
detectBtn := widget.NewButton("Detect Scenes", func() {
targetPath := ""
if state.authorFile != nil {
targetPath = state.authorFile.Path
} else if len(state.authorClips) > 0 {
targetPath = state.authorClips[0].Path
}
if targetPath == "" {
dialog.ShowInformation("No File", "Please select a video file first", state.window)
return
}
progress := dialog.NewProgressInfinite("Scene Detection", "Analyzing scene changes with FFmpeg...", state.window)
progress.Show()
state.authorDetecting = true
go func() {
chapters, err := detectSceneChapters(targetPath, state.authorSceneThreshold)
runOnUI(func() {
progress.Hide()
state.authorDetecting = false
if err != nil {
dialog.ShowError(err, state.window)
return
}
if len(chapters) == 0 {
dialog.ShowInformation("Scene Detection", "No scene changes detected at the current sensitivity.", state.window)
return
}
// Show chapter preview dialog for visual verification
state.showChapterPreview(targetPath, chapters, func(accepted bool) {
if accepted {
state.authorChapters = chapters
state.authorChapterSource = "scenes"
state.updateAuthorSummary()
refreshChapters()
}
})
})
}()
})
detectBtn.Importance = widget.HighImportance
addChapterBtn := widget.NewButton("+ Add Chapter", func() {
dialog.ShowInformation("Add Chapter", "Manual chapter addition will be implemented.", state.window)
})
exportBtn := widget.NewButton("Export Chapters", func() {
dialog.ShowInformation("Export", "Chapter export will be implemented", state.window)
})
controlsTop := container.NewVBox(
fileLabel,
selectBtn,
widget.NewSeparator(),
widget.NewLabel("Scene Detection:"),
thresholdLabel,
thresholdSlider,
detectBtn,
widget.NewSeparator(),
widget.NewLabel("Chapters:"),
sourceLabel,
)
listScroll := container.NewScroll(chapterList)
bottomRow := container.NewHBox(addChapterBtn, exportBtn)
controls := container.NewBorder(
controlsTop,
bottomRow,
nil,
nil,
listScroll,
)
refreshChapters()
return container.NewPadded(controls)
}
func buildSubtitlesTab(state *appState) fyne.CanvasObject {
list := container.NewVBox()
listScroll := container.NewVScroll(list)
var buildSubList func()
var emptyOverlay *fyne.Container
buildSubList = func() {
list.Objects = nil
if len(state.authorSubtitles) == 0 {
if emptyOverlay != nil {
emptyOverlay.Show()
}
list.Refresh()
return
}
if emptyOverlay != nil {
emptyOverlay.Hide()
}
for i, path := range state.authorSubtitles {
idx := i
card := widget.NewCard(filepath.Base(path), "", nil)
removeBtn := widget.NewButton("Remove", func() {
state.authorSubtitles = append(state.authorSubtitles[:idx], state.authorSubtitles[idx+1:]...)
buildSubList()
state.updateAuthorSummary()
})
removeBtn.Importance = widget.MediumImportance
cardContent := container.NewVBox(removeBtn)
card.SetContent(cardContent)
list.Add(card)
}
list.Refresh()
}
addBtn := widget.NewButton("Add Subtitles", func() {
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
if err != nil || reader == nil {
return
}
defer reader.Close()
state.authorSubtitles = append(state.authorSubtitles, reader.URI().Path())
buildSubList()
state.updateAuthorSummary()
}, state.window)
})
addBtn.Importance = widget.HighImportance
openSubtitlesBtn := widget.NewButton("Open Subtitles Tool", func() {
if state.authorFile != nil {
state.subtitleVideoPath = state.authorFile.Path
} else if len(state.authorClips) > 0 {
state.subtitleVideoPath = state.authorClips[0].Path
}
if len(state.authorSubtitles) > 0 {
state.subtitleFilePath = state.authorSubtitles[0]
}
state.showSubtitlesView()
})
openSubtitlesBtn.Importance = widget.MediumImportance
clearBtn := widget.NewButton("Clear All", func() {
state.authorSubtitles = []string{}
buildSubList()
state.updateAuthorSummary()
})
clearBtn.Importance = widget.MediumImportance
dropTarget := ui.NewDroppable(listScroll, func(items []fyne.URI) {
var paths []string
for _, uri := range items {
if uri.Scheme() == "file" {
paths = append(paths, uri.Path())
}
}
if len(paths) > 0 {
state.authorSubtitles = append(state.authorSubtitles, paths...)
buildSubList()
state.updateAuthorSummary()
}
})
emptyLabel := widget.NewLabel("Drag and drop subtitle files here\nor click 'Add Subtitles' to select")
emptyLabel.Alignment = fyne.TextAlignCenter
emptyOverlay = container.NewCenter(emptyLabel)
listArea := container.NewMax(dropTarget, emptyOverlay)
controls := container.NewBorder(
widget.NewLabel("Subtitle Tracks:"),
container.NewHBox(addBtn, openSubtitlesBtn, clearBtn),
nil,
nil,
listArea,
)
buildSubList()
return container.NewPadded(controls)
}
func buildAuthorSettingsTab(state *appState) fyne.CanvasObject {
outputType := widget.NewSelect([]string{"DVD (VIDEO_TS)", "ISO Image"}, func(value string) {
if value == "DVD (VIDEO_TS)" {
state.authorOutputType = "dvd"
} else {
state.authorOutputType = "iso"
}
state.updateAuthorSummary()
state.persistAuthorConfig()
})
if state.authorOutputType == "iso" {
outputType.SetSelected("ISO Image")
} else {
outputType.SetSelected("DVD (VIDEO_TS)")
}
regionSelect := widget.NewSelect([]string{"AUTO", "NTSC", "PAL"}, func(value string) {
state.authorRegion = value
state.updateAuthorSummary()
state.persistAuthorConfig()
})
if state.authorRegion == "" {
regionSelect.SetSelected("AUTO")
} else {
regionSelect.SetSelected(state.authorRegion)
}
aspectSelect := widget.NewSelect([]string{"AUTO", "4:3", "16:9"}, func(value string) {
state.authorAspectRatio = value
state.updateAuthorSummary()
state.persistAuthorConfig()
})
if state.authorAspectRatio == "" {
aspectSelect.SetSelected("AUTO")
} else {
aspectSelect.SetSelected(state.authorAspectRatio)
}
titleEntry := widget.NewEntry()
titleEntry.SetPlaceHolder("DVD Title")
titleEntry.SetText(state.authorTitle)
titleEntry.OnChanged = func(value string) {
state.authorTitle = value
state.updateAuthorSummary()
state.persistAuthorConfig()
}
discSizeSelect := widget.NewSelect([]string{"DVD5", "DVD9"}, func(value string) {
state.authorDiscSize = value
state.updateAuthorSummary()
state.persistAuthorConfig()
})
if state.authorDiscSize == "" {
discSizeSelect.SetSelected("DVD5")
} else {
discSizeSelect.SetSelected(state.authorDiscSize)
}
applyControls := func() {
if state.authorOutputType == "iso" {
outputType.SetSelected("ISO Image")
} else {
outputType.SetSelected("DVD (VIDEO_TS)")
}
if state.authorRegion == "" {
regionSelect.SetSelected("AUTO")
} else {
regionSelect.SetSelected(state.authorRegion)
}
if state.authorAspectRatio == "" {
aspectSelect.SetSelected("AUTO")
} else {
aspectSelect.SetSelected(state.authorAspectRatio)
}
if state.authorDiscSize == "" {
discSizeSelect.SetSelected("DVD5")
} else {
discSizeSelect.SetSelected(state.authorDiscSize)
}
titleEntry.SetText(state.authorTitle)
}
loadCfgBtn := widget.NewButton("Load Config", func() {
cfg, err := loadPersistedAuthorConfig()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
dialog.ShowInformation("No Config", "No saved config found yet. It will save automatically after your first change.", state.window)
} else {
dialog.ShowError(fmt.Errorf("failed to load config: %w", err), state.window)
}
return
}
state.applyAuthorConfig(cfg)
applyControls()
state.updateAuthorSummary()
})
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,
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,
MenuExtrasEnabled: state.authorMenuExtrasEnabled,
MenuChapterThumbSrc: state.authorMenuChapterThumbSrc,
TreatAsChapters: state.authorTreatAsChapters,
SceneThreshold: state.authorSceneThreshold,
}
if err := savePersistedAuthorConfig(cfg); err != nil {
dialog.ShowError(fmt.Errorf("failed to save config: %w", err), state.window)
return
}
dialog.ShowInformation("Config Saved", fmt.Sprintf("Saved to %s", moduleConfigPath("author")), state.window)
})
resetBtn := widget.NewButton("Reset", func() {
cfg := defaultAuthorConfig()
state.applyAuthorConfig(cfg)
applyControls()
state.updateAuthorSummary()
state.persistAuthorConfig()
})
info := widget.NewLabel("Requires: ffmpeg, dvdauthor, and mkisofs/genisoimage (for ISO).")
info.Wrapping = fyne.TextWrapWord
controls := container.NewVBox(
widget.NewLabel("Output Settings:"),
widget.NewSeparator(),
widget.NewLabel("Output Type:"),
outputType,
widget.NewLabel("Region:"),
regionSelect,
widget.NewLabel("Aspect Ratio:"),
aspectSelect,
widget.NewLabel("Disc Size:"),
discSizeSelect,
widget.NewLabel("DVD Title:"),
titleEntry,
widget.NewSeparator(),
info,
widget.NewSeparator(),
container.NewHBox(resetBtn, loadCfgBtn, saveCfgBtn),
)
return container.NewPadded(controls)
}
func buildAuthorMenuTab(state *appState) fyne.CanvasObject {
navyBlue := utils.MustHex("#191F35")
boxAccent := gridColor
var updateMenuControls func(bool)
buildMenuBox := func(title string, content fyne.CanvasObject) fyne.CanvasObject {
bg := canvas.NewRectangle(navyBlue)
bg.CornerRadius = 10
bg.StrokeColor = boxAccent
bg.StrokeWidth = 1
body := container.NewVBox(
widget.NewLabelWithStyle(title, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
widget.NewSeparator(),
content,
)
return container.NewMax(bg, container.NewPadded(body))
}
sectionGap := func() fyne.CanvasObject {
gap := canvas.NewRectangle(color.Transparent)
gap.SetMinSize(fyne.NewSize(0, 10))
return gap
}
createMenuCheck := widget.NewCheck("Enable DVD Menus", func(checked bool) {
state.authorCreateMenu = checked
state.updateAuthorSummary()
state.persistAuthorConfig()
if updateMenuControls != nil {
updateMenuControls(checked)
}
})
createMenuCheck.SetChecked(state.authorCreateMenu)
menuDisabledNote := widget.NewLabel("DVD menus will not be generated unless enabled.")
menuDisabledNote.TextStyle = fyne.TextStyle{Italic: true}
menuDisabledNote.Wrapping = fyne.TextWrapWord
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)
templateOptions := []string{
"Simple (Title + Buttons)",
"Poster (Grid Thumbnails)",
"Classic (Title + Buttons)",
}
templateValueByLabel := map[string]string{
"Simple (Title + Buttons)": "Simple",
"Poster (Grid Thumbnails)": "Poster",
"Classic (Title + Buttons)": "Dark",
}
templateLabelByValue := map[string]string{
"Simple": "Simple (Title + Buttons)",
"Poster": "Poster (Grid Thumbnails)",
"Dark": "Classic (Title + Buttons)",
}
menuTemplateSelect := widget.NewSelect(templateOptions, func(value string) {
state.authorMenuTemplate = templateValueByLabel[value]
state.updateAuthorSummary()
state.persistAuthorConfig()
})
if state.authorMenuTemplate == "" {
state.authorMenuTemplate = "Simple"
}
templateLabel := templateLabelByValue[state.authorMenuTemplate]
if templateLabel == "" {
templateLabel = templateOptions[0]
state.authorMenuTemplate = templateValueByLabel[templateLabel]
}
menuTemplateSelect.SetSelected(templateLabel)
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 = templateValueByLabel[value]
showPoster := state.authorMenuTemplate == "Poster"
bgImageButton.Hidden = !showPoster
bgImageLabel.Hidden = !showPoster
state.updateAuthorSummary()
state.persistAuthorConfig()
}
logoEnableCheck := widget.NewCheck("Embed Logo", nil)
logoEnableCheck.SetChecked(state.authorMenuLogoEnabled)
logoFileEntry := widget.NewEntry()
logoFileEntry.Disable()
logoFileEntry.SetPlaceHolder("No logo selected")
logoPreview := canvas.NewImageFromFile("")
logoPreview.FillMode = canvas.ImageFillContain
logoPreview.SetMinSize(fyne.NewSize(96, 96))
logoPreviewLabel := widget.NewLabel("No logo selected")
logoPreviewLabel.Wrapping = fyne.TextWrapWord
logoPreviewSize := widget.NewLabel("")
logoPreviewSize.Wrapping = fyne.TextWrapWord
logoPreviewBorder := canvas.NewRectangle(color.NRGBA{R: 80, G: 86, B: 100, A: 120})
logoPreviewBorder.SetMinSize(fyne.NewSize(120, 96))
logoPreviewBox := container.NewMax(
logoPreviewBorder,
container.NewPadded(container.NewCenter(logoPreview)),
)
previewMin := canvas.NewRectangle(color.NRGBA{A: 0})
previewMin.SetMinSize(fyne.NewSize(220, 0))
updateBrandingTitle := func() {}
menuPreviewSize := func() (int, int) {
width := 720
height := 480
switch strings.ToUpper(strings.TrimSpace(state.authorRegion)) {
case "PAL":
height = 576
}
return width, height
}
logoDisplayName := func() string {
if strings.TrimSpace(state.authorMenuLogoPath) == "" {
return "VT_Logo.png (default)"
}
return filepath.Base(state.authorMenuLogoPath)
}
updateLogoPreview := func() {
logoFileEntry.SetText(logoDisplayName())
if !state.authorMenuLogoEnabled {
logoPreviewBox.Hide()
logoPreviewLabel.SetText("Logo disabled")
logoPreviewSize.SetText("")
return
}
path := state.authorMenuLogoPath
if strings.TrimSpace(path) == "" {
path = filepath.Join("assets", "logo", "VT_Logo.png")
}
if _, err := os.Stat(path); err != nil {
logoPreviewBox.Hide()
logoPreviewLabel.SetText("Logo file not found")
logoPreviewSize.SetText("")
return
}
logoPreviewBox.Show()
logoPreviewLabel.SetText(filepath.Base(path))
logoPreview.File = path
logoPreview.Refresh()
file, err := os.Open(path)
if err != nil {
logoPreviewSize.SetText("")
return
}
defer file.Close()
cfg, _, err := image.DecodeConfig(file)
if err != nil {
logoPreviewSize.SetText("")
return
}
menuW, menuH := menuPreviewSize()
maxW := int(float64(menuW) * 0.25)
maxH := int(float64(menuH) * 0.25)
scale := state.authorMenuLogoScale
targetW := int(math.Round(float64(cfg.Width) * scale))
targetH := int(math.Round(float64(cfg.Height) * scale))
if targetW > maxW || targetH > maxH {
ratioW := float64(maxW) / float64(targetW)
ratioH := float64(maxH) / float64(targetH)
ratio := math.Min(ratioW, ratioH)
targetW = int(math.Round(float64(targetW) * ratio))
targetH = int(math.Round(float64(targetH) * ratio))
}
logoPreviewSize.SetText(fmt.Sprintf("Logo size: %dx%d (max %dx%d)", targetW, targetH, maxW, maxH))
}
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()
logoFileEntry.SetText(logoDisplayName())
updateLogoPreview()
updateBrandingTitle()
state.updateAuthorSummary()
state.persistAuthorConfig()
}, state.window)
})
logoPickButton.Importance = widget.MediumImportance
logoClearButton := widget.NewButton("Clear", func() {
state.authorMenuLogoPath = ""
logoFileEntry.SetText(logoDisplayName())
logoEnableCheck.SetChecked(false)
updateLogoPreview()
updateBrandingTitle()
state.updateAuthorSummary()
state.persistAuthorConfig()
})
logoPositionSelect := widget.NewSelect([]string{
"Top Left",
"Top Right",
"Bottom Left",
"Bottom Right",
"Center",
}, func(value string) {
state.authorMenuLogoPosition = value
updateBrandingTitle()
state.persistAuthorConfig()
})
if state.authorMenuLogoPosition == "" {
state.authorMenuLogoPosition = "Top Right"
}
logoPositionSelect.SetSelected(state.authorMenuLogoPosition)
scaleOptions := []string{"50%", "75%", "100%", "125%", "150%", "200%"}
scaleValueByLabel := map[string]float64{
"50%": 0.5,
"75%": 0.75,
"100%": 1.0,
"125%": 1.25,
"150%": 1.5,
"200%": 2.0,
}
scaleLabelByValue := map[float64]string{
0.5: "50%",
0.75: "75%",
1.0: "100%",
1.25: "125%",
1.5: "150%",
2.0: "200%",
}
if state.authorMenuLogoScale == 0 {
state.authorMenuLogoScale = 1.0
}
logoScaleSelect := widget.NewSelect(scaleOptions, func(value string) {
if scale, ok := scaleValueByLabel[value]; ok {
state.authorMenuLogoScale = scale
updateLogoPreview()
updateBrandingTitle()
state.persistAuthorConfig()
}
})
scaleLabel := scaleLabelByValue[state.authorMenuLogoScale]
if scaleLabel == "" {
scaleLabel = "100%"
state.authorMenuLogoScale = 1.0
}
logoScaleSelect.SetSelected(scaleLabel)
if state.authorMenuLogoMargin == 0 {
state.authorMenuLogoMargin = 24
}
marginEntry := widget.NewEntry()
marginEntry.SetText(strconv.Itoa(state.authorMenuLogoMargin))
updatingMargin := false
updateMargin := func(value int, updateEntry bool) {
if value < 0 {
value = 0
}
if value > 60 {
value = 60
}
state.authorMenuLogoMargin = value
if updateEntry {
updatingMargin = true
marginEntry.SetText(strconv.Itoa(value))
updatingMargin = false
}
state.persistAuthorConfig()
}
marginEntry.OnChanged = func(value string) {
if updatingMargin {
return
}
if strings.TrimSpace(value) == "" {
return
}
if v, err := strconv.Atoi(value); err == nil {
if v == state.authorMenuLogoMargin {
return
}
updateMargin(v, true)
}
}
marginMinus := widget.NewButton("-", func() {
updateMargin(state.authorMenuLogoMargin-2, true)
})
marginPlus := widget.NewButton("+", func() {
updateMargin(state.authorMenuLogoMargin+2, true)
})
safeAreaNote := widget.NewLabel("Logos are constrained to DVD safe areas.")
safeAreaNote.TextStyle = fyne.TextStyle{Italic: true}
safeAreaNote.Wrapping = fyne.TextWrapWord
menuStructureSelect := widget.NewSelect([]string{
"Feature Only",
"Feature + Chapters",
"Feature + Extras",
"Feature + Chapters + Extras",
}, func(value string) {
state.authorMenuStructure = value
state.persistAuthorConfig()
})
if state.authorMenuStructure == "" {
state.authorMenuStructure = "Feature + Chapters"
}
menuStructureSelect.SetSelected(state.authorMenuStructure)
extrasMenuCheck := widget.NewCheck("Enable Extras Menu", func(checked bool) {
state.authorMenuExtrasEnabled = checked
state.persistAuthorConfig()
})
extrasMenuCheck.SetChecked(state.authorMenuExtrasEnabled)
extrasNote := widget.NewLabel("Videos marked as Extras appear in a separate menu.")
extrasNote.Wrapping = fyne.TextWrapWord
thumbSourceSelect := widget.NewSelect([]string{
"Auto",
"First Frame",
"Midpoint",
"Custom (Advanced)",
}, func(value string) {
state.authorMenuChapterThumbSrc = value
state.persistAuthorConfig()
})
if state.authorMenuChapterThumbSrc == "" {
state.authorMenuChapterThumbSrc = "Auto"
}
thumbSourceSelect.SetSelected(state.authorMenuChapterThumbSrc)
info := widget.NewLabel("DVD menus are generated using the VideoTools theme and IBM Plex Mono. Menu settings apply only to disc authoring.")
info.Wrapping = fyne.TextWrapWord
logoPreviewGroup := container.NewMax(
previewMin,
container.NewVBox(
widget.NewLabel("Preview:"),
logoPreviewLabel,
logoPreviewBox,
logoPreviewSize,
),
)
logoButtonRow := container.NewHBox(
logoPickButton,
logoClearButton,
)
logoFileRow := container.NewBorder(nil, nil, nil, logoButtonRow, logoFileEntry)
logoPositionRow := container.NewHBox(
widget.NewLabel("Position:"),
layout.NewSpacer(),
logoPositionSelect,
)
logoScaleRow := container.NewHBox(
widget.NewLabel("Scale:"),
layout.NewSpacer(),
logoScaleSelect,
)
logoMarginRow := container.NewHBox(
widget.NewLabel("Margin:"),
marginMinus,
marginEntry,
marginPlus,
widget.NewLabel("px"),
)
menuCore := buildMenuBox("Menu Core", container.NewVBox(
createMenuCheck,
menuDisabledNote,
widget.NewLabel("Theme:"),
menuThemeSelect,
widget.NewLabel("Template:"),
menuTemplateSelect,
bgImageLabel,
bgImageButton,
widget.NewLabel("Menu Structure:"),
menuStructureSelect,
))
brandingLeft := container.NewVBox(
logoEnableCheck,
widget.NewLabel("Logo File:"),
logoFileRow,
logoPositionRow,
logoScaleRow,
logoMarginRow,
safeAreaNote,
)
brandingContent := container.NewBorder(nil, nil, nil, logoPreviewGroup, brandingLeft)
brandingItem := widget.NewAccordionItem("Branding (Logo Overlay)", brandingContent)
brandingItem.Open = false
brandingAccordion := widget.NewAccordion(brandingItem)
branding := container.NewMax(
canvas.NewRectangle(navyBlue),
container.NewPadded(brandingAccordion),
)
if bg, ok := branding.Objects[0].(*canvas.Rectangle); ok {
bg.CornerRadius = 10
bg.StrokeColor = boxAccent
bg.StrokeWidth = 1
}
navigation := buildMenuBox("Navigation", container.NewVBox(
extrasMenuCheck,
extrasNote,
widget.NewLabel("Chapter Thumbnail Source:"),
thumbSourceSelect,
))
controls := container.NewVBox(
menuCore,
sectionGap(),
branding,
sectionGap(),
navigation,
sectionGap(),
info,
)
updateMenuControls = func(enabled bool) {
menuDisabledNote.Hidden = enabled
setEnabled := func(on bool, items ...fyne.Disableable) {
for _, item := range items {
if on {
item.Enable()
} else {
item.Disable()
}
}
}
setEnabled(enabled,
menuThemeSelect,
menuTemplateSelect,
bgImageButton,
menuStructureSelect,
logoEnableCheck,
logoPickButton,
logoClearButton,
logoPositionSelect,
logoScaleSelect,
marginEntry,
marginMinus,
marginPlus,
extrasMenuCheck,
thumbSourceSelect,
)
logoControlsEnabled := enabled && state.authorMenuLogoEnabled
setEnabled(logoControlsEnabled,
logoPickButton,
logoClearButton,
logoPositionSelect,
logoScaleSelect,
marginEntry,
marginMinus,
marginPlus,
)
}
updateBrandingTitle = func() {
if !state.authorMenuLogoEnabled {
brandingItem.Title = "Branding: Disabled"
brandingAccordion.Refresh()
return
}
scaleText := scaleLabelByValue[state.authorMenuLogoScale]
if scaleText == "" {
scaleText = "100%"
}
name := logoDisplayName()
brandingItem.Title = fmt.Sprintf("Branding: %s (%s, %s)", name, state.authorMenuLogoPosition, scaleText)
brandingAccordion.Refresh()
}
logoEnableCheck.OnChanged = func(checked bool) {
state.authorMenuLogoEnabled = checked
updateLogoPreview()
updateBrandingTitle()
updateMenuControls(state.authorCreateMenu)
state.updateAuthorSummary()
state.persistAuthorConfig()
}
updateLogoPreview()
updateBrandingTitle()
updateMenuControls(state.authorCreateMenu)
scroll := container.NewVScroll(container.NewPadded(controls))
scroll.SetMinSize(fyne.NewSize(0, 420))
return scroll
}
func buildAuthorDiscTab(state *appState) fyne.CanvasObject {
generateBtn := widget.NewButton("GENERATE DVD", func() {
if len(state.authorClips) == 0 && state.authorFile == nil {
dialog.ShowInformation("No Content", "Please add video clips or select a single video file", state.window)
return
}
state.startAuthorGeneration(true)
})
generateBtn.Importance = widget.HighImportance
summaryLabel := widget.NewLabel(authorSummary(state))
summaryLabel.Wrapping = fyne.TextWrapWord
state.authorSummaryLabel = summaryLabel
statusLabel := widget.NewLabel("Ready")
statusLabel.Wrapping = fyne.TextWrapWord
state.authorStatusLabel = statusLabel
progressBar := widget.NewProgressBar()
progressBar.SetValue(state.authorProgress / 100.0)
state.authorProgressBar = progressBar
logEntry := widget.NewMultiLineEntry()
logEntry.Wrapping = fyne.TextWrapOff
logEntry.Disable()
logEntry.SetText(state.authorLogText)
state.authorLogEntry = logEntry
logScroll := container.NewVScroll(logEntry)
logScroll.SetMinSize(fyne.NewSize(0, 200))
state.authorLogScroll = logScroll
// Log control buttons
copyLogBtn := widget.NewButton("Copy Log", func() {
if state.authorLogFilePath != "" {
// Copy from file for accuracy
if data, err := os.ReadFile(state.authorLogFilePath); err == nil {
state.window.Clipboard().SetContent(string(data))
dialog.ShowInformation("Copied", "Full authoring log copied to clipboard", state.window)
return
}
}
// Fallback to in-memory log
state.window.Clipboard().SetContent(state.authorLogText)
dialog.ShowInformation("Copied", "Authoring log copied to clipboard", state.window)
})
copyLogBtn.Importance = widget.LowImportance
viewFullLogBtn := widget.NewButton("View Full Log", func() {
if state.authorLogFilePath == "" || state.authorLogFilePath == "-" {
dialog.ShowInformation("No Log File", "No log file available to view", state.window)
return
}
if _, err := os.Stat(state.authorLogFilePath); err != nil {
dialog.ShowError(fmt.Errorf("log file not found: %w", err), state.window)
return
}
state.openLogViewer("Authoring Log", state.authorLogFilePath, false)
})
viewFullLogBtn.Importance = widget.LowImportance
logControls := container.NewHBox(
widget.NewLabel("Authoring Log (last 100 lines):"),
layout.NewSpacer(),
copyLogBtn,
viewFullLogBtn,
)
controls := container.NewVBox(
widget.NewLabel("Generate DVD/ISO:"),
widget.NewSeparator(),
summaryLabel,
widget.NewSeparator(),
widget.NewLabel("Status:"),
statusLabel,
progressBar,
widget.NewSeparator(),
logControls,
logScroll,
widget.NewSeparator(),
generateBtn,
)
return container.NewPadded(controls)
}
func authorSummary(state *appState) string {
summary := "Ready to generate:\n\n"
if state.authorVideoTSPath != "" {
summary += fmt.Sprintf("VIDEO_TS: %s\n", filepath.Base(filepath.Dir(state.authorVideoTSPath)))
} else if len(state.authorClips) > 0 {
summary += fmt.Sprintf("Videos: %d\n", len(state.authorClips))
for i, clip := range state.authorClips {
summary += fmt.Sprintf(" %d. %s (%.2fs)\n", i+1, clip.DisplayName, clip.Duration)
}
} else if state.authorFile != nil {
summary += fmt.Sprintf("Video File: %s\n", filepath.Base(state.authorFile.Path))
}
if len(state.authorSubtitles) > 0 {
summary += fmt.Sprintf("Subtitle Tracks: %d\n", len(state.authorSubtitles))
for i, path := range state.authorSubtitles {
summary += fmt.Sprintf(" %d. %s\n", i+1, filepath.Base(path))
}
}
if count, label := state.authorChapterSummary(); count > 0 {
summary += fmt.Sprintf("%s: %d\n", label, count)
}
summary += fmt.Sprintf("Output Type: %s\n", state.authorOutputType)
summary += fmt.Sprintf("Disc Size: %s\n", state.authorDiscSize)
summary += fmt.Sprintf("Region: %s\n", state.authorRegion)
summary += fmt.Sprintf("Aspect Ratio: %s\n", state.authorAspectRatio)
if outPath := authorDefaultOutputPath(state.authorOutputType, authorOutputTitle(state), authorSummaryPaths(state)); outPath != "" {
summary += fmt.Sprintf("Output Path: %s\n", outPath)
}
if state.authorTitle != "" {
summary += fmt.Sprintf("DVD Title: %s\n", state.authorTitle)
}
if totalDur := authorTotalDuration(state); totalDur > 0 {
bitrate := authorTargetBitrateKbps(state.authorDiscSize, totalDur)
summary += fmt.Sprintf("Estimated Target Bitrate: %dkbps\n", bitrate)
}
return summary
}
func (s *appState) addAuthorFiles(paths []string) {
wasEmpty := len(s.authorClips) == 0
for _, path := range paths {
src, err := probeVideo(path)
if err != nil {
dialog.ShowError(fmt.Errorf("failed to load video %s: %w", filepath.Base(path), err), s.window)
continue
}
clip := authorClip{
Path: path,
DisplayName: filepath.Base(path),
Duration: src.Duration,
Chapters: []authorChapter{},
ChapterTitle: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)),
}
s.authorClips = append(s.authorClips, clip)
}
if wasEmpty && len(s.authorClips) == 1 {
s.loadEmbeddedChapters(s.authorClips[0].Path)
} else if len(s.authorClips) > 1 && s.authorChapterSource == "embedded" {
s.authorChapters = nil
s.authorChapterSource = ""
}
s.authorTitle = ""
s.updateAuthorSummary()
// Update the UI for the title entry if the settings tab is currently visible.
// This ensures the title entry visually resets as well.
if s.active == "author" && s.window.Canvas() != nil {
app := fyne.CurrentApp()
if app != nil && app.Driver() != nil {
app.Driver().DoFromGoroutine(func() {
// Rebuild the settings tab to refresh its controls.
// This is a bit heavy, but ensures the titleEntry reflects the change.
s.showAuthorView()
}, false)
}
}
}
func (s *appState) updateAuthorSummary() {
if s.authorSummaryLabel == nil {
return
}
s.authorSummaryLabel.SetText(authorSummary(s))
}
func (s *appState) authorChapterSummary() (int, string) {
if len(s.authorChapters) > 0 {
switch s.authorChapterSource {
case "embedded":
return len(s.authorChapters), "Embedded Chapters"
case "scenes":
return len(s.authorChapters), "Scene Chapters"
case "videots":
return len(s.authorChapters), "VIDEO_TS Chapters"
default:
return len(s.authorChapters), "Chapters"
}
}
if s.authorTreatAsChapters && len(s.authorClips) > 1 {
return len(s.authorClips), "Clip Chapters"
}
return 0, ""
}
func authorTotalDuration(state *appState) float64 {
if len(state.authorClips) > 0 {
var total float64
for _, clip := range state.authorClips {
total += clip.Duration
}
return total
}
if state.authorFile != nil {
return state.authorFile.Duration
}
return 0
}
func authorSummaryPaths(state *appState) []string {
if state.authorVideoTSPath != "" {
return []string{state.authorVideoTSPath}
}
if len(state.authorClips) > 0 {
paths := make([]string, 0, len(state.authorClips))
for _, clip := range state.authorClips {
paths = append(paths, clip.Path)
}
return paths
}
if state.authorFile != nil {
return []string{state.authorFile.Path}
}
return nil
}
func authorOutputTitle(state *appState) string {
title := strings.TrimSpace(state.authorTitle)
if title != "" {
return title
}
if state.authorVideoTSPath != "" {
return filepath.Base(filepath.Dir(state.authorVideoTSPath))
}
return defaultAuthorTitle(authorSummaryPaths(state))
}
func authorTargetBitrateKbps(discSize string, totalSeconds float64) int {
if totalSeconds <= 0 {
return 0
}
var targetBytes float64
switch strings.ToUpper(strings.TrimSpace(discSize)) {
case "DVD9":
targetBytes = 7.3 * 1024 * 1024 * 1024
default:
targetBytes = 4.1 * 1024 * 1024 * 1024
}
totalBits := targetBytes * 8
kbps := int(totalBits / totalSeconds / 1000)
if kbps > 9500 {
kbps = 9500
}
if kbps < 1500 {
kbps = 1500
}
return kbps
}
func (s *appState) loadEmbeddedChapters(path string) {
chapters, err := extractChaptersFromFile(path)
if err != nil || len(chapters) == 0 {
if s.authorChapterSource == "embedded" {
s.authorChapters = nil
s.authorChapterSource = ""
s.updateAuthorSummary()
if s.authorChaptersRefresh != nil {
s.authorChaptersRefresh()
}
}
return
}
s.authorChapters = chapters
s.authorChapterSource = "embedded"
s.updateAuthorSummary()
if s.authorChaptersRefresh != nil {
s.authorChaptersRefresh()
}
}
func (s *appState) loadVideoTSChapters(videoTSPath string) {
chapters, err := extractChaptersFromVideoTS(videoTSPath)
if err != nil || len(chapters) == 0 {
// No chapters found, clear if previously set
if s.authorChapterSource == "videots" {
s.authorChapters = nil
s.authorChapterSource = ""
s.updateAuthorSummary()
if s.authorChaptersRefresh != nil {
s.authorChaptersRefresh()
}
}
return
}
s.authorChapters = chapters
s.authorChapterSource = "videots"
s.updateAuthorSummary()
if s.authorChaptersRefresh != nil {
s.authorChaptersRefresh()
}
}
func chaptersFromClips(clips []authorClip) []authorChapter {
if len(clips) == 0 {
return nil
}
var chapters []authorChapter
var t float64
firstTitle := strings.TrimSpace(clips[0].ChapterTitle)
if firstTitle == "" {
firstTitle = "Chapter 1"
}
chapters = append(chapters, authorChapter{Timestamp: 0, Title: firstTitle, Auto: true})
for i := 1; i < len(clips); i++ {
t += clips[i-1].Duration
title := strings.TrimSpace(clips[i].ChapterTitle)
if title == "" {
title = fmt.Sprintf("Chapter %d", i+1)
}
chapters = append(chapters, authorChapter{
Timestamp: t,
Title: title,
Auto: true,
})
}
return chapters
}
func detectSceneChapters(path string, threshold float64) ([]authorChapter, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
filter := fmt.Sprintf("select='gt(scene,%.2f)',showinfo", threshold)
cmd := utils.CreateCommand(ctx, utils.GetFFmpegPath(),
"-hide_banner",
"-loglevel", "info",
"-i", path,
"-vf", filter,
"-an",
"-f", "null",
"-",
)
out, err := cmd.CombinedOutput()
if ctx.Err() != nil {
return nil, ctx.Err()
}
times := map[float64]struct{}{}
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
idx := strings.Index(line, "pts_time:")
if idx == -1 {
continue
}
rest := line[idx+len("pts_time:"):]
end := strings.IndexAny(rest, " ")
if end == -1 {
end = len(rest)
}
valStr := strings.TrimSpace(rest[:end])
if valStr == "" {
continue
}
if val, err := utils.ParseFloat(valStr); err == nil {
times[val] = struct{}{}
}
}
var vals []float64
for v := range times {
if v < 0.01 {
continue
}
vals = append(vals, v)
}
sort.Float64s(vals)
if len(vals) == 0 {
if err != nil {
return nil, fmt.Errorf("scene detection failed: %s", strings.TrimSpace(string(out)))
}
return nil, nil
}
chapters := []authorChapter{{Timestamp: 0, Title: "Chapter 1", Auto: true}}
for i, v := range vals {
chapters = append(chapters, authorChapter{
Timestamp: v,
Title: fmt.Sprintf("Chapter %d", i+2),
Auto: true,
})
}
return chapters, nil
}
func extractChaptersFromFile(path string) ([]authorChapter, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := utils.CreateCommand(ctx, utils.GetFFprobePath(),
"-v", "quiet",
"-print_format", "json",
"-show_chapters",
path,
)
out, err := cmd.Output()
if err != nil {
return nil, err
}
var result struct {
Chapters []struct {
StartTime string `json:"start_time"`
Tags map[string]interface{} `json:"tags"`
} `json:"chapters"`
}
if err := json.Unmarshal(out, &result); err != nil {
return nil, err
}
var chapters []authorChapter
for i, ch := range result.Chapters {
t, err := utils.ParseFloat(ch.StartTime)
if err != nil {
continue
}
title := ""
if ch.Tags != nil {
if v, ok := ch.Tags["title"]; ok {
title = fmt.Sprintf("%v", v)
}
}
if title == "" {
title = fmt.Sprintf("Chapter %d", i+1)
}
chapters = append(chapters, authorChapter{
Timestamp: t,
Title: title,
Auto: true,
})
}
return chapters, nil
}
func extractChaptersFromVideoTS(videoTSPath string) ([]authorChapter, error) {
logging.Debug(logging.CatModule, "extractChaptersFromVideoTS: searching for VOB files in: %s", videoTSPath)
// Try to find the main title VOB files
// Usually VTS_01_1.VOB contains the main content
vobFiles, err := filepath.Glob(filepath.Join(videoTSPath, "VTS_*_1.VOB"))
if err != nil {
logging.Debug(logging.CatModule, "extractChaptersFromVideoTS: glob error: %v", err)
return nil, fmt.Errorf("error searching for VOB files: %w", err)
}
if len(vobFiles) == 0 {
logging.Debug(logging.CatModule, "extractChaptersFromVideoTS: no VTS_*_1.VOB files found")
return nil, fmt.Errorf("no VOB files found in VIDEO_TS")
}
logging.Debug(logging.CatModule, "extractChaptersFromVideoTS: found %d VOB files: %v", len(vobFiles), vobFiles)
// Sort to get the first title set (usually the main feature)
sort.Strings(vobFiles)
mainVOB := vobFiles[0]
logging.Debug(logging.CatModule, "extractChaptersFromVideoTS: using main VOB: %s", mainVOB)
// Try to extract chapters from the main VOB using ffprobe
chapters, err := extractChaptersFromFile(mainVOB)
if err != nil {
logging.Debug(logging.CatModule, "extractChaptersFromVideoTS: ffprobe error: %v", err)
return nil, err
}
logging.Debug(logging.CatModule, "extractChaptersFromVideoTS: extracted %d chapters", len(chapters))
return chapters, nil
}
func chaptersToDVDAuthor(chapters []authorChapter) string {
if len(chapters) == 0 {
return ""
}
var times []float64
for _, ch := range chapters {
if ch.Timestamp < 0 {
continue
}
times = append(times, ch.Timestamp)
}
if len(times) == 0 {
return ""
}
sort.Float64s(times)
if times[0] > 0.01 {
times = append([]float64{0}, times...)
}
seen := map[int]struct{}{}
var parts []string
for _, t := range times {
key := int(t * 1000)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
parts = append(parts, formatChapterTime(t))
}
return strings.Join(parts, ",")
}
func formatChapterTime(sec float64) string {
if sec < 0 {
sec = 0
}
d := time.Duration(sec * float64(time.Second))
h := int(d.Hours())
m := int(d.Minutes()) % 60
s := int(d.Seconds()) % 60
return fmt.Sprintf("%02d:%02d:%02d", h, m, s)
}
func concatDVDMpg(inputs []string, output string) error {
listPath := filepath.Join(filepath.Dir(output), "concat_list.txt")
listFile, err := os.Create(listPath)
if err != nil {
return fmt.Errorf("failed to create concat list: %w", err)
}
for _, path := range inputs {
fmt.Fprintf(listFile, "file '%s'\n", strings.ReplaceAll(path, "'", "'\\''"))
}
if err := listFile.Close(); err != nil {
return fmt.Errorf("failed to write concat list: %w", err)
}
defer os.Remove(listPath)
args := []string{
"-hide_banner",
"-loglevel", "error",
"-f", "concat",
"-safe", "0",
"-i", listPath,
"-c", "copy",
"-f", "dvd", // Maintain DVD format
"-muxrate", "10080000", // DVD mux rate
"-packetsize", "2048", // DVD packet size
output,
}
cmd := utils.CreateCommandRaw(utils.GetFFmpegPath(), args...)
return cmd.Run()
}
func (s *appState) resetAuthorLog() {
s.authorLogText = ""
s.authorLogLines = nil
s.authorLogFilePath = ""
if s.authorLogEntry != nil {
s.authorLogEntry.SetText("")
}
if s.authorLogScroll != nil {
s.authorLogScroll.ScrollToTop()
}
}
func (s *appState) appendAuthorLog(line string) {
if strings.TrimSpace(line) == "" {
return
}
// Keep only last 100 lines for UI display (tail behavior)
const maxLines = 100
s.authorLogLines = append(s.authorLogLines, line)
if len(s.authorLogLines) > maxLines {
s.authorLogLines = s.authorLogLines[len(s.authorLogLines)-maxLines:]
}
// Rebuild text from buffer
s.authorLogText = strings.Join(s.authorLogLines, "\n")
if s.authorLogEntry != nil {
s.authorLogEntry.SetText(s.authorLogText)
}
if s.authorLogScroll != nil {
s.authorLogScroll.ScrollToBottom()
}
}
func (s *appState) setAuthorStatus(text string) {
if text == "" {
text = "Ready"
}
if s.authorStatusLabel != nil {
s.authorStatusLabel.SetText(text)
}
}
func (s *appState) setAuthorProgress(percent float64) {
if percent < 0 {
percent = 0
}
if percent > 100 {
percent = 100
}
s.authorProgress = percent
if s.authorProgressBar != nil {
s.authorProgressBar.SetValue(percent / 100.0)
}
}
func (s *appState) updateAuthorCancelButton() {
if s.authorCancelBtn == nil {
return
}
if s.jobQueue == nil {
s.authorCancelBtn.Hide()
return
}
job := s.jobQueue.CurrentRunning()
if job != nil && job.Type == queue.JobTypeAuthor {
s.authorCancelBtn.Show()
} else {
s.authorCancelBtn.Hide()
}
}
func (s *appState) startAuthorGeneration(startNow bool) {
if s.authorVideoTSPath != "" {
title := authorOutputTitle(s)
outputPath := authorDefaultOutputPath("iso", title, []string{s.authorVideoTSPath})
if outputPath == "" {
dialog.ShowError(fmt.Errorf("failed to resolve output path"), s.window)
return
}
if err := s.addAuthorVideoTSToQueue(s.authorVideoTSPath, title, outputPath, startNow); err != nil {
dialog.ShowError(err, s.window)
}
return
}
paths, primary, err := s.authorSourcePaths()
if err != nil {
dialog.ShowError(err, s.window)
return
}
region := resolveAuthorRegion(s.authorRegion, primary)
aspect := resolveAuthorAspect(s.authorAspectRatio, primary)
title := strings.TrimSpace(s.authorTitle)
if title == "" {
title = defaultAuthorTitle(paths)
}
warnings := authorWarnings(s)
uiCall := func(fn func()) {
app := fyne.CurrentApp()
if app != nil && app.Driver() != nil {
app.Driver().DoFromGoroutine(fn, false)
return
}
fn()
}
continuePrompt := func() {
uiCall(func() {
s.promptAuthorOutput(paths, region, aspect, title, startNow)
})
}
if len(warnings) > 0 {
uiCall(func() {
dialog.ShowConfirm("Authoring Notes", strings.Join(warnings, "\n")+"\n\nContinue?", func(ok bool) {
if ok {
continuePrompt()
}
}, s.window)
})
return
}
continuePrompt()
}
func (s *appState) promptAuthorOutput(paths []string, region, aspect, title string, startNow bool) {
outputType := strings.ToLower(strings.TrimSpace(s.authorOutputType))
if outputType == "" {
outputType = "dvd"
}
outputPath := authorDefaultOutputPath(outputType, title, paths)
if outputType == "iso" {
s.generateAuthoring(paths, region, aspect, title, outputPath, true, startNow)
return
}
s.generateAuthoring(paths, region, aspect, title, outputPath, false, startNow)
}
func authorWarnings(state *appState) []string {
var warnings []string
if len(state.authorSubtitles) > 0 {
warnings = append(warnings, "Subtitle tracks are not authored yet; they will be ignored.")
}
if len(state.authorAudioTracks) > 0 {
warnings = append(warnings, "Additional audio tracks are not authored yet; they will be ignored.")
}
if totalDur := authorTotalDuration(state); totalDur > 0 {
bitrate := authorTargetBitrateKbps(state.authorDiscSize, totalDur)
if bitrate < 3000 {
warnings = append(warnings, fmt.Sprintf("Long runtime detected; target bitrate ~%dkbps may reduce quality.", bitrate))
}
}
return warnings
}
func (s *appState) authorSourcePaths() ([]string, *videoSource, error) {
if len(s.authorClips) > 0 {
paths := make([]string, 0, len(s.authorClips))
for _, clip := range s.authorClips {
paths = append(paths, clip.Path)
}
primary, err := probeVideo(paths[0])
if err != nil {
return nil, nil, fmt.Errorf("failed to probe source: %w", err)
}
return paths, primary, nil
}
if s.authorFile != nil {
return []string{s.authorFile.Path}, s.authorFile, nil
}
return nil, nil, fmt.Errorf("no authoring content selected")
}
func resolveAuthorRegion(pref string, src *videoSource) string {
pref = strings.ToUpper(strings.TrimSpace(pref))
if pref == "NTSC" || pref == "PAL" {
return pref
}
if src != nil {
if src.FrameRate > 0 {
if src.FrameRate <= 26 {
return "PAL"
}
return "NTSC"
}
if src.Height == 576 {
return "PAL"
}
if src.Height == 480 {
return "NTSC"
}
}
return "NTSC"
}
func resolveAuthorAspect(pref string, src *videoSource) string {
pref = strings.TrimSpace(pref)
if pref == "4:3" || pref == "16:9" {
return pref
}
if src != nil && src.Width > 0 && src.Height > 0 {
ratio := float64(src.Width) / float64(src.Height)
if ratio >= 1.55 {
return "16:9"
}
return "4:3"
}
return "16:9"
}
func defaultAuthorTitle(paths []string) string {
if len(paths) == 0 {
return "DVD"
}
base := filepath.Base(paths[0])
return strings.TrimSuffix(base, filepath.Ext(base))
}
func authorOutputFolderName(title string, paths []string) string {
name := strings.TrimSpace(title)
if name == "" {
name = defaultAuthorTitle(paths)
}
name = sanitizeForPath(name)
if name == "" {
name = "dvd_output"
}
return name
}
func authorDefaultOutputDir(outputType string) string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
home = "."
}
dir := filepath.Join(home, "Videos", "VideoTools")
if strings.EqualFold(outputType, "iso") {
return filepath.Join(dir, "ISO_Convert")
}
return filepath.Join(dir, "DVD_Convert")
}
func authorDefaultOutputPath(outputType, title string, paths []string) string {
outputType = strings.ToLower(strings.TrimSpace(outputType))
if outputType == "" {
outputType = "dvd"
}
baseDir := authorDefaultOutputDir(outputType)
name := strings.TrimSpace(title)
if name == "" {
name = defaultAuthorTitle(paths)
}
name = sanitizeForPath(name)
if name == "" {
name = "dvd_output"
}
if outputType == "iso" {
return uniqueFilePath(filepath.Join(baseDir, name+".iso"))
}
return uniqueFolderPath(filepath.Join(baseDir, name))
}
func authorTempRoot(outputPath string) string {
trimmed := strings.TrimSpace(outputPath)
if trimmed == "" {
return utils.TempDir()
}
lower := strings.ToLower(trimmed)
root := trimmed
if strings.HasSuffix(lower, ".iso") {
root = filepath.Dir(trimmed)
} else if ext := filepath.Ext(trimmed); ext != "" {
root = filepath.Dir(trimmed)
}
if root == "" || root == "." {
return utils.TempDir()
}
return root
}
func uniqueFolderPath(path string) string {
if _, err := os.Stat(path); os.IsNotExist(err) {
return path
}
for i := 1; i < 1000; i++ {
tryPath := fmt.Sprintf("%s-%d", path, i)
if _, err := os.Stat(tryPath); os.IsNotExist(err) {
return tryPath
}
}
return fmt.Sprintf("%s-%d", path, time.Now().Unix())
}
func uniqueFilePath(path string) string {
if _, err := os.Stat(path); os.IsNotExist(err) {
return path
}
ext := filepath.Ext(path)
base := strings.TrimSuffix(path, ext)
for i := 1; i < 1000; i++ {
tryPath := fmt.Sprintf("%s-%d%s", base, i, ext)
if _, err := os.Stat(tryPath); os.IsNotExist(err) {
return tryPath
}
}
return fmt.Sprintf("%s-%d%s", base, time.Now().Unix(), ext)
}
func (s *appState) generateAuthoring(paths []string, region, aspect, title, outputPath string, makeISO, startNow bool) {
if err := s.addAuthorToQueue(paths, region, aspect, title, outputPath, makeISO, startNow); err != nil {
dialog.ShowError(err, s.window)
}
}
func (s *appState) addAuthorToQueue(paths []string, region, aspect, title, outputPath string, makeISO bool, startNow bool) error {
if s.jobQueue == nil {
return fmt.Errorf("queue not initialized")
}
clips := make([]map[string]interface{}, 0, len(s.authorClips))
for _, clip := range s.authorClips {
clips = append(clips, map[string]interface{}{
"path": clip.Path,
"displayName": clip.DisplayName,
"duration": clip.Duration,
"chapterTitle": clip.ChapterTitle,
})
}
chapters := make([]map[string]interface{}, 0, len(s.authorChapters))
for _, ch := range s.authorChapters {
chapters = append(chapters, map[string]interface{}{
"timestamp": ch.Timestamp,
"title": ch.Title,
"auto": ch.Auto,
})
}
config := map[string]interface{}{
"paths": paths,
"region": region,
"aspect": aspect,
"title": title,
"outputPath": outputPath,
"makeISO": makeISO,
"treatAsChapters": s.authorTreatAsChapters,
"clips": clips,
"chapters": chapters,
"discSize": s.authorDiscSize,
"outputType": s.authorOutputType,
"authorTitle": s.authorTitle,
"authorRegion": s.authorRegion,
"authorAspect": s.authorAspectRatio,
"createMenu": s.authorCreateMenu,
"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,
"menuExtrasEnabled": s.authorMenuExtrasEnabled,
"menuChapterThumbSrc": s.authorMenuChapterThumbSrc,
}
titleLabel := title
if strings.TrimSpace(titleLabel) == "" {
titleLabel = "DVD"
}
// Sanitize output path to ensure no special characters in filesystem operations
sanitizedOutputPath := outputPath
dir := filepath.Dir(outputPath)
base := filepath.Base(outputPath)
ext := filepath.Ext(base)
nameWithoutExt := strings.TrimSuffix(base, ext)
sanitizedName := sanitizeForPath(nameWithoutExt)
if sanitizedName == "" {
sanitizedName = "dvd_output"
}
sanitizedOutputPath = filepath.Join(dir, sanitizedName+ext)
job := &queue.Job{
Type: queue.JobTypeAuthor,
Title: fmt.Sprintf("Author DVD: %s", titleLabel),
Description: fmt.Sprintf("Output: %s", utils.ShortenMiddle(filepath.Base(sanitizedOutputPath), 40)),
InputFile: paths[0],
OutputFile: sanitizedOutputPath,
Config: config,
}
s.resetAuthorLog()
s.setAuthorStatus("Queued authoring job...")
s.setAuthorProgress(0)
s.jobQueue.Add(job)
if startNow && !s.jobQueue.IsRunning() {
s.jobQueue.Start()
}
// Navigate to queue view when starting a job immediately
if startNow {
s.showQueue()
}
return nil
}
func (s *appState) addAuthorVideoTSToQueue(videoTSPath, title, outputPath string, startNow bool) error {
if s.jobQueue == nil {
return fmt.Errorf("queue not initialized")
}
// Sanitize output path to ensure no special characters in filesystem operations
sanitizedOutputPath := outputPath
dir := filepath.Dir(outputPath)
base := filepath.Base(outputPath)
ext := filepath.Ext(base)
nameWithoutExt := strings.TrimSuffix(base, ext)
sanitizedName := sanitizeForPath(nameWithoutExt)
if sanitizedName == "" {
sanitizedName = "dvd_output"
}
sanitizedOutputPath = filepath.Join(dir, sanitizedName+ext)
job := &queue.Job{
Type: queue.JobTypeAuthor,
Title: fmt.Sprintf("Author ISO: %s", title),
Description: fmt.Sprintf("VIDEO_TS -> %s", utils.ShortenMiddle(filepath.Base(sanitizedOutputPath), 40)),
InputFile: videoTSPath,
OutputFile: sanitizedOutputPath,
Config: map[string]interface{}{
"videoTSPath": videoTSPath,
"outputPath": sanitizedOutputPath,
"makeISO": true,
"title": title,
},
}
s.resetAuthorLog()
s.setAuthorStatus("Queued authoring job...")
s.setAuthorProgress(0)
s.jobQueue.Add(job)
if startNow && !s.jobQueue.IsRunning() {
s.jobQueue.Start()
}
// Navigate to queue view when starting a job immediately
if startNow {
s.showQueue()
}
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 {
tempRoot := authorTempRoot(outputPath)
if err := os.MkdirAll(tempRoot, 0755); err != nil {
return fmt.Errorf("failed to create temp root: %w", err)
}
workDir, err := os.MkdirTemp(tempRoot, "videotools-author-")
if err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(workDir)
if logFn != nil {
logFn(fmt.Sprintf("Temp workspace: %s", workDir))
}
discRoot := outputPath
var cleanup func()
if makeISO {
tempRoot, err := os.MkdirTemp(tempRoot, "videotools-dvd-")
if err != nil {
return fmt.Errorf("failed to create DVD output directory: %w", err)
}
discRoot = tempRoot
cleanup = func() {
_ = os.RemoveAll(tempRoot)
}
}
if cleanup != nil {
defer cleanup()
}
if err := prepareDiscRoot(discRoot); err != nil {
return err
}
var totalDuration float64
for _, path := range paths {
src, err := probeVideo(path)
if err == nil {
totalDuration += src.Duration
}
}
encodingProgressShare := 80.0
otherStepsProgressShare := 20.0
otherStepsCount := 2.0
if makeISO {
otherStepsCount++
}
progressForOtherStep := otherStepsProgressShare / otherStepsCount
var accumulatedProgress float64
var mpgPaths []string
for i, path := range paths {
if logFn != nil {
logFn(fmt.Sprintf("Encoding %d/%d: %s", i+1, len(paths), filepath.Base(path)))
}
outPath := filepath.Join(workDir, fmt.Sprintf("title_%02d.mpg", i+1))
src, err := probeVideo(path)
if err != nil {
return fmt.Errorf("failed to probe %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("title_%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)
mpgPaths = append(mpgPaths, 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 {
src, err := probeVideo(path)
duration := 0.0
displayName := filepath.Base(path)
chapterTitle := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
if err == nil {
duration = src.Duration
displayName = src.DisplayName
// Use metadata title if available, otherwise use filename
if src.Metadata != nil {
if title, ok := src.Metadata["title"]; ok && strings.TrimSpace(title) != "" {
chapterTitle = title
}
}
}
clips = append(clips, authorClip{
Path: path,
DisplayName: displayName,
Duration: duration,
ChapterTitle: chapterTitle,
})
}
if logFn != nil {
logFn(fmt.Sprintf("Generated %d clips from input paths for chapter markers", len(clips)))
}
}
// Generate chapters from clips if available (for professional DVD navigation)
if len(chapters) == 0 && len(clips) > 1 {
chapters = chaptersFromClips(clips)
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 {
chapters = embed
if logFn != nil {
logFn(fmt.Sprintf("Extracted %d embedded chapters from source", len(chapters)))
}
}
}
// For professional DVD: always concatenate multiple files into one title with chapters
if len(mpgPaths) > 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)))
}
if err := concatDVDMpg(mpgPaths, concatPath); err != nil {
return fmt.Errorf("failed to concatenate videos: %w", err)
}
mpgPaths = []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 {
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))
}
}
}
var menuMpg string
var menuButtons []dvdMenuButton
if createMenu {
template, ok := menuTemplates[menuTemplate]
if !ok {
template = &SimpleMenu{}
}
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
}
}
xmlPath := filepath.Join(workDir, "dvd.xml")
if err := writeDVDAuthorXML(xmlPath, mpgPaths, region, aspect, chapters, menuMpg, menuButtons); err != nil {
return err
}
// Log chapter information
if len(chapters) > 0 {
if logFn != nil {
logFn(fmt.Sprintf("Final DVD structure: 1 title with %d chapters", len(chapters)))
for i, ch := range chapters {
logFn(fmt.Sprintf(" Chapter %d: %s at %s", i+1, ch.Title, formatChapterTime(ch.Timestamp)))
}
}
}
// Log the XML content for debugging
if xmlContent, err := os.ReadFile(xmlPath); err == nil {
logFn("Generated DVD XML:")
logFn(string(xmlContent))
}
logFn("Authoring DVD structure...")
logFn(fmt.Sprintf(">> dvdauthor -o %s -x %s", discRoot, xmlPath))
if err := runCommandWithLogger(ctx, "dvdauthor", []string{"-o", discRoot, "-x", xmlPath}, logFn); err != nil {
logFn(fmt.Sprintf("ERROR: dvdauthor failed: %v", err))
return fmt.Errorf("dvdauthor structure creation failed: %w", err)
}
accumulatedProgress += progressForOtherStep
progressFn(accumulatedProgress)
logFn("Building DVD tables...")
logFn(fmt.Sprintf(">> dvdauthor -o %s -T", discRoot))
if err := runCommandWithLogger(ctx, "dvdauthor", []string{"-o", discRoot, "-T"}, logFn); err != nil {
logFn(fmt.Sprintf("ERROR: dvdauthor -T failed: %v", err))
return fmt.Errorf("dvdauthor table build failed: %w", err)
}
accumulatedProgress += progressForOtherStep
progressFn(accumulatedProgress)
if err := os.MkdirAll(filepath.Join(discRoot, "AUDIO_TS"), 0755); err != nil {
return fmt.Errorf("failed to create AUDIO_TS: %w", err)
}
if makeISO {
// Create output directory for ISO file if it doesn't exist
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
return fmt.Errorf("failed to create ISO output directory: %w", err)
}
tool, args, err := buildISOCommand(outputPath, discRoot, title)
if err != nil {
logFn(fmt.Sprintf("ERROR: ISO tool not found: %v", err))
return fmt.Errorf("ISO creation setup failed: %w", err)
}
logFn("Creating ISO image...")
logFn(fmt.Sprintf(">> %s %s", tool, strings.Join(args, " ")))
if err := runCommandWithLogger(ctx, tool, args, logFn); err != nil {
logFn(fmt.Sprintf("ERROR: ISO creation failed: %v", err))
return fmt.Errorf("ISO creation failed: %w", err)
}
accumulatedProgress += progressForOtherStep
progressFn(accumulatedProgress)
// Verify ISO was created
if info, err := os.Stat(outputPath); err == nil {
logFn(fmt.Sprintf("ISO created successfully: %s (%d bytes)", filepath.Base(outputPath), info.Size()))
} else {
logFn(fmt.Sprintf("WARNING: ISO file verification failed: %v", err))
}
}
progressFn(100.0)
return nil
}
func runAuthorFFmpeg(ctx context.Context, args []string, duration float64, logFn func(string), progressFn func(float64)) error {
finalArgs := append([]string{"-progress", "pipe:1", "-nostats"}, args...)
cmd := exec.CommandContext(ctx, utils.GetFFmpegPath(), finalArgs...)
utils.ApplyNoWindow(cmd)
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("ffmpeg stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("ffmpeg stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("ffmpeg start failed: %w", err)
}
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
if logFn != nil {
logFn(scanner.Text())
}
}
}()
go func() {
defer wg.Done()
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
continue
}
key, val := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
if key == "out_time_ms" {
if ms, err := strconv.ParseInt(val, 10, 64); err == nil && ms > 0 {
currentSec := float64(ms) / 1000000.0
if duration > 0 {
stepPct := (currentSec / duration) * 100.0
if stepPct > 100 {
stepPct = 100
}
if progressFn != nil {
progressFn(stepPct)
}
}
}
}
if logFn != nil {
logFn(line)
}
}
}()
err = cmd.Wait()
wg.Wait()
if err != nil {
return fmt.Errorf("ffmpeg failed: %w", err)
}
return nil
}
func (s *appState) executeAuthorJob(ctx context.Context, job *queue.Job, progressCallback func(float64)) error {
cfg := job.Config
if cfg == nil {
return fmt.Errorf("author job config missing")
}
if videoTSPath := strings.TrimSpace(toString(cfg["videoTSPath"])); videoTSPath != "" {
outputPath := toString(cfg["outputPath"])
title := toString(cfg["title"])
if err := ensureAuthorDependencies(true, false); err != nil {
return err
}
logFile, logPath, logErr := createAuthorLog([]string{videoTSPath}, outputPath, true, "", "", title)
if logErr != nil {
logging.Debug(logging.CatSystem, "author log open failed: %v", logErr)
} else {
job.LogPath = logPath
s.authorLogFilePath = logPath // Store for UI access
defer logFile.Close()
}
appendLog := func(line string) {
if logFile != nil {
fmt.Fprintln(logFile, line)
}
app := fyne.CurrentApp()
if app != nil && app.Driver() != nil {
app.Driver().DoFromGoroutine(func() {
s.appendAuthorLog(line)
}, false)
}
}
updateProgress := func(percent float64) {
progressCallback(percent)
app := fyne.CurrentApp()
if app != nil && app.Driver() != nil {
app.Driver().DoFromGoroutine(func() {
s.setAuthorProgress(percent)
}, false)
}
}
appendLog(fmt.Sprintf("Authoring ISO from VIDEO_TS: %s", videoTSPath))
// Create output directory for ISO file if it doesn't exist
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
return fmt.Errorf("failed to create ISO output directory: %w", err)
}
tool, args, err := buildISOCommand(outputPath, videoTSPath, title)
if err != nil {
return err
}
appendLog(fmt.Sprintf(">> %s %s", tool, strings.Join(args, " ")))
updateProgress(10)
if err := runCommandWithLogger(ctx, tool, args, appendLog); err != nil {
return err
}
updateProgress(100)
appendLog("ISO creation completed successfully.")
return nil
}
rawPaths, _ := cfg["paths"].([]interface{})
var paths []string
for _, p := range rawPaths {
paths = append(paths, toString(p))
}
if len(paths) == 0 {
if path, ok := cfg["paths"].([]string); ok {
paths = append(paths, path...)
}
}
if len(paths) == 0 {
if input, ok := cfg["inputPath"].(string); ok && input != "" {
paths = append(paths, input)
}
}
if len(paths) == 0 {
return fmt.Errorf("no input paths for author job")
}
region := toString(cfg["region"])
aspect := toString(cfg["aspect"])
title := toString(cfg["title"])
outputPath := toString(cfg["outputPath"])
makeISO, _ := cfg["makeISO"].(bool)
treatAsChapters, _ := cfg["treatAsChapters"].(bool)
createMenu := toBool(cfg["createMenu"])
if err := ensureAuthorDependencies(makeISO, createMenu); err != nil {
return err
}
var clips []authorClip
if rawClips, ok := cfg["clips"].([]interface{}); ok {
for _, rc := range rawClips {
if m, ok := rc.(map[string]interface{}); ok {
clips = append(clips, authorClip{
Path: toString(m["path"]),
DisplayName: toString(m["displayName"]),
Duration: toFloat(m["duration"]),
ChapterTitle: toString(m["chapterTitle"]),
})
}
}
}
var chapters []authorChapter
if rawChapters, ok := cfg["chapters"].([]interface{}); ok {
for _, rc := range rawChapters {
if m, ok := rc.(map[string]interface{}); ok {
chapters = append(chapters, authorChapter{
Timestamp: toFloat(m["timestamp"]),
Title: toString(m["title"]),
Auto: toBool(m["auto"]),
})
}
}
}
logFile, logPath, logErr := createAuthorLog(paths, outputPath, makeISO, region, aspect, title)
if logErr != nil {
logging.Debug(logging.CatSystem, "author log open failed: %v", logErr)
} else {
job.LogPath = logPath
s.authorLogFilePath = logPath // Store for UI access
defer logFile.Close()
}
appendLog := func(line string) {
if logFile != nil {
fmt.Fprintln(logFile, line)
}
app := fyne.CurrentApp()
if app != nil && app.Driver() != nil {
app.Driver().DoFromGoroutine(func() {
s.appendAuthorLog(line)
}, false)
}
}
updateProgress := func(percent float64) {
progressCallback(percent)
app := fyne.CurrentApp()
if app != nil && app.Driver() != nil {
app.Driver().DoFromGoroutine(func() {
s.setAuthorProgress(percent)
}, false)
}
}
appendLog(fmt.Sprintf("Authoring started: %s", time.Now().Format(time.RFC3339)))
appendLog(fmt.Sprintf("Inputs: %s", strings.Join(paths, ", ")))
appendLog(fmt.Sprintf("Output: %s", outputPath))
if makeISO {
appendLog("Output mode: ISO")
} else {
appendLog("Output mode: VIDEO_TS")
}
app := fyne.CurrentApp()
if app != nil && app.Driver() != nil {
app.Driver().DoFromGoroutine(func() {
s.setAuthorStatus("Authoring in progress...")
}, false)
}
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)
if app != nil && app.Driver() != nil {
app.Driver().DoFromGoroutine(func() {
s.setAuthorStatus(friendly)
}, false)
}
return fmt.Errorf("%s\nSee Authoring Log for details.", friendly)
}
if app != nil && app.Driver() != nil {
app.Driver().DoFromGoroutine(func() {
s.setAuthorStatus("Authoring complete")
s.setAuthorProgress(100)
}, false)
}
appendLog("Authoring completed successfully.")
return nil
}
func authorFriendlyError(err error) string {
if err == nil {
return "Authoring failed"
}
msg := err.Error()
lower := strings.ToLower(msg)
switch {
case strings.Contains(lower, "disk quota exceeded"),
strings.Contains(lower, "no space left"),
strings.Contains(lower, "not enough space"):
return "Not enough disk space for authoring output."
case strings.Contains(lower, "output folder must be empty"):
return "Output folder must be empty before authoring."
case strings.Contains(lower, "dvdauthor not found"):
return "dvdauthor not found. Install DVD authoring tools."
case strings.Contains(lower, "mkisofs"),
strings.Contains(lower, "genisoimage"),
strings.Contains(lower, "xorriso"):
return "ISO tool not found. Install mkisofs/genisoimage/xorriso."
case strings.Contains(lower, "permission denied"):
return "Permission denied writing to output folder."
case strings.Contains(lower, "ffmpeg"):
return "FFmpeg failed during DVD encoding."
default:
if len(msg) > 140 {
return "Authoring failed. See Authoring Log for details."
}
return msg
}
}
func prepareDiscRoot(path string) error {
if err := os.MkdirAll(path, 0755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
entries, err := os.ReadDir(path)
if err != nil {
return fmt.Errorf("failed to read output directory: %w", err)
}
if len(entries) > 0 {
return fmt.Errorf("output folder must be empty: %s", path)
}
return nil
}
func encodeAuthorSources(paths []string, region, aspect, workDir string) ([]string, error) {
var mpgPaths []string
for i, path := range paths {
idx := i + 1
outPath := filepath.Join(workDir, fmt.Sprintf("title_%02d.mpg", idx))
src, err := probeVideo(path)
if err != nil {
return nil, fmt.Errorf("failed to probe %s: %w", filepath.Base(path), err)
}
args := buildAuthorFFmpegArgs(path, outPath, region, aspect, src.IsProgressive())
if err := runCommand(utils.GetFFmpegPath(), args); err != nil {
return nil, err
}
mpgPaths = append(mpgPaths, outPath)
}
return mpgPaths, nil
}
func buildAuthorFFmpegArgs(inputPath, outputPath, region, aspect string, progressive bool) []string {
width := 720
height := 480
fps := "30000/1001"
gop := "15"
bitrate := "6000k"
maxrate := "9000k"
if region == "PAL" {
height = 576
fps = "25"
gop = "12"
bitrate = "8000k"
maxrate = "9500k"
}
vf := []string{
fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease", width, height),
fmt.Sprintf("pad=%d:%d:(ow-iw)/2:(oh-ih)/2", width, height),
fmt.Sprintf("setdar=%s", aspect),
"setsar=1",
fmt.Sprintf("fps=%s", fps),
}
args := []string{
"-y",
"-hide_banner",
"-loglevel", "error",
"-i", inputPath,
"-vf", strings.Join(vf, ","),
"-c:v", "mpeg2video",
"-r", fps,
"-b:v", bitrate,
"-maxrate", maxrate,
"-bufsize", "1835k",
"-g", gop,
"-pix_fmt", "yuv420p",
}
if !progressive {
args = append(args, "-flags", "+ilme+ildct")
}
args = append(args,
"-c:a", "ac3",
"-b:a", "192k",
"-ar", "48000",
"-ac", "2",
"-f", "dvd", // DVD-compliant MPEG-PS format
"-muxrate", "10080000", // DVD mux rate (10.08 Mbps)
"-packetsize", "2048", // DVD packet size
outputPath,
)
return args
}
func writeDVDAuthorXML(path string, mpgPaths []string, region, aspect string, chapters []authorChapter, menuMpg string, menuButtons []dvdMenuButton) error {
format := strings.ToLower(region)
if format != "pal" {
format = "ntsc"
}
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(" <titleset>\n")
b.WriteString(" <titles>\n")
b.WriteString(fmt.Sprintf(" <video format=\"%s\" aspect=\"%s\" />\n", format, aspect))
for _, mpg := range mpgPaths {
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)))
}
b.WriteString(" </pgc>\n")
}
b.WriteString(" </titles>\n")
b.WriteString(" </titleset>\n")
b.WriteString("</dvdauthor>\n")
if err := os.WriteFile(path, []byte(b.String()), 0644); err != nil {
return fmt.Errorf("failed to write dvdauthor XML: %w", err)
}
return nil
}
func escapeXMLAttr(value string) string {
var b strings.Builder
if err := xml.EscapeText(&b, []byte(value)); err != nil {
return strings.ReplaceAll(value, "\"", "&quot;")
}
escaped := b.String()
return strings.ReplaceAll(escaped, "\"", "&quot;")
}
func ensureAuthorDependencies(makeISO bool, createMenu bool) error {
if err := ensureExecutable(utils.GetFFmpegPath(), "ffmpeg"); err != nil {
return err
}
if _, err := exec.LookPath("dvdauthor"); err != nil {
return fmt.Errorf("dvdauthor not found in PATH")
}
if createMenu {
if _, err := exec.LookPath("spumux"); err != nil {
return fmt.Errorf("spumux not found in PATH")
}
}
if makeISO {
if _, _, err := buildISOCommand("output.iso", "output", "VIDEO_TOOLS"); err != nil {
return err
}
}
return nil
}
func createAuthorLog(inputs []string, outputPath string, makeISO bool, region, aspect, title string) (*os.File, string, error) {
base := strings.TrimSuffix(filepath.Base(outputPath), filepath.Ext(outputPath))
if base == "" {
base = "author"
}
// Sanitize log filename to remove special characters
base = sanitizeForPath(base)
if base == "" {
base = "author"
}
logPath := filepath.Join(getLogsDir(), base+"-author"+conversionLogSuffix)
if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
return nil, logPath, fmt.Errorf("create log dir: %w", err)
}
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return nil, logPath, err
}
mode := "VIDEO_TS"
if makeISO {
mode = "ISO"
}
header := fmt.Sprintf(`VideoTools Authoring Log
Started: %s
Inputs: %s
Output: %s
Mode: %s
Region: %s
Aspect: %s
Title: %s
`, time.Now().Format(time.RFC3339), strings.Join(inputs, ", "), outputPath, mode, region, aspect, title)
if _, err := f.WriteString(header); err != nil {
_ = f.Close()
return nil, logPath, err
}
return f, logPath, nil
}
func runCommandWithLogger(ctx context.Context, name string, args []string, logFn func(string)) error {
cmd := exec.CommandContext(ctx, name, args...)
utils.ApplyNoWindow(cmd)
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("%s stdout: %w", name, err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("%s stderr: %w", name, err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("%s start: %w", name, err)
}
var wg sync.WaitGroup
stream := func(r io.Reader) {
defer wg.Done()
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
if logFn != nil {
logFn(scanner.Text())
}
}
}
wg.Add(2)
go stream(stdout)
go stream(stderr)
err = cmd.Wait()
wg.Wait()
if err != nil {
return fmt.Errorf("%s failed: %w", name, err)
}
return nil
}
func toBool(v interface{}) bool {
switch val := v.(type) {
case bool:
return val
case string:
return strings.EqualFold(val, "true")
case float64:
return val != 0
case int:
return val != 0
default:
return false
}
}
func ensureExecutable(path, label string) error {
if filepath.IsAbs(path) {
if _, err := os.Stat(path); err == nil {
return nil
}
}
if _, err := exec.LookPath(path); err == nil {
return nil
}
return fmt.Errorf("%s not found (%s)", label, path)
}
func buildISOCommand(outputISO, discRoot, title string) (string, []string, error) {
tool, prefixArgs, err := findISOTool()
if err != nil {
return "", nil, err
}
label := isoVolumeLabel(title)
args := append([]string{}, prefixArgs...)
args = append(args, "-dvd-video", "-V", label, "-o", outputISO, discRoot)
return tool, args, nil
}
func findISOTool() (string, []string, error) {
if path, err := exec.LookPath("mkisofs"); err == nil {
return path, nil, nil
}
if path, err := exec.LookPath("genisoimage"); err == nil {
return path, nil, nil
}
if path, err := exec.LookPath("xorriso"); err == nil {
return path, []string{"-as", "mkisofs"}, nil
}
return "", nil, fmt.Errorf("mkisofs, genisoimage, or xorriso not found in PATH")
}
func isoVolumeLabel(title string) string {
label := strings.ToUpper(strings.TrimSpace(title))
if label == "" {
label = "VIDEO_TOOLS"
}
var b strings.Builder
for _, r := range label {
switch {
case r >= 'A' && r <= 'Z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '_' || r == '-':
b.WriteRune('_')
default:
b.WriteRune('_')
}
}
clean := strings.Trim(b.String(), "_")
if clean == "" {
clean = "VIDEO_TOOLS"
}
if len(clean) > 32 {
clean = clean[:32]
}
return clean
}
func runCommand(name string, args []string) error {
cmd := exec.Command(name, args...)
utils.ApplyNoWindow(cmd)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s failed: %s", name, strings.TrimSpace(string(output)))
}
return nil
}
func (s *appState) showChapterPreview(videoPath string, chapters []authorChapter, callback func(bool)) {
dlg := dialog.NewCustom("Chapter Preview", "Close", container.NewVBox(
widget.NewLabel(fmt.Sprintf("Detected %d chapters - generating thumbnails...", len(chapters))),
widget.NewProgressBarInfinite(),
), s.window)
dlg.Resize(fyne.NewSize(800, 600))
dlg.Show()
go func() {
// Limit preview to first 24 chapters for performance
previewCount := len(chapters)
if previewCount > 24 {
previewCount = 24
}
thumbnails := make([]fyne.CanvasObject, 0, previewCount)
for i := 0; i < previewCount; i++ {
ch := chapters[i]
thumbPath, err := extractChapterThumbnail(videoPath, ch.Timestamp)
if err != nil {
logging.Debug(logging.CatSystem, "failed to extract thumbnail at %.2f: %v", ch.Timestamp, err)
continue
}
img := canvas.NewImageFromFile(thumbPath)
img.FillMode = canvas.ImageFillContain
img.SetMinSize(fyne.NewSize(160, 90))
timeLabel := widget.NewLabel(fmt.Sprintf("%.2fs", ch.Timestamp))
timeLabel.Alignment = fyne.TextAlignCenter
thumbCard := container.NewVBox(
container.NewMax(img),
timeLabel,
)
thumbnails = append(thumbnails, thumbCard)
}
runOnUI(func() {
dlg.Hide()
if len(thumbnails) == 0 {
dialog.ShowError(fmt.Errorf("failed to generate chapter thumbnails"), s.window)
return
}
grid := container.NewGridWrap(fyne.NewSize(170, 120), thumbnails...)
scroll := container.NewVScroll(grid)
// scroll.SetMinSize(fyne.NewSize(780, 500)) // Removed for flexible sizing
infoText := fmt.Sprintf("Found %d chapters", len(chapters))
if len(chapters) > previewCount {
infoText += fmt.Sprintf(" (showing first %d)", previewCount)
}
info := widget.NewLabel(infoText)
info.Wrapping = fyne.TextWrapWord
var previewDlg *dialog.CustomDialog
acceptBtn := widget.NewButton("Accept Chapters", func() {
previewDlg.Hide()
callback(true)
})
acceptBtn.Importance = widget.HighImportance
rejectBtn := widget.NewButton("Reject", func() {
previewDlg.Hide()
callback(false)
})
content := container.NewBorder(
container.NewVBox(info, widget.NewSeparator()),
container.NewHBox(rejectBtn, acceptBtn),
nil,
nil,
scroll,
)
previewDlg = dialog.NewCustom("Chapter Preview", "Close", content, s.window)
previewDlg.Resize(fyne.NewSize(800, 600))
previewDlg.Show()
})
}()
}
func extractChapterThumbnail(videoPath string, timestamp float64) (string, error) {
tmpDir := filepath.Join(os.TempDir(), "videotools-chapter-thumbs")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return "", err
}
outputPath := filepath.Join(tmpDir, fmt.Sprintf("thumb_%.2f.jpg", timestamp))
args := []string{
"-ss", fmt.Sprintf("%.2f", timestamp),
"-i", videoPath,
"-frames:v", "1",
"-q:v", "2",
"-vf", "scale=320:180",
"-y",
outputPath,
}
cmd := exec.Command(utils.GetFFmpegPath(), args...)
utils.ApplyNoWindow(cmd)
if err := cmd.Run(); err != nil {
return "", err
}
return outputPath, nil
}
func runOnUI(fn func()) {
fn()
}