From 588fc586a122a62acf136a4c0e3b96c90c83702c Mon Sep 17 00:00:00 2001 From: Stu Leak Date: Tue, 23 Dec 2025 20:47:10 -0500 Subject: [PATCH] Add authoring log/progress and queue job --- author_module.go | 451 ++++++++++++++++++++++++++++++++++++---- internal/queue/queue.go | 1 + main.go | 8 + 3 files changed, 424 insertions(+), 36 deletions(-) diff --git a/author_module.go b/author_module.go index 7654621..a93d0fa 100644 --- a/author_module.go +++ b/author_module.go @@ -7,11 +7,13 @@ import ( "encoding/json" "encoding/xml" "fmt" + "io" "os" "os/exec" "path/filepath" "sort" "strings" + "sync" "time" "fyne.io/fyne/v2" @@ -20,6 +22,8 @@ import ( "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" ) @@ -555,11 +559,35 @@ func buildAuthorDiscTab(state *appState) fyne.CanvasObject { 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, ) @@ -925,6 +953,51 @@ func concatDVDMpg(inputs []string, output string) error { 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 { @@ -1083,34 +1156,78 @@ func authorOutputFolderName(title string, paths []string) string { } func (s *appState) generateAuthoring(paths []string, region, aspect, title, outputPath string, makeISO bool) { - if err := ensureAuthorDependencies(makeISO); err != nil { + if err := s.addAuthorToQueue(paths, region, aspect, title, outputPath, makeISO, true); err != nil { dialog.ShowError(err, s.window) - return } - - progress := dialog.NewProgressInfinite("Authoring DVD", "Encoding sources...", s.window) - progress.Show() - - go func() { - err := s.runAuthoringPipeline(paths, region, aspect, title, outputPath, makeISO) - message := "DVD authoring complete." - if makeISO { - message = fmt.Sprintf("ISO image created:\n%s", outputPath) - } else { - message = fmt.Sprintf("DVD folders created:\n%s", outputPath) - } - runOnUI(func() { - progress.Hide() - if err != nil { - dialog.ShowError(err, s.window) - return - } - dialog.ShowInformation("Authoring Complete", message, s.window) - }) - }() } -func (s *appState) runAuthoringPipeline(paths []string, region, aspect, title, outputPath string, makeISO bool) error { +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) @@ -1137,25 +1254,56 @@ func (s *appState) runAuthoringPipeline(paths []string, region, aspect, title, o return err } - mpgPaths, err := encodeAuthorSources(paths, region, aspect, workDir) - if 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) + } } - chapters := s.authorChapters - if len(chapters) == 0 && s.authorTreatAsChapters && len(s.authorClips) > 1 { - chapters = chaptersFromClips(s.authorClips) - s.authorChapterSource = "clips" + 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 - s.authorChapterSource = "embedded" } } - if s.authorTreatAsChapters && len(mpgPaths) > 1 { + 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 } @@ -1171,13 +1319,23 @@ func (s *appState) runAuthoringPipeline(paths []string, region, aspect, title, o return err } - if err := runCommand("dvdauthor", []string{"-o", discRoot, "-x", xmlPath}); err != nil { + 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 err := runCommand("dvdauthor", []string{"-o", discRoot, "-T"}); err != nil { + 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) @@ -1188,11 +1346,144 @@ func (s *appState) runAuthoringPipeline(paths []string, region, aspect, title, o if err != nil { return err } - if err := runCommand(tool, args); err != nil { + 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 { + if app != nil && app.Driver() != nil { + app.Driver().DoFromGoroutine(func() { + s.setAuthorStatus("Authoring failed") + }, false) + } + return err + } + + if app != nil && app.Driver() != nil { + app.Driver().DoFromGoroutine(func() { + s.setAuthorStatus("Authoring complete") + s.setAuthorProgress(100) + }, false) + } + appendLog("Authoring completed successfully.") return nil } @@ -1338,6 +1629,94 @@ func ensureAuthorDependencies(makeISO bool) error { 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 { diff --git a/internal/queue/queue.go b/internal/queue/queue.go index f341434..4c76216 100644 --- a/internal/queue/queue.go +++ b/internal/queue/queue.go @@ -22,6 +22,7 @@ const ( JobTypeAudio JobType = "audio" JobTypeThumb JobType = "thumb" JobTypeSnippet JobType = "snippet" + JobTypeAuthor JobType = "author" ) // JobStatus represents the current state of a job diff --git a/main.go b/main.go index 9332ef8..6065739 100644 --- a/main.go +++ b/main.go @@ -920,6 +920,12 @@ type appState struct { authorChapterSource string // embedded, scenes, clips, manual authorChaptersRefresh func() // Refresh hook for chapter list UI authorDiscSize string // "DVD5" or "DVD9" + authorLogText string + authorLogEntry *widget.Entry + authorLogScroll *container.Scroll + authorProgress float64 + authorProgressBar *widget.ProgressBar + authorStatusLabel *widget.Label // Subtitles module state subtitleVideoPath string @@ -3216,6 +3222,8 @@ func (s *appState) jobExecutor(ctx context.Context, job *queue.Job, progressCall return s.executeThumbJob(ctx, job, progressCallback) case queue.JobTypeSnippet: return s.executeSnippetJob(ctx, job, progressCallback) + case queue.JobTypeAuthor: + return s.executeAuthorJob(ctx, job, progressCallback) default: return fmt.Errorf("unknown job type: %s", job.Type) }