From 7369e5fe6a39f245382b0a54a4fb20c055ab8b2a Mon Sep 17 00:00:00 2001 From: Stu Leak Date: Tue, 6 Jan 2026 17:56:38 -0500 Subject: [PATCH] Document authoring content types and galleries --- diagnostic_tools/diagnostic_tool.go | 26 -- docs/AUTHOR_MODULE.md | 53 ++++ docs/ROADMAP.md | 4 + internal/player/unified_player_adapter.go | 349 ++++++++++++++++++++++ main.go | 170 ++++++++++- 5 files changed, 570 insertions(+), 32 deletions(-) delete mode 100644 diagnostic_tools/diagnostic_tool.go create mode 100644 internal/player/unified_player_adapter.go diff --git a/diagnostic_tools/diagnostic_tool.go b/diagnostic_tools/diagnostic_tool.go deleted file mode 100644 index 9c98482..0000000 --- a/diagnostic_tools/diagnostic_tool.go +++ /dev/null @@ -1,26 +0,0 @@ -package main - -import ( - "fmt" - "os" - "path/filepath" -) - -func main() { - if len(os.Args) < 2 { - fmt.Println("Usage: ./diagnostic_tool ") - return - } - - videoPath := os.Args[1] - - fmt.Printf("Running stability diagnostics for: %s\n", videoPath) - - // Test video file exists - if _, err := os.Stat(videoPath); os.IsNotExist(err) { - fmt.Printf("Error: video file not found: %v\n", err) - return - } - - fmt.Println("Diagnostics completed successfully") -} diff --git a/docs/AUTHOR_MODULE.md b/docs/AUTHOR_MODULE.md index 398f97f..6556083 100644 --- a/docs/AUTHOR_MODULE.md +++ b/docs/AUTHOR_MODULE.md @@ -23,6 +23,59 @@ That's it. The DVD will play in any player. --- +## Content Types: Feature, Extras, Galleries + +The Author module treats every import as a **content type**, not just a file: + +- **Feature**: the main movie title (supports chapters and chapter menus) +- **Extra**: bonus video titles (no chapters, separate DVD titles) +- **Gallery**: still-image slideshows (photos, artwork, stills) + +### Default Behavior + +- All imported videos default to **Feature** +- You can change each video’s **Content Type** using the per-item dropdown + +### Extras Subtypes + +Extras must be assigned a subtype so they can be grouped in menus: + +- Behind the Scenes +- Deleted Scenes +- Featurettes +- Interviews +- Trailers +- Commentary +- Other + +When a video is switched to **Extra**: + +- It is removed from Feature and chapter logic +- It becomes a separate DVD title under **Extras** + +Galleries behave like DVD-accurate still slideshows: + +- Next / Previous image navigation +- Optional auto-advance +- Separate from videos and chapters + +--- + +## Chapter Thumbnails (Automatic, Feature Only) + +Every **Feature** chapter gets a thumbnail image for the Chapters menu. + +### How it works + +- One thumbnail is generated per chapter (FFmpeg) +- Default capture is **2 seconds into the chapter** +- If capture fails, the first valid frame is used +- Users can optionally override a thumbnail with a custom image + +Extras and galleries do **not** generate chapter thumbnails. + +--- + ## Scene Detection - Finding Chapter Points Automatically ### What Are Chapters? diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d5a24a8..8256732 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -37,6 +37,10 @@ This roadmap is intentionally lightweight. It captures the next few high-priorit - **Upscale workflow parity** - Replace Upscale output quality with Convert-style Bitrate Mode controls - Ensure FFmpeg-based upscale jobs report progress in queue +- **Authoring structure upgrade** + - Feature/Extras/Gallery content types with subtype grouping + - Chapter thumbnails auto-generated for Feature only + - Galleries authored as still-image slideshows under Extras ## Next (dev25+) diff --git a/internal/player/unified_player_adapter.go b/internal/player/unified_player_adapter.go new file mode 100644 index 0000000..14cb101 --- /dev/null +++ b/internal/player/unified_player_adapter.go @@ -0,0 +1,349 @@ +package player + +import ( + "image" + "image/color" + "sync" + "sync/atomic" + "time" + + "fyne.io/fyne/v2/canvas" +) + +// unifiedPlayerAdapter wraps UnifiedPlayer to provide playSession interface compatibility +// This allows seamless replacement of the dual-process player with UnifiedPlayer +type unifiedPlayerAdapter struct { + // Core UnifiedPlayer + player *UnifiedPlayer + + // Interface compatibility fields (from playSession) + path string + fps float64 + width int + height int + targetW int + targetH int + volume float64 + muted bool + paused bool + current float64 + stop chan struct{} + done chan struct{} + prog func(float64) + frameFunc func(int) // Callback for frame number updates + img *canvas.Image + mu sync.Mutex + frameN int + duration float64 // Total duration in seconds + startTime time.Time + + // Adapter-specific state + lastUpdateTime time.Time + updateTicker *time.Ticker +} + +// NewUnifiedPlayerAdapter creates a new adapter that wraps UnifiedPlayer +func NewUnifiedPlayerAdapter(path string, width, height int, fps, duration float64, targetW, targetH int, prog func(float64), frameFunc func(int), img *canvas.Image) *unifiedPlayerAdapter { + adapter := &unifiedPlayerAdapter{ + path: path, + fps: fps, + width: width, + height: height, + targetW: targetW, + targetH: targetH, + volume: 100.0, + muted: false, + paused: true, + current: 0.0, + stop: make(chan struct{}), + done: make(chan struct{}), + prog: prog, + frameFunc: frameFunc, + img: img, + duration: duration, + startTime: time.Now(), + } + + // Create UnifiedPlayer with proper configuration + config := Config{ + Backend: BackendUnified, + WindowX: 0, + WindowY: 0, + WindowWidth: targetW, + WindowHeight: targetH, + Volume: 1.0, // Full volume + Muted: false, + AutoPlay: false, + HardwareAccel: false, + PreviewMode: false, + AudioOutput: "auto", + VideoOutput: "rgb24", + CacheEnabled: true, + CacheSize: 64 * 1024 * 1024, // 64MB + LogLevel: 3, // Debug + } + + adapter.player = NewUnifiedPlayer(config) + + // Set up callbacks for progress and frame updates + adapter.player.SetTimeCallback(func(d time.Duration) { + seconds := d.Seconds() + adapter.current = seconds + if adapter.prog != nil { + adapter.prog(seconds) + } + }) + + adapter.player.SetFrameCallback(func(frame int64) { + adapter.frameN = int(frame) + if adapter.frameFunc != nil { + adapter.frameFunc(int(frame)) + } + }) + + return adapter +} + +// Play starts or resumes playback +func (p *unifiedPlayerAdapter) Play() { + p.mu.Lock() + defer p.mu.Unlock() + + if p.player == nil { + return + } + + if p.paused { + // Start playback if not already started + if p.current == 0 { + err := p.player.Load(p.path, 0) + if err != nil { + return + } + } + + p.paused = false + p.startTime = time.Now().Add(-time.Duration(p.current * float64(time.Second))) + p.startUpdateLoop() + } +} + +// Pause pauses playback +func (p *unifiedPlayerAdapter) Pause() { + p.mu.Lock() + defer p.mu.Unlock() + + p.paused = true + p.stopUpdateLoop() +} + +// Seek seeks to the specified time offset +func (p *unifiedPlayerAdapter) Seek(offset float64) { + p.mu.Lock() + defer p.mu.Unlock() + + if offset < 0 { + offset = 0 + } + if offset > p.duration { + offset = p.duration + } + + paused := p.paused + p.current = offset + p.frameN = int(offset * p.fps) + + // Seek in UnifiedPlayer + if p.player != nil { + err := p.player.SeekToTime(time.Duration(offset * float64(time.Second))) + if err != nil { + return + } + } + + p.paused = paused + if p.prog != nil { + p.prog(p.current) + } + if p.frameFunc != nil { + p.frameFunc(p.frameN) + } +} + +// StepFrame moves forward or backward by a specific number of frames +func (p *unifiedPlayerAdapter) StepFrame(delta int) { + p.mu.Lock() + defer p.mu.Unlock() + + if p.fps <= 0 { + return + } + + // Calculate current frame from time position + currentFrame := int(p.current * p.fps) + targetFrame := currentFrame + delta + + // Clamp to valid range + if targetFrame < 0 { + targetFrame = 0 + } + maxFrame := int(p.duration * p.fps) + if targetFrame > maxFrame { + targetFrame = maxFrame + } + + // Convert to time offset + offset := float64(targetFrame) / p.fps + + // Seek to the new position + if p.player != nil { + err := p.player.SeekToFrame(int64(targetFrame)) + if err != nil { + return + } + } + + p.current = offset + p.frameN = targetFrame + p.paused = true // Auto-pause when frame stepping + + if p.prog != nil { + p.prog(p.current) + } + if p.frameFunc != nil { + p.frameFunc(p.frameN) + } +} + +// GetCurrentFrame returns the current frame number +func (p *unifiedPlayerAdapter) GetCurrentFrame() int { + p.mu.Lock() + defer p.mu.Unlock() + return p.frameN +} + +// SetVolume sets the audio volume (0-100) +func (p *unifiedPlayerAdapter) SetVolume(v float64) { + p.mu.Lock() + defer p.mu.Unlock() + + p.volume = v + if p.player != nil { + // Convert 0-100 to 0.0-1.0 range + volumeLevel := v / 100.0 + err := p.player.SetVolume(volumeLevel) + if err != nil { + return + } + } +} + +// Stop stops playback and cleans up resources +func (p *unifiedPlayerAdapter) Stop() { + p.mu.Lock() + defer p.mu.Unlock() + + p.stopUpdateLoop() + + if p.player != nil { + p.player.Close() + p.player = nil + } + + // Close channels to signal completion + select { + case <-p.stop: + default: + close(p.stop) + } +} + +// startUpdateLoop starts the update loop for progress tracking +func (p *unifiedPlayerAdapter) startUpdateLoop() { + if p.updateTicker != nil { + return // Already running + } + + // Update progress based on frame rate (30fps updates) + interval := time.Second / 30 + p.updateTicker = time.NewTicker(interval) + + go func() { + defer p.updateTicker.Stop() + + for { + select { + case <-p.stop: + return + case <-p.updateTicker.C: + p.mu.Lock() + if !p.paused && p.player != nil { + // Get current time from UnifiedPlayer + currentTime := p.player.GetCurrentTime() + p.current = currentTime.Seconds() + p.frameN = int(p.current * p.fps) + + // Update UI callbacks + if p.prog != nil { + p.prog(p.current) + } + if p.frameFunc != nil { + p.frameFunc(p.frameN) + } + } + p.mu.Unlock() + } + } + }() +} + +// stopUpdateLoop stops the update loop +func (p *unifiedPlayerAdapter) stopUpdateLoop() { + if p.updateTicker != nil { + p.updateTicker.Stop() + p.updateTicker = nil + } +} + +// GetVideoFrame returns the current video frame for display +func (p *unifiedPlayerAdapter) GetVideoFrame() *image.RGBA { + p.mu.Lock() + defer p.mu.Unlock() + + if p.player == nil { + return nil + } + + // Create a placeholder frame for now + // In full implementation, this would get frame from UnifiedPlayer + rect := image.Rect(0, 0, p.targetW, p.targetH) + frame := image.NewRGBA(rect) + + // Fill with black background + for y := 0; y < p.targetH; y++ { + for x := 0; x < p.targetW; x++ { + frame.SetRGBA(x, y, color.RGBA{0, 0, 0, 255}) + } + } + + return frame +} + +// IsPlaying returns whether playback is active +func (p *unifiedPlayerAdapter) IsPlaying() bool { + p.mu.Lock() + defer p.mu.Unlock() + return !p.paused +} + +// GetDuration returns the total duration in seconds +func (p *unifiedPlayerAdapter) GetDuration() float64 { + p.mu.Lock() + defer p.mu.Unlock() + return p.duration +} + +// Close closes the adapter and cleans up resources +func (p *unifiedPlayerAdapter) Close() { + p.Stop() +} diff --git a/main.go b/main.go index 816246d..5413ee5 100644 --- a/main.go +++ b/main.go @@ -10935,6 +10935,9 @@ type playSession struct { videoTime float64 // Last video frame time syncOffset float64 // A/V sync offset for adjustment audioActive atomic.Bool // Whether audio stream is running + + // UnifiedPlayer adapter for stable A/V playback + unifiedAdapter *player.UnifiedPlayerAdapter } var audioCtxGlobal struct { @@ -10972,6 +10975,10 @@ func newPlaySession(path string, w, h int, fps, duration float64, targetW, targe if targetH <= 0 { targetH = int(float64(targetW) * (float64(h) / float64(utils.MaxInt(w, 1)))) } + + // Create UnifiedPlayer adapter for stable A/V playback + unifiedAdapter := player.NewUnifiedPlayerAdapter(path, w, h, fps, duration, targetW, targetH, prog, frameFunc, img) + return &playSession{ path: path, fps: fps, @@ -10986,12 +10993,53 @@ func newPlaySession(path string, w, h int, fps, duration float64, targetW, targe prog: prog, frameFunc: frameFunc, img: img, + unifiedAdapter: unifiedAdapter, + } +} + if targetW <= 0 { + targetW = 640 + } + if targetH <= 0 { + targetH = int(float64(targetW) * (float64(h) / float64(utils.MaxInt(w, 1)))) + } + + // Create UnifiedPlayer adapter instead of dual-process player + adapter := player.NewUnifiedPlayerAdapter(path, w, h, fps, duration, targetW, targetH, prog, frameFunc, img) + + // Create playSession wrapper to maintain interface compatibility + return &playSession{ + // Store adapter in videoCmd to avoid breaking existing code + videoCmd: (*exec.Cmd)(unsafe.Pointer(adapter)), // Type hack to store adapter pointer + + // Keep interface fields for compatibility + path: path, + fps: fps, + width: w, + height: h, + targetW: targetW, + targetH: targetH, + volume: 100, + duration: duration, + stop: make(chan struct{}), + done: make(chan struct{}), + prog: prog, + frameFunc: frameFunc, + img: img, } } func (p *playSession) Play() { p.mu.Lock() defer p.mu.Unlock() + + // Use UnifiedPlayer adapter if available + if p.unifiedAdapter != nil { + p.unifiedAdapter.Play() + p.paused = false + return + } + + // Fallback to dual-process if p.videoCmd == nil && p.audioCmd == nil { p.startLocked(p.current) return @@ -11002,6 +11050,14 @@ func (p *playSession) Play() { func (p *playSession) Pause() { p.mu.Lock() defer p.mu.Unlock() + + // Use UnifiedPlayer adapter if available + if p.unifiedAdapter != nil { + p.unifiedAdapter.Pause() + p.paused = true + return + } + p.paused = true } @@ -11011,6 +11067,16 @@ func (p *playSession) Seek(offset float64) { if offset < 0 { offset = 0 } + + // Use UnifiedPlayer adapter if available + if p.unifiedAdapter != nil { + p.unifiedAdapter.Seek(offset) + p.current = offset + p.paused = p.unifiedAdapter.IsPlaying() == false + return + } + + // Fallback to dual-process paused := p.paused p.current = offset p.stopLocked() @@ -11038,6 +11104,58 @@ func (p *playSession) StepFrame(delta int) { return } + // Use UnifiedPlayer adapter if available + if p.unifiedAdapter != nil { + p.unifiedAdapter.StepFrame(delta) + p.current = p.unifiedAdapter.GetCurrentFrame() / p.fps + p.paused = true + return + } + + // Fallback to dual-process + currentFrame := int(p.current * p.fps) + targetFrame := currentFrame + delta + + // Clamp to valid range + if targetFrame < 0 { + targetFrame = 0 + } + maxFrame := int(p.duration * p.fps) + if targetFrame > maxFrame { + targetFrame = maxFrame + } + + // Convert to time offset + offset := float64(targetFrame) / p.fps + if offset < 0 { + offset = 0 + } + if offset > p.duration { + offset = p.duration + } + + // Auto-pause when frame stepping + p.paused = true + p.current = offset + + // Seek to new position + if offset >= 0 { + p.stopLocked() + p.startLocked(offset) + } + + // Ensure loops honor paused right after restart. + time.AfterFunc(30*time.Millisecond, func() { + p.mu.Lock() + defer p.mu.Unlock() + p.paused = true + }) + + if p.prog != nil { + p.prog(p.current) + } +} + // Calculate current frame from time position (not from p.frameN which resets on seek) currentFrame := int(p.current * p.fps) targetFrame := currentFrame + delta @@ -11086,16 +11204,33 @@ func (p *playSession) StepFrame(delta int) { func (p *playSession) GetCurrentFrame() int { p.mu.Lock() defer p.mu.Unlock() + + // Use UnifiedPlayer adapter if available + if p.unifiedAdapter != nil { + return p.unifiedAdapter.GetCurrentFrame() + } + return p.frameN } func (p *playSession) SetVolume(v float64) { p.mu.Lock() - oldVolume := p.volume - oldMuted := p.muted - if v < 0 { - v = 0 + defer p.mu.Unlock() + p.volume = v + + // Use UnifiedPlayer adapter if available + if p.unifiedAdapter != nil { + p.unifiedAdapter.SetVolume(v) + return } + + // Fallback to dual-process + if p.audioCmd != nil && p.audioCmd.Process != nil { + // Send volume command to FFmpeg + cmd := fmt.Sprintf("volume %.1f\n", v/100.0) + p.writeStringToStdin(cmd) + } +} if v > 100 { v = 100 } @@ -11139,10 +11274,25 @@ func (p *playSession) restartAudio(offset float64) { func (p *playSession) Stop() { p.mu.Lock() defer p.mu.Unlock() + + // Use UnifiedPlayer adapter if available + if p.unifiedAdapter != nil { + p.unifiedAdapter.Stop() + return + } + + // Fallback to dual-process p.stopLocked() } func (p *playSession) stopLocked() { + // Use UnifiedPlayer adapter if available + if p.unifiedAdapter != nil { + p.unifiedAdapter.Stop() + return + } + + // Fallback to dual-process cleanup select { case <-p.stop: default: @@ -11171,9 +11321,17 @@ func (p *playSession) startLocked(offset float64) { p.videoTime = offset p.syncOffset = 0 logging.Debug(logging.CatFFMPEG, "playSession start path=%s offset=%.3f fps=%.3f target=%dx%d", p.path, offset, p.fps, p.targetW, p.targetH) + + // If using UnifiedPlayer adapter, no need to run dual-process + if p.unifiedAdapter != nil { + // UnifiedPlayer handles A/V sync internally + p.unifiedAdapter.Seek(offset) + return + } + + // Fallback to dual-process (old method) p.runVideo(offset) - // TEMPORARY: Disable audio to prevent A/V sync crashes - // p.runAudio(offset) will be re-enabled when UnifiedPlayer is properly integrated + p.runAudio(offset) } func (p *playSession) runVideo(offset float64) {