Allows visual verification of detected scene changes before accepting. Features: - Extracts thumbnail at each detected chapter timestamp - Displays first 24 chapters in scrollable grid (4 columns) - Shows timestamp below each thumbnail (160x90px previews) - Accept/Reject buttons to confirm or discard detection - Progress indicator during thumbnail generation Implementation: - showChapterPreview() function creates preview dialog - extractChapterThumbnail() uses FFmpeg to extract frame - Scales to 320x180, saves as JPEG in temp dir - Thumbnails generated in background, dialog updated when ready Performance: - Limits to 24 chapters for UI responsiveness - Shows "(showing first 24)" if more detected - Temp files stored in videotools-chapter-thumbs/ User workflow: 1. Adjust sensitivity slider 2. Click "Detect Scenes" 3. Review thumbnails to verify detection quality 4. Accept to use chapters, or Reject to try different sensitivity
2397 lines
64 KiB
Go
2397 lines
64 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"errors"
|
|
"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"
|
|
)
|
|
|
|
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()
|
|
|
|
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 {
|
|
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
|
|
|
|
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 = ""
|
|
state.authorVideoTSPath = ""
|
|
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()
|
|
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 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))
|
|
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()
|
|
})
|
|
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.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 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 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 = ""
|
|
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) startAuthorGeneration() {
|
|
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, true); 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)
|
|
})
|
|
}
|
|
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) {
|
|
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)
|
|
return
|
|
}
|
|
s.generateAuthoring(paths, region, aspect, title, outputPath, false)
|
|
}
|
|
|
|
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")
|
|
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 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) 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()
|
|
}
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Remultiplex the MPEG to fix timestamps for DVD compliance
|
|
// This resolves "SCR moves backwards" errors from dvdauthor
|
|
remuxPath := filepath.Join(workDir, fmt.Sprintf("title_%02d_remux.mpg", i+1))
|
|
remuxArgs := []string{
|
|
"-fflags", "+genpts",
|
|
"-i", outPath,
|
|
"-c", "copy",
|
|
"-f", "dvd",
|
|
"-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)
|
|
}
|
|
|
|
// Remove original encode, use remuxed version
|
|
os.Remove(outPath)
|
|
mpgPaths = append(mpgPaths, remuxPath)
|
|
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")
|
|
}
|
|
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))
|
|
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",
|
|
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, "\"", """)
|
|
}
|
|
escaped := b.String()
|
|
return strings.ReplaceAll(escaped, "\"", """)
|
|
}
|
|
|
|
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()
|
|
}
|