diff --git a/docs/COMPARE_FULLSCREEN.md b/docs/COMPARE_FULLSCREEN.md new file mode 100644 index 0000000..ad0cd1b --- /dev/null +++ b/docs/COMPARE_FULLSCREEN.md @@ -0,0 +1,150 @@ +# Compare Module - Fullscreen Mode + +## Overview +The Compare module now includes a **Fullscreen Compare** mode that displays two videos side-by-side in a larger view, optimized for detailed visual comparison. + +## Features + +### Current (v0.1) +- ✅ Side-by-side fullscreen layout +- ✅ Larger video players for better visibility +- ✅ Individual playback controls for each video +- ✅ File labels showing video names +- ✅ Back button to return to regular Compare view +- ✅ Pink colored header/footer matching Compare module + +### Planned (Future - requires VT_Player enhancements) +- ⏳ **Synchronized playback** - Play/Pause both videos simultaneously +- ⏳ **Linked seeking** - Seek to same timestamp in both videos +- ⏳ **Frame-by-frame sync** - Step through both videos in lockstep +- ⏳ **Volume link** - Adjust volume on both players together +- ⏳ **Playback speed sync** - Change speed on both players at once + +## Usage + +### Accessing Fullscreen Mode +1. Load two videos in the Compare module +2. Click the **"Fullscreen Compare"** button +3. Videos will display side-by-side in larger players + +### Controls +- **Individual players**: Each video has its own play/pause/seek controls +- **"Play Both" button**: Placeholder for future synchronized playback +- **"Pause Both" button**: Placeholder for future synchronized pause +- **"< BACK TO COMPARE"**: Return to regular Compare view + +## Use Cases + +### Visual Quality Comparison +Compare encoding settings or compression quality: +- Original vs. compressed +- Different codec outputs +- Before/after color grading +- Different resolution scaling + +### Frame-Accurate Comparison +When VT_Player sync is implemented: +- Compare edits side-by-side +- Check for sync issues in re-encodes +- Validate frame-accurate cuts +- Compare different filter applications + +### A/B Testing +Test different processing settings: +- Different deinterlacing methods +- Upscaling algorithms +- Noise reduction levels +- Color correction approaches + +## Technical Notes + +### Current Implementation +- Uses standard `buildVideoPane()` for each side +- 640x360 minimum player size (scales with window) +- Independent playback state per video +- No shared controls between players yet + +### VT_Player API Requirements for Sync +For synchronized playback, VT_Player will need: + +```go +// Playback state access +player.IsPlaying() bool +player.GetPosition() time.Duration + +// Event callbacks +player.OnPlaybackStateChanged(callback func(playing bool)) +player.OnPositionChanged(callback func(position time.Duration)) + +// Synchronized control +player.SyncWith(otherPlayer *Player) +player.Unsync() +``` + +### Synchronization Strategy +When VT_Player supports it: +1. **Master-Slave Pattern**: One player is master, other follows +2. **Linked Events**: Play/pause/seek events trigger on both +3. **Position Polling**: Periodically check for drift and correct +4. **Frame-Accurate Sync**: Step both players frame-by-frame together + +## Keyboard Shortcuts (Planned) +When implemented in VT_Player: +- `Space` - Play/Pause both videos +- `J` / `L` - Rewind/Forward both videos +- `←` / `→` - Step both videos frame-by-frame +- `K` - Pause both videos +- `0-9` - Seek to percentage (0% to 90%) in both +- `Esc` - Exit fullscreen mode + +## UI Layout + +``` +┌─────────────────────────────────────────────────────────────┐ +│ < BACK TO COMPARE │ ← Pink header +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Side-by-side fullscreen comparison. Use individual... │ +│ │ +│ [▶ Play Both] [⏸ Pause Both] │ +│ ───────────────────────────────────────────────────────── │ +│ │ +│ ┌─────────────────────────┬─────────────────────────────┐ │ +│ │ File 1: video1.mp4 │ File 2: video2.mp4 │ │ +│ ├─────────────────────────┼─────────────────────────────┤ │ +│ │ │ │ │ +│ │ Video Player 1 │ Video Player 2 │ │ +│ │ (640x360 min) │ (640x360 min) │ │ +│ │ │ │ │ +│ │ [Play] [Pause] [Seek] │ [Play] [Pause] [Seek] │ │ +│ │ │ │ │ +│ └─────────────────────────┴─────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ + ← Pink footer +``` + +## Future Enhancements + +### v0.2 - Synchronized Playback +- Implement master-slave sync between players +- Add "Link" toggle button to enable/disable sync +- Visual indicator when players are synced + +### v0.3 - Advanced Sync +- Offset compensation (e.g., if videos start at different times) +- Manual sync adjustment (nudge one video forward/back) +- Sync validation indicator (shows if videos are in sync) + +### v0.4 - Comparison Tools +- Split-screen view with adjustable divider +- A/B quick toggle (show only one at a time) +- Difference overlay (highlight changed regions) +- Frame difference metrics display + +## Notes +- Fullscreen mode is accessible from regular Compare view +- Videos must be loaded before entering fullscreen mode +- Synchronized controls are placeholders until VT_Player API is enhanced +- Window can be resized freely - players will scale +- Each player maintains independent state for now diff --git a/docs/LOSSLESSCUT_INSPIRATION.md b/docs/LOSSLESSCUT_INSPIRATION.md new file mode 100644 index 0000000..554c861 --- /dev/null +++ b/docs/LOSSLESSCUT_INSPIRATION.md @@ -0,0 +1,460 @@ +# LosslessCut Features - Inspiration for VideoTools Trim Module + +## Overview +LosslessCut is a mature, feature-rich video trimming application built on Electron/React with FFmpeg backend. This document extracts key features and UX patterns that should inspire VideoTools' Trim module development. + +--- + +## 🎯 Core Trim Features to Adopt + +### 1. **Segment-Based Editing** ⭐⭐⭐ (HIGHEST PRIORITY) +LosslessCut uses "segments" as first-class citizens rather than simple In/Out points. + +**How it works:** +- Each segment has: start time, end time (optional), label, tags, and segment number +- Multiple segments can exist on timeline simultaneously +- Segments without end time = "markers" (vertical lines on timeline) +- Segments can be reordered by drag-drop in segment list + +**Benefits for VideoTools:** +- User can mark multiple trim regions in one session +- Export all segments at once (batch trim) +- Save/load trim projects for later refinement +- More flexible than single In/Out point workflow + +**Implementation priority:** HIGH +- Start with single segment (In/Out points) +- Phase 2: Add multiple segments support +- Phase 3: Add segment labels/tags + +**Example workflow:** +``` +1. User loads video +2. Finds first good section: 0:30 to 1:45 → Press I, seek, press O → Segment 1 created +3. Press + to add new segment +4. Finds second section: 3:20 to 5:10 → Segment 2 created +5. Export → Creates 2 output files (or 1 merged file if mode set) +``` + +--- + +### 2. **Keyboard-First Workflow** ⭐⭐⭐ (HIGHEST PRIORITY) +LosslessCut is designed for speed via keyboard shortcuts. + +**Essential shortcuts:** +| Key | Action | Notes | +|-----|--------|-------| +| `SPACE` | Play/Pause | Standard | +| `I` | Set segment In point | Industry standard (Adobe, FCP) | +| `O` | Set segment Out point | Industry standard | +| `←` / `→` | Seek backward/forward | Frame or keyframe stepping | +| `,` / `.` | Frame step | Precise frame-by-frame (1 frame) | +| `+` | Add new segment | Quick workflow | +| `B` | Split segment at cursor | Divide segment into two | +| `BACKSPACE` | Delete segment/cutpoint | Quick removal | +| `E` | Export | Fast export shortcut | +| `C` | Capture screenshot | Snapshot current frame | +| Mouse wheel | Seek timeline | Smooth scrubbing | + +**Why keyboard shortcuts matter:** +- Professional users edit faster with keyboard +- Reduces mouse movement fatigue +- Enables "flow state" editing +- Standard shortcuts reduce learning curve + +**Implementation for VideoTools:** +- Integrate keyboard handling into VT_Player +- Show keyboard shortcut overlay (SHIFT+/) +- Allow user customization later + +--- + +### 3. **Timeline Zoom** ⭐⭐⭐ (HIGH PRIORITY) +Timeline can zoom in/out for precision editing. + +**How it works:** +- Zoom slider or mouse wheel on timeline +- Zoomed view shows: thumbnails, waveform, keyframes +- Timeline scrolls horizontally when zoomed +- Zoom follows playhead (keeps current position centered) + +**Benefits:** +- Find exact cut points in long videos +- Frame-accurate editing even in 2-hour files +- See waveform detail for audio-based cuts + +**Implementation notes:** +- VT_Player needs horizontal scrolling timeline widget +- Zoom level: 1x (full video) to 100x (extreme detail) +- Auto-scroll to keep playhead in view + +--- + +### 4. **Waveform Display** ⭐⭐ (MEDIUM PRIORITY) +Audio waveform shown on timeline for visual reference. + +**Features:** +- Shows amplitude over time +- Useful for finding speech/silence boundaries +- Click waveform to seek +- Updates as timeline zooms + +**Use cases:** +- Trim silence from beginning/end +- Find exact start of dialogue +- Cut between sentences +- Detect audio glitches + +**Implementation:** +- FFmpeg can generate waveform images: `ffmpeg -i input.mp4 -filter_complex showwavespic output.png` +- Display as timeline background +- Optional feature (enable/disable) + +--- + +### 5. **Keyframe Visualization** ⭐⭐ (MEDIUM PRIORITY) +Timeline shows video keyframes (I-frames) as markers. + +**Why keyframes matter:** +- Lossless copy (`-c copy`) only cuts at keyframes +- Cutting between keyframes requires re-encode +- Users need visual feedback on keyframe positions + +**How LosslessCut handles it:** +- Vertical lines on timeline = keyframes +- Color-coded: bright = keyframe, dim = P/B frame +- "Smart cut" mode: cuts at keyframe + re-encodes small section + +**Implementation for VideoTools:** +- Probe keyframes: `ffprobe -select_streams v -show_frames -show_entries frame=pict_type,pts_time` +- Display on timeline +- Warn user if cut point not on keyframe (when using `-c copy`) + +--- + +### 6. **Invert Cut Mode** ⭐⭐ (MEDIUM PRIORITY) +Yin-yang toggle: Keep segments vs. Remove segments + +**Two modes:** +1. **Keep mode** (default): Export marked segments, discard rest +2. **Cut mode** (inverted): Remove marked segments, keep rest + +**Example:** +``` +Video: [────────────────────] +Segments: [ SEG1 ] [ SEG2 ] + +Keep mode → Output: SEG1.mp4, SEG2.mp4 +Cut mode → Output: parts between segments (commercials removed) +``` + +**Use cases:** +- **Keep**: Extract highlights from long recording +- **Cut**: Remove commercials from TV recording + +**Implementation:** +- Simple boolean toggle in UI +- Changes FFmpeg command logic +- Useful for both workflows + +--- + +### 7. **Merge Mode** ⭐⭐ (MEDIUM PRIORITY) +Option to merge multiple segments into single output file. + +**Export options:** +- **Separate files**: Each segment → separate file +- **Merge cuts**: All segments → 1 merged file +- **Merge + separate**: Both outputs + +**FFmpeg technique:** +```bash +# Create concat file listing segments +echo "file 'segment1.mp4'" > concat.txt +echo "file 'segment2.mp4'" >> concat.txt + +# Merge with concat demuxer +ffmpeg -f concat -safe 0 -i concat.txt -c copy merged.mp4 +``` + +**Implementation:** +- UI toggle: "Merge segments" +- Temp directory for segment exports +- Concat demuxer for lossless merge +- Clean up temp files after + +--- + +### 8. **Manual Timecode Entry** ⭐ (LOW PRIORITY) +Type exact timestamps instead of scrubbing. + +**Features:** +- Click In/Out time → text input appears +- Type: `1:23:45.123` or `83.456` +- Formats: HH:MM:SS.mmm, MM:SS, seconds +- Paste timestamps from clipboard + +**Use cases:** +- User has exact timestamps from notes +- Import cut times from CSV/spreadsheet +- Frame-accurate entry (1:23:45.033) + +**Implementation:** +- Text input next to In/Out displays +- Parse various time formats +- Validate against video duration + +--- + +### 9. **Project Files (.llc)** ⭐ (LOW PRIORITY - FUTURE) +Save segments to file, resume editing later. + +**LosslessCut project format (JSON5):** +```json +{ + "version": 1, + "cutSegments": [ + { + "start": 30.5, + "end": 105.3, + "name": "Opening scene", + "tags": { "category": "intro" } + }, + { + "start": 180.0, + "end": 245.7, + "name": "Action sequence" + } + ] +} +``` + +**Benefits:** +- Resume trim session after closing app +- Share trim points with team +- Version control trim decisions + +**Implementation (later):** +- Simple JSON format +- Save/load from File menu +- Auto-save to temp on changes + +--- + +## 🎨 UX Patterns to Adopt + +### 1. **Timeline Interaction Model** +- Click timeline → seek to position +- Drag timeline → scrub (live preview) +- Mouse wheel → seek forward/backward +- Shift+wheel → zoom timeline +- Right-click → context menu (set In/Out, add segment, etc.) + +### 2. **Visual Feedback** +- **Current time indicator**: Vertical line with triangular markers (top/bottom) +- **Segment visualization**: Colored rectangles on timeline +- **Hover preview**: Show timestamp on hover +- **Segment labels**: Display segment names on timeline + +### 3. **Segment List Panel** +LosslessCut shows sidebar with all segments: +``` +┌─ Segments ─────────────────┐ +│ 1. [00:30 - 01:45] Intro │ ← Selected +│ 2. [03:20 - 05:10] Action │ +│ 3. [07:00 - 09:30] Ending │ +└────────────────────────────┘ +``` +**Features:** +- Click segment → select & seek to start +- Drag to reorder +- Right-click for options (rename, delete, duplicate) + +### 4. **Export Preview Dialog** +Before final export, show summary: +``` +┌─ Export Preview ──────────────────────────┐ +│ Export mode: Separate files │ +│ Output format: MP4 (same as source) │ +│ Keyframe mode: Smart cut │ +│ │ +│ Segments to export: │ +│ 1. Intro.mp4 (0:30 - 1:45) → 1.25 min │ +│ 2. Action.mp4 (3:20 - 5:10) → 1.83 min │ +│ 3. Ending.mp4 (7:00 - 9:30) → 2.50 min │ +│ │ +│ Total output size: ~125 MB │ +│ │ +│ [Cancel] [Export] │ +└───────────────────────────────────────────┘ +``` + +--- + +## 🚀 Advanced Features (Future Inspiration) + +### 1. **Scene Detection** +Auto-create segments at scene changes. +```bash +ffmpeg -i input.mp4 -filter_complex \ + "select='gt(scene,0.4)',metadata=print:file=scenes.txt" \ + -f null - +``` + +### 2. **Silence Detection** +Auto-trim silent sections. +```bash +ffmpeg -i input.mp4 -af silencedetect=noise=-30dB:d=0.5 -f null - +``` + +### 3. **Black Screen Detection** +Find and remove black sections. +```bash +ffmpeg -i input.mp4 -vf blackdetect=d=0.5:pix_th=0.10 -f null - +``` + +### 4. **Chapter Import/Export** +- Load MKV/MP4 chapters as segments +- Export segments as chapter markers +- Useful for DVD/Blu-ray rips + +### 5. **Thumbnail Scrubbing** +- Generate thumbnail strip +- Show preview on timeline hover +- Faster visual navigation + +--- + +## 📋 Implementation Roadmap for VideoTools + +### Phase 1: Essential Trim (Week 1-2) +**Goal:** Basic usable trim functionality +- ✅ VT_Player keyframing API (In/Out points) +- ✅ Keyboard shortcuts (I, O, Space, ←/→) +- ✅ Timeline markers visualization +- ✅ Single segment export +- ✅ Keep/Cut mode toggle + +### Phase 2: Professional Workflow (Week 3-4) +**Goal:** Multi-segment editing +- Multiple segments support +- Segment list panel +- Drag-to-reorder segments +- Merge mode +- Timeline zoom + +### Phase 3: Visual Enhancements (Week 5-6) +**Goal:** Precision editing +- Waveform display +- Keyframe visualization +- Frame-accurate stepping +- Manual timecode entry + +### Phase 4: Advanced Features (Week 7+) +**Goal:** Power user tools +- Project save/load +- Scene detection +- Silence detection +- Export presets +- Batch processing + +--- + +## 🎓 Key Lessons from LosslessCut + +### 1. **Start Simple, Scale Later** +LosslessCut began with basic trim, added features over time. Don't over-engineer initial release. + +### 2. **Keyboard Shortcuts are Critical** +Professional users demand keyboard efficiency. Design around keyboard-first workflow. + +### 3. **Visual Feedback Matters** +Users need to SEE what they're doing: +- Timeline markers +- Segment rectangles +- Waveforms +- Keyframes + +### 4. **Lossless is Tricky** +Educate users about keyframes, smart cut, and when re-encode is necessary. + +### 5. **FFmpeg Does the Heavy Lifting** +LosslessCut is primarily a UI wrapper around FFmpeg. Focus on great UX, let FFmpeg handle processing. + +--- + +## 🔗 References + +- **LosslessCut GitHub**: https://github.com/mifi/lossless-cut +- **Documentation**: `~/tools/lossless-cut/docs.md` +- **Source code**: `~/tools/lossless-cut/src/` +- **Keyboard shortcuts**: `~/tools/lossless-cut/README.md` (search "keyboard") + +--- + +## 💡 VideoTools-Specific Considerations + +### Advantages VideoTools Has: +1. **Native Go + Fyne**: Faster startup, smaller binary than Electron +2. **Integrated workflow**: Trim → Convert → Compare in one app +3. **Queue system**: Already have batch processing foundation +4. **Smart presets**: Leverage existing quality presets + +### Unique Features to Add: +1. **Trim + Convert**: Set In/Out, choose quality preset, export in one step +2. **Compare integration**: Auto-load trimmed vs. original for verification +3. **Batch trim**: Apply same trim offsets to multiple files (e.g., remove first 30s from all) +4. **Smart defaults**: Detect intros/outros and suggest trim points + +--- + +## ✅ Action Items for VT_Player Team + +Based on LosslessCut analysis, VT_Player needs: + +### Essential APIs: +1. **Keyframe API** + ```go + SetInPoint(time.Duration) + SetOutPoint(time.Duration) + GetInPoint() (time.Duration, bool) + GetOutPoint() (time.Duration, bool) + ClearKeyframes() + ``` + +2. **Timeline Visualization** + - Draw In/Out markers on timeline + - Highlight segment region between markers + - Support multiple segments (future) + +3. **Keyboard Shortcuts** + - I/O for In/Out points + - ←/→ for frame stepping + - Space for play/pause + - Mouse wheel for seek + +4. **Frame Navigation** + ```go + StepForward() // Next frame + StepBackward() // Previous frame + GetCurrentFrame() int64 + SeekToFrame(int64) + ``` + +5. **Timeline Zoom** (Phase 2) + ```go + SetZoomLevel(float64) // 1.0 to 100.0 + GetZoomLevel() float64 + ScrollToTime(time.Duration) + ``` + +### Reference Implementation: +- Study LosslessCut's Timeline.tsx for zoom logic +- Study TimelineSeg.tsx for segment visualization +- Study useSegments.tsx for segment state management + +--- + +**Document created**: 2025-12-04 +**Source**: LosslessCut v3.x codebase analysis +**Next steps**: Share with VT_Player team, begin Phase 1 implementation diff --git a/docs/TRIM_MODULE_DESIGN.md b/docs/TRIM_MODULE_DESIGN.md new file mode 100644 index 0000000..5bd68ea --- /dev/null +++ b/docs/TRIM_MODULE_DESIGN.md @@ -0,0 +1,169 @@ +# Trim Module Design + +## Overview +The Trim module allows users to cut portions of video files using visual keyframe markers. Users can set In/Out points on the timeline and preview the trimmed segment before processing. + +## Core Features + +### 1. Visual Timeline Editing +- Load video with VT_Player +- Set **In Point** (start of keep region) - Press `I` or click button +- Set **Out Point** (end of keep region) - Press `O` or click button +- Visual markers on timeline showing trim region +- Scrub through video to find exact frames + +### 2. Keyframe Controls +``` +[In Point] ←────────────────→ [Out Point] + 0:10 Keep Region 2:45 + ═══════════════════════════════════════════ +``` + +### 3. Frame-Accurate Navigation +- `←` / `→` - Step backward/forward one frame +- `Shift+←` / `Shift+→` - Jump 1 second +- `I` - Set In Point at current position +- `O` - Set Out Point at current position +- `Space` - Play/Pause +- `C` - Clear all keyframes + +### 4. Multiple Trim Modes + +#### Mode 1: Keep Region (Default) +Keep video between In and Out points, discard rest. +``` +Input: [─────IN════════OUT─────] +Output: [════════] +``` + +#### Mode 2: Cut Region +Remove video between In and Out points, keep rest. +``` +Input: [─────IN════════OUT─────] +Output: [─────] [─────] +``` + +#### Mode 3: Multiple Segments (Advanced) +Define multiple keep/cut regions using segment list. + +## UI Layout + +``` +┌─────────────────────────────────────────────┐ +│ < TRIM │ ← Cyan header bar +├─────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────────────┐ │ +│ │ Video Player (VT_Player) │ │ +│ │ │ │ +│ │ [Timeline with In/Out markers] │ │ +│ │ ────I═══════════════O──────── │ │ +│ │ │ │ +│ │ [Play] [Pause] [In] [Out] [Clear] │ │ +│ └───────────────────────────────────────┘ │ +│ │ +│ Trim Mode: ○ Keep Region ○ Cut Region │ +│ │ +│ In Point: 00:01:23.456 [Set In] [Clear] │ +│ Out Point: 00:04:56.789 [Set Out] [Clear] │ +│ Duration: 00:03:33.333 │ +│ │ +│ Output Settings: │ +│ ┌─────────────────────────────────────┐ │ +│ │ Format: [Same as source ▼] │ │ +│ │ Re-encode: [ ] Smart copy (fast) │ │ +│ │ Quality: [Source quality] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ [Preview Trimmed] [Add to Queue] │ +│ │ +└─────────────────────────────────────────────┘ + ← Cyan footer bar +``` + +## VT_Player API Requirements + +### Required Methods +```go +// Keyframe management +player.SetInPoint(position time.Duration) +player.SetOutPoint(position time.Duration) +player.GetInPoint() time.Duration +player.GetOutPoint() time.Duration +player.ClearKeyframes() + +// Frame-accurate navigation +player.StepForward() // Advance one frame +player.StepBackward() // Go back one frame +player.GetCurrentTime() time.Duration +player.GetFrameRate() float64 + +// Visual feedback +player.ShowMarkers(in, out time.Duration) // Draw on timeline +``` + +### Required Events +```go +// Keyboard shortcuts +- OnKeyPress('I') -> Set In Point +- OnKeyPress('O') -> Set Out Point +- OnKeyPress('→') -> Step Forward +- OnKeyPress('←') -> Step Backward +- OnKeyPress('Space') -> Play/Pause +- OnKeyPress('C') -> Clear Keyframes +``` + +## FFmpeg Integration + +### Keep Region Mode +```bash +ffmpeg -i input.mp4 -ss 00:01:23.456 -to 00:04:56.789 -c copy output.mp4 +``` + +### Cut Region Mode (Complex filter) +```bash +ffmpeg -i input.mp4 \ + -filter_complex "[0:v]split[v1][v2]; \ + [v1]trim=start=0:end=83.456[v1t]; \ + [v2]trim=start=296.789[v2t]; \ + [v1t][v2t]concat=n=2:v=1:a=0[outv]" \ + -map [outv] output.mp4 +``` + +### Smart Copy (Fast) +- Use `-c copy` when no re-encoding needed +- Only works at keyframe boundaries +- Show warning if In/Out not at keyframes + +## Workflow + +1. **Load Video** - Drag video onto Trim tile or use Load button +2. **Navigate** - Scrub or use keyboard to find start point +3. **Set In** - Press `I` or click "Set In" button +4. **Find End** - Navigate to end of region to keep +5. **Set Out** - Press `O` or click "Set Out" button +6. **Preview** - Click "Preview Trimmed" to see result +7. **Queue** - Click "Add to Queue" to process + +## Technical Notes + +### Precision Considerations +- Frame-accurate requires seeking to exact frame boundaries +- Display timestamps with millisecond precision (HH:MM:SS.mmm) +- VT_Player must handle fractional frame positions +- Consider GOP (Group of Pictures) boundaries for smart copy + +### Performance +- Preview shouldn't require full re-encode +- Show preview using VT_Player with constrained timeline +- Cache preview segments for quick playback testing + +## Future Enhancements +- Multiple trim regions in single operation +- Batch trim multiple files with same In/Out offsets +- Save trim presets (e.g., "Remove first 30s and last 10s") +- Visual waveform for audio-based trimming +- Chapter-aware trimming (trim to chapter boundaries) + +## Module Color +**Cyan** - #44DDFF (already defined in modulesList) diff --git a/docs/VIDEO_METADATA_GUIDE.md b/docs/VIDEO_METADATA_GUIDE.md new file mode 100644 index 0000000..666f34f --- /dev/null +++ b/docs/VIDEO_METADATA_GUIDE.md @@ -0,0 +1,612 @@ +# Video Metadata Guide for VideoTools + +## Overview +This guide covers adding custom metadata fields to video files, NFO generation, and integration with VideoTools modules. + +--- + +## 📦 Container Format Metadata Capabilities + +### MP4 / MOV (MPEG-4) +**Metadata storage:** Atoms in `moov` container + +**Standard iTunes-compatible tags:** +``` +©nam - Title +©ART - Artist +©alb - Album +©day - Year +©gen - Genre +©cmt - Comment +desc - Description +©too - Encoding tool +©enc - Encoded by +cprt - Copyright +``` + +**Custom tags (with proper keys):** +``` +----:com.apple.iTunes:DIRECTOR - Director +----:com.apple.iTunes:PERFORMERS - Performers +----:com.apple.iTunes:STUDIO - Studio/Production +----:com.apple.iTunes:SERIES - Series name +----:com.apple.iTunes:SCENE - Scene number +----:com.apple.iTunes:CATEGORIES - Categories/Tags +``` + +**Setting metadata with FFmpeg:** +```bash +ffmpeg -i input.mp4 -c copy \ + -metadata title="Scene Title" \ + -metadata artist="Performer Name" \ + -metadata album="Series Name" \ + -metadata date="2025" \ + -metadata genre="Category" \ + -metadata comment="Scene description" \ + -metadata description="Full scene info" \ + output.mp4 +``` + +**Custom fields:** +```bash +ffmpeg -i input.mp4 -c copy \ + -metadata:s:v:0 custom_field="Custom Value" \ + output.mp4 +``` + +--- + +### MKV (Matroska) +**Metadata storage:** Tags element (XML-based) + +**Built-in tag support:** +```xml + + + + TITLE + Scene Title + + + ARTIST + Performer Name + + + DIRECTOR + Director Name + + + STUDIO + Production Studio + + + + PERFORMERS + Performer 1, Performer 2 + + + SCENE_NUMBER + EP042 + + + CATEGORIES + Cat1, Cat2, Cat3 + + + +``` + +**Setting metadata with FFmpeg:** +```bash +ffmpeg -i input.mkv -c copy \ + -metadata title="Scene Title" \ + -metadata artist="Performer Name" \ + -metadata director="Director" \ + -metadata studio="Studio Name" \ + output.mkv +``` + +**Advantages of MKV:** +- Unlimited custom tags (any key-value pairs) +- Can attach files (NFO, images, scripts) +- Hierarchical metadata structure +- Best for archival/preservation + +--- + +### MOV (QuickTime) +Same as MP4 (both use MPEG-4 structure), but QuickTime supports additional proprietary tags. + +--- + +## 📄 NFO File Format + +NFO (Info) files are plain text/XML files that contain detailed metadata. Common in media libraries (Kodi, Plex, etc.). + +### NFO Format for Movies: +```xml + + + Scene Title + Original Title + Sort Title + 2025 + 2025-12-04 + Scene description and plot summary + 45 + Production Studio + Director Name + + + Performer 1 + Role 1 + path/to/performer1.jpg + + + Performer 2 + Role 2 + + + Category 1 + Category 2 + + Tag1 + Tag2 + + 8.5 + 9.0 + + + + + + + + + + Series Name + 42 + EP042 + +``` + +### NFO Format for TV Episodes: +```xml + + + Episode Title + Series Name + 1 + 5 + 2025-12-04 + Episode description + 30 + Director Name + + + Performer 1 + Character + + + Production Studio + 8.0 + +``` + +--- + +## 🛠️ VideoTools Integration Plan + +### Module: **Metadata Editor** (New Module) +**Purpose:** Edit video metadata and generate NFO files + +**Features:** +1. **Load video** → Extract existing metadata +2. **Edit fields** → Standard + custom fields +3. **NFO generation** → Auto-generate from metadata +4. **Embed metadata** → Write back to video file (lossless remux) +5. **Batch metadata** → Apply same metadata to multiple files +6. **Templates** → Save/load metadata templates + +**UI Layout:** +``` +┌─────────────────────────────────────────────────┐ +│ < METADATA │ ← Purple header +├─────────────────────────────────────────────────┤ +│ │ +│ File: scene_042.mp4 │ +│ │ +│ ┌─ Basic Info ──────────────────────────────┐ │ +│ │ Title: [________________] │ │ +│ │ Studio: [________________] │ │ +│ │ Series: [________________] │ │ +│ │ Scene #: [____] │ │ +│ │ Date: [2025-12-04] │ │ +│ │ Duration: 45:23 (auto) │ │ +│ └──────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Performers ────────────────────────────────┐ │ +│ │ Performer 1: [________________] [X] │ │ +│ │ Performer 2: [________________] [X] │ │ +│ │ [+ Add Performer] │ │ +│ └──────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Categories/Tags ──────────────────────────┐ │ +│ │ [Tag1] [Tag2] [Tag3] [+ Add] │ │ +│ └──────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Description ────────────────────────────────┐ │ +│ │ [Multiline text area for plot/description] │ │ +│ │ │ │ +│ └──────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Custom Fields ────────────────────────────┐ │ +│ │ Director: [________________] │ │ +│ │ IMDB ID: [________________] │ │ +│ │ Custom 1: [________________] │ │ +│ │ [+ Add Field] │ │ +│ └──────────────────────────────────────────────┘ │ +│ │ +│ [Generate NFO] [Embed in Video] [Save Template]│ +│ │ +└─────────────────────────────────────────────────┘ +``` + +--- + +## 🔧 Implementation Details + +### 1. Reading Metadata +**Using FFprobe:** +```bash +ffprobe -v quiet -print_format json -show_format input.mp4 + +# Output includes: +{ + "format": { + "filename": "input.mp4", + "tags": { + "title": "Scene Title", + "artist": "Performer Name", + "album": "Series Name", + "date": "2025", + "genre": "Category", + "comment": "Description" + } + } +} +``` + +**Go implementation:** +```go +type VideoMetadata struct { + Title string + Studio string + Series string + SceneNumber string + Date string + Performers []string + Director string + Categories []string + Description string + CustomFields map[string]string +} + +func probeMetadata(path string) (*VideoMetadata, error) { + cmd := exec.Command("ffprobe", + "-v", "quiet", + "-print_format", "json", + "-show_format", + path, + ) + + output, err := cmd.Output() + if err != nil { + return nil, err + } + + var result struct { + Format struct { + Tags map[string]string `json:"tags"` + } `json:"format"` + } + + json.Unmarshal(output, &result) + + metadata := &VideoMetadata{ + Title: result.Format.Tags["title"], + Studio: result.Format.Tags["studio"], + Series: result.Format.Tags["album"], + Date: result.Format.Tags["date"], + Categories: strings.Split(result.Format.Tags["genre"], ", "), + Description: result.Format.Tags["comment"], + CustomFields: make(map[string]string), + } + + return metadata, nil +} +``` + +--- + +### 2. Writing Metadata +**Using FFmpeg (lossless remux):** +```go +func embedMetadata(inputPath string, metadata *VideoMetadata, outputPath string) error { + args := []string{ + "-i", inputPath, + "-c", "copy", // Lossless copy + } + + // Add standard tags + if metadata.Title != "" { + args = append(args, "-metadata", fmt.Sprintf("title=%s", metadata.Title)) + } + if metadata.Studio != "" { + args = append(args, "-metadata", fmt.Sprintf("studio=%s", metadata.Studio)) + } + if metadata.Series != "" { + args = append(args, "-metadata", fmt.Sprintf("album=%s", metadata.Series)) + } + if metadata.Date != "" { + args = append(args, "-metadata", fmt.Sprintf("date=%s", metadata.Date)) + } + if len(metadata.Categories) > 0 { + args = append(args, "-metadata", fmt.Sprintf("genre=%s", strings.Join(metadata.Categories, ", "))) + } + if metadata.Description != "" { + args = append(args, "-metadata", fmt.Sprintf("comment=%s", metadata.Description)) + } + + // Add custom fields + for key, value := range metadata.CustomFields { + args = append(args, "-metadata", fmt.Sprintf("%s=%s", key, value)) + } + + args = append(args, outputPath) + + cmd := exec.Command("ffmpeg", args...) + return cmd.Run() +} +``` + +--- + +### 3. Generating NFO +```go +func generateNFO(metadata *VideoMetadata, videoPath string) (string, error) { + nfo := ` + + ` + escapeXML(metadata.Title) + ` + ` + escapeXML(metadata.Studio) + ` + ` + escapeXML(metadata.Series) + ` + ` + metadata.Date + ` + ` + escapeXML(metadata.Description) + ` +` + + // Add performers + for _, performer := range metadata.Performers { + nfo += ` + ` + escapeXML(performer) + ` + +` + } + + // Add categories/genres + for _, category := range metadata.Categories { + nfo += ` ` + escapeXML(category) + ` +` + } + + // Add custom fields + for key, value := range metadata.CustomFields { + nfo += ` <` + key + `>` + escapeXML(value) + ` +` + } + + nfo += `` + + // Save to file (same name as video + .nfo extension) + nfoPath := strings.TrimSuffix(videoPath, filepath.Ext(videoPath)) + ".nfo" + return nfoPath, os.WriteFile(nfoPath, []byte(nfo), 0644) +} + +func escapeXML(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, "\"", """) + s = strings.ReplaceAll(s, "'", "'") + return s +} +``` + +--- + +### 4. Attaching NFO to MKV +MKV supports embedded attachments (like NFO files): + +```bash +# Attach NFO file to MKV +mkvpropedit video.mkv --add-attachment scene_info.nfo --attachment-mime-type text/plain --attachment-name "scene_info.nfo" + +# Or with FFmpeg (re-mux required) +ffmpeg -i input.mkv -i scene_info.nfo -c copy \ + -attach scene_info.nfo -metadata:s:t:0 mimetype=text/plain \ + output.mkv +``` + +**Go implementation:** +```go +func attachNFOtoMKV(mkvPath string, nfoPath string) error { + cmd := exec.Command("mkvpropedit", mkvPath, + "--add-attachment", nfoPath, + "--attachment-mime-type", "text/plain", + "--attachment-name", filepath.Base(nfoPath), + ) + return cmd.Run() +} +``` + +--- + +## 📋 Metadata Templates + +Allow users to save metadata templates for batch processing. + +**Template JSON:** +```json +{ + "name": "Studio XYZ Default Template", + "fields": { + "studio": "Studio XYZ", + "series": "Series Name", + "categories": ["Category1", "Category2"], + "custom_fields": { + "director": "John Doe", + "producer": "Jane Smith" + } + } +} +``` + +**Usage:** +1. User creates template with common studio/series info +2. Load template when editing new video +3. Only fill in unique fields (title, performers, date, scene #) +4. Batch apply template to multiple files + +--- + +## 🎯 Use Cases + +### 1. Adult Content Library +``` +Title: "Scene Title" +Studio: "Production Studio" +Series: "Series Name - Season 2" +Scene Number: "EP042" +Performers: ["Performer A", "Performer B"] +Director: "Director Name" +Categories: ["Category1", "Category2", "Category3"] +Date: "2025-12-04" +Description: "Full scene description and plot" +``` + +### 2. Personal Video Archive +``` +Title: "Birthday Party 2025" +Event: "John's 30th Birthday" +Location: "Los Angeles, CA" +People: ["John", "Sarah", "Mike", "Emily"] +Date: "2025-06-15" +Description: "John's surprise birthday party" +``` + +### 3. Movie Collection +``` +Title: "Movie Title" +Original Title: "原題" +Director: "Christopher Nolan" +Year: "2024" +IMDB ID: "tt1234567" +Actors: ["Actor 1", "Actor 2"] +Genre: ["Sci-Fi", "Thriller"] +Rating: "8.5/10" +``` + +--- + +## 🔌 Integration with Existing Modules + +### Convert Module +- **Checkbox**: "Preserve metadata" (default: on) +- **Checkbox**: "Copy metadata from source" (default: on) +- Allow adding/editing metadata before conversion + +### Inspect Module +- **Add tab**: "Metadata" to view/edit metadata +- Show both standard and custom fields +- Quick edit without re-encoding + +### Compare Module +- **Add**: "Compare Metadata" button +- Show metadata diff between two files +- Highlight differences + +--- + +## 🚀 Implementation Roadmap + +### Phase 1: Basic Metadata Support (Week 1) +- Read metadata with ffprobe +- Display in Inspect module +- Edit basic fields (title, artist, date, comment) +- Write metadata with FFmpeg (lossless) + +### Phase 2: NFO Generation (Week 2) +- NFO file generation +- Save alongside video file +- Load NFO and populate fields +- Template system + +### Phase 3: Advanced Metadata (Week 3) +- Custom fields support +- Performers list +- Categories/tags +- Metadata Editor module UI + +### Phase 4: Batch & Templates (Week 4) +- Metadata templates +- Batch apply to multiple files +- MKV attachment support (embed NFO) + +--- + +## 📚 References + +### FFmpeg Metadata Documentation +- https://ffmpeg.org/ffmpeg-formats.html#Metadata +- https://wiki.multimedia.cx/index.php/FFmpeg_Metadata + +### NFO Format Standards +- Kodi NFO: https://kodi.wiki/view/NFO_files +- Plex Agents: https://support.plex.tv/articles/ + +### Matroska Tags +- https://www.matroska.org/technical/specs/tagging/index.html + +--- + +## ✅ Summary + +**Yes, you can absolutely store custom metadata in video files!** + +**Best format for rich metadata:** MKV (unlimited custom tags + file attachments) + +**Most compatible:** MP4/MOV (iTunes tags work in QuickTime, VLC, etc.) + +**Recommended approach for VideoTools:** +1. Support both embedded metadata (in video file) AND sidecar NFO files +2. MKV: Embed NFO as attachment + metadata tags +3. MP4: Metadata tags + separate .nfo file +4. Allow users to choose what metadata to embed +5. Generate NFO for media center compatibility (Kodi, Plex, Jellyfin) + +**Next steps:** +1. Add basic metadata reading to `probeVideo()` function +2. Add metadata display to Inspect module +3. Create Metadata Editor module +4. Implement NFO generation +5. Add metadata templates + +This would be a killer feature for VideoTools! 🚀 diff --git a/docs/VIDEO_PLAYER_FORK.md b/docs/VIDEO_PLAYER_FORK.md index 363a8eb..529ad32 100644 --- a/docs/VIDEO_PLAYER_FORK.md +++ b/docs/VIDEO_PLAYER_FORK.md @@ -1,12 +1,16 @@ # Video Player Fork Plan +## Status: COMPLETED ✅ +**VT_Player has been forked as a separate project for independent development.** + ## Overview -The video player component will be extracted into a separate project to allow independent development and improvement of video playback controls while keeping VideoTools focused on video processing. +The video player component has been extracted into a separate project (VT_Player) to allow independent development and improvement of video playback controls while keeping VideoTools focused on video processing. ## Current Player Integration -The player is currently embedded in VideoTools at: -- `internal/player/` - Player implementation -- `main.go` - Player state and controls in Convert module +The player is used in VideoTools at: +- Convert module - Video preview and playback +- Compare module - Side-by-side video comparison (as of dev13) +- Inspect module - Single video playback with metadata (as of dev13) - Preview frame display - Playback controls (play/pause, seek, volume) @@ -19,17 +23,20 @@ The player is currently embedded in VideoTools at: - Can be used by other projects ### 2. Improved Controls -Current limitations to address: +Features to develop in VT_Player: +- **Keyframing** - Mark in/out points for trimming and chapter creation - Tighten up video controls - Better seek bar with thumbnails on hover - Improved timeline scrubbing - Keyboard shortcuts for playback -- Frame-accurate stepping -- Playback speed controls +- Frame-accurate stepping (←/→ keys for frame-by-frame) +- Playback speed controls (0.25x to 2x) - Better volume control UI +- Timeline markers for chapters +- Visual in/out point indicators ### 3. Clean API -The forked player should expose a clean API: +VT_Player should expose a clean API for VideoTools integration: ```go type Player interface { Load(path string) error @@ -38,36 +45,42 @@ type Player interface { Seek(position time.Duration) GetFrame(position time.Duration) (image.Image, error) SetVolume(level float64) + + // Keyframing support for Trim/Chapter modules + SetInPoint(position time.Duration) + SetOutPoint(position time.Duration) + GetInPoint() time.Duration + GetOutPoint() time.Duration + ClearKeyframes() + Close() } ``` -## Migration Strategy +## VT_Player Development Strategy -### Phase 1: Extract to Separate Module -1. Create new repository: `github.com/yourusername/fyne-videoplayer` -2. Copy `internal/player/` to new repo -3. Extract player dependencies -4. Create clean API surface -5. Add comprehensive tests +### Phase 1: Core Player Features ✅ +- [x] Basic playback controls (play/pause/seek) +- [x] Volume control +- [x] Frame preview display +- [x] Integration with VideoTools modules -### Phase 2: Update VideoTools -1. Import fyne-videoplayer as dependency -2. Replace internal/player with external package -3. Update player instantiation -4. Verify all playback features work -5. Remove old internal/player code +### Phase 2: Enhanced Controls (Current Focus) +Priority features for Trim/Chapter module integration: +- [ ] **Keyframe markers** - Set In/Out points visually on timeline +- [ ] **Frame-accurate stepping** - ←/→ keys for frame-by-frame navigation +- [ ] **Visual timeline with markers** - Show In/Out points on seek bar +- [ ] **Keyboard shortcuts** - I (in), O (out), Space (play/pause), ←/→ (step) +- [ ] **Export keyframe data** - Return In/Out timestamps to VideoTools -### Phase 3: Enhance Player (Post-Fork) -Features to add after fork: +### Phase 3: Advanced Features (Future) - [ ] Thumbnail preview on seek bar hover -- [ ] Frame-accurate stepping (←/→ keys) - [ ] Playback speed controls (0.25x to 2x) -- [ ] Improved volume slider -- [ ] Keyboard shortcuts (Space, K, J, L, etc.) -- [ ] Timeline markers +- [ ] Improved volume slider with visual feedback +- [ ] Chapter markers on timeline - [ ] Subtitle support - [ ] Multi-audio track switching +- [ ] Zoom timeline for precision editing ## Technical Considerations @@ -89,18 +102,37 @@ Player must work on: - Low CPU usage during playback - Fast seeking -## Timeline -1. **Week 1-2**: Extract player code, create repo, clean API -2. **Week 3**: Integration testing, update VideoTools -3. **Week 4+**: Enhanced controls and features +## VideoTools Module Integration + +### Modules Using VT_Player +1. **Convert Module** - Preview video before conversion +2. **Compare Module** - Side-by-side video playback for comparison +3. **Inspect Module** - Single video playback with detailed metadata +4. **Trim Module** (planned) - Keyframe-based trimming with In/Out points +5. **Chapter Module** (planned) - Mark chapter points on timeline + +### Integration Requirements for Trim/Chapter +The Trim and Chapter modules will require: +- Keyframe API to set In/Out points +- Visual markers on timeline showing trim regions +- Frame-accurate seeking for precise cuts +- Ability to export timestamp data for FFmpeg commands +- Preview of trimmed segment before processing ## Benefits -- **VideoTools**: Leaner codebase, focus on processing -- **Player**: Independent evolution, reusable component -- **Users**: Better video controls, more reliable playback -- **Developers**: Easier to contribute to either project +- **VideoTools**: Leaner codebase, focus on video processing +- **VT_Player**: Independent evolution, reusable component, dedicated feature development +- **Users**: Professional-grade video controls, precise editing capabilities +- **Developers**: Easier to contribute, clear separation of concerns + +## Development Philosophy +- **VT_Player**: Focus on playback, navigation, and visual controls +- **VideoTools**: Focus on video processing, encoding, and batch operations +- Clean API boundary allows independent versioning +- VT_Player features can be tested independently before VideoTools integration ## Notes -- Keep player dependency minimal in VideoTools -- Player should be optional - frame display can work without playback -- Consider using player in Compare module for side-by-side playback (future) +- VT_Player repo: Separate project with independent development cycle +- VideoTools will import VT_Player as external dependency +- Keyframing features are priority for Trim/Chapter module development +- Compare module demonstrates successful multi-player integration diff --git a/docs/VT_PLAYER_INTEGRATION_NOTES.md b/docs/VT_PLAYER_INTEGRATION_NOTES.md new file mode 100644 index 0000000..31ed102 --- /dev/null +++ b/docs/VT_PLAYER_INTEGRATION_NOTES.md @@ -0,0 +1,373 @@ +# VT_Player Integration Notes for Lead Developer + +## Project Context + +**VideoTools Repository**: https://git.leaktechnologies.dev/Leak_Technologies/VideoTools.git +**VT_Player**: Forked video player component for independent development + +VT_Player was forked from VideoTools to enable dedicated development of video playback controls and features without impacting the main VideoTools codebase. + +## Current Integration Points + +### VideoTools Modules Using VT_Player + +1. **Convert Module** - Preview video before/during conversion +2. **Compare Module** - Side-by-side video comparison (2 players) +3. **Inspect Module** - Single video playback with metadata display +4. **Compare Fullscreen** - Larger side-by-side view (planned: synchronized playback) + +### Current VT_Player Usage Pattern + +```go +// VideoTools calls buildVideoPane() which creates player +videoPane := buildVideoPane(state, fyne.NewSize(320, 180), videoSource, updateCallback) + +// buildVideoPane internally: +// - Creates player.Controller +// - Sets up playback controls +// - Returns fyne.CanvasObject with player UI +``` + +## Priority Features Needed in VT_Player + +### 1. Keyframing API (HIGHEST PRIORITY) +**Required for**: Trim Module, Chapter Module + +```go +// Proposed API +type KeyframeController interface { + // Set keyframe markers + SetInPoint(position time.Duration) error + SetOutPoint(position time.Duration) error + ClearInPoint() + ClearOutPoint() + ClearAllKeyframes() + + // Get keyframe data + GetInPoint() (time.Duration, bool) // Returns position and hasInPoint + GetOutPoint() (time.Duration, bool) + GetSegmentDuration() time.Duration // Duration between In and Out + + // Visual feedback + ShowKeyframeMarkers(show bool) // Toggle marker visibility on timeline + HighlightSegment(in, out time.Duration) // Highlight region between markers +} +``` + +**Use Case**: User scrubs video, presses `I` to set In point, scrubs to end, presses `O` to set Out point. Visual markers show on timeline. VideoTools reads timestamps for FFmpeg trim command. + +### 2. Frame-Accurate Navigation (HIGH PRIORITY) +**Required for**: Trim Module, Compare sync + +```go +type FrameNavigationController interface { + // Step through video frame-by-frame + StepForward() error // Advance exactly 1 frame + StepBackward() error // Go back exactly 1 frame + + // Frame info + GetCurrentFrame() int64 // Current frame number + GetFrameAtTime(time.Duration) int64 // Frame number at timestamp + GetTimeAtFrame(int64) time.Duration // Timestamp of frame number + GetTotalFrames() int64 + + // Seek to exact frame + SeekToFrame(frameNum int64) error +} +``` + +**Use Case**: User finds exact frame for cut point using arrow keys (←/→), sets In/Out markers precisely. + +### 3. Synchronized Playback API (MEDIUM PRIORITY) +**Required for**: Compare Fullscreen, Compare Module sync + +```go +type SyncController interface { + // Link two players together + SyncWith(otherPlayer player.Controller) error + Unsync() + IsSynced() bool + GetSyncMaster() player.Controller + + // Callbacks for sync events + OnPlayStateChanged(callback func(playing bool)) + OnPositionChanged(callback func(position time.Duration)) + + // Sync with offset (for videos that don't start at same time) + SetSyncOffset(offset time.Duration) + GetSyncOffset() time.Duration +} +``` + +**Use Case**: Compare module loads two videos. User clicks "Play Both" button. Both players play in sync. When one player is paused/seeked, other follows. + +### 4. Playback Speed Control (MEDIUM PRIORITY) +**Required for**: Trim Module, general UX improvement + +```go +type PlaybackSpeedController interface { + SetPlaybackSpeed(speed float64) error // 0.25x to 2.0x + GetPlaybackSpeed() float64 + GetSupportedSpeeds() []float64 // [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0] +} +``` + +**Use Case**: User slows playback to 0.25x to find exact frame for trim point. + +## Integration Architecture + +### Current Pattern +``` +VideoTools (main.go) + └─> buildVideoPane() + └─> player.New() + └─> player.Controller interface + └─> Returns fyne.CanvasObject +``` + +### Proposed Enhanced Pattern +``` +VideoTools (main.go) + └─> buildVideoPane() + └─> player.NewEnhanced() + ├─> player.Controller (basic playback) + ├─> player.KeyframeController (trim support) + ├─> player.FrameNavigationController (frame stepping) + ├─> player.SyncController (multi-player sync) + └─> player.PlaybackSpeedController (speed control) + └─> Returns fyne.CanvasObject +``` + +### Backward Compatibility +- Keep existing `player.Controller` interface unchanged +- Add new optional interfaces +- VideoTools checks if player implements enhanced interfaces: + +```go +if keyframer, ok := player.(KeyframeController); ok { + // Use keyframe features +} +``` + +## Technical Requirements + +### 1. Timeline Visual Enhancements + +Current timeline needs: +- **In/Out Point Markers**: Visual indicators (⬇️ symbols or colored bars) +- **Segment Highlight**: Show region between In and Out with different color +- **Frame Number Display**: Show current frame number alongside timestamp +- **Marker Drag Support**: Allow dragging markers to adjust In/Out points + +### 2. Keyboard Shortcuts + +Essential shortcuts for VT_Player: + +| Key | Action | Notes | +|-----|--------|-------| +| `Space` | Play/Pause | Standard | +| `←` | Step backward 1 frame | Frame-accurate | +| `→` | Step forward 1 frame | Frame-accurate | +| `Shift+←` | Jump back 1 second | Quick navigation | +| `Shift+→` | Jump forward 1 second | Quick navigation | +| `I` | Set In Point | Trim support | +| `O` | Set Out Point | Trim support | +| `C` | Clear keyframes | Reset markers | +| `K` | Pause | Video editor standard | +| `J` | Rewind | Video editor standard | +| `L` | Fast forward | Video editor standard | +| `0-9` | Seek to % | 0=start, 5=50%, 9=90% | + +### 3. Performance Considerations + +- **Frame stepping**: Must be instant, no lag +- **Keyframe display**: Update timeline without stuttering +- **Sync**: Maximum 1-frame drift between synced players +- **Memory**: Don't load entire video into RAM for frame navigation + +### 4. FFmpeg Integration + +VT_Player should expose frame-accurate timestamps that VideoTools can use: + +```bash +# Example: VideoTools gets In=83.456s, Out=296.789s from VT_Player +ffmpeg -ss 83.456 -to 296.789 -i input.mp4 -c copy output.mp4 +``` + +Frame-accurate seeking requires: +- Seek to nearest keyframe before target +- Decode frames until exact target reached +- Display correct frame with minimal latency + +## Data Flow Examples + +### Trim Module Workflow +``` +1. User loads video in Trim module +2. VideoTools creates VT_Player with keyframe support +3. User navigates with arrow keys (VT_Player handles frame stepping) +4. User presses 'I' → VT_Player sets In point marker +5. User navigates to end point +6. User presses 'O' → VT_Player sets Out point marker +7. User clicks "Preview Trim" → VT_Player plays segment between markers +8. User clicks "Add to Queue" +9. VideoTools reads keyframes: in = player.GetInPoint(), out = player.GetOutPoint() +10. VideoTools builds FFmpeg command with timestamps +11. FFmpeg trims video +``` + +### Compare Sync Workflow +``` +1. User loads 2 videos in Compare module +2. VideoTools creates 2 VT_Player instances +3. User clicks "Play Both" +4. VideoTools calls: player1.SyncWith(player2) +5. VideoTools calls: player1.Play() +6. VT_Player automatically plays player2 in sync +7. User pauses player1 → VT_Player pauses player2 +8. User seeks player1 → VT_Player seeks player2 to same position +``` + +## Testing Requirements + +VT_Player should include tests for: + +1. **Keyframe Accuracy** + - Set In/Out points, verify exact timestamps returned + - Clear markers, verify they're removed + - Test edge cases (In > Out, negative times, beyond duration) + +2. **Frame Navigation** + - Step forward/backward through entire video + - Verify frame numbers are sequential + - Test at video start (can't go back) and end (can't go forward) + +3. **Sync Reliability** + - Play two videos for 30 seconds, verify max drift < 1 frame + - Pause/seek operations propagate correctly + - Unsync works properly + +4. **Performance** + - Frame step latency < 50ms + - Timeline marker updates < 16ms (60fps) + - Memory usage stable during long playback sessions + +## Communication Protocol + +### VideoTools → VT_Player + +VideoTools will request features through interface methods: + +```go +// Example: VideoTools wants to enable trim mode +if trimmer, ok := player.(TrimController); ok { + trimmer.EnableTrimMode(true) + trimmer.OnInPointSet(func(t time.Duration) { + // Update VideoTools UI to show In point timestamp + }) + trimmer.OnOutPointSet(func(t time.Duration) { + // Update VideoTools UI to show Out point timestamp + }) +} +``` + +### VT_Player → VideoTools + +VT_Player communicates state changes through callbacks: + +```go +player.OnPlaybackStateChanged(func(playing bool) { + // VideoTools updates UI (play button ↔ pause button) +}) + +player.OnPositionChanged(func(position time.Duration) { + // VideoTools updates position display +}) + +player.OnKeyframeSet(func(markerType string, position time.Duration) { + // VideoTools logs keyframe for FFmpeg command +}) +``` + +## Migration Strategy + +### Phase 1: Core API (Immediate) +- Define interfaces for keyframe, frame nav, sync +- Implement basic keyframe markers (In/Out points) +- Add frame stepping (←/→ keys) +- Document API for VideoTools integration + +### Phase 2: Visual Enhancements (Week 2) +- Enhanced timeline with marker display +- Segment highlighting between In/Out +- Frame number display +- Keyboard shortcuts + +### Phase 3: Sync Features (Week 3) +- Implement synchronized playback API +- Master-slave pattern for linked players +- Offset compensation for non-aligned videos + +### Phase 4: Advanced Features (Week 4+) +- Playback speed control +- Timeline zoom for precision editing +- Thumbnail preview on hover +- Chapter markers + +## Notes for VT_Player Developer + +1. **Keep backward compatibility**: Existing VideoTools code using basic player.Controller should continue working + +2. **Frame-accurate is critical**: Trim module requires exact frame positioning. Off-by-one frame errors are unacceptable. + +3. **Performance over features**: Frame stepping must be instant. Users will hold arrow keys to scrub through video. + +4. **Visual feedback matters**: Keyframe markers must be immediately visible. Timeline updates should be smooth. + +5. **Cross-platform testing**: VT_Player must work on Linux (GNOME/X11/Wayland), macOS, and Windows + +6. **FFmpeg integration**: VT_Player doesn't run FFmpeg, but must provide precise timestamps that VideoTools can pass to FFmpeg + +7. **Minimize dependencies**: Keep VT_Player focused on playback/navigation. VideoTools handles video processing. + +## Questions to Consider + +1. **Keyframe storage**: Should keyframes be stored in VT_Player or passed back to VideoTools immediately? + +2. **Sync drift handling**: If synced players drift apart, which one is "correct"? Should we periodically resync? + +3. **Frame stepping during playback**: Can user step frame-by-frame while video is playing, or must they pause first? + +4. **Memory management**: For long videos (hours), how do we efficiently support frame-accurate navigation without excessive memory? + +5. **Hardware acceleration**: Should frame stepping use GPU decoding, or is CPU sufficient for single frames? + +## Current VideoTools Status + +### Working Modules +- ✅ Convert - Video conversion with preview +- ✅ Compare - Side-by-side comparison (basic) +- ✅ Inspect - Single video with metadata +- ✅ Compare Fullscreen - Larger view (sync placeholders added) + +### Planned Modules Needing VT_Player Features +- ⏳ Trim - **Needs**: Keyframing + frame navigation +- ⏳ Chapter - **Needs**: Multiple keyframe markers on timeline +- ⏳ Merge - May need synchronized preview of multiple clips + +### Auto-Compare Feature (NEW) +- ✅ Checkbox in Convert module: "Compare After" +- ✅ After conversion completes, automatically loads: + - File 1 (Original) = source video + - File 2 (Converted) = output video +- ✅ User can immediately inspect conversion quality + +## Contact & Coordination + +For questions about VideoTools integration: +- Review this document +- Check `/docs/VIDEO_PLAYER_FORK.md` for fork strategy +- Check `/docs/TRIM_MODULE_DESIGN.md` for detailed trim module requirements +- Check `/docs/COMPARE_FULLSCREEN.md` for sync requirements + +VideoTools will track VT_Player changes and update integration code as new features become available. diff --git a/internal/queue/queue.go b/internal/queue/queue.go index b248add..df2beb7 100644 --- a/internal/queue/queue.go +++ b/internal/queue/queue.go @@ -330,6 +330,22 @@ func (q *Queue) processJobs() { return } + // Check if there's already a running job (only process one at a time) + hasRunningJob := false + for _, job := range q.jobs { + if job.Status == JobStatusRunning { + hasRunningJob = true + break + } + } + + // If a job is already running, wait and check again later + if hasRunningJob { + q.mu.Unlock() + time.Sleep(500 * time.Millisecond) + continue + } + // Find highest priority pending job var nextJob *Job highestPriority := -1 diff --git a/internal/ui/components.go b/internal/ui/components.go index c131f1f..800375f 100644 --- a/internal/ui/components.go +++ b/internal/ui/components.go @@ -4,6 +4,7 @@ import ( "fmt" "image/color" "strings" + "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" @@ -49,11 +50,13 @@ func (m *MonoTheme) Size(name fyne.ThemeSizeName) float32 { // ModuleTile is a clickable tile widget for module selection type ModuleTile struct { widget.BaseWidget - label string - color color.Color - enabled bool - onTapped func() - onDropped func([]fyne.URI) + label string + color color.Color + enabled bool + onTapped func() + onDropped func([]fyne.URI) + flashing bool + draggedOver bool } // NewModuleTile creates a new module tile @@ -72,15 +75,40 @@ func NewModuleTile(label string, col color.Color, enabled bool, tapped func(), d // DraggedOver implements desktop.Droppable interface func (m *ModuleTile) DraggedOver(pos fyne.Position) { logging.Debug(logging.CatUI, "DraggedOver tile=%s enabled=%v pos=%v", m.label, m.enabled, pos) + if m.enabled { + m.draggedOver = true + m.Refresh() + } +} + +// DraggedOut is called when drag leaves the tile +func (m *ModuleTile) DraggedOut() { + logging.Debug(logging.CatUI, "DraggedOut tile=%s", m.label) + m.draggedOver = false + m.Refresh() } // Dropped implements desktop.Droppable interface func (m *ModuleTile) Dropped(pos fyne.Position, items []fyne.URI) { + fmt.Printf("[DROPTILE] Dropped on tile=%s enabled=%v itemCount=%d\n", m.label, m.enabled, len(items)) logging.Debug(logging.CatUI, "Dropped on tile=%s enabled=%v items=%v", m.label, m.enabled, items) + // Reset dragged over state + m.draggedOver = false + if m.enabled && m.onDropped != nil { + fmt.Printf("[DROPTILE] Calling callback for %s\n", m.label) logging.Debug(logging.CatUI, "Calling onDropped callback for %s", m.label) + // Trigger flash animation + m.flashing = true + m.Refresh() + // Reset flash after 300ms + time.AfterFunc(300*time.Millisecond, func() { + m.flashing = false + m.Refresh() + }) m.onDropped(items) } else { + fmt.Printf("[DROPTILE] Drop IGNORED on %s: enabled=%v hasCallback=%v\n", m.label, m.enabled, m.onDropped != nil) logging.Debug(logging.CatUI, "Drop ignored: enabled=%v hasCallback=%v", m.enabled, m.onDropped != nil) } } @@ -145,6 +173,22 @@ func (r *moduleTileRenderer) MinSize() fyne.Size { func (r *moduleTileRenderer) Refresh() { r.bg.FillColor = r.tile.color + + // Apply visual feedback based on state + if r.tile.flashing { + // Flash animation - white outline + r.bg.StrokeColor = color.White + r.bg.StrokeWidth = 3 + } else if r.tile.draggedOver { + // Dragging over - cyan/blue outline to indicate drop zone + r.bg.StrokeColor = color.NRGBA{R: 0, G: 200, B: 255, A: 255} + r.bg.StrokeWidth = 3 + } else { + // Normal state + r.bg.StrokeColor = GridColor + r.bg.StrokeWidth = 1 + } + r.bg.Refresh() r.label.Text = r.tile.label r.label.Refresh() diff --git a/internal/ui/mainmenu.go b/internal/ui/mainmenu.go index 1308e2b..709227b 100644 --- a/internal/ui/mainmenu.go +++ b/internal/ui/mainmenu.go @@ -34,18 +34,25 @@ func BuildMainMenu(modules []ModuleInfo, onModuleClick func(string), onModuleDro ) var tileObjects []fyne.CanvasObject - for _, mod := range modules { - modID := mod.ID // Capture for closure + for i := range modules { + mod := modules[i] // Create new variable for this iteration + modID := mod.ID // Capture for closure var tapFunc func() var dropFunc func([]fyne.URI) if mod.Enabled { + // Create new closure with properly captured modID + id := modID // Explicit capture tapFunc = func() { - onModuleClick(modID) + onModuleClick(id) } dropFunc = func(items []fyne.URI) { - onModuleDrop(modID, items) + fmt.Printf("[MAINMENU] dropFunc called for module=%s itemCount=%d\n", id, len(items)) + logging.Debug(logging.CatUI, "MainMenu dropFunc called for module=%s itemCount=%d", id, len(items)) + onModuleDrop(id, items) } } + fmt.Printf("[MAINMENU] Creating tile for module=%s enabled=%v hasDropFunc=%v\n", modID, mod.Enabled, dropFunc != nil) + logging.Debug(logging.CatUI, "Creating tile for module=%s enabled=%v hasDropFunc=%v", modID, mod.Enabled, dropFunc != nil) tileObjects = append(tileObjects, buildModuleTile(mod, tapFunc, dropFunc)) } diff --git a/internal/ui/queueview.go b/internal/ui/queueview.go index 911ac95..c6a3017 100644 --- a/internal/ui/queueview.go +++ b/internal/ui/queueview.go @@ -228,7 +228,22 @@ func getStatusText(job *queue.Job) string { if job.StartedAt != nil { elapsed = fmt.Sprintf(" | Elapsed: %s", time.Since(*job.StartedAt).Round(time.Second)) } - return fmt.Sprintf("Status: Running | Progress: %.1f%%%s", job.Progress, elapsed) + + // Add FPS and speed info if available in Config + var extras string + if job.Config != nil { + if fps, ok := job.Config["fps"].(float64); ok && fps > 0 { + extras += fmt.Sprintf(" | %.0f fps", fps) + } + if speed, ok := job.Config["speed"].(float64); ok && speed > 0 { + extras += fmt.Sprintf(" | %.2fx", speed) + } + if etaDuration, ok := job.Config["eta"].(time.Duration); ok && etaDuration > 0 { + extras += fmt.Sprintf(" | ETA %s", etaDuration.Round(time.Second)) + } + } + + return fmt.Sprintf("Status: Running | Progress: %.1f%%%s%s", job.Progress, elapsed, extras) case queue.JobStatusPaused: return "Status: Paused" case queue.JobStatusCompleted: @@ -259,23 +274,23 @@ func buildModuleBadge(t queue.JobType) fyne.CanvasObject { return container.NewMax(bg, container.NewCenter(label)) } -// moduleColor maps job types to distinct colors for quick visual scanning. +// moduleColor maps job types to distinct colors matching the main module colors func moduleColor(t queue.JobType) color.Color { switch t { case queue.JobTypeConvert: - return color.RGBA{R: 76, G: 232, B: 112, A: 255} // green + return color.RGBA{R: 139, G: 68, B: 255, A: 255} // Violet (#8B44FF) case queue.JobTypeMerge: - return color.RGBA{R: 68, G: 136, B: 255, A: 255} // blue + return color.RGBA{R: 68, G: 136, B: 255, A: 255} // Blue (#4488FF) case queue.JobTypeTrim: - return color.RGBA{R: 255, G: 193, B: 7, A: 255} // amber + return color.RGBA{R: 68, G: 221, B: 255, A: 255} // Cyan (#44DDFF) case queue.JobTypeFilter: - return color.RGBA{R: 160, G: 86, B: 255, A: 255} // purple + return color.RGBA{R: 68, G: 255, B: 136, A: 255} // Green (#44FF88) case queue.JobTypeUpscale: - return color.RGBA{R: 255, G: 138, B: 101, A: 255} // coral + return color.RGBA{R: 170, G: 255, B: 68, A: 255} // Yellow-Green (#AAFF44) case queue.JobTypeAudio: - return color.RGBA{R: 255, G: 215, B: 64, A: 255} // gold + return color.RGBA{R: 255, G: 215, B: 68, A: 255} // Yellow (#FFD744) case queue.JobTypeThumb: - return color.RGBA{R: 102, G: 217, B: 239, A: 255} // teal + return color.RGBA{R: 255, G: 136, B: 68, A: 255} // Orange (#FF8844) default: return color.Gray{Y: 180} } diff --git a/main.go b/main.go index b83f541..b1b2f9a 100644 --- a/main.go +++ b/main.go @@ -194,6 +194,9 @@ type appState struct { convertActiveIn string convertActiveOut string convertProgress float64 + convertFPS float64 + convertSpeed float64 + convertETA time.Duration playSess *playSession jobQueue *queue.Queue statsBar *ui.ConversionStatsBar @@ -202,6 +205,8 @@ type appState struct { queueOffset fyne.Position compareFile1 *videoSource compareFile2 *videoSource + inspectFile *videoSource + autoCompare bool // Auto-load Compare module after conversion } func (s *appState) stopPreview() { @@ -430,7 +435,7 @@ func (s *appState) showMainMenu() { ID: m.ID, Label: m.Label, Color: m.Color, - Enabled: m.ID == "convert" || m.ID == "compare", // Convert and compare modules are functional + Enabled: m.ID == "convert" || m.ID == "compare" || m.ID == "inspect", // Convert, compare, and inspect modules are functional }) } @@ -491,6 +496,11 @@ func (s *appState) refreshQueueView() { Title: fmt.Sprintf("Direct convert: %s", in), Description: fmt.Sprintf("Output: %s", out), Progress: s.convertProgress, + Config: map[string]interface{}{ + "fps": s.convertFPS, + "speed": s.convertSpeed, + "eta": s.convertETA, + }, }}, jobs...) } @@ -568,9 +578,18 @@ func (s *appState) refreshQueueView() { // Restore scroll offset s.queueScroll = scroll - if s.queueScroll != nil { - s.queueScroll.Offset = s.queueOffset - s.queueScroll.Refresh() + if s.queueScroll != nil && s.active == "queue" { + // Use ScrollTo instead of directly setting Offset to prevent rubber banding + // Defer to allow UI to settle first + go func() { + time.Sleep(50 * time.Millisecond) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + if s.queueScroll != nil { + s.queueScroll.Offset = s.queueOffset + s.queueScroll.Refresh() + } + }, false) + }() } s.setContent(container.NewPadded(view)) @@ -659,6 +678,8 @@ func (s *appState) showModule(id string) { s.showConvertView(nil) case "compare": s.showCompareView() + case "inspect": + s.showInspectView() default: logging.Debug(logging.CatUI, "UI module %s not wired yet", id) } @@ -735,10 +756,24 @@ func (s *appState) handleModuleDrop(moduleID string, items []fyne.URI) { }, false) } - // Update state and show module + // Update state and show module (with small delay to allow flash animation to be seen) + time.Sleep(350 * time.Millisecond) fyne.CurrentApp().Driver().DoFromGoroutine(func() { - s.compareFile1 = src1 - s.compareFile2 = src2 + // Smart slot assignment: if dropping 2 videos, fill both slots + if len(videoPaths) >= 2 { + s.compareFile1 = src1 + s.compareFile2 = src2 + } else { + // Single video: fill the empty slot, or slot 1 if both empty + if s.compareFile1 == nil { + s.compareFile1 = src1 + } else if s.compareFile2 == nil { + s.compareFile2 = src1 + } else { + // Both slots full, overwrite slot 1 + s.compareFile1 = src1 + } + } s.showModule(moduleID) logging.Debug(logging.CatModule, "loaded %d video(s) for compare module", len(videoPaths)) }, false) @@ -746,6 +781,30 @@ func (s *appState) handleModuleDrop(moduleID string, items []fyne.URI) { return } + // If inspect module, load video into inspect slot + if moduleID == "inspect" { + path := videoPaths[0] + go func() { + src, err := probeVideo(path) + if err != nil { + logging.Debug(logging.CatModule, "failed to load video for inspect: %v", err) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + dialog.ShowError(fmt.Errorf("failed to load video: %w", err), s.window) + }, false) + return + } + + // Update state and show module (with small delay to allow flash animation) + time.Sleep(350 * time.Millisecond) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.inspectFile = src + s.showModule(moduleID) + logging.Debug(logging.CatModule, "loaded video for inspect module") + }, false) + }() + return + } + // Single file or non-convert module: load first video and show module path := videoPaths[0] logging.Debug(logging.CatModule, "drop on module %s path=%s - starting load", moduleID, path) @@ -753,7 +812,8 @@ func (s *appState) handleModuleDrop(moduleID string, items []fyne.URI) { go func() { logging.Debug(logging.CatModule, "loading video in goroutine") s.loadVideo(path) - // After loading, switch to the module + // After loading, switch to the module (with small delay to allow flash animation) + time.Sleep(350 * time.Millisecond) fyne.CurrentApp().Driver().DoFromGoroutine(func() { logging.Debug(logging.CatModule, "showing module %s after load", moduleID) s.showModule(moduleID) @@ -931,6 +991,20 @@ func (s *appState) showCompareView() { s.setContent(buildCompareView(s)) } +func (s *appState) showInspectView() { + s.stopPreview() + s.lastModule = s.active + s.active = "inspect" + s.setContent(buildInspectView(s)) +} + +func (s *appState) showCompareFullscreen() { + s.stopPreview() + s.lastModule = s.active + s.active = "compare-fullscreen" + s.setContent(buildCompareFullscreenView(s)) +} + // jobExecutor executes a job from the queue func (s *appState) jobExecutor(ctx context.Context, job *queue.Job, progressCallback func(float64)) error { logging.Debug(logging.CatSystem, "executing job %s: %s", job.ID, job.Title) @@ -2529,20 +2603,23 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject { } convertBtn = widget.NewButton("CONVERT NOW", func() { - // Check if queue is already running - if state.jobQueue != nil && state.jobQueue.IsRunning() { - dialog.ShowInformation("Queue Active", - "The conversion queue is currently running. Click \"Add to Queue\" to add this video to the queue instead.", - state.window) - // Auto-add to queue instead - if err := state.addConvertToQueue(); err != nil { - dialog.ShowError(err, state.window) - } else { - dialog.ShowInformation("Queue", "Job added to queue!", state.window) - } + // Add job to queue and start immediately + if err := state.addConvertToQueue(); err != nil { + dialog.ShowError(err, state.window) return } - state.startConvert(statusLabel, convertBtn, cancelBtn, activity) + + // Start the queue if not already running + if state.jobQueue != nil && !state.jobQueue.IsRunning() { + state.jobQueue.Start() + logging.Debug(logging.CatSystem, "started queue from Convert Now") + } + + // Clear the loaded video from convert module + state.clearVideo() + + // Show success message + dialog.ShowInformation("Convert", "Conversion started! View progress in Job Queue.", state.window) }) convertBtn.Importance = widget.HighImportance if src == nil { @@ -2560,7 +2637,13 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject { addQueueBtn.Enable() } - leftControls := container.NewHBox(resetBtn) + // Auto-compare checkbox + autoCompareCheck := widget.NewCheck("Compare After", func(checked bool) { + state.autoCompare = checked + }) + autoCompareCheck.SetChecked(state.autoCompare) + + leftControls := container.NewHBox(resetBtn, autoCompareCheck) centerStatus := container.NewHBox(activity, statusLabel) rightControls := container.NewHBox(cancelBtn, addQueueBtn, convertBtn) actionInner := container.NewBorder(nil, nil, leftControls, rightControls, centerStatus) @@ -3702,7 +3785,7 @@ func (s *appState) handleDrop(pos fyne.Position, items []fyne.URI) { // Load videos sequentially to avoid race conditions go func() { if len(videoPaths) == 1 { - // Single video dropped - fill first empty slot + // Single video dropped - use smart slot assignment src, err := probeVideo(videoPaths[0]) if err != nil { logging.Debug(logging.CatModule, "failed to load video: %v", err) @@ -3713,19 +3796,17 @@ func (s *appState) handleDrop(pos fyne.Position, items []fyne.URI) { } fyne.CurrentApp().Driver().DoFromGoroutine(func() { - // Fill first empty slot + // Smart slot assignment: fill the empty slot, or slot 1 if both empty if s.compareFile1 == nil { s.compareFile1 = src - logging.Debug(logging.CatModule, "loaded video into slot 1") + logging.Debug(logging.CatModule, "loaded video into empty slot 1") } else if s.compareFile2 == nil { s.compareFile2 = src - logging.Debug(logging.CatModule, "loaded video into slot 2") + logging.Debug(logging.CatModule, "loaded video into empty slot 2") } else { - // Both slots full - ask which to replace - dialog.ShowInformation("Both Slots Full", - "Both comparison slots are full. Use the Clear button to empty a slot first.", - s.window) - return + // Both slots full, overwrite slot 1 + s.compareFile1 = src + logging.Debug(logging.CatModule, "both slots full, overwriting slot 1") } s.showCompareView() }, false) @@ -3765,6 +3846,47 @@ func (s *appState) handleDrop(pos fyne.Position, items []fyne.URI) { return } + // If in inspect module, handle single video file + if s.active == "inspect" { + // Collect video files from dropped items + var videoPaths []string + for _, uri := range items { + if uri.Scheme() != "file" { + continue + } + path := uri.Path() + if s.isVideoFile(path) { + videoPaths = append(videoPaths, path) + } + } + + if len(videoPaths) == 0 { + logging.Debug(logging.CatUI, "no valid video files in dropped items") + dialog.ShowInformation("Inspect Video", "No video files found in dropped items.", s.window) + return + } + + // Load first video + go func() { + src, err := probeVideo(videoPaths[0]) + if err != nil { + logging.Debug(logging.CatModule, "failed to load video for inspect: %v", err) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + dialog.ShowError(fmt.Errorf("failed to load video: %w", err), s.window) + }, false) + return + } + + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.inspectFile = src + s.showInspectView() + logging.Debug(logging.CatModule, "loaded video into inspect module") + }, false) + }() + + return + } + // Other modules don't handle file drops yet logging.Debug(logging.CatUI, "drop ignored; module %s cannot handle files", s.active) } @@ -4592,6 +4714,7 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But progressQuit := make(chan struct{}) go func() { scanner := bufio.NewScanner(stdout) + var currentFPS float64 for scanner.Scan() { select { case <-progressQuit: @@ -4604,6 +4727,15 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But continue } key, val := parts[0], parts[1] + + // Capture FPS value + if key == "fps" { + if fps, err := strconv.ParseFloat(val, 64); err == nil { + currentFPS = fps + } + continue + } + if key != "out_time_ms" && key != "progress" { continue } @@ -4628,9 +4760,26 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But if elapsedWall > 0 { speed = elapsedProc / elapsedWall } - lbl := fmt.Sprintf("Converting… %.0f%% | elapsed %s | ETA %s | %.2fx", pct, formatShortDuration(elapsedWall), etaOrDash(eta), speed) + + var etaDuration time.Duration + if pct > 0 && elapsedWall > 0 && pct < 100 { + remaining := elapsedWall * (100 - pct) / pct + etaDuration = time.Duration(remaining * float64(time.Second)) + } + + // Build status with FPS + var lbl string + if currentFPS > 0 { + lbl = fmt.Sprintf("Converting… %.0f%% | %.0f fps | elapsed %s | ETA %s | %.2fx", pct, currentFPS, formatShortDuration(elapsedWall), etaOrDash(eta), speed) + } else { + lbl = fmt.Sprintf("Converting… %.0f%% | elapsed %s | ETA %s | %.2fx", pct, formatShortDuration(elapsedWall), etaOrDash(eta), speed) + } + fyne.CurrentApp().Driver().DoFromGoroutine(func() { s.convertProgress = pct + s.convertFPS = currentFPS + s.convertSpeed = speed + s.convertETA = etaDuration setStatus(lbl) // Keep stats bar and queue view in sync during direct converts s.updateStatsBar() @@ -4736,6 +4885,26 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But s.convertActiveOut = "" s.convertProgress = 100 setStatus("Done") + + // Auto-compare if enabled + if s.autoCompare { + go func() { + // Probe the output file + convertedSrc, err := probeVideo(outPath) + if err != nil { + logging.Debug(logging.CatModule, "auto-compare: failed to probe converted file: %v", err) + return + } + + // Load original and converted into compare slots + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + s.compareFile1 = src // Original + s.compareFile2 = convertedSrc // Converted + s.showCompareView() + logging.Debug(logging.CatModule, "auto-compare: loaded original vs converted") + }, false) + }() + } }, false) s.convertCancel = nil }() @@ -5287,6 +5456,15 @@ type CropValues struct { // detectCrop runs cropdetect analysis on a video to find black bars // Returns nil if no crop is detected or if detection fails func detectCrop(path string, duration float64) *CropValues { + // First, get source video dimensions for validation + src, err := probeVideo(path) + if err != nil { + logging.Debug(logging.CatFFMPEG, "failed to probe video for crop detection: %v", err) + return nil + } + sourceWidth := src.Width + sourceHeight := src.Height + logging.Debug(logging.CatFFMPEG, "source dimensions: %dx%d", sourceWidth, sourceHeight) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -5336,6 +5514,58 @@ func detectCrop(path string, duration float64) *CropValues { y, _ := strconv.Atoi(lastMatch[4]) logging.Debug(logging.CatFFMPEG, "detected crop: %dx%d at %d,%d", width, height, x, y) + + // Validate crop dimensions + if width <= 0 || height <= 0 { + logging.Debug(logging.CatFFMPEG, "invalid crop dimensions: width=%d height=%d", width, height) + return nil + } + + // Ensure crop doesn't exceed source dimensions + if width > sourceWidth { + logging.Debug(logging.CatFFMPEG, "crop width %d exceeds source width %d, clamping", width, sourceWidth) + width = sourceWidth + } + if height > sourceHeight { + logging.Debug(logging.CatFFMPEG, "crop height %d exceeds source height %d, clamping", height, sourceHeight) + height = sourceHeight + } + + // Ensure crop position + size doesn't exceed source + if x + width > sourceWidth { + logging.Debug(logging.CatFFMPEG, "crop x+width exceeds source, adjusting x from %d to %d", x, sourceWidth-width) + x = sourceWidth - width + if x < 0 { + x = 0 + width = sourceWidth + } + } + if y + height > sourceHeight { + logging.Debug(logging.CatFFMPEG, "crop y+height exceeds source, adjusting y from %d to %d", y, sourceHeight-height) + y = sourceHeight - height + if y < 0 { + y = 0 + height = sourceHeight + } + } + + // Ensure even dimensions (required for many codecs) + if width % 2 != 0 { + width -= 1 + logging.Debug(logging.CatFFMPEG, "adjusted width to even number: %d", width) + } + if height % 2 != 0 { + height -= 1 + logging.Debug(logging.CatFFMPEG, "adjusted height to even number: %d", height) + } + + // If crop is the same as source, no cropping needed + if width == sourceWidth && height == sourceHeight { + logging.Debug(logging.CatFFMPEG, "crop dimensions match source, no cropping needed") + return nil + } + + logging.Debug(logging.CatFFMPEG, "validated crop: %dx%d at %d,%d", width, height, x, y) return &CropValues{ Width: width, Height: height, @@ -5374,6 +5604,169 @@ func buildCompareView(state *appState) fyne.CanvasObject { instructions.Wrapping = fyne.TextWrapWord instructions.Alignment = fyne.TextAlignCenter + // Fullscreen Compare button + fullscreenBtn := widget.NewButton("Fullscreen Compare", func() { + if state.compareFile1 == nil && state.compareFile2 == nil { + dialog.ShowInformation("No Videos", "Load two videos to use fullscreen comparison.", state.window) + return + } + state.showCompareFullscreen() + }) + fullscreenBtn.Importance = widget.MediumImportance + + // Copy Comparison button - copies both files' metadata side by side + copyComparisonBtn := widget.NewButton("Copy Comparison", func() { + if state.compareFile1 == nil && state.compareFile2 == nil { + dialog.ShowInformation("No Videos", "Load at least one video to copy comparison metadata.", state.window) + return + } + + // Format side-by-side comparison + var comparisonText strings.Builder + comparisonText.WriteString("═══════════════════════════════════════════════════════════════════════\n") + comparisonText.WriteString(" VIDEO COMPARISON REPORT\n") + comparisonText.WriteString("═══════════════════════════════════════════════════════════════════════\n\n") + + // File names header + file1Name := "Not loaded" + file2Name := "Not loaded" + if state.compareFile1 != nil { + file1Name = filepath.Base(state.compareFile1.Path) + } + if state.compareFile2 != nil { + file2Name = filepath.Base(state.compareFile2.Path) + } + + comparisonText.WriteString(fmt.Sprintf("FILE 1: %s\n", file1Name)) + comparisonText.WriteString(fmt.Sprintf("FILE 2: %s\n", file2Name)) + comparisonText.WriteString("───────────────────────────────────────────────────────────────────────\n\n") + + // Helper to get field value or placeholder + getField := func(src *videoSource, getter func(*videoSource) string) string { + if src == nil { + return "—" + } + return getter(src) + } + + // File Info section + comparisonText.WriteString("━━━ FILE INFO ━━━\n") + + file1Size := getField(state.compareFile1, func(src *videoSource) string { + if fi, err := os.Stat(src.Path); err == nil { + sizeMB := float64(fi.Size()) / (1024 * 1024) + if sizeMB >= 1024 { + return fmt.Sprintf("%.2f GB", sizeMB/1024) + } + return fmt.Sprintf("%.2f MB", sizeMB) + } + return "Unknown" + }) + file2Size := getField(state.compareFile2, func(src *videoSource) string { + if fi, err := os.Stat(src.Path); err == nil { + sizeMB := float64(fi.Size()) / (1024 * 1024) + if sizeMB >= 1024 { + return fmt.Sprintf("%.2f GB", sizeMB/1024) + } + return fmt.Sprintf("%.2f MB", sizeMB) + } + return "Unknown" + }) + + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", "File Size:", file1Size, file2Size)) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Format:", + getField(state.compareFile1, func(s *videoSource) string { return s.Format }), + getField(state.compareFile2, func(s *videoSource) string { return s.Format }))) + + // Video section + comparisonText.WriteString("\n━━━ VIDEO ━━━\n") + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Codec:", + getField(state.compareFile1, func(s *videoSource) string { return s.VideoCodec }), + getField(state.compareFile2, func(s *videoSource) string { return s.VideoCodec }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Resolution:", + getField(state.compareFile1, func(s *videoSource) string { return fmt.Sprintf("%dx%d", s.Width, s.Height) }), + getField(state.compareFile2, func(s *videoSource) string { return fmt.Sprintf("%dx%d", s.Width, s.Height) }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Aspect Ratio:", + getField(state.compareFile1, func(s *videoSource) string { return s.AspectRatioString() }), + getField(state.compareFile2, func(s *videoSource) string { return s.AspectRatioString() }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Frame Rate:", + getField(state.compareFile1, func(s *videoSource) string { return fmt.Sprintf("%.2f fps", s.FrameRate) }), + getField(state.compareFile2, func(s *videoSource) string { return fmt.Sprintf("%.2f fps", s.FrameRate) }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Bitrate:", + getField(state.compareFile1, func(s *videoSource) string { return formatBitrate(s.Bitrate) }), + getField(state.compareFile2, func(s *videoSource) string { return formatBitrate(s.Bitrate) }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Pixel Format:", + getField(state.compareFile1, func(s *videoSource) string { return s.PixelFormat }), + getField(state.compareFile2, func(s *videoSource) string { return s.PixelFormat }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Color Space:", + getField(state.compareFile1, func(s *videoSource) string { return s.ColorSpace }), + getField(state.compareFile2, func(s *videoSource) string { return s.ColorSpace }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Color Range:", + getField(state.compareFile1, func(s *videoSource) string { return s.ColorRange }), + getField(state.compareFile2, func(s *videoSource) string { return s.ColorRange }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Field Order:", + getField(state.compareFile1, func(s *videoSource) string { return s.FieldOrder }), + getField(state.compareFile2, func(s *videoSource) string { return s.FieldOrder }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "GOP Size:", + getField(state.compareFile1, func(s *videoSource) string { return fmt.Sprintf("%d", s.GOPSize) }), + getField(state.compareFile2, func(s *videoSource) string { return fmt.Sprintf("%d", s.GOPSize) }))) + + // Audio section + comparisonText.WriteString("\n━━━ AUDIO ━━━\n") + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Codec:", + getField(state.compareFile1, func(s *videoSource) string { return s.AudioCodec }), + getField(state.compareFile2, func(s *videoSource) string { return s.AudioCodec }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Bitrate:", + getField(state.compareFile1, func(s *videoSource) string { return formatBitrate(s.AudioBitrate) }), + getField(state.compareFile2, func(s *videoSource) string { return formatBitrate(s.AudioBitrate) }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Sample Rate:", + getField(state.compareFile1, func(s *videoSource) string { return fmt.Sprintf("%d Hz", s.AudioRate) }), + getField(state.compareFile2, func(s *videoSource) string { return fmt.Sprintf("%d Hz", s.AudioRate) }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Channels:", + getField(state.compareFile1, func(s *videoSource) string { return fmt.Sprintf("%d", s.Channels) }), + getField(state.compareFile2, func(s *videoSource) string { return fmt.Sprintf("%d", s.Channels) }))) + + // Other section + comparisonText.WriteString("\n━━━ OTHER ━━━\n") + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Duration:", + getField(state.compareFile1, func(s *videoSource) string { return s.DurationString() }), + getField(state.compareFile2, func(s *videoSource) string { return s.DurationString() }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "SAR (Pixel Aspect):", + getField(state.compareFile1, func(s *videoSource) string { return s.SampleAspectRatio }), + getField(state.compareFile2, func(s *videoSource) string { return s.SampleAspectRatio }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Chapters:", + getField(state.compareFile1, func(s *videoSource) string { return fmt.Sprintf("%v", s.HasChapters) }), + getField(state.compareFile2, func(s *videoSource) string { return fmt.Sprintf("%v", s.HasChapters) }))) + comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", + "Metadata:", + getField(state.compareFile1, func(s *videoSource) string { return fmt.Sprintf("%v", s.HasMetadata) }), + getField(state.compareFile2, func(s *videoSource) string { return fmt.Sprintf("%v", s.HasMetadata) }))) + + comparisonText.WriteString("\n═══════════════════════════════════════════════════════════════════════\n") + + state.window.Clipboard().SetContent(comparisonText.String()) + dialog.ShowInformation("Copied", "Comparison metadata copied to clipboard", state.window) + }) + copyComparisonBtn.Importance = widget.LowImportance + // Clear All button clearAllBtn := widget.NewButton("Clear All", func() { state.compareFile1 = nil @@ -5382,7 +5775,7 @@ func buildCompareView(state *appState) fyne.CanvasObject { }) clearAllBtn.Importance = widget.LowImportance - instructionsRow := container.NewBorder(nil, nil, nil, clearAllBtn, instructions) + instructionsRow := container.NewBorder(nil, nil, nil, container.NewHBox(fullscreenBtn, copyComparisonBtn, clearAllBtn), instructions) // File labels file1Label := widget.NewLabel("File 1: Not loaded") @@ -5391,21 +5784,13 @@ func buildCompareView(state *appState) fyne.CanvasObject { file2Label := widget.NewLabel("File 2: Not loaded") file2Label.TextStyle = fyne.TextStyle{Bold: true} - // Thumbnail images - use smaller minimum for better flexibility - file1Thumbnail := canvas.NewImageFromImage(nil) - file1Thumbnail.FillMode = canvas.ImageFillContain - file1Thumbnail.SetMinSize(fyne.NewSize(240, 135)) + // Video player containers + file1VideoContainer := container.NewMax() + file2VideoContainer := container.NewMax() - file2Thumbnail := canvas.NewImageFromImage(nil) - file2Thumbnail.FillMode = canvas.ImageFillContain - file2Thumbnail.SetMinSize(fyne.NewSize(240, 135)) - - // Placeholder backgrounds - file1ThumbBg := canvas.NewRectangle(utils.MustHex("#0F1529")) - file1ThumbBg.SetMinSize(fyne.NewSize(240, 135)) - - file2ThumbBg := canvas.NewRectangle(utils.MustHex("#0F1529")) - file2ThumbBg.SetMinSize(fyne.NewSize(240, 135)) + // Initialize with placeholders + file1VideoContainer.Objects = []fyne.CanvasObject{container.NewCenter(widget.NewLabel("No video loaded"))} + file2VideoContainer.Objects = []fyne.CanvasObject{container.NewCenter(widget.NewLabel("No video loaded"))} // Info labels file1Info := widget.NewLabel("No file loaded") @@ -5476,36 +5861,6 @@ func buildCompareView(state *appState) fyne.CanvasObject { ) } - // Helper to load thumbnail for a video - loadThumbnail := func(src *videoSource, img *canvas.Image) { - if src == nil { - return - } - // Generate preview frames if not already present - if len(src.PreviewFrames) == 0 { - go func() { - if thumb, err := capturePreviewFrames(src.Path, src.Duration); err == nil && len(thumb) > 0 { - src.PreviewFrames = thumb - // Update thumbnail on UI thread - fyne.CurrentApp().Driver().DoFromGoroutine(func() { - thumbImg := canvas.NewImageFromFile(src.PreviewFrames[0]) - if thumbImg.Image != nil { - img.Image = thumbImg.Image - img.Refresh() - } - }, false) - } - }() - return - } - // Load the first preview frame as thumbnail - thumbImg := canvas.NewImageFromFile(src.PreviewFrames[0]) - if thumbImg.Image != nil { - img.Image = thumbImg.Image - img.Refresh() - } - } - // Helper to truncate filename if too long truncateFilename := func(filename string, maxLen int) string { if len(filename) <= maxLen { @@ -5535,12 +5890,18 @@ func buildCompareView(state *appState) fyne.CanvasObject { displayName := truncateFilename(filename, 35) file1Label.SetText(fmt.Sprintf("File 1: %s", displayName)) file1Info.SetText(formatMetadata(state.compareFile1)) - loadThumbnail(state.compareFile1, file1Thumbnail) + // Build video player with compact size for side-by-side + file1VideoContainer.Objects = []fyne.CanvasObject{ + buildVideoPane(state, fyne.NewSize(320, 180), state.compareFile1, nil), + } + file1VideoContainer.Refresh() } else { file1Label.SetText("File 1: Not loaded") file1Info.SetText("No file loaded") - file1Thumbnail.Image = nil - file1Thumbnail.Refresh() + file1VideoContainer.Objects = []fyne.CanvasObject{ + container.NewCenter(widget.NewLabel("No video loaded")), + } + file1VideoContainer.Refresh() } } @@ -5550,12 +5911,18 @@ func buildCompareView(state *appState) fyne.CanvasObject { displayName := truncateFilename(filename, 35) file2Label.SetText(fmt.Sprintf("File 2: %s", displayName)) file2Info.SetText(formatMetadata(state.compareFile2)) - loadThumbnail(state.compareFile2, file2Thumbnail) + // Build video player with compact size for side-by-side + file2VideoContainer.Objects = []fyne.CanvasObject{ + buildVideoPane(state, fyne.NewSize(320, 180), state.compareFile2, nil), + } + file2VideoContainer.Refresh() } else { file2Label.SetText("File 2: Not loaded") file2Info.SetText("No file loaded") - file2Thumbnail.Image = nil - file2Thumbnail.Refresh() + file2VideoContainer.Objects = []fyne.CanvasObject{ + container.NewCenter(widget.NewLabel("No video loaded")), + } + file2VideoContainer.Refresh() } } @@ -5657,24 +6024,24 @@ func buildCompareView(state *appState) fyne.CanvasObject { file2InfoScroll := container.NewVScroll(file2Info) file2InfoScroll.SetMinSize(fyne.NewSize(250, 150)) - // File 1 column: header, thumb, metadata (using Border to make metadata expand) + // File 1 column: header, video player, metadata (using Border to make metadata expand) file1Column := container.NewBorder( container.NewVBox( file1Header, widget.NewSeparator(), - container.NewMax(file1ThumbBg, file1Thumbnail), + file1VideoContainer, widget.NewSeparator(), ), nil, nil, nil, file1InfoScroll, ) - // File 2 column: header, thumb, metadata (using Border to make metadata expand) + // File 2 column: header, video player, metadata (using Border to make metadata expand) file2Column := container.NewBorder( container.NewVBox( file2Header, widget.NewSeparator(), - container.NewMax(file2ThumbBg, file2Thumbnail), + file2VideoContainer, widget.NewSeparator(), ), nil, nil, nil, @@ -5693,3 +6060,314 @@ func buildCompareView(state *appState) fyne.CanvasObject { return container.NewBorder(topBar, bottomBar, nil, nil, content) } + +// buildInspectView creates the UI for inspecting a single video with player +func buildInspectView(state *appState) fyne.CanvasObject { + inspectColor := moduleColor("inspect") + + // Back button + backBtn := widget.NewButton("< INSPECT", func() { + state.showMainMenu() + }) + backBtn.Importance = widget.LowImportance + + // Top bar with module color + topBar := ui.TintedBar(inspectColor, container.NewHBox(backBtn, layout.NewSpacer())) + + // Instructions + instructions := widget.NewLabel("Load a video to inspect its properties and preview playback. Drag a video here or use the button below.") + instructions.Wrapping = fyne.TextWrapWord + instructions.Alignment = fyne.TextAlignCenter + + // Clear button + clearBtn := widget.NewButton("Clear", func() { + state.inspectFile = nil + state.showInspectView() + }) + clearBtn.Importance = widget.LowImportance + + instructionsRow := container.NewBorder(nil, nil, nil, clearBtn, instructions) + + // File label + fileLabel := widget.NewLabel("No file loaded") + fileLabel.TextStyle = fyne.TextStyle{Bold: true} + + // Metadata text + metadataText := widget.NewLabel("No file loaded") + metadataText.Wrapping = fyne.TextWrapWord + + // Metadata scroll + metadataScroll := container.NewScroll(metadataText) + metadataScroll.SetMinSize(fyne.NewSize(400, 200)) + + // Helper function to format metadata + formatMetadata := func(src *videoSource) string { + fileSize := "Unknown" + if fi, err := os.Stat(src.Path); err == nil { + sizeMB := float64(fi.Size()) / (1024 * 1024) + if sizeMB >= 1024 { + fileSize = fmt.Sprintf("%.2f GB", sizeMB/1024) + } else { + fileSize = fmt.Sprintf("%.2f MB", sizeMB) + } + } + + return fmt.Sprintf( + "━━━ FILE INFO ━━━\n"+ + "Path: %s\n"+ + "File Size: %s\n"+ + "Format: %s\n"+ + "\n━━━ VIDEO ━━━\n"+ + "Codec: %s\n"+ + "Resolution: %dx%d\n"+ + "Aspect Ratio: %s\n"+ + "Frame Rate: %.2f fps\n"+ + "Bitrate: %s\n"+ + "Pixel Format: %s\n"+ + "Color Space: %s\n"+ + "Color Range: %s\n"+ + "Field Order: %s\n"+ + "GOP Size: %d\n"+ + "\n━━━ AUDIO ━━━\n"+ + "Codec: %s\n"+ + "Bitrate: %s\n"+ + "Sample Rate: %d Hz\n"+ + "Channels: %d\n"+ + "\n━━━ OTHER ━━━\n"+ + "Duration: %s\n"+ + "SAR (Pixel Aspect): %s\n"+ + "Chapters: %v\n"+ + "Metadata: %v", + filepath.Base(src.Path), + fileSize, + src.Format, + src.VideoCodec, + src.Width, src.Height, + src.AspectRatioString(), + src.FrameRate, + formatBitrate(src.Bitrate), + src.PixelFormat, + src.ColorSpace, + src.ColorRange, + src.FieldOrder, + src.GOPSize, + src.AudioCodec, + formatBitrate(src.AudioBitrate), + src.AudioRate, + src.Channels, + src.DurationString(), + src.SampleAspectRatio, + src.HasChapters, + src.HasMetadata, + ) + } + + // Video player container + var videoContainer fyne.CanvasObject = container.NewCenter(widget.NewLabel("No video loaded")) + + // Update display function + updateDisplay := func() { + if state.inspectFile != nil { + filename := filepath.Base(state.inspectFile.Path) + // Truncate if too long + if len(filename) > 50 { + ext := filepath.Ext(filename) + nameWithoutExt := strings.TrimSuffix(filename, ext) + if len(ext) > 10 { + filename = filename[:47] + "..." + } else { + availableLen := 47 - len(ext) + if availableLen < 1 { + filename = filename[:47] + "..." + } else { + filename = nameWithoutExt[:availableLen] + "..." + ext + } + } + } + fileLabel.SetText(fmt.Sprintf("File: %s", filename)) + metadataText.SetText(formatMetadata(state.inspectFile)) + + // Build video player + videoContainer = buildVideoPane(state, fyne.NewSize(640, 360), state.inspectFile, nil) + } else { + fileLabel.SetText("No file loaded") + metadataText.SetText("No file loaded") + videoContainer = container.NewCenter(widget.NewLabel("No video loaded")) + } + } + + // Initialize display + updateDisplay() + + // Load button + loadBtn := widget.NewButton("Load Video", func() { + dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { + if err != nil || reader == nil { + return + } + path := reader.URI().Path() + reader.Close() + + src, err := probeVideo(path) + if err != nil { + dialog.ShowError(fmt.Errorf("failed to load video: %w", err), state.window) + return + } + + state.inspectFile = src + state.showInspectView() + logging.Debug(logging.CatModule, "loaded inspect file: %s", path) + }, state.window) + }) + + // Copy metadata button + copyBtn := widget.NewButton("Copy Metadata", func() { + if state.inspectFile == nil { + return + } + metadata := formatMetadata(state.inspectFile) + state.window.Clipboard().SetContent(metadata) + dialog.ShowInformation("Copied", "Metadata copied to clipboard", state.window) + }) + copyBtn.Importance = widget.LowImportance + + // Action buttons + actionButtons := container.NewHBox(loadBtn, copyBtn, clearBtn) + + // Main layout: left side is video player, right side is metadata + leftColumn := container.NewBorder( + fileLabel, + nil, nil, nil, + videoContainer, + ) + + rightColumn := container.NewBorder( + widget.NewLabel("Metadata:"), + nil, nil, nil, + metadataScroll, + ) + + // Bottom bar with module color + bottomBar := ui.TintedBar(inspectColor, container.NewHBox(layout.NewSpacer())) + + // Main content + content := container.NewBorder( + container.NewVBox(instructionsRow, actionButtons, widget.NewSeparator()), + nil, nil, nil, + container.NewGridWithColumns(2, leftColumn, rightColumn), + ) + + return container.NewBorder(topBar, bottomBar, nil, nil, content) +} +// buildCompareFullscreenView creates fullscreen side-by-side comparison with synchronized controls +func buildCompareFullscreenView(state *appState) fyne.CanvasObject { + compareColor := moduleColor("compare") + + // Back button + backBtn := widget.NewButton("< BACK TO COMPARE", func() { + state.showCompareView() + }) + backBtn.Importance = widget.LowImportance + + // Top bar with module color + topBar := ui.TintedBar(compareColor, container.NewHBox(backBtn, layout.NewSpacer())) + + // Video player containers - large size for fullscreen + file1VideoContainer := container.NewMax() + file2VideoContainer := container.NewMax() + + // Build players if videos are loaded - use flexible size that won't force window expansion + if state.compareFile1 != nil { + file1VideoContainer.Objects = []fyne.CanvasObject{ + buildVideoPane(state, fyne.NewSize(400, 225), state.compareFile1, nil), + } + } else { + file1VideoContainer.Objects = []fyne.CanvasObject{ + container.NewCenter(widget.NewLabel("No video loaded")), + } + } + + if state.compareFile2 != nil { + file2VideoContainer.Objects = []fyne.CanvasObject{ + buildVideoPane(state, fyne.NewSize(400, 225), state.compareFile2, nil), + } + } else { + file2VideoContainer.Objects = []fyne.CanvasObject{ + container.NewCenter(widget.NewLabel("No video loaded")), + } + } + + // File labels + file1Name := "File 1: Not loaded" + if state.compareFile1 != nil { + file1Name = fmt.Sprintf("File 1: %s", filepath.Base(state.compareFile1.Path)) + } + + file2Name := "File 2: Not loaded" + if state.compareFile2 != nil { + file2Name = fmt.Sprintf("File 2: %s", filepath.Base(state.compareFile2.Path)) + } + + file1Label := widget.NewLabel(file1Name) + file1Label.TextStyle = fyne.TextStyle{Bold: true} + file1Label.Alignment = fyne.TextAlignCenter + + file2Label := widget.NewLabel(file2Name) + file2Label.TextStyle = fyne.TextStyle{Bold: true} + file2Label.Alignment = fyne.TextAlignCenter + + // Synchronized playback controls (note: actual sync would require VT_Player API enhancement) + playBtn := widget.NewButton("▶ Play Both", func() { + // TODO: When VT_Player API supports it, trigger synchronized playback + dialog.ShowInformation("Synchronized Playback", + "Synchronized playback control will be available when VT_Player API is enhanced.\n\n"+ + "For now, use individual player controls.", + state.window) + }) + playBtn.Importance = widget.HighImportance + + pauseBtn := widget.NewButton("⏸ Pause Both", func() { + // TODO: Synchronized pause + dialog.ShowInformation("Synchronized Playback", + "Synchronized playback control will be available when VT_Player API is enhanced.", + state.window) + }) + + syncControls := container.NewHBox( + layout.NewSpacer(), + playBtn, + pauseBtn, + layout.NewSpacer(), + ) + + // Info text + infoLabel := widget.NewLabel("Side-by-side fullscreen comparison. Use individual player controls until synchronized playback is implemented in VT_Player.") + infoLabel.Wrapping = fyne.TextWrapWord + infoLabel.Alignment = fyne.TextAlignCenter + + // Left column (File 1) + leftColumn := container.NewBorder( + file1Label, + nil, nil, nil, + file1VideoContainer, + ) + + // Right column (File 2) + rightColumn := container.NewBorder( + file2Label, + nil, nil, nil, + file2VideoContainer, + ) + + // Bottom bar with module color + bottomBar := ui.TintedBar(compareColor, container.NewHBox(layout.NewSpacer())) + + // Main content + content := container.NewBorder( + container.NewVBox(infoLabel, syncControls, widget.NewSeparator()), + nil, nil, nil, + container.NewGridWithColumns(2, leftColumn, rightColumn), + ) + + return container.NewBorder(topBar, bottomBar, nil, nil, content) +}