VideoTools/author_module.go

1830 lines
48 KiB
Go

package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"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"
)
func buildAuthorView(state *appState) fyne.CanvasObject {
state.stopPreview()
state.lastModule = state.active
state.active = "author"
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()
topBar := ui.TintedBar(authorColor, container.NewHBox(backBtn, layout.NewSpacer(), 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 {
list := container.NewVBox()
listScroll := container.NewVScroll(list)
var rebuildList func()
var emptyOverlay *fyne.Container
rebuildList = func() {
list.Objects = nil
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()
}
}
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),
)
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 = ""
rebuildList()
state.updateAuthorSummary()
})
clearBtn.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()
})
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()
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 here\nor click 'Add Files' to select videos")
emptyLabel.Alignment = fyne.TextAlignCenter
emptyOverlay = container.NewCenter(emptyLabel)
listArea := container.NewMax(dropTarget, emptyOverlay)
controls := container.NewBorder(
widget.NewLabel("Videos:"),
container.NewVBox(chapterToggle, container.NewHBox(addBtn, clearBtn, 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")
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)))
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))
}
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
}
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()
})
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()
})
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()
})
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()
}
createMenuCheck := widget.NewCheck("Create DVD Menu", func(checked bool) {
state.authorCreateMenu = checked
state.updateAuthorSummary()
})
createMenuCheck.SetChecked(state.authorCreateMenu)
discSizeSelect := widget.NewSelect([]string{"DVD5", "DVD9"}, func(value string) {
state.authorDiscSize = value
state.updateAuthorSummary()
})
if state.authorDiscSize == "" {
discSizeSelect.SetSelected("DVD5")
} else {
discSizeSelect.SetSelected(state.authorDiscSize)
}
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,
)
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()
})
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
controls := container.NewVBox(
widget.NewLabel("Generate DVD/ISO:"),
widget.NewSeparator(),
summaryLabel,
widget.NewSeparator(),
widget.NewLabel("Status:"),
statusLabel,
progressBar,
widget.NewSeparator(),
widget.NewLabel("Authoring Log:"),
logScroll,
widget.NewSeparator(),
generateBtn,
)
return container.NewPadded(controls)
}
func authorSummary(state *appState) string {
summary := "Ready to generate:\n\n"
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 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.updateAuthorSummary()
}
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"
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 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 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 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",
output,
}
return runCommand(platformConfig.FFmpegPath, args)
}
func (s *appState) resetAuthorLog() {
s.authorLogText = ""
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
}
s.authorLogText += line + "\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) startAuthorGeneration() {
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)
continuePrompt := func() {
s.promptAuthorOutput(paths, region, aspect, title)
}
if len(warnings) > 0 {
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) {
outputType := strings.ToLower(strings.TrimSpace(s.authorOutputType))
if outputType == "" {
outputType = "dvd"
}
if outputType == "iso" {
dialog.ShowFileSave(func(writer fyne.URIWriteCloser, err error) {
if err != nil || writer == nil {
return
}
path := writer.URI().Path()
writer.Close()
if !strings.HasSuffix(strings.ToLower(path), ".iso") {
path += ".iso"
}
s.generateAuthoring(paths, region, aspect, title, path, true)
}, s.window)
return
}
dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) {
if err != nil || uri == nil {
return
}
discRoot := filepath.Join(uri.Path(), authorOutputFolderName(title, paths))
s.generateAuthoring(paths, region, aspect, title, discRoot, false)
}, s.window)
}
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 (s *appState) generateAuthoring(paths []string, region, aspect, title, outputPath string, makeISO bool) {
if err := s.addAuthorToQueue(paths, region, aspect, title, outputPath, makeISO, true); 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()
}
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 {
workDir, err := os.MkdirTemp(utils.TempDir(), "videotools-author-")
if err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(workDir)
discRoot := outputPath
var cleanup func()
if makeISO {
tempRoot, err := os.MkdirTemp(utils.TempDir(), "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
}
totalSteps := len(paths) + 2
if makeISO {
totalSteps++
}
step := 0
advance := func(message string) {
step++
if logFn != nil && message != "" {
logFn(message)
}
if progressFn != nil && totalSteps > 0 {
progressFn(float64(step) / float64(totalSteps) * 100.0)
}
}
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)
}
args := buildAuthorFFmpegArgs(path, outPath, region, aspect, src.IsProgressive())
if logFn != nil {
logFn(fmt.Sprintf(">> ffmpeg %s", strings.Join(args, " ")))
}
if err := runCommandWithLogger(ctx, platformConfig.FFmpegPath, args, logFn); err != nil {
return err
}
mpgPaths = append(mpgPaths, outPath)
advance("")
}
if len(chapters) == 0 && treatAsChapters && len(clips) > 1 {
chapters = chaptersFromClips(clips)
}
if len(chapters) == 0 && len(mpgPaths) == 1 {
if embed, err := extractChaptersFromFile(paths[0]); err == nil && len(embed) > 0 {
chapters = embed
}
}
if treatAsChapters && len(mpgPaths) > 1 {
concatPath := filepath.Join(workDir, "titles_joined.mpg")
if logFn != nil {
logFn("Concatenating chapters into a single title...")
}
if err := concatDVDMpg(mpgPaths, concatPath); err != nil {
return err
}
mpgPaths = []string{concatPath}
}
if len(mpgPaths) > 1 {
chapters = nil
}
xmlPath := filepath.Join(workDir, "dvd.xml")
if err := writeDVDAuthorXML(xmlPath, mpgPaths, region, aspect, chapters); err != nil {
return err
}
if logFn != nil {
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 {
return err
}
advance("")
if logFn != nil {
logFn("Building DVD tables...")
logFn(fmt.Sprintf(">> dvdauthor -o %s -T", discRoot))
}
if err := runCommandWithLogger(ctx, "dvdauthor", []string{"-o", discRoot, "-T"}, logFn); err != nil {
return err
}
advance("")
if err := os.MkdirAll(filepath.Join(discRoot, "AUDIO_TS"), 0755); err != nil {
return fmt.Errorf("failed to create AUDIO_TS: %w", err)
}
if makeISO {
tool, args, err := buildISOCommand(outputPath, discRoot, title)
if err != nil {
return err
}
if logFn != nil {
logFn("Creating ISO image...")
logFn(fmt.Sprintf(">> %s %s", tool, strings.Join(args, " ")))
}
if err := runCommandWithLogger(ctx, tool, args, logFn); err != nil {
return err
}
advance("")
}
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")
}
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
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",
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 runOnUI(fn func()) {
fn()
}