VideoTools/author_module.go
Stu Leak f8a9844b53 feat(author): Add Cancel Job button to Author module
Added a "Cancel Job" button to the Author module top bar that:
- Appears only when an Author job is currently running
- Uses DangerImportance styling for clear visual indication
- Cancels the running author job when clicked
- Hides automatically when no author job is running

This addresses the user's request for cancel buttons in every module
where it's relevant. The button provides immediate job cancellation
without needing to navigate to the queue view.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 22:09:25 -05:00

2735 lines
74 KiB
Go

package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"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"`
TreatAsChapters bool `json:"treatAsChapters"`
SceneThreshold float64 `json:"sceneThreshold"`
}
func defaultAuthorConfig() authorConfig {
return authorConfig{
OutputType: "dvd",
Region: "AUTO",
AspectRatio: "AUTO",
DiscSize: "DVD5",
Title: "",
CreateMenu: false,
TreatAsChapters: false,
SceneThreshold: 0.3,
}
}
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.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.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,
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"
}
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("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)
// 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("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()
}
createMenuCheck := widget.NewCheck("Create DVD Menu", func(checked bool) {
state.authorCreateMenu = checked
state.updateAuthorSummary()
state.persistAuthorConfig()
})
createMenuCheck.SetChecked(state.authorCreateMenu)
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)
createMenuCheck.SetChecked(state.authorCreateMenu)
}
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,
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,
createMenuCheck,
widget.NewSeparator(),
info,
widget.NewSeparator(),
container.NewHBox(resetBtn, loadCfgBtn, saveCfgBtn),
)
return container.NewPadded(controls)
}
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 := exec.CommandContext(ctx, platformConfig.FFmpegPath,
"-hide_banner",
"-loglevel", "info",
"-i", path,
"-vf", filter,
"-an",
"-f", "null",
"-",
)
utils.ApplyNoWindow(cmd)
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 := exec.CommandContext(ctx, platformConfig.FFprobePath,
"-v", "quiet",
"-print_format", "json",
"-show_chapters",
path,
)
utils.ApplyNoWindow(cmd)
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) {
// 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 || len(vobFiles) == 0 {
return nil, fmt.Errorf("no VOB files found in VIDEO_TS")
}
// Sort to get the first title set (usually the main feature)
sort.Strings(vobFiles)
mainVOB := vobFiles[0]
// Try to extract chapters from the main VOB using ffprobe
chapters, err := extractChaptersFromFile(mainVOB)
if err != nil {
return nil, err
}
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,
}
return runCommand(platformConfig.FFmpegPath, args)
}
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 state.authorCreateMenu {
warnings = append(warnings, "DVD menus are not implemented yet; the disc will play titles directly.")
}
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,
"chapterSource": s.authorChapterSource,
"subtitleTracks": append([]string{}, s.authorSubtitles...),
"additionalAudios": append([]string{}, s.authorAudioTracks...),
}
titleLabel := title
if strings.TrimSpace(titleLabel) == "" {
titleLabel = "DVD"
}
job := &queue.Job{
Type: queue.JobTypeAuthor,
Title: fmt.Sprintf("Author DVD: %s", titleLabel),
Description: fmt.Sprintf("Output: %s", utils.ShortenMiddle(filepath.Base(outputPath), 40)),
InputFile: paths[0],
OutputFile: outputPath,
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")
}
job := &queue.Job{
Type: queue.JobTypeAuthor,
Title: fmt.Sprintf("Author ISO: %s", title),
Description: fmt.Sprintf("VIDEO_TS -> %s", utils.ShortenMiddle(filepath.Base(outputPath), 40)),
InputFile: videoTSPath,
OutputFile: outputPath,
Config: map[string]interface{}{
"videoTSPath": videoTSPath,
"outputPath": outputPath,
"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, 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, platformConfig.FFmpegPath, 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))
}
}
}
xmlPath := filepath.Join(workDir, "dvd.xml")
if err := writeDVDAuthorXML(xmlPath, mpgPaths, region, aspect, chapters); 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, platformConfig.FFmpegPath, 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); 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)
if err := ensureAuthorDependencies(makeISO); 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, 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(platformConfig.FFmpegPath, 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) error {
format := strings.ToLower(region)
if format != "pal" {
format = "ntsc"
}
var b strings.Builder
b.WriteString("<dvdauthor>\n")
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) error {
if err := ensureExecutable(platformConfig.FFmpegPath, "ffmpeg"); err != nil {
return err
}
if _, err := exec.LookPath("dvdauthor"); err != nil {
return fmt.Errorf("dvdauthor 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"
}
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))
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(platformConfig.FFmpegPath, args...)
utils.ApplyNoWindow(cmd)
if err := cmd.Run(); err != nil {
return "", err
}
return outputPath, nil
}
func runOnUI(fn func()) {
fn()
}