From c8f4eec0d19c480186c0a104183b4e2540d9a820 Mon Sep 17 00:00:00 2001 From: Stu Leak Date: Sat, 27 Dec 2025 01:34:57 -0500 Subject: [PATCH] feat(author): Implement real-time progress, add to queue, clear title This commit introduces several enhancements to the Author module: - **Real-time Progress Reporting:** Implemented granular, real-time progress updates for FFmpeg encoding steps during DVD authoring. The progress bar now updates smoothly, reflecting the actual video processing. Progress calculation is weighted by video durations for accuracy. - **Add to Queue Functionality:** Added an 'Add to Queue' button to the Author module, allowing users to queue authoring jobs for later execution without immediate start. The authoring workflow was refactored to accept a 'startNow' parameter for this purpose. - **Clear Output Title:** Modified the 'Clear All' functionality to also reset the DVD Output Title, preventing accidental naming conflicts for new projects. Additionally, this commit includes a UI enhancement: - **Main Menu Categorization:** Relocated 'Author', 'Rip', and 'Blu-Ray' modules to a new 'Disc' category on the main menu, improving logical grouping. Fixes: - Corrected a missing argument error in a call to . - Added missing import in . Updates: - and have been updated to reflect these changes. --- DONE.md | 42 ++++++++--- TODO.md | 31 +++++++- author_module.go | 188 +++++++++++++++++++++++++++++++++-------------- main.go | 6 +- 4 files changed, 197 insertions(+), 70 deletions(-) diff --git a/DONE.md b/DONE.md index b68dc53..b9cc319 100644 --- a/DONE.md +++ b/DONE.md @@ -2,6 +2,27 @@ This file tracks completed features, fixes, and milestones. +## Version 0.1.0-dev20+ (2025-12-26) - Author Module & UI Enhancements + +### Features +- ✅ **Author Module - Real-time Progress Reporting** + - Implemented granular progress updates for FFmpeg encoding steps in the Author module. + - Progress bar now updates smoothly during video processing, providing better feedback. + - Weighted progress calculation based on video durations for accurate overall progress. + +- ✅ **Author Module - "Add to Queue" & Output Title Clear** + - Added an "Add to Queue" button to the Author module for non-immediate job execution. + - Refactored authoring workflow to support queuing jobs via a `startNow` parameter. + - Modified "Clear All" functionality to also clear the DVD Output Title, preventing naming conflicts. + +- ✅ **Main Menu - "Disc" Category for Author, Rip, and Blu-Ray** + - Relocated "Author", "Rip", and "Blu-Ray" buttons to a new "Disc" category on the main menu. + - Improved logical grouping of disc-related functionalities. + +- ✅ **Subtitles Module - Video File Path Population** + - Fixed an issue where dragging and dropping a video file onto the Subtitles module would not populate the "Video File Path" section. + - Ensured the video entry widget correctly reflects the dropped video's path. + ## Version 0.1.0-dev20+ (2025-12-23) - Player UX & Installer Polish ### Features (2025-12-23 Session) @@ -424,13 +445,11 @@ This file tracks completed features, fixes, and milestones. - Filter chain combination support ### Bug Fixes -- ✅ Fixed snippet duration issues with dual-mode approach - - Default Format: Uses stream copy (keyframe-level precision) - - Output Format: Re-encodes for frame-perfect duration -- ✅ Fixed container/codec mismatch in snippet generation - - Now properly matches container to codec (MP4 for h264, source format for stream copy) -- ✅ Fixed missing audio bitrate in thumbnail metadata -- ✅ Fixed contact sheet dimensions not accounting for padding +- ✅ Fixed incorrect thumbnail count in contact sheets (was generating 34 instead of 40 for 5x8 grid) +- ✅ Fixed frame selection FPS assumption (hardcoded 30fps removed) +- ✅ Fixed module visibility (added thumb module to enabled check) +- ✅ Fixed undefined function call (openFileManager → openFolder) +- ✅ Fixed dynamic total count not updating when changing grid dimensions - ✅ Added missing `strings` import to thumbnail/generator.go - ✅ Updated snippet UI labels for clarity (Default Format vs Output Format) @@ -709,7 +728,7 @@ This file tracks completed features, fixes, and milestones. - Braille character animations - Shows current task during build and install - Interactive path selection (system-wide or user-local) -- ✅ Added error dialogs with "Copy Error" button + - Added error dialogs with "Copy Error" button - One-click error message copying for debugging - Applied to all major error scenarios - Better user experience when reporting issues @@ -871,7 +890,6 @@ This file tracks completed features, fixes, and milestones. - ✅ Category-based logging (SYS, UI, MODULE, etc.) - ✅ Timestamp formatting - ✅ Debug output toggle via environment variable -- ✅ Comprehensive debug messages throughout application - ✅ Log file output (videotools.log) ### Error Handling @@ -907,6 +925,10 @@ This file tracks completed features, fixes, and milestones. - ✅ Audio decoding and playback - ✅ Synchronization between audio and video - ✅ Embedded playback within application window +- ✅ Seek functionality with progress bar +- ✅ Player window sizing based on video aspect ratio +- ✅ Frame pump system for smooth playback +- ✅ Audio/video synchronization - ✅ Checkpoint system for playback position ### UI/UX @@ -1021,4 +1043,4 @@ This file tracks completed features, fixes, and milestones. --- -*Last Updated: 2025-12-21* +*Last Updated: 2025-12-21* \ No newline at end of file diff --git a/TODO.md b/TODO.md index 397909c..04b39b7 100644 --- a/TODO.md +++ b/TODO.md @@ -41,6 +41,8 @@ This file tracks upcoming features, improvements, and known issues. - Lossless option only for H.265/AV1 - Dynamic dropdown based on codec - Lossless + Target Size mode support + - Dynamic dropdown based on codec + - Lossless + Target Size mode support - Audio bitrate estimation when metadata is missing - Target size unit selector and numeric entry - Snippet history updates in sidebar @@ -70,7 +72,7 @@ This file tracks upcoming features, improvements, and known issues. - Frame interpolation presets in Filters with Upscale linkage - Real-ESRGAN AI upscale controls with ncnn pipeline (models, presets, tiles, TTA) -*Last Updated: 2025-12-21* +*Last Updated: 2025-12-26* ## Priority Features for dev20+ @@ -112,7 +114,30 @@ This file tracks upcoming features, improvements, and known issues. - Creative effects (grayscale, vignette) - Real-time preview system -- [ ] **DVD Authoring module** +- [ ] **Upscale module implementation** + - Design UI for upscaling + - Implement traditional scaling (Lanczos, Bicubic) + - Integrate Waifu2x (if feasible) + - Integrate Real-ESRGAN (if feasible) + - Add resolution presets + - Quality vs. speed slider + - Before/after comparison + - Batch upscaling + +- [ ] **Audio module implementation** + - Design audio extraction UI + - Implement audio track extraction + - Audio track replacement/addition + - Multi-track management + - Volume normalization + - Audio delay correction + - Format conversion + - Channel mapping + - Audio-only operations + +- [x] **DVD Authoring module** + - [x] **Real-time progress reporting for FFmpeg encoding** + - [x] **"Add to Queue" and "Clear Output Title" functionality** - Output VIDEO_TS folder + burn-ready ISO - Auto-detect NTSC/PAL with manual override - Preserve all audio tracks @@ -844,4 +869,4 @@ Built-in Video File Explorer/Manager for comprehensive file management without l - [ ] AI upscaling integration options - [ ] Disc copy protection legal landscape - [ ] Cross-platform video codecs support -- [ ] HDR/Dolby Vision handling +- [ ] HDR/Dolby Vision handling \ No newline at end of file diff --git a/author_module.go b/author_module.go index 238e7e8..88bf2f8 100644 --- a/author_module.go +++ b/author_module.go @@ -13,6 +13,7 @@ import ( "os/exec" "path/filepath" "sort" + "strconv" "strings" "sync" "time" @@ -250,17 +251,27 @@ func buildVideoClipsTab(state *appState) fyne.CanvasObject { state.authorChapters = nil state.authorChapterSource = "" state.authorVideoTSPath = "" + state.authorTitle = "" rebuildList() state.updateAuthorSummary() }) clearBtn.Importance = widget.MediumImportance + addQueueBtn := widget.NewButton("Add to Queue", func() { + if len(state.authorClips) == 0 { + dialog.ShowInformation("No Clips", "Please add video clips first", state.window) + return + } + state.startAuthorGeneration(false) + }) + addQueueBtn.Importance = widget.MediumImportance + compileBtn := widget.NewButton("COMPILE TO DVD", func() { if len(state.authorClips) == 0 { dialog.ShowInformation("No Clips", "Please add video clips first", state.window) return } - state.startAuthorGeneration() + state.startAuthorGeneration(true) }) compileBtn.Importance = widget.HighImportance @@ -302,7 +313,7 @@ func buildVideoClipsTab(state *appState) fyne.CanvasObject { controls := container.NewBorder( widget.NewLabel("Videos:"), - container.NewVBox(chapterToggle, container.NewHBox(addBtn, clearBtn, compileBtn)), + container.NewVBox(chapterToggle, container.NewHBox(addBtn, clearBtn, addQueueBtn, compileBtn)), nil, nil, listArea, @@ -730,7 +741,7 @@ func buildAuthorDiscTab(state *appState) fyne.CanvasObject { dialog.ShowInformation("No Content", "Please add video clips or select a single video file", state.window) return } - state.startAuthorGeneration() + state.startAuthorGeneration(true) }) generateBtn.Importance = widget.HighImportance @@ -1258,7 +1269,7 @@ func (s *appState) setAuthorProgress(percent float64) { } } -func (s *appState) startAuthorGeneration() { +func (s *appState) startAuthorGeneration(startNow bool) { if s.authorVideoTSPath != "" { title := authorOutputTitle(s) outputPath := authorDefaultOutputPath("iso", title, []string{s.authorVideoTSPath}) @@ -1266,7 +1277,7 @@ func (s *appState) startAuthorGeneration() { dialog.ShowError(fmt.Errorf("failed to resolve output path"), s.window) return } - if err := s.addAuthorVideoTSToQueue(s.authorVideoTSPath, title, outputPath, true); err != nil { + if err := s.addAuthorVideoTSToQueue(s.authorVideoTSPath, title, outputPath, startNow); err != nil { dialog.ShowError(err, s.window) } return @@ -1296,7 +1307,7 @@ func (s *appState) startAuthorGeneration() { } continuePrompt := func() { uiCall(func() { - s.promptAuthorOutput(paths, region, aspect, title) + s.promptAuthorOutput(paths, region, aspect, title, startNow) }) } if len(warnings) > 0 { @@ -1313,7 +1324,7 @@ func (s *appState) startAuthorGeneration() { continuePrompt() } -func (s *appState) promptAuthorOutput(paths []string, region, aspect, title string) { +func (s *appState) promptAuthorOutput(paths []string, region, aspect, title string, startNow bool) { outputType := strings.ToLower(strings.TrimSpace(s.authorOutputType)) if outputType == "" { outputType = "dvd" @@ -1321,10 +1332,10 @@ func (s *appState) promptAuthorOutput(paths []string, region, aspect, title stri outputPath := authorDefaultOutputPath(outputType, title, paths) if outputType == "iso" { - s.generateAuthoring(paths, region, aspect, title, outputPath, true) + s.generateAuthoring(paths, region, aspect, title, outputPath, true, startNow) return } - s.generateAuthoring(paths, region, aspect, title, outputPath, false) + s.generateAuthoring(paths, region, aspect, title, outputPath, false, startNow) } func authorWarnings(state *appState) []string { @@ -1502,8 +1513,8 @@ func uniqueFilePath(path string) string { 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 { +func (s *appState) generateAuthoring(paths []string, region, aspect, title, outputPath string, makeISO, startNow bool) { + if err := s.addAuthorToQueue(paths, region, aspect, title, outputPath, makeISO, startNow); err != nil { dialog.ShowError(err, s.window) } } @@ -1636,20 +1647,22 @@ func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, reg return err } - totalSteps := len(paths) + 2 + var totalDuration float64 + for _, path := range paths { + src, err := probeVideo(path) + if err == nil { + totalDuration += src.Duration + } + } + + encodingProgressShare := 80.0 + otherStepsProgressShare := 20.0 + otherStepsCount := 2.0 if makeISO { - 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) - } + otherStepsCount++ } + progressForOtherStep := otherStepsProgressShare / otherStepsCount + var accumulatedProgress float64 var mpgPaths []string for i, path := range paths { @@ -1661,36 +1674,43 @@ func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, reg if err != nil { return fmt.Errorf("failed to probe %s: %w", filepath.Base(path), err) } + + clipProgressShare := 0.0 + if totalDuration > 0 { + clipProgressShare = (src.Duration / totalDuration) * encodingProgressShare + } + + ffmpegProgressFn := func(stepPct float64) { + overallPct := accumulatedProgress + (stepPct / 100.0 * clipProgressShare) + if progressFn != nil { + progressFn(overallPct) + } + } + args := buildAuthorFFmpegArgs(path, outPath, region, aspect, src.IsProgressive()) if logFn != nil { logFn(fmt.Sprintf(">> ffmpeg %s", strings.Join(args, " "))) } - if err := runCommandWithLogger(ctx, platformConfig.FFmpegPath, args, logFn); err != nil { + + if err := runAuthorFFmpeg(ctx, args, src.Duration, logFn, ffmpegProgressFn); 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, + accumulatedProgress += clipProgressShare + if progressFn != nil { + progressFn(accumulatedProgress) } + + remuxPath := filepath.Join(workDir, fmt.Sprintf("title_%02d_remux.mpg", i+1)) + remuxArgs := []string{"-fflags", "+genpts", "-i", outPath, "-c", "copy", "-f", "dvd", "-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 { @@ -1701,7 +1721,6 @@ func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, reg chapters = embed } } - if treatAsChapters && len(mpgPaths) > 1 { concatPath := filepath.Join(workDir, "titles_joined.mpg") if logFn != nil { @@ -1712,7 +1731,6 @@ func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, reg } mpgPaths = []string{concatPath} } - if len(mpgPaths) > 1 { chapters = nil } @@ -1722,23 +1740,21 @@ func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, reg return err } - if logFn != nil { - logFn("Authoring DVD structure...") - logFn(fmt.Sprintf(">> dvdauthor -o %s -x %s", discRoot, xmlPath)) - } + 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("") + accumulatedProgress += progressForOtherStep + progressFn(accumulatedProgress) - if logFn != nil { - logFn("Building DVD tables...") - logFn(fmt.Sprintf(">> dvdauthor -o %s -T", discRoot)) - } + 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("") + accumulatedProgress += progressForOtherStep + progressFn(accumulatedProgress) if err := os.MkdirAll(filepath.Join(discRoot, "AUDIO_TS"), 0755); err != nil { return fmt.Errorf("failed to create AUDIO_TS: %w", err) @@ -1749,19 +1765,83 @@ func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, reg if err != nil { return err } - if logFn != nil { - logFn("Creating ISO image...") - logFn(fmt.Sprintf(">> %s %s", tool, strings.Join(args, " "))) - } + 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("") + accumulatedProgress += progressForOtherStep + progressFn(accumulatedProgress) } + progressFn(100.0) return nil } +func runAuthorFFmpeg(ctx context.Context, args []string, duration float64, logFn func(string), progressFn func(float64)) error { + finalArgs := append([]string{"-progress", "pipe:1", "-nostats"}, args...) + cmd := exec.CommandContext(ctx, platformConfig.FFmpegPath, finalArgs...) + utils.ApplyNoWindow(cmd) + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("ffmpeg stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("ffmpeg stderr pipe: %w", err) + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("ffmpeg start failed: %w", err) + } + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + if logFn != nil { + logFn(scanner.Text()) + } + } + }() + go func() { + defer wg.Done() + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + parts := strings.SplitN(line, "=", 2) + if len(parts) < 2 { + continue + } + key, val := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) + if key == "out_time_ms" { + if ms, err := strconv.ParseInt(val, 10, 64); err == nil && ms > 0 { + currentSec := float64(ms) / 1000000.0 + if duration > 0 { + stepPct := (currentSec / duration) * 100.0 + if stepPct > 100 { + stepPct = 100 + } + if progressFn != nil { + progressFn(stepPct) + } + } + } + } + if logFn != nil { + logFn(line) + } + } + }() + err = cmd.Wait() + wg.Wait() + if err != nil { + return fmt.Errorf("ffmpeg failed: %w", err) + } + return nil +} + + func (s *appState) executeAuthorJob(ctx context.Context, job *queue.Job, progressCallback func(float64)) error { cfg := job.Config if cfg == nil { diff --git a/main.go b/main.go index f46b7df..1328f02 100644 --- a/main.go +++ b/main.go @@ -86,9 +86,9 @@ var ( {"filters", "Filters", utils.MustHex("#44FF88"), "Convert", modules.HandleFilters}, // Green {"upscale", "Upscale", utils.MustHex("#AAFF44"), "Advanced", modules.HandleUpscale}, // Yellow-Green {"audio", "Audio", utils.MustHex("#FFD744"), "Convert", modules.HandleAudio}, // Yellow - {"author", "Author", utils.MustHex("#FFAA44"), "DVD", modules.HandleAuthor}, // Orange - {"rip", "Rip", utils.MustHex("#FF9944"), "DVD", modules.HandleRip}, // Orange - {"bluray", "Blu-Ray", utils.MustHex("#4D7CFE"), "Blu-Ray", modules.HandleBluRay}, // Blue + {"author", "Author", utils.MustHex("#FFAA44"), "Disc", modules.HandleAuthor}, // Orange + {"rip", "Rip", utils.MustHex("#FF9944"), "Disc", modules.HandleRip}, // Orange + {"bluray", "Blu-Ray", utils.MustHex("#4D7CFE"), "Disc", modules.HandleBluRay}, // Blue {"subtitles", "Subtitles", utils.MustHex("#44A6FF"), "Convert", modules.HandleSubtitles}, // Azure {"thumb", "Thumb", utils.MustHex("#FF8844"), "Screenshots", modules.HandleThumb}, // Orange {"compare", "Compare", utils.MustHex("#FF44AA"), "Inspect", modules.HandleCompare}, // Pink