Compare commits
204 Commits
v0.1.0-dev
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d7cd1e46d | |||
| 20a3165dc3 | |||
| 8763da0799 | |||
| c297bf1a09 | |||
| 75d0149f34 | |||
| 920d17ddbb | |||
| 6cd5e01fbe | |||
| 3518e187ee | |||
| f01e804490 | |||
| ed9926e24e | |||
| 8238870cc5 | |||
| 17765e484f | |||
| 079969d375 | |||
| 2d79b3322d | |||
| 5c8ad4e355 | |||
| ff1fdd9c1e | |||
| a486961c4a | |||
| d0a35cdcb2 | |||
| cac747f5c2 | |||
| 8403c991e9 | |||
| 62dd39347a | |||
| d7175ed04d | |||
| 5fe3c853f4 | |||
| ec51114372 | |||
| c4d31b31bc | |||
| 9f55604d69 | |||
| 7954524bac | |||
| 776ec1f672 | |||
| 89824f7859 | |||
| 3b99cad32b | |||
| 41e08b18a7 | |||
| f1556175db | |||
| 462dfb06c6 | |||
| d240f1f773 | |||
| b079bff6fb | |||
| 879bec4309 | |||
| f8a9844b53 | |||
| 52dce047b7 | |||
| bb8b8f7039 | |||
| cc16352098 | |||
| 75a8b900e8 | |||
| 2964de5b14 | |||
| 673e914f5e | |||
| 11b5fae23d | |||
| 7cecf2bdd7 | |||
| 254cc1243f | |||
| 6497e27e0d | |||
| 006a80fb03 | |||
| 34cf6382cc | |||
| a1a0a653e6 | |||
| 984088749c | |||
| b71c066517 | |||
| ad1da23c6c | |||
| 1b4e504a57 | |||
| 4a58a22b81 | |||
| 4f74d5b2b2 | |||
| 58c55d3bc6 | |||
| 03e036be51 | |||
| ebf71a3214 | |||
| e1c4d9ca50 | |||
| 19739b0fab | |||
| 8896206b68 | |||
| 03569cd813 | |||
| f50edeb9c6 | |||
| de81c9f999 | |||
| 16767a5ca6 | |||
| 3645291988 | |||
| 4c4d436a66 | |||
| 4e8486a5da | |||
| e0fc69ab97 | |||
| 15537ba73a | |||
| 1934ed0d5e | |||
| 40e647ee5b | |||
| 62425537c1 | |||
| 6413082365 | |||
| 88b318c5e4 | |||
| e951f40894 | |||
| 8ce6240c02 | |||
| b6c09bf9b3 | |||
| b964c70da0 | |||
| 63539db36d | |||
| 69a1cd5ba7 | |||
| e923715b95 | |||
| c464a7a7dd | |||
| cf219e9770 | |||
| ff65928ba0 | |||
| b887142401 | |||
| 5026a946f5 | |||
| 3863242ba9 | |||
| 1051329763 | |||
| 8f73913817 | |||
| b41e41e5ad | |||
| da49a1dd7b | |||
| 8cff33fcab | |||
| b3e448f2fe | |||
| 1546b5f5d1 | |||
| c4db2f9c56 | |||
| ad7b1ef2f7 | |||
| 02c2e389e0 | |||
| 953bfb44a8 | |||
| c8f4eec0d1 | |||
| 0193886676 | |||
| 660485580c | |||
| 3be5857cbb | |||
| e6c97b5e33 | |||
| e3aebdcbb7 | |||
| 9257cc79f0 | |||
| 1f5a21466c | |||
| 18209240f2 | |||
| 7a82542f91 | |||
| 230523c737 | |||
| 0d1235d867 | |||
| d781ce2d58 | |||
| 49e01f5817 | |||
| e919339e3d | |||
| 7226da0970 | |||
| 9237bae4ff | |||
| 0e74f28379 | |||
| 804d27a0b5 | |||
| d566a085d1 | |||
| e22eae8207 | |||
| 834d6b5517 | |||
| aa659b80f5 | |||
| 63804f7475 | |||
| e84dfd5eed | |||
| ff612b547c | |||
| de70448897 | |||
| 1491d0b0c0 | |||
| fe5d0f7f87 | |||
| 0779016616 | |||
| a821f59668 | |||
| b7e9157324 | |||
| 6729e98fae | |||
| e896fd086d | |||
| a91a3e60d7 | |||
| a7b3452312 | |||
| 4a09626e28 | |||
| 14712f7785 | |||
| ff9071902e | |||
| b0bd1cf179 | |||
| 6e13a53569 | |||
| 30bc747f0c | |||
| a7bffb63ee | |||
| 01af1b8cf2 | |||
| c8bcaf476c | |||
| e5dcde953b | |||
| 165480cf8c | |||
| 67c71e2070 | |||
| 4e449f8748 | |||
| b02cd844c4 | |||
| f5a162b440 | |||
| cf9422ad6b | |||
| 81773c46a1 | |||
| 1a268ce983 | |||
| c98c1aa924 | |||
| a42b353aea | |||
| 0bad7d858f | |||
| c883a92155 | |||
| e1af8181c0 | |||
| faef905f18 | |||
| 0d5670f34b | |||
| ee67bffbd9 | |||
| 68ce3c2168 | |||
| ec7649aee8 | |||
| 1da9317d73 | |||
| 9dc946b7c0 | |||
| 960def5730 | |||
| 1b1657bc21 | |||
| 9315a793ba | |||
| 588fc586a1 | |||
| 62802aa79e | |||
| 364c3aa1ed | |||
| 16140e2e12 | |||
| 77ff859575 | |||
| 4ea3834d76 | |||
| 1ef88069bc | |||
| c75f6a0453 | |||
| d69573fa7f | |||
| 89d9a15fa9 | |||
| e356dfca6d | |||
| eeb62d8e4b | |||
| 4d031a4dae | |||
| 056df2ec25 | |||
| f3f4ee0f3a | |||
| 71021f5585 | |||
| 595b1603ee | |||
| 23759caeea | |||
| bff07bd746 | |||
| 0c91f63329 | |||
| 22eb734df2 | |||
| 9b4fedc181 | |||
| f5d78cc218 | |||
| acdb523fb1 | |||
| a7901c8f66 | |||
| 513a60058b | |||
| 573e7894b2 | |||
| e910bee641 | |||
| bc85ed9940 | |||
| ac155f72a3 | |||
| 8644fc5d9a | |||
| 9f47d503ff | |||
| 931fda6dd2 | |||
| 8513902232 | |||
| d031afa269 |
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -5,6 +5,9 @@ logs/
|
||||||
.cache/
|
.cache/
|
||||||
VideoTools
|
VideoTools
|
||||||
|
|
||||||
|
# Design mockups and assets
|
||||||
|
assets/mockup/
|
||||||
|
|
||||||
# Windows build artifacts
|
# Windows build artifacts
|
||||||
VideoTools.exe
|
VideoTools.exe
|
||||||
ffmpeg.exe
|
ffmpeg.exe
|
||||||
|
|
|
||||||
286
DONE.md
286
DONE.md
|
|
@ -2,7 +2,270 @@
|
||||||
|
|
||||||
This file tracks completed features, fixes, and milestones.
|
This file tracks completed features, fixes, and milestones.
|
||||||
|
|
||||||
## Version 0.1.0-dev19 (2025-12-18 to 2025-12-20) - Convert Module Cleanup & UX Polish
|
## Version 0.1.0-dev20+ (2025-12-28) - Queue UI Performance & Workflow Improvements
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- ✅ **Player Module Investigation**
|
||||||
|
- Investigated reported player crash
|
||||||
|
- Discovered player is ALREADY fully internal and lightweight
|
||||||
|
- Uses FFmpeg directly (no external VLC/MPV/FFplay dependencies)
|
||||||
|
- Implementation: FFmpeg pipes raw frames + audio → Oto library for output
|
||||||
|
- Frame-accurate seeking and A/V sync built-in
|
||||||
|
- Error handling: Falls back to video-only playback if audio fails
|
||||||
|
- Player module re-enabled - follows VideoTools' core principles
|
||||||
|
|
||||||
|
### Workflow Enhancements
|
||||||
|
- ✅ **Benchmark Result Caching**
|
||||||
|
- Benchmark results now persist across app restarts
|
||||||
|
- Opening Benchmark module shows cached results instead of auto-running
|
||||||
|
- Clear timestamp display (e.g., "Showing cached results from December 28, 2025 at 2:45 PM")
|
||||||
|
- "Run New Benchmark" button available when viewing cached results
|
||||||
|
- Auto-runs only when no previous results exist or hardware has changed (GPU detection)
|
||||||
|
- Saves to `~/.config/VideoTools/benchmark.json` with last 10 runs in history
|
||||||
|
- No more redundant benchmarks every time you open the module
|
||||||
|
|
||||||
|
- ✅ **Merge Module Output Path UX Improvement**
|
||||||
|
- Split single output path field into separate folder and filename fields
|
||||||
|
- "Output Folder" field with "Browse Folder" button for directory selection
|
||||||
|
- "Output Filename" field for easy filename editing (e.g., "merged.mkv")
|
||||||
|
- No more navigating through long paths to change filenames
|
||||||
|
- Cleaner, more intuitive interface following standard file dialog patterns
|
||||||
|
- Auto-population sets directory and filename independently
|
||||||
|
|
||||||
|
- ✅ **Queue Priority System for Convert Now**
|
||||||
|
- "Convert Now" during active conversions adds job to top of queue (after running job)
|
||||||
|
- "Add to Queue" continues to add to end as expected
|
||||||
|
- Implemented AddNext() method in queue package for priority insertion
|
||||||
|
- User feedback message indicates queue position: "Added to top of queue!" vs "Conversion started!"
|
||||||
|
- Better workflow when adding files during active batch conversions
|
||||||
|
|
||||||
|
- ✅ **Auto-Cleanup for Failed Conversions**
|
||||||
|
- Convert jobs now automatically delete incomplete/broken output files on failure
|
||||||
|
- Success tracking ensures complete files are never removed
|
||||||
|
- Prevents accumulation of partial files from crashed/cancelled conversions
|
||||||
|
- Cleaner disk space management and error handling
|
||||||
|
|
||||||
|
- ✅ **Queue List Jankiness Reduction**
|
||||||
|
- Increased auto-refresh interval from 1000ms to 2000ms for smoother updates
|
||||||
|
- Reduced scroll restoration delay from 50ms to 10ms for faster position recovery
|
||||||
|
- Fixed race condition in scroll offset saving
|
||||||
|
- Eliminated visible jumping during queue view rebuilds
|
||||||
|
|
||||||
|
### Performance Optimizations
|
||||||
|
- ✅ **Queue View Button Responsiveness**
|
||||||
|
- Fixed Windows-specific button lag after conversion completion
|
||||||
|
- Eliminated redundant UI refreshes in queue button handlers (Pause, Resume, Cancel, Remove, Move Up/Down, etc.)
|
||||||
|
- Queue onChange callback now handles all refreshes automatically - removed duplicate manual calls
|
||||||
|
- Added stopQueueAutoRefresh() before navigation to prevent conflicting UI updates
|
||||||
|
- Result: Instant button response on Windows (was 1-3 second lag)
|
||||||
|
- Reported by: Jake & Stu
|
||||||
|
|
||||||
|
- ✅ **Main Menu Performance**
|
||||||
|
- Fixed main menu lag when sidebar visible and queue active
|
||||||
|
- Implemented 300ms throttling for main menu rebuilds (prevents excessive redraws)
|
||||||
|
- Cached jobQueue.List() calls to eliminate multiple expensive copies (was 2-3 copies per refresh)
|
||||||
|
- Smart conditional refresh: only rebuild sidebar when history actually changes
|
||||||
|
- Result: 3-5x improvement in main menu responsiveness, especially on Windows
|
||||||
|
- RAM usage confirmed: 220MB (lean and efficient for video processing app)
|
||||||
|
|
||||||
|
- ✅ **Queue Auto-Refresh Optimization**
|
||||||
|
- Reduced auto-refresh interval from 500ms to 1000ms (1 second)
|
||||||
|
- Reduces UI thread pressure on Windows while maintaining smooth progress updates
|
||||||
|
- Combined with 500ms manual throttle in refreshQueueView() for optimal balance
|
||||||
|
|
||||||
|
### User Experience Improvements
|
||||||
|
- ✅ **Benchmark UI Cleanup**
|
||||||
|
- Hide benchmark indicator in Convert module when settings are already applied
|
||||||
|
- Only show "Benchmark: Not Applied" status when action is needed
|
||||||
|
- Removes clutter from UI when using benchmark settings
|
||||||
|
- Cleaner interface for active conversions with benchmark recommendations
|
||||||
|
|
||||||
|
- ✅ **Queue Position Labeling**
|
||||||
|
- Fixed confusing priority display in queue view
|
||||||
|
- Changed from internal priority numbers (3, 2, 1) to user-friendly queue positions (1, 2, 3)
|
||||||
|
- Now displays "Queue Position: 1" for first job, "Queue Position: 2" for second, etc.
|
||||||
|
- Applied to both Pending and Paused jobs
|
||||||
|
- Much clearer for users to understand execution order
|
||||||
|
|
||||||
|
### Remux Safety System (Fool-Proof Implementation)
|
||||||
|
- ✅ **Comprehensive Codec Compatibility Validation**
|
||||||
|
- Added validateRemuxCompatibility() function with format-specific checks
|
||||||
|
- Automatically detects incompatible codec/container combinations
|
||||||
|
- Validates before ANY remux operation to prevent silent failures
|
||||||
|
|
||||||
|
- ✅ **Container-Specific Validation**
|
||||||
|
- MP4: Blocks VP8, VP9, AV1, Theora, Vorbis, Opus (not reliably supported)
|
||||||
|
- MKV: Allows almost everything (ultra-flexible)
|
||||||
|
- WebM: Enforces VP8/VP9/AV1 video + Vorbis/Opus audio only
|
||||||
|
- MOV: Apple-friendly codecs (H.264, H.265, ProRes, MJPEG)
|
||||||
|
|
||||||
|
- ✅ **Automatic Fallback to Re-encoding**
|
||||||
|
- WMV/ASF sources automatically re-encode (timestamp/codec issues)
|
||||||
|
- FLV with legacy codecs (Sorenson/VP6) auto re-encode
|
||||||
|
- Incompatible codec/container pairs auto re-encode to safe default (H.264)
|
||||||
|
- User never gets broken files - system handles it transparently
|
||||||
|
|
||||||
|
- ✅ **Auto-Fixable Format Detection**
|
||||||
|
- AVI: Applies -fflags +genpts for timestamp regeneration
|
||||||
|
- FLV (H.264): Applies timestamp fixes
|
||||||
|
- MPEG-TS/M2TS/MTS: Extended analysis + timestamp fixes
|
||||||
|
- VOB (DVD rips): Full timestamp regeneration
|
||||||
|
- All apply -avoid_negative_ts make_zero automatically
|
||||||
|
|
||||||
|
- ✅ **Enhanced FFmpeg Safety Flags**
|
||||||
|
- All remux operations now include:
|
||||||
|
- `-fflags +genpts` (regenerate timestamps)
|
||||||
|
- `-avoid_negative_ts make_zero` (fix negative timestamps)
|
||||||
|
- `-map 0` (preserve all streams)
|
||||||
|
- `-map_chapters 0` (preserve chapters)
|
||||||
|
- MPEG-TS sources get extended analysis parameters
|
||||||
|
- Result: Robust, reliable remuxing with zero risk of corruption
|
||||||
|
|
||||||
|
- ✅ **Codec Name Normalization**
|
||||||
|
- Added normalizeCodecName() to handle codec name variations
|
||||||
|
- Maps h264/avc/avc1/h.264/x264 → h264
|
||||||
|
- Maps h265/hevc/h.265/x265 → h265
|
||||||
|
- Maps divx/xvid/mpeg-4 → mpeg4
|
||||||
|
- Ensures accurate validation regardless of FFprobe output variations
|
||||||
|
|
||||||
|
### Technical Improvements
|
||||||
|
- ✅ **Smart UI Update Strategy**
|
||||||
|
- Throttled refreshes prevent cascading rebuilds
|
||||||
|
- Conditional updates only when state actually changes
|
||||||
|
- Queue list caching eliminates redundant memory allocations
|
||||||
|
- Windows-optimized rendering pipeline
|
||||||
|
|
||||||
|
- ✅ **Debug Logging**
|
||||||
|
- Added comprehensive logging for remux compatibility decisions
|
||||||
|
- Clear messages when auto-fixing vs auto re-encoding
|
||||||
|
- Helps debugging and user understanding
|
||||||
|
|
||||||
|
## Version 0.1.0-dev20+ (2025-12-26) - Author Module & UI Enhancements
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- ✅ **Author Module - Real-time Progress Reporting**
|
||||||
|
- Implemented granular progress updates for FFmpeg encoding steps in the Author module.
|
||||||
|
- Progress bar now updates smoothly during video processing, providing better feedback.
|
||||||
|
- Weighted progress calculation based on video durations for accurate overall progress.
|
||||||
|
|
||||||
|
- ✅ **Author Module - "Add to Queue" & Output Title Clear**
|
||||||
|
- Added an "Add to Queue" button to the Author module for non-immediate job execution.
|
||||||
|
- Refactored authoring workflow to support queuing jobs via a `startNow` parameter.
|
||||||
|
- Modified "Clear All" functionality to also clear the DVD Output Title, preventing naming conflicts.
|
||||||
|
|
||||||
|
- ✅ **Main Menu - "Disc" Category for Author, Rip, and Blu-Ray**
|
||||||
|
- Relocated "Author", "Rip", and "Blu-Ray" buttons to a new "Disc" category on the main menu.
|
||||||
|
- Improved logical grouping of disc-related functionalities.
|
||||||
|
|
||||||
|
- ✅ **Subtitles Module - Video File Path Population**
|
||||||
|
- Fixed an issue where dragging and dropping a video file onto the Subtitles module would not populate the "Video File Path" section.
|
||||||
|
- Ensured the video entry widget correctly reflects the dropped video's path.
|
||||||
|
|
||||||
|
## Version 0.1.0-dev20+ (2025-12-23) - Player UX & Installer Polish
|
||||||
|
|
||||||
|
### Features (2025-12-23 Session)
|
||||||
|
- ✅ **Player Module UI Improvements**
|
||||||
|
- Responsive video player sizing based on screen resolution
|
||||||
|
- Screens < 1600px wide: 640x360 (prevents layout breaking)
|
||||||
|
- Screens ≥ 1600px wide: 1280x720 (larger viewing area)
|
||||||
|
- Dynamically adapts to display when player view is built
|
||||||
|
- Prevents excessive negative space on lower resolution displays
|
||||||
|
|
||||||
|
- ✅ **Main Menu Cleanup**
|
||||||
|
- Hidden "Logs" button from main menu (history sidebar replaces it)
|
||||||
|
- Logs button only appears when onLogsClick callback is provided
|
||||||
|
- Cleaner, less cluttered interface
|
||||||
|
- Dynamic header controls based on available functionality
|
||||||
|
|
||||||
|
- ✅ **Windows Installer Fix**
|
||||||
|
- Fixed DVDStyler download from SourceForge mirrors
|
||||||
|
- Added `-MaximumRedirection 10` to handle SourceForge redirects
|
||||||
|
- Added browser user agent to prevent rejection
|
||||||
|
- Resolves "invalid archive" error on Windows 11
|
||||||
|
- Reported by: Jake
|
||||||
|
|
||||||
|
### Technical Improvements
|
||||||
|
- ✅ **Responsive Design Pattern**
|
||||||
|
- Canvas size detection for adaptive UI sizing
|
||||||
|
- Prevents window layout issues on smaller displays
|
||||||
|
- Maintains larger preview on high-resolution screens
|
||||||
|
|
||||||
|
- ✅ **PowerShell Download Robustness**
|
||||||
|
- Proper redirect following for mirror systems
|
||||||
|
- User agent spoofing for compatibility
|
||||||
|
- Multiple fallback URLs for resilience
|
||||||
|
|
||||||
|
## Version 0.1.0-dev20 (2025-12-21) - VT_Player Framework Implementation
|
||||||
|
|
||||||
|
### Features (2025-12-21 Session)
|
||||||
|
- ✅ **VT_Player Module - Complete Framework Implementation**
|
||||||
|
- **Frame-Accurate Video Player Interface** (`internal/player/vtplayer.go`)
|
||||||
|
- Microsecond precision seeking with `SeekToTime()` and `SeekToFrame()`
|
||||||
|
- Frame extraction capabilities for preview systems (`ExtractFrame()`, `ExtractCurrentFrame()`)
|
||||||
|
- Real-time callbacks for position and state updates
|
||||||
|
- Preview mode support for trim/upscale/filter integration
|
||||||
|
- **Multiple Backend Support**
|
||||||
|
- **MPV Controller** (`internal/player/mpv_controller.go`)
|
||||||
|
- Primary backend with best frame accuracy
|
||||||
|
- High-precision seeking with `--hr-seek=yes` and `--hr-seek-framedrop=no`
|
||||||
|
- Command-line MPV integration with IPC control foundation
|
||||||
|
- Hardware acceleration and configuration options
|
||||||
|
- **VLC Controller** (`internal/player/vlc_controller.go`)
|
||||||
|
- Cross-platform fallback option
|
||||||
|
- Command-line VLC integration for compatibility
|
||||||
|
- Basic playback control foundation for RC interface expansion
|
||||||
|
- **FFplay Wrapper** (`internal/player/ffplay_wrapper.go`)
|
||||||
|
- Bridges existing ffplay controller to new VTPlayer interface
|
||||||
|
- Maintains backward compatibility with current codebase
|
||||||
|
- Provides smooth migration path to enhanced player system
|
||||||
|
- **Factory Pattern Implementation** (`internal/player/factory.go`)
|
||||||
|
- Automatic backend detection and selection
|
||||||
|
- Priority order: MPV > VLC > FFplay for optimal performance
|
||||||
|
- Runtime backend availability checking
|
||||||
|
- Configuration-driven backend choice
|
||||||
|
- **Fyne UI Integration** (`internal/player/fyne_ui.go`)
|
||||||
|
- Clean, responsive interface with real-time controls
|
||||||
|
- Frame-accurate seeking with visual feedback
|
||||||
|
- Volume and speed controls
|
||||||
|
- File loading and playback management
|
||||||
|
- Cross-platform compatibility without icon dependencies
|
||||||
|
- **Frame-Accurate Functionality**
|
||||||
|
- Microsecond-precision seeking for professional editing workflows
|
||||||
|
- Frame calculation based on actual video FPS
|
||||||
|
- Real-time position callbacks with 50Hz update rate
|
||||||
|
- Accurate duration tracking and state management
|
||||||
|
- **Preview System Foundation**
|
||||||
|
- `EnablePreviewMode()` for trim/upscale workflow integration
|
||||||
|
- Frame extraction at specific timestamps for preview generation
|
||||||
|
- Live preview support for filter parameter changes
|
||||||
|
- Optimized for preview performance in professional workflows
|
||||||
|
- **Demo and Testing** (`cmd/player_demo/main.go`)
|
||||||
|
- Working demonstration of VT_Player capabilities
|
||||||
|
- Backend detection and selection validation
|
||||||
|
- Frame-accurate method testing
|
||||||
|
- Integration example for other modules
|
||||||
|
|
||||||
|
### Technical Implementation Details
|
||||||
|
- **Cross-Platform Backend Support**: Command-line integration for MPV/VLC with future IPC expansion
|
||||||
|
- **Frame Accuracy**: Microsecond precision timing with time.Duration throughout
|
||||||
|
- **Error Handling**: Graceful fallbacks and comprehensive error reporting
|
||||||
|
- **Resource Management**: Proper process cleanup and context cancellation
|
||||||
|
- **Interface Design**: Clean separation between UI and playback engine
|
||||||
|
- **Future Extensibility**: Foundation for enhanced IPC control and additional backends
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
- **Trim Module**: Frame-accurate preview of cut points and timeline navigation
|
||||||
|
- **Upscale Module**: Real-time preview with live parameter updates
|
||||||
|
- **Filters Module**: Frame-by-frame comparison and live effect preview
|
||||||
|
- **Convert Module**: Video loading and preview integration
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- ✅ Created comprehensive implementation documentation (`docs/VT_PLAYER_IMPLEMENTATION.md`)
|
||||||
|
- ✅ Documented architecture decisions and backend selection logic
|
||||||
|
- ✅ Provided integration examples for module developers
|
||||||
|
- ✅ Outlined future enhancement roadmap
|
||||||
|
|
||||||
|
## Version 0.1.0-dev20 (2025-12-18 to 2025-12-20) - Convert Module Cleanup & UX Polish
|
||||||
|
|
||||||
### Features (2025-12-20 Session)
|
### Features (2025-12-20 Session)
|
||||||
- ✅ **History Sidebar - In Progress Tab**
|
- ✅ **History Sidebar - In Progress Tab**
|
||||||
|
|
@ -320,13 +583,11 @@ This file tracks completed features, fixes, and milestones.
|
||||||
- Filter chain combination support
|
- Filter chain combination support
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
- ✅ Fixed snippet duration issues with dual-mode approach
|
- ✅ Fixed incorrect thumbnail count in contact sheets (was generating 34 instead of 40 for 5x8 grid)
|
||||||
- Default Format: Uses stream copy (keyframe-level precision)
|
- ✅ Fixed frame selection FPS assumption (hardcoded 30fps removed)
|
||||||
- Output Format: Re-encodes for frame-perfect duration
|
- ✅ Fixed module visibility (added thumb module to enabled check)
|
||||||
- ✅ Fixed container/codec mismatch in snippet generation
|
- ✅ Fixed undefined function call (openFileManager → openFolder)
|
||||||
- Now properly matches container to codec (MP4 for h264, source format for stream copy)
|
- ✅ Fixed dynamic total count not updating when changing grid dimensions
|
||||||
- ✅ Fixed missing audio bitrate in thumbnail metadata
|
|
||||||
- ✅ Fixed contact sheet dimensions not accounting for padding
|
|
||||||
- ✅ Added missing `strings` import to thumbnail/generator.go
|
- ✅ Added missing `strings` import to thumbnail/generator.go
|
||||||
- ✅ Updated snippet UI labels for clarity (Default Format vs Output Format)
|
- ✅ Updated snippet UI labels for clarity (Default Format vs Output Format)
|
||||||
|
|
||||||
|
|
@ -605,7 +866,7 @@ This file tracks completed features, fixes, and milestones.
|
||||||
- Braille character animations
|
- Braille character animations
|
||||||
- Shows current task during build and install
|
- Shows current task during build and install
|
||||||
- Interactive path selection (system-wide or user-local)
|
- Interactive path selection (system-wide or user-local)
|
||||||
- ✅ Added error dialogs with "Copy Error" button
|
- Added error dialogs with "Copy Error" button
|
||||||
- One-click error message copying for debugging
|
- One-click error message copying for debugging
|
||||||
- Applied to all major error scenarios
|
- Applied to all major error scenarios
|
||||||
- Better user experience when reporting issues
|
- Better user experience when reporting issues
|
||||||
|
|
@ -767,7 +1028,6 @@ This file tracks completed features, fixes, and milestones.
|
||||||
- ✅ Category-based logging (SYS, UI, MODULE, etc.)
|
- ✅ Category-based logging (SYS, UI, MODULE, etc.)
|
||||||
- ✅ Timestamp formatting
|
- ✅ Timestamp formatting
|
||||||
- ✅ Debug output toggle via environment variable
|
- ✅ Debug output toggle via environment variable
|
||||||
- ✅ Comprehensive debug messages throughout application
|
|
||||||
- ✅ Log file output (videotools.log)
|
- ✅ Log file output (videotools.log)
|
||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
|
|
@ -803,6 +1063,10 @@ This file tracks completed features, fixes, and milestones.
|
||||||
- ✅ Audio decoding and playback
|
- ✅ Audio decoding and playback
|
||||||
- ✅ Synchronization between audio and video
|
- ✅ Synchronization between audio and video
|
||||||
- ✅ Embedded playback within application window
|
- ✅ Embedded playback within application window
|
||||||
|
- ✅ Seek functionality with progress bar
|
||||||
|
- ✅ Player window sizing based on video aspect ratio
|
||||||
|
- ✅ Frame pump system for smooth playback
|
||||||
|
- ✅ Audio/video synchronization
|
||||||
- ✅ Checkpoint system for playback position
|
- ✅ Checkpoint system for playback position
|
||||||
|
|
||||||
### UI/UX
|
### UI/UX
|
||||||
|
|
@ -917,4 +1181,4 @@ This file tracks completed features, fixes, and milestones.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Last Updated: 2025-12-20*
|
*Last Updated: 2025-12-21*
|
||||||
|
|
@ -2,5 +2,5 @@
|
||||||
Icon = "assets/logo/VT_Icon.png"
|
Icon = "assets/logo/VT_Icon.png"
|
||||||
Name = "VideoTools"
|
Name = "VideoTools"
|
||||||
ID = "com.leaktechnologies.videotools"
|
ID = "com.leaktechnologies.videotools"
|
||||||
Version = "0.1.0-dev19"
|
Version = "0.1.0-dev20"
|
||||||
Build = 19
|
Build = 19
|
||||||
|
|
|
||||||
354
PLAYER_PERFORMANCE_ISSUES.md
Normal file
354
PLAYER_PERFORMANCE_ISSUES.md
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
# Player Module Performance Issues & Fixes
|
||||||
|
|
||||||
|
## Current Problems Causing Stuttering
|
||||||
|
|
||||||
|
### 1. **Separate Video & Audio Processes (No Sync)**
|
||||||
|
**Location:** `main.go:9144` (runVideo) and `main.go:9233` (runAudio)
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
- Video and audio run in completely separate FFmpeg processes
|
||||||
|
- No synchronization mechanism between them
|
||||||
|
- They will inevitably drift apart, causing A/V desync and stuttering
|
||||||
|
|
||||||
|
**Current Implementation:**
|
||||||
|
```go
|
||||||
|
func (p *playSession) startLocked(offset float64) {
|
||||||
|
p.runVideo(offset) // Separate process
|
||||||
|
p.runAudio(offset) // Separate process
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why It Stutters:**
|
||||||
|
- If video frame processing takes too long → audio continues → desync
|
||||||
|
- If audio buffer underruns → video continues → desync
|
||||||
|
- No feedback loop to keep them in sync
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **Audio Buffer Too Small**
|
||||||
|
**Location:** `main.go:8960` (audio context) and `main.go:9274` (chunk size)
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
```go
|
||||||
|
// Audio context with tiny buffer (42ms at 48kHz)
|
||||||
|
audioCtxGlobal.ctx, audioCtxGlobal.err = oto.NewContext(sampleRate, channels, bytesPerSample, 2048)
|
||||||
|
|
||||||
|
// Tiny read chunks (21ms of audio)
|
||||||
|
chunk := make([]byte, 4096)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why It Stutters:**
|
||||||
|
- 21ms chunks mean we need to read 47 times per second
|
||||||
|
- Any delay > 21ms causes audio dropout/stuttering
|
||||||
|
- 2048 sample buffer gives only 42ms protection against underruns
|
||||||
|
- Modern systems need 100-200ms buffers for smooth playback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. **Volume Processing in Hot Path**
|
||||||
|
**Location:** `main.go:9294-9318`
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
```go
|
||||||
|
// Processes volume on EVERY audio chunk read
|
||||||
|
for i := 0; i+1 < n; i += 2 {
|
||||||
|
sample := int16(binary.LittleEndian.Uint16(tmp[i:]))
|
||||||
|
amp := int(float64(sample) * gain)
|
||||||
|
// ... clamping ...
|
||||||
|
binary.LittleEndian.PutUint16(tmp[i:], uint16(int16(amp)))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why It Stutters:**
|
||||||
|
- CPU-intensive per-sample processing
|
||||||
|
- Happens 47 times/second with tiny chunks
|
||||||
|
- Blocks the audio read loop
|
||||||
|
- Should use FFmpeg's volume filter or hardware mixing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. **Video Frame Pacing Issues**
|
||||||
|
**Location:** `main.go:9200-9203`
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
```go
|
||||||
|
if delay := time.Until(nextFrameAt); delay > 0 {
|
||||||
|
time.Sleep(delay)
|
||||||
|
}
|
||||||
|
nextFrameAt = nextFrameAt.Add(frameDur)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why It Stutters:**
|
||||||
|
- `time.Sleep()` is not precise (can wake up late)
|
||||||
|
- Cumulative drift: if one frame is late, all future frames shift
|
||||||
|
- No correction mechanism if we fall behind
|
||||||
|
- UI thread delays from `DoFromGoroutine` can cause frame drops
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. **UI Thread Blocking**
|
||||||
|
**Location:** `main.go:9207-9215`
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
```go
|
||||||
|
// Every frame waits for UI thread to be available
|
||||||
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
||||||
|
p.img.Image = frame
|
||||||
|
p.img.Refresh()
|
||||||
|
}, false)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why It Stutters:**
|
||||||
|
- If UI thread is busy, frame updates queue up
|
||||||
|
- Can cause video to appear choppy even if FFmpeg is delivering smoothly
|
||||||
|
- No frame dropping mechanism if UI can't keep up
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. **Frame Allocation on Every Frame**
|
||||||
|
**Location:** `main.go:9205-9206`
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
```go
|
||||||
|
// Allocates new frame buffer 24-60 times per second
|
||||||
|
frame := image.NewRGBA(image.Rect(0, 0, p.targetW, p.targetH))
|
||||||
|
utils.CopyRGBToRGBA(frame.Pix, buf)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why It Stutters:**
|
||||||
|
- Memory allocation on every frame causes GC pressure
|
||||||
|
- Extra copy operation adds latency
|
||||||
|
- Could reuse buffers or use ring buffer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Fixes (Priority Order)
|
||||||
|
|
||||||
|
### Priority 1: Increase Audio Buffers (Quick Fix)
|
||||||
|
|
||||||
|
**Change `main.go:8960`:**
|
||||||
|
```go
|
||||||
|
// OLD: 2048 samples = 42ms
|
||||||
|
audioCtxGlobal.ctx, audioCtxGlobal.err = oto.NewContext(sampleRate, channels, bytesPerSample, 2048)
|
||||||
|
|
||||||
|
// NEW: 8192 samples = 170ms (more buffer = smoother playback)
|
||||||
|
audioCtxGlobal.ctx, audioCtxGlobal.err = oto.NewContext(sampleRate, channels, bytesPerSample, 8192)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Change `main.go:9274`:**
|
||||||
|
```go
|
||||||
|
// OLD: 4096 bytes = 21ms
|
||||||
|
chunk := make([]byte, 4096)
|
||||||
|
|
||||||
|
// NEW: 16384 bytes = 85ms per chunk
|
||||||
|
chunk := make([]byte, 16384)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Result:** Audio stuttering should improve significantly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Priority 2: Use FFmpeg for Volume Control
|
||||||
|
|
||||||
|
**Change `main.go:9238-9247`:**
|
||||||
|
```go
|
||||||
|
// Add volume filter to FFmpeg command instead of processing in Go
|
||||||
|
volumeFilter := ""
|
||||||
|
if p.muted || p.volume <= 0 {
|
||||||
|
volumeFilter = "-af volume=0"
|
||||||
|
} else if math.Abs(p.volume - 100) > 0.1 {
|
||||||
|
volumeFilter = fmt.Sprintf("-af volume=%.2f", p.volume/100.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(platformConfig.FFmpegPath,
|
||||||
|
"-hide_banner", "-loglevel", "error",
|
||||||
|
"-ss", fmt.Sprintf("%.3f", offset),
|
||||||
|
"-i", p.path,
|
||||||
|
"-vn",
|
||||||
|
"-ac", fmt.Sprintf("%d", channels),
|
||||||
|
"-ar", fmt.Sprintf("%d", sampleRate),
|
||||||
|
volumeFilter, // Let FFmpeg handle volume
|
||||||
|
"-f", "s16le",
|
||||||
|
"-",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remove volume processing loop (lines 9294-9318):**
|
||||||
|
```go
|
||||||
|
// Simply write chunks directly
|
||||||
|
localPlayer.Write(chunk[:n])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Result:** Reduced CPU usage, smoother audio
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Priority 3: Use Single FFmpeg Process with A/V Sync
|
||||||
|
|
||||||
|
**Conceptual Change:**
|
||||||
|
Instead of separate video/audio processes, use ONE FFmpeg process that:
|
||||||
|
1. Outputs video frames to one pipe
|
||||||
|
2. Outputs audio to another pipe (or use `-f matroska` with demuxing)
|
||||||
|
3. Maintains sync internally
|
||||||
|
|
||||||
|
**Pseudocode:**
|
||||||
|
```go
|
||||||
|
cmd := exec.Command(platformConfig.FFmpegPath,
|
||||||
|
"-ss", fmt.Sprintf("%.3f", offset),
|
||||||
|
"-i", p.path,
|
||||||
|
// Video stream
|
||||||
|
"-map", "0:v:0",
|
||||||
|
"-f", "rawvideo",
|
||||||
|
"-pix_fmt", "rgb24",
|
||||||
|
"-r", fmt.Sprintf("%.3f", p.fps),
|
||||||
|
"pipe:4", // Video to fd 4
|
||||||
|
// Audio stream
|
||||||
|
"-map", "0:a:0",
|
||||||
|
"-ac", "2",
|
||||||
|
"-ar", "48000",
|
||||||
|
"-f", "s16le",
|
||||||
|
"pipe:5", // Audio to fd 5
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Result:** Perfect A/V sync, no drift
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Priority 4: Frame Buffer Reuse
|
||||||
|
|
||||||
|
**Change `main.go:9205-9206`:**
|
||||||
|
```go
|
||||||
|
// Reuse frame buffers instead of allocating every frame
|
||||||
|
type framePool struct {
|
||||||
|
pool sync.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *framePool) get(w, h int) *image.RGBA {
|
||||||
|
if img := p.pool.Get(); img != nil {
|
||||||
|
return img.(*image.RGBA)
|
||||||
|
}
|
||||||
|
return image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *framePool) put(img *image.RGBA) {
|
||||||
|
// Clear pixel data
|
||||||
|
for i := range img.Pix {
|
||||||
|
img.Pix[i] = 0
|
||||||
|
}
|
||||||
|
p.pool.Put(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
// In video loop:
|
||||||
|
frame := framePool.get(p.targetW, p.targetH)
|
||||||
|
utils.CopyRGBToRGBA(frame.Pix, buf)
|
||||||
|
// ... use frame ...
|
||||||
|
// Note: can't return to pool if UI is still using it
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Result:** Reduced GC pressure, smoother frame delivery
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Priority 5: Adaptive Frame Timing
|
||||||
|
|
||||||
|
**Change `main.go:9200-9203`:**
|
||||||
|
```go
|
||||||
|
// Track actual vs expected time to detect drift
|
||||||
|
now := time.Now()
|
||||||
|
behind := now.Sub(nextFrameAt)
|
||||||
|
|
||||||
|
if behind < 0 {
|
||||||
|
// We're ahead, sleep until next frame
|
||||||
|
time.Sleep(-behind)
|
||||||
|
} else if behind > frameDur*2 {
|
||||||
|
// We're way behind (>2 frames), skip this frame
|
||||||
|
logging.Debug(logging.CatFFMPEG, "dropping frame, %.0fms behind", behind.Seconds()*1000)
|
||||||
|
nextFrameAt = now
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
// We're slightly behind, catchup gradually
|
||||||
|
nextFrameAt = now.Add(frameDur / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
nextFrameAt = nextFrameAt.Add(frameDur)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Result:** Better handling of temporary slowdowns, adaptive recovery
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
After each fix, test:
|
||||||
|
|
||||||
|
- [ ] 24fps video plays smoothly
|
||||||
|
- [ ] 30fps video plays smoothly
|
||||||
|
- [ ] 60fps video plays smoothly
|
||||||
|
- [ ] Audio doesn't stutter
|
||||||
|
- [ ] A/V sync maintained over 30+ seconds
|
||||||
|
- [ ] Seeking doesn't cause prolonged stuttering
|
||||||
|
- [ ] CPU usage is reasonable (<20% for playback)
|
||||||
|
- [ ] Works on both Linux and Windows
|
||||||
|
- [ ] Works with various codecs (H.264, H.265, VP9)
|
||||||
|
- [ ] Volume control works smoothly
|
||||||
|
- [ ] Pause/resume doesn't cause issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Monitoring
|
||||||
|
|
||||||
|
Add instrumentation to measure:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Video frame timing
|
||||||
|
frameDeliveryTime := time.Since(frameReadStart)
|
||||||
|
if frameDeliveryTime > frameDur*1.5 {
|
||||||
|
logging.Debug(logging.CatFFMPEG, "slow frame delivery: %.1fms (target: %.1fms)",
|
||||||
|
frameDeliveryTime.Seconds()*1000,
|
||||||
|
frameDur.Seconds()*1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio buffer health
|
||||||
|
if audioBufferFillLevel < 0.3 {
|
||||||
|
logging.Debug(logging.CatFFMPEG, "audio buffer low: %.0f%%", audioBufferFillLevel*100)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternative: Use External Player Library
|
||||||
|
|
||||||
|
If these tweaks don't achieve smooth playback, consider:
|
||||||
|
|
||||||
|
1. **mpv library** (libmpv) - Industry standard, perfect A/V sync
|
||||||
|
2. **FFmpeg's ffplay** code - Reference implementation
|
||||||
|
3. **VLC libvlc** - Proven playback engine
|
||||||
|
|
||||||
|
These handle all the complex synchronization automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
**Root Causes:**
|
||||||
|
1. Separate video/audio processes with no sync
|
||||||
|
2. Tiny audio buffers causing underruns
|
||||||
|
3. CPU waste on per-sample volume processing
|
||||||
|
4. Frame timing drift with no correction
|
||||||
|
5. UI thread blocking frame updates
|
||||||
|
|
||||||
|
**Quick Wins (30 min):**
|
||||||
|
- Increase audio buffers (Priority 1)
|
||||||
|
- Move volume to FFmpeg (Priority 2)
|
||||||
|
|
||||||
|
**Proper Fix (2-4 hours):**
|
||||||
|
- Single FFmpeg process with A/V muxing (Priority 3)
|
||||||
|
- Frame buffer pooling (Priority 4)
|
||||||
|
- Adaptive timing (Priority 5)
|
||||||
|
|
||||||
|
**Expected Final Result:**
|
||||||
|
- Smooth playback at all frame rates
|
||||||
|
- Rock-solid A/V sync
|
||||||
|
- Low CPU usage
|
||||||
|
- No stuttering or dropouts
|
||||||
11
README.md
11
README.md
|
|
@ -1,4 +1,4 @@
|
||||||
# VideoTools - Professional Video Processing Suite
|
# VideoTools - Video Processing Suite
|
||||||
|
|
||||||
## What is VideoTools?
|
## What is VideoTools?
|
||||||
|
|
||||||
|
|
@ -30,7 +30,7 @@ VideoTools is a professional-grade video processing application with a modern GU
|
||||||
### Installation (One Command)
|
### Installation (One Command)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash install.sh
|
bash scripts/install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer will build, install, and set up everything automatically with a guided wizard!
|
The installer will build, install, and set up everything automatically with a guided wizard!
|
||||||
|
|
@ -43,15 +43,16 @@ VideoTools
|
||||||
|
|
||||||
### Alternative: Developer Setup
|
### Alternative: Developer Setup
|
||||||
|
|
||||||
If you already have the repo cloned:
|
If you already have the repo cloned (dev workflow):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /path/to/VideoTools
|
cd /path/to/VideoTools
|
||||||
source scripts/alias.sh
|
bash scripts/build.sh
|
||||||
VideoTools
|
bash scripts/run.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
For detailed installation options, troubleshooting, and platform-specific notes, see **INSTALLATION.md**.
|
For detailed installation options, troubleshooting, and platform-specific notes, see **INSTALLATION.md**.
|
||||||
|
For upcoming work and priorities, see **docs/ROADMAP.md**.
|
||||||
|
|
||||||
## How to Create a Professional DVD
|
## How to Create a Professional DVD
|
||||||
|
|
||||||
|
|
|
||||||
41
TODO.md
41
TODO.md
|
|
@ -1,4 +1,4 @@
|
||||||
# VideoTools TODO (v0.1.0-dev19+ plan)
|
# VideoTools TODO (v0.1.0-dev20+ plan)
|
||||||
|
|
||||||
This file tracks upcoming features, improvements, and known issues.
|
This file tracks upcoming features, improvements, and known issues.
|
||||||
|
|
||||||
|
|
@ -19,7 +19,7 @@ This file tracks upcoming features, improvements, and known issues.
|
||||||
- Ensure all conversions preserve color metadata (color_space, color_primaries, color_trc, color_range)
|
- Ensure all conversions preserve color metadata (color_space, color_primaries, color_trc, color_range)
|
||||||
- Test with HDR content
|
- Test with HDR content
|
||||||
|
|
||||||
### Completed in dev19 (2025-12-20)
|
### Completed in dev20 (2025-12-20)
|
||||||
- [x] **History Sidebar - In Progress Tab** ✅ COMPLETED
|
- [x] **History Sidebar - In Progress Tab** ✅ COMPLETED
|
||||||
- Shows running/pending jobs without opening full queue
|
- Shows running/pending jobs without opening full queue
|
||||||
- Animated progress bars per module color
|
- Animated progress bars per module color
|
||||||
|
|
@ -41,6 +41,8 @@ This file tracks upcoming features, improvements, and known issues.
|
||||||
- Lossless option only for H.265/AV1
|
- Lossless option only for H.265/AV1
|
||||||
- Dynamic dropdown based on codec
|
- Dynamic dropdown based on codec
|
||||||
- Lossless + Target Size mode support
|
- Lossless + Target Size mode support
|
||||||
|
- Dynamic dropdown based on codec
|
||||||
|
- Lossless + Target Size mode support
|
||||||
- Audio bitrate estimation when metadata is missing
|
- Audio bitrate estimation when metadata is missing
|
||||||
- Target size unit selector and numeric entry
|
- Target size unit selector and numeric entry
|
||||||
- Snippet history updates in sidebar
|
- Snippet history updates in sidebar
|
||||||
|
|
@ -70,7 +72,7 @@ This file tracks upcoming features, improvements, and known issues.
|
||||||
- Frame interpolation presets in Filters with Upscale linkage
|
- Frame interpolation presets in Filters with Upscale linkage
|
||||||
- Real-ESRGAN AI upscale controls with ncnn pipeline (models, presets, tiles, TTA)
|
- Real-ESRGAN AI upscale controls with ncnn pipeline (models, presets, tiles, TTA)
|
||||||
|
|
||||||
*Last Updated: 2025-12-20*
|
*Last Updated: 2025-12-26*
|
||||||
|
|
||||||
## Priority Features for dev20+
|
## Priority Features for dev20+
|
||||||
|
|
||||||
|
|
@ -112,7 +114,30 @@ This file tracks upcoming features, improvements, and known issues.
|
||||||
- Creative effects (grayscale, vignette)
|
- Creative effects (grayscale, vignette)
|
||||||
- Real-time preview system
|
- Real-time preview system
|
||||||
|
|
||||||
- [ ] **DVD Authoring module**
|
- [ ] **Upscale module implementation**
|
||||||
|
- Design UI for upscaling
|
||||||
|
- Implement traditional scaling (Lanczos, Bicubic)
|
||||||
|
- Integrate Waifu2x (if feasible)
|
||||||
|
- Integrate Real-ESRGAN (if feasible)
|
||||||
|
- Add resolution presets
|
||||||
|
- Quality vs. speed slider
|
||||||
|
- Before/after comparison
|
||||||
|
- Batch upscaling
|
||||||
|
|
||||||
|
- [ ] **Audio module implementation**
|
||||||
|
- Design audio extraction UI
|
||||||
|
- Implement audio track extraction
|
||||||
|
- Audio track replacement/addition
|
||||||
|
- Multi-track management
|
||||||
|
- Volume normalization
|
||||||
|
- Audio delay correction
|
||||||
|
- Format conversion
|
||||||
|
- Channel mapping
|
||||||
|
- Audio-only operations
|
||||||
|
|
||||||
|
- [x] **DVD Authoring module**
|
||||||
|
- [x] **Real-time progress reporting for FFmpeg encoding**
|
||||||
|
- [x] **"Add to Queue" and "Clear Output Title" functionality**
|
||||||
- Output VIDEO_TS folder + burn-ready ISO
|
- Output VIDEO_TS folder + burn-ready ISO
|
||||||
- Auto-detect NTSC/PAL with manual override
|
- Auto-detect NTSC/PAL with manual override
|
||||||
- Preserve all audio tracks
|
- Preserve all audio tracks
|
||||||
|
|
@ -467,13 +492,15 @@ This file tracks upcoming features, improvements, and known issues.
|
||||||
- [ ] Transition effects (optional)
|
- [ ] Transition effects (optional)
|
||||||
- [ ] Chapter markers at join points
|
- [ ] Chapter markers at join points
|
||||||
|
|
||||||
### Trim Module (Lossless-Cut Inspired) 🔄 PLANNED
|
### Trim Module (Lossless-Cut Inspired) ✅ FRAMEWORK READY
|
||||||
Trim provides frame-accurate cutting with lossless-first philosophy (inspired by Lossless-Cut):
|
Trim provides frame-accurate cutting with lossless-first philosophy (inspired by Lossless-Cut):
|
||||||
|
|
||||||
#### Core Features
|
#### Core Features
|
||||||
|
- [x] **VT_Player Framework** - Frame-accurate video playback system implemented
|
||||||
|
- [x] **Frame-Accurate Navigation** - Microsecond precision seeking available
|
||||||
|
- [x] **Preview System** - Frame extraction for trim preview functionality
|
||||||
- [ ] **Lossless-First Approach** - Stream copy when possible, smart re-encode fallback
|
- [ ] **Lossless-First Approach** - Stream copy when possible, smart re-encode fallback
|
||||||
- [ ] **Keyframe-Snapping Timeline** - Visual keyframe markers with smart snapping
|
- [ ] **Keyframe-Snapping Timeline** - Visual keyframe markers with smart snapping
|
||||||
- [ ] **Frame-Accurate Navigation** - Reuse VT_Player's keyframe detection system
|
|
||||||
- [ ] **Smart Export System** - Automatic method selection (lossless/re-encode/hybrid)
|
- [ ] **Smart Export System** - Automatic method selection (lossless/re-encode/hybrid)
|
||||||
- [ ] **Multi-Segment Trimming** - Multiple cuts from single source with auto-chapters
|
- [ ] **Multi-Segment Trimming** - Multiple cuts from single source with auto-chapters
|
||||||
|
|
||||||
|
|
@ -842,4 +869,4 @@ Built-in Video File Explorer/Manager for comprehensive file management without l
|
||||||
- [ ] AI upscaling integration options
|
- [ ] AI upscaling integration options
|
||||||
- [ ] Disc copy protection legal landscape
|
- [ ] Disc copy protection legal landscape
|
||||||
- [ ] Cross-platform video codecs support
|
- [ ] Cross-platform video codecs support
|
||||||
- [ ] HDR/Dolby Vision handling
|
- [ ] HDR/Dolby Vision handling
|
||||||
1
TODO_EXTRACTION_NOTES.md
Normal file
1
TODO_EXTRACTION_NOTES.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Adding to documentation: Need to simplify Whisper and Whisper usage in Subtitles module
|
||||||
252
WINDOWS_BUILD_PERFORMANCE.md
Normal file
252
WINDOWS_BUILD_PERFORMANCE.md
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
# Windows Build Performance Guide
|
||||||
|
|
||||||
|
## Issue: Slow Builds (5+ Minutes)
|
||||||
|
|
||||||
|
If you're experiencing very slow build times on Windows, follow these steps to dramatically improve performance.
|
||||||
|
|
||||||
|
## Quick Fixes
|
||||||
|
|
||||||
|
### 1. Use the Optimized Build Scripts
|
||||||
|
|
||||||
|
We've updated the build scripts with performance optimizations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Git Bash (Most Windows users)
|
||||||
|
./scripts/build.sh
|
||||||
|
|
||||||
|
# PowerShell
|
||||||
|
.\scripts\build.ps1
|
||||||
|
|
||||||
|
# Command Prompt
|
||||||
|
.\scripts\build.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
**New Optimizations:**
|
||||||
|
- `-p N`: Parallel compilation using all CPU cores
|
||||||
|
- `-trimpath`: Faster builds and smaller binaries
|
||||||
|
- `-ldflags="-s -w"`: Strip debug symbols (faster linking)
|
||||||
|
|
||||||
|
### 2. Add Windows Defender Exclusions (CRITICAL!)
|
||||||
|
|
||||||
|
**This is the #1 cause of slow builds on Windows.**
|
||||||
|
|
||||||
|
Windows Defender scans every intermediate `.o` file during compilation, adding 2-5 minutes to build time.
|
||||||
|
|
||||||
|
#### Automated Script (Easiest - For Git Bash Users):
|
||||||
|
|
||||||
|
**From Git Bash (Run as Administrator):**
|
||||||
|
```bash
|
||||||
|
# Run the automated exclusion script
|
||||||
|
powershell.exe -ExecutionPolicy Bypass -File ./scripts/add-defender-exclusions.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
**To run Git Bash as Administrator:**
|
||||||
|
1. Search for "Git Bash" in Start Menu
|
||||||
|
2. Right-click → "Run as administrator"
|
||||||
|
3. Navigate to your VideoTools directory
|
||||||
|
4. Run the command above
|
||||||
|
|
||||||
|
#### Manual Method (GUI):
|
||||||
|
|
||||||
|
1. **Open Windows Security**
|
||||||
|
- Press `Win + I` → Update & Security → Windows Security → Virus & threat protection
|
||||||
|
|
||||||
|
2. **Add Exclusions** (Manage settings → Add or remove exclusions):
|
||||||
|
- `C:\Users\YourName\go` - Go package cache
|
||||||
|
- `C:\Users\YourName\AppData\Local\go-build` - Go build cache
|
||||||
|
- `C:\Users\YourName\Projects\VideoTools` - Your project directory
|
||||||
|
- `C:\msys64` - MinGW toolchain (if using MSYS2)
|
||||||
|
|
||||||
|
#### PowerShell Method (If Not Using Git Bash):
|
||||||
|
|
||||||
|
Run PowerShell as Administrator:
|
||||||
|
```powershell
|
||||||
|
# Run the automated script
|
||||||
|
.\scripts\add-defender-exclusions.ps1
|
||||||
|
|
||||||
|
# Or add manually:
|
||||||
|
Add-MpPreference -ExclusionPath "$env:LOCALAPPDATA\go-build"
|
||||||
|
Add-MpPreference -ExclusionPath "$env:USERPROFILE\go"
|
||||||
|
Add-MpPreference -ExclusionPath "C:\Users\$env:USERNAME\Projects\VideoTools"
|
||||||
|
Add-MpPreference -ExclusionPath "C:\msys64"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected improvement:** 5 minutes → 30-90 seconds
|
||||||
|
|
||||||
|
### 3. Use Go Build Cache
|
||||||
|
|
||||||
|
Make sure Go's build cache is enabled (it should be by default):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Check cache location
|
||||||
|
go env GOCACHE
|
||||||
|
|
||||||
|
# Should output something like: C:\Users\YourName\AppData\Local\go-build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Don't use `-Clean` flag** unless you're troubleshooting. Clean builds are much slower.
|
||||||
|
|
||||||
|
### 4. Optimize MinGW/GCC
|
||||||
|
|
||||||
|
If using MSYS2/MinGW, ensure it's in your PATH before other compilers:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Check GCC version
|
||||||
|
gcc --version
|
||||||
|
|
||||||
|
# Should show: gcc (GCC) 13.x or newer
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Optimizations
|
||||||
|
|
||||||
|
### 1. Use Faster SSD for Build Cache
|
||||||
|
|
||||||
|
Move your Go cache to an SSD if it's on an HDD:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Set custom cache location on fast SSD
|
||||||
|
$env:GOCACHE = "D:\FastSSD\go-build"
|
||||||
|
go env -w GOCACHE="D:\FastSSD\go-build"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Increase Go Build Parallelism
|
||||||
|
|
||||||
|
For high-core-count CPUs:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Use all CPU threads
|
||||||
|
$env:GOMAXPROCS = [Environment]::ProcessorCount
|
||||||
|
|
||||||
|
# Or set specific count
|
||||||
|
$env:GOMAXPROCS = 16
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Disable Real-Time Scanning Temporarily
|
||||||
|
|
||||||
|
**Only during builds** (not recommended for normal use):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Disable (run as Administrator)
|
||||||
|
Set-MpPreference -DisableRealtimeMonitoring $true
|
||||||
|
|
||||||
|
# Build your project
|
||||||
|
.\scripts\build.ps1
|
||||||
|
|
||||||
|
# Re-enable immediately after
|
||||||
|
Set-MpPreference -DisableRealtimeMonitoring $false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benchmarking Your Build
|
||||||
|
|
||||||
|
Time your build to measure improvements:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# PowerShell
|
||||||
|
Measure-Command { .\scripts\build.ps1 }
|
||||||
|
|
||||||
|
# Command Prompt
|
||||||
|
echo %time% && .\scripts\build.bat && echo %time%
|
||||||
|
```
|
||||||
|
|
||||||
|
## Expected Build Times
|
||||||
|
|
||||||
|
With optimizations:
|
||||||
|
|
||||||
|
| Machine Type | Clean Build | Incremental Build |
|
||||||
|
|--------------|-------------|-------------------|
|
||||||
|
| Modern Desktop (8+ cores, SSD) | 30-60 seconds | 5-15 seconds |
|
||||||
|
| Laptop (4-6 cores, SSD) | 60-90 seconds | 10-20 seconds |
|
||||||
|
| Older Machine (2-4 cores, HDD) | 2-3 minutes | 30-60 seconds |
|
||||||
|
|
||||||
|
**Without Defender exclusions:** Add 2-5 minutes to above times.
|
||||||
|
|
||||||
|
## Still Slow?
|
||||||
|
|
||||||
|
### Check for Common Issues:
|
||||||
|
|
||||||
|
1. **Antivirus Software**
|
||||||
|
- Third-party antivirus can be even worse than Defender
|
||||||
|
- Add same exclusions in your antivirus settings
|
||||||
|
|
||||||
|
2. **Disk Space**
|
||||||
|
- Go cache can grow large
|
||||||
|
- Ensure 5+ GB free space on cache drive
|
||||||
|
|
||||||
|
3. **Background Processes**
|
||||||
|
- Close resource-heavy applications during builds
|
||||||
|
- Check Task Manager for CPU/disk usage
|
||||||
|
|
||||||
|
4. **Network Drives**
|
||||||
|
- **Never** build on network drives or cloud-synced folders
|
||||||
|
- Move project to local SSD
|
||||||
|
|
||||||
|
5. **WSL2 vs Native Windows**
|
||||||
|
- Building in WSL2 can be faster
|
||||||
|
- But adds complexity with GUI apps
|
||||||
|
|
||||||
|
## Troubleshooting Commands
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Check Go environment
|
||||||
|
go env
|
||||||
|
|
||||||
|
# Check build cache size
|
||||||
|
Get-ChildItem -Path (go env GOCACHE) -Recurse | Measure-Object -Property Length -Sum
|
||||||
|
|
||||||
|
# Clean cache if too large (>10 GB)
|
||||||
|
go clean -cache
|
||||||
|
|
||||||
|
# Verify GCC is working
|
||||||
|
gcc --version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting Help
|
||||||
|
|
||||||
|
If you're still experiencing slow builds after following this guide:
|
||||||
|
|
||||||
|
1. **Capture build timing:**
|
||||||
|
```powershell
|
||||||
|
Measure-Command { go build -v -x . } > build-log.txt 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Check system specs:**
|
||||||
|
```powershell
|
||||||
|
systeminfo | findstr /C:"Processor" /C:"Physical Memory"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Report issue** with:
|
||||||
|
- Build timing output
|
||||||
|
- System specifications
|
||||||
|
- Windows version
|
||||||
|
- Antivirus software in use
|
||||||
|
|
||||||
|
## Summary: Quick Start for Git Bash Users
|
||||||
|
|
||||||
|
**If you're using Git Bash on Windows (most users), do this:**
|
||||||
|
|
||||||
|
1. **Open Git Bash as Administrator**
|
||||||
|
- Right-click Git Bash → "Run as administrator"
|
||||||
|
|
||||||
|
2. **Navigate to VideoTools:**
|
||||||
|
```bash
|
||||||
|
cd ~/Projects/VideoTools # or wherever your project is
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Add Defender exclusions (ONE TIME ONLY):**
|
||||||
|
```bash
|
||||||
|
powershell.exe -ExecutionPolicy Bypass -File ./scripts/add-defender-exclusions.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Close and reopen Git Bash (normal, not admin)**
|
||||||
|
|
||||||
|
5. **Build with optimized script:**
|
||||||
|
```bash
|
||||||
|
./scripts/build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected result:** 5+ minutes → 30-90 seconds
|
||||||
|
|
||||||
|
### What Each Step Does:
|
||||||
|
1. ✅ **Add Windows Defender exclusions** (saves 2-5 minutes) - Most important!
|
||||||
|
2. ✅ **Use optimized build scripts** (saves 30-60 seconds) - Parallel compilation
|
||||||
|
3. ✅ **Avoid clean builds** (saves 1-2 minutes) - Uses Go's build cache
|
||||||
BIN
assets/logo/VT_Icon.ico.backup
Normal file
BIN
assets/logo/VT_Icon.ico.backup
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
1104
audio_module.go
Normal file
1104
audio_module.go
Normal file
File diff suppressed because it is too large
Load Diff
260
author_dvd_functions.go
Normal file
260
author_dvd_functions.go
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/dialog"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildDVDRipTab creates a DVD/ISO ripping tab with import support
|
||||||
|
func buildDVDRipTab(state *appState) fyne.CanvasObject {
|
||||||
|
// DVD/ISO source
|
||||||
|
var sourceType string // "dvd" or "iso"
|
||||||
|
var isDVD5 bool
|
||||||
|
var isDVD9 bool
|
||||||
|
var titles []DVDTitle
|
||||||
|
|
||||||
|
sourceLabel := widget.NewLabel("No DVD/ISO selected")
|
||||||
|
sourceLabel.TextStyle = fyne.TextStyle{Bold: true}
|
||||||
|
|
||||||
|
var updateTitleList func()
|
||||||
|
importBtn := widget.NewButton("Import DVD/ISO", func() {
|
||||||
|
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
|
||||||
|
if err != nil || reader == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
path := reader.URI().Path()
|
||||||
|
|
||||||
|
if strings.ToLower(filepath.Ext(path)) == ".iso" {
|
||||||
|
sourceType = "iso"
|
||||||
|
sourceLabel.SetText(fmt.Sprintf("ISO: %s", filepath.Base(path)))
|
||||||
|
} else if isDVDPath(path) {
|
||||||
|
sourceType = "dvd"
|
||||||
|
sourceLabel.SetText(fmt.Sprintf("DVD: %s", path))
|
||||||
|
} else {
|
||||||
|
dialog.ShowError(fmt.Errorf("not a valid DVD or ISO file"), state.window)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze DVD/ISO
|
||||||
|
analyzedTitles, dvd5, dvd9 := analyzeDVDStructure(path, sourceType)
|
||||||
|
titles = analyzedTitles
|
||||||
|
isDVD5 = dvd5
|
||||||
|
isDVD9 = dvd9
|
||||||
|
updateTitleList()
|
||||||
|
}, state.window)
|
||||||
|
})
|
||||||
|
importBtn.Importance = widget.HighImportance
|
||||||
|
|
||||||
|
// Title list
|
||||||
|
titleList := container.NewVBox()
|
||||||
|
|
||||||
|
updateTitleList = func() {
|
||||||
|
titleList.Objects = nil
|
||||||
|
|
||||||
|
if len(titles) == 0 {
|
||||||
|
emptyLabel := widget.NewLabel("Import a DVD or ISO to analyze")
|
||||||
|
emptyLabel.Alignment = fyne.TextAlignCenter
|
||||||
|
titleList.Add(container.NewCenter(emptyLabel))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add DVD5/DVD9 indicators
|
||||||
|
if isDVD5 {
|
||||||
|
dvd5Label := widget.NewLabel("🎞 DVD-5 Detected (Single Layer)")
|
||||||
|
dvd5Label.Importance = widget.LowImportance
|
||||||
|
titleList.Add(dvd5Label)
|
||||||
|
}
|
||||||
|
if isDVD9 {
|
||||||
|
dvd9Label := widget.NewLabel("🎞 DVD-9 Detected (Dual Layer)")
|
||||||
|
dvd9Label.Importance = widget.LowImportance
|
||||||
|
titleList.Add(dvd9Label)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add titles
|
||||||
|
for i, title := range titles {
|
||||||
|
idx := i
|
||||||
|
titleCard := widget.NewCard(
|
||||||
|
fmt.Sprintf("Title %d: %s", idx+1, title.Name),
|
||||||
|
fmt.Sprintf("%.2fs (%.1f GB)", title.Duration, title.SizeGB),
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Title details
|
||||||
|
details := container.NewVBox(
|
||||||
|
widget.NewLabel(fmt.Sprintf("Duration: %.2f seconds", title.Duration)),
|
||||||
|
widget.NewLabel(fmt.Sprintf("Size: %.1f GB", title.SizeGB)),
|
||||||
|
widget.NewLabel(fmt.Sprintf("Video: %s", title.VideoCodec)),
|
||||||
|
widget.NewLabel(fmt.Sprintf("Audio: %d tracks", len(title.AudioTracks))),
|
||||||
|
widget.NewLabel(fmt.Sprintf("Subtitles: %d tracks", len(title.SubtitleTracks))),
|
||||||
|
widget.NewLabel(fmt.Sprintf("Chapters: %d", len(title.Chapters))),
|
||||||
|
)
|
||||||
|
titleCard.SetContent(details)
|
||||||
|
|
||||||
|
// Rip button for this title
|
||||||
|
ripBtn := widget.NewButton("Rip Title", func() {
|
||||||
|
ripTitle(title, state)
|
||||||
|
})
|
||||||
|
ripBtn.Importance = widget.HighImportance
|
||||||
|
|
||||||
|
// Add to controls
|
||||||
|
controls := container.NewVBox(details, widget.NewSeparator(), ripBtn)
|
||||||
|
titleCard.SetContent(controls)
|
||||||
|
titleList.Add(titleCard)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rip all button
|
||||||
|
ripAllBtn := widget.NewButton("Rip All Titles", func() {
|
||||||
|
if len(titles) == 0 {
|
||||||
|
dialog.ShowInformation("No Titles", "Please import a DVD or ISO first", state.window)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ripAllTitles(titles, state)
|
||||||
|
})
|
||||||
|
ripAllBtn.Importance = widget.HighImportance
|
||||||
|
|
||||||
|
controls := container.NewVBox(
|
||||||
|
widget.NewLabel("DVD/ISO Source:"),
|
||||||
|
sourceLabel,
|
||||||
|
importBtn,
|
||||||
|
widget.NewSeparator(),
|
||||||
|
widget.NewLabel("Titles Found:"),
|
||||||
|
container.NewScroll(titleList),
|
||||||
|
widget.NewSeparator(),
|
||||||
|
container.NewHBox(ripAllBtn),
|
||||||
|
)
|
||||||
|
|
||||||
|
return container.NewPadded(controls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DVDTitle represents a DVD title
|
||||||
|
type DVDTitle struct {
|
||||||
|
Number int
|
||||||
|
Name string
|
||||||
|
Duration float64
|
||||||
|
SizeGB float64
|
||||||
|
VideoCodec string
|
||||||
|
AudioTracks []DVDTrack
|
||||||
|
SubtitleTracks []DVDTrack
|
||||||
|
Chapters []DVDChapter
|
||||||
|
AngleCount int
|
||||||
|
IsPAL bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DVDTrack represents an audio/subtitle track
|
||||||
|
type DVDTrack struct {
|
||||||
|
ID int
|
||||||
|
Language string
|
||||||
|
Codec string
|
||||||
|
Channels int
|
||||||
|
SampleRate int
|
||||||
|
Bitrate int
|
||||||
|
}
|
||||||
|
|
||||||
|
// DVDChapter represents a chapter
|
||||||
|
type DVDChapter struct {
|
||||||
|
Number int
|
||||||
|
Title string
|
||||||
|
StartTime float64
|
||||||
|
Duration float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDVDPath checks if path is likely a DVD structure
|
||||||
|
func isDVDPath(path string) bool {
|
||||||
|
// Check for VIDEO_TS directory
|
||||||
|
videoTS := filepath.Join(path, "VIDEO_TS")
|
||||||
|
if _, err := os.Stat(videoTS); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for common DVD file patterns
|
||||||
|
dirs, err := os.ReadDir(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dir := range dirs {
|
||||||
|
name := strings.ToUpper(dir.Name())
|
||||||
|
if strings.Contains(name, "VIDEO_TS") ||
|
||||||
|
strings.Contains(name, "VTS_") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// analyzeDVDStructure analyzes a DVD or ISO file for titles
|
||||||
|
func analyzeDVDStructure(path string, sourceType string) ([]DVDTitle, bool, bool) {
|
||||||
|
// This is a placeholder implementation
|
||||||
|
// In reality, you would use FFmpeg with DVD input support
|
||||||
|
dialog.ShowInformation("DVD Analysis",
|
||||||
|
fmt.Sprintf("Analyzing %s: %s\n\nThis will extract DVD structure and find all titles, audio tracks, and subtitles.", sourceType, filepath.Base(path)),
|
||||||
|
nil)
|
||||||
|
|
||||||
|
// Return sample titles
|
||||||
|
return []DVDTitle{
|
||||||
|
{
|
||||||
|
Number: 1,
|
||||||
|
Name: "Main Feature",
|
||||||
|
Duration: 7200, // 2 hours
|
||||||
|
SizeGB: 7.8,
|
||||||
|
VideoCodec: "MPEG-2",
|
||||||
|
AudioTracks: []DVDTrack{
|
||||||
|
{ID: 1, Language: "en", Codec: "AC-3", Channels: 6, SampleRate: 48000, Bitrate: 448000},
|
||||||
|
{ID: 2, Language: "es", Codec: "AC-3", Channels: 2, SampleRate: 48000, Bitrate: 192000},
|
||||||
|
},
|
||||||
|
SubtitleTracks: []DVDTrack{
|
||||||
|
{ID: 1, Language: "en", Codec: "SubRip"},
|
||||||
|
{ID: 2, Language: "es", Codec: "SubRip"},
|
||||||
|
},
|
||||||
|
Chapters: []DVDChapter{
|
||||||
|
{Number: 1, Title: "Chapter 1", StartTime: 0, Duration: 1800},
|
||||||
|
{Number: 2, Title: "Chapter 2", StartTime: 1800, Duration: 1800},
|
||||||
|
{Number: 3, Title: "Chapter 3", StartTime: 3600, Duration: 1800},
|
||||||
|
{Number: 4, Title: "Chapter 4", StartTime: 5400, Duration: 1800},
|
||||||
|
},
|
||||||
|
AngleCount: 1,
|
||||||
|
IsPAL: false,
|
||||||
|
},
|
||||||
|
}, false, false // DVD-5 by default for this example
|
||||||
|
}
|
||||||
|
|
||||||
|
// ripTitle rips a single DVD title to MKV format
|
||||||
|
func ripTitle(title DVDTitle, state *appState) {
|
||||||
|
// Default to AV1 in MKV for best quality
|
||||||
|
outputPath := fmt.Sprintf("%s_%s_Title%d.mkv",
|
||||||
|
strings.TrimSuffix(strings.TrimSuffix(filepath.Base(state.authorFile.Path), filepath.Ext(state.authorFile.Path)), ".dvd"),
|
||||||
|
title.Name,
|
||||||
|
title.Number)
|
||||||
|
|
||||||
|
dialog.ShowInformation("Rip Title",
|
||||||
|
fmt.Sprintf("Ripping Title %d: %s\n\nOutput: %s\nFormat: MKV (AV1)\nAudio: All tracks\nSubtitles: All tracks",
|
||||||
|
title.Number, title.Name, outputPath),
|
||||||
|
state.window)
|
||||||
|
|
||||||
|
// TODO: Implement actual ripping with FFmpeg
|
||||||
|
// This would use FFmpeg to extract the title with selected codec
|
||||||
|
// For DVD: ffmpeg -i dvd://1 -c:v libaom-av1 -c:a libopus -map_metadata 0 output.mkv
|
||||||
|
// For ISO: ffmpeg -i path/to/iso -map 0:v:0 -map 0:a -c:v libaom-av1 -c:a libopus output.mkv
|
||||||
|
}
|
||||||
|
|
||||||
|
// ripAllTitles rips all DVD titles
|
||||||
|
func ripAllTitles(titles []DVDTitle, state *appState) {
|
||||||
|
dialog.ShowInformation("Rip All Titles",
|
||||||
|
fmt.Sprintf("Ripping all %d titles\n\nThis will extract each title to separate MKV files with AV1 encoding.", len(titles)),
|
||||||
|
state.window)
|
||||||
|
|
||||||
|
// TODO: Implement batch ripping
|
||||||
|
for _, title := range titles {
|
||||||
|
ripTitle(title, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
2763
author_module.go
Normal file
2763
author_module.go
Normal file
File diff suppressed because it is too large
Load Diff
20
config_helpers.go
Normal file
20
config_helpers.go
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func moduleConfigPath(name string) string {
|
||||||
|
configDir, err := os.UserConfigDir()
|
||||||
|
if err != nil || configDir == "" {
|
||||||
|
home := os.Getenv("HOME")
|
||||||
|
if home != "" {
|
||||||
|
configDir = filepath.Join(home, ".config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if configDir == "" {
|
||||||
|
return name + ".json"
|
||||||
|
}
|
||||||
|
return filepath.Join(configDir, "VideoTools", name+".json")
|
||||||
|
}
|
||||||
263
docs/AUTHOR_MODULE.md
Normal file
263
docs/AUTHOR_MODULE.md
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
# Author Module Guide
|
||||||
|
|
||||||
|
## What Does This Do?
|
||||||
|
|
||||||
|
The Author module turns your video files into DVDs that'll play in any DVD player - the kind you'd hook up to a TV. It handles all the technical stuff so you don't have to worry about it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Making a Single DVD
|
||||||
|
|
||||||
|
1. Click **Author** from the main menu
|
||||||
|
2. **Files Tab** → Click "Select File" → Pick your video
|
||||||
|
3. **Settings Tab**:
|
||||||
|
- DVD or Blu-ray (pick DVD for now)
|
||||||
|
- NTSC or PAL - pick NTSC if you're in the US
|
||||||
|
- 16:9 or 4:3 - pick 16:9 for widescreen
|
||||||
|
4. **Generate Tab** → Click "Generate DVD/ISO"
|
||||||
|
5. Wait for it to finish, then burn the .iso file to a DVD-R
|
||||||
|
|
||||||
|
That's it. The DVD will play in any player.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scene Detection - Finding Chapter Points Automatically
|
||||||
|
|
||||||
|
### What Are Chapters?
|
||||||
|
|
||||||
|
You know how DVDs let you skip to different parts of the movie? Those are chapters. The Author module can find these automatically by detecting when scenes change.
|
||||||
|
|
||||||
|
### How to Use It
|
||||||
|
|
||||||
|
1. Load your video (Files or Clips tab)
|
||||||
|
2. Go to **Chapters Tab**
|
||||||
|
3. Move the "Detection Sensitivity" slider:
|
||||||
|
- Move it **left** for more chapters (catches small changes)
|
||||||
|
- Move it **right** for fewer chapters (only big changes)
|
||||||
|
4. Click "Detect Scenes"
|
||||||
|
5. Look at the thumbnails that pop up - these show where chapters will be
|
||||||
|
6. If it looks good, click "Accept." If not, click "Reject" and try a different sensitivity
|
||||||
|
|
||||||
|
### What Sensitivity Should I Use?
|
||||||
|
|
||||||
|
It depends on your video:
|
||||||
|
|
||||||
|
- **Movies**: Use 0.5 - 0.6 (only major scene changes)
|
||||||
|
- **TV shows**: Use 0.3 - 0.4 (catches scene changes between commercial breaks)
|
||||||
|
- **Music videos**: Use 0.2 - 0.3 (lots of quick cuts)
|
||||||
|
- **Your phone videos**: Use 0.4 - 0.5 (depends on how much you moved around)
|
||||||
|
|
||||||
|
Don't stress about getting it perfect. Just adjust the slider and click "Detect Scenes" again until the preview looks right.
|
||||||
|
|
||||||
|
### The Preview Window
|
||||||
|
|
||||||
|
After detection runs, you'll see a grid of thumbnails. Each thumbnail is a freeze-frame from where a chapter starts. This lets you actually see if the detection makes sense - way better than just seeing a list of timestamps.
|
||||||
|
|
||||||
|
The preview shows the first 24 chapters. If more were detected, you'll see a message like "Found 152 chapters (showing first 24)". That's a sign you should increase the sensitivity slider.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Understanding the Settings
|
||||||
|
|
||||||
|
### Output Type
|
||||||
|
|
||||||
|
**DVD** - Standard DVD format. Works everywhere.
|
||||||
|
**Blu-ray** - Not ready yet. Stick with DVD.
|
||||||
|
|
||||||
|
### Region
|
||||||
|
|
||||||
|
**NTSC** - US, Canada, Japan. Videos play at 30 frames per second.
|
||||||
|
**PAL** - Europe, Australia, most of the world. Videos play at 25 frames per second.
|
||||||
|
|
||||||
|
Pick based on where you live. If you're not sure, pick NTSC.
|
||||||
|
|
||||||
|
### Aspect Ratio
|
||||||
|
|
||||||
|
**16:9** - Widescreen. Use this for videos from phones, cameras, YouTube.
|
||||||
|
**4:3** - Old TV shape. Only use if your video is actually in this format (rare now).
|
||||||
|
**AUTO** - Let the software decide. Safe choice.
|
||||||
|
|
||||||
|
When in doubt, use 16:9.
|
||||||
|
|
||||||
|
### Disc Size
|
||||||
|
|
||||||
|
**DVD5** - Holds 4.7 GB. Standard blank DVDs you buy at the store.
|
||||||
|
**DVD9** - Holds 8.5 GB. Dual-layer discs (more expensive).
|
||||||
|
|
||||||
|
Use DVD5 unless you're making a really long video (over 2 hours).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Scenarios
|
||||||
|
|
||||||
|
### Scenario 1: Burning Home Videos to DVD
|
||||||
|
|
||||||
|
You filmed stuff on your phone and want to give it to relatives who don't use computers much.
|
||||||
|
|
||||||
|
1. **Files Tab** → Select your phone video
|
||||||
|
2. **Chapters Tab** → Detect scenes with sensitivity around 0.4
|
||||||
|
3. Check the preview - should show major moments (birthday, cake, opening presents, etc.)
|
||||||
|
4. **Settings Tab**:
|
||||||
|
- Output Type: DVD
|
||||||
|
- Region: NTSC
|
||||||
|
- Aspect Ratio: 16:9
|
||||||
|
5. **Generate Tab**:
|
||||||
|
- Title: "Birthday 2024"
|
||||||
|
- Pick where to save it
|
||||||
|
- Click Generate
|
||||||
|
6. When done, burn the .iso file to a DVD-R
|
||||||
|
7. Hand it to grandma - it'll just work in her DVD player
|
||||||
|
|
||||||
|
### Scenario 2: Multiple Episodes on One Disc
|
||||||
|
|
||||||
|
You downloaded 3 episodes of a show and want them on one disc with a menu.
|
||||||
|
|
||||||
|
1. **Clips Tab** → Click "Add Video" for each episode
|
||||||
|
2. Leave "Treat as Chapters" OFF - this keeps them as separate titles
|
||||||
|
3. **Settings Tab**:
|
||||||
|
- Output Type: DVD
|
||||||
|
- Region: NTSC
|
||||||
|
- Create Menu: YES (important!)
|
||||||
|
4. **Generate Tab** → Generate the disc
|
||||||
|
5. The DVD will have a menu where you can pick which episode to watch
|
||||||
|
|
||||||
|
### Scenario 3: Concert Video with Song Chapters
|
||||||
|
|
||||||
|
You recorded a concert and want to skip to specific songs.
|
||||||
|
|
||||||
|
Option A - Automatic:
|
||||||
|
1. Load the concert video
|
||||||
|
2. **Chapters Tab** → Try sensitivity 0.3 first
|
||||||
|
3. Look at preview - if chapters line up with songs, you're done
|
||||||
|
4. If not, adjust sensitivity and try again
|
||||||
|
|
||||||
|
Option B - Manual:
|
||||||
|
1. Play through the video and note the times when songs start
|
||||||
|
2. **Chapters Tab** → Click "+ Add Chapter" for each song
|
||||||
|
3. Enter the time (like 3:45 for 3 minutes 45 seconds)
|
||||||
|
4. Name it (Song 1, Song 2, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's Happening Behind the Scenes?
|
||||||
|
|
||||||
|
You don't need to know this to use the software, but if you're curious:
|
||||||
|
|
||||||
|
### The Encoding Process
|
||||||
|
|
||||||
|
When you click Generate:
|
||||||
|
|
||||||
|
1. **Encoding**: Your video gets converted to MPEG-2 format (the DVD standard)
|
||||||
|
2. **Timestamp Fix**: The software makes sure the timestamps are perfectly sequential (DVDs are picky about this)
|
||||||
|
3. **Structure Creation**: It builds the VIDEO_TS folder structure that DVD players expect
|
||||||
|
4. **ISO Creation**: If you picked ISO, everything gets packed into one burnable file
|
||||||
|
|
||||||
|
### Why Does It Take So Long?
|
||||||
|
|
||||||
|
Converting video to MPEG-2 is CPU-intensive. A 90-minute video might take 30-60 minutes to encode, depending on your computer. You can queue multiple jobs and let it run overnight.
|
||||||
|
|
||||||
|
### The Timestamp Fix Thing
|
||||||
|
|
||||||
|
Some videos, especially .avi files, have timestamps that go slightly backwards occasionally. DVD players hate this and will error out. The software automatically fixes it by running the encoded video through a "remux" step - think of it like reformatting a document to fix the page numbers. Takes a few extra seconds but ensures the DVD actually works.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "I got 200 chapters, that's way too many"
|
||||||
|
|
||||||
|
Your sensitivity is too low. Move the slider right to 0.5 or higher and try again.
|
||||||
|
|
||||||
|
### "It only found 3 chapters in a 2-hour movie"
|
||||||
|
|
||||||
|
Sensitivity is too high. Move the slider left to 0.3 or 0.4.
|
||||||
|
|
||||||
|
### "The program is really slow when generating"
|
||||||
|
|
||||||
|
That's normal. Encoding video is slow. The good news is you can:
|
||||||
|
- Queue multiple jobs and walk away
|
||||||
|
- Work on other stuff - the encoding happens in the background
|
||||||
|
- Check the log to see progress
|
||||||
|
|
||||||
|
### "The authoring log is making everything lag"
|
||||||
|
|
||||||
|
This was a bug that's now fixed. The log only shows the last 100 lines. If you want to see everything, click "View Full Log" and it opens in a separate window.
|
||||||
|
|
||||||
|
### "My ISO file won't fit on a DVD-R"
|
||||||
|
|
||||||
|
Your video is too long or the quality is too high. Options:
|
||||||
|
- Use a dual-layer DVD-R (DVD9) instead
|
||||||
|
- Split into 2 discs
|
||||||
|
- Check if you accidentally loaded multiple long videos
|
||||||
|
|
||||||
|
### "The DVD plays but skips or stutters"
|
||||||
|
|
||||||
|
This is usually because your original video had variable frame rate (VFR) - phone videos often do this. The software will warn you if it detects this. Solution:
|
||||||
|
- Try generating again (sometimes it just works)
|
||||||
|
- Convert the source video to constant frame rate first using the Convert module
|
||||||
|
- Check if the source video itself plays smoothly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Size Reference
|
||||||
|
|
||||||
|
Here's roughly how much video fits on each disc type:
|
||||||
|
|
||||||
|
**DVD5 (4.7 GB)**
|
||||||
|
- About 2 hours of video at standard quality
|
||||||
|
- Most movies fit comfortably
|
||||||
|
|
||||||
|
**DVD9 (8.5 GB)**
|
||||||
|
- About 4 hours of video
|
||||||
|
- Good for director's cuts or multiple episodes
|
||||||
|
|
||||||
|
If you're over these limits, split your content across multiple discs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Output Files Explained
|
||||||
|
|
||||||
|
### VIDEO_TS Folder
|
||||||
|
|
||||||
|
This is what DVD players actually read. It contains:
|
||||||
|
- .IFO files - the "table of contents"
|
||||||
|
- .VOB files - the actual video data
|
||||||
|
|
||||||
|
You can copy this folder to a USB drive and some DVD players can read it directly.
|
||||||
|
|
||||||
|
### ISO File
|
||||||
|
|
||||||
|
Think of this as a zip file of the VIDEO_TS folder, formatted specifically for burning to disc. When you burn an ISO to a DVD-R, it extracts everything into the right structure automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
**Test Before Making Multiple Copies**
|
||||||
|
Make one disc, test it in a DVD player, make sure everything works. Then make more copies.
|
||||||
|
|
||||||
|
**Name Your Files Clearly**
|
||||||
|
Use names like "vacation_2024.iso" not "output.iso". Future you will thank you.
|
||||||
|
|
||||||
|
**Keep the Source Files**
|
||||||
|
Don't delete your original videos after making DVDs. Hard drives are cheap, memories aren't.
|
||||||
|
|
||||||
|
**Preview the Chapters**
|
||||||
|
Always check that chapter preview before accepting. It takes 10 seconds and prevents surprises.
|
||||||
|
|
||||||
|
**Use the Queue**
|
||||||
|
Got 5 videos to convert? Add them all to the queue and start it before bed. They'll all be done by morning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Guides
|
||||||
|
|
||||||
|
- **DVD_USER_GUIDE.md** - How to use the Convert module for DVD encoding
|
||||||
|
- **QUEUE_SYSTEM_GUIDE.md** - Managing multiple jobs
|
||||||
|
- **MODULES.md** - What all the other modules do
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
That's everything. Load a video, adjust some settings, click Generate. The software handles the complicated parts.
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
- **Cross-compilation script** (`scripts/build-windows.sh`)
|
- **Cross-compilation script** (`scripts/build-windows.sh`)
|
||||||
|
|
||||||
#### Professional Installation System
|
#### Professional Installation System
|
||||||
- **One-command installer** (`install.sh`) with guided wizard
|
- **One-command installer** (`scripts/install.sh`) with guided wizard
|
||||||
- **Automatic shell detection** (bash/zsh) and configuration
|
- **Automatic shell detection** (bash/zsh) and configuration
|
||||||
- **System-wide vs user-local installation** options
|
- **System-wide vs user-local installation** options
|
||||||
- **Convenience aliases** (`VideoTools`, `VideoToolsRebuild`, `VideoToolsClean`)
|
- **Convenience aliases** (`VideoTools`, `VideoToolsRebuild`, `VideoToolsClean`)
|
||||||
|
|
@ -198,14 +198,12 @@
|
||||||
### 📚 Documentation Updates
|
### 📚 Documentation Updates
|
||||||
|
|
||||||
#### New Documentation Added
|
#### New Documentation Added
|
||||||
- `HANDBRAKE_REPLACEMENT.md` - Comprehensive modern video processing strategy
|
|
||||||
- Enhanced `TODO.md` with Lossless-Cut inspired trim module specifications
|
- Enhanced `TODO.md` with Lossless-Cut inspired trim module specifications
|
||||||
- Updated `MODULES.md` with detailed trim module implementation plan
|
- Updated `MODULES.md` with detailed trim module implementation plan
|
||||||
- Enhanced `docs/README.md` with VT_Player integration links
|
- Enhanced `docs/README.md` with VT_Player integration links
|
||||||
|
|
||||||
#### Documentation Enhancements
|
#### Documentation Enhancements
|
||||||
- **Trim Module Specifications** - Detailed Lossless-Cut inspired design
|
- **Trim Module Specifications** - Detailed Lossless-Cut inspired design
|
||||||
- **HandBrake Parity Analysis** - Feature comparison and migration strategy
|
|
||||||
- **VT_Player Integration Notes** - Cross-project component reuse
|
- **VT_Player Integration Notes** - Cross-project component reuse
|
||||||
- **Implementation Roadmap** - Clear development phases and priorities
|
- **Implementation Roadmap** - Clear development phases and priorities
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -328,5 +328,4 @@ Happy encoding! 📀
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Generated with Claude Code*
|
For technical details on DVD authoring with chapters, see AUTHOR_MODULE.md
|
||||||
*For support, check the comprehensive guides in the project repository*
|
|
||||||
|
|
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
# VideoTools: Modern Video Processing Strategy
|
|
||||||
|
|
||||||
## 🎯 Project Vision
|
|
||||||
|
|
||||||
VideoTools provides a **modern approach to video processing** with enhanced capabilities while maintaining simplicity and focusing on core video processing workflows.
|
|
||||||
|
|
||||||
## 📊 Modern Video Processing Features
|
|
||||||
|
|
||||||
### ✅ Core Video Processing Features (VideoTools Status)
|
|
||||||
| Feature | VideoTools Status | Notes |
|
|
||||||
|---------|-------------------|---------|
|
|
||||||
| Video transcoding | ✅ IMPLEMENTED | Enhanced with DVD/Blu-ray presets |
|
|
||||||
| Queue system | ✅ IMPLEMENTED | Advanced with job reordering and prioritization |
|
|
||||||
| Preset management | 🔄 PARTIAL | Basic presets, needs modern device profiles |
|
|
||||||
| Chapter support | 🔄 PLANNED | Auto-chapter creation in trim/merge modules |
|
|
||||||
| Multi-title support | 🔄 PLANNED | For DVD/Blu-ray sources |
|
|
||||||
| Subtitle support | 🔄 PLANNED | Advanced subtitle handling and styling |
|
|
||||||
| Audio track management | 🔄 PLANNED | Multi-track selection and processing |
|
|
||||||
| Quality control | ✅ IMPLEMENTED | Enhanced with size targets and validation |
|
|
||||||
| Device profiles | 🔄 PLANNED | Modern device optimization |
|
|
||||||
|
|
||||||
### 🚀 VideoTools Modern Advantages
|
|
||||||
| Feature | Traditional Tools | VideoTools | Advantage |
|
|
||||||
|---------|------------------|-------------|-----------|
|
|
||||||
| **Modern Architecture** | Monolithic | Modular | Extensible, maintainable |
|
|
||||||
| **Cross-Platform** | Limited | Full support | Linux, Windows parity |
|
|
||||||
| **AI Upscaling** | None | Planned | Next-gen enhancement |
|
|
||||||
| **Smart Chapters** | Manual | Auto-generation | Intelligent workflow |
|
|
||||||
| **Advanced Queue** | Basic | Enhanced | Better batch processing |
|
|
||||||
| **Lossless-Cut Style** | No | Planned | Frame-accurate trimming |
|
|
||||||
| **Blu-ray Authoring** | No | Planned | Professional workflows |
|
|
||||||
| **VT_Player Integration** | No | Planned | Unified ecosystem |
|
|
||||||
|
|
||||||
## 🎯 Core HandBrake Replacement Features
|
|
||||||
|
|
||||||
### 1. **Enhanced Convert Module** (Core Replacement)
|
|
||||||
```go
|
|
||||||
// HandBrake-equivalent transcoding with modern enhancements
|
|
||||||
type ConvertConfig struct {
|
|
||||||
// HandBrake parity features
|
|
||||||
VideoCodec string // H.264, H.265, AV1
|
|
||||||
AudioCodec string // AAC, AC3, Opus, FLAC
|
|
||||||
Quality Quality // CRF, bitrate, 2-pass
|
|
||||||
Preset string // Fast, Balanced, HQ, Archive
|
|
||||||
|
|
||||||
// VideoTools enhancements
|
|
||||||
DeviceProfile string // iPhone, Android, TV, Gaming
|
|
||||||
ContentAware bool // Auto-optimize for content type
|
|
||||||
SmartBitrate bool // Size-target encoding
|
|
||||||
AIUpscale bool // AI enhancement when upscaling
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. **Professional Preset System** (Enhanced)
|
|
||||||
```go
|
|
||||||
// Modern device and platform presets
|
|
||||||
type PresetCategory string
|
|
||||||
const (
|
|
||||||
PresetDevices PresetCategory = "devices" // iPhone, Android, TV
|
|
||||||
PresetPlatforms PresetCategory = "platforms" // YouTube, TikTok, Instagram
|
|
||||||
PresetQuality PresetCategory = "quality" // Fast, Balanced, HQ
|
|
||||||
PresetArchive PresetCategory = "archive" // Long-term preservation
|
|
||||||
)
|
|
||||||
|
|
||||||
// HandBrake-compatible + modern presets
|
|
||||||
- iPhone 15 Pro Max
|
|
||||||
- Samsung Galaxy S24
|
|
||||||
- PlayStation 5
|
|
||||||
- YouTube 4K HDR
|
|
||||||
- TikTok Vertical
|
|
||||||
- Instagram Reels
|
|
||||||
- Netflix 4K Profile
|
|
||||||
- Archive Master Quality
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **Advanced Queue System** (Enhanced)
|
|
||||||
```go
|
|
||||||
// HandBrake queue with modern features
|
|
||||||
type QueueJob struct {
|
|
||||||
// HandBrake parity
|
|
||||||
Source string
|
|
||||||
Destination string
|
|
||||||
Settings ConvertConfig
|
|
||||||
Status JobStatus
|
|
||||||
|
|
||||||
// VideoTools enhancements
|
|
||||||
Priority int // Job prioritization
|
|
||||||
Dependencies []int // Job dependencies
|
|
||||||
RetryCount int // Smart retry logic
|
|
||||||
ETA time.Duration // Accurate time estimation
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. **Smart Title Selection** (Enhanced)
|
|
||||||
```go
|
|
||||||
// Enhanced title detection for multi-title sources
|
|
||||||
type TitleInfo struct {
|
|
||||||
ID int
|
|
||||||
Duration time.Duration
|
|
||||||
Resolution string
|
|
||||||
AudioTracks []AudioTrack
|
|
||||||
Subtitles []SubtitleTrack
|
|
||||||
Chapters []Chapter
|
|
||||||
Quality QualityMetrics
|
|
||||||
Recommended bool // AI-based recommendation
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sources: DVD, Blu-ray, multi-title MKV
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔄 User Experience Strategy
|
|
||||||
|
|
||||||
### **Modern Video Processing Experience**
|
|
||||||
- **Intuitive Interface** - Clean, focused layout for common workflows
|
|
||||||
- **Smart Presets** - Content-aware and device-optimized settings
|
|
||||||
- **Efficient Queue** - Advanced batch processing with job management
|
|
||||||
- **Professional Workflows** - DVD/Blu-ray authoring, multi-format output
|
|
||||||
|
|
||||||
### **Enhanced Processing Capabilities**
|
|
||||||
- **Smart Defaults** - Content-aware optimization for better results
|
|
||||||
- **Hardware Acceleration** - GPU utilization across all platforms
|
|
||||||
- **Modern Codecs** - AV1, HEVC, VP9 with professional profiles
|
|
||||||
- **AI Features** - Intelligent upscaling and quality enhancement
|
|
||||||
|
|
||||||
## 📋 Implementation Priority
|
|
||||||
|
|
||||||
### **Phase 1: Core Modern Features** (6-8 weeks)
|
|
||||||
1. **Enhanced Convert Module** - Modern transcoding with smart optimization
|
|
||||||
2. **Professional Presets** - Device and platform-specific profiles
|
|
||||||
3. **Advanced Queue System** - Intelligent batch processing with prioritization
|
|
||||||
4. **Multi-Title Support** - DVD/Blu-ray source handling
|
|
||||||
|
|
||||||
### **Phase 2: Enhanced Workflows** (4-6 weeks)
|
|
||||||
5. **Smart Chapter System** - Auto-generation in trim/merge modules
|
|
||||||
6. **Advanced Audio Processing** - Multi-track management and conversion
|
|
||||||
7. **Comprehensive Subtitle System** - Advanced subtitle handling and styling
|
|
||||||
8. **Quality Control Tools** - Size targets and validation systems
|
|
||||||
|
|
||||||
### **Phase 3: Next-Generation Features** (6-8 weeks)
|
|
||||||
9. **AI-Powered Upscaling** - Modern enhancement and upscaling
|
|
||||||
10. **VT_Player Integration** - Unified playback and processing ecosystem
|
|
||||||
11. **Professional Blu-ray Authoring** - Complete Blu-ray workflow support
|
|
||||||
12. **Content-Aware Processing** - Intelligent optimization based on content analysis
|
|
||||||
|
|
||||||
## 🎯 Key Differentiators
|
|
||||||
|
|
||||||
### **Technical Advantages**
|
|
||||||
- **Modern Codebase** - Go language for better maintainability and performance
|
|
||||||
- **Modular Architecture** - Extensible design for future enhancements
|
|
||||||
- **Cross-Platform** - Native support on Linux and Windows
|
|
||||||
- **Hardware Acceleration** - Optimized GPU utilization across platforms
|
|
||||||
- **AI Integration** - Next-generation enhancement capabilities
|
|
||||||
|
|
||||||
### **User Experience**
|
|
||||||
- **Intuitive Interface** - Focused design for common video workflows
|
|
||||||
- **Smart Defaults** - Content-aware settings for excellent results
|
|
||||||
- **Optimized Performance** - Efficient encoding pipelines and processing
|
|
||||||
- **Real-time Feedback** - Quality metrics and progress indicators
|
|
||||||
- **Unified Ecosystem** - Integrated VT_Player for seamless workflow
|
|
||||||
|
|
||||||
### **Professional Features**
|
|
||||||
- **Broadcast Quality** - Professional standards compliance and validation
|
|
||||||
- **Advanced Workflows** - Complete DVD and Blu-ray authoring capabilities
|
|
||||||
- **Intelligent Batch Processing** - Advanced queue system with job management
|
|
||||||
- **Quality Assurance** - Built-in validation and testing tools
|
|
||||||
|
|
||||||
## 📊 Success Metrics
|
|
||||||
|
|
||||||
### **Modern Video Processing Goals**
|
|
||||||
- ✅ **Complete Feature Set** - Comprehensive video processing capabilities
|
|
||||||
- ✅ **50% Faster Encoding** - Optimized hardware utilization
|
|
||||||
- ✅ **30% Better Quality** - Smart optimization algorithms
|
|
||||||
- ✅ **Cross-Platform** - Native Linux/Windows support
|
|
||||||
|
|
||||||
### **Market Positioning**
|
|
||||||
- **Modern Video Suite** - Next-generation architecture and features
|
|
||||||
- **Professional Tool** - Beyond consumer-level capabilities
|
|
||||||
- **Intuitive Processing** - Smart defaults and user-friendly workflows
|
|
||||||
- **Ecosystem Solution** - Integrated VT_Player for seamless experience
|
|
||||||
|
|
||||||
## 🚀 User Experience Strategy
|
|
||||||
|
|
||||||
### **Launch Positioning**
|
|
||||||
- **"Modern Video Processing"** - Next-generation approach to video tools
|
|
||||||
- **"AI-Powered Enhancement"** - Intelligent upscaling and optimization
|
|
||||||
- **"Professional Video Suite"** - Comprehensive processing capabilities
|
|
||||||
- **"Cross-Platform Solution"** - Native support everywhere
|
|
||||||
|
|
||||||
### **User Onboarding**
|
|
||||||
- **Intuitive Interface** - Familiar workflows with modern enhancements
|
|
||||||
- **Smart Presets** - Content-aware settings for excellent results
|
|
||||||
- **Tutorial Integration** - Built-in guidance for advanced features
|
|
||||||
- **Workflow Examples** - Show common use cases and best practices
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
This strategy positions VideoTools as a **direct HandBrake replacement** while adding significant modern advantages and professional capabilities.
|
|
||||||
|
|
@ -7,7 +7,7 @@ This guide will help you install VideoTools with minimal setup.
|
||||||
### One-Command Installation
|
### One-Command Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash install.sh
|
bash scripts/install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
That's it! The installer will:
|
That's it! The installer will:
|
||||||
|
|
@ -43,7 +43,7 @@ VideoTools
|
||||||
### Option 1: System-Wide Installation (Recommended for Shared Computers)
|
### Option 1: System-Wide Installation (Recommended for Shared Computers)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash install.sh
|
bash scripts/install.sh
|
||||||
# Select option 1 when prompted
|
# Select option 1 when prompted
|
||||||
# Enter your password if requested
|
# Enter your password if requested
|
||||||
```
|
```
|
||||||
|
|
@ -61,7 +61,7 @@ bash install.sh
|
||||||
### Option 2: User-Local Installation (Recommended for Personal Use)
|
### Option 2: User-Local Installation (Recommended for Personal Use)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash install.sh
|
bash scripts/install.sh
|
||||||
# Select option 2 when prompted (default)
|
# Select option 2 when prompted (default)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -78,7 +78,7 @@ bash install.sh
|
||||||
|
|
||||||
## What the Installer Does
|
## What the Installer Does
|
||||||
|
|
||||||
The `install.sh` script performs these steps:
|
The `scripts/install.sh` script performs these steps:
|
||||||
|
|
||||||
### Step 1: Go Verification
|
### Step 1: Go Verification
|
||||||
- Checks if Go 1.21+ is installed
|
- Checks if Go 1.21+ is installed
|
||||||
|
|
@ -122,6 +122,23 @@ VideoToolsClean # Clean build artifacts and cache
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
For day-to-day development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build.sh
|
||||||
|
./scripts/run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `./scripts/install.sh` when you add new system dependencies or want to reinstall.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
See `docs/ROADMAP.md` for the current dev focus and priorities.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Essential
|
### Essential
|
||||||
|
|
@ -135,7 +152,7 @@ VideoToolsClean # Clean build artifacts and cache
|
||||||
```
|
```
|
||||||
|
|
||||||
### System
|
### System
|
||||||
- Linux, macOS, or WSL (Windows Subsystem for Linux)
|
- Linux, macOS, or Windows (native)
|
||||||
- At least 2 GB free disk space
|
- At least 2 GB free disk space
|
||||||
- Stable internet connection (for dependencies)
|
- Stable internet connection (for dependencies)
|
||||||
|
|
||||||
|
|
@ -157,7 +174,7 @@ go version
|
||||||
**Solution:** Check build log for specific errors:
|
**Solution:** Check build log for specific errors:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash install.sh
|
bash scripts/install.sh
|
||||||
# Look for error messages in the build log output
|
# Look for error messages in the build log output
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -356,4 +373,3 @@ Installation works in WSL environment. Ensure you have WSL with Linux distro ins
|
||||||
---
|
---
|
||||||
|
|
||||||
Enjoy using VideoTools! 🎬
|
Enjoy using VideoTools! 🎬
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ The queue view now displays:
|
||||||
|
|
||||||
### New Files
|
### New Files
|
||||||
|
|
||||||
1. **Enhanced `install.sh`** - One-command installation
|
1. **Enhanced `scripts/install.sh`** - One-command installation
|
||||||
2. **New `INSTALLATION.md`** - Comprehensive installation guide
|
2. **New `INSTALLATION.md`** - Comprehensive installation guide
|
||||||
|
|
||||||
### install.sh Features
|
### install.sh Features
|
||||||
|
|
@ -96,7 +96,7 @@ The queue view now displays:
|
||||||
The installer now performs all setup automatically:
|
The installer now performs all setup automatically:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash install.sh
|
bash scripts/install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This handles:
|
This handles:
|
||||||
|
|
@ -113,13 +113,13 @@ This handles:
|
||||||
|
|
||||||
**Option 1: System-Wide (for shared computers)**
|
**Option 1: System-Wide (for shared computers)**
|
||||||
```bash
|
```bash
|
||||||
bash install.sh
|
bash scripts/install.sh
|
||||||
# Select option 1 when prompted
|
# Select option 1 when prompted
|
||||||
```
|
```
|
||||||
|
|
||||||
**Option 2: User-Local (default, no sudo required)**
|
**Option 2: User-Local (default, no sudo required)**
|
||||||
```bash
|
```bash
|
||||||
bash install.sh
|
bash scripts/install.sh
|
||||||
# Select option 2 when prompted (or just press Enter)
|
# Select option 2 when prompted (or just press Enter)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -235,7 +235,7 @@ All features are built and ready:
|
||||||
3. Test reordering with up/down arrows
|
3. Test reordering with up/down arrows
|
||||||
|
|
||||||
### For Testing Installation
|
### For Testing Installation
|
||||||
1. Run `bash install.sh` on a clean system
|
1. Run `bash scripts/install.sh` on a clean system
|
||||||
2. Verify binary is in PATH
|
2. Verify binary is in PATH
|
||||||
3. Verify aliases are available
|
3. Verify aliases are available
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -135,20 +135,17 @@ Comprehensive metadata viewer and editor:
|
||||||
|
|
||||||
**Current Status:** Basic metadata viewing implemented, advanced features planned.
|
**Current Status:** Basic metadata viewing implemented, advanced features planned.
|
||||||
|
|
||||||
### Rip 🔄 PLANNED
|
### Rip ✅ IMPLEMENTED
|
||||||
Extract and convert content from optical media and disc images:
|
Extract and convert content from optical media and disc images:
|
||||||
- ⏳ Rip directly from DVD/Blu-ray drives to video files
|
- ✅ Rip from VIDEO_TS folders
|
||||||
- ⏳ Extract from ISO, IMG, and other disc image formats
|
- ✅ Extract from ISO images (requires `xorriso` or `bsdtar`)
|
||||||
- ⏳ Title and chapter selection
|
- ✅ Default lossless DVD → MKV (stream copy)
|
||||||
- ⏳ Preserve or transcode during extraction
|
- ✅ Optional H.264 MKV/MP4 outputs
|
||||||
- ⏳ Handle copy protection (via libdvdcss/libaacs when available)
|
- ✅ Queue-based execution with logs and progress
|
||||||
- ⏳ Subtitle and audio track selection
|
|
||||||
- ⏳ Batch ripping of multiple titles
|
|
||||||
- ⏳ Output to lossless or compressed formats
|
|
||||||
|
|
||||||
**FFmpeg Features:** DVD/Blu-ray input, concat, stream copying
|
**FFmpeg Features:** concat demuxer, stream copy, H.264 encoding
|
||||||
|
|
||||||
**Current Status:** Planned for dev16, requires legal research and library integration.
|
**Current Status:** Available in dev20+. Physical disc and multi-title selection are still planned.
|
||||||
|
|
||||||
### Blu-ray 🔄 PLANNED
|
### Blu-ray 🔄 PLANNED
|
||||||
Professional Blu-ray Disc authoring and encoding system:
|
Professional Blu-ray Disc authoring and encoding system:
|
||||||
|
|
|
||||||
|
|
@ -14,18 +14,20 @@ Get VideoTools running in minutes!
|
||||||
cd VideoTools
|
cd VideoTools
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Run the setup script**:
|
2. **Install dependencies and build** (Git Bash or similar):
|
||||||
- Double-click `setup-windows.bat`
|
```bash
|
||||||
- OR run in PowerShell:
|
./scripts/install.sh
|
||||||
```powershell
|
```
|
||||||
.\scripts\setup-windows.ps1 -Portable
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Done!** FFmpeg will be downloaded automatically and VideoTools will be ready to run.
|
Or install Windows dependencies directly:
|
||||||
|
```powershell
|
||||||
|
.\scripts\install-deps-windows.ps1
|
||||||
|
```
|
||||||
|
|
||||||
4. **Launch VideoTools**:
|
3. **Run VideoTools**:
|
||||||
- Navigate to `dist/windows/`
|
```bash
|
||||||
- Double-click `VideoTools.exe`
|
./scripts/run.sh
|
||||||
|
```
|
||||||
|
|
||||||
### If You Need to Build
|
### If You Need to Build
|
||||||
|
|
||||||
|
|
@ -58,26 +60,14 @@ If `VideoTools.exe` doesn't exist yet:
|
||||||
cd VideoTools
|
cd VideoTools
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Install FFmpeg** (if not already installed):
|
2. **Install dependencies and build**:
|
||||||
```bash
|
```bash
|
||||||
# Fedora/RHEL
|
./scripts/install.sh
|
||||||
sudo dnf install ffmpeg
|
|
||||||
|
|
||||||
# Ubuntu/Debian
|
|
||||||
sudo apt install ffmpeg
|
|
||||||
|
|
||||||
# Arch Linux
|
|
||||||
sudo pacman -S ffmpeg
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Build VideoTools**:
|
3. **Run**:
|
||||||
```bash
|
```bash
|
||||||
./scripts/build.sh
|
./scripts/run.sh
|
||||||
```
|
|
||||||
|
|
||||||
4. **Run**:
|
|
||||||
```bash
|
|
||||||
./VideoTools
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Cross-Compile for Windows from Linux
|
### Cross-Compile for Windows from Linux
|
||||||
|
|
@ -107,21 +97,16 @@ sudo apt install gcc-mingw-w64 # Ubuntu/Debian
|
||||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Install FFmpeg**:
|
2. **Clone and install dependencies/build**:
|
||||||
```bash
|
|
||||||
brew install ffmpeg
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Clone and build**:
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repository-url>
|
git clone <repository-url>
|
||||||
cd VideoTools
|
cd VideoTools
|
||||||
go build -o VideoTools
|
./scripts/install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Run**:
|
3. **Run**:
|
||||||
```bash
|
```bash
|
||||||
./VideoTools
|
./scripts/run.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# VideoTools Documentation
|
# VideoTools Documentation
|
||||||
|
|
||||||
VideoTools is a professional-grade video processing suite with a modern GUI, currently on v0.1.0-dev18. It specializes in creating DVD-compliant videos for authoring and distribution.
|
VideoTools is a professional-grade video processing suite with a modern GUI, currently on v0.1.0-dev20. It specializes in creating DVD-compliant videos for authoring and distribution.
|
||||||
|
|
||||||
## Documentation Structure
|
## Documentation Structure
|
||||||
|
|
||||||
|
|
@ -18,7 +18,7 @@ VideoTools is a professional-grade video processing suite with a modern GUI, cur
|
||||||
- [Upscale](upscale/) - Resolution enhancement *(AI + traditional now wired)*
|
- [Upscale](upscale/) - Resolution enhancement *(AI + traditional now wired)*
|
||||||
- [Audio](audio/) - Audio track operations *(planned)*
|
- [Audio](audio/) - Audio track operations *(planned)*
|
||||||
- [Thumb](thumb/) - Thumbnail generation *(planned)*
|
- [Thumb](thumb/) - Thumbnail generation *(planned)*
|
||||||
- [Rip](rip/) - DVD/Blu-ray extraction *(planned)*
|
- [Rip](rip/) - DVD/ISO/VIDEO_TS extraction and conversion
|
||||||
|
|
||||||
### Additional Modules (Proposed)
|
### Additional Modules (Proposed)
|
||||||
- [Subtitle](subtitle/) - Subtitle management *(planned)*
|
- [Subtitle](subtitle/) - Subtitle management *(planned)*
|
||||||
|
|
@ -52,5 +52,4 @@ VideoTools is a professional-grade video processing suite with a modern GUI, cur
|
||||||
- [Module Feature Matrix](MODULES.md#module-coverage-summary)
|
- [Module Feature Matrix](MODULES.md#module-coverage-summary)
|
||||||
- [Latest Updates](../LATEST_UPDATES.md) - Recent development changes
|
- [Latest Updates](../LATEST_UPDATES.md) - Recent development changes
|
||||||
- [Windows Implementation](DEV14_WINDOWS_IMPLEMENTATION.md) - dev14 Windows support
|
- [Windows Implementation](DEV14_WINDOWS_IMPLEMENTATION.md) - dev14 Windows support
|
||||||
- [Modern Video Processing Strategy](HANDBRAKE_REPLACEMENT.md) - Next-generation video tools approach
|
|
||||||
- [VT_Player Integration](../VT_Player/README.md) - Frame-accurate playback system
|
- [VT_Player Integration](../VT_Player/README.md) - Frame-accurate playback system
|
||||||
|
|
|
||||||
39
docs/ROADMAP.md
Normal file
39
docs/ROADMAP.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# VideoTools Roadmap
|
||||||
|
|
||||||
|
This roadmap is intentionally lightweight. It captures the next few
|
||||||
|
high-priority goals without locking the project into a rigid plan.
|
||||||
|
|
||||||
|
## How We Use This
|
||||||
|
|
||||||
|
- The roadmap is a short list, not a full backlog.
|
||||||
|
- Items can move between buckets as priorities change.
|
||||||
|
- We update this at the start of each dev cycle.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
- dev20 focused on cleanup and the Authoring module.
|
||||||
|
- Authoring is now functional (DVD folders + ISO pipeline).
|
||||||
|
|
||||||
|
## Now (dev21 focus)
|
||||||
|
|
||||||
|
- Finalize Convert module cleanup and preset behavior.
|
||||||
|
- Validate preset defaults and edge cases (aspect, bitrate, CRF).
|
||||||
|
- Tighten UI copy and error messaging for Convert/Queue.
|
||||||
|
- Add smoke tests for authoring and DVD encode workflows.
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- Color space preservation across Convert/Upscale.
|
||||||
|
- Merge module completion (reorder, mixed format handling).
|
||||||
|
- Filters module polish (controls + real-time preview stability).
|
||||||
|
|
||||||
|
## Later
|
||||||
|
|
||||||
|
- Trim module UX and timeline tooling.
|
||||||
|
- AI frame interpolation support (model management + UI).
|
||||||
|
- Packaging polish for v0.1.1 (AppImage + Windows EXE).
|
||||||
|
|
||||||
|
## Versioning Note
|
||||||
|
|
||||||
|
We keep continuous dev numbering. After v0.1.1 release, the next dev
|
||||||
|
tag becomes v0.1.1-dev26 (or whatever the next number is).
|
||||||
|
|
@ -1,297 +1,48 @@
|
||||||
# Rip Module
|
# Rip Module
|
||||||
|
|
||||||
Extract and convert content from DVDs, Blu-rays, and disc images.
|
Extract and convert content from DVD folder structures and disc images.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The Rip module (formerly "Remux") handles extraction of video content from optical media and disc image files. It can rip directly from physical drives or work with ISO/IMG files, providing options for both lossless extraction and transcoding during the rip process.
|
The Rip module focuses on offline extraction from VIDEO_TS folders or DVD ISO images. It is designed to be fast and lossless by default, with optional H.264 transcodes when you want smaller files. All processing happens locally.
|
||||||
|
|
||||||
> **Note:** This module is currently in planning phase. Features described below are proposed functionality.
|
## Current Capabilities (dev20+)
|
||||||
|
|
||||||
## Features
|
### Supported Sources
|
||||||
|
- VIDEO_TS folders
|
||||||
|
- ISO images (requires `xorriso` or `bsdtar` to extract)
|
||||||
|
|
||||||
### Source Support
|
### Output Modes
|
||||||
|
- Lossless DVD -> MKV (stream copy, default)
|
||||||
|
- H.264 MKV (transcode)
|
||||||
|
- H.264 MP4 (transcode)
|
||||||
|
|
||||||
#### Physical Media
|
### Behavior Notes
|
||||||
- **DVD** - Standard DVDs with VOB structure
|
- Uses a queue job with progress and logs.
|
||||||
- **Blu-ray** - BD structure with M2TS files
|
- No online lookups or network calls.
|
||||||
- **CD** - Video CDs (VCD/SVCD)
|
- ISO extraction is performed to a temporary working folder before FFmpeg runs.
|
||||||
- Direct drive access for ripping
|
- Default output naming is based on the source name.
|
||||||
|
|
||||||
#### Disc Images
|
## Not Yet Implemented
|
||||||
- **ISO** - Standard disc image format
|
- Direct ripping from physical drives (DVD/Blu-ray)
|
||||||
- **IMG** - Raw disc images
|
- Multi-title selection from ISO contents
|
||||||
- **BIN/CUE** - CD image pairs
|
- Auto metadata lookup
|
||||||
- Mount and extract without burning
|
- Subtitle/audio track selection UI
|
||||||
|
|
||||||
### Title Selection
|
## Usage
|
||||||
|
|
||||||
#### Auto-Detection
|
1. Open the Rip module.
|
||||||
- Scan disc for all titles
|
2. Drag a VIDEO_TS folder or an ISO into the drop area.
|
||||||
- Identify main feature (longest title)
|
3. Choose the output mode (lossless MKV or H.264 MKV/MP4).
|
||||||
- List all extras/bonus content
|
4. Start the rip job and monitor the log/progress.
|
||||||
- Show duration and chapter count for each
|
|
||||||
|
|
||||||
#### Manual Selection
|
## Dependencies
|
||||||
- Preview titles before ripping
|
|
||||||
- Select multiple titles for batch rip
|
|
||||||
- Choose specific chapters from titles
|
|
||||||
- Merge chapters from different titles
|
|
||||||
|
|
||||||
### Track Management
|
- `ffmpeg`
|
||||||
|
- `xorriso` or `bsdtar` for ISO extraction
|
||||||
|
|
||||||
#### Video Tracks
|
## Example FFmpeg Flow (conceptual)
|
||||||
- Select video angle (for multi-angle DVDs)
|
|
||||||
- Choose video quality/stream
|
|
||||||
|
|
||||||
#### Audio Tracks
|
- VIDEO_TS: concatenate VOBs then stream copy to MKV.
|
||||||
- List all audio tracks with language
|
- ISO: extract VIDEO_TS from the ISO, then follow the same flow.
|
||||||
- Select which tracks to include
|
|
||||||
- Reorder track priority
|
|
||||||
- Convert audio format during rip
|
|
||||||
|
|
||||||
#### Subtitle Tracks
|
|
||||||
- List all subtitle languages
|
|
||||||
- Extract or burn subtitles
|
|
||||||
- Select multiple subtitle tracks
|
|
||||||
- Convert subtitle formats
|
|
||||||
|
|
||||||
### Rip Modes
|
|
||||||
|
|
||||||
#### Direct Copy (Lossless)
|
|
||||||
Fast extraction with no quality loss:
|
|
||||||
- Copy VOB → MKV/MP4 container
|
|
||||||
- No re-encoding
|
|
||||||
- Preserves original quality
|
|
||||||
- Fastest option
|
|
||||||
- Larger file sizes
|
|
||||||
|
|
||||||
#### Transcode
|
|
||||||
Convert during extraction:
|
|
||||||
- Choose output codec (H.264, H.265, etc.)
|
|
||||||
- Set quality/bitrate
|
|
||||||
- Resize if desired
|
|
||||||
- Compress to smaller file
|
|
||||||
- Slower but more flexible
|
|
||||||
|
|
||||||
#### Smart Mode
|
|
||||||
Automatically choose best approach:
|
|
||||||
- Copy if already efficient codec
|
|
||||||
- Transcode if old/inefficient codec
|
|
||||||
- Optimize settings for content type
|
|
||||||
|
|
||||||
### Copy Protection Handling
|
|
||||||
|
|
||||||
#### DVD CSS
|
|
||||||
- Use libdvdcss when available
|
|
||||||
- Automatic decryption during rip
|
|
||||||
- Legal for personal use (varies by region)
|
|
||||||
|
|
||||||
#### Blu-ray AACS
|
|
||||||
- Use libaacs for AACS decryption
|
|
||||||
- Support for BD+ (limited)
|
|
||||||
- Requires key database
|
|
||||||
|
|
||||||
#### Region Codes
|
|
||||||
- Detect region restrictions
|
|
||||||
- Handle multi-region discs
|
|
||||||
- RPC-1 drive support
|
|
||||||
|
|
||||||
### Quality Settings
|
|
||||||
|
|
||||||
#### Presets
|
|
||||||
- **Archival** - Lossless or very high quality
|
|
||||||
- **Standard** - Good quality, moderate size
|
|
||||||
- **Efficient** - Smaller files, acceptable quality
|
|
||||||
- **Custom** - User-defined settings
|
|
||||||
|
|
||||||
#### Special Handling
|
|
||||||
- Deinterlace DVD content automatically
|
|
||||||
- Inverse telecine for film sources
|
|
||||||
- Upscale SD content to HD (optional)
|
|
||||||
- HDR passthrough for Blu-ray
|
|
||||||
|
|
||||||
### Batch Processing
|
|
||||||
|
|
||||||
#### Multiple Titles
|
|
||||||
- Queue all titles from disc
|
|
||||||
- Process sequentially
|
|
||||||
- Different settings per title
|
|
||||||
- Automatic naming
|
|
||||||
|
|
||||||
#### Multiple Discs
|
|
||||||
- Load multiple ISO files
|
|
||||||
- Batch rip entire series
|
|
||||||
- Consistent settings across discs
|
|
||||||
- Progress tracking
|
|
||||||
|
|
||||||
### Output Options
|
|
||||||
|
|
||||||
#### Naming Templates
|
|
||||||
Automatic file naming:
|
|
||||||
```
|
|
||||||
{disc_name}_Title{title_num}_Chapter{start}-{end}
|
|
||||||
Star_Wars_Title01_Chapter01-25.mp4
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Metadata
|
|
||||||
- Auto-populate from disc info
|
|
||||||
- Lookup online databases (IMDB, TheTVDB)
|
|
||||||
- Chapter markers preserved
|
|
||||||
- Cover art extraction
|
|
||||||
|
|
||||||
#### Organization
|
|
||||||
- Create folder per disc
|
|
||||||
- Separate folders for extras
|
|
||||||
- Season/episode structure for TV
|
|
||||||
- Automatic file organization
|
|
||||||
|
|
||||||
## Usage Guide
|
|
||||||
|
|
||||||
### Ripping a DVD
|
|
||||||
|
|
||||||
1. **Insert Disc or Load ISO**
|
|
||||||
- Physical disc: Insert and click "Scan Drive"
|
|
||||||
- ISO file: Click "Load ISO" and select file
|
|
||||||
|
|
||||||
2. **Scan Disc**
|
|
||||||
- Application analyzes disc structure
|
|
||||||
- Lists all titles with duration/chapters
|
|
||||||
- Main feature highlighted
|
|
||||||
|
|
||||||
3. **Select Title(s)**
|
|
||||||
- Choose main feature or specific titles
|
|
||||||
- Select desired chapters
|
|
||||||
- Preview title information
|
|
||||||
|
|
||||||
4. **Configure Tracks**
|
|
||||||
- Select audio tracks (e.g., English 5.1)
|
|
||||||
- Choose subtitle tracks if desired
|
|
||||||
- Set track order/defaults
|
|
||||||
|
|
||||||
5. **Choose Rip Mode**
|
|
||||||
- Direct Copy for fastest/lossless
|
|
||||||
- Transcode to save space
|
|
||||||
- Configure quality settings
|
|
||||||
|
|
||||||
6. **Set Output**
|
|
||||||
- Choose output folder
|
|
||||||
- Set filename or use template
|
|
||||||
- Select container format
|
|
||||||
|
|
||||||
7. **Start Rip**
|
|
||||||
- Click "Start Ripping"
|
|
||||||
- Monitor progress
|
|
||||||
- Can queue multiple titles
|
|
||||||
|
|
||||||
### Ripping a Blu-ray
|
|
||||||
|
|
||||||
Similar to DVD but with additional considerations:
|
|
||||||
- Much larger files (20-40GB for feature)
|
|
||||||
- Better quality settings available
|
|
||||||
- HDR preservation options
|
|
||||||
- Multi-audio track handling
|
|
||||||
|
|
||||||
### Batch Ripping a TV Series
|
|
||||||
|
|
||||||
1. **Load all disc ISOs** for season
|
|
||||||
2. **Scan each disc** to identify episodes
|
|
||||||
3. **Enable batch mode**
|
|
||||||
4. **Configure naming** with episode numbers
|
|
||||||
5. **Set consistent quality** for all
|
|
||||||
6. **Start batch rip**
|
|
||||||
|
|
||||||
## FFmpeg Integration
|
|
||||||
|
|
||||||
### Direct Copy Example
|
|
||||||
```bash
|
|
||||||
# Extract VOB to MKV without re-encoding
|
|
||||||
ffmpeg -i /dev/dvd -map 0 -c copy output.mkv
|
|
||||||
|
|
||||||
# Extract specific title
|
|
||||||
ffmpeg -i dvd://1 -map 0 -c copy title_01.mkv
|
|
||||||
```
|
|
||||||
|
|
||||||
### Transcode Example
|
|
||||||
```bash
|
|
||||||
# Rip DVD with H.264 encoding
|
|
||||||
ffmpeg -i dvd://1 \
|
|
||||||
-vf yadif,scale=720:480 \
|
|
||||||
-c:v libx264 -crf 20 \
|
|
||||||
-c:a aac -b:a 192k \
|
|
||||||
output.mp4
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multi-Track Example
|
|
||||||
```bash
|
|
||||||
# Preserve multiple audio and subtitle tracks
|
|
||||||
ffmpeg -i dvd://1 \
|
|
||||||
-map 0:v:0 \
|
|
||||||
-map 0:a:0 -map 0:a:1 \
|
|
||||||
-map 0:s:0 -map 0:s:1 \
|
|
||||||
-c copy output.mkv
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tips & Best Practices
|
|
||||||
|
|
||||||
### DVD Quality
|
|
||||||
- Original DVD is 720×480 (NTSC) or 720×576 (PAL)
|
|
||||||
- Always deinterlace DVD content
|
|
||||||
- Consider upscaling to 1080p for modern displays
|
|
||||||
- Use inverse telecine for film sources (24fps)
|
|
||||||
|
|
||||||
### Blu-ray Handling
|
|
||||||
- Main feature typically 20-50GB
|
|
||||||
- Consider transcoding to H.265 to reduce size
|
|
||||||
- Preserve 1080p resolution
|
|
||||||
- Keep high bitrate audio (DTS-HD, TrueHD)
|
|
||||||
|
|
||||||
### File Size Management
|
|
||||||
| Source | Direct Copy | H.264 CRF 20 | H.265 CRF 24 |
|
|
||||||
|--------|-------------|--------------|--------------|
|
|
||||||
| DVD (2hr) | 4-8 GB | 2-4 GB | 1-3 GB |
|
|
||||||
| Blu-ray (2hr) | 30-50 GB | 6-10 GB | 4-6 GB |
|
|
||||||
|
|
||||||
### Legal Considerations
|
|
||||||
- Ripping for personal backup is legal in many regions
|
|
||||||
- Circumventing copy protection may have legal restrictions
|
|
||||||
- Distribution of ripped content is typically illegal
|
|
||||||
- Check local laws and regulations
|
|
||||||
|
|
||||||
### Drive Requirements
|
|
||||||
- DVD-ROM drive for DVD ripping
|
|
||||||
- Blu-ray drive for Blu-ray ripping (obviously)
|
|
||||||
- RPC-1 (region-free) firmware helpful
|
|
||||||
- External drives work fine
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Can't Read Disc
|
|
||||||
- Clean disc surface
|
|
||||||
- Try different drive
|
|
||||||
- Check drive region code
|
|
||||||
- Verify disc isn't damaged
|
|
||||||
|
|
||||||
### Copy Protection Errors
|
|
||||||
- Install libdvdcss (DVD) or libaacs (Blu-ray)
|
|
||||||
- Update key database
|
|
||||||
- Check disc region compatibility
|
|
||||||
- Try different disc copy
|
|
||||||
|
|
||||||
### Slow Ripping
|
|
||||||
- Direct copy is fastest
|
|
||||||
- Transcoding is CPU-intensive
|
|
||||||
- Use hardware acceleration if available
|
|
||||||
- Check drive speed settings
|
|
||||||
|
|
||||||
### Audio/Video Sync Issues
|
|
||||||
- Common with VFR content
|
|
||||||
- Use -vsync parameter
|
|
||||||
- Force constant frame rate
|
|
||||||
- Check source for corruption
|
|
||||||
|
|
||||||
## See Also
|
|
||||||
- [Convert Module](../convert/) - Transcode ripped files further
|
|
||||||
- [Streams Module](../streams/) - Manage multi-track ripped files
|
|
||||||
- [Subtitle Module](../subtitle/) - Handle extracted subtitle tracks
|
|
||||||
- [Inspect Module](../inspect/) - Analyze ripped output quality
|
|
||||||
|
|
|
||||||
262
filters_module.go
Normal file
262
filters_module.go
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/dialog"
|
||||||
|
"fyne.io/fyne/v2/layout"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *appState) showFiltersView() {
|
||||||
|
s.stopPreview()
|
||||||
|
s.lastModule = s.active
|
||||||
|
s.active = "filters"
|
||||||
|
s.setContent(buildFiltersView(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildFiltersView(state *appState) fyne.CanvasObject {
|
||||||
|
filtersColor := moduleColor("filters")
|
||||||
|
|
||||||
|
// Back button
|
||||||
|
backBtn := widget.NewButton("< FILTERS", func() {
|
||||||
|
state.showMainMenu()
|
||||||
|
})
|
||||||
|
backBtn.Importance = widget.LowImportance
|
||||||
|
|
||||||
|
// Queue button
|
||||||
|
queueBtn := widget.NewButton("View Queue", func() {
|
||||||
|
state.showQueue()
|
||||||
|
})
|
||||||
|
state.queueBtn = queueBtn
|
||||||
|
state.updateQueueButtonLabel()
|
||||||
|
|
||||||
|
clearCompletedBtn := widget.NewButton("⌫", func() {
|
||||||
|
state.clearCompletedJobs()
|
||||||
|
})
|
||||||
|
clearCompletedBtn.Importance = widget.LowImportance
|
||||||
|
|
||||||
|
// Top bar with module color
|
||||||
|
topBar := ui.TintedBar(filtersColor, container.NewHBox(backBtn, layout.NewSpacer(), clearCompletedBtn, queueBtn))
|
||||||
|
bottomBar := moduleFooter(filtersColor, layout.NewSpacer(), state.statsBar)
|
||||||
|
|
||||||
|
// Instructions
|
||||||
|
instructions := widget.NewLabel("Apply filters and color corrections to your video. Preview changes in real-time.")
|
||||||
|
instructions.Wrapping = fyne.TextWrapWord
|
||||||
|
instructions.Alignment = fyne.TextAlignCenter
|
||||||
|
|
||||||
|
// Initialize state defaults
|
||||||
|
if state.filterBrightness == 0 && state.filterContrast == 0 && state.filterSaturation == 0 {
|
||||||
|
state.filterBrightness = 0.0 // -1.0 to 1.0
|
||||||
|
state.filterContrast = 1.0 // 0.0 to 3.0
|
||||||
|
state.filterSaturation = 1.0 // 0.0 to 3.0
|
||||||
|
state.filterSharpness = 0.0 // 0.0 to 5.0
|
||||||
|
state.filterDenoise = 0.0 // 0.0 to 10.0
|
||||||
|
}
|
||||||
|
if state.filterInterpPreset == "" {
|
||||||
|
state.filterInterpPreset = "Balanced"
|
||||||
|
}
|
||||||
|
if state.filterInterpFPS == "" {
|
||||||
|
state.filterInterpFPS = "60"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFilterChain := func() {
|
||||||
|
var chain []string
|
||||||
|
if state.filterInterpEnabled {
|
||||||
|
fps := state.filterInterpFPS
|
||||||
|
if fps == "" {
|
||||||
|
fps = "60"
|
||||||
|
}
|
||||||
|
var filter string
|
||||||
|
switch state.filterInterpPreset {
|
||||||
|
case "Ultra Fast":
|
||||||
|
filter = fmt.Sprintf("minterpolate=fps=%s:mi_mode=blend", fps)
|
||||||
|
case "Fast":
|
||||||
|
filter = fmt.Sprintf("minterpolate=fps=%s:mi_mode=duplicate", fps)
|
||||||
|
case "High Quality":
|
||||||
|
filter = fmt.Sprintf("minterpolate=fps=%s:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1:search_param=32", fps)
|
||||||
|
case "Maximum Quality":
|
||||||
|
filter = fmt.Sprintf("minterpolate=fps=%s:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1:search_param=64", fps)
|
||||||
|
default: // Balanced
|
||||||
|
filter = fmt.Sprintf("minterpolate=fps=%s:mi_mode=mci:mc_mode=obmc:me_mode=bidir:me=epzs:search_param=16:vsbmc=0", fps)
|
||||||
|
}
|
||||||
|
chain = append(chain, filter)
|
||||||
|
}
|
||||||
|
state.filterActiveChain = chain
|
||||||
|
}
|
||||||
|
|
||||||
|
// File label
|
||||||
|
fileLabel := widget.NewLabel("No file loaded")
|
||||||
|
fileLabel.TextStyle = fyne.TextStyle{Bold: true}
|
||||||
|
|
||||||
|
var videoContainer fyne.CanvasObject
|
||||||
|
if state.filtersFile != nil {
|
||||||
|
fileLabel.SetText(fmt.Sprintf("File: %s", filepath.Base(state.filtersFile.Path)))
|
||||||
|
videoContainer = buildVideoPane(state, fyne.NewSize(480, 270), state.filtersFile, nil)
|
||||||
|
} else {
|
||||||
|
videoContainer = container.NewCenter(widget.NewLabel("No video loaded"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load button
|
||||||
|
loadBtn := widget.NewButton("Load Video", func() {
|
||||||
|
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
|
||||||
|
if err != nil || reader == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
path := reader.URI().Path()
|
||||||
|
go func() {
|
||||||
|
src, err := probeVideo(path)
|
||||||
|
if err != nil {
|
||||||
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
||||||
|
dialog.ShowError(err, state.window)
|
||||||
|
}, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
||||||
|
state.filtersFile = src
|
||||||
|
state.showFiltersView()
|
||||||
|
}, false)
|
||||||
|
}()
|
||||||
|
}, state.window)
|
||||||
|
})
|
||||||
|
loadBtn.Importance = widget.HighImportance
|
||||||
|
|
||||||
|
// Navigation to Upscale module
|
||||||
|
upscaleNavBtn := widget.NewButton("Send to Upscale →", func() {
|
||||||
|
if state.filtersFile != nil {
|
||||||
|
state.upscaleFile = state.filtersFile
|
||||||
|
buildFilterChain()
|
||||||
|
state.upscaleFilterChain = append([]string{}, state.filterActiveChain...)
|
||||||
|
}
|
||||||
|
state.showUpscaleView()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Color Correction Section
|
||||||
|
colorSection := widget.NewCard("Color Correction", "", container.NewVBox(
|
||||||
|
widget.NewLabel("Adjust brightness, contrast, and saturation"),
|
||||||
|
container.NewGridWithColumns(2,
|
||||||
|
widget.NewLabel("Brightness:"),
|
||||||
|
widget.NewSlider(-1.0, 1.0),
|
||||||
|
widget.NewLabel("Contrast:"),
|
||||||
|
widget.NewSlider(0.0, 3.0),
|
||||||
|
widget.NewLabel("Saturation:"),
|
||||||
|
widget.NewSlider(0.0, 3.0),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
// Enhancement Section
|
||||||
|
enhanceSection := widget.NewCard("Enhancement", "", container.NewVBox(
|
||||||
|
widget.NewLabel("Sharpen, blur, and denoise"),
|
||||||
|
container.NewGridWithColumns(2,
|
||||||
|
widget.NewLabel("Sharpness:"),
|
||||||
|
widget.NewSlider(0.0, 5.0),
|
||||||
|
widget.NewLabel("Denoise:"),
|
||||||
|
widget.NewSlider(0.0, 10.0),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
// Transform Section
|
||||||
|
transformSection := widget.NewCard("Transform", "", container.NewVBox(
|
||||||
|
widget.NewLabel("Rotate and flip video"),
|
||||||
|
container.NewGridWithColumns(2,
|
||||||
|
widget.NewLabel("Rotation:"),
|
||||||
|
widget.NewSelect([]string{"0°", "90°", "180°", "270°"}, func(s string) {}),
|
||||||
|
widget.NewLabel("Flip Horizontal:"),
|
||||||
|
widget.NewCheck("", func(b bool) { state.filterFlipH = b }),
|
||||||
|
widget.NewLabel("Flip Vertical:"),
|
||||||
|
widget.NewCheck("", func(b bool) { state.filterFlipV = b }),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
// Creative Effects Section
|
||||||
|
creativeSection := widget.NewCard("Creative Effects", "", container.NewVBox(
|
||||||
|
widget.NewLabel("Apply artistic effects"),
|
||||||
|
widget.NewCheck("Grayscale", func(b bool) { state.filterGrayscale = b }),
|
||||||
|
))
|
||||||
|
|
||||||
|
// Frame Interpolation Section
|
||||||
|
interpEnabledCheck := widget.NewCheck("Enable Frame Interpolation", func(checked bool) {
|
||||||
|
state.filterInterpEnabled = checked
|
||||||
|
buildFilterChain()
|
||||||
|
})
|
||||||
|
interpEnabledCheck.SetChecked(state.filterInterpEnabled)
|
||||||
|
|
||||||
|
interpPresetSelect := widget.NewSelect([]string{"Ultra Fast", "Fast", "Balanced", "High Quality", "Maximum Quality"}, func(val string) {
|
||||||
|
state.filterInterpPreset = val
|
||||||
|
buildFilterChain()
|
||||||
|
})
|
||||||
|
interpPresetSelect.SetSelected(state.filterInterpPreset)
|
||||||
|
|
||||||
|
interpFPSSelect := widget.NewSelect([]string{"24", "30", "50", "59.94", "60"}, func(val string) {
|
||||||
|
state.filterInterpFPS = val
|
||||||
|
buildFilterChain()
|
||||||
|
})
|
||||||
|
interpFPSSelect.SetSelected(state.filterInterpFPS)
|
||||||
|
|
||||||
|
interpHint := widget.NewLabel("Balanced preset is recommended; higher presets are CPU-intensive.")
|
||||||
|
interpHint.TextStyle = fyne.TextStyle{Italic: true}
|
||||||
|
interpHint.Wrapping = fyne.TextWrapWord
|
||||||
|
|
||||||
|
interpSection := widget.NewCard("Frame Interpolation (Minterpolate)", "", container.NewVBox(
|
||||||
|
widget.NewLabel("Generate smoother motion by interpolating new frames"),
|
||||||
|
interpEnabledCheck,
|
||||||
|
container.NewGridWithColumns(2,
|
||||||
|
widget.NewLabel("Preset:"),
|
||||||
|
interpPresetSelect,
|
||||||
|
widget.NewLabel("Target FPS:"),
|
||||||
|
interpFPSSelect,
|
||||||
|
),
|
||||||
|
interpHint,
|
||||||
|
))
|
||||||
|
buildFilterChain()
|
||||||
|
|
||||||
|
// Apply button
|
||||||
|
applyBtn := widget.NewButton("Apply Filters", func() {
|
||||||
|
if state.filtersFile == nil {
|
||||||
|
dialog.ShowInformation("No Video", "Please load a video first.", state.window)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buildFilterChain()
|
||||||
|
dialog.ShowInformation("Filters", "Filters are now configured and will be applied when sent to Upscale.", state.window)
|
||||||
|
})
|
||||||
|
applyBtn.Importance = widget.HighImportance
|
||||||
|
|
||||||
|
// Main content
|
||||||
|
leftPanel := container.NewVBox(
|
||||||
|
instructions,
|
||||||
|
widget.NewSeparator(),
|
||||||
|
fileLabel,
|
||||||
|
loadBtn,
|
||||||
|
upscaleNavBtn,
|
||||||
|
)
|
||||||
|
|
||||||
|
settingsPanel := container.NewVBox(
|
||||||
|
colorSection,
|
||||||
|
enhanceSection,
|
||||||
|
transformSection,
|
||||||
|
interpSection,
|
||||||
|
creativeSection,
|
||||||
|
applyBtn,
|
||||||
|
)
|
||||||
|
|
||||||
|
settingsScroll := container.NewVScroll(settingsPanel)
|
||||||
|
// Adaptive height for small screens - allow content to flow
|
||||||
|
settingsScroll.SetMinSize(fyne.NewSize(350, 400))
|
||||||
|
|
||||||
|
mainContent := container.New(&fixedHSplitLayout{ratio: 0.6},
|
||||||
|
container.NewVBox(leftPanel, container.NewCenter(videoContainer)),
|
||||||
|
settingsScroll,
|
||||||
|
)
|
||||||
|
|
||||||
|
content := container.NewPadded(mainContent)
|
||||||
|
|
||||||
|
return container.NewBorder(topBar, bottomBar, nil, nil, content)
|
||||||
|
}
|
||||||
298
inspect_module.go
Normal file
298
inspect_module.go
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/dialog"
|
||||||
|
"fyne.io/fyne/v2/layout"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/interlace"
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/logging"
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/ui"
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *appState) showInspectView() {
|
||||||
|
s.stopPreview()
|
||||||
|
s.lastModule = s.active
|
||||||
|
s.active = "inspect"
|
||||||
|
s.setContent(buildInspectView(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
queueBtn := widget.NewButton("View Queue", func() {
|
||||||
|
state.showQueue()
|
||||||
|
})
|
||||||
|
state.queueBtn = queueBtn
|
||||||
|
state.updateQueueButtonLabel()
|
||||||
|
|
||||||
|
clearCompletedBtn := widget.NewButton("⌫", func() {
|
||||||
|
state.clearCompletedJobs()
|
||||||
|
})
|
||||||
|
clearCompletedBtn.Importance = widget.LowImportance
|
||||||
|
|
||||||
|
topBar := ui.TintedBar(inspectColor, container.NewHBox(backBtn, layout.NewSpacer(), clearCompletedBtn, queueBtn))
|
||||||
|
bottomBar := moduleFooter(inspectColor, layout.NewSpacer(), state.statsBar)
|
||||||
|
|
||||||
|
// 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, nil, 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 {
|
||||||
|
fileSize = utils.FormatBytes(fi.Size())
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := fmt.Sprintf(
|
||||||
|
"━━━ FILE INFO ━━━\n"+
|
||||||
|
"Path: %s\n"+
|
||||||
|
"File Size: %s\n"+
|
||||||
|
"Format Family: %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,
|
||||||
|
formatBitrateFull(src.Bitrate),
|
||||||
|
src.PixelFormat,
|
||||||
|
src.ColorSpace,
|
||||||
|
src.ColorRange,
|
||||||
|
src.FieldOrder,
|
||||||
|
src.GOPSize,
|
||||||
|
src.AudioCodec,
|
||||||
|
formatBitrateFull(src.AudioBitrate),
|
||||||
|
src.AudioRate,
|
||||||
|
src.Channels,
|
||||||
|
src.DurationString(),
|
||||||
|
src.SampleAspectRatio,
|
||||||
|
src.HasChapters,
|
||||||
|
src.HasMetadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add interlacing detection results if available
|
||||||
|
if state.inspectInterlaceAnalyzing {
|
||||||
|
metadata += "\n\n━━━ INTERLACING DETECTION ━━━\n"
|
||||||
|
metadata += "Analyzing... (first 500 frames)"
|
||||||
|
} else if state.inspectInterlaceResult != nil {
|
||||||
|
result := state.inspectInterlaceResult
|
||||||
|
metadata += "\n\n━━━ INTERLACING DETECTION ━━━\n"
|
||||||
|
metadata += fmt.Sprintf("Status: %s\n", result.Status)
|
||||||
|
metadata += fmt.Sprintf("Interlaced Frames: %.1f%%\n", result.InterlacedPercent)
|
||||||
|
metadata += fmt.Sprintf("Field Order: %s\n", result.FieldOrder)
|
||||||
|
metadata += fmt.Sprintf("Confidence: %s\n", result.Confidence)
|
||||||
|
metadata += fmt.Sprintf("Recommendation: %s\n", result.Recommendation)
|
||||||
|
metadata += fmt.Sprintf("\nFrame Counts:\n")
|
||||||
|
metadata += fmt.Sprintf(" Progressive: %d\n", result.Progressive)
|
||||||
|
metadata += fmt.Sprintf(" Top Field First: %d\n", result.TFF)
|
||||||
|
metadata += fmt.Sprintf(" Bottom Field First: %d\n", result.BFF)
|
||||||
|
metadata += fmt.Sprintf(" Undetermined: %d\n", result.Undetermined)
|
||||||
|
metadata += fmt.Sprintf(" Total Analyzed: %d", result.TotalFrames)
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(480, 270), 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.inspectInterlaceResult = nil
|
||||||
|
state.inspectInterlaceAnalyzing = true
|
||||||
|
state.showInspectView()
|
||||||
|
logging.Debug(logging.CatModule, "loaded inspect file: %s", path)
|
||||||
|
|
||||||
|
// Auto-run interlacing detection in background
|
||||||
|
go func() {
|
||||||
|
detector := interlace.NewDetector(platformConfig.FFmpegPath, platformConfig.FFprobePath)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result, err := detector.QuickAnalyze(ctx, path)
|
||||||
|
|
||||||
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
||||||
|
state.inspectInterlaceAnalyzing = false
|
||||||
|
if err != nil {
|
||||||
|
logging.Debug(logging.CatSystem, "auto interlacing analysis failed: %v", err)
|
||||||
|
state.inspectInterlaceResult = nil
|
||||||
|
} else {
|
||||||
|
state.inspectInterlaceResult = result
|
||||||
|
logging.Debug(logging.CatSystem, "auto interlacing analysis complete: %s", result.Status)
|
||||||
|
}
|
||||||
|
state.showInspectView() // Refresh to show results
|
||||||
|
}, false)
|
||||||
|
}()
|
||||||
|
}, 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
|
||||||
|
|
||||||
|
logPath := ""
|
||||||
|
if state.inspectFile != nil {
|
||||||
|
base := strings.TrimSuffix(filepath.Base(state.inspectFile.Path), filepath.Ext(state.inspectFile.Path))
|
||||||
|
p := filepath.Join(getLogsDir(), base+conversionLogSuffix)
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
logPath = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewLogBtn := widget.NewButton("View Conversion Log", func() {
|
||||||
|
if logPath == "" {
|
||||||
|
dialog.ShowInformation("No Log", "No conversion log found for this file.", state.window)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.openLogViewer("Conversion Log", logPath, false)
|
||||||
|
})
|
||||||
|
viewLogBtn.Importance = widget.LowImportance
|
||||||
|
if logPath == "" {
|
||||||
|
viewLogBtn.Disable()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
actionButtons := container.NewHBox(loadBtn, copyBtn, viewLogBtn, 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 = moduleFooter(inspectColor, layout.NewSpacer(), state.statsBar)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
187
install.sh
187
install.sh
|
|
@ -1,187 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
BLUE='\033[0;34m'
|
|
||||||
CYAN='\033[0;36m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
# Spinner function
|
|
||||||
spinner() {
|
|
||||||
local pid=$1
|
|
||||||
local task=$2
|
|
||||||
local spin='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
|
|
||||||
local i=0
|
|
||||||
|
|
||||||
while kill -0 $pid 2>/dev/null; do
|
|
||||||
i=$(( (i+1) %10 ))
|
|
||||||
printf "\r${BLUE}${spin:$i:1}${NC} %s..." "$task"
|
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
printf "\r"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
BINARY_NAME="VideoTools"
|
|
||||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
DEFAULT_INSTALL_PATH="/usr/local/bin"
|
|
||||||
USER_INSTALL_PATH="$HOME/.local/bin"
|
|
||||||
|
|
||||||
echo "════════════════════════════════════════════════════════════════"
|
|
||||||
echo " VideoTools Professional Installation"
|
|
||||||
echo "════════════════════════════════════════════════════════════════"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Step 1: Check if Go is installed
|
|
||||||
echo -e "${CYAN}[1/5]${NC} Checking Go installation..."
|
|
||||||
if ! command -v go &> /dev/null; then
|
|
||||||
echo -e "${RED}✗ Error: Go is not installed or not in PATH${NC}"
|
|
||||||
echo "Please install Go 1.21+ from https://go.dev/dl/"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
GO_VERSION=$(go version | awk '{print $3}' | sed 's/go//')
|
|
||||||
echo -e "${GREEN}✓${NC} Found Go version: $GO_VERSION"
|
|
||||||
|
|
||||||
# Step 2: Build the binary
|
|
||||||
echo ""
|
|
||||||
echo -e "${CYAN}[2/5]${NC} Building VideoTools..."
|
|
||||||
cd "$PROJECT_ROOT"
|
|
||||||
CGO_ENABLED=1 go build -o "$BINARY_NAME" . > /tmp/videotools-build.log 2>&1 &
|
|
||||||
BUILD_PID=$!
|
|
||||||
spinner $BUILD_PID "Building $BINARY_NAME"
|
|
||||||
|
|
||||||
if wait $BUILD_PID; then
|
|
||||||
echo -e "${GREEN}✓${NC} Build successful"
|
|
||||||
else
|
|
||||||
echo -e "${RED}✗ Build failed${NC}"
|
|
||||||
echo ""
|
|
||||||
echo "Build log:"
|
|
||||||
cat /tmp/videotools-build.log
|
|
||||||
rm -f /tmp/videotools-build.log
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
rm -f /tmp/videotools-build.log
|
|
||||||
|
|
||||||
# Step 3: Determine installation path
|
|
||||||
echo ""
|
|
||||||
echo -e "${CYAN}[3/5]${NC} Installation path selection"
|
|
||||||
echo ""
|
|
||||||
echo "Where would you like to install $BINARY_NAME?"
|
|
||||||
echo " 1) System-wide (/usr/local/bin) - requires sudo, available to all users"
|
|
||||||
echo " 2) User-local (~/.local/bin) - no sudo needed, available only to you"
|
|
||||||
echo ""
|
|
||||||
read -p "Enter choice [1 or 2, default 2]: " choice
|
|
||||||
choice=${choice:-2}
|
|
||||||
|
|
||||||
case $choice in
|
|
||||||
1)
|
|
||||||
INSTALL_PATH="$DEFAULT_INSTALL_PATH"
|
|
||||||
NEEDS_SUDO=true
|
|
||||||
;;
|
|
||||||
2)
|
|
||||||
INSTALL_PATH="$USER_INSTALL_PATH"
|
|
||||||
NEEDS_SUDO=false
|
|
||||||
mkdir -p "$INSTALL_PATH"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo -e "${RED}✗ Invalid choice. Exiting.${NC}"
|
|
||||||
rm -f "$BINARY_NAME"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Step 4: Install the binary
|
|
||||||
echo ""
|
|
||||||
echo -e "${CYAN}[4/5]${NC} Installing binary to $INSTALL_PATH..."
|
|
||||||
if [ "$NEEDS_SUDO" = true ]; then
|
|
||||||
sudo install -m 755 "$BINARY_NAME" "$INSTALL_PATH/$BINARY_NAME" > /dev/null 2>&1 &
|
|
||||||
INSTALL_PID=$!
|
|
||||||
spinner $INSTALL_PID "Installing $BINARY_NAME"
|
|
||||||
|
|
||||||
if wait $INSTALL_PID; then
|
|
||||||
echo -e "${GREEN}✓${NC} Installation successful"
|
|
||||||
else
|
|
||||||
echo -e "${RED}✗ Installation failed${NC}"
|
|
||||||
rm -f "$BINARY_NAME"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
install -m 755 "$BINARY_NAME" "$INSTALL_PATH/$BINARY_NAME" > /dev/null 2>&1 &
|
|
||||||
INSTALL_PID=$!
|
|
||||||
spinner $INSTALL_PID "Installing $BINARY_NAME"
|
|
||||||
|
|
||||||
if wait $INSTALL_PID; then
|
|
||||||
echo -e "${GREEN}✓${NC} Installation successful"
|
|
||||||
else
|
|
||||||
echo -e "${RED}✗ Installation failed${NC}"
|
|
||||||
rm -f "$BINARY_NAME"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -f "$BINARY_NAME"
|
|
||||||
|
|
||||||
# Step 5: Setup shell aliases and environment
|
|
||||||
echo ""
|
|
||||||
echo -e "${CYAN}[5/5]${NC} Setting up shell environment..."
|
|
||||||
|
|
||||||
# Detect shell
|
|
||||||
if [ -n "$ZSH_VERSION" ]; then
|
|
||||||
SHELL_RC="$HOME/.zshrc"
|
|
||||||
SHELL_NAME="zsh"
|
|
||||||
elif [ -n "$BASH_VERSION" ]; then
|
|
||||||
SHELL_RC="$HOME/.bashrc"
|
|
||||||
SHELL_NAME="bash"
|
|
||||||
else
|
|
||||||
# Default to bash
|
|
||||||
SHELL_RC="$HOME/.bashrc"
|
|
||||||
SHELL_NAME="bash"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create alias setup script
|
|
||||||
ALIAS_SCRIPT="$PROJECT_ROOT/scripts/alias.sh"
|
|
||||||
|
|
||||||
# Add installation path to PATH if needed
|
|
||||||
if [[ ":$PATH:" != *":$INSTALL_PATH:"* ]]; then
|
|
||||||
# Check if PATH export already exists
|
|
||||||
if ! grep -q "export PATH.*$INSTALL_PATH" "$SHELL_RC" 2>/dev/null; then
|
|
||||||
echo "" >> "$SHELL_RC"
|
|
||||||
echo "# VideoTools installation path" >> "$SHELL_RC"
|
|
||||||
echo "export PATH=\"$INSTALL_PATH:\$PATH\"" >> "$SHELL_RC"
|
|
||||||
echo -e "${GREEN}✓${NC} Added $INSTALL_PATH to PATH in $SHELL_RC"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Add alias sourcing if not already present
|
|
||||||
if ! grep -q "source.*alias.sh" "$SHELL_RC" 2>/dev/null; then
|
|
||||||
echo "" >> "$SHELL_RC"
|
|
||||||
echo "# VideoTools convenience aliases" >> "$SHELL_RC"
|
|
||||||
echo "source \"$ALIAS_SCRIPT\"" >> "$SHELL_RC"
|
|
||||||
echo -e "${GREEN}✓${NC} Added VideoTools aliases to $SHELL_RC"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "════════════════════════════════════════════════════════════════"
|
|
||||||
echo -e "${GREEN}Installation Complete!${NC}"
|
|
||||||
echo "════════════════════════════════════════════════════════════════"
|
|
||||||
echo ""
|
|
||||||
echo "Next steps:"
|
|
||||||
echo ""
|
|
||||||
echo "1. ${CYAN}Reload your shell configuration:${NC}"
|
|
||||||
echo " source $SHELL_RC"
|
|
||||||
echo ""
|
|
||||||
echo "2. ${CYAN}Run VideoTools:${NC}"
|
|
||||||
echo " VideoTools"
|
|
||||||
echo ""
|
|
||||||
echo "3. ${CYAN}Available commands:${NC}"
|
|
||||||
echo " • VideoTools - Run the application"
|
|
||||||
echo " • VideoToolsRebuild - Force rebuild from source"
|
|
||||||
echo " • VideoToolsClean - Clean build artifacts and cache"
|
|
||||||
echo ""
|
|
||||||
echo "For more information, see BUILD_AND_RUN.md and DVD_USER_GUIDE.md"
|
|
||||||
echo ""
|
|
||||||
|
|
@ -20,11 +20,11 @@ func NewDVDConfig() *DVDConvertConfig {
|
||||||
func (d *DVDConvertConfig) GetFFmpegArgs(inputPath, outputPath string, videoWidth, videoHeight int, videoFramerate float64, audioSampleRate int, isProgressive bool) []string {
|
func (d *DVDConvertConfig) GetFFmpegArgs(inputPath, outputPath string, videoWidth, videoHeight int, videoFramerate float64, audioSampleRate int, isProgressive bool) []string {
|
||||||
// Create a minimal videoSource for passing to BuildDVDFFmpegArgs
|
// Create a minimal videoSource for passing to BuildDVDFFmpegArgs
|
||||||
tempSrc := &convert.VideoSource{
|
tempSrc := &convert.VideoSource{
|
||||||
Width: videoWidth,
|
Width: videoWidth,
|
||||||
Height: videoHeight,
|
Height: videoHeight,
|
||||||
FrameRate: videoFramerate,
|
FrameRate: videoFramerate,
|
||||||
AudioRate: audioSampleRate,
|
AudioRate: audioSampleRate,
|
||||||
FieldOrder: fieldOrderFromProgressive(isProgressive),
|
FieldOrder: fieldOrderFromProgressive(isProgressive),
|
||||||
}
|
}
|
||||||
|
|
||||||
return convert.BuildDVDFFmpegArgs(inputPath, outputPath, d.cfg, tempSrc)
|
return convert.BuildDVDFFmpegArgs(inputPath, outputPath, d.cfg, tempSrc)
|
||||||
|
|
@ -59,16 +59,16 @@ func fieldOrderFromProgressive(isProgressive bool) string {
|
||||||
|
|
||||||
// DVDPresetInfo provides information about DVD-NTSC capability
|
// DVDPresetInfo provides information about DVD-NTSC capability
|
||||||
type DVDPresetInfo struct {
|
type DVDPresetInfo struct {
|
||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
VideoCodec string
|
VideoCodec string
|
||||||
AudioCodec string
|
AudioCodec string
|
||||||
Container string
|
Container string
|
||||||
Resolution string
|
Resolution string
|
||||||
FrameRate string
|
FrameRate string
|
||||||
DefaultBitrate string
|
DefaultBitrate string
|
||||||
MaxBitrate string
|
MaxBitrate string
|
||||||
Features []string
|
Features []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDVDPresetInfo returns detailed information about the DVD-NTSC preset
|
// GetDVDPresetInfo returns detailed information about the DVD-NTSC preset
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import (
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Result stores the outcome of a single encoder benchmark test
|
// Result stores the outcome of a single encoder benchmark test
|
||||||
|
|
@ -60,6 +62,7 @@ func (s *Suite) GenerateTestVideo(ctx context.Context, duration int) (string, er
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, s.FFmpegPath, args...)
|
cmd := exec.CommandContext(ctx, s.FFmpegPath, args...)
|
||||||
|
utils.ApplyNoWindow(cmd) // Hide command window on Windows during benchmark test video generation
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return "", fmt.Errorf("failed to generate test video: %w", err)
|
return "", fmt.Errorf("failed to generate test video: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -131,6 +134,7 @@ func (s *Suite) TestEncoder(ctx context.Context, encoder, preset string) Result
|
||||||
// Measure encoding time
|
// Measure encoding time
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
cmd := exec.CommandContext(ctx, s.FFmpegPath, args...)
|
cmd := exec.CommandContext(ctx, s.FFmpegPath, args...)
|
||||||
|
utils.ApplyNoWindow(cmd) // Hide command window on Windows during benchmark encoding test
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
result.Error = fmt.Sprintf("encoding failed: %v", err)
|
result.Error = fmt.Sprintf("encoding failed: %v", err)
|
||||||
|
|
|
||||||
|
|
@ -206,8 +206,8 @@ func normalizeFrameRate(rate float64) string {
|
||||||
}
|
}
|
||||||
// Check for common framerates with tolerance
|
// Check for common framerates with tolerance
|
||||||
checks := []struct {
|
checks := []struct {
|
||||||
name string
|
name string
|
||||||
min, max float64
|
min, max float64
|
||||||
}{
|
}{
|
||||||
{"23.976", 23.9, 24.0},
|
{"23.976", 23.9, 24.0},
|
||||||
{"24.0", 23.99, 24.01},
|
{"24.0", 23.99, 24.01},
|
||||||
|
|
|
||||||
|
|
@ -9,26 +9,26 @@ import (
|
||||||
type DVDRegion string
|
type DVDRegion string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DVDNTSCRegionFree DVDRegion = "ntsc-region-free"
|
DVDNTSCRegionFree DVDRegion = "ntsc-region-free"
|
||||||
DVDPALRegionFree DVDRegion = "pal-region-free"
|
DVDPALRegionFree DVDRegion = "pal-region-free"
|
||||||
DVDSECAMRegionFree DVDRegion = "secam-region-free"
|
DVDSECAMRegionFree DVDRegion = "secam-region-free"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DVDStandard represents the technical specifications for a DVD encoding standard
|
// DVDStandard represents the technical specifications for a DVD encoding standard
|
||||||
type DVDStandard struct {
|
type DVDStandard struct {
|
||||||
Region DVDRegion
|
Region DVDRegion
|
||||||
Name string
|
Name string
|
||||||
Resolution string // "720x480" or "720x576"
|
Resolution string // "720x480" or "720x576"
|
||||||
FrameRate string // "29.97" or "25.00"
|
FrameRate string // "29.97" or "25.00"
|
||||||
VideoFrames int // 30 or 25
|
VideoFrames int // 30 or 25
|
||||||
AudioRate int // 48000 Hz (universal)
|
AudioRate int // 48000 Hz (universal)
|
||||||
Type string // "NTSC", "PAL", or "SECAM"
|
Type string // "NTSC", "PAL", or "SECAM"
|
||||||
Countries []string
|
Countries []string
|
||||||
DefaultBitrate string // "6000k" for NTSC, "8000k" for PAL
|
DefaultBitrate string // "6000k" for NTSC, "8000k" for PAL
|
||||||
MaxBitrate string // "9000k" for NTSC, "9500k" for PAL
|
MaxBitrate string // "9000k" for NTSC, "9500k" for PAL
|
||||||
AspectRatios []string
|
AspectRatios []string
|
||||||
InterlaceMode string // "interlaced" or "progressive"
|
InterlaceMode string // "interlaced" or "progressive"
|
||||||
Description string
|
Description string
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDVDStandard returns specifications for a given DVD region
|
// GetDVDStandard returns specifications for a given DVD region
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,11 @@ import (
|
||||||
// DetectionResult contains the results of interlacing analysis
|
// DetectionResult contains the results of interlacing analysis
|
||||||
type DetectionResult struct {
|
type DetectionResult struct {
|
||||||
// Frame counts from idet filter
|
// Frame counts from idet filter
|
||||||
TFF int // Top Field First frames
|
TFF int // Top Field First frames
|
||||||
BFF int // Bottom Field First frames
|
BFF int // Bottom Field First frames
|
||||||
Progressive int // Progressive frames
|
Progressive int // Progressive frames
|
||||||
Undetermined int // Undetermined frames
|
Undetermined int // Undetermined frames
|
||||||
TotalFrames int // Total frames analyzed
|
TotalFrames int // Total frames analyzed
|
||||||
|
|
||||||
// Calculated metrics
|
// Calculated metrics
|
||||||
InterlacedPercent float64 // Percentage of interlaced frames
|
InterlacedPercent float64 // Percentage of interlaced frames
|
||||||
|
|
@ -26,21 +26,21 @@ type DetectionResult struct {
|
||||||
Confidence string // "High", "Medium", "Low"
|
Confidence string // "High", "Medium", "Low"
|
||||||
|
|
||||||
// Recommendations
|
// Recommendations
|
||||||
Recommendation string // Human-readable recommendation
|
Recommendation string // Human-readable recommendation
|
||||||
SuggestDeinterlace bool // Whether deinterlacing is recommended
|
SuggestDeinterlace bool // Whether deinterlacing is recommended
|
||||||
SuggestedFilter string // "yadif", "bwdif", etc.
|
SuggestedFilter string // "yadif", "bwdif", etc.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detector analyzes video for interlacing
|
// Detector analyzes video for interlacing
|
||||||
type Detector struct {
|
type Detector struct {
|
||||||
FFmpegPath string
|
FFmpegPath string
|
||||||
FFprobePath string
|
FFprobePath string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDetector creates a new interlacing detector
|
// NewDetector creates a new interlacing detector
|
||||||
func NewDetector(ffmpegPath, ffprobePath string) *Detector {
|
func NewDetector(ffmpegPath, ffprobePath string) *Detector {
|
||||||
return &Detector{
|
return &Detector{
|
||||||
FFmpegPath: ffmpegPath,
|
FFmpegPath: ffmpegPath,
|
||||||
FFprobePath: ffprobePath,
|
FFprobePath: ffprobePath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"runtime/debug"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -80,3 +81,50 @@ func FilePath() string {
|
||||||
func History() []string {
|
func History() []string {
|
||||||
return history
|
return history
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Error logs an error message with a category (always logged, even when debug is off)
|
||||||
|
func Error(cat Category, format string, args ...interface{}) {
|
||||||
|
msg := fmt.Sprintf("%s ERROR: %s", cat, fmt.Sprintf(format, args...))
|
||||||
|
timestamp := time.Now().Format(time.RFC3339Nano)
|
||||||
|
if file != nil {
|
||||||
|
fmt.Fprintf(file, "%s %s\n", timestamp, msg)
|
||||||
|
}
|
||||||
|
history = append(history, fmt.Sprintf("%s %s", timestamp, msg))
|
||||||
|
if len(history) > historyMax {
|
||||||
|
history = history[len(history)-historyMax:]
|
||||||
|
}
|
||||||
|
logger.Printf("%s %s", timestamp, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fatal logs a fatal error and exits (always logged)
|
||||||
|
func Fatal(cat Category, format string, args ...interface{}) {
|
||||||
|
msg := fmt.Sprintf("%s FATAL: %s", cat, fmt.Sprintf(format, args...))
|
||||||
|
timestamp := time.Now().Format(time.RFC3339Nano)
|
||||||
|
if file != nil {
|
||||||
|
fmt.Fprintf(file, "%s %s\n", timestamp, msg)
|
||||||
|
file.Sync()
|
||||||
|
}
|
||||||
|
logger.Printf("%s %s", timestamp, msg)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Panic logs a panic with stack trace
|
||||||
|
func Panic(recovered interface{}) {
|
||||||
|
msg := fmt.Sprintf("%s PANIC: %v\nStack trace:\n%s", CatSystem, recovered, string(debug.Stack()))
|
||||||
|
timestamp := time.Now().Format(time.RFC3339Nano)
|
||||||
|
if file != nil {
|
||||||
|
fmt.Fprintf(file, "%s %s\n", timestamp, msg)
|
||||||
|
file.Sync()
|
||||||
|
}
|
||||||
|
history = append(history, fmt.Sprintf("%s %s", timestamp, msg))
|
||||||
|
logger.Printf("%s %s", timestamp, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecoverPanic should be used with defer to catch and log panics
|
||||||
|
func RecoverPanic() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
Panic(r)
|
||||||
|
// Re-panic to let the program crash with the logged info
|
||||||
|
panic(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,20 @@ func HandleAudio(files []string) {
|
||||||
// HandleAuthor handles the disc authoring module (DVD/Blu-ray) (placeholder)
|
// HandleAuthor handles the disc authoring module (DVD/Blu-ray) (placeholder)
|
||||||
func HandleAuthor(files []string) {
|
func HandleAuthor(files []string) {
|
||||||
logging.Debug(logging.CatModule, "author handler invoked with %v", files)
|
logging.Debug(logging.CatModule, "author handler invoked with %v", files)
|
||||||
fmt.Println("author", files)
|
// This will be handled by the UI drag-and-drop system
|
||||||
|
// File loading is managed in buildAuthorView()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleRip handles the rip module (placeholder)
|
||||||
|
func HandleRip(files []string) {
|
||||||
|
logging.Debug(logging.CatModule, "rip handler invoked with %v", files)
|
||||||
|
fmt.Println("rip", files)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleBluRay handles the Blu-Ray authoring module (placeholder)
|
||||||
|
func HandleBluRay(files []string) {
|
||||||
|
logging.Debug(logging.CatModule, "bluray handler invoked with %v", files)
|
||||||
|
fmt.Println("bluray", files)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSubtitles handles the subtitles module (placeholder)
|
// HandleSubtitles handles the subtitles module (placeholder)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
// JobType represents the type of job to execute
|
// JobType represents the type of job to execute
|
||||||
|
|
@ -22,6 +24,8 @@ const (
|
||||||
JobTypeAudio JobType = "audio"
|
JobTypeAudio JobType = "audio"
|
||||||
JobTypeThumb JobType = "thumb"
|
JobTypeThumb JobType = "thumb"
|
||||||
JobTypeSnippet JobType = "snippet"
|
JobTypeSnippet JobType = "snippet"
|
||||||
|
JobTypeAuthor JobType = "author"
|
||||||
|
JobTypeRip JobType = "rip"
|
||||||
)
|
)
|
||||||
|
|
||||||
// JobStatus represents the current state of a job
|
// JobStatus represents the current state of a job
|
||||||
|
|
@ -93,7 +97,7 @@ func (q *Queue) notifyChange() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add adds a job to the queue
|
// Add adds a job to the queue (at the end)
|
||||||
func (q *Queue) Add(job *Job) {
|
func (q *Queue) Add(job *Job) {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
|
|
||||||
|
|
@ -113,6 +117,37 @@ func (q *Queue) Add(job *Job) {
|
||||||
q.notifyChange()
|
q.notifyChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddNext adds a job to the front of the pending queue (right after any running job)
|
||||||
|
func (q *Queue) AddNext(job *Job) {
|
||||||
|
q.mu.Lock()
|
||||||
|
|
||||||
|
if job.ID == "" {
|
||||||
|
job.ID = generateID()
|
||||||
|
}
|
||||||
|
if job.CreatedAt.IsZero() {
|
||||||
|
job.CreatedAt = time.Now()
|
||||||
|
}
|
||||||
|
if job.Status == "" {
|
||||||
|
job.Status = JobStatusPending
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the position after any running jobs
|
||||||
|
insertPos := 0
|
||||||
|
for i, j := range q.jobs {
|
||||||
|
if j.Status == JobStatusRunning {
|
||||||
|
insertPos = i + 1
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert at the calculated position
|
||||||
|
q.jobs = append(q.jobs[:insertPos], append([]*Job{job}, q.jobs[insertPos:]...)...)
|
||||||
|
q.rebalancePrioritiesLocked()
|
||||||
|
q.mu.Unlock()
|
||||||
|
q.notifyChange()
|
||||||
|
}
|
||||||
|
|
||||||
// Remove removes a job from the queue by ID
|
// Remove removes a job from the queue by ID
|
||||||
func (q *Queue) Remove(id string) error {
|
func (q *Queue) Remove(id string) error {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
|
|
@ -340,6 +375,7 @@ func (q *Queue) ResumeAll() {
|
||||||
|
|
||||||
// processJobs continuously processes pending jobs
|
// processJobs continuously processes pending jobs
|
||||||
func (q *Queue) processJobs() {
|
func (q *Queue) processJobs() {
|
||||||
|
defer logging.RecoverPanic() // Catch and log any panics in job processing
|
||||||
for {
|
for {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
if !q.running {
|
if !q.running {
|
||||||
|
|
|
||||||
|
|
@ -9,19 +9,20 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.leaktechnologies.dev/stu/VideoTools/internal/logging"
|
"git.leaktechnologies.dev/stu/VideoTools/internal/logging"
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HardwareInfo contains system hardware information
|
// HardwareInfo contains system hardware information
|
||||||
type HardwareInfo struct {
|
type HardwareInfo struct {
|
||||||
CPU string `json:"cpu"`
|
CPU string `json:"cpu"`
|
||||||
CPUCores int `json:"cpu_cores"`
|
CPUCores int `json:"cpu_cores"`
|
||||||
CPUMHz string `json:"cpu_mhz"`
|
CPUMHz string `json:"cpu_mhz"`
|
||||||
GPU string `json:"gpu"`
|
GPU string `json:"gpu"`
|
||||||
GPUDriver string `json:"gpu_driver"`
|
GPUDriver string `json:"gpu_driver"`
|
||||||
RAM string `json:"ram"`
|
RAM string `json:"ram"`
|
||||||
RAMMBytes uint64 `json:"ram_mb"`
|
RAMMBytes uint64 `json:"ram_mb"`
|
||||||
OS string `json:"os"`
|
OS string `json:"os"`
|
||||||
Arch string `json:"arch"`
|
Arch string `json:"arch"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect gathers system hardware information
|
// Detect gathers system hardware information
|
||||||
|
|
@ -103,6 +104,7 @@ func detectCPULinux() (model, mhz string) {
|
||||||
func detectCPUWindows() (model, mhz string) {
|
func detectCPUWindows() (model, mhz string) {
|
||||||
// Use wmic to get CPU info
|
// Use wmic to get CPU info
|
||||||
cmd := exec.Command("wmic", "cpu", "get", "name,maxclockspeed")
|
cmd := exec.Command("wmic", "cpu", "get", "name,maxclockspeed")
|
||||||
|
utils.ApplyNoWindow(cmd) // Hide command window on Windows
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Debug(logging.CatSystem, "failed to run wmic cpu: %v", err)
|
logging.Debug(logging.CatSystem, "failed to run wmic cpu: %v", err)
|
||||||
|
|
@ -208,6 +210,7 @@ func detectGPULinux() (model, driver string) {
|
||||||
func detectGPUWindows() (model, driver string) {
|
func detectGPUWindows() (model, driver string) {
|
||||||
// Use nvidia-smi if available (NVIDIA GPUs)
|
// Use nvidia-smi if available (NVIDIA GPUs)
|
||||||
cmd := exec.Command("nvidia-smi", "--query-gpu=name,driver_version", "--format=csv,noheader")
|
cmd := exec.Command("nvidia-smi", "--query-gpu=name,driver_version", "--format=csv,noheader")
|
||||||
|
utils.ApplyNoWindow(cmd) // Hide command window on Windows
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
parts := strings.Split(strings.TrimSpace(string(output)), ",")
|
parts := strings.Split(strings.TrimSpace(string(output)), ",")
|
||||||
|
|
@ -220,21 +223,41 @@ func detectGPUWindows() (model, driver string) {
|
||||||
|
|
||||||
// Try wmic for generic GPU info
|
// Try wmic for generic GPU info
|
||||||
cmd = exec.Command("wmic", "path", "win32_VideoController", "get", "name,driverversion")
|
cmd = exec.Command("wmic", "path", "win32_VideoController", "get", "name,driverversion")
|
||||||
|
utils.ApplyNoWindow(cmd) // Hide command window on Windows
|
||||||
output, err = cmd.Output()
|
output, err = cmd.Output()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
lines := strings.Split(string(output), "\n")
|
lines := strings.Split(string(output), "\n")
|
||||||
if len(lines) >= 2 {
|
// Iterate through all video controllers, skip virtual/non-physical adapters
|
||||||
// Skip header, get first GPU
|
for i, line := range lines {
|
||||||
line := strings.TrimSpace(lines[1])
|
if i == 0 { // Skip header
|
||||||
if line != "" {
|
continue
|
||||||
// Parse: Name DriverVersion
|
}
|
||||||
re := regexp.MustCompile(`^(.+?)\s+(\S+)$`)
|
line = strings.TrimSpace(line)
|
||||||
matches := re.FindStringSubmatch(line)
|
if line == "" {
|
||||||
if len(matches) == 3 {
|
continue
|
||||||
model = strings.TrimSpace(matches[1])
|
}
|
||||||
driver = strings.TrimSpace(matches[2])
|
|
||||||
return model, driver
|
// Filter out virtual/software adapters
|
||||||
}
|
lineLower := strings.ToLower(line)
|
||||||
|
if strings.Contains(lineLower, "virtual") ||
|
||||||
|
strings.Contains(lineLower, "microsoft basic") ||
|
||||||
|
strings.Contains(lineLower, "remote") ||
|
||||||
|
strings.Contains(lineLower, "vnc") ||
|
||||||
|
strings.Contains(lineLower, "parsec") ||
|
||||||
|
strings.Contains(lineLower, "teamviewer") {
|
||||||
|
logging.Debug(logging.CatSystem, "skipping virtual GPU: %s", line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse: Name DriverVersion
|
||||||
|
// Use flexible regex to handle varying whitespace
|
||||||
|
re := regexp.MustCompile(`^(.+?)\s+(\S+)$`)
|
||||||
|
matches := re.FindStringSubmatch(line)
|
||||||
|
if len(matches) == 3 {
|
||||||
|
model = strings.TrimSpace(matches[1])
|
||||||
|
driver = strings.TrimSpace(matches[2])
|
||||||
|
logging.Debug(logging.CatSystem, "detected physical GPU: %s (driver: %s)", model, driver)
|
||||||
|
return model, driver
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -316,6 +339,7 @@ func detectRAMLinux() (readable string, mb uint64) {
|
||||||
|
|
||||||
func detectRAMWindows() (readable string, mb uint64) {
|
func detectRAMWindows() (readable string, mb uint64) {
|
||||||
cmd := exec.Command("wmic", "computersystem", "get", "totalphysicalmemory")
|
cmd := exec.Command("wmic", "computersystem", "get", "totalphysicalmemory")
|
||||||
|
utils.ApplyNoWindow(cmd) // Hide command window on Windows
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Debug(logging.CatSystem, "failed to run wmic computersystem: %v", err)
|
logging.Debug(logging.CatSystem, "failed to run wmic computersystem: %v", err)
|
||||||
|
|
|
||||||
|
|
@ -38,12 +38,12 @@ type BenchmarkProgressView struct {
|
||||||
textColor color.Color
|
textColor color.Color
|
||||||
onCancel func()
|
onCancel func()
|
||||||
|
|
||||||
container *fyne.Container
|
container *fyne.Container
|
||||||
statusLabel *widget.Label
|
statusLabel *widget.Label
|
||||||
progressBar *widget.ProgressBar
|
progressBar *widget.ProgressBar
|
||||||
currentLabel *widget.Label
|
currentLabel *widget.Label
|
||||||
resultsBox *fyne.Container
|
resultsBox *fyne.Container
|
||||||
cancelBtn *widget.Button
|
cancelBtn *widget.Button
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *BenchmarkProgressView) build() {
|
func (v *BenchmarkProgressView) build() {
|
||||||
|
|
|
||||||
134
internal/ui/colors.go
Normal file
134
internal/ui/colors.go
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Semantic Color System for VideoTools
|
||||||
|
// Based on professional NLE and broadcast tooling conventions
|
||||||
|
|
||||||
|
// Container / Format Colors (File Wrapper)
|
||||||
|
var (
|
||||||
|
ColorMKV = utils.MustHex("#00B3B3") // Teal / Cyan - Neutral, modern, flexible container
|
||||||
|
ColorRemux = utils.MustHex("#06B6D4") // Cyan-Glow - Lossless remux (no re-encoding)
|
||||||
|
ColorMP4 = utils.MustHex("#3B82F6") // Blue - Widely recognised, consumer-friendly
|
||||||
|
ColorMOV = utils.MustHex("#6366F1") // Indigo - Pro / Apple / QuickTime lineage
|
||||||
|
ColorAVI = utils.MustHex("#64748B") // Grey-Blue - Legacy container
|
||||||
|
ColorWEBM = utils.MustHex("#22C55E") // Green-Teal - Web-native
|
||||||
|
ColorTS = utils.MustHex("#F59E0B") // Amber - Broadcast / transport streams
|
||||||
|
ColorM2TS = utils.MustHex("#F59E0B") // Amber - Broadcast / transport streams
|
||||||
|
)
|
||||||
|
|
||||||
|
// Video Codec Colors (Compression Method)
|
||||||
|
// Modern / Efficient Codecs
|
||||||
|
var (
|
||||||
|
ColorAV1 = utils.MustHex("#10B981") // Emerald - Modern, efficient
|
||||||
|
ColorHEVC = utils.MustHex("#84CC16") // Lime-Green - Modern, efficient
|
||||||
|
ColorH265 = utils.MustHex("#84CC16") // Lime-Green - Same as HEVC
|
||||||
|
ColorVP9 = utils.MustHex("#22D3EE") // Green-Cyan - Modern, efficient
|
||||||
|
)
|
||||||
|
|
||||||
|
// Established / Legacy Video Codecs
|
||||||
|
var (
|
||||||
|
ColorH264 = utils.MustHex("#38BDF8") // Sky Blue - Compatibility
|
||||||
|
ColorAVC = utils.MustHex("#38BDF8") // Sky Blue - Same as H.264
|
||||||
|
ColorMPEG2 = utils.MustHex("#EAB308") // Yellow-Amber - Legacy / broadcast
|
||||||
|
ColorDivX = utils.MustHex("#FB923C") // Muted Orange - Legacy
|
||||||
|
ColorXviD = utils.MustHex("#FB923C") // Muted Orange - Legacy
|
||||||
|
ColorMPEG4 = utils.MustHex("#FB923C") // Muted Orange - Legacy
|
||||||
|
)
|
||||||
|
|
||||||
|
// Audio Codec Colors (Secondary but Distinct)
|
||||||
|
var (
|
||||||
|
ColorOpus = utils.MustHex("#8B5CF6") // Violet - Modern audio
|
||||||
|
ColorAAC = utils.MustHex("#7C3AED") // Purple-Blue - Common audio
|
||||||
|
ColorFLAC = utils.MustHex("#EC4899") // Magenta - Lossless audio
|
||||||
|
ColorMP3 = utils.MustHex("#F43F5E") // Rose - Legacy audio
|
||||||
|
ColorAC3 = utils.MustHex("#F97316") // Orange-Red - Surround audio
|
||||||
|
ColorVorbis = utils.MustHex("#A855F7") // Purple - Open codec
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pixel Format / Colour Data (Technical Metadata)
|
||||||
|
var (
|
||||||
|
ColorYUV420P = utils.MustHex("#94A3B8") // Slate - Standard
|
||||||
|
ColorYUV422P = utils.MustHex("#64748B") // Slate-Blue - Intermediate
|
||||||
|
ColorYUV444P = utils.MustHex("#475569") // Steel - High quality
|
||||||
|
ColorHDR = utils.MustHex("#06B6D4") // Cyan-Glow - HDR content
|
||||||
|
ColorSDR = utils.MustHex("#9CA3AF") // Neutral Grey - SDR content
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetContainerColor returns the semantic color for a container format
|
||||||
|
func GetContainerColor(format string) color.Color {
|
||||||
|
switch format {
|
||||||
|
case "mkv", "matroska":
|
||||||
|
return ColorMKV
|
||||||
|
case "mp4", "m4v":
|
||||||
|
return ColorMP4
|
||||||
|
case "mov", "quicktime":
|
||||||
|
return ColorMOV
|
||||||
|
case "avi":
|
||||||
|
return ColorAVI
|
||||||
|
case "webm":
|
||||||
|
return ColorWEBM
|
||||||
|
case "ts", "m2ts", "mts":
|
||||||
|
return ColorTS
|
||||||
|
default:
|
||||||
|
return color.RGBA{100, 100, 100, 255} // Default grey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVideoCodecColor returns the semantic color for a video codec
|
||||||
|
func GetVideoCodecColor(codec string) color.Color {
|
||||||
|
switch codec {
|
||||||
|
case "av1":
|
||||||
|
return ColorAV1
|
||||||
|
case "hevc", "h265", "h.265":
|
||||||
|
return ColorHEVC
|
||||||
|
case "vp9":
|
||||||
|
return ColorVP9
|
||||||
|
case "h264", "avc", "h.264":
|
||||||
|
return ColorH264
|
||||||
|
case "mpeg2":
|
||||||
|
return ColorMPEG2
|
||||||
|
case "divx", "xvid", "mpeg4":
|
||||||
|
return ColorDivX
|
||||||
|
default:
|
||||||
|
return color.RGBA{100, 100, 100, 255} // Default grey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAudioCodecColor returns the semantic color for an audio codec
|
||||||
|
func GetAudioCodecColor(codec string) color.Color {
|
||||||
|
switch codec {
|
||||||
|
case "opus":
|
||||||
|
return ColorOpus
|
||||||
|
case "aac":
|
||||||
|
return ColorAAC
|
||||||
|
case "flac":
|
||||||
|
return ColorFLAC
|
||||||
|
case "mp3":
|
||||||
|
return ColorMP3
|
||||||
|
case "ac3":
|
||||||
|
return ColorAC3
|
||||||
|
case "vorbis":
|
||||||
|
return ColorVorbis
|
||||||
|
default:
|
||||||
|
return color.RGBA{100, 100, 100, 255} // Default grey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPixelFormatColor returns the semantic color for a pixel format
|
||||||
|
func GetPixelFormatColor(pixfmt string) color.Color {
|
||||||
|
switch pixfmt {
|
||||||
|
case "yuv420p", "yuv420p10le":
|
||||||
|
return ColorYUV420P
|
||||||
|
case "yuv422p", "yuv422p10le":
|
||||||
|
return ColorYUV422P
|
||||||
|
case "yuv444p", "yuv444p10le":
|
||||||
|
return ColorYUV444P
|
||||||
|
default:
|
||||||
|
return ColorSDR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -32,10 +33,18 @@ func SetColors(grid, text color.Color) {
|
||||||
TextColor = text
|
TextColor = text
|
||||||
}
|
}
|
||||||
|
|
||||||
// MonoTheme ensures all text uses a monospace font
|
// MonoTheme ensures all text uses a monospace font and swaps hover/selection colors
|
||||||
type MonoTheme struct{}
|
type MonoTheme struct{}
|
||||||
|
|
||||||
func (m *MonoTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
|
func (m *MonoTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
|
||||||
|
switch name {
|
||||||
|
case theme.ColorNameSelection:
|
||||||
|
// Use the default hover color for selection
|
||||||
|
return theme.DefaultTheme().Color(theme.ColorNameHover, variant)
|
||||||
|
case theme.ColorNameHover:
|
||||||
|
// Use the default selection color for hover
|
||||||
|
return theme.DefaultTheme().Color(theme.ColorNameSelection, variant)
|
||||||
|
}
|
||||||
return theme.DefaultTheme().Color(name, variant)
|
return theme.DefaultTheme().Color(name, variant)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,20 +64,22 @@ func (m *MonoTheme) Size(name fyne.ThemeSizeName) float32 {
|
||||||
// ModuleTile is a clickable tile widget for module selection
|
// ModuleTile is a clickable tile widget for module selection
|
||||||
type ModuleTile struct {
|
type ModuleTile struct {
|
||||||
widget.BaseWidget
|
widget.BaseWidget
|
||||||
label string
|
label string
|
||||||
color color.Color
|
color color.Color
|
||||||
enabled bool
|
enabled bool
|
||||||
onTapped func()
|
missingDependencies bool
|
||||||
onDropped func([]fyne.URI)
|
onTapped func()
|
||||||
flashing bool
|
onDropped func([]fyne.URI)
|
||||||
draggedOver bool
|
flashing bool
|
||||||
|
draggedOver bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewModuleTile creates a new module tile
|
// NewModuleTile creates a new module tile
|
||||||
func NewModuleTile(label string, col color.Color, enabled bool, tapped func(), dropped func([]fyne.URI)) *ModuleTile {
|
func NewModuleTile(label string, col color.Color, enabled bool, missingDeps bool, tapped func(), dropped func([]fyne.URI)) *ModuleTile {
|
||||||
m := &ModuleTile{
|
m := &ModuleTile{
|
||||||
label: strings.ToUpper(label),
|
label: strings.ToUpper(label),
|
||||||
color: col,
|
color: col,
|
||||||
|
missingDependencies: missingDeps,
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
onTapped: tapped,
|
onTapped: tapped,
|
||||||
onDropped: dropped,
|
onDropped: dropped,
|
||||||
|
|
@ -118,19 +129,34 @@ func (m *ModuleTile) Dropped(pos fyne.Position, items []fyne.URI) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getContrastColor returns black or white text color based on background brightness
|
||||||
|
func getContrastColor(bgColor color.Color) color.Color {
|
||||||
|
r, g, b, _ := bgColor.RGBA()
|
||||||
|
// Convert from 16-bit to 8-bit
|
||||||
|
r8 := float64(r >> 8)
|
||||||
|
g8 := float64(g >> 8)
|
||||||
|
b8 := float64(b >> 8)
|
||||||
|
|
||||||
|
// Calculate relative luminance (WCAG formula)
|
||||||
|
luminance := (0.2126*r8 + 0.7152*g8 + 0.0722*b8) / 255.0
|
||||||
|
|
||||||
|
// If bright background, use dark text; if dark background, use light text
|
||||||
|
if luminance > 0.5 {
|
||||||
|
return color.NRGBA{R: 20, G: 20, B: 20, A: 255} // Dark text
|
||||||
|
}
|
||||||
|
return TextColor // Light text
|
||||||
|
}
|
||||||
|
|
||||||
func (m *ModuleTile) CreateRenderer() fyne.WidgetRenderer {
|
func (m *ModuleTile) CreateRenderer() fyne.WidgetRenderer {
|
||||||
tileColor := m.color
|
tileColor := m.color
|
||||||
labelColor := TextColor
|
labelColor := TextColor // White text for all modules
|
||||||
|
|
||||||
// Dim disabled tiles
|
// Orange background for modules missing dependencies
|
||||||
if !m.enabled {
|
if m.missingDependencies {
|
||||||
// Reduce opacity by mixing with dark background
|
tileColor = color.NRGBA{R: 255, G: 152, B: 0, A: 255} // Orange
|
||||||
if c, ok := m.color.(color.NRGBA); ok {
|
} else if !m.enabled {
|
||||||
tileColor = color.NRGBA{R: c.R / 3, G: c.G / 3, B: c.B / 3, A: c.A}
|
// Grey background for not implemented modules
|
||||||
}
|
tileColor = color.NRGBA{R: 80, G: 80, B: 80, A: 255}
|
||||||
if c, ok := TextColor.(color.NRGBA); ok {
|
|
||||||
labelColor = color.NRGBA{R: c.R / 2, G: c.G / 2, B: c.B / 2, A: c.A}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bg := canvas.NewRectangle(tileColor)
|
bg := canvas.NewRectangle(tileColor)
|
||||||
|
|
@ -143,10 +169,45 @@ func (m *ModuleTile) CreateRenderer() fyne.WidgetRenderer {
|
||||||
txt.Alignment = fyne.TextAlignCenter
|
txt.Alignment = fyne.TextAlignCenter
|
||||||
txt.TextSize = 20
|
txt.TextSize = 20
|
||||||
|
|
||||||
|
// Lock icon for disabled modules
|
||||||
|
lockIcon := canvas.NewText("🔒", color.NRGBA{R: 200, G: 200, B: 200, A: 255})
|
||||||
|
lockIcon.TextSize = 16
|
||||||
|
lockIcon.Alignment = fyne.TextAlignCenter
|
||||||
|
if m.enabled {
|
||||||
|
lockIcon.Hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagonal stripe overlay for disabled modules
|
||||||
|
disabledStripe := canvas.NewRaster(func(w, h int) image.Image {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
|
||||||
|
// Only draw stripes if disabled
|
||||||
|
if !m.enabled {
|
||||||
|
// Semi-transparent dark stripes
|
||||||
|
darkStripe := color.NRGBA{R: 0, G: 0, B: 0, A: 100}
|
||||||
|
lightStripe := color.NRGBA{R: 0, G: 0, B: 0, A: 30}
|
||||||
|
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
// Thicker diagonal stripes (dividing by 8 instead of 4)
|
||||||
|
if ((x + y) / 8 % 2) == 0 {
|
||||||
|
img.Set(x, y, darkStripe)
|
||||||
|
} else {
|
||||||
|
img.Set(x, y, lightStripe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Return transparent image for enabled modules
|
||||||
|
return img
|
||||||
|
})
|
||||||
|
|
||||||
return &moduleTileRenderer{
|
return &moduleTileRenderer{
|
||||||
tile: m,
|
tile: m,
|
||||||
bg: bg,
|
bg: bg,
|
||||||
label: txt,
|
label: txt,
|
||||||
|
lockIcon: lockIcon,
|
||||||
|
disabledStripe: disabledStripe,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,27 +218,62 @@ func (m *ModuleTile) Tapped(*fyne.PointEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type moduleTileRenderer struct {
|
type moduleTileRenderer struct {
|
||||||
tile *ModuleTile
|
tile *ModuleTile
|
||||||
bg *canvas.Rectangle
|
bg *canvas.Rectangle
|
||||||
label *canvas.Text
|
label *canvas.Text
|
||||||
|
lockIcon *canvas.Text
|
||||||
|
disabledStripe *canvas.Raster
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *moduleTileRenderer) Layout(size fyne.Size) {
|
func (r *moduleTileRenderer) Layout(size fyne.Size) {
|
||||||
r.bg.Resize(size)
|
r.bg.Resize(size)
|
||||||
|
r.bg.Move(fyne.NewPos(0, 0))
|
||||||
|
|
||||||
|
// Stripe overlay covers entire tile
|
||||||
|
if r.disabledStripe != nil {
|
||||||
|
r.disabledStripe.Resize(size)
|
||||||
|
r.disabledStripe.Move(fyne.NewPos(0, 0))
|
||||||
|
}
|
||||||
|
|
||||||
// Center the label by positioning it in the middle
|
// Center the label by positioning it in the middle
|
||||||
labelSize := r.label.MinSize()
|
labelSize := r.label.MinSize()
|
||||||
r.label.Resize(labelSize)
|
r.label.Resize(labelSize)
|
||||||
x := (size.Width - labelSize.Width) / 2
|
x := (size.Width - labelSize.Width) / 2
|
||||||
y := (size.Height - labelSize.Height) / 2
|
y := (size.Height - labelSize.Height) / 2
|
||||||
r.label.Move(fyne.NewPos(x, y))
|
r.label.Move(fyne.NewPos(x, y))
|
||||||
|
|
||||||
|
// Position lock icon in top-right corner
|
||||||
|
if r.lockIcon != nil {
|
||||||
|
lockSize := r.lockIcon.MinSize()
|
||||||
|
r.lockIcon.Resize(lockSize)
|
||||||
|
lockX := size.Width - lockSize.Width - 4
|
||||||
|
lockY := float32(4)
|
||||||
|
r.lockIcon.Move(fyne.NewPos(lockX, lockY))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *moduleTileRenderer) MinSize() fyne.Size {
|
func (r *moduleTileRenderer) MinSize() fyne.Size {
|
||||||
return fyne.NewSize(150, 65)
|
return fyne.NewSize(135, 58)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *moduleTileRenderer) Refresh() {
|
func (r *moduleTileRenderer) Refresh() {
|
||||||
r.bg.FillColor = r.tile.color
|
// Update tile color and text color based on enabled state
|
||||||
|
if r.tile.enabled {
|
||||||
|
r.bg.FillColor = r.tile.color
|
||||||
|
r.label.Color = TextColor // Always white text for enabled modules
|
||||||
|
if r.lockIcon != nil {
|
||||||
|
r.lockIcon.Hide()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Dim disabled tiles
|
||||||
|
if c, ok := r.tile.color.(color.NRGBA); ok {
|
||||||
|
r.bg.FillColor = color.NRGBA{R: c.R / 3, G: c.G / 3, B: c.B / 3, A: c.A}
|
||||||
|
}
|
||||||
|
r.label.Color = color.NRGBA{R: 100, G: 100, B: 100, A: 255}
|
||||||
|
if r.lockIcon != nil {
|
||||||
|
r.lockIcon.Show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Apply visual feedback based on state
|
// Apply visual feedback based on state
|
||||||
if r.tile.flashing {
|
if r.tile.flashing {
|
||||||
|
|
@ -197,12 +293,18 @@ func (r *moduleTileRenderer) Refresh() {
|
||||||
r.bg.Refresh()
|
r.bg.Refresh()
|
||||||
r.label.Text = r.tile.label
|
r.label.Text = r.tile.label
|
||||||
r.label.Refresh()
|
r.label.Refresh()
|
||||||
|
if r.lockIcon != nil {
|
||||||
|
r.lockIcon.Refresh()
|
||||||
|
}
|
||||||
|
if r.disabledStripe != nil {
|
||||||
|
r.disabledStripe.Refresh()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *moduleTileRenderer) Destroy() {}
|
func (r *moduleTileRenderer) Destroy() {}
|
||||||
|
|
||||||
func (r *moduleTileRenderer) Objects() []fyne.CanvasObject {
|
func (r *moduleTileRenderer) Objects() []fyne.CanvasObject {
|
||||||
return []fyne.CanvasObject{r.bg, r.label}
|
return []fyne.CanvasObject{r.bg, r.disabledStripe, r.label, r.lockIcon}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TintedBar creates a colored bar container
|
// TintedBar creates a colored bar container
|
||||||
|
|
@ -332,6 +434,58 @@ func (r *droppableRenderer) Objects() []fyne.CanvasObject {
|
||||||
return []fyne.CanvasObject{r.content}
|
return []fyne.CanvasObject{r.content}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FastVScroll creates a vertical scroll container with faster scroll speed
|
||||||
|
type FastVScroll struct {
|
||||||
|
widget.BaseWidget
|
||||||
|
scroll *container.Scroll
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFastVScroll creates a new fast-scrolling vertical scroll container
|
||||||
|
func NewFastVScroll(content fyne.CanvasObject) *FastVScroll {
|
||||||
|
f := &FastVScroll{
|
||||||
|
scroll: container.NewVScroll(content),
|
||||||
|
}
|
||||||
|
f.ExtendBaseWidget(f)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FastVScroll) CreateRenderer() fyne.WidgetRenderer {
|
||||||
|
return &fastScrollRenderer{scroll: f.scroll}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FastVScroll) Scrolled(ev *fyne.ScrollEvent) {
|
||||||
|
// Multiply scroll speed by 12x for much faster navigation
|
||||||
|
fastEvent := &fyne.ScrollEvent{
|
||||||
|
Scrolled: fyne.Delta{
|
||||||
|
DX: ev.Scrolled.DX * 12.0,
|
||||||
|
DY: ev.Scrolled.DY * 12.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f.scroll.Scrolled(fastEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
type fastScrollRenderer struct {
|
||||||
|
scroll *container.Scroll
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fastScrollRenderer) Layout(size fyne.Size) {
|
||||||
|
r.scroll.Resize(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fastScrollRenderer) MinSize() fyne.Size {
|
||||||
|
return r.scroll.MinSize()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fastScrollRenderer) Refresh() {
|
||||||
|
r.scroll.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fastScrollRenderer) Objects() []fyne.CanvasObject {
|
||||||
|
return []fyne.CanvasObject{r.scroll}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fastScrollRenderer) Destroy() {}
|
||||||
|
|
||||||
// DraggableVScroll creates a vertical scroll container with draggable track
|
// DraggableVScroll creates a vertical scroll container with draggable track
|
||||||
type DraggableVScroll struct {
|
type DraggableVScroll struct {
|
||||||
widget.BaseWidget
|
widget.BaseWidget
|
||||||
|
|
@ -416,7 +570,14 @@ func (d *DraggableVScroll) Tapped(ev *fyne.PointEvent) {
|
||||||
|
|
||||||
// Scrolled handles scroll events (mouse wheel)
|
// Scrolled handles scroll events (mouse wheel)
|
||||||
func (d *DraggableVScroll) Scrolled(ev *fyne.ScrollEvent) {
|
func (d *DraggableVScroll) Scrolled(ev *fyne.ScrollEvent) {
|
||||||
d.scroll.Scrolled(ev)
|
// Multiply scroll speed by 2.5x for faster scrolling
|
||||||
|
fastEvent := &fyne.ScrollEvent{
|
||||||
|
Scrolled: fyne.Delta{
|
||||||
|
DX: ev.Scrolled.DX * 2.5,
|
||||||
|
DY: ev.Scrolled.DY * 2.5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
d.scroll.Scrolled(fastEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
type draggableScrollRenderer struct {
|
type draggableScrollRenderer struct {
|
||||||
|
|
@ -738,29 +899,35 @@ func BuildModuleBadge(jobType queue.JobType) fyne.CanvasObject {
|
||||||
|
|
||||||
switch jobType {
|
switch jobType {
|
||||||
case queue.JobTypeConvert:
|
case queue.JobTypeConvert:
|
||||||
badgeColor = utils.MustHex("#4A90E2")
|
badgeColor = utils.MustHex("#673AB7") // Deep Purple
|
||||||
badgeText = "CONVERT"
|
badgeText = "CONVERT"
|
||||||
case queue.JobTypeMerge:
|
case queue.JobTypeMerge:
|
||||||
badgeColor = utils.MustHex("#E24A90")
|
badgeColor = utils.MustHex("#4CAF50") // Green
|
||||||
badgeText = "MERGE"
|
badgeText = "MERGE"
|
||||||
case queue.JobTypeTrim:
|
case queue.JobTypeTrim:
|
||||||
badgeColor = utils.MustHex("#90E24A")
|
badgeColor = utils.MustHex("#FFEB3B") // Yellow
|
||||||
badgeText = "TRIM"
|
badgeText = "TRIM"
|
||||||
case queue.JobTypeFilter:
|
case queue.JobTypeFilter:
|
||||||
badgeColor = utils.MustHex("#E2904A")
|
badgeColor = utils.MustHex("#00BCD4") // Cyan
|
||||||
badgeText = "FILTER"
|
badgeText = "FILTER"
|
||||||
case queue.JobTypeUpscale:
|
case queue.JobTypeUpscale:
|
||||||
badgeColor = utils.MustHex("#9A4AE2")
|
badgeColor = utils.MustHex("#9C27B0") // Purple
|
||||||
badgeText = "UPSCALE"
|
badgeText = "UPSCALE"
|
||||||
case queue.JobTypeAudio:
|
case queue.JobTypeAudio:
|
||||||
badgeColor = utils.MustHex("#4AE290")
|
badgeColor = utils.MustHex("#FFC107") // Amber
|
||||||
badgeText = "AUDIO"
|
badgeText = "AUDIO"
|
||||||
case queue.JobTypeThumb:
|
case queue.JobTypeThumb:
|
||||||
badgeColor = utils.MustHex("#E2E24A")
|
badgeColor = utils.MustHex("#00ACC1") // Dark Cyan
|
||||||
badgeText = "THUMB"
|
badgeText = "THUMB"
|
||||||
case queue.JobTypeSnippet:
|
case queue.JobTypeSnippet:
|
||||||
badgeColor = utils.MustHex("#4AE2E2")
|
badgeColor = utils.MustHex("#00BCD4") // Cyan (same as Convert)
|
||||||
badgeText = "SNIPPET"
|
badgeText = "SNIPPET"
|
||||||
|
case queue.JobTypeAuthor:
|
||||||
|
badgeColor = utils.MustHex("#FF5722") // Deep Orange
|
||||||
|
badgeText = "AUTHOR"
|
||||||
|
case queue.JobTypeRip:
|
||||||
|
badgeColor = utils.MustHex("#FF9800") // Orange
|
||||||
|
badgeText = "RIP"
|
||||||
default:
|
default:
|
||||||
badgeColor = utils.MustHex("#808080")
|
badgeColor = utils.MustHex("#808080")
|
||||||
badgeText = "OTHER"
|
badgeText = "OTHER"
|
||||||
|
|
@ -777,3 +944,40 @@ func BuildModuleBadge(jobType queue.JobType) fyne.CanvasObject {
|
||||||
|
|
||||||
return container.NewMax(rect, container.NewCenter(text))
|
return container.NewMax(rect, container.NewCenter(text))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SectionHeader creates a color-coded section header for better visual separation
|
||||||
|
// Helps fix usability issue where settings sections blend together
|
||||||
|
func SectionHeader(title string, accentColor color.Color) fyne.CanvasObject {
|
||||||
|
// Left accent bar (Memphis geometric style)
|
||||||
|
accent := canvas.NewRectangle(accentColor)
|
||||||
|
accent.SetMinSize(fyne.NewSize(4, 20))
|
||||||
|
|
||||||
|
// Title text
|
||||||
|
label := widget.NewLabel(title)
|
||||||
|
label.TextStyle = fyne.TextStyle{Bold: true}
|
||||||
|
label.Importance = widget.HighImportance
|
||||||
|
|
||||||
|
// Combine accent bar + title with padding
|
||||||
|
content := container.NewBorder(
|
||||||
|
nil, nil,
|
||||||
|
accent,
|
||||||
|
nil,
|
||||||
|
container.NewPadded(label),
|
||||||
|
)
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
// SectionSpacer creates vertical spacing between sections for better readability
|
||||||
|
func SectionSpacer() fyne.CanvasObject {
|
||||||
|
spacer := canvas.NewRectangle(color.Transparent)
|
||||||
|
spacer.SetMinSize(fyne.NewSize(0, 12))
|
||||||
|
return spacer
|
||||||
|
}
|
||||||
|
|
||||||
|
// ColoredDivider creates a thin horizontal divider with accent color
|
||||||
|
func ColoredDivider(accentColor color.Color) fyne.CanvasObject {
|
||||||
|
divider := canvas.NewRectangle(accentColor)
|
||||||
|
divider.SetMinSize(fyne.NewSize(0, 2))
|
||||||
|
return divider
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,12 @@ import (
|
||||||
|
|
||||||
// ModuleInfo contains information about a module for display
|
// ModuleInfo contains information about a module for display
|
||||||
type ModuleInfo struct {
|
type ModuleInfo struct {
|
||||||
ID string
|
ID string
|
||||||
Label string
|
Label string
|
||||||
Color color.Color
|
Color color.Color
|
||||||
Enabled bool
|
Enabled bool
|
||||||
Category string
|
Category string
|
||||||
|
MissingDependencies bool // true if disabled due to missing dependencies
|
||||||
}
|
}
|
||||||
|
|
||||||
// HistoryEntry represents a completed job in the history
|
// HistoryEntry represents a completed job in the history
|
||||||
|
|
@ -65,61 +66,95 @@ func BuildMainMenu(modules []ModuleInfo, onModuleClick func(string), onModuleDro
|
||||||
viewResultsBtn := widget.NewButton("Results", onBenchmarkHistoryClick)
|
viewResultsBtn := widget.NewButton("Results", onBenchmarkHistoryClick)
|
||||||
viewResultsBtn.Importance = widget.LowImportance
|
viewResultsBtn.Importance = widget.LowImportance
|
||||||
|
|
||||||
logsBtn := widget.NewButton("Logs", onLogsClick)
|
// Build header controls dynamically - only show logs button if callback is provided
|
||||||
logsBtn.Importance = widget.LowImportance
|
headerControls := []fyne.CanvasObject{sidebarToggleBtn}
|
||||||
|
if onLogsClick != nil {
|
||||||
|
logsBtn := widget.NewButton("Logs", onLogsClick)
|
||||||
|
logsBtn.Importance = widget.LowImportance
|
||||||
|
headerControls = append(headerControls, logsBtn)
|
||||||
|
}
|
||||||
|
headerControls = append(headerControls, benchmarkBtn, viewResultsBtn, queueTile)
|
||||||
|
|
||||||
// Compact header - title on left, controls on right
|
// Compact header - title on left, controls on right
|
||||||
header := container.NewBorder(
|
header := container.NewBorder(
|
||||||
nil, nil,
|
nil, nil,
|
||||||
title,
|
title,
|
||||||
container.NewHBox(sidebarToggleBtn, logsBtn, benchmarkBtn, viewResultsBtn, queueTile),
|
container.NewHBox(headerControls...),
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
categorized := map[string][]fyne.CanvasObject{}
|
// Create module map for quick lookup
|
||||||
for i := range modules {
|
moduleMap := make(map[string]ModuleInfo)
|
||||||
mod := modules[i] // Create new variable for this iteration
|
for _, mod := range modules {
|
||||||
modID := mod.ID // Capture for closure
|
moduleMap[mod.ID] = mod
|
||||||
cat := mod.Category
|
}
|
||||||
if cat == "" {
|
|
||||||
cat = "General"
|
// Helper to build a tile
|
||||||
|
buildTile := func(modID string) fyne.CanvasObject {
|
||||||
|
mod, exists := moduleMap[modID]
|
||||||
|
if !exists {
|
||||||
|
return layout.NewSpacer()
|
||||||
}
|
}
|
||||||
|
|
||||||
var tapFunc func()
|
var tapFunc func()
|
||||||
var dropFunc func([]fyne.URI)
|
var dropFunc func([]fyne.URI)
|
||||||
if mod.Enabled {
|
if mod.Enabled {
|
||||||
// Create new closure with properly captured modID
|
id := modID
|
||||||
id := modID // Explicit capture
|
tapFunc = func() { onModuleClick(id) }
|
||||||
tapFunc = func() {
|
|
||||||
onModuleClick(id)
|
|
||||||
}
|
|
||||||
dropFunc = func(items []fyne.URI) {
|
dropFunc = func(items []fyne.URI) {
|
||||||
logging.Debug(logging.CatUI, "MainMenu dropFunc called for module=%s itemCount=%d", id, len(items))
|
logging.Debug(logging.CatUI, "MainMenu dropFunc called for module=%s itemCount=%d", id, len(items))
|
||||||
onModuleDrop(id, items)
|
onModuleDrop(id, items)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logging.Debug(logging.CatUI, "Creating tile for module=%s enabled=%v hasDropFunc=%v", modID, mod.Enabled, dropFunc != nil)
|
return buildModuleTile(mod, tapFunc, dropFunc)
|
||||||
categorized[cat] = append(categorized[cat], buildModuleTile(mod, tapFunc, dropFunc))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var sections []fyne.CanvasObject
|
// Helper to create category label
|
||||||
for _, cat := range sortedKeys(categorized) {
|
makeCatLabel := func(text string) *canvas.Text {
|
||||||
catLabel := canvas.NewText(cat, textColor)
|
label := canvas.NewText(text, textColor)
|
||||||
catLabel.TextSize = 12
|
label.TextSize = 10
|
||||||
catLabel.TextStyle = fyne.TextStyle{Bold: true}
|
label.Alignment = fyne.TextAlignLeading
|
||||||
sections = append(sections,
|
return label
|
||||||
catLabel,
|
|
||||||
container.NewGridWithColumns(3, categorized[cat]...),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
padding := canvas.NewRectangle(color.Transparent)
|
// Build rows with category labels above tiles
|
||||||
padding.SetMinSize(fyne.NewSize(0, 4))
|
var rows []fyne.CanvasObject
|
||||||
|
|
||||||
// Compact body without scrolling
|
// Convert section
|
||||||
body := container.NewVBox(
|
rows = append(rows, makeCatLabel("Convert"))
|
||||||
|
rows = append(rows, container.NewGridWithColumns(3,
|
||||||
|
buildTile("convert"), buildTile("merge"), buildTile("trim"),
|
||||||
|
))
|
||||||
|
rows = append(rows, container.NewGridWithColumns(3,
|
||||||
|
buildTile("filters"), buildTile("audio"), buildTile("subtitles"),
|
||||||
|
))
|
||||||
|
|
||||||
|
// Inspect section
|
||||||
|
rows = append(rows, makeCatLabel("Inspect"))
|
||||||
|
rows = append(rows, container.NewGridWithColumns(3,
|
||||||
|
buildTile("compare"), buildTile("inspect"), buildTile("upscale"),
|
||||||
|
))
|
||||||
|
|
||||||
|
// Disc section
|
||||||
|
rows = append(rows, makeCatLabel("Disc"))
|
||||||
|
rows = append(rows, container.NewGridWithColumns(3,
|
||||||
|
buildTile("author"), buildTile("rip"), buildTile("bluray"),
|
||||||
|
))
|
||||||
|
|
||||||
|
// Playback section
|
||||||
|
rows = append(rows, makeCatLabel("Playback"))
|
||||||
|
rows = append(rows, container.NewGridWithColumns(3,
|
||||||
|
buildTile("player"), buildTile("thumb"), buildTile("settings"),
|
||||||
|
))
|
||||||
|
|
||||||
|
gridBox := container.NewVBox(rows...)
|
||||||
|
scroll := container.NewVScroll(gridBox)
|
||||||
|
scroll.SetMinSize(fyne.NewSize(0, 0))
|
||||||
|
|
||||||
|
body := container.NewBorder(
|
||||||
header,
|
header,
|
||||||
padding,
|
nil, nil, nil,
|
||||||
container.NewVBox(sections...),
|
scroll,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Wrap with HSplit if sidebar is visible
|
// Wrap with HSplit if sidebar is visible
|
||||||
|
|
@ -134,8 +169,8 @@ func BuildMainMenu(modules []ModuleInfo, onModuleClick func(string), onModuleDro
|
||||||
|
|
||||||
// buildModuleTile creates a single module tile
|
// buildModuleTile creates a single module tile
|
||||||
func buildModuleTile(mod ModuleInfo, tapped func(), dropped func([]fyne.URI)) fyne.CanvasObject {
|
func buildModuleTile(mod ModuleInfo, tapped func(), dropped func([]fyne.URI)) fyne.CanvasObject {
|
||||||
logging.Debug(logging.CatUI, "building tile %s color=%v enabled=%v", mod.ID, mod.Color, mod.Enabled)
|
logging.Debug(logging.CatUI, "building tile %s color=%v enabled=%v missingDeps=%v", mod.ID, mod.Color, mod.Enabled, mod.MissingDependencies)
|
||||||
return NewModuleTile(mod.Label, mod.Color, mod.Enabled, tapped, dropped)
|
return NewModuleTile(mod.Label, mod.Color, mod.Enabled, mod.MissingDependencies, tapped, dropped)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildQueueTile creates the queue status tile
|
// buildQueueTile creates the queue status tile
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
|
|
@ -23,6 +24,9 @@ type StripedProgress struct {
|
||||||
color color.Color
|
color color.Color
|
||||||
bg color.Color
|
bg color.Color
|
||||||
offset float64
|
offset float64
|
||||||
|
activity bool
|
||||||
|
animMu sync.Mutex
|
||||||
|
animStop chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStripedProgress creates a new striped progress bar with the given color
|
// NewStripedProgress creates a new striped progress bar with the given color
|
||||||
|
|
@ -48,13 +52,68 @@ func (s *StripedProgress) SetProgress(p float64) {
|
||||||
s.Refresh()
|
s.Refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetActivity toggles the full-width animated background when progress is near zero.
|
||||||
|
func (s *StripedProgress) SetActivity(active bool) {
|
||||||
|
s.activity = active
|
||||||
|
s.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartAnimation starts the stripe animation.
|
||||||
|
func (s *StripedProgress) StartAnimation() {
|
||||||
|
s.animMu.Lock()
|
||||||
|
if s.animStop != nil {
|
||||||
|
s.animMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stop := make(chan struct{})
|
||||||
|
s.animStop = stop
|
||||||
|
s.animMu.Unlock()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(80 * time.Millisecond)
|
||||||
|
go func() {
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
app := fyne.CurrentApp()
|
||||||
|
if app == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
||||||
|
s.Refresh()
|
||||||
|
}, false)
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopAnimation stops the stripe animation.
|
||||||
|
func (s *StripedProgress) StopAnimation() {
|
||||||
|
s.animMu.Lock()
|
||||||
|
if s.animStop == nil {
|
||||||
|
s.animMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
close(s.animStop)
|
||||||
|
s.animStop = nil
|
||||||
|
s.animMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *StripedProgress) CreateRenderer() fyne.WidgetRenderer {
|
func (s *StripedProgress) CreateRenderer() fyne.WidgetRenderer {
|
||||||
bgRect := canvas.NewRectangle(s.bg)
|
bgRect := canvas.NewRectangle(s.bg)
|
||||||
fillRect := canvas.NewRectangle(applyAlpha(s.color, 200))
|
fillRect := canvas.NewRectangle(applyAlpha(s.color, 200))
|
||||||
stripes := canvas.NewRaster(func(w, h int) image.Image {
|
stripes := canvas.NewRaster(func(w, h int) image.Image {
|
||||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
light := applyAlpha(s.color, 80)
|
lightAlpha := uint8(80)
|
||||||
dark := applyAlpha(s.color, 220)
|
darkAlpha := uint8(220)
|
||||||
|
if s.activity && s.progress <= 0 {
|
||||||
|
lightAlpha = 40
|
||||||
|
darkAlpha = 90
|
||||||
|
}
|
||||||
|
light := applyAlpha(s.color, lightAlpha)
|
||||||
|
dark := applyAlpha(s.color, darkAlpha)
|
||||||
for y := 0; y < h; y++ {
|
for y := 0; y < h; y++ {
|
||||||
for x := 0; x < w; x++ {
|
for x := 0; x < w; x++ {
|
||||||
// animate diagonal stripes using offset
|
// animate diagonal stripes using offset
|
||||||
|
|
@ -93,12 +152,17 @@ func (r *stripedProgressRenderer) Layout(size fyne.Size) {
|
||||||
r.bg.Move(fyne.NewPos(0, 0))
|
r.bg.Move(fyne.NewPos(0, 0))
|
||||||
|
|
||||||
fillWidth := size.Width * float32(r.bar.progress)
|
fillWidth := size.Width * float32(r.bar.progress)
|
||||||
|
stripeWidth := fillWidth
|
||||||
|
if r.bar.activity && r.bar.progress <= 0 {
|
||||||
|
stripeWidth = size.Width
|
||||||
|
}
|
||||||
fillSize := fyne.NewSize(fillWidth, size.Height)
|
fillSize := fyne.NewSize(fillWidth, size.Height)
|
||||||
|
stripeSize := fyne.NewSize(stripeWidth, size.Height)
|
||||||
|
|
||||||
r.fill.Resize(fillSize)
|
r.fill.Resize(fillSize)
|
||||||
r.fill.Move(fyne.NewPos(0, 0))
|
r.fill.Move(fyne.NewPos(0, 0))
|
||||||
|
|
||||||
r.stripes.Resize(fillSize)
|
r.stripes.Resize(stripeSize)
|
||||||
r.stripes.Move(fyne.NewPos(0, 0))
|
r.stripes.Move(fyne.NewPos(0, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,8 +171,14 @@ func (r *stripedProgressRenderer) MinSize() fyne.Size {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *stripedProgressRenderer) Refresh() {
|
func (r *stripedProgressRenderer) Refresh() {
|
||||||
// small drift to animate stripes
|
// Only animate stripes when animation is active
|
||||||
r.bar.offset += 2
|
r.bar.animMu.Lock()
|
||||||
|
shouldAnimate := r.bar.animStop != nil
|
||||||
|
r.bar.animMu.Unlock()
|
||||||
|
|
||||||
|
if shouldAnimate {
|
||||||
|
r.bar.offset += 2
|
||||||
|
}
|
||||||
r.Layout(r.bg.Size())
|
r.Layout(r.bg.Size())
|
||||||
canvas.Refresh(r.bg)
|
canvas.Refresh(r.bg)
|
||||||
canvas.Refresh(r.stripes)
|
canvas.Refresh(r.stripes)
|
||||||
|
|
@ -116,7 +186,7 @@ func (r *stripedProgressRenderer) Refresh() {
|
||||||
|
|
||||||
func (r *stripedProgressRenderer) BackgroundColor() color.Color { return color.Transparent }
|
func (r *stripedProgressRenderer) BackgroundColor() color.Color { return color.Transparent }
|
||||||
func (r *stripedProgressRenderer) Objects() []fyne.CanvasObject { return r.objects }
|
func (r *stripedProgressRenderer) Objects() []fyne.CanvasObject { return r.objects }
|
||||||
func (r *stripedProgressRenderer) Destroy() {}
|
func (r *stripedProgressRenderer) Destroy() { r.bar.StopAnimation() }
|
||||||
|
|
||||||
func applyAlpha(c color.Color, alpha uint8) color.Color {
|
func applyAlpha(c color.Color, alpha uint8) color.Color {
|
||||||
r, g, b, _ := c.RGBA()
|
r, g, b, _ := c.RGBA()
|
||||||
|
|
@ -142,7 +212,9 @@ func BuildQueueView(
|
||||||
onViewLog func(string),
|
onViewLog func(string),
|
||||||
onCopyCommand func(string),
|
onCopyCommand func(string),
|
||||||
titleColor, bgColor, textColor color.Color,
|
titleColor, bgColor, textColor color.Color,
|
||||||
) (fyne.CanvasObject, *container.Scroll) {
|
) (fyne.CanvasObject, *container.Scroll, []*StripedProgress) {
|
||||||
|
// Track active progress animations to prevent goroutine leaks
|
||||||
|
var activeProgress []*StripedProgress
|
||||||
// Header
|
// Header
|
||||||
title := canvas.NewText("JOB QUEUE", titleColor)
|
title := canvas.NewText("JOB QUEUE", titleColor)
|
||||||
title.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
|
title.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
|
||||||
|
|
@ -183,8 +255,18 @@ func BuildQueueView(
|
||||||
emptyMsg.Alignment = fyne.TextAlignCenter
|
emptyMsg.Alignment = fyne.TextAlignCenter
|
||||||
jobItems = append(jobItems, container.NewCenter(emptyMsg))
|
jobItems = append(jobItems, container.NewCenter(emptyMsg))
|
||||||
} else {
|
} else {
|
||||||
|
// Calculate queue positions for pending/paused jobs
|
||||||
|
queuePositions := make(map[string]int)
|
||||||
|
position := 1
|
||||||
for _, job := range jobs {
|
for _, job := range jobs {
|
||||||
jobItems = append(jobItems, buildJobItem(job, onPause, onResume, onCancel, onRemove, onMoveUp, onMoveDown, onCopyError, onViewLog, onCopyCommand, bgColor, textColor))
|
if job.Status == queue.JobStatusPending || job.Status == queue.JobStatusPaused {
|
||||||
|
queuePositions[job.ID] = position
|
||||||
|
position++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, job := range jobs {
|
||||||
|
jobItems = append(jobItems, buildJobItem(job, queuePositions, onPause, onResume, onCancel, onRemove, onMoveUp, onMoveDown, onCopyError, onViewLog, onCopyCommand, bgColor, textColor, &activeProgress))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,12 +282,13 @@ func BuildQueueView(
|
||||||
scrollable,
|
scrollable,
|
||||||
)
|
)
|
||||||
|
|
||||||
return container.NewPadded(body), scrollable
|
return container.NewPadded(body), scrollable, activeProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildJobItem creates a single job item in the queue list
|
// buildJobItem creates a single job item in the queue list
|
||||||
func buildJobItem(
|
func buildJobItem(
|
||||||
job *queue.Job,
|
job *queue.Job,
|
||||||
|
queuePositions map[string]int,
|
||||||
onPause func(string),
|
onPause func(string),
|
||||||
onResume func(string),
|
onResume func(string),
|
||||||
onCancel func(string),
|
onCancel func(string),
|
||||||
|
|
@ -216,6 +299,7 @@ func buildJobItem(
|
||||||
onViewLog func(string),
|
onViewLog func(string),
|
||||||
onCopyCommand func(string),
|
onCopyCommand func(string),
|
||||||
bgColor, textColor color.Color,
|
bgColor, textColor color.Color,
|
||||||
|
activeProgress *[]*StripedProgress,
|
||||||
) fyne.CanvasObject {
|
) fyne.CanvasObject {
|
||||||
// Status color
|
// Status color
|
||||||
statusColor := GetStatusColor(job.Status)
|
statusColor := GetStatusColor(job.Status)
|
||||||
|
|
@ -233,7 +317,7 @@ func buildJobItem(
|
||||||
|
|
||||||
descLabel := widget.NewLabel(descText)
|
descLabel := widget.NewLabel(descText)
|
||||||
descLabel.TextStyle = fyne.TextStyle{Italic: true}
|
descLabel.TextStyle = fyne.TextStyle{Italic: true}
|
||||||
descLabel.Wrapping = fyne.TextWrapWord
|
descLabel.Wrapping = fyne.TextTruncate
|
||||||
|
|
||||||
// Progress bar (for running jobs)
|
// Progress bar (for running jobs)
|
||||||
progress := NewStripedProgress(ModuleColor(job.Type))
|
progress := NewStripedProgress(ModuleColor(job.Type))
|
||||||
|
|
@ -241,16 +325,25 @@ func buildJobItem(
|
||||||
if job.Status == queue.JobStatusCompleted {
|
if job.Status == queue.JobStatusCompleted {
|
||||||
progress.SetProgress(1.0)
|
progress.SetProgress(1.0)
|
||||||
}
|
}
|
||||||
|
if job.Status == queue.JobStatusRunning {
|
||||||
|
progress.SetActivity(job.Progress <= 0.01)
|
||||||
|
progress.StartAnimation()
|
||||||
|
// Track active progress to stop animation on next refresh (prevents goroutine leaks)
|
||||||
|
*activeProgress = append(*activeProgress, progress)
|
||||||
|
} else {
|
||||||
|
progress.SetActivity(false)
|
||||||
|
progress.StopAnimation()
|
||||||
|
}
|
||||||
progressWidget := progress
|
progressWidget := progress
|
||||||
|
|
||||||
// Module badge
|
// Module badge
|
||||||
badge := BuildModuleBadge(job.Type)
|
badge := BuildModuleBadge(job.Type)
|
||||||
|
|
||||||
// Status text
|
// Status text
|
||||||
statusText := getStatusText(job)
|
statusText := getStatusText(job, queuePositions)
|
||||||
statusLabel := widget.NewLabel(statusText)
|
statusLabel := widget.NewLabel(statusText)
|
||||||
statusLabel.TextStyle = fyne.TextStyle{Monospace: true}
|
statusLabel.TextStyle = fyne.TextStyle{Monospace: true}
|
||||||
statusLabel.Wrapping = fyne.TextWrapWord
|
statusLabel.Wrapping = fyne.TextTruncate
|
||||||
|
|
||||||
// Control buttons
|
// Control buttons
|
||||||
var buttons []fyne.CanvasObject
|
var buttons []fyne.CanvasObject
|
||||||
|
|
@ -316,6 +409,7 @@ func buildJobItem(
|
||||||
// Card background
|
// Card background
|
||||||
card := canvas.NewRectangle(bgColor)
|
card := canvas.NewRectangle(bgColor)
|
||||||
card.CornerRadius = 4
|
card.CornerRadius = 4
|
||||||
|
card.SetMinSize(fyne.NewSize(0, 140)) // Fixed minimum height to prevent jumping
|
||||||
|
|
||||||
item := container.NewPadded(
|
item := container.NewPadded(
|
||||||
container.NewMax(card, content),
|
container.NewMax(card, content),
|
||||||
|
|
@ -332,10 +426,14 @@ func buildJobItem(
|
||||||
}
|
}
|
||||||
|
|
||||||
// getStatusText returns a human-readable status string
|
// getStatusText returns a human-readable status string
|
||||||
func getStatusText(job *queue.Job) string {
|
func getStatusText(job *queue.Job, queuePositions map[string]int) string {
|
||||||
switch job.Status {
|
switch job.Status {
|
||||||
case queue.JobStatusPending:
|
case queue.JobStatusPending:
|
||||||
return fmt.Sprintf("Status: Pending | Priority: %d", job.Priority)
|
// Display position in queue (1 = first to run, 2 = second, etc.)
|
||||||
|
if pos, ok := queuePositions[job.ID]; ok {
|
||||||
|
return fmt.Sprintf("Status: Pending | Queue Position: %d", pos)
|
||||||
|
}
|
||||||
|
return "Status: Pending"
|
||||||
case queue.JobStatusRunning:
|
case queue.JobStatusRunning:
|
||||||
elapsed := ""
|
elapsed := ""
|
||||||
if job.StartedAt != nil {
|
if job.StartedAt != nil {
|
||||||
|
|
@ -358,6 +456,10 @@ func getStatusText(job *queue.Job) string {
|
||||||
|
|
||||||
return fmt.Sprintf("Status: Running | Progress: %.1f%%%s%s", job.Progress, elapsed, extras)
|
return fmt.Sprintf("Status: Running | Progress: %.1f%%%s%s", job.Progress, elapsed, extras)
|
||||||
case queue.JobStatusPaused:
|
case queue.JobStatusPaused:
|
||||||
|
// Display position in queue for paused jobs too
|
||||||
|
if pos, ok := queuePositions[job.ID]; ok {
|
||||||
|
return fmt.Sprintf("Status: Paused | Queue Position: %d", pos)
|
||||||
|
}
|
||||||
return "Status: Paused"
|
return "Status: Paused"
|
||||||
case queue.JobStatusCompleted:
|
case queue.JobStatusCompleted:
|
||||||
duration := ""
|
duration := ""
|
||||||
|
|
@ -380,24 +482,27 @@ func getStatusText(job *queue.Job) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// moduleColor maps job types to distinct colors matching the main module colors
|
// ModuleColor returns rainbow ROYGBIV colors matching main module palette
|
||||||
// ModuleColor returns the color for a given job type
|
|
||||||
func ModuleColor(t queue.JobType) color.Color {
|
func ModuleColor(t queue.JobType) color.Color {
|
||||||
switch t {
|
switch t {
|
||||||
case queue.JobTypeConvert:
|
case queue.JobTypeConvert:
|
||||||
return color.RGBA{R: 139, G: 68, B: 255, A: 255} // Violet (#8B44FF)
|
return color.RGBA{R: 103, G: 58, B: 183, A: 255} // Deep Purple (#673AB7)
|
||||||
case queue.JobTypeMerge:
|
case queue.JobTypeMerge:
|
||||||
return color.RGBA{R: 68, G: 136, B: 255, A: 255} // Blue (#4488FF)
|
return color.RGBA{R: 76, G: 175, B: 80, A: 255} // Green (#4CAF50)
|
||||||
case queue.JobTypeTrim:
|
case queue.JobTypeTrim:
|
||||||
return color.RGBA{R: 68, G: 221, B: 255, A: 255} // Cyan (#44DDFF)
|
return color.RGBA{R: 255, G: 235, B: 59, A: 255} // Yellow (#FFEB3B)
|
||||||
case queue.JobTypeFilter:
|
case queue.JobTypeFilter:
|
||||||
return color.RGBA{R: 68, G: 255, B: 136, A: 255} // Green (#44FF88)
|
return color.RGBA{R: 0, G: 188, B: 212, A: 255} // Cyan (#00BCD4)
|
||||||
case queue.JobTypeUpscale:
|
case queue.JobTypeUpscale:
|
||||||
return color.RGBA{R: 170, G: 255, B: 68, A: 255} // Yellow-Green (#AAFF44)
|
return color.RGBA{R: 156, G: 39, B: 176, A: 255} // Purple (#9C27B0)
|
||||||
case queue.JobTypeAudio:
|
case queue.JobTypeAudio:
|
||||||
return color.RGBA{R: 255, G: 215, B: 68, A: 255} // Yellow (#FFD744)
|
return color.RGBA{R: 255, G: 193, B: 7, A: 255} // Amber (#FFC107)
|
||||||
case queue.JobTypeThumb:
|
case queue.JobTypeThumb:
|
||||||
return color.RGBA{R: 255, G: 136, B: 68, A: 255} // Orange (#FF8844)
|
return color.RGBA{R: 0, G: 172, B: 193, A: 255} // Dark Cyan (#00ACC1)
|
||||||
|
case queue.JobTypeAuthor:
|
||||||
|
return color.RGBA{R: 255, G: 87, B: 34, A: 255} // Deep Orange (#FF5722)
|
||||||
|
case queue.JobTypeRip:
|
||||||
|
return color.RGBA{R: 255, G: 152, B: 0, A: 255} // Orange (#FF9800)
|
||||||
default:
|
default:
|
||||||
return color.Gray{Y: 180}
|
return color.Gray{Y: 180}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14387
main.go.backup-before-inspect-extraction
Normal file
14387
main.go.backup-before-inspect-extraction
Normal file
File diff suppressed because it is too large
Load Diff
97
merge_config.go
Normal file
97
merge_config.go
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mergeConfig struct {
|
||||||
|
Format string `json:"format"`
|
||||||
|
KeepAllStreams bool `json:"keepAllStreams"`
|
||||||
|
Chapters bool `json:"chapters"`
|
||||||
|
CodecMode string `json:"codecMode"`
|
||||||
|
DVDRegion string `json:"dvdRegion"`
|
||||||
|
DVDAspect string `json:"dvdAspect"`
|
||||||
|
FrameRate string `json:"frameRate"`
|
||||||
|
MotionInterpolation bool `json:"motionInterpolation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultMergeConfig() mergeConfig {
|
||||||
|
return mergeConfig{
|
||||||
|
Format: "mkv-copy",
|
||||||
|
KeepAllStreams: false,
|
||||||
|
Chapters: true,
|
||||||
|
CodecMode: "",
|
||||||
|
DVDRegion: "NTSC",
|
||||||
|
DVDAspect: "16:9",
|
||||||
|
FrameRate: "Source",
|
||||||
|
MotionInterpolation: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPersistedMergeConfig() (mergeConfig, error) {
|
||||||
|
var cfg mergeConfig
|
||||||
|
path := moduleConfigPath("merge")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
if cfg.Format == "" {
|
||||||
|
cfg.Format = "mkv-copy"
|
||||||
|
}
|
||||||
|
if cfg.DVDRegion == "" {
|
||||||
|
cfg.DVDRegion = "NTSC"
|
||||||
|
}
|
||||||
|
if cfg.DVDAspect == "" {
|
||||||
|
cfg.DVDAspect = "16:9"
|
||||||
|
}
|
||||||
|
if cfg.FrameRate == "" {
|
||||||
|
cfg.FrameRate = "Source"
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func savePersistedMergeConfig(cfg mergeConfig) error {
|
||||||
|
path := moduleConfigPath("merge")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) applyMergeConfig(cfg mergeConfig) {
|
||||||
|
s.mergeFormat = cfg.Format
|
||||||
|
s.mergeKeepAll = cfg.KeepAllStreams
|
||||||
|
s.mergeChapters = cfg.Chapters
|
||||||
|
s.mergeCodecMode = cfg.CodecMode
|
||||||
|
s.mergeDVDRegion = cfg.DVDRegion
|
||||||
|
s.mergeDVDAspect = cfg.DVDAspect
|
||||||
|
s.mergeFrameRate = cfg.FrameRate
|
||||||
|
s.mergeMotionInterpolation = cfg.MotionInterpolation
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) persistMergeConfig() {
|
||||||
|
cfg := mergeConfig{
|
||||||
|
Format: s.mergeFormat,
|
||||||
|
KeepAllStreams: s.mergeKeepAll,
|
||||||
|
Chapters: s.mergeChapters,
|
||||||
|
CodecMode: s.mergeCodecMode,
|
||||||
|
DVDRegion: s.mergeDVDRegion,
|
||||||
|
DVDAspect: s.mergeDVDAspect,
|
||||||
|
FrameRate: s.mergeFrameRate,
|
||||||
|
MotionInterpolation: s.mergeMotionInterpolation,
|
||||||
|
}
|
||||||
|
if err := savePersistedMergeConfig(cfg); err != nil {
|
||||||
|
logging.Debug(logging.CatSystem, "failed to persist merge config: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,14 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func defaultOutputBase(src *videoSource) string {
|
func defaultOutputBase(src *videoSource) string {
|
||||||
|
if src == nil {
|
||||||
|
return "converted"
|
||||||
|
}
|
||||||
|
base := strings.TrimSuffix(src.DisplayName, filepath.Ext(src.DisplayName))
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultOutputBaseWithSuffix(src *videoSource) string {
|
||||||
if src == nil {
|
if src == nil {
|
||||||
return "converted"
|
return "converted"
|
||||||
}
|
}
|
||||||
|
|
@ -19,7 +27,13 @@ func defaultOutputBase(src *videoSource) string {
|
||||||
// resolveOutputBase returns the output base for a source.
|
// resolveOutputBase returns the output base for a source.
|
||||||
// keepExisting preserves manual edits when auto-naming is disabled; it is ignored when auto-naming is on.
|
// keepExisting preserves manual edits when auto-naming is disabled; it is ignored when auto-naming is on.
|
||||||
func (s *appState) resolveOutputBase(src *videoSource, keepExisting bool) string {
|
func (s *appState) resolveOutputBase(src *videoSource, keepExisting bool) string {
|
||||||
fallback := defaultOutputBase(src)
|
// Use suffix if AppendSuffix is enabled
|
||||||
|
var fallback string
|
||||||
|
if s.convert.AppendSuffix {
|
||||||
|
fallback = defaultOutputBaseWithSuffix(src)
|
||||||
|
} else {
|
||||||
|
fallback = defaultOutputBase(src)
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-naming overrides manual values.
|
// Auto-naming overrides manual values.
|
||||||
if s.convert.UseAutoNaming && src != nil && strings.TrimSpace(s.convert.AutoNameTemplate) != "" {
|
if s.convert.UseAutoNaming && src != nil && strings.TrimSpace(s.convert.AutoNameTemplate) != "" {
|
||||||
|
|
|
||||||
706
rip_module.go
Normal file
706
rip_module.go
Normal file
|
|
@ -0,0 +1,706 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/dialog"
|
||||||
|
"fyne.io/fyne/v2/layout"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/logging"
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/queue"
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/ui"
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ripFormatLosslessMKV = "Lossless MKV (Copy)"
|
||||||
|
ripFormatH264MKV = "H.264 MKV (CRF 18)"
|
||||||
|
ripFormatH264MP4 = "H.264 MP4 (CRF 18)"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ripConfig struct {
|
||||||
|
Format string `json:"format"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultRipConfig() ripConfig {
|
||||||
|
return ripConfig{
|
||||||
|
Format: ripFormatLosslessMKV,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPersistedRipConfig() (ripConfig, error) {
|
||||||
|
var cfg ripConfig
|
||||||
|
path := moduleConfigPath("rip")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
if cfg.Format == "" {
|
||||||
|
cfg.Format = ripFormatLosslessMKV
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func savePersistedRipConfig(cfg ripConfig) error {
|
||||||
|
path := moduleConfigPath("rip")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) applyRipConfig(cfg ripConfig) {
|
||||||
|
s.ripFormat = cfg.Format
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) persistRipConfig() {
|
||||||
|
cfg := ripConfig{
|
||||||
|
Format: s.ripFormat,
|
||||||
|
}
|
||||||
|
if err := savePersistedRipConfig(cfg); err != nil {
|
||||||
|
logging.Debug(logging.CatSystem, "failed to persist rip config: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) showRipView() {
|
||||||
|
s.stopPreview()
|
||||||
|
s.lastModule = s.active
|
||||||
|
s.active = "rip"
|
||||||
|
|
||||||
|
if cfg, err := loadPersistedRipConfig(); err == nil {
|
||||||
|
s.applyRipConfig(cfg)
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
logging.Debug(logging.CatSystem, "failed to load persisted rip config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.ripFormat == "" {
|
||||||
|
s.ripFormat = ripFormatLosslessMKV
|
||||||
|
}
|
||||||
|
if s.ripStatusLabel != nil {
|
||||||
|
s.ripStatusLabel.SetText("Ready")
|
||||||
|
}
|
||||||
|
s.setContent(buildRipView(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRipView(state *appState) fyne.CanvasObject {
|
||||||
|
ripColor := moduleColor("rip")
|
||||||
|
|
||||||
|
backBtn := widget.NewButton("< BACK", func() {
|
||||||
|
state.showMainMenu()
|
||||||
|
})
|
||||||
|
backBtn.Importance = widget.LowImportance
|
||||||
|
|
||||||
|
queueBtn := widget.NewButton("View Queue", func() {
|
||||||
|
state.showQueue()
|
||||||
|
})
|
||||||
|
state.queueBtn = queueBtn
|
||||||
|
state.updateQueueButtonLabel()
|
||||||
|
|
||||||
|
clearCompletedBtn := widget.NewButton("⌫", func() {
|
||||||
|
state.clearCompletedJobs()
|
||||||
|
})
|
||||||
|
clearCompletedBtn.Importance = widget.LowImportance
|
||||||
|
|
||||||
|
topBar := ui.TintedBar(ripColor, container.NewHBox(backBtn, layout.NewSpacer(), clearCompletedBtn, queueBtn))
|
||||||
|
bottomBar := moduleFooter(ripColor, layout.NewSpacer(), state.statsBar)
|
||||||
|
|
||||||
|
sourceEntry := widget.NewEntry()
|
||||||
|
sourceEntry.SetPlaceHolder("Drop DVD/ISO/VIDEO_TS path here")
|
||||||
|
sourceEntry.SetText(state.ripSourcePath)
|
||||||
|
sourceEntry.OnChanged = func(val string) {
|
||||||
|
state.ripSourcePath = strings.TrimSpace(val)
|
||||||
|
state.ripOutputPath = defaultRipOutputPath(state.ripSourcePath, state.ripFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
outputEntry := widget.NewEntry()
|
||||||
|
outputEntry.SetPlaceHolder("Output path")
|
||||||
|
outputEntry.SetText(state.ripOutputPath)
|
||||||
|
outputEntry.OnChanged = func(val string) {
|
||||||
|
state.ripOutputPath = strings.TrimSpace(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
formatSelect := widget.NewSelect([]string{ripFormatLosslessMKV, ripFormatH264MKV, ripFormatH264MP4}, func(val string) {
|
||||||
|
state.ripFormat = val
|
||||||
|
state.ripOutputPath = defaultRipOutputPath(state.ripSourcePath, state.ripFormat)
|
||||||
|
outputEntry.SetText(state.ripOutputPath)
|
||||||
|
state.persistRipConfig()
|
||||||
|
})
|
||||||
|
formatSelect.SetSelected(state.ripFormat)
|
||||||
|
|
||||||
|
statusLabel := widget.NewLabel("Ready")
|
||||||
|
statusLabel.Wrapping = fyne.TextWrapWord
|
||||||
|
state.ripStatusLabel = statusLabel
|
||||||
|
|
||||||
|
progressBar := widget.NewProgressBar()
|
||||||
|
progressBar.SetValue(state.ripProgress / 100.0)
|
||||||
|
state.ripProgressBar = progressBar
|
||||||
|
|
||||||
|
logEntry := widget.NewMultiLineEntry()
|
||||||
|
logEntry.Wrapping = fyne.TextWrapOff
|
||||||
|
logEntry.Disable()
|
||||||
|
logEntry.SetText(state.ripLogText)
|
||||||
|
state.ripLogEntry = logEntry
|
||||||
|
logScroll := container.NewVScroll(logEntry)
|
||||||
|
logScroll.SetMinSize(fyne.NewSize(0, 200))
|
||||||
|
state.ripLogScroll = logScroll
|
||||||
|
|
||||||
|
addQueueBtn := widget.NewButton("Add Rip to Queue", func() {
|
||||||
|
if err := state.addRipToQueue(false); err != nil {
|
||||||
|
dialog.ShowError(err, state.window)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dialog.ShowInformation("Queue", "Rip job added to queue.", state.window)
|
||||||
|
if state.jobQueue != nil && !state.jobQueue.IsRunning() {
|
||||||
|
state.jobQueue.Start()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addQueueBtn.Importance = widget.MediumImportance
|
||||||
|
|
||||||
|
runNowBtn := widget.NewButton("Rip Now", func() {
|
||||||
|
if err := state.addRipToQueue(true); err != nil {
|
||||||
|
dialog.ShowError(err, state.window)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if state.jobQueue != nil && !state.jobQueue.IsRunning() {
|
||||||
|
state.jobQueue.Start()
|
||||||
|
}
|
||||||
|
dialog.ShowInformation("Rip", "Rip started! Track progress in Job Queue.", state.window)
|
||||||
|
})
|
||||||
|
runNowBtn.Importance = widget.HighImportance
|
||||||
|
|
||||||
|
applyControls := func() {
|
||||||
|
formatSelect.SetSelected(state.ripFormat)
|
||||||
|
outputEntry.SetText(state.ripOutputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCfgBtn := widget.NewButton("Load Config", func() {
|
||||||
|
cfg, err := loadPersistedRipConfig()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
dialog.ShowInformation("No Config", "No saved config found yet. It will save automatically after your first change.", state.window)
|
||||||
|
} else {
|
||||||
|
dialog.ShowError(fmt.Errorf("failed to load config: %w", err), state.window)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.applyRipConfig(cfg)
|
||||||
|
state.ripOutputPath = defaultRipOutputPath(state.ripSourcePath, state.ripFormat)
|
||||||
|
applyControls()
|
||||||
|
})
|
||||||
|
|
||||||
|
saveCfgBtn := widget.NewButton("Save Config", func() {
|
||||||
|
cfg := ripConfig{
|
||||||
|
Format: state.ripFormat,
|
||||||
|
}
|
||||||
|
if err := savePersistedRipConfig(cfg); err != nil {
|
||||||
|
dialog.ShowError(fmt.Errorf("failed to save config: %w", err), state.window)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dialog.ShowInformation("Config Saved", fmt.Sprintf("Saved to %s", moduleConfigPath("rip")), state.window)
|
||||||
|
})
|
||||||
|
|
||||||
|
resetBtn := widget.NewButton("Reset", func() {
|
||||||
|
cfg := defaultRipConfig()
|
||||||
|
state.applyRipConfig(cfg)
|
||||||
|
state.ripOutputPath = defaultRipOutputPath(state.ripSourcePath, state.ripFormat)
|
||||||
|
applyControls()
|
||||||
|
state.persistRipConfig()
|
||||||
|
})
|
||||||
|
|
||||||
|
clearISOBtn := widget.NewButton("Clear ISO", func() {
|
||||||
|
state.ripSourcePath = ""
|
||||||
|
state.ripOutputPath = ""
|
||||||
|
sourceEntry.SetText("")
|
||||||
|
outputEntry.SetText("")
|
||||||
|
})
|
||||||
|
clearISOBtn.Importance = widget.LowImportance
|
||||||
|
|
||||||
|
controls := container.NewVBox(
|
||||||
|
widget.NewLabelWithStyle("Source", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
ui.NewDroppable(sourceEntry, func(items []fyne.URI) {
|
||||||
|
path := firstLocalPath(items)
|
||||||
|
if path != "" {
|
||||||
|
state.ripSourcePath = path
|
||||||
|
sourceEntry.SetText(path)
|
||||||
|
state.ripOutputPath = defaultRipOutputPath(path, state.ripFormat)
|
||||||
|
outputEntry.SetText(state.ripOutputPath)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
clearISOBtn,
|
||||||
|
widget.NewLabelWithStyle("Format", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
formatSelect,
|
||||||
|
widget.NewLabelWithStyle("Output", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
outputEntry,
|
||||||
|
container.NewHBox(addQueueBtn, runNowBtn),
|
||||||
|
widget.NewSeparator(),
|
||||||
|
container.NewHBox(resetBtn, loadCfgBtn, saveCfgBtn),
|
||||||
|
widget.NewSeparator(),
|
||||||
|
widget.NewLabelWithStyle("Status", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
statusLabel,
|
||||||
|
progressBar,
|
||||||
|
widget.NewSeparator(),
|
||||||
|
widget.NewLabelWithStyle("Rip Log", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
logScroll,
|
||||||
|
)
|
||||||
|
|
||||||
|
return container.NewBorder(topBar, bottomBar, nil, nil, container.NewPadded(controls))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) addRipToQueue(startNow bool) error {
|
||||||
|
if s.jobQueue == nil {
|
||||||
|
return fmt.Errorf("queue not initialized")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s.ripSourcePath) == "" {
|
||||||
|
return fmt.Errorf("set a DVD/ISO/VIDEO_TS source path")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s.ripOutputPath) == "" {
|
||||||
|
s.ripOutputPath = defaultRipOutputPath(s.ripSourcePath, s.ripFormat)
|
||||||
|
}
|
||||||
|
job := &queue.Job{
|
||||||
|
Type: queue.JobTypeRip,
|
||||||
|
Title: fmt.Sprintf("Rip DVD: %s", filepath.Base(s.ripSourcePath)),
|
||||||
|
Description: fmt.Sprintf("Output: %s", utils.ShortenMiddle(filepath.Base(s.ripOutputPath), 40)),
|
||||||
|
InputFile: s.ripSourcePath,
|
||||||
|
OutputFile: s.ripOutputPath,
|
||||||
|
Config: map[string]interface{}{
|
||||||
|
"sourcePath": s.ripSourcePath,
|
||||||
|
"outputPath": s.ripOutputPath,
|
||||||
|
"format": s.ripFormat,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
s.resetRipLog()
|
||||||
|
s.setRipStatus("Queued rip job...")
|
||||||
|
s.setRipProgress(0)
|
||||||
|
s.jobQueue.Add(job)
|
||||||
|
if startNow && !s.jobQueue.IsRunning() {
|
||||||
|
s.jobQueue.Start()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) executeRipJob(ctx context.Context, job *queue.Job, progressCallback func(float64)) error {
|
||||||
|
cfg := job.Config
|
||||||
|
if cfg == nil {
|
||||||
|
return fmt.Errorf("rip job config missing")
|
||||||
|
}
|
||||||
|
sourcePath := toString(cfg["sourcePath"])
|
||||||
|
outputPath := toString(cfg["outputPath"])
|
||||||
|
format := toString(cfg["format"])
|
||||||
|
if sourcePath == "" || outputPath == "" {
|
||||||
|
return fmt.Errorf("rip job missing paths")
|
||||||
|
}
|
||||||
|
logFile, logPath, logErr := createRipLog(sourcePath, outputPath, format)
|
||||||
|
if logErr != nil {
|
||||||
|
logging.Debug(logging.CatSystem, "rip log open failed: %v", logErr)
|
||||||
|
} else {
|
||||||
|
job.LogPath = logPath
|
||||||
|
defer logFile.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
appendLog := func(line string) {
|
||||||
|
if logFile != nil {
|
||||||
|
fmt.Fprintln(logFile, line)
|
||||||
|
}
|
||||||
|
app := fyne.CurrentApp()
|
||||||
|
if app != nil && app.Driver() != nil {
|
||||||
|
app.Driver().DoFromGoroutine(func() {
|
||||||
|
s.appendRipLog(line)
|
||||||
|
}, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateProgress := func(percent float64) {
|
||||||
|
progressCallback(percent)
|
||||||
|
app := fyne.CurrentApp()
|
||||||
|
if app != nil && app.Driver() != nil {
|
||||||
|
app.Driver().DoFromGoroutine(func() {
|
||||||
|
s.setRipProgress(percent)
|
||||||
|
}, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appendLog(fmt.Sprintf("Rip started: %s", time.Now().Format(time.RFC3339)))
|
||||||
|
appendLog(fmt.Sprintf("Source: %s", sourcePath))
|
||||||
|
appendLog(fmt.Sprintf("Output: %s", outputPath))
|
||||||
|
appendLog(fmt.Sprintf("Format: %s", format))
|
||||||
|
|
||||||
|
videoTSPath, cleanup, err := resolveVideoTSPath(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cleanup != nil {
|
||||||
|
defer cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
sets, err := collectVOBSets(videoTSPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(sets) == 0 {
|
||||||
|
return fmt.Errorf("no VOB files found in VIDEO_TS")
|
||||||
|
}
|
||||||
|
|
||||||
|
set := sets[0]
|
||||||
|
appendLog(fmt.Sprintf("Using title set: %s", set.Name))
|
||||||
|
listFile, err := buildConcatList(set.Files)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(listFile)
|
||||||
|
|
||||||
|
// Create output directory if it doesn't exist
|
||||||
|
outputDir := filepath.Dir(outputPath)
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create output directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := buildRipFFmpegArgs(listFile, outputPath, format)
|
||||||
|
appendLog(fmt.Sprintf(">> ffmpeg %s", strings.Join(args, " ")))
|
||||||
|
updateProgress(10)
|
||||||
|
if err := runCommandWithLogger(ctx, platformConfig.FFmpegPath, args, appendLog); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updateProgress(100)
|
||||||
|
appendLog("Rip completed successfully.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultRipOutputPath(sourcePath, format string) string {
|
||||||
|
if sourcePath == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil || home == "" {
|
||||||
|
home = "."
|
||||||
|
}
|
||||||
|
baseDir := filepath.Join(home, "Videos", "VideoTools", "DVD_Rips")
|
||||||
|
name := strings.TrimSuffix(filepath.Base(sourcePath), filepath.Ext(sourcePath))
|
||||||
|
if strings.EqualFold(name, "video_ts") {
|
||||||
|
name = filepath.Base(filepath.Dir(sourcePath))
|
||||||
|
}
|
||||||
|
name = sanitizeForPath(name)
|
||||||
|
if name == "" {
|
||||||
|
name = "dvd_rip"
|
||||||
|
}
|
||||||
|
ext := ".mkv"
|
||||||
|
if format == ripFormatH264MP4 {
|
||||||
|
ext = ".mp4"
|
||||||
|
}
|
||||||
|
return uniqueFilePath(filepath.Join(baseDir, name+ext))
|
||||||
|
}
|
||||||
|
|
||||||
|
func createRipLog(inputPath, outputPath, format string) (*os.File, string, error) {
|
||||||
|
base := strings.TrimSuffix(filepath.Base(outputPath), filepath.Ext(outputPath))
|
||||||
|
if base == "" {
|
||||||
|
base = "rip"
|
||||||
|
}
|
||||||
|
logPath := filepath.Join(getLogsDir(), base+"-rip"+conversionLogSuffix)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
|
||||||
|
return nil, logPath, fmt.Errorf("create log dir: %w", err)
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return nil, logPath, err
|
||||||
|
}
|
||||||
|
header := fmt.Sprintf(`VideoTools Rip Log
|
||||||
|
Started: %s
|
||||||
|
Source: %s
|
||||||
|
Output: %s
|
||||||
|
Format: %s
|
||||||
|
|
||||||
|
`, time.Now().Format(time.RFC3339), inputPath, outputPath, format)
|
||||||
|
if _, err := f.WriteString(header); err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return nil, logPath, err
|
||||||
|
}
|
||||||
|
return f, logPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveVideoTSPath(path string) (string, func(), error) {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("source not found: %w", err)
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
if strings.EqualFold(filepath.Base(path), "VIDEO_TS") {
|
||||||
|
return path, nil, nil
|
||||||
|
}
|
||||||
|
videoTS := filepath.Join(path, "VIDEO_TS")
|
||||||
|
if info, err := os.Stat(videoTS); err == nil && info.IsDir() {
|
||||||
|
return videoTS, nil, nil
|
||||||
|
}
|
||||||
|
return "", nil, fmt.Errorf("no VIDEO_TS folder found in %s", path)
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(strings.ToLower(path), ".iso") {
|
||||||
|
// Try mount-based extraction first (works for UDF ISOs)
|
||||||
|
videoTS, cleanup, err := tryMountISO(path)
|
||||||
|
if err == nil {
|
||||||
|
return videoTS, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to extraction tools
|
||||||
|
tempDir, err := os.MkdirTemp(utils.TempDir(), "videotools-iso-")
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("failed to create temp dir: %w", err)
|
||||||
|
}
|
||||||
|
cleanup = func() {
|
||||||
|
_ = os.RemoveAll(tempDir)
|
||||||
|
}
|
||||||
|
tool, args, err := buildISOExtractCommand(path, tempDir)
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
if err := runCommandWithLogger(context.Background(), tool, args, nil); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
videoTS = filepath.Join(tempDir, "VIDEO_TS")
|
||||||
|
if info, err := os.Stat(videoTS); err == nil && info.IsDir() {
|
||||||
|
return videoTS, cleanup, nil
|
||||||
|
}
|
||||||
|
cleanup()
|
||||||
|
return "", nil, fmt.Errorf("VIDEO_TS not found in ISO")
|
||||||
|
}
|
||||||
|
return "", nil, fmt.Errorf("unsupported source: %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryMountISO attempts to mount the ISO and copy VIDEO_TS to a temp directory
|
||||||
|
func tryMountISO(isoPath string) (string, func(), error) {
|
||||||
|
// Create mount point
|
||||||
|
mountPoint, err := os.MkdirTemp(utils.TempDir(), "videotools-mount-")
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("failed to create mount point: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to mount the ISO
|
||||||
|
mountCmd := exec.Command("mount", "-o", "loop,ro", isoPath, mountPoint)
|
||||||
|
if err := mountCmd.Run(); err != nil {
|
||||||
|
os.RemoveAll(mountPoint)
|
||||||
|
return "", nil, fmt.Errorf("mount failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if VIDEO_TS exists
|
||||||
|
videoTSMounted := filepath.Join(mountPoint, "VIDEO_TS")
|
||||||
|
if info, err := os.Stat(videoTSMounted); err != nil || !info.IsDir() {
|
||||||
|
exec.Command("umount", mountPoint).Run()
|
||||||
|
os.RemoveAll(mountPoint)
|
||||||
|
return "", nil, fmt.Errorf("VIDEO_TS not found in mounted ISO")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy VIDEO_TS to temp directory
|
||||||
|
tempDir, err := os.MkdirTemp(utils.TempDir(), "videotools-iso-")
|
||||||
|
if err != nil {
|
||||||
|
exec.Command("umount", mountPoint).Run()
|
||||||
|
os.RemoveAll(mountPoint)
|
||||||
|
return "", nil, fmt.Errorf("failed to create temp dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use cp to copy VIDEO_TS
|
||||||
|
cpCmd := exec.Command("cp", "-r", videoTSMounted, tempDir)
|
||||||
|
if err := cpCmd.Run(); err != nil {
|
||||||
|
exec.Command("umount", mountPoint).Run()
|
||||||
|
os.RemoveAll(mountPoint)
|
||||||
|
os.RemoveAll(tempDir)
|
||||||
|
return "", nil, fmt.Errorf("copy failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmount and clean up mount point
|
||||||
|
exec.Command("umount", mountPoint).Run()
|
||||||
|
os.RemoveAll(mountPoint)
|
||||||
|
|
||||||
|
// Return path to copied VIDEO_TS
|
||||||
|
videoTS := filepath.Join(tempDir, "VIDEO_TS")
|
||||||
|
cleanup := func() {
|
||||||
|
_ = os.RemoveAll(tempDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
return videoTS, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildISOExtractCommand(isoPath, destDir string) (string, []string, error) {
|
||||||
|
// Try xorriso first (best for UDF and ISO9660)
|
||||||
|
if _, err := exec.LookPath("xorriso"); err == nil {
|
||||||
|
return "xorriso", []string{"-osirrox", "on", "-indev", isoPath, "-extract", "/VIDEO_TS", destDir}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try 7z (works well with both UDF and ISO9660)
|
||||||
|
if _, err := exec.LookPath("7z"); err == nil {
|
||||||
|
return "7z", []string{"x", "-o" + destDir, isoPath, "VIDEO_TS"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try bsdtar (works with ISO9660, may fail on UDF)
|
||||||
|
if _, err := exec.LookPath("bsdtar"); err == nil {
|
||||||
|
return "bsdtar", []string{"-C", destDir, "-xf", isoPath, "VIDEO_TS"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil, fmt.Errorf("no ISO extraction tool found (install xorriso, 7z, or bsdtar)")
|
||||||
|
}
|
||||||
|
|
||||||
|
type vobSet struct {
|
||||||
|
Name string
|
||||||
|
Files []string
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectVOBSets(videoTS string) ([]vobSet, error) {
|
||||||
|
entries, err := os.ReadDir(videoTS)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read VIDEO_TS: %w", err)
|
||||||
|
}
|
||||||
|
sets := map[string]*vobSet{}
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(strings.ToLower(name), ".vob") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(strings.ToUpper(name), "VTS_") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.TrimSuffix(name, ".VOB"), "_")
|
||||||
|
if len(parts) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
setKey := strings.Join(parts[:2], "_")
|
||||||
|
if sets[setKey] == nil {
|
||||||
|
sets[setKey] = &vobSet{Name: setKey}
|
||||||
|
}
|
||||||
|
full := filepath.Join(videoTS, name)
|
||||||
|
info, err := os.Stat(full)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sets[setKey].Files = append(sets[setKey].Files, full)
|
||||||
|
sets[setKey].Size += info.Size()
|
||||||
|
}
|
||||||
|
var result []vobSet
|
||||||
|
for _, set := range sets {
|
||||||
|
sort.Strings(set.Files)
|
||||||
|
result = append(result, *set)
|
||||||
|
}
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
return result[i].Size > result[j].Size
|
||||||
|
})
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildConcatList(files []string) (string, error) {
|
||||||
|
if len(files) == 0 {
|
||||||
|
return "", fmt.Errorf("no VOB files to concatenate")
|
||||||
|
}
|
||||||
|
listFile, err := os.CreateTemp(utils.TempDir(), "vt-rip-list-*.txt")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
writer := bufio.NewWriter(listFile)
|
||||||
|
for _, f := range files {
|
||||||
|
fmt.Fprintf(writer, "file '%s'\n", strings.ReplaceAll(f, "'", "'\\''"))
|
||||||
|
}
|
||||||
|
_ = writer.Flush()
|
||||||
|
_ = listFile.Close()
|
||||||
|
return listFile.Name(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRipFFmpegArgs(listFile, outputPath, format string) []string {
|
||||||
|
args := []string{
|
||||||
|
"-y",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel", "error",
|
||||||
|
"-f", "concat",
|
||||||
|
"-safe", "0",
|
||||||
|
"-i", listFile,
|
||||||
|
}
|
||||||
|
switch format {
|
||||||
|
case ripFormatH264MKV:
|
||||||
|
args = append(args,
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-crf", "18",
|
||||||
|
"-preset", "medium",
|
||||||
|
"-c:a", "copy",
|
||||||
|
)
|
||||||
|
case ripFormatH264MP4:
|
||||||
|
args = append(args,
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-crf", "18",
|
||||||
|
"-preset", "medium",
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-b:a", "192k",
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
args = append(args, "-c", "copy")
|
||||||
|
}
|
||||||
|
args = append(args, outputPath)
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLocalPath(items []fyne.URI) string {
|
||||||
|
for _, uri := range items {
|
||||||
|
if uri.Scheme() == "file" {
|
||||||
|
return uri.Path()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) resetRipLog() {
|
||||||
|
s.ripLogText = ""
|
||||||
|
if s.ripLogEntry != nil {
|
||||||
|
s.ripLogEntry.SetText("")
|
||||||
|
}
|
||||||
|
if s.ripLogScroll != nil {
|
||||||
|
s.ripLogScroll.ScrollToTop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) appendRipLog(line string) {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.ripLogText += line + "\n"
|
||||||
|
if s.ripLogEntry != nil {
|
||||||
|
s.ripLogEntry.SetText(s.ripLogText)
|
||||||
|
}
|
||||||
|
if s.ripLogScroll != nil {
|
||||||
|
s.ripLogScroll.ScrollToBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) setRipStatus(text string) {
|
||||||
|
if text == "" {
|
||||||
|
text = "Ready"
|
||||||
|
}
|
||||||
|
if s.ripStatusLabel != nil {
|
||||||
|
s.ripStatusLabel.SetText(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) setRipProgress(percent float64) {
|
||||||
|
if percent < 0 {
|
||||||
|
percent = 0
|
||||||
|
}
|
||||||
|
if percent > 100 {
|
||||||
|
percent = 100
|
||||||
|
}
|
||||||
|
s.ripProgress = percent
|
||||||
|
if s.ripProgressBar != nil {
|
||||||
|
s.ripProgressBar.SetValue(percent / 100.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,18 @@
|
||||||
|
|
||||||
This directory contains scripts for building and managing VideoTools on different platforms.
|
This directory contains scripts for building and managing VideoTools on different platforms.
|
||||||
|
|
||||||
|
## Recommended Workflow
|
||||||
|
|
||||||
|
For development on any platform:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/install.sh
|
||||||
|
./scripts/build.sh
|
||||||
|
./scripts/run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `./scripts/install.sh` whenever you add new dependencies or need to reinstall.
|
||||||
|
|
||||||
## Linux
|
## Linux
|
||||||
|
|
||||||
### Install Dependencies
|
### Install Dependencies
|
||||||
|
|
@ -73,6 +85,7 @@ Run in PowerShell as Administrator:
|
||||||
- MinGW-w64 (GCC compiler)
|
- MinGW-w64 (GCC compiler)
|
||||||
- ffmpeg
|
- ffmpeg
|
||||||
- Git (optional, for development)
|
- Git (optional, for development)
|
||||||
|
- DVD authoring tools (via DVDStyler portable: dvdauthor + mkisofs)
|
||||||
|
|
||||||
**Package managers supported:**
|
**Package managers supported:**
|
||||||
- Chocolatey (default, requires admin)
|
- Chocolatey (default, requires admin)
|
||||||
|
|
|
||||||
83
scripts/add-defender-exclusions.ps1
Normal file
83
scripts/add-defender-exclusions.ps1
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
# Add Windows Defender Exclusions for VideoTools Build Performance
|
||||||
|
# This script adds build directories to Windows Defender exclusions
|
||||||
|
# Saves 2-5 minutes on build times!
|
||||||
|
|
||||||
|
# Check if running as Administrator
|
||||||
|
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
|
|
||||||
|
if (-not $isAdmin) {
|
||||||
|
Write-Host "❌ ERROR: This script must be run as Administrator!" -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "To run as Administrator:" -ForegroundColor Yellow
|
||||||
|
Write-Host " 1. Right-click PowerShell" -ForegroundColor White
|
||||||
|
Write-Host " 2. Select 'Run as Administrator'" -ForegroundColor White
|
||||||
|
Write-Host " 3. Navigate to this directory" -ForegroundColor White
|
||||||
|
Write-Host " 4. Run: .\scripts\add-defender-exclusions.ps1" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Or from Git Bash (as Administrator):" -ForegroundColor Yellow
|
||||||
|
Write-Host " powershell.exe -ExecutionPolicy Bypass -File ./scripts/add-defender-exclusions.ps1" -ForegroundColor White
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
|
||||||
|
Write-Host " Adding Windows Defender Exclusions for VideoTools" -ForegroundColor Cyan
|
||||||
|
Write-Host "════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Get paths
|
||||||
|
$goBuildCache = "$env:LOCALAPPDATA\go-build"
|
||||||
|
$goModCache = "$env:USERPROFILE\go"
|
||||||
|
$projectDir = Split-Path -Parent $PSScriptRoot
|
||||||
|
$msys64 = "C:\msys64"
|
||||||
|
|
||||||
|
Write-Host "Adding exclusions..." -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Add Go build cache
|
||||||
|
try {
|
||||||
|
Add-MpPreference -ExclusionPath $goBuildCache -ErrorAction Stop
|
||||||
|
Write-Host "✓ Added: $goBuildCache" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host "⚠ Already excluded or failed: $goBuildCache" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add Go module cache
|
||||||
|
try {
|
||||||
|
Add-MpPreference -ExclusionPath $goModCache -ErrorAction Stop
|
||||||
|
Write-Host "✓ Added: $goModCache" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host "⚠ Already excluded or failed: $goModCache" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add project directory
|
||||||
|
try {
|
||||||
|
Add-MpPreference -ExclusionPath $projectDir -ErrorAction Stop
|
||||||
|
Write-Host "✓ Added: $projectDir" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host "⚠ Already excluded or failed: $projectDir" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add MSYS2 if it exists
|
||||||
|
if (Test-Path $msys64) {
|
||||||
|
try {
|
||||||
|
Add-MpPreference -ExclusionPath $msys64 -ErrorAction Stop
|
||||||
|
Write-Host "✓ Added: $msys64" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host "⚠ Already excluded or failed: $msys64" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host "⊘ Skipped: $msys64 (not found)" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
|
||||||
|
Write-Host "✅ EXCLUSIONS ADDED" -ForegroundColor Green
|
||||||
|
Write-Host "════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Expected build time improvement: 5+ minutes → 30-90 seconds" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Next steps:" -ForegroundColor Yellow
|
||||||
|
Write-Host " 1. Close and reopen your terminal" -ForegroundColor White
|
||||||
|
Write-Host " 2. Run: ./scripts/build.ps1 (PowerShell) or ./scripts/build.bat" -ForegroundColor White
|
||||||
|
Write-Host " 3. Or from Git Bash: ./scripts/build.sh" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
|
@ -9,28 +9,18 @@ alias VideoTools="bash $PROJECT_ROOT/scripts/run.sh"
|
||||||
|
|
||||||
# Also create a rebuild function for quick rebuilds
|
# Also create a rebuild function for quick rebuilds
|
||||||
VideoToolsRebuild() {
|
VideoToolsRebuild() {
|
||||||
echo "🔨 Rebuilding VideoTools..."
|
echo "Rebuilding VideoTools..."
|
||||||
bash "$PROJECT_ROOT/scripts/build.sh"
|
bash "$PROJECT_ROOT/scripts/build.sh"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create a clean function
|
# Create a clean function
|
||||||
VideoToolsClean() {
|
VideoToolsClean() {
|
||||||
echo "🧹 Cleaning VideoTools build artifacts..."
|
echo "Cleaning VideoTools build artifacts..."
|
||||||
cd "$PROJECT_ROOT"
|
cd "$PROJECT_ROOT"
|
||||||
go clean -cache -modcache -testcache
|
go clean -cache -modcache -testcache
|
||||||
rm -f "$PROJECT_ROOT/VideoTools"
|
rm -f "$PROJECT_ROOT/VideoTools"
|
||||||
echo "✓ Clean complete"
|
echo "Clean complete"
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "════════════════════════════════════════════════════════════════"
|
# VideoTools commands loaded silently
|
||||||
echo "✅ VideoTools Commands Available"
|
# Available commands: VideoTools, VideoToolsRebuild, VideoToolsClean
|
||||||
echo "════════════════════════════════════════════════════════════════"
|
|
||||||
echo ""
|
|
||||||
echo "Commands:"
|
|
||||||
echo " VideoTools - Run VideoTools (auto-builds if needed)"
|
|
||||||
echo " VideoToolsRebuild - Force rebuild of VideoTools"
|
|
||||||
echo " VideoToolsClean - Clean build artifacts and cache"
|
|
||||||
echo ""
|
|
||||||
echo "To make these permanent, add this line to your ~/.bashrc or ~/.zshrc:"
|
|
||||||
echo " source $PROJECT_ROOT/scripts/alias.sh"
|
|
||||||
echo ""
|
|
||||||
|
|
|
||||||
|
|
@ -17,49 +17,49 @@ echo ""
|
||||||
|
|
||||||
# Check if go is installed
|
# Check if go is installed
|
||||||
if ! command -v go &> /dev/null; then
|
if ! command -v go &> /dev/null; then
|
||||||
echo "❌ ERROR: Go is not installed. Please install Go 1.21 or later."
|
echo "ERROR: Go is not installed. Please install Go 1.21 or later."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "📦 Go version:"
|
echo "Go version:"
|
||||||
go version
|
go version
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Change to project directory
|
# Change to project directory
|
||||||
cd "$PROJECT_ROOT"
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
echo "🧹 Cleaning previous builds and cache..."
|
echo "Cleaning previous builds and cache..."
|
||||||
go clean -cache -testcache 2>/dev/null || true
|
go clean -cache -testcache 2>/dev/null || true
|
||||||
rm -f "$BUILD_OUTPUT" 2>/dev/null || true
|
rm -f "$BUILD_OUTPUT" 2>/dev/null || true
|
||||||
# Also clear build cache directory to avoid permission issues
|
# Also clear build cache directory to avoid permission issues
|
||||||
rm -rf "${GOCACHE:-$HOME/.cache/go-build}" 2>/dev/null || true
|
rm -rf "${GOCACHE:-$HOME/.cache/go-build}" 2>/dev/null || true
|
||||||
echo "✓ Cache cleaned"
|
echo "Cache cleaned"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo "⬇️ Downloading and verifying dependencies (skips if already cached)..."
|
echo "Downloading and verifying dependencies (skips if already cached)..."
|
||||||
if go list -m all >/dev/null 2>&1; then
|
if go list -m all >/dev/null 2>&1; then
|
||||||
echo "✓ Dependencies already present"
|
echo "Dependencies already present"
|
||||||
else
|
else
|
||||||
if go mod download && go mod verify; then
|
if go mod download && go mod verify; then
|
||||||
echo "✓ Dependencies downloaded and verified"
|
echo "Dependencies downloaded and verified"
|
||||||
else
|
else
|
||||||
echo "❌ Failed to download/verify modules. Check network/GOPROXY or try again."
|
echo "Failed to download/verify modules. Check network/GOPROXY or try again."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo "🔨 Building VideoTools..."
|
echo "Building VideoTools..."
|
||||||
# Fyne needs cgo for GLFW/OpenGL bindings; build with CGO enabled.
|
# Fyne needs cgo for GLFW/OpenGL bindings; build with CGO enabled.
|
||||||
export CGO_ENABLED=1
|
export CGO_ENABLED=1
|
||||||
export GOCACHE="$PROJECT_ROOT/.cache/go-build"
|
export GOCACHE="$PROJECT_ROOT/.cache/go-build"
|
||||||
export GOMODCACHE="$PROJECT_ROOT/.cache/go-mod"
|
export GOMODCACHE="$PROJECT_ROOT/.cache/go-mod"
|
||||||
mkdir -p "$GOCACHE" "$GOMODCACHE"
|
mkdir -p "$GOCACHE" "$GOMODCACHE"
|
||||||
if go build -o "$BUILD_OUTPUT" .; then
|
if go build -o "$BUILD_OUTPUT" .; then
|
||||||
echo "✓ Build successful! (VideoTools $APP_VERSION)"
|
echo "Build successful! (VideoTools $APP_VERSION)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "════════════════════════════════════════════════════════════════"
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
echo "✅ BUILD COMPLETE - $APP_VERSION"
|
echo "BUILD COMPLETE - $APP_VERSION"
|
||||||
echo "════════════════════════════════════════════════════════════════"
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Output: $BUILD_OUTPUT"
|
echo "Output: $BUILD_OUTPUT"
|
||||||
|
|
@ -74,7 +74,7 @@ if go build -o "$BUILD_OUTPUT" .; then
|
||||||
echo " VideoTools"
|
echo " VideoTools"
|
||||||
echo ""
|
echo ""
|
||||||
else
|
else
|
||||||
echo "❌ Build failed! (VideoTools $APP_VERSION)"
|
echo "Build failed! (VideoTools $APP_VERSION)"
|
||||||
echo "Diagnostics: version=$APP_VERSION os=$(uname -s) arch=$(uname -m) go=$(go version | awk '{print $3}')"
|
echo "Diagnostics: version=$APP_VERSION os=$(uname -s) arch=$(uname -m) go=$(go version | awk '{print $3}')"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Help: check the Go error messages above."
|
echo "Help: check the Go error messages above."
|
||||||
|
|
|
||||||
|
|
@ -15,17 +15,17 @@ echo ""
|
||||||
|
|
||||||
# Check if go is installed
|
# Check if go is installed
|
||||||
if ! command -v go &> /dev/null; then
|
if ! command -v go &> /dev/null; then
|
||||||
echo "❌ ERROR: Go is not installed. Please install Go 1.21 or later."
|
echo "ERROR: Go is not installed. Please install Go 1.21 or later."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "📦 Go version:"
|
echo "Go version:"
|
||||||
go version
|
go version
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Check if MinGW-w64 is installed
|
# Check if MinGW-w64 is installed
|
||||||
if ! command -v x86_64-w64-mingw32-gcc &> /dev/null; then
|
if ! command -v x86_64-w64-mingw32-gcc &> /dev/null; then
|
||||||
echo "❌ ERROR: MinGW-w64 cross-compiler not found!"
|
echo "ERROR: MinGW-w64 cross-compiler not found!"
|
||||||
echo ""
|
echo ""
|
||||||
echo "To install on Fedora/RHEL:"
|
echo "To install on Fedora/RHEL:"
|
||||||
echo " sudo dnf install mingw64-gcc mingw64-winpthreads-static"
|
echo " sudo dnf install mingw64-gcc mingw64-winpthreads-static"
|
||||||
|
|
@ -36,26 +36,26 @@ if ! command -v x86_64-w64-mingw32-gcc &> /dev/null; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "🔧 MinGW-w64 detected:"
|
echo "MinGW-w64 detected:"
|
||||||
x86_64-w64-mingw32-gcc --version | head -1
|
x86_64-w64-mingw32-gcc --version | head -1
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Change to project directory
|
# Change to project directory
|
||||||
cd "$PROJECT_ROOT"
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
echo "🧹 Cleaning previous Windows builds..."
|
echo "Cleaning previous Windows builds..."
|
||||||
rm -f "$BUILD_OUTPUT" 2>/dev/null || true
|
rm -f "$BUILD_OUTPUT" 2>/dev/null || true
|
||||||
rm -rf "$DIST_DIR" 2>/dev/null || true
|
rm -rf "$DIST_DIR" 2>/dev/null || true
|
||||||
echo "✓ Previous builds cleaned"
|
echo "Previous builds cleaned"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo "⬇️ Downloading and verifying dependencies..."
|
echo "Downloading and verifying dependencies..."
|
||||||
go mod download
|
go mod download
|
||||||
go mod verify
|
go mod verify
|
||||||
echo "✓ Dependencies verified"
|
echo "Dependencies verified"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo "🔨 Cross-compiling for Windows (amd64)..."
|
echo "Cross-compiling for Windows (amd64)..."
|
||||||
echo " Target: windows/amd64"
|
echo " Target: windows/amd64"
|
||||||
echo " Compiler: x86_64-w64-mingw32-gcc"
|
echo " Compiler: x86_64-w64-mingw32-gcc"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
@ -73,27 +73,27 @@ export CXX=x86_64-w64-mingw32-g++
|
||||||
LDFLAGS="-H windowsgui -s -w"
|
LDFLAGS="-H windowsgui -s -w"
|
||||||
|
|
||||||
if go build -ldflags="$LDFLAGS" -o "$BUILD_OUTPUT" .; then
|
if go build -ldflags="$LDFLAGS" -o "$BUILD_OUTPUT" .; then
|
||||||
echo "✓ Cross-compilation successful!"
|
echo "Cross-compilation successful!"
|
||||||
echo ""
|
echo ""
|
||||||
else
|
else
|
||||||
echo "❌ Build failed!"
|
echo "Build failed!"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "📦 Creating distribution package..."
|
echo "Creating distribution package..."
|
||||||
mkdir -p "$DIST_DIR"
|
mkdir -p "$DIST_DIR"
|
||||||
|
|
||||||
# Copy executable
|
# Copy executable
|
||||||
cp "$BUILD_OUTPUT" "$DIST_DIR/"
|
cp "$BUILD_OUTPUT" "$DIST_DIR/"
|
||||||
echo "✓ Copied VideoTools.exe"
|
echo "Copied VideoTools.exe"
|
||||||
|
|
||||||
# Copy documentation
|
# Copy documentation
|
||||||
cp README.md "$DIST_DIR/" 2>/dev/null || echo "⚠️ README.md not found"
|
cp README.md "$DIST_DIR/" 2>/dev/null || echo "WARNING: README.md not found"
|
||||||
cp LICENSE "$DIST_DIR/" 2>/dev/null || echo "⚠️ LICENSE not found"
|
cp LICENSE "$DIST_DIR/" 2>/dev/null || echo "WARNING: LICENSE not found"
|
||||||
|
|
||||||
# Download and bundle FFmpeg automatically
|
# Download and bundle FFmpeg automatically
|
||||||
if [ ! -f "ffmpeg.exe" ]; then
|
if [ ! -f "ffmpeg.exe" ]; then
|
||||||
echo "📥 FFmpeg not found locally, downloading..."
|
echo "FFmpeg not found locally, downloading..."
|
||||||
FFMPEG_URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
|
FFMPEG_URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
|
||||||
FFMPEG_ZIP="$PROJECT_ROOT/ffmpeg-windows.zip"
|
FFMPEG_ZIP="$PROJECT_ROOT/ffmpeg-windows.zip"
|
||||||
|
|
||||||
|
|
@ -102,14 +102,14 @@ if [ ! -f "ffmpeg.exe" ]; then
|
||||||
elif command -v curl &> /dev/null; then
|
elif command -v curl &> /dev/null; then
|
||||||
curl -L "$FFMPEG_URL" -o "$FFMPEG_ZIP" --progress-bar
|
curl -L "$FFMPEG_URL" -o "$FFMPEG_ZIP" --progress-bar
|
||||||
else
|
else
|
||||||
echo "⚠️ wget or curl not found. Cannot download FFmpeg automatically."
|
echo "WARNING: wget or curl not found. Cannot download FFmpeg automatically."
|
||||||
echo " Please download manually from: $FFMPEG_URL"
|
echo " Please download manually from: $FFMPEG_URL"
|
||||||
echo " Extract ffmpeg.exe and ffprobe.exe to project root"
|
echo " Extract ffmpeg.exe and ffprobe.exe to project root"
|
||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -f "$FFMPEG_ZIP" ]; then
|
if [ -f "$FFMPEG_ZIP" ]; then
|
||||||
echo "📦 Extracting FFmpeg..."
|
echo "Extracting FFmpeg..."
|
||||||
unzip -q "$FFMPEG_ZIP" "*/bin/ffmpeg.exe" "*/bin/ffprobe.exe" -d "$PROJECT_ROOT/ffmpeg-temp"
|
unzip -q "$FFMPEG_ZIP" "*/bin/ffmpeg.exe" "*/bin/ffprobe.exe" -d "$PROJECT_ROOT/ffmpeg-temp"
|
||||||
|
|
||||||
# Find and copy the executables (they're nested in a versioned directory)
|
# Find and copy the executables (they're nested in a versioned directory)
|
||||||
|
|
@ -118,28 +118,28 @@ if [ ! -f "ffmpeg.exe" ]; then
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
rm -rf "$PROJECT_ROOT/ffmpeg-temp" "$FFMPEG_ZIP"
|
rm -rf "$PROJECT_ROOT/ffmpeg-temp" "$FFMPEG_ZIP"
|
||||||
echo "✓ FFmpeg downloaded and extracted"
|
echo "FFmpeg downloaded and extracted"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Bundle FFmpeg with the distribution
|
# Bundle FFmpeg with the distribution
|
||||||
if [ -f "ffmpeg.exe" ]; then
|
if [ -f "ffmpeg.exe" ]; then
|
||||||
cp ffmpeg.exe "$DIST_DIR/"
|
cp ffmpeg.exe "$DIST_DIR/"
|
||||||
echo "✓ Bundled ffmpeg.exe"
|
echo "Bundled ffmpeg.exe"
|
||||||
else
|
else
|
||||||
echo "⚠️ ffmpeg.exe not found - distribution will require separate FFmpeg installation"
|
echo "WARNING: ffmpeg.exe not found - distribution will require separate FFmpeg installation"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -f "ffprobe.exe" ]; then
|
if [ -f "ffprobe.exe" ]; then
|
||||||
cp ffprobe.exe "$DIST_DIR/"
|
cp ffprobe.exe "$DIST_DIR/"
|
||||||
echo "✓ Bundled ffprobe.exe"
|
echo "Bundled ffprobe.exe"
|
||||||
else
|
else
|
||||||
echo "⚠️ ffprobe.exe not found"
|
echo "WARNING: ffprobe.exe not found"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "════════════════════════════════════════════════════════════════"
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
echo "✅ WINDOWS BUILD COMPLETE"
|
echo "WINDOWS BUILD COMPLETE"
|
||||||
echo "════════════════════════════════════════════════════════════════"
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Output directory: $DIST_DIR"
|
echo "Output directory: $DIST_DIR"
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,18 @@ echo [INFO] Building VideoTools.exe...
|
||||||
REM Enable CGO for Windows build (required for Fyne)
|
REM Enable CGO for Windows build (required for Fyne)
|
||||||
set CGO_ENABLED=1
|
set CGO_ENABLED=1
|
||||||
|
|
||||||
|
REM Detect CPU cores for parallel compilation
|
||||||
|
for /f "tokens=2 delims==" %%I in ('wmic cpu get NumberOfLogicalProcessors /value ^| find "="') do set NUM_CORES=%%I
|
||||||
|
if not defined NUM_CORES set NUM_CORES=4
|
||||||
|
echo [INFO] Using %NUM_CORES% parallel build processes
|
||||||
|
|
||||||
|
REM Build with optimizations:
|
||||||
|
REM -p: Parallel build processes (use all CPU cores)
|
||||||
|
REM -trimpath: Remove absolute paths (faster builds, smaller binary)
|
||||||
|
REM -ldflags: Strip debug info (-s -w) and use Windows GUI mode (-H windowsgui)
|
||||||
go build ^
|
go build ^
|
||||||
|
-p %NUM_CORES% ^
|
||||||
|
-trimpath ^
|
||||||
-ldflags="-H windowsgui -s -w" ^
|
-ldflags="-H windowsgui -s -w" ^
|
||||||
-o VideoTools.exe ^
|
-o VideoTools.exe ^
|
||||||
.
|
.
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,18 @@ Write-Host ""
|
||||||
# Fyne needs CGO for GLFW/OpenGL bindings
|
# Fyne needs CGO for GLFW/OpenGL bindings
|
||||||
$env:CGO_ENABLED = "1"
|
$env:CGO_ENABLED = "1"
|
||||||
|
|
||||||
# Build the application
|
# Detect number of CPU cores for parallel compilation
|
||||||
go build -o $BUILD_OUTPUT .
|
$numCores = (Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors
|
||||||
|
if (-not $numCores -or $numCores -lt 1) {
|
||||||
|
$numCores = 4 # Fallback to 4 if detection fails
|
||||||
|
}
|
||||||
|
Write-Host "Using $numCores parallel build processes" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Build the application with optimizations
|
||||||
|
# -p: Number of parallel build processes (use all cores)
|
||||||
|
# -ldflags="-s -w": Strip debug info and symbol table (faster linking, smaller binary)
|
||||||
|
# -trimpath: Remove absolute file paths from binary (faster builds, smaller binary)
|
||||||
|
go build -p $numCores -ldflags="-s -w" -trimpath -o $BUILD_OUTPUT .
|
||||||
|
|
||||||
if ($LASTEXITCODE -eq 0) {
|
if ($LASTEXITCODE -eq 0) {
|
||||||
Write-Host "✓ Build successful!" -ForegroundColor Green
|
Write-Host "✓ Build successful!" -ForegroundColor Green
|
||||||
|
|
|
||||||
|
|
@ -9,29 +9,29 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
APP_VERSION="$(grep -m1 'appVersion' "$PROJECT_ROOT/main.go" | sed -E 's/.*\"([^\"]+)\".*/\1/')"
|
APP_VERSION="$(grep -m1 'appVersion' "$PROJECT_ROOT/main.go" | sed -E 's/.*\"([^\"]+)\".*/\1/')"
|
||||||
[ -z "$APP_VERSION" ] && APP_VERSION="(version unknown)"
|
[ -z "$APP_VERSION" ] && APP_VERSION="(version unknown)"
|
||||||
|
|
||||||
echo "════════════════════════════════════════════════════════════════"
|
|
||||||
echo " VideoTools Universal Build Script"
|
|
||||||
echo "════════════════════════════════════════════════════════════════"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Detect platform
|
# Detect platform
|
||||||
PLATFORM="$(uname -s)"
|
PLATFORM="$(uname -s)"
|
||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
Linux*) OS="Linux" ;;
|
Linux*) OS="Linux" ;;
|
||||||
Darwin*) OS="macOS" ;;
|
Darwin*) OS="macOS" ;;
|
||||||
CYGWIN*|MINGW*|MSYS*) OS="Windows" ;;
|
CYGWIN*|MINGW*|MSYS*) OS="Windows" ;;
|
||||||
*) echo "❌ Unknown platform: $PLATFORM"; exit 1 ;;
|
*) echo "Unknown platform: $PLATFORM"; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
echo "🔍 Detected platform: $OS"
|
|
||||||
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
|
echo " VideoTools ${OS} Build"
|
||||||
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
echo "Detected platform: $OS"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Go check
|
# Go check
|
||||||
if ! command -v go >/dev/null 2>&1; then
|
if ! command -v go >/dev/null 2>&1; then
|
||||||
echo "❌ ERROR: Go is not installed. Please install Go 1.21+ (go version currently missing)."
|
echo "ERROR: Go is not installed. Please install Go 1.21+ (go version currently missing)."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "📦 Go version:"
|
echo "Go version:"
|
||||||
go version
|
go version
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
|
@ -50,26 +50,26 @@ case "$OS" in
|
||||||
echo ""
|
echo ""
|
||||||
cd "$PROJECT_ROOT"
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
echo "🧹 Cleaning previous builds..."
|
echo "Cleaning previous builds..."
|
||||||
rm -f VideoTools.exe 2>/dev/null || true
|
rm -f VideoTools.exe 2>/dev/null || true
|
||||||
# Clear Go cache to avoid permission issues
|
# Clear Go cache to avoid permission issues
|
||||||
go clean -cache -modcache -testcache 2>/dev/null || true
|
go clean -cache -modcache -testcache 2>/dev/null || true
|
||||||
echo "✓ Cache cleaned"
|
echo "Cache cleaned"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo "⬇️ Downloading dependencies..."
|
echo "Downloading dependencies..."
|
||||||
go mod download
|
go mod download
|
||||||
echo "✓ Dependencies downloaded"
|
echo "Dependencies downloaded"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo "🔨 Building VideoTools $APP_VERSION for Windows..."
|
echo "Building VideoTools $APP_VERSION for Windows..."
|
||||||
export CGO_ENABLED=1
|
export CGO_ENABLED=1
|
||||||
if go build -ldflags="-H windowsgui -s -w" -o VideoTools.exe .; then
|
if go build -ldflags="-H windowsgui -s -w" -o VideoTools.exe .; then
|
||||||
echo "✓ Build successful! (VideoTools $APP_VERSION)"
|
echo "Build successful! (VideoTools $APP_VERSION)"
|
||||||
echo ""
|
echo ""
|
||||||
if [ -f "setup-windows.bat" ]; then
|
if [ -f "setup-windows.bat" ]; then
|
||||||
echo "════════════════════════════════════════════════════════════════"
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
echo "✅ BUILD COMPLETE - $APP_VERSION"
|
echo "BUILD COMPLETE - $APP_VERSION"
|
||||||
echo "════════════════════════════════════════════════════════════════"
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Output: VideoTools.exe"
|
echo "Output: VideoTools.exe"
|
||||||
|
|
@ -93,11 +93,11 @@ case "$OS" in
|
||||||
echo "You can skip if FFmpeg is already installed elsewhere."
|
echo "You can skip if FFmpeg is already installed elsewhere."
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "✓ Build complete: VideoTools.exe"
|
echo "Build complete: VideoTools.exe"
|
||||||
diagnostics
|
diagnostics
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "❌ Build failed! (VideoTools $APP_VERSION)"
|
echo "Build failed! (VideoTools $APP_VERSION)"
|
||||||
diagnostics
|
diagnostics
|
||||||
echo ""
|
echo ""
|
||||||
echo "Help: check the Go error messages above."
|
echo "Help: check the Go error messages above."
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "════════════════════════════════════════════════════════════════"
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
echo " VideoTools Dependency Installer (Linux)"
|
echo " VideoTools Linux Installation"
|
||||||
echo "════════════════════════════════════════════════════════════════"
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,60 +4,22 @@ chcp 65001 >nul
|
||||||
title VideoTools Windows Dependency Installer
|
title VideoTools Windows Dependency Installer
|
||||||
|
|
||||||
echo ========================================================
|
echo ========================================================
|
||||||
echo VideoTools Windows Dependency Installer (.bat)
|
echo VideoTools Windows Installation
|
||||||
echo Installs Go, MinGW (GCC), Git, and FFmpeg
|
echo Delegating to PowerShell for full dependency setup
|
||||||
echo ========================================================
|
echo ========================================================
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
REM Prefer Chocolatey if available; otherwise fall back to winget.
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0install-deps-windows.ps1"
|
||||||
where choco >nul 2>&1
|
set EXIT_CODE=%errorlevel%
|
||||||
if %errorlevel%==0 (
|
|
||||||
echo Using Chocolatey...
|
if not %EXIT_CODE%==0 (
|
||||||
call :install_choco
|
echo.
|
||||||
goto :verify
|
echo Dependency installer failed with exit code %EXIT_CODE%.
|
||||||
|
pause
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
)
|
)
|
||||||
|
|
||||||
where winget >nul 2>&1
|
|
||||||
if %errorlevel%==0 (
|
|
||||||
echo Chocolatey not found; using winget...
|
|
||||||
call :install_winget
|
|
||||||
goto :verify
|
|
||||||
)
|
|
||||||
|
|
||||||
echo Neither Chocolatey nor winget found.
|
|
||||||
echo Please install Chocolatey (recommended): https://chocolatey.org/install
|
|
||||||
echo Then re-run this script.
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
|
|
||||||
:install_choco
|
|
||||||
echo.
|
echo.
|
||||||
echo Installing dependencies via Chocolatey...
|
echo Done. Restart your terminal to refresh PATH.
|
||||||
choco install -y golang mingw git ffmpeg
|
|
||||||
goto :eof
|
|
||||||
|
|
||||||
:install_winget
|
|
||||||
echo.
|
|
||||||
echo Installing dependencies via winget...
|
|
||||||
REM Winget package IDs can vary; these are common defaults.
|
|
||||||
winget install -e --id GoLang.Go
|
|
||||||
winget install -e --id Git.Git
|
|
||||||
winget install -e --id GnuWin32.Mingw
|
|
||||||
winget install -e --id Gyan.FFmpeg
|
|
||||||
goto :eof
|
|
||||||
|
|
||||||
:verify
|
|
||||||
echo.
|
|
||||||
echo ========================================================
|
|
||||||
echo Verifying installs
|
|
||||||
echo ========================================================
|
|
||||||
where go >nul 2>&1 && go version
|
|
||||||
where gcc >nul 2>&1 && gcc --version | findstr /R /C:"gcc"
|
|
||||||
where git >nul 2>&1 && git --version
|
|
||||||
where ffmpeg >nul 2>&1 && ffmpeg -version | head -n 1
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo Done. If any tool is missing, ensure its bin folder is in PATH
|
|
||||||
echo (restart terminal after installation).
|
|
||||||
pause
|
pause
|
||||||
exit /b 0
|
exit /b 0
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,24 @@
|
||||||
# VideoTools Dependency Installer for Windows
|
# VideoTools Dependency Installer for Windows
|
||||||
# Installs all required build and runtime dependencies using Chocolatey or Scoop
|
# Installs all required build and runtime dependencies using Chocolatey or Scoop
|
||||||
|
|
||||||
param(
|
param(
|
||||||
[switch]$UseScoop = $false,
|
[switch]$UseScoop = $false,
|
||||||
[switch]$SkipFFmpeg = $false
|
[switch]$SkipFFmpeg = $false,
|
||||||
|
[string]$DvdStylerUrl = "",
|
||||||
|
[string]$DvdStylerZip = "",
|
||||||
|
[switch]$SkipDvdStyler = $false
|
||||||
)
|
)
|
||||||
|
|
||||||
Write-Host "════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
|
Write-Host "===============================================================" -ForegroundColor Cyan
|
||||||
Write-Host " VideoTools Dependency Installer (Windows)" -ForegroundColor Cyan
|
Write-Host " VideoTools Windows Installation" -ForegroundColor Cyan
|
||||||
Write-Host "════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
|
Write-Host "===============================================================" -ForegroundColor Cyan
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
||||||
# Check if running as administrator
|
# Check if running as administrator
|
||||||
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
|
|
||||||
if (-not $isAdmin) {
|
if (-not $isAdmin) {
|
||||||
Write-Host "⚠️ This script should be run as Administrator for best results" -ForegroundColor Yellow
|
Write-Host "[WARN] This script should be run as Administrator for best results" -ForegroundColor Yellow
|
||||||
Write-Host " Right-click PowerShell and select 'Run as Administrator'" -ForegroundColor Yellow
|
Write-Host " Right-click PowerShell and select 'Run as Administrator'" -ForegroundColor Yellow
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
$continue = Read-Host "Continue anyway? (y/N)"
|
$continue = Read-Host "Continue anyway? (y/N)"
|
||||||
|
|
@ -25,6 +28,10 @@ if (-not $isAdmin) {
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($DvdStylerUrl) {
|
||||||
|
$env:VT_DVDSTYLER_URL = $DvdStylerUrl
|
||||||
|
}
|
||||||
|
|
||||||
# Function to check if a command exists
|
# Function to check if a command exists
|
||||||
function Test-Command {
|
function Test-Command {
|
||||||
param($Command)
|
param($Command)
|
||||||
|
|
@ -32,9 +39,159 @@ function Test-Command {
|
||||||
return $?
|
return $?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Ensure DVD authoring tools exist on Windows by downloading DVDStyler portable
|
||||||
|
function Ensure-DVDStylerTools {
|
||||||
|
if ($SkipDvdStyler) {
|
||||||
|
Write-Host "[SKIP] DVD authoring tools skipped (DVDStyler)" -ForegroundColor Yellow
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$toolsRoot = Join-Path $PSScriptRoot "tools"
|
||||||
|
$dvdstylerDir = Join-Path $toolsRoot "dvdstyler"
|
||||||
|
$dvdstylerBin = Join-Path $dvdstylerDir "bin"
|
||||||
|
$dvdstylerReferer = "https://sourceforge.net/projects/dvdstyler/"
|
||||||
|
$dvdstylerUrls = @(
|
||||||
|
"https://downloads.sourceforge.net/project/dvdstyler/DVDStyler/3.2.1/DVDStyler-3.2.1-win64.zip",
|
||||||
|
"https://netcologne.dl.sourceforge.net/project/dvdstyler/DVDStyler/3.2.1/DVDStyler-3.2.1-win64.zip",
|
||||||
|
"https://cfhcable.dl.sourceforge.net/project/dvdstyler/DVDStyler/3.2.1/DVDStyler-3.2.1-win64.zip",
|
||||||
|
"https://pilotfiber.dl.sourceforge.net/project/dvdstyler/DVDStyler/3.2.1/DVDStyler-3.2.1-win64.zip",
|
||||||
|
"https://versaweb.dl.sourceforge.net/project/dvdstyler/DVDStyler/3.2.1/DVDStyler-3.2.1-win64.zip",
|
||||||
|
"https://liquidtelecom.dl.sourceforge.net/project/dvdstyler/DVDStyler/3.2.1/DVDStyler-3.2.1-win64.zip",
|
||||||
|
"https://master.dl.sourceforge.net/project/dvdstyler/DVDStyler/3.2.1/DVDStyler-3.2.1-win64.zip",
|
||||||
|
"https://ufpr.dl.sourceforge.net/project/dvdstyler/DVDStyler/3.2.1/DVDStyler-3.2.1-win64.zip",
|
||||||
|
"https://sourceforge.net/projects/dvdstyler/files/DVDStyler/3.2.1/DVDStyler-3.2.1-win64.zip/download"
|
||||||
|
)
|
||||||
|
if ($env:VT_DVDSTYLER_URL) {
|
||||||
|
$dvdstylerUrls = @($env:VT_DVDSTYLER_URL) + $dvdstylerUrls
|
||||||
|
}
|
||||||
|
$dvdstylerZip = Join-Path $env:TEMP "dvdstyler-win64.zip"
|
||||||
|
$needsDVDTools = (-not (Test-Command dvdauthor)) -or (-not (Test-Command mkisofs))
|
||||||
|
|
||||||
|
if (-not $needsDVDTools) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Installing DVD authoring tools (DVDStyler portable)..." -ForegroundColor Yellow
|
||||||
|
if (-not (Test-Path $toolsRoot)) {
|
||||||
|
New-Item -ItemType Directory -Force -Path $toolsRoot | Out-Null
|
||||||
|
}
|
||||||
|
if (Test-Path $dvdstylerDir) {
|
||||||
|
Remove-Item -Recurse -Force $dvdstylerDir
|
||||||
|
}
|
||||||
|
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072
|
||||||
|
$userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||||
|
$downloaded = $false
|
||||||
|
$lastUrl = ""
|
||||||
|
if ($DvdStylerZip) {
|
||||||
|
if (Test-Path $DvdStylerZip) {
|
||||||
|
Copy-Item -Path $DvdStylerZip -Destination $dvdstylerZip -Force
|
||||||
|
$downloaded = $true
|
||||||
|
$lastUrl = $DvdStylerZip
|
||||||
|
} else {
|
||||||
|
Write-Host "[ERROR] Provided DVDStyler ZIP not found: $DvdStylerZip" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
foreach ($url in $dvdstylerUrls) {
|
||||||
|
$lastUrl = $url
|
||||||
|
$downloadOk = $false
|
||||||
|
if (Test-Path $dvdstylerZip) {
|
||||||
|
Remove-Item -Force $dvdstylerZip
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Invoke-WebRequest -Uri $url -OutFile $dvdstylerZip -UseBasicParsing -MaximumRedirection 10 -UserAgent $userAgent -Headers @{
|
||||||
|
"Referer" = $dvdstylerReferer
|
||||||
|
"Accept" = "application/zip"
|
||||||
|
}
|
||||||
|
$downloadOk = $true
|
||||||
|
} catch {
|
||||||
|
$downloadOk = $false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $downloadOk) {
|
||||||
|
try {
|
||||||
|
Start-BitsTransfer -Source $url -Destination $dvdstylerZip -ErrorAction Stop
|
||||||
|
$downloadOk = $true
|
||||||
|
} catch {
|
||||||
|
$downloadOk = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $downloadOk -and (Test-Command curl.exe)) {
|
||||||
|
try {
|
||||||
|
& curl.exe -L --retry 3 --user-agent $userAgent -o $dvdstylerZip $url | Out-Null
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
$downloadOk = $true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$downloadOk = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $downloadOk -or -not (Test-Path $dvdstylerZip)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$fs = [System.IO.File]::OpenRead($dvdstylerZip)
|
||||||
|
try {
|
||||||
|
$fileSize = (Get-Item $dvdstylerZip).Length
|
||||||
|
if ($fileSize -lt 102400) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$sig = New-Object byte[] 2
|
||||||
|
$null = $fs.Read($sig, 0, 2)
|
||||||
|
if ($sig[0] -eq 0x50 -and $sig[1] -eq 0x4B) {
|
||||||
|
$downloaded = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$fs.Close()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
# Try next URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $downloaded) {
|
||||||
|
Write-Host "[ERROR] Failed to download DVDStyler ZIP (invalid archive)" -ForegroundColor Red
|
||||||
|
Write-Host "Last URL tried: $lastUrl" -ForegroundColor Yellow
|
||||||
|
Write-Host "Tip: Set VT_DVDSTYLER_URL to a direct ZIP link and retry." -ForegroundColor Yellow
|
||||||
|
Write-Host "Manual download page: https://sourceforge.net/projects/dvdstyler/files/DVDStyler/3.2.1/" -ForegroundColor Yellow
|
||||||
|
Write-Host "After download, extract and ensure bin\\dvdauthor.exe and bin\\mkisofs.exe are on PATH." -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$extractRoot = Join-Path $env:TEMP ("dvdstyler-extract-" + [System.Guid]::NewGuid().ToString())
|
||||||
|
New-Item -ItemType Directory -Force -Path $extractRoot | Out-Null
|
||||||
|
Expand-Archive -Path $dvdstylerZip -DestinationPath $extractRoot -Force
|
||||||
|
|
||||||
|
$entries = Get-ChildItem -Path $extractRoot
|
||||||
|
if ($entries.Count -eq 1 -and $entries[0].PSIsContainer) {
|
||||||
|
Copy-Item -Path (Join-Path $entries[0].FullName "*") -Destination $dvdstylerDir -Recurse -Force
|
||||||
|
} else {
|
||||||
|
Copy-Item -Path (Join-Path $extractRoot "*") -Destination $dvdstylerDir -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
Remove-Item -Force $dvdstylerZip
|
||||||
|
Remove-Item -Recurse -Force $extractRoot
|
||||||
|
|
||||||
|
if (Test-Path $dvdstylerBin) {
|
||||||
|
$env:Path = "$dvdstylerBin;$env:Path"
|
||||||
|
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
|
||||||
|
if ($userPath -notmatch [Regex]::Escape($dvdstylerBin)) {
|
||||||
|
[Environment]::SetEnvironmentVariable("Path", "$dvdstylerBin;$userPath", "User")
|
||||||
|
}
|
||||||
|
Write-Host "[OK] DVD authoring tools installed to $dvdstylerDir" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "[ERROR] DVDStyler tools missing after install" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Function to install via Chocolatey
|
# Function to install via Chocolatey
|
||||||
function Install-ViaChocolatey {
|
function Install-ViaChocolatey {
|
||||||
Write-Host "📦 Using Chocolatey package manager..." -ForegroundColor Green
|
Write-Host " Using Chocolatey package manager..." -ForegroundColor Green
|
||||||
|
|
||||||
# Check if Chocolatey is installed
|
# Check if Chocolatey is installed
|
||||||
if (-not (Test-Command choco)) {
|
if (-not (Test-Command choco)) {
|
||||||
|
|
@ -44,12 +201,12 @@ function Install-ViaChocolatey {
|
||||||
Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
|
Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
|
||||||
|
|
||||||
if (-not (Test-Command choco)) {
|
if (-not (Test-Command choco)) {
|
||||||
Write-Host "❌ Failed to install Chocolatey" -ForegroundColor Red
|
Write-Host "[ERROR] Failed to install Chocolatey" -ForegroundColor Red
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
Write-Host "✓ Chocolatey installed" -ForegroundColor Green
|
Write-Host "[OK] Chocolatey installed" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
Write-Host "✓ Chocolatey already installed" -ForegroundColor Green
|
Write-Host "[OK] Chocolatey already installed" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
@ -60,7 +217,7 @@ function Install-ViaChocolatey {
|
||||||
Write-Host "Installing Go..." -ForegroundColor Yellow
|
Write-Host "Installing Go..." -ForegroundColor Yellow
|
||||||
choco install -y golang
|
choco install -y golang
|
||||||
} else {
|
} else {
|
||||||
Write-Host "✓ Go already installed" -ForegroundColor Green
|
Write-Host "[OK] Go already installed" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
|
||||||
# Install GCC (via TDM-GCC or mingw)
|
# Install GCC (via TDM-GCC or mingw)
|
||||||
|
|
@ -68,7 +225,7 @@ function Install-ViaChocolatey {
|
||||||
Write-Host "Installing MinGW-w64 (GCC)..." -ForegroundColor Yellow
|
Write-Host "Installing MinGW-w64 (GCC)..." -ForegroundColor Yellow
|
||||||
choco install -y mingw
|
choco install -y mingw
|
||||||
} else {
|
} else {
|
||||||
Write-Host "✓ GCC already installed" -ForegroundColor Green
|
Write-Host "[OK] GCC already installed" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
|
||||||
# Install Git (useful for development)
|
# Install Git (useful for development)
|
||||||
|
|
@ -76,7 +233,7 @@ function Install-ViaChocolatey {
|
||||||
Write-Host "Installing Git..." -ForegroundColor Yellow
|
Write-Host "Installing Git..." -ForegroundColor Yellow
|
||||||
choco install -y git
|
choco install -y git
|
||||||
} else {
|
} else {
|
||||||
Write-Host "✓ Git already installed" -ForegroundColor Green
|
Write-Host "[OK] Git already installed" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
|
||||||
# Install ffmpeg
|
# Install ffmpeg
|
||||||
|
|
@ -85,16 +242,16 @@ function Install-ViaChocolatey {
|
||||||
Write-Host "Installing ffmpeg..." -ForegroundColor Yellow
|
Write-Host "Installing ffmpeg..." -ForegroundColor Yellow
|
||||||
choco install -y ffmpeg
|
choco install -y ffmpeg
|
||||||
} else {
|
} else {
|
||||||
Write-Host "✓ ffmpeg already installed" -ForegroundColor Green
|
Write-Host "[OK] ffmpeg already installed" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "✓ Chocolatey installation complete" -ForegroundColor Green
|
Write-Host "[OK] Chocolatey installation complete" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
|
||||||
# Function to install via Scoop
|
# Function to install via Scoop
|
||||||
function Install-ViaScoop {
|
function Install-ViaScoop {
|
||||||
Write-Host "📦 Using Scoop package manager..." -ForegroundColor Green
|
Write-Host " Using Scoop package manager..." -ForegroundColor Green
|
||||||
|
|
||||||
# Check if Scoop is installed
|
# Check if Scoop is installed
|
||||||
if (-not (Test-Command scoop)) {
|
if (-not (Test-Command scoop)) {
|
||||||
|
|
@ -103,12 +260,12 @@ function Install-ViaScoop {
|
||||||
Invoke-Expression (New-Object System.Net.WebClient).DownloadString('https://get.scoop.sh')
|
Invoke-Expression (New-Object System.Net.WebClient).DownloadString('https://get.scoop.sh')
|
||||||
|
|
||||||
if (-not (Test-Command scoop)) {
|
if (-not (Test-Command scoop)) {
|
||||||
Write-Host "❌ Failed to install Scoop" -ForegroundColor Red
|
Write-Host "[ERROR] Failed to install Scoop" -ForegroundColor Red
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
Write-Host "✓ Scoop installed" -ForegroundColor Green
|
Write-Host "[OK] Scoop installed" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
Write-Host "✓ Scoop already installed" -ForegroundColor Green
|
Write-Host "[OK] Scoop already installed" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
@ -119,7 +276,7 @@ function Install-ViaScoop {
|
||||||
Write-Host "Installing Go..." -ForegroundColor Yellow
|
Write-Host "Installing Go..." -ForegroundColor Yellow
|
||||||
scoop install go
|
scoop install go
|
||||||
} else {
|
} else {
|
||||||
Write-Host "✓ Go already installed" -ForegroundColor Green
|
Write-Host "[OK] Go already installed" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
|
||||||
# Install GCC
|
# Install GCC
|
||||||
|
|
@ -127,7 +284,7 @@ function Install-ViaScoop {
|
||||||
Write-Host "Installing MinGW-w64 (GCC)..." -ForegroundColor Yellow
|
Write-Host "Installing MinGW-w64 (GCC)..." -ForegroundColor Yellow
|
||||||
scoop install mingw
|
scoop install mingw
|
||||||
} else {
|
} else {
|
||||||
Write-Host "✓ GCC already installed" -ForegroundColor Green
|
Write-Host "[OK] GCC already installed" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
|
||||||
# Install Git
|
# Install Git
|
||||||
|
|
@ -135,7 +292,7 @@ function Install-ViaScoop {
|
||||||
Write-Host "Installing Git..." -ForegroundColor Yellow
|
Write-Host "Installing Git..." -ForegroundColor Yellow
|
||||||
scoop install git
|
scoop install git
|
||||||
} else {
|
} else {
|
||||||
Write-Host "✓ Git already installed" -ForegroundColor Green
|
Write-Host "[OK] Git already installed" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
|
||||||
# Install ffmpeg
|
# Install ffmpeg
|
||||||
|
|
@ -144,11 +301,11 @@ function Install-ViaScoop {
|
||||||
Write-Host "Installing ffmpeg..." -ForegroundColor Yellow
|
Write-Host "Installing ffmpeg..." -ForegroundColor Yellow
|
||||||
scoop install ffmpeg
|
scoop install ffmpeg
|
||||||
} else {
|
} else {
|
||||||
Write-Host "✓ ffmpeg already installed" -ForegroundColor Green
|
Write-Host "[OK] ffmpeg already installed" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "✓ Scoop installation complete" -ForegroundColor Green
|
Write-Host "[OK] Scoop installation complete" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
|
||||||
# Main installation logic
|
# Main installation logic
|
||||||
|
|
@ -160,7 +317,7 @@ $osVersion = [System.Environment]::OSVersion.Version
|
||||||
Write-Host "Windows Version: $($osVersion.Major).$($osVersion.Minor) (Build $($osVersion.Build))" -ForegroundColor Cyan
|
Write-Host "Windows Version: $($osVersion.Major).$($osVersion.Minor) (Build $($osVersion.Build))" -ForegroundColor Cyan
|
||||||
|
|
||||||
if ($osVersion.Major -lt 10) {
|
if ($osVersion.Major -lt 10) {
|
||||||
Write-Host "⚠️ Warning: Windows 10 or later is recommended" -ForegroundColor Yellow
|
Write-Host "[WARN] Warning: Windows 10 or later is recommended" -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
||||||
|
|
@ -191,10 +348,12 @@ if ($UseScoop) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ensure-DVDStylerTools
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
|
Write-Host "===============================================================" -ForegroundColor Cyan
|
||||||
Write-Host "✅ DEPENDENCIES INSTALLED" -ForegroundColor Green
|
Write-Host "[OK] DEPENDENCIES INSTALLED" -ForegroundColor Green
|
||||||
Write-Host "════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
|
Write-Host "===============================================================" -ForegroundColor Cyan
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
||||||
# Refresh environment variables
|
# Refresh environment variables
|
||||||
|
|
@ -206,39 +365,51 @@ Write-Host ""
|
||||||
|
|
||||||
if (Test-Command go) {
|
if (Test-Command go) {
|
||||||
$goVersion = go version
|
$goVersion = go version
|
||||||
Write-Host "✓ Go: $goVersion" -ForegroundColor Green
|
Write-Host "[OK] Go: $goVersion" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
Write-Host "⚠️ Go not found in PATH (restart terminal)" -ForegroundColor Yellow
|
Write-Host "[WARN] Go not found in PATH (restart terminal)" -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Test-Command gcc) {
|
if (Test-Command gcc) {
|
||||||
$gccVersion = gcc --version | Select-Object -First 1
|
$gccVersion = gcc --version | Select-Object -First 1
|
||||||
Write-Host "✓ GCC: $gccVersion" -ForegroundColor Green
|
Write-Host "[OK] GCC: $gccVersion" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
Write-Host "⚠️ GCC not found in PATH (restart terminal)" -ForegroundColor Yellow
|
Write-Host "[WARN] GCC not found in PATH (restart terminal)" -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Test-Command ffmpeg) {
|
if (Test-Command ffmpeg) {
|
||||||
$ffmpegVersion = ffmpeg -version | Select-Object -First 1
|
$ffmpegVersion = ffmpeg -version | Select-Object -First 1
|
||||||
Write-Host "✓ ffmpeg: $ffmpegVersion" -ForegroundColor Green
|
Write-Host "[OK] ffmpeg: $ffmpegVersion" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
if ($SkipFFmpeg) {
|
if ($SkipFFmpeg) {
|
||||||
Write-Host "ℹ️ ffmpeg skipped (use -SkipFFmpeg:$false to install)" -ForegroundColor Cyan
|
Write-Host "[INFO] ffmpeg skipped (use -SkipFFmpeg:$false to install)" -ForegroundColor Cyan
|
||||||
} else {
|
} else {
|
||||||
Write-Host "⚠️ ffmpeg not found in PATH (restart terminal)" -ForegroundColor Yellow
|
Write-Host "[WARN] ffmpeg not found in PATH (restart terminal)" -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Test-Command dvdauthor) {
|
||||||
|
Write-Host "[OK] dvdauthor: found" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "[WARN] dvdauthor not found in PATH (restart terminal)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Command mkisofs) {
|
||||||
|
Write-Host "[OK] mkisofs: found" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "[WARN] mkisofs not found in PATH (restart terminal)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
if (Test-Command git) {
|
if (Test-Command git) {
|
||||||
$gitVersion = git --version
|
$gitVersion = git --version
|
||||||
Write-Host "✓ Git: $gitVersion" -ForegroundColor Green
|
Write-Host "[OK] Git: $gitVersion" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
Write-Host "ℹ️ Git not installed (optional)" -ForegroundColor Cyan
|
Write-Host "[INFO] Git not installed (optional)" -ForegroundColor Cyan
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
|
Write-Host "===============================================================" -ForegroundColor Cyan
|
||||||
Write-Host "🎉 Setup complete!" -ForegroundColor Green
|
Write-Host " Setup complete!" -ForegroundColor Green
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Next steps:" -ForegroundColor Yellow
|
Write-Host "Next steps:" -ForegroundColor Yellow
|
||||||
Write-Host " 1. Restart your terminal/PowerShell" -ForegroundColor White
|
Write-Host " 1. Restart your terminal/PowerShell" -ForegroundColor White
|
||||||
|
|
|
||||||
373
scripts/install.sh
Executable file
373
scripts/install.sh
Executable file
|
|
@ -0,0 +1,373 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Spinner function
|
||||||
|
spinner() {
|
||||||
|
local pid=$1
|
||||||
|
local task=$2
|
||||||
|
local spin='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
|
||||||
|
local i=0
|
||||||
|
|
||||||
|
while kill -0 $pid 2>/dev/null; do
|
||||||
|
i=$(( (i+1) %10 ))
|
||||||
|
printf "\r${BLUE}${spin:$i:1}${NC} %s..." "$task"
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
printf "\r"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
# Args
|
||||||
|
DVDSTYLER_URL=""
|
||||||
|
DVDSTYLER_ZIP=""
|
||||||
|
SKIP_DVD_TOOLS=""
|
||||||
|
SKIP_AI_TOOLS=""
|
||||||
|
SKIP_WHISPER=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--dvdstyler-url=*)
|
||||||
|
DVDSTYLER_URL="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--dvdstyler-url)
|
||||||
|
DVDSTYLER_URL="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--dvdstyler-zip=*)
|
||||||
|
DVDSTYLER_ZIP="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--dvdstyler-zip)
|
||||||
|
DVDSTYLER_ZIP="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--skip-dvd)
|
||||||
|
SKIP_DVD_TOOLS=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--skip-ai)
|
||||||
|
SKIP_AI_TOOLS=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--skip-whisper)
|
||||||
|
SKIP_WHISPER=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1"
|
||||||
|
echo "Usage: $0 [--dvdstyler-url URL] [--dvdstyler-zip PATH] [--skip-dvd] [--skip-ai] [--skip-whisper]"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Platform detection
|
||||||
|
UNAME_S="$(uname -s)"
|
||||||
|
IS_WINDOWS=false
|
||||||
|
IS_DARWIN=false
|
||||||
|
IS_LINUX=false
|
||||||
|
case "$UNAME_S" in
|
||||||
|
MINGW*|MSYS*|CYGWIN*)
|
||||||
|
IS_WINDOWS=true
|
||||||
|
;;
|
||||||
|
Darwin*)
|
||||||
|
IS_DARWIN=true
|
||||||
|
;;
|
||||||
|
Linux*)
|
||||||
|
IS_LINUX=true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
INSTALL_TITLE="VideoTools Installation"
|
||||||
|
if [ "$IS_WINDOWS" = true ]; then
|
||||||
|
INSTALL_TITLE="VideoTools Windows Installation"
|
||||||
|
elif [ "$IS_DARWIN" = true ]; then
|
||||||
|
INSTALL_TITLE="VideoTools macOS Installation"
|
||||||
|
elif [ "$IS_LINUX" = true ]; then
|
||||||
|
INSTALL_TITLE="VideoTools Linux Installation"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
|
echo " $INSTALL_TITLE"
|
||||||
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Step 1: Check if Go is installed
|
||||||
|
echo -e "${CYAN}[1/2]${NC} Checking Go installation..."
|
||||||
|
if ! command -v go &> /dev/null; then
|
||||||
|
echo -e "${RED}[ERROR] Error: Go is not installed or not in PATH${NC}"
|
||||||
|
echo "Please install Go 1.21+ from https://go.dev/dl/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
GO_VERSION=$(go version | awk '{print $3}' | sed 's/go//')
|
||||||
|
echo -e "${GREEN}[OK]${NC} Found Go version: $GO_VERSION"
|
||||||
|
|
||||||
|
# Step 2: Check authoring dependencies
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}[2/2]${NC} Checking authoring dependencies..."
|
||||||
|
|
||||||
|
if [ "$IS_WINDOWS" = true ]; then
|
||||||
|
echo "Detected Windows environment."
|
||||||
|
if [ -z "$SKIP_DVD_TOOLS" ]; then
|
||||||
|
echo ""
|
||||||
|
read -p "Install DVD authoring tools (DVDStyler)? [y/N]: " dvd_choice
|
||||||
|
if [[ "$dvd_choice" =~ ^[Yy]$ ]]; then
|
||||||
|
SKIP_DVD_TOOLS=false
|
||||||
|
else
|
||||||
|
SKIP_DVD_TOOLS=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if command -v powershell.exe &> /dev/null; then
|
||||||
|
PS_ARGS=()
|
||||||
|
if [ "$SKIP_DVD_TOOLS" = true ]; then
|
||||||
|
PS_ARGS+=("-SkipDvdStyler")
|
||||||
|
fi
|
||||||
|
if [ -n "$DVDSTYLER_ZIP" ]; then
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$PROJECT_ROOT/scripts/install-deps-windows.ps1" -DvdStylerZip "$DVDSTYLER_ZIP" "${PS_ARGS[@]}"
|
||||||
|
elif [ -n "$DVDSTYLER_URL" ]; then
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$PROJECT_ROOT/scripts/install-deps-windows.ps1" -DvdStylerUrl "$DVDSTYLER_URL" "${PS_ARGS[@]}"
|
||||||
|
else
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$PROJECT_ROOT/scripts/install-deps-windows.ps1" "${PS_ARGS[@]}"
|
||||||
|
fi
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}[ERROR] Windows dependency installer failed.${NC}"
|
||||||
|
echo "If DVDStyler download failed, retry with a direct mirror:"
|
||||||
|
echo ""
|
||||||
|
echo "Git Bash:"
|
||||||
|
echo " export VT_DVDSTYLER_URL=\"https://netcologne.dl.sourceforge.net/project/dvdstyler/DVDStyler/3.2.1/DVDStyler-3.2.1-win64.zip\""
|
||||||
|
echo " ./scripts/install.sh"
|
||||||
|
echo ""
|
||||||
|
echo "PowerShell:"
|
||||||
|
echo " \$env:VT_DVDSTYLER_URL=\"https://netcologne.dl.sourceforge.net/project/dvdstyler/DVDStyler/3.2.1/DVDStyler-3.2.1-win64.zip\""
|
||||||
|
echo " .\\scripts\\install-deps-windows.ps1"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo -e "${GREEN}[OK]${NC} Windows dependency installer completed"
|
||||||
|
else
|
||||||
|
echo -e "${RED}[ERROR] powershell.exe not found.${NC}"
|
||||||
|
echo "Please run: $PROJECT_ROOT\\scripts\\install-deps-windows.ps1"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
missing_deps=()
|
||||||
|
if ! command -v ffmpeg &> /dev/null; then
|
||||||
|
missing_deps+=("ffmpeg")
|
||||||
|
fi
|
||||||
|
if [ -z "$SKIP_DVD_TOOLS" ]; then
|
||||||
|
echo ""
|
||||||
|
read -p "Install DVD authoring tools (dvdauthor + ISO tools)? [y/N]: " dvd_choice
|
||||||
|
if [[ "$dvd_choice" =~ ^[Yy]$ ]]; then
|
||||||
|
SKIP_DVD_TOOLS=false
|
||||||
|
else
|
||||||
|
SKIP_DVD_TOOLS=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ "$SKIP_DVD_TOOLS" = false ]; then
|
||||||
|
if ! command -v dvdauthor &> /dev/null; then
|
||||||
|
missing_deps+=("dvdauthor")
|
||||||
|
fi
|
||||||
|
if ! command -v xorriso &> /dev/null; then
|
||||||
|
missing_deps+=("xorriso")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ask about AI upscaling tools
|
||||||
|
if [ -z "$SKIP_AI_TOOLS" ]; then
|
||||||
|
echo ""
|
||||||
|
read -p "Install AI upscaling tools (Real-ESRGAN NCNN)? [y/N]: " ai_choice
|
||||||
|
if [[ "$ai_choice" =~ ^[Yy]$ ]]; then
|
||||||
|
SKIP_AI_TOOLS=false
|
||||||
|
else
|
||||||
|
SKIP_AI_TOOLS=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ "$SKIP_AI_TOOLS" = false ]; then
|
||||||
|
if ! command -v realesrgan-ncnn-vulkan &> /dev/null; then
|
||||||
|
missing_deps+=("realesrgan-ncnn-vulkan")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ask about Whisper for subtitling
|
||||||
|
if [ -z "$SKIP_WHISPER" ]; then
|
||||||
|
echo ""
|
||||||
|
read -p "Install Whisper for automated subtitling? [y/N]: " whisper_choice
|
||||||
|
if [[ "$whisper_choice" =~ ^[Yy]$ ]]; then
|
||||||
|
SKIP_WHISPER=false
|
||||||
|
else
|
||||||
|
SKIP_WHISPER=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ "$SKIP_WHISPER" = false ]; then
|
||||||
|
if ! command -v whisper &> /dev/null && ! command -v whisper.cpp &> /dev/null; then
|
||||||
|
missing_deps+=("whisper")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
install_deps=false
|
||||||
|
if [ ${#missing_deps[@]} -gt 0 ]; then
|
||||||
|
echo -e "${YELLOW}WARNING:${NC} Missing dependencies: ${missing_deps[*]}"
|
||||||
|
echo "Installing missing dependencies..."
|
||||||
|
install_deps=true
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}[OK]${NC} All authoring dependencies found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$install_deps" = true ]; then
|
||||||
|
if command -v apt-get &> /dev/null; then
|
||||||
|
sudo apt-get update
|
||||||
|
if [ "$SKIP_DVD_TOOLS" = true ]; then
|
||||||
|
sudo apt-get install -y ffmpeg
|
||||||
|
else
|
||||||
|
sudo apt-get install -y ffmpeg dvdauthor xorriso
|
||||||
|
fi
|
||||||
|
elif command -v dnf &> /dev/null; then
|
||||||
|
if [ "$SKIP_DVD_TOOLS" = true ]; then
|
||||||
|
sudo dnf install -y ffmpeg
|
||||||
|
else
|
||||||
|
sudo dnf install -y ffmpeg dvdauthor xorriso
|
||||||
|
fi
|
||||||
|
elif command -v pacman &> /dev/null; then
|
||||||
|
if [ "$SKIP_DVD_TOOLS" = true ]; then
|
||||||
|
sudo pacman -Sy --noconfirm ffmpeg
|
||||||
|
else
|
||||||
|
sudo pacman -Sy --noconfirm ffmpeg dvdauthor cdrtools
|
||||||
|
fi
|
||||||
|
elif command -v zypper &> /dev/null; then
|
||||||
|
if [ "$SKIP_DVD_TOOLS" = true ]; then
|
||||||
|
sudo zypper install -y ffmpeg
|
||||||
|
else
|
||||||
|
sudo zypper install -y ffmpeg dvdauthor xorriso
|
||||||
|
fi
|
||||||
|
elif command -v brew &> /dev/null; then
|
||||||
|
if [ "$SKIP_DVD_TOOLS" = true ]; then
|
||||||
|
brew install ffmpeg
|
||||||
|
else
|
||||||
|
brew install ffmpeg dvdauthor xorriso
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${RED}[ERROR] No supported package manager found.${NC}"
|
||||||
|
echo "Please install: ffmpeg, dvdauthor, and mkisofs/genisoimage/xorriso"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install Real-ESRGAN NCNN if requested and not available
|
||||||
|
if [ "$SKIP_AI_TOOLS" = false ] && ! command -v realesrgan-ncnn-vulkan &> /dev/null; then
|
||||||
|
echo ""
|
||||||
|
echo "Installing Real-ESRGAN NCNN..."
|
||||||
|
|
||||||
|
# Detect architecture
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
if [ "$ARCH" = "x86_64" ]; then
|
||||||
|
ESRGAN_ARCH="ubuntu"
|
||||||
|
elif [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||||
|
echo -e "${YELLOW}WARNING:${NC} ARM architecture detected. You may need to build realesrgan-ncnn-vulkan from source."
|
||||||
|
echo "See: https://github.com/xinntao/Real-ESRGAN-ncnn-vulkan"
|
||||||
|
ESRGAN_ARCH=""
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}WARNING:${NC} Unsupported architecture: $ARCH"
|
||||||
|
ESRGAN_ARCH=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$ESRGAN_ARCH" ]; then
|
||||||
|
ESRGAN_URL="https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesrgan-ncnn-vulkan-20220424-ubuntu.zip"
|
||||||
|
TEMP_DIR=$(mktemp -d)
|
||||||
|
|
||||||
|
if command -v wget &> /dev/null; then
|
||||||
|
wget -q "$ESRGAN_URL" -O "$TEMP_DIR/realesrgan.zip"
|
||||||
|
elif command -v curl &> /dev/null; then
|
||||||
|
curl -sL "$ESRGAN_URL" -o "$TEMP_DIR/realesrgan.zip"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}WARNING:${NC} Neither wget nor curl found. Cannot download Real-ESRGAN."
|
||||||
|
echo "Please install manually from: https://github.com/xinntao/Real-ESRGAN/releases"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$TEMP_DIR/realesrgan.zip" ]; then
|
||||||
|
unzip -q "$TEMP_DIR/realesrgan.zip" -d "$TEMP_DIR"
|
||||||
|
sudo install -m 755 "$TEMP_DIR/realesrgan-ncnn-vulkan" /usr/local/bin/ 2>/dev/null || \
|
||||||
|
install -m 755 "$TEMP_DIR/realesrgan-ncnn-vulkan" "$HOME/.local/bin/" 2>/dev/null || \
|
||||||
|
echo -e "${YELLOW}WARNING:${NC} Could not install to /usr/local/bin or ~/.local/bin"
|
||||||
|
rm -rf "$TEMP_DIR"
|
||||||
|
|
||||||
|
if command -v realesrgan-ncnn-vulkan &> /dev/null; then
|
||||||
|
echo -e "${GREEN}[OK]${NC} Real-ESRGAN NCNN installed successfully"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install Whisper if requested and not available
|
||||||
|
if [ "$SKIP_WHISPER" = false ] && ! command -v whisper &> /dev/null; then
|
||||||
|
echo ""
|
||||||
|
echo "Installing Whisper for automated subtitling..."
|
||||||
|
|
||||||
|
# Check if Python 3 and pip are available
|
||||||
|
if command -v python3 &> /dev/null && command -v pip3 &> /dev/null; then
|
||||||
|
# Install openai-whisper
|
||||||
|
if pip3 install --user openai-whisper 2>/dev/null; then
|
||||||
|
echo -e "${GREEN}[OK]${NC} Whisper installed successfully"
|
||||||
|
echo "To download models, run: whisper --model base dummy.mp3"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}WARNING:${NC} Failed to install Whisper via pip3"
|
||||||
|
echo "You can install it manually with: pip3 install openai-whisper"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}WARNING:${NC} Python 3 and pip3 are required for Whisper"
|
||||||
|
echo "Please install Python 3 and pip3, then run: pip3 install openai-whisper"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure ~/.local/bin is in PATH for user-installed packages
|
||||||
|
if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}NOTE:${NC} Add ~/.local/bin to your PATH to use Whisper:"
|
||||||
|
echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.bashrc"
|
||||||
|
echo " source ~/.bashrc"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v ffmpeg &> /dev/null; then
|
||||||
|
echo -e "${RED}[ERROR] Missing required dependencies after install attempt.${NC}"
|
||||||
|
echo "Please install: ffmpeg"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$SKIP_DVD_TOOLS" = false ]; then
|
||||||
|
if ! command -v dvdauthor &> /dev/null; then
|
||||||
|
echo -e "${RED}[ERROR] Missing required dependencies after install attempt.${NC}"
|
||||||
|
echo "Please install: dvdauthor"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! command -v xorriso &> /dev/null; then
|
||||||
|
echo -e "${RED}[ERROR] Missing xorriso after install attempt.${NC}"
|
||||||
|
echo "Please install: xorriso (required for DVD ISO extraction)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
|
echo "Dependency Installation Complete!"
|
||||||
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo ""
|
||||||
|
echo "1. Build VideoTools:"
|
||||||
|
echo " ./scripts/build.sh"
|
||||||
|
echo ""
|
||||||
|
echo "2. Run VideoTools:"
|
||||||
|
echo " ./scripts/run.sh"
|
||||||
|
echo ""
|
||||||
|
echo "For more information, see BUILD_AND_RUN.md and DVD_USER_GUIDE.md"
|
||||||
|
echo ""
|
||||||
|
|
@ -5,8 +5,17 @@
|
||||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
BUILD_OUTPUT="$PROJECT_ROOT/VideoTools"
|
BUILD_OUTPUT="$PROJECT_ROOT/VideoTools"
|
||||||
|
|
||||||
|
# Detect platform
|
||||||
|
PLATFORM="$(uname -s)"
|
||||||
|
case "$PLATFORM" in
|
||||||
|
Linux*) OS="Linux" ;;
|
||||||
|
Darwin*) OS="macOS" ;;
|
||||||
|
CYGWIN*|MINGW*|MSYS*) OS="Windows" ;;
|
||||||
|
*) echo "❌ Unknown platform: $PLATFORM"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
echo "════════════════════════════════════════════════════════════════"
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
echo " VideoTools - Run Script"
|
echo " VideoTools ${OS} Run"
|
||||||
echo "════════════════════════════════════════════════════════════════"
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
|
|
||||||
297
settings_module.go
Normal file
297
settings_module.go
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/canvas"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/layout"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/ui"
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dependency represents a system dependency
|
||||||
|
type Dependency struct {
|
||||||
|
Name string
|
||||||
|
Command string // Command to check if installed
|
||||||
|
Required bool // If true, core functionality requires this
|
||||||
|
Description string
|
||||||
|
InstallCmd string // Command to install (platform-specific)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleDependencies maps module IDs to their required dependencies
|
||||||
|
var moduleDependencies = map[string][]string{
|
||||||
|
"convert": {"ffmpeg"},
|
||||||
|
"merge": {"ffmpeg"},
|
||||||
|
"trim": {"ffmpeg"},
|
||||||
|
"filters": {"ffmpeg"},
|
||||||
|
"upscale": {"ffmpeg", "realesrgan-ncnn-vulkan"},
|
||||||
|
"audio": {"ffmpeg"},
|
||||||
|
"author": {"ffmpeg", "dvdauthor", "xorriso"},
|
||||||
|
"rip": {"ffmpeg", "xorriso"},
|
||||||
|
"bluray": {"ffmpeg"},
|
||||||
|
"subtitles": {"ffmpeg", "whisper"},
|
||||||
|
"thumb": {"ffmpeg"},
|
||||||
|
"compare": {"ffmpeg"},
|
||||||
|
"inspect": {"ffmpeg"},
|
||||||
|
"player": {"ffmpeg"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllDependencies defines all possible dependencies
|
||||||
|
var allDependencies = map[string]Dependency{
|
||||||
|
"ffmpeg": {
|
||||||
|
Name: "FFmpeg",
|
||||||
|
Command: "ffmpeg",
|
||||||
|
Required: true,
|
||||||
|
Description: "Core video processing engine",
|
||||||
|
InstallCmd: getFFmpegInstallCmd(),
|
||||||
|
},
|
||||||
|
"dvdauthor": {
|
||||||
|
Name: "DVDAuthor",
|
||||||
|
Command: "dvdauthor",
|
||||||
|
Required: false,
|
||||||
|
Description: "DVD authoring tool",
|
||||||
|
InstallCmd: getDVDAuthorInstallCmd(),
|
||||||
|
},
|
||||||
|
"xorriso": {
|
||||||
|
Name: "xorriso",
|
||||||
|
Command: "xorriso",
|
||||||
|
Required: false,
|
||||||
|
Description: "ISO creation and extraction",
|
||||||
|
InstallCmd: getXorrisoInstallCmd(),
|
||||||
|
},
|
||||||
|
"realesrgan-ncnn-vulkan": {
|
||||||
|
Name: "Real-ESRGAN",
|
||||||
|
Command: "realesrgan-ncnn-vulkan",
|
||||||
|
Required: false,
|
||||||
|
Description: "AI video upscaling",
|
||||||
|
InstallCmd: "See install.sh --skip-ai=false",
|
||||||
|
},
|
||||||
|
"whisper": {
|
||||||
|
Name: "Whisper",
|
||||||
|
Command: "whisper",
|
||||||
|
Required: false,
|
||||||
|
Description: "AI subtitle generation",
|
||||||
|
InstallCmd: "pip3 install --user openai-whisper",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFFmpegInstallCmd() string {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
return "sudo apt-get install ffmpeg # or dnf/pacman/zypper"
|
||||||
|
case "darwin":
|
||||||
|
return "brew install ffmpeg"
|
||||||
|
case "windows":
|
||||||
|
return "Download from ffmpeg.org"
|
||||||
|
default:
|
||||||
|
return "See ffmpeg.org for installation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDVDAuthorInstallCmd() string {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
return "sudo apt-get install dvdauthor # or dnf/pacman/zypper"
|
||||||
|
case "darwin":
|
||||||
|
return "brew install dvdauthor"
|
||||||
|
default:
|
||||||
|
return "./scripts/install.sh"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getXorrisoInstallCmd() string {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
return "sudo apt-get install xorriso # or dnf/pacman/zypper"
|
||||||
|
case "darwin":
|
||||||
|
return "brew install xorriso"
|
||||||
|
default:
|
||||||
|
return "./scripts/install.sh"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDependency checks if a command is available
|
||||||
|
func checkDependency(command string) bool {
|
||||||
|
_, err := exec.LookPath(command)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getModuleDependencyStatus checks which dependencies a module is missing
|
||||||
|
func getModuleDependencyStatus(moduleID string) (missing []string, hasAll bool) {
|
||||||
|
deps, ok := moduleDependencies[moduleID]
|
||||||
|
if !ok {
|
||||||
|
return nil, true // Module has no dependencies
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, depName := range deps {
|
||||||
|
dep, exists := allDependencies[depName]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !checkDependency(dep.Command) {
|
||||||
|
missing = append(missing, depName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return missing, len(missing) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// isModuleAvailable returns true if all required dependencies are installed
|
||||||
|
func isModuleAvailable(moduleID string) bool {
|
||||||
|
_, hasAll := getModuleDependencyStatus(moduleID)
|
||||||
|
return hasAll
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSettingsView(state *appState) fyne.CanvasObject {
|
||||||
|
settingsColor := utils.MustHex("#607D8B") // Blue Grey for settings
|
||||||
|
|
||||||
|
backBtn := widget.NewButton("< BACK", func() {
|
||||||
|
state.showMainMenu()
|
||||||
|
})
|
||||||
|
backBtn.Importance = widget.LowImportance
|
||||||
|
|
||||||
|
topBar := ui.TintedBar(settingsColor, container.NewHBox(backBtn, layout.NewSpacer()))
|
||||||
|
bottomBar := moduleFooter(settingsColor, layout.NewSpacer(), state.statsBar)
|
||||||
|
|
||||||
|
tabs := container.NewAppTabs(
|
||||||
|
container.NewTabItem("Dependencies", buildDependenciesTab(state)),
|
||||||
|
container.NewTabItem("Preferences", buildPreferencesTab(state)),
|
||||||
|
)
|
||||||
|
tabs.SetTabLocation(container.TabLocationTop)
|
||||||
|
|
||||||
|
// Single fast scroll container for entire tabs area (12x speed)
|
||||||
|
scrollableTabs := ui.NewFastVScroll(tabs)
|
||||||
|
|
||||||
|
return container.NewBorder(topBar, bottomBar, nil, nil, scrollableTabs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDependenciesTab(state *appState) fyne.CanvasObject {
|
||||||
|
content := container.NewVBox()
|
||||||
|
|
||||||
|
// Header
|
||||||
|
header := widget.NewLabel("System Dependencies")
|
||||||
|
header.TextStyle = fyne.TextStyle{Bold: true}
|
||||||
|
content.Add(header)
|
||||||
|
|
||||||
|
desc := widget.NewLabel("Manage VideoTools dependencies. Some modules require specific tools to be installed.")
|
||||||
|
desc.Wrapping = fyne.TextWrapWord
|
||||||
|
content.Add(desc)
|
||||||
|
|
||||||
|
content.Add(widget.NewSeparator())
|
||||||
|
|
||||||
|
// Check all dependencies
|
||||||
|
for depName, dep := range allDependencies {
|
||||||
|
isInstalled := checkDependency(dep.Command)
|
||||||
|
|
||||||
|
nameLabel := widget.NewLabel(dep.Name)
|
||||||
|
nameLabel.TextStyle = fyne.TextStyle{Bold: true}
|
||||||
|
|
||||||
|
statusLabel := widget.NewLabel("")
|
||||||
|
if isInstalled {
|
||||||
|
statusLabel.SetText("✓ Installed")
|
||||||
|
statusLabel.TextStyle = fyne.TextStyle{Italic: true}
|
||||||
|
} else {
|
||||||
|
statusLabel.SetText("✗ Not Installed")
|
||||||
|
statusLabel.TextStyle = fyne.TextStyle{Italic: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
descLabel := widget.NewLabel(dep.Description)
|
||||||
|
descLabel.TextStyle = fyne.TextStyle{Italic: true}
|
||||||
|
descLabel.Wrapping = fyne.TextWrapWord
|
||||||
|
|
||||||
|
installLabel := widget.NewLabel(dep.InstallCmd)
|
||||||
|
installLabel.Wrapping = fyne.TextWrapWord
|
||||||
|
|
||||||
|
var statusColor color.Color
|
||||||
|
if isInstalled {
|
||||||
|
statusColor = utils.MustHex("#4CAF50") // Green
|
||||||
|
} else {
|
||||||
|
statusColor = utils.MustHex("#F44336") // Red
|
||||||
|
}
|
||||||
|
|
||||||
|
statusBg := canvas.NewRectangle(statusColor)
|
||||||
|
statusBg.CornerRadius = 3
|
||||||
|
statusBg.SetMinSize(fyne.NewSize(12, 12))
|
||||||
|
|
||||||
|
statusRow := container.NewHBox(statusBg, statusLabel)
|
||||||
|
|
||||||
|
infoBox := container.NewVBox(
|
||||||
|
container.NewHBox(nameLabel, layout.NewSpacer(), statusRow),
|
||||||
|
descLabel,
|
||||||
|
)
|
||||||
|
|
||||||
|
if !isInstalled {
|
||||||
|
installCmdLabel := widget.NewLabel("Install: " + installLabel.Text)
|
||||||
|
installCmdLabel.Wrapping = fyne.TextWrapWord
|
||||||
|
infoBox.Add(installCmdLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check which modules need this dependency
|
||||||
|
modulesNeeding := []string{}
|
||||||
|
for modID, deps := range moduleDependencies {
|
||||||
|
for _, d := range deps {
|
||||||
|
if d == depName {
|
||||||
|
// Find module name
|
||||||
|
for _, m := range modulesList {
|
||||||
|
if m.ID == modID {
|
||||||
|
modulesNeeding = append(modulesNeeding, m.Label)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(modulesNeeding) > 0 {
|
||||||
|
neededLabel := widget.NewLabel("Required by: " + strings.Join(modulesNeeding, ", "))
|
||||||
|
neededLabel.TextStyle = fyne.TextStyle{Italic: true}
|
||||||
|
neededLabel.Wrapping = fyne.TextWrapWord
|
||||||
|
infoBox.Add(neededLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
cardBg := canvas.NewRectangle(utils.MustHex("#171C2A"))
|
||||||
|
cardBg.CornerRadius = 6
|
||||||
|
card := container.NewPadded(container.NewMax(cardBg, infoBox))
|
||||||
|
content.Add(card)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh button
|
||||||
|
content.Add(widget.NewSeparator())
|
||||||
|
refreshBtn := widget.NewButton("Refresh Status", func() {
|
||||||
|
state.showSettingsView()
|
||||||
|
})
|
||||||
|
content.Add(refreshBtn)
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPreferencesTab(state *appState) fyne.CanvasObject {
|
||||||
|
content := container.NewVBox()
|
||||||
|
|
||||||
|
header := widget.NewLabel("Application Preferences")
|
||||||
|
header.TextStyle = fyne.TextStyle{Bold: true}
|
||||||
|
content.Add(header)
|
||||||
|
|
||||||
|
content.Add(widget.NewLabel("Preferences panel - Coming soon"))
|
||||||
|
content.Add(widget.NewLabel("This will include settings for:"))
|
||||||
|
content.Add(widget.NewLabel("• Default output directories"))
|
||||||
|
content.Add(widget.NewLabel("• Default encoding presets"))
|
||||||
|
content.Add(widget.NewLabel("• UI theme preferences"))
|
||||||
|
content.Add(widget.NewLabel("• Automatic updates"))
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) showSettingsView() {
|
||||||
|
s.stopPreview()
|
||||||
|
s.lastModule = s.active
|
||||||
|
s.active = "settings"
|
||||||
|
s.setContent(buildSettingsView(s))
|
||||||
|
}
|
||||||
996
subtitles_module.go
Normal file
996
subtitles_module.go
Normal file
|
|
@ -0,0 +1,996 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/canvas"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/dialog"
|
||||||
|
"fyne.io/fyne/v2/layout"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/logging"
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/ui"
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
subtitleModeExternal = "External SRT"
|
||||||
|
subtitleModeEmbed = "Embed Subtitle Track"
|
||||||
|
subtitleModeBurn = "Burn In Subtitles"
|
||||||
|
)
|
||||||
|
|
||||||
|
type subtitleCue struct {
|
||||||
|
Start float64
|
||||||
|
End float64
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
type subtitlesConfig struct {
|
||||||
|
OutputMode string `json:"outputMode"`
|
||||||
|
ModelPath string `json:"modelPath"`
|
||||||
|
BackendPath string `json:"backendPath"`
|
||||||
|
BurnOutput string `json:"burnOutput"`
|
||||||
|
TimeOffset float64 `json:"timeOffset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultSubtitlesConfig() subtitlesConfig {
|
||||||
|
return subtitlesConfig{
|
||||||
|
OutputMode: subtitleModeExternal,
|
||||||
|
ModelPath: "",
|
||||||
|
BackendPath: "",
|
||||||
|
BurnOutput: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPersistedSubtitlesConfig() (subtitlesConfig, error) {
|
||||||
|
var cfg subtitlesConfig
|
||||||
|
path := moduleConfigPath("subtitles")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
if cfg.OutputMode == "" {
|
||||||
|
cfg.OutputMode = subtitleModeExternal
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func savePersistedSubtitlesConfig(cfg subtitlesConfig) error {
|
||||||
|
path := moduleConfigPath("subtitles")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) applySubtitlesConfig(cfg subtitlesConfig) {
|
||||||
|
s.subtitleOutputMode = cfg.OutputMode
|
||||||
|
s.subtitleModelPath = cfg.ModelPath
|
||||||
|
s.subtitleBackendPath = cfg.BackendPath
|
||||||
|
s.subtitleBurnOutput = cfg.BurnOutput
|
||||||
|
s.subtitleTimeOffset = cfg.TimeOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) persistSubtitlesConfig() {
|
||||||
|
cfg := subtitlesConfig{
|
||||||
|
OutputMode: s.subtitleOutputMode,
|
||||||
|
ModelPath: s.subtitleModelPath,
|
||||||
|
BackendPath: s.subtitleBackendPath,
|
||||||
|
BurnOutput: s.subtitleBurnOutput,
|
||||||
|
TimeOffset: s.subtitleTimeOffset,
|
||||||
|
}
|
||||||
|
if err := savePersistedSubtitlesConfig(cfg); err != nil {
|
||||||
|
logging.Debug(logging.CatSystem, "failed to persist subtitles config: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) showSubtitlesView() {
|
||||||
|
s.stopPreview()
|
||||||
|
s.lastModule = s.active
|
||||||
|
s.active = "subtitles"
|
||||||
|
|
||||||
|
if cfg, err := loadPersistedSubtitlesConfig(); err == nil {
|
||||||
|
s.applySubtitlesConfig(cfg)
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
logging.Debug(logging.CatSystem, "failed to load persisted subtitles config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.subtitleOutputMode == "" {
|
||||||
|
s.subtitleOutputMode = subtitleModeExternal
|
||||||
|
}
|
||||||
|
|
||||||
|
s.setContent(buildSubtitlesView(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSubtitlesView(state *appState) fyne.CanvasObject {
|
||||||
|
subtitlesColor := moduleColor("subtitles")
|
||||||
|
|
||||||
|
backBtn := widget.NewButton("< BACK", func() {
|
||||||
|
state.showMainMenu()
|
||||||
|
})
|
||||||
|
backBtn.Importance = widget.LowImportance
|
||||||
|
|
||||||
|
queueBtn := widget.NewButton("View Queue", func() {
|
||||||
|
state.showQueue()
|
||||||
|
})
|
||||||
|
state.queueBtn = queueBtn
|
||||||
|
state.updateQueueButtonLabel()
|
||||||
|
|
||||||
|
clearCompletedBtn := widget.NewButton("⌫", func() {
|
||||||
|
state.clearCompletedJobs()
|
||||||
|
})
|
||||||
|
clearCompletedBtn.Importance = widget.LowImportance
|
||||||
|
|
||||||
|
topBar := ui.TintedBar(subtitlesColor, container.NewHBox(backBtn, layout.NewSpacer(), clearCompletedBtn, queueBtn))
|
||||||
|
bottomBar := moduleFooter(subtitlesColor, layout.NewSpacer(), state.statsBar)
|
||||||
|
|
||||||
|
videoEntry := widget.NewEntry()
|
||||||
|
videoEntry.SetPlaceHolder("Video file path")
|
||||||
|
logging.Debug(logging.CatModule, "buildSubtitlesView: creating videoEntry with subtitleVideoPath=%s", state.subtitleVideoPath)
|
||||||
|
videoEntry.SetText(state.subtitleVideoPath)
|
||||||
|
videoEntry.OnChanged = func(val string) {
|
||||||
|
state.subtitleVideoPath = strings.TrimSpace(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
subtitleEntry := widget.NewEntry()
|
||||||
|
subtitleEntry.SetPlaceHolder("Subtitle file (.srt or .vtt)")
|
||||||
|
subtitleEntry.SetText(state.subtitleFilePath)
|
||||||
|
subtitleEntry.OnChanged = func(val string) {
|
||||||
|
state.subtitleFilePath = strings.TrimSpace(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
modelEntry := widget.NewEntry()
|
||||||
|
modelEntry.SetPlaceHolder("Whisper model path (ggml-*.bin)")
|
||||||
|
modelEntry.SetText(state.subtitleModelPath)
|
||||||
|
modelEntry.OnChanged = func(val string) {
|
||||||
|
state.subtitleModelPath = strings.TrimSpace(val)
|
||||||
|
state.persistSubtitlesConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
backendEntry := widget.NewEntry()
|
||||||
|
backendEntry.SetPlaceHolder("Whisper backend path (whisper.cpp/main)")
|
||||||
|
backendEntry.SetText(state.subtitleBackendPath)
|
||||||
|
backendEntry.OnChanged = func(val string) {
|
||||||
|
state.subtitleBackendPath = strings.TrimSpace(val)
|
||||||
|
state.persistSubtitlesConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
outputEntry := widget.NewEntry()
|
||||||
|
outputEntry.SetPlaceHolder("Output video path (for embed/burn)")
|
||||||
|
outputEntry.SetText(state.subtitleBurnOutput)
|
||||||
|
outputEntry.OnChanged = func(val string) {
|
||||||
|
state.subtitleBurnOutput = strings.TrimSpace(val)
|
||||||
|
state.persistSubtitlesConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
statusLabel := widget.NewLabel("")
|
||||||
|
statusLabel.Wrapping = fyne.TextWrapWord
|
||||||
|
state.subtitleStatusLabel = statusLabel
|
||||||
|
if state.subtitleStatus != "" {
|
||||||
|
statusLabel.SetText(state.subtitleStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rebuildCues func()
|
||||||
|
cueList := container.NewVBox()
|
||||||
|
listScroll := container.NewVScroll(cueList)
|
||||||
|
var emptyOverlay *fyne.Container
|
||||||
|
rebuildCues = func() {
|
||||||
|
cueList.Objects = nil
|
||||||
|
if len(state.subtitleCues) == 0 {
|
||||||
|
if emptyOverlay != nil {
|
||||||
|
emptyOverlay.Show()
|
||||||
|
}
|
||||||
|
cueList.Refresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if emptyOverlay != nil {
|
||||||
|
emptyOverlay.Hide()
|
||||||
|
}
|
||||||
|
for i, cue := range state.subtitleCues {
|
||||||
|
idx := i
|
||||||
|
|
||||||
|
startEntry := widget.NewEntry()
|
||||||
|
startEntry.SetPlaceHolder("00:00:00,000")
|
||||||
|
startEntry.SetText(formatSRTTimestamp(cue.Start))
|
||||||
|
startEntry.OnChanged = func(val string) {
|
||||||
|
if seconds, ok := parseSRTTimestamp(val); ok {
|
||||||
|
state.subtitleCues[idx].Start = seconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endEntry := widget.NewEntry()
|
||||||
|
endEntry.SetPlaceHolder("00:00:00,000")
|
||||||
|
endEntry.SetText(formatSRTTimestamp(cue.End))
|
||||||
|
endEntry.OnChanged = func(val string) {
|
||||||
|
if seconds, ok := parseSRTTimestamp(val); ok {
|
||||||
|
state.subtitleCues[idx].End = seconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textEntry := widget.NewMultiLineEntry()
|
||||||
|
textEntry.SetText(cue.Text)
|
||||||
|
textEntry.Wrapping = fyne.TextWrapWord
|
||||||
|
textEntry.OnChanged = func(val string) {
|
||||||
|
state.subtitleCues[idx].Text = val
|
||||||
|
}
|
||||||
|
|
||||||
|
removeBtn := widget.NewButton("Remove", func() {
|
||||||
|
state.subtitleCues = append(state.subtitleCues[:idx], state.subtitleCues[idx+1:]...)
|
||||||
|
rebuildCues()
|
||||||
|
})
|
||||||
|
removeBtn.Importance = widget.MediumImportance
|
||||||
|
|
||||||
|
timesCol := container.NewVBox(
|
||||||
|
widget.NewLabel("Start"),
|
||||||
|
startEntry,
|
||||||
|
widget.NewLabel("End"),
|
||||||
|
endEntry,
|
||||||
|
)
|
||||||
|
|
||||||
|
row := container.NewBorder(nil, nil, timesCol, removeBtn, textEntry)
|
||||||
|
cardBg := canvas.NewRectangle(utils.MustHex("#171C2A"))
|
||||||
|
cardBg.CornerRadius = 6
|
||||||
|
cardBg.SetMinSize(fyne.NewSize(0, startEntry.MinSize().Height+endEntry.MinSize().Height+textEntry.MinSize().Height+24))
|
||||||
|
cueList.Add(container.NewPadded(container.NewMax(cardBg, row)))
|
||||||
|
}
|
||||||
|
cueList.Refresh()
|
||||||
|
}
|
||||||
|
state.subtitleCuesRefresh = rebuildCues
|
||||||
|
|
||||||
|
handleDrop := func(items []fyne.URI) {
|
||||||
|
logging.Debug(logging.CatModule, "subtitles handleDrop called with %d items", len(items))
|
||||||
|
var videoPath string
|
||||||
|
var subtitlePath string
|
||||||
|
for _, uri := range items {
|
||||||
|
logging.Debug(logging.CatModule, "subtitles handleDrop: uri scheme=%s path=%s", uri.Scheme(), uri.Path())
|
||||||
|
if uri.Scheme() != "file" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := uri.Path()
|
||||||
|
if videoPath == "" && state.isVideoFile(path) {
|
||||||
|
videoPath = path
|
||||||
|
logging.Debug(logging.CatModule, "subtitles handleDrop: identified as video: %s", path)
|
||||||
|
}
|
||||||
|
if subtitlePath == "" && state.isSubtitleFile(path) {
|
||||||
|
subtitlePath = path
|
||||||
|
logging.Debug(logging.CatModule, "subtitles handleDrop: identified as subtitle: %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if videoPath != "" {
|
||||||
|
logging.Debug(logging.CatModule, "subtitles handleDrop: setting video path to %s", videoPath)
|
||||||
|
state.subtitleVideoPath = videoPath
|
||||||
|
videoEntry.SetText(videoPath)
|
||||||
|
logging.Debug(logging.CatModule, "subtitles handleDrop: videoEntry text set to %s", videoPath)
|
||||||
|
}
|
||||||
|
if subtitlePath != "" {
|
||||||
|
logging.Debug(logging.CatModule, "subtitles handleDrop: setting subtitle path to %s", subtitlePath)
|
||||||
|
subtitleEntry.SetText(subtitlePath)
|
||||||
|
if err := state.loadSubtitleFile(subtitlePath); err != nil {
|
||||||
|
state.setSubtitleStatus(err.Error())
|
||||||
|
}
|
||||||
|
rebuildCues()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyLabel := widget.NewLabel("Drag and drop subtitle files here\nor generate subtitles from speech")
|
||||||
|
emptyLabel.Alignment = fyne.TextAlignCenter
|
||||||
|
emptyOverlay = container.NewCenter(emptyLabel)
|
||||||
|
|
||||||
|
listArea := container.NewMax(listScroll, emptyOverlay)
|
||||||
|
|
||||||
|
addCueBtn := widget.NewButton("Add Cue", func() {
|
||||||
|
start := 0.0
|
||||||
|
if len(state.subtitleCues) > 0 {
|
||||||
|
start = state.subtitleCues[len(state.subtitleCues)-1].End
|
||||||
|
}
|
||||||
|
state.subtitleCues = append(state.subtitleCues, subtitleCue{
|
||||||
|
Start: start,
|
||||||
|
End: start + 2.0,
|
||||||
|
Text: "",
|
||||||
|
})
|
||||||
|
rebuildCues()
|
||||||
|
})
|
||||||
|
addCueBtn.Importance = widget.HighImportance
|
||||||
|
|
||||||
|
clearBtn := widget.NewButton("Clear All", func() {
|
||||||
|
state.subtitleCues = nil
|
||||||
|
rebuildCues()
|
||||||
|
})
|
||||||
|
|
||||||
|
loadBtn := widget.NewButton("Load Subtitles", func() {
|
||||||
|
if err := state.loadSubtitleFile(state.subtitleFilePath); err != nil {
|
||||||
|
state.setSubtitleStatus(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rebuildCues()
|
||||||
|
})
|
||||||
|
|
||||||
|
saveBtn := widget.NewButton("Save Subtitles", func() {
|
||||||
|
path := strings.TrimSpace(state.subtitleFilePath)
|
||||||
|
if path == "" {
|
||||||
|
path = defaultSubtitlePath(state.subtitleVideoPath)
|
||||||
|
state.subtitleFilePath = path
|
||||||
|
subtitleEntry.SetText(path)
|
||||||
|
}
|
||||||
|
if err := state.saveSubtitleFile(path); err != nil {
|
||||||
|
state.setSubtitleStatus(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.setSubtitleStatus(fmt.Sprintf("Saved subtitles to %s", filepath.Base(path)))
|
||||||
|
})
|
||||||
|
|
||||||
|
generateBtn := widget.NewButton("Generate From Speech (Offline)", func() {
|
||||||
|
state.generateSubtitlesFromSpeech()
|
||||||
|
rebuildCues()
|
||||||
|
})
|
||||||
|
generateBtn.Importance = widget.HighImportance
|
||||||
|
|
||||||
|
outputModeSelect := widget.NewSelect(
|
||||||
|
[]string{subtitleModeExternal, subtitleModeEmbed, subtitleModeBurn},
|
||||||
|
func(val string) {
|
||||||
|
state.subtitleOutputMode = val
|
||||||
|
state.persistSubtitlesConfig()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
outputModeSelect.SetSelected(state.subtitleOutputMode)
|
||||||
|
|
||||||
|
applyBtn := widget.NewButton("Create Output", func() {
|
||||||
|
state.applySubtitlesToVideo()
|
||||||
|
})
|
||||||
|
applyBtn.Importance = widget.HighImportance
|
||||||
|
|
||||||
|
browseVideoBtn := widget.NewButton("Browse", func() {
|
||||||
|
dialog.ShowFileOpen(func(file fyne.URIReadCloser, err error) {
|
||||||
|
if err != nil || file == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
path := file.URI().Path()
|
||||||
|
state.subtitleVideoPath = path
|
||||||
|
videoEntry.SetText(path)
|
||||||
|
}, state.window)
|
||||||
|
})
|
||||||
|
|
||||||
|
browseSubtitleBtn := widget.NewButton("Browse", func() {
|
||||||
|
dialog.ShowFileOpen(func(file fyne.URIReadCloser, err error) {
|
||||||
|
if err != nil || file == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
path := file.URI().Path()
|
||||||
|
if err := state.loadSubtitleFile(path); err != nil {
|
||||||
|
state.setSubtitleStatus(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subtitleEntry.SetText(path)
|
||||||
|
rebuildCues()
|
||||||
|
}, state.window)
|
||||||
|
})
|
||||||
|
|
||||||
|
offsetEntry := widget.NewEntry()
|
||||||
|
offsetEntry.SetPlaceHolder("0.0")
|
||||||
|
offsetEntry.SetText(fmt.Sprintf("%.2f", state.subtitleTimeOffset))
|
||||||
|
offsetEntry.OnChanged = func(val string) {
|
||||||
|
if offset, err := strconv.ParseFloat(strings.TrimSpace(val), 64); err == nil {
|
||||||
|
state.subtitleTimeOffset = offset
|
||||||
|
state.persistSubtitlesConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyOffsetBtn := widget.NewButton("Apply Offset", func() {
|
||||||
|
state.applySubtitleTimeOffset(state.subtitleTimeOffset)
|
||||||
|
})
|
||||||
|
applyOffsetBtn.Importance = widget.HighImportance
|
||||||
|
|
||||||
|
offsetPlus1Btn := widget.NewButton("+1s", func() {
|
||||||
|
state.applySubtitleTimeOffset(1.0)
|
||||||
|
})
|
||||||
|
|
||||||
|
offsetMinus1Btn := widget.NewButton("-1s", func() {
|
||||||
|
state.applySubtitleTimeOffset(-1.0)
|
||||||
|
})
|
||||||
|
|
||||||
|
offsetPlus01Btn := widget.NewButton("+0.1s", func() {
|
||||||
|
state.applySubtitleTimeOffset(0.1)
|
||||||
|
})
|
||||||
|
|
||||||
|
offsetMinus01Btn := widget.NewButton("-0.1s", func() {
|
||||||
|
state.applySubtitleTimeOffset(-0.1)
|
||||||
|
})
|
||||||
|
|
||||||
|
applyControls := func() {
|
||||||
|
outputModeSelect.SetSelected(state.subtitleOutputMode)
|
||||||
|
backendEntry.SetText(state.subtitleBackendPath)
|
||||||
|
modelEntry.SetText(state.subtitleModelPath)
|
||||||
|
outputEntry.SetText(state.subtitleBurnOutput)
|
||||||
|
offsetEntry.SetText(fmt.Sprintf("%.2f", state.subtitleTimeOffset))
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCfgBtn := widget.NewButton("Load Config", func() {
|
||||||
|
cfg, err := loadPersistedSubtitlesConfig()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
dialog.ShowInformation("No Config", "No saved config found yet. It will save automatically after your first change.", state.window)
|
||||||
|
} else {
|
||||||
|
dialog.ShowError(fmt.Errorf("failed to load config: %w", err), state.window)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.applySubtitlesConfig(cfg)
|
||||||
|
applyControls()
|
||||||
|
})
|
||||||
|
|
||||||
|
saveCfgBtn := widget.NewButton("Save Config", func() {
|
||||||
|
cfg := subtitlesConfig{
|
||||||
|
OutputMode: state.subtitleOutputMode,
|
||||||
|
ModelPath: state.subtitleModelPath,
|
||||||
|
BackendPath: state.subtitleBackendPath,
|
||||||
|
BurnOutput: state.subtitleBurnOutput,
|
||||||
|
TimeOffset: state.subtitleTimeOffset,
|
||||||
|
}
|
||||||
|
if err := savePersistedSubtitlesConfig(cfg); err != nil {
|
||||||
|
dialog.ShowError(fmt.Errorf("failed to save config: %w", err), state.window)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dialog.ShowInformation("Config Saved", fmt.Sprintf("Saved to %s", moduleConfigPath("subtitles")), state.window)
|
||||||
|
})
|
||||||
|
|
||||||
|
resetBtn := widget.NewButton("Reset", func() {
|
||||||
|
cfg := defaultSubtitlesConfig()
|
||||||
|
state.applySubtitlesConfig(cfg)
|
||||||
|
applyControls()
|
||||||
|
state.persistSubtitlesConfig()
|
||||||
|
})
|
||||||
|
|
||||||
|
left := container.NewVBox(
|
||||||
|
widget.NewLabelWithStyle("Sources", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
container.NewBorder(nil, nil, nil, browseVideoBtn, videoEntry),
|
||||||
|
container.NewBorder(nil, nil, nil, browseSubtitleBtn, subtitleEntry),
|
||||||
|
widget.NewSeparator(),
|
||||||
|
widget.NewLabelWithStyle("Timing Adjustment", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
widget.NewLabel("Shift all subtitle times by offset (seconds):"),
|
||||||
|
offsetEntry,
|
||||||
|
container.NewHBox(offsetMinus1Btn, offsetMinus01Btn, offsetPlus01Btn, offsetPlus1Btn),
|
||||||
|
applyOffsetBtn,
|
||||||
|
widget.NewSeparator(),
|
||||||
|
widget.NewLabelWithStyle("Offline Speech-to-Text (whisper.cpp)", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
backendEntry,
|
||||||
|
modelEntry,
|
||||||
|
container.NewHBox(generateBtn),
|
||||||
|
widget.NewSeparator(),
|
||||||
|
widget.NewLabelWithStyle("Output", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
outputModeSelect,
|
||||||
|
outputEntry,
|
||||||
|
applyBtn,
|
||||||
|
widget.NewSeparator(),
|
||||||
|
widget.NewLabelWithStyle("Status", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
statusLabel,
|
||||||
|
widget.NewSeparator(),
|
||||||
|
container.NewHBox(resetBtn, loadCfgBtn, saveCfgBtn),
|
||||||
|
)
|
||||||
|
|
||||||
|
right := container.NewBorder(
|
||||||
|
container.NewVBox(
|
||||||
|
widget.NewLabelWithStyle("Subtitle Cues", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
container.NewHBox(addCueBtn, clearBtn, loadBtn, saveBtn),
|
||||||
|
),
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
listArea,
|
||||||
|
)
|
||||||
|
|
||||||
|
rebuildCues()
|
||||||
|
|
||||||
|
// Wrap both panels in droppable so drops anywhere will work
|
||||||
|
droppableLeft := ui.NewDroppable(left, handleDrop)
|
||||||
|
droppableRight := ui.NewDroppable(right, handleDrop)
|
||||||
|
content := container.NewGridWithColumns(2, droppableLeft, droppableRight)
|
||||||
|
return container.NewBorder(topBar, bottomBar, nil, nil, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) setSubtitleStatus(msg string) {
|
||||||
|
s.subtitleStatus = msg
|
||||||
|
if s.subtitleStatusLabel != nil {
|
||||||
|
s.subtitleStatusLabel.SetText(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) setSubtitleStatusAsync(msg string) {
|
||||||
|
app := fyne.CurrentApp()
|
||||||
|
if app == nil || app.Driver() == nil {
|
||||||
|
s.setSubtitleStatus(msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.Driver().DoFromGoroutine(func() {
|
||||||
|
s.setSubtitleStatus(msg)
|
||||||
|
}, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) handleSubtitlesModuleDrop(items []fyne.URI) {
|
||||||
|
logging.Debug(logging.CatModule, "handleSubtitlesModuleDrop called with %d items", len(items))
|
||||||
|
var videoPath string
|
||||||
|
var subtitlePath string
|
||||||
|
for _, uri := range items {
|
||||||
|
logging.Debug(logging.CatModule, "handleSubtitlesModuleDrop: uri scheme=%s path=%s", uri.Scheme(), uri.Path())
|
||||||
|
if uri.Scheme() != "file" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := uri.Path()
|
||||||
|
if videoPath == "" && s.isVideoFile(path) {
|
||||||
|
videoPath = path
|
||||||
|
logging.Debug(logging.CatModule, "handleSubtitlesModuleDrop: identified as video: %s", path)
|
||||||
|
}
|
||||||
|
if subtitlePath == "" && s.isSubtitleFile(path) {
|
||||||
|
subtitlePath = path
|
||||||
|
logging.Debug(logging.CatModule, "handleSubtitlesModuleDrop: identified as subtitle: %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if videoPath == "" && subtitlePath == "" {
|
||||||
|
logging.Debug(logging.CatModule, "handleSubtitlesModuleDrop: no video or subtitle found, returning")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if videoPath != "" {
|
||||||
|
logging.Debug(logging.CatModule, "handleSubtitlesModuleDrop: setting subtitleVideoPath to %s", videoPath)
|
||||||
|
s.subtitleVideoPath = videoPath
|
||||||
|
}
|
||||||
|
if subtitlePath != "" {
|
||||||
|
logging.Debug(logging.CatModule, "handleSubtitlesModuleDrop: loading subtitle file %s", subtitlePath)
|
||||||
|
if err := s.loadSubtitleFile(subtitlePath); err != nil {
|
||||||
|
s.setSubtitleStatus(err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to subtitles module to show the loaded files
|
||||||
|
logging.Debug(logging.CatModule, "handleSubtitlesModuleDrop: calling showModule(subtitles), subtitleVideoPath=%s", s.subtitleVideoPath)
|
||||||
|
s.showModule("subtitles")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) loadSubtitleFile(path string) error {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return fmt.Errorf("subtitle path is empty")
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read subtitles: %w", err)
|
||||||
|
}
|
||||||
|
cues, err := parseSubtitlePayload(path, string(data))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.subtitleFilePath = path
|
||||||
|
s.subtitleCues = cues
|
||||||
|
s.setSubtitleStatus(fmt.Sprintf("Loaded %d subtitle cues", len(cues)))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) saveSubtitleFile(path string) error {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return fmt.Errorf("subtitle output path is empty")
|
||||||
|
}
|
||||||
|
if len(s.subtitleCues) == 0 {
|
||||||
|
return fmt.Errorf("no subtitle cues to save")
|
||||||
|
}
|
||||||
|
payload := formatSRT(s.subtitleCues)
|
||||||
|
if err := os.WriteFile(path, []byte(payload), 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write subtitles: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) applySubtitleTimeOffset(offsetSeconds float64) {
|
||||||
|
if len(s.subtitleCues) == 0 {
|
||||||
|
s.setSubtitleStatus("No subtitle cues to adjust")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range s.subtitleCues {
|
||||||
|
s.subtitleCues[i].Start += offsetSeconds
|
||||||
|
s.subtitleCues[i].End += offsetSeconds
|
||||||
|
if s.subtitleCues[i].Start < 0 {
|
||||||
|
s.subtitleCues[i].Start = 0
|
||||||
|
}
|
||||||
|
if s.subtitleCues[i].End < 0 {
|
||||||
|
s.subtitleCues[i].End = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.subtitleCuesRefresh != nil {
|
||||||
|
s.subtitleCuesRefresh()
|
||||||
|
}
|
||||||
|
s.setSubtitleStatus(fmt.Sprintf("Applied %.2fs offset to %d subtitle cues", offsetSeconds, len(s.subtitleCues)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) generateSubtitlesFromSpeech() {
|
||||||
|
videoPath := strings.TrimSpace(s.subtitleVideoPath)
|
||||||
|
if videoPath == "" {
|
||||||
|
s.setSubtitleStatus("Set a video file to generate subtitles.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(videoPath); err != nil {
|
||||||
|
s.setSubtitleStatus("Video file not found.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modelPath := strings.TrimSpace(s.subtitleModelPath)
|
||||||
|
if modelPath == "" {
|
||||||
|
s.setSubtitleStatus("Set a whisper model path.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
backendPath := strings.TrimSpace(s.subtitleBackendPath)
|
||||||
|
if backendPath == "" {
|
||||||
|
if detected := detectWhisperBackend(); detected != "" {
|
||||||
|
backendPath = detected
|
||||||
|
s.subtitleBackendPath = detected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if backendPath == "" {
|
||||||
|
s.setSubtitleStatus("Whisper backend not found. Set the backend path.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
outputPath := strings.TrimSpace(s.subtitleFilePath)
|
||||||
|
if outputPath == "" {
|
||||||
|
outputPath = defaultSubtitlePath(videoPath)
|
||||||
|
s.subtitleFilePath = outputPath
|
||||||
|
}
|
||||||
|
baseOutput := strings.TrimSuffix(outputPath, filepath.Ext(outputPath))
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
tmpWav := filepath.Join(os.TempDir(), fmt.Sprintf("vt-stt-%d.wav", time.Now().UnixNano()))
|
||||||
|
defer os.Remove(tmpWav)
|
||||||
|
|
||||||
|
s.setSubtitleStatusAsync("Extracting audio for speech-to-text...")
|
||||||
|
if err := runFFmpeg([]string{
|
||||||
|
"-y",
|
||||||
|
"-i", videoPath,
|
||||||
|
"-vn",
|
||||||
|
"-ac", "1",
|
||||||
|
"-ar", "16000",
|
||||||
|
"-f", "wav",
|
||||||
|
tmpWav,
|
||||||
|
}); err != nil {
|
||||||
|
s.setSubtitleStatusAsync(fmt.Sprintf("Audio extraction failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.setSubtitleStatusAsync("Running offline speech-to-text...")
|
||||||
|
if err := runWhisper(backendPath, modelPath, tmpWav, baseOutput); err != nil {
|
||||||
|
s.setSubtitleStatusAsync(fmt.Sprintf("Speech-to-text failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
finalPath := baseOutput + ".srt"
|
||||||
|
if err := s.loadSubtitleFile(finalPath); err != nil {
|
||||||
|
s.setSubtitleStatusAsync(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.setSubtitleStatusAsync(fmt.Sprintf("Generated subtitles: %s", filepath.Base(finalPath)))
|
||||||
|
app := fyne.CurrentApp()
|
||||||
|
if app != nil && app.Driver() != nil {
|
||||||
|
app.Driver().DoFromGoroutine(func() {
|
||||||
|
if s.active == "subtitles" {
|
||||||
|
s.showSubtitlesView()
|
||||||
|
}
|
||||||
|
}, false)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) applySubtitlesToVideo() {
|
||||||
|
videoPath := strings.TrimSpace(s.subtitleVideoPath)
|
||||||
|
if videoPath == "" {
|
||||||
|
s.setSubtitleStatus("Set a video file before creating output.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(videoPath); err != nil {
|
||||||
|
s.setSubtitleStatus("Video file not found.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := s.subtitleOutputMode
|
||||||
|
if mode == "" {
|
||||||
|
mode = subtitleModeExternal
|
||||||
|
}
|
||||||
|
|
||||||
|
subPath := strings.TrimSpace(s.subtitleFilePath)
|
||||||
|
if subPath == "" {
|
||||||
|
subPath = defaultSubtitlePath(videoPath)
|
||||||
|
s.subtitleFilePath = subPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.saveSubtitleFile(subPath); err != nil {
|
||||||
|
s.setSubtitleStatus(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode == subtitleModeExternal {
|
||||||
|
s.setSubtitleStatus(fmt.Sprintf("Saved subtitles to %s", filepath.Base(subPath)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
outputPath := strings.TrimSpace(s.subtitleBurnOutput)
|
||||||
|
if outputPath == "" {
|
||||||
|
outputPath = defaultSubtitleOutputPath(videoPath)
|
||||||
|
s.subtitleBurnOutput = outputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
s.setSubtitleStatusAsync("Creating output with subtitles...")
|
||||||
|
var args []string
|
||||||
|
switch mode {
|
||||||
|
case subtitleModeEmbed:
|
||||||
|
subCodec := subtitleCodecForOutput(outputPath)
|
||||||
|
args = []string{
|
||||||
|
"-y",
|
||||||
|
"-i", videoPath,
|
||||||
|
"-i", subPath,
|
||||||
|
"-map", "0",
|
||||||
|
"-map", "1",
|
||||||
|
"-c", "copy",
|
||||||
|
"-c:s", subCodec,
|
||||||
|
outputPath,
|
||||||
|
}
|
||||||
|
case subtitleModeBurn:
|
||||||
|
filterPath := escapeFFmpegFilterPath(subPath)
|
||||||
|
args = []string{
|
||||||
|
"-y",
|
||||||
|
"-i", videoPath,
|
||||||
|
"-vf", fmt.Sprintf("subtitles=%s", filterPath),
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-crf", "18",
|
||||||
|
"-preset", "fast",
|
||||||
|
"-c:a", "copy",
|
||||||
|
outputPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runFFmpeg(args); err != nil {
|
||||||
|
s.setSubtitleStatusAsync(fmt.Sprintf("Subtitle output failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.setSubtitleStatusAsync(fmt.Sprintf("Output created: %s", filepath.Base(outputPath)))
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSubtitlePayload(path, content string) ([]subtitleCue, error) {
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
switch ext {
|
||||||
|
case ".vtt":
|
||||||
|
content = stripVTTHeader(content)
|
||||||
|
return parseSRT(content), nil
|
||||||
|
case ".srt":
|
||||||
|
return parseSRT(content), nil
|
||||||
|
case ".ass", ".ssa":
|
||||||
|
return nil, fmt.Errorf("ASS/SSA subtitles are not supported yet")
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported subtitle format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripVTTHeader(content string) string {
|
||||||
|
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
var kept []string
|
||||||
|
for i, line := range lines {
|
||||||
|
if i == 0 && strings.HasPrefix(strings.TrimSpace(line), "WEBVTT") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(line), "NOTE") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, line)
|
||||||
|
}
|
||||||
|
return strings.Join(kept, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSRT(content string) []subtitleCue {
|
||||||
|
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||||
|
var cues []subtitleCue
|
||||||
|
var inCue bool
|
||||||
|
var start float64
|
||||||
|
var end float64
|
||||||
|
var lines []string
|
||||||
|
|
||||||
|
flush := func() {
|
||||||
|
if inCue && len(lines) > 0 {
|
||||||
|
cues = append(cues, subtitleCue{
|
||||||
|
Start: start,
|
||||||
|
End: end,
|
||||||
|
Text: strings.Join(lines, "\n"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
inCue = false
|
||||||
|
lines = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
flush()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(line, "-->") {
|
||||||
|
parts := strings.Split(line, "-->")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
if s, ok := parseSRTTimestamp(strings.TrimSpace(parts[0])); ok {
|
||||||
|
if e, ok := parseSRTTimestamp(strings.TrimSpace(parts[1])); ok {
|
||||||
|
start = s
|
||||||
|
end = e
|
||||||
|
inCue = true
|
||||||
|
lines = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !inCue {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
flush()
|
||||||
|
return cues
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSRTTimestamp(value string) (float64, bool) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
value = strings.ReplaceAll(value, ",", ".")
|
||||||
|
parts := strings.Split(value, ":")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
hours, err := strconv.Atoi(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
minutes, err := strconv.Atoi(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
secParts := strings.SplitN(parts[2], ".", 2)
|
||||||
|
seconds, err := strconv.Atoi(secParts[0])
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
ms := 0
|
||||||
|
if len(secParts) == 2 {
|
||||||
|
msStr := secParts[1]
|
||||||
|
if len(msStr) > 3 {
|
||||||
|
msStr = msStr[:3]
|
||||||
|
}
|
||||||
|
for len(msStr) < 3 {
|
||||||
|
msStr += "0"
|
||||||
|
}
|
||||||
|
ms, err = strconv.Atoi(msStr)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
totalMs := ((hours*60+minutes)*60+seconds)*1000 + ms
|
||||||
|
return float64(totalMs) / 1000.0, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSRTTimestamp(seconds float64) string {
|
||||||
|
if seconds < 0 {
|
||||||
|
seconds = 0
|
||||||
|
}
|
||||||
|
totalMs := int64(seconds*1000 + 0.5)
|
||||||
|
hours := totalMs / 3600000
|
||||||
|
minutes := (totalMs % 3600000) / 60000
|
||||||
|
secs := (totalMs % 60000) / 1000
|
||||||
|
ms := totalMs % 1000
|
||||||
|
return fmt.Sprintf("%02d:%02d:%02d,%03d", hours, minutes, secs, ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSRT(cues []subtitleCue) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for i, cue := range cues {
|
||||||
|
b.WriteString(fmt.Sprintf("%d\n", i+1))
|
||||||
|
b.WriteString(fmt.Sprintf("%s --> %s\n", formatSRTTimestamp(cue.Start), formatSRTTimestamp(cue.End)))
|
||||||
|
b.WriteString(strings.TrimSpace(cue.Text))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultSubtitlePath(videoPath string) string {
|
||||||
|
if videoPath == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(videoPath)
|
||||||
|
base := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath))
|
||||||
|
return filepath.Join(dir, base+".srt")
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultSubtitleOutputPath(videoPath string) string {
|
||||||
|
if videoPath == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(videoPath)
|
||||||
|
base := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath))
|
||||||
|
ext := filepath.Ext(videoPath)
|
||||||
|
if ext == "" {
|
||||||
|
ext = ".mp4"
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, base+"-subtitled"+ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func subtitleCodecForOutput(outputPath string) string {
|
||||||
|
ext := strings.ToLower(filepath.Ext(outputPath))
|
||||||
|
switch ext {
|
||||||
|
case ".mp4", ".m4v", ".mov":
|
||||||
|
return "mov_text"
|
||||||
|
default:
|
||||||
|
return "srt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeFFmpegFilterPath(path string) string {
|
||||||
|
escaped := strings.ReplaceAll(path, "\\", "\\\\")
|
||||||
|
escaped = strings.ReplaceAll(escaped, ":", "\\:")
|
||||||
|
escaped = strings.ReplaceAll(escaped, "'", "\\'")
|
||||||
|
return escaped
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectWhisperBackend() string {
|
||||||
|
candidates := []string{"whisper.cpp", "whisper", "main", "main.exe", "whisper.exe"}
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if found, err := exec.LookPath(candidate); err == nil {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWhisper(binaryPath, modelPath, inputPath, outputBase string) error {
|
||||||
|
args := []string{
|
||||||
|
"-m", modelPath,
|
||||||
|
"-f", inputPath,
|
||||||
|
"-of", outputBase,
|
||||||
|
"-osrt",
|
||||||
|
}
|
||||||
|
cmd := exec.Command(binaryPath, args...)
|
||||||
|
utils.ApplyNoWindow(cmd)
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("whisper failed: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runFFmpeg(args []string) error {
|
||||||
|
cmd := exec.Command(platformConfig.FFmpegPath, args...)
|
||||||
|
utils.ApplyNoWindow(cmd)
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("ffmpeg failed: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
412
thumb_module.go
Normal file
412
thumb_module.go
Normal file
|
|
@ -0,0 +1,412 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/canvas"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/dialog"
|
||||||
|
"fyne.io/fyne/v2/layout"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/logging"
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/queue"
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/thumbnail"
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *appState) showThumbView() {
|
||||||
|
s.stopPreview()
|
||||||
|
s.lastModule = s.active
|
||||||
|
s.active = "thumb"
|
||||||
|
s.setContent(buildThumbView(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildThumbView(state *appState) fyne.CanvasObject {
|
||||||
|
thumbColor := moduleColor("thumb")
|
||||||
|
|
||||||
|
// Back button
|
||||||
|
backBtn := widget.NewButton("< THUMBNAILS", func() {
|
||||||
|
state.showMainMenu()
|
||||||
|
})
|
||||||
|
backBtn.Importance = widget.LowImportance
|
||||||
|
|
||||||
|
// Top bar with module color
|
||||||
|
queueBtn := widget.NewButton("View Queue", func() {
|
||||||
|
state.showQueue()
|
||||||
|
})
|
||||||
|
state.queueBtn = queueBtn
|
||||||
|
state.updateQueueButtonLabel()
|
||||||
|
|
||||||
|
clearCompletedBtn := widget.NewButton("⌫", func() {
|
||||||
|
state.clearCompletedJobs()
|
||||||
|
})
|
||||||
|
clearCompletedBtn.Importance = widget.LowImportance
|
||||||
|
|
||||||
|
topBar := ui.TintedBar(thumbColor, container.NewHBox(backBtn, layout.NewSpacer(), clearCompletedBtn, queueBtn))
|
||||||
|
|
||||||
|
// Instructions
|
||||||
|
instructions := widget.NewLabel("Generate thumbnails from a video file. Load a video and configure settings.")
|
||||||
|
instructions.Wrapping = fyne.TextWrapWord
|
||||||
|
instructions.Alignment = fyne.TextAlignCenter
|
||||||
|
|
||||||
|
// Initialize state defaults
|
||||||
|
if state.thumbCount == 0 {
|
||||||
|
state.thumbCount = 24 // Default to 24 thumbnails (good for contact sheets)
|
||||||
|
}
|
||||||
|
if state.thumbWidth == 0 {
|
||||||
|
state.thumbWidth = 320
|
||||||
|
}
|
||||||
|
if state.thumbColumns == 0 {
|
||||||
|
state.thumbColumns = 4 // 4 columns works well for widescreen videos
|
||||||
|
}
|
||||||
|
if state.thumbRows == 0 {
|
||||||
|
state.thumbRows = 6 // 4x6 = 24 thumbnails
|
||||||
|
}
|
||||||
|
|
||||||
|
// File label and video preview
|
||||||
|
fileLabel := widget.NewLabel("No file loaded")
|
||||||
|
fileLabel.TextStyle = fyne.TextStyle{Bold: true}
|
||||||
|
|
||||||
|
var videoContainer fyne.CanvasObject
|
||||||
|
if state.thumbFile != nil {
|
||||||
|
fileLabel.SetText(fmt.Sprintf("File: %s", filepath.Base(state.thumbFile.Path)))
|
||||||
|
videoContainer = buildVideoPane(state, fyne.NewSize(480, 270), state.thumbFile, nil)
|
||||||
|
} else {
|
||||||
|
videoContainer = container.NewCenter(widget.NewLabel("No video loaded"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.thumbFile = src
|
||||||
|
state.showThumbView()
|
||||||
|
logging.Debug(logging.CatModule, "loaded thumbnail file: %s", path)
|
||||||
|
}, state.window)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clear button
|
||||||
|
clearBtn := widget.NewButton("Clear", func() {
|
||||||
|
state.thumbFile = nil
|
||||||
|
state.showThumbView()
|
||||||
|
})
|
||||||
|
clearBtn.Importance = widget.LowImportance
|
||||||
|
|
||||||
|
// Contact sheet checkbox
|
||||||
|
contactSheetCheck := widget.NewCheck("Generate Contact Sheet (single image)", func(checked bool) {
|
||||||
|
state.thumbContactSheet = checked
|
||||||
|
state.showThumbView()
|
||||||
|
})
|
||||||
|
contactSheetCheck.Checked = state.thumbContactSheet
|
||||||
|
|
||||||
|
// Conditional settings based on contact sheet mode
|
||||||
|
var settingsOptions fyne.CanvasObject
|
||||||
|
if state.thumbContactSheet {
|
||||||
|
// Contact sheet mode: show columns and rows
|
||||||
|
colLabel := widget.NewLabel(fmt.Sprintf("Columns: %d", state.thumbColumns))
|
||||||
|
rowLabel := widget.NewLabel(fmt.Sprintf("Rows: %d", state.thumbRows))
|
||||||
|
|
||||||
|
totalThumbs := state.thumbColumns * state.thumbRows
|
||||||
|
totalLabel := widget.NewLabel(fmt.Sprintf("Total thumbnails: %d", totalThumbs))
|
||||||
|
totalLabel.TextStyle = fyne.TextStyle{Italic: true}
|
||||||
|
|
||||||
|
colSlider := widget.NewSlider(2, 12)
|
||||||
|
colSlider.Value = float64(state.thumbColumns)
|
||||||
|
colSlider.Step = 1
|
||||||
|
colSlider.OnChanged = func(val float64) {
|
||||||
|
state.thumbColumns = int(val)
|
||||||
|
colLabel.SetText(fmt.Sprintf("Columns: %d", int(val)))
|
||||||
|
totalLabel.SetText(fmt.Sprintf("Total thumbnails: %d", state.thumbColumns*state.thumbRows))
|
||||||
|
}
|
||||||
|
|
||||||
|
rowSlider := widget.NewSlider(2, 12)
|
||||||
|
rowSlider.Value = float64(state.thumbRows)
|
||||||
|
rowSlider.Step = 1
|
||||||
|
rowSlider.OnChanged = func(val float64) {
|
||||||
|
state.thumbRows = int(val)
|
||||||
|
rowLabel.SetText(fmt.Sprintf("Rows: %d", int(val)))
|
||||||
|
totalLabel.SetText(fmt.Sprintf("Total thumbnails: %d", state.thumbColumns*state.thumbRows))
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsOptions = container.NewVBox(
|
||||||
|
widget.NewSeparator(),
|
||||||
|
widget.NewLabel("Contact Sheet Grid:"),
|
||||||
|
colLabel,
|
||||||
|
colSlider,
|
||||||
|
rowLabel,
|
||||||
|
rowSlider,
|
||||||
|
totalLabel,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Individual thumbnails mode: show count and width
|
||||||
|
countLabel := widget.NewLabel(fmt.Sprintf("Thumbnail Count: %d", state.thumbCount))
|
||||||
|
countSlider := widget.NewSlider(3, 50)
|
||||||
|
countSlider.Value = float64(state.thumbCount)
|
||||||
|
countSlider.Step = 1
|
||||||
|
countSlider.OnChanged = func(val float64) {
|
||||||
|
state.thumbCount = int(val)
|
||||||
|
countLabel.SetText(fmt.Sprintf("Thumbnail Count: %d", int(val)))
|
||||||
|
}
|
||||||
|
|
||||||
|
widthLabel := widget.NewLabel(fmt.Sprintf("Thumbnail Width: %d px", state.thumbWidth))
|
||||||
|
widthSlider := widget.NewSlider(160, 640)
|
||||||
|
widthSlider.Value = float64(state.thumbWidth)
|
||||||
|
widthSlider.Step = 32
|
||||||
|
widthSlider.OnChanged = func(val float64) {
|
||||||
|
state.thumbWidth = int(val)
|
||||||
|
widthLabel.SetText(fmt.Sprintf("Thumbnail Width: %d px", int(val)))
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsOptions = container.NewVBox(
|
||||||
|
widget.NewSeparator(),
|
||||||
|
widget.NewLabel("Individual Thumbnails:"),
|
||||||
|
countLabel,
|
||||||
|
countSlider,
|
||||||
|
widthLabel,
|
||||||
|
widthSlider,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create thumbnail job
|
||||||
|
createThumbJob := func() *queue.Job {
|
||||||
|
// Create output directory in same folder as video
|
||||||
|
videoDir := filepath.Dir(state.thumbFile.Path)
|
||||||
|
videoBaseName := strings.TrimSuffix(filepath.Base(state.thumbFile.Path), filepath.Ext(state.thumbFile.Path))
|
||||||
|
outputDir := filepath.Join(videoDir, fmt.Sprintf("%s_thumbnails", videoBaseName))
|
||||||
|
|
||||||
|
// Configure based on mode
|
||||||
|
var count, width int
|
||||||
|
var description string
|
||||||
|
if state.thumbContactSheet {
|
||||||
|
// Contact sheet: count is determined by grid, use larger width for analyzable screenshots
|
||||||
|
count = state.thumbColumns * state.thumbRows
|
||||||
|
width = 280 // Larger width for contact sheets to make screenshots analyzable (4x8 grid = ~1144x1416)
|
||||||
|
description = fmt.Sprintf("Contact sheet: %dx%d grid (%d thumbnails)", state.thumbColumns, state.thumbRows, count)
|
||||||
|
} else {
|
||||||
|
// Individual thumbnails: use user settings
|
||||||
|
count = state.thumbCount
|
||||||
|
width = state.thumbWidth
|
||||||
|
description = fmt.Sprintf("%d individual thumbnails (%dpx width)", count, width)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &queue.Job{
|
||||||
|
Type: queue.JobTypeThumb,
|
||||||
|
Title: "Thumbnails: " + filepath.Base(state.thumbFile.Path),
|
||||||
|
Description: description,
|
||||||
|
InputFile: state.thumbFile.Path,
|
||||||
|
OutputFile: outputDir,
|
||||||
|
Config: map[string]interface{}{
|
||||||
|
"inputPath": state.thumbFile.Path,
|
||||||
|
"outputDir": outputDir,
|
||||||
|
"count": float64(count),
|
||||||
|
"width": float64(width),
|
||||||
|
"contactSheet": state.thumbContactSheet,
|
||||||
|
"columns": float64(state.thumbColumns),
|
||||||
|
"rows": float64(state.thumbRows),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate Now button - adds to queue and starts it
|
||||||
|
generateNowBtn := widget.NewButton("GENERATE NOW", func() {
|
||||||
|
if state.thumbFile == nil {
|
||||||
|
dialog.ShowInformation("No Video", "Please load a video file first.", state.window)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.jobQueue == nil {
|
||||||
|
dialog.ShowInformation("Queue", "Queue not initialized.", state.window)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
job := createThumbJob()
|
||||||
|
state.jobQueue.Add(job)
|
||||||
|
|
||||||
|
// Start queue if not already running
|
||||||
|
if !state.jobQueue.IsRunning() {
|
||||||
|
state.jobQueue.Start()
|
||||||
|
logging.Debug(logging.CatSystem, "started queue from Generate Now")
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.ShowInformation("Thumbnails", "Thumbnail generation started! View progress in Job Queue.", state.window)
|
||||||
|
})
|
||||||
|
generateNowBtn.Importance = widget.HighImportance
|
||||||
|
|
||||||
|
if state.thumbFile == nil {
|
||||||
|
generateNowBtn.Disable()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to Queue button
|
||||||
|
addQueueBtn := widget.NewButton("Add to Queue", func() {
|
||||||
|
if state.thumbFile == nil {
|
||||||
|
dialog.ShowInformation("No Video", "Please load a video file first.", state.window)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.jobQueue == nil {
|
||||||
|
dialog.ShowInformation("Queue", "Queue not initialized.", state.window)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
job := createThumbJob()
|
||||||
|
state.jobQueue.Add(job)
|
||||||
|
|
||||||
|
dialog.ShowInformation("Queue", "Thumbnail job added to queue!", state.window)
|
||||||
|
})
|
||||||
|
addQueueBtn.Importance = widget.MediumImportance
|
||||||
|
|
||||||
|
if state.thumbFile == nil {
|
||||||
|
addQueueBtn.Disable()
|
||||||
|
}
|
||||||
|
|
||||||
|
// View Queue button
|
||||||
|
viewQueueBtn := widget.NewButton("View Queue", func() {
|
||||||
|
state.showQueue()
|
||||||
|
})
|
||||||
|
viewQueueBtn.Importance = widget.MediumImportance
|
||||||
|
|
||||||
|
// View Results button - shows output folder if it exists
|
||||||
|
viewResultsBtn := widget.NewButton("View Results", func() {
|
||||||
|
if state.thumbFile == nil {
|
||||||
|
dialog.ShowInformation("No Video", "Load a video first to locate results.", state.window)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
videoDir := filepath.Dir(state.thumbFile.Path)
|
||||||
|
videoBaseName := strings.TrimSuffix(filepath.Base(state.thumbFile.Path), filepath.Ext(state.thumbFile.Path))
|
||||||
|
outputDir := filepath.Join(videoDir, fmt.Sprintf("%s_thumbnails", videoBaseName))
|
||||||
|
|
||||||
|
// Check if output exists
|
||||||
|
if _, err := os.Stat(outputDir); os.IsNotExist(err) {
|
||||||
|
dialog.ShowInformation("No Results", "No generated thumbnails found. Generate thumbnails first.", state.window)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If contact sheet mode, try to show contact sheet image
|
||||||
|
if state.thumbContactSheet {
|
||||||
|
contactSheetPath := filepath.Join(outputDir, "contact_sheet.jpg")
|
||||||
|
if _, err := os.Stat(contactSheetPath); err == nil {
|
||||||
|
// Show contact sheet in a dialog
|
||||||
|
go func() {
|
||||||
|
img := canvas.NewImageFromFile(contactSheetPath)
|
||||||
|
img.FillMode = canvas.ImageFillContain
|
||||||
|
// Adaptive size for small screens - use scrollable dialog
|
||||||
|
img.SetMinSize(fyne.NewSize(640, 480))
|
||||||
|
|
||||||
|
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
||||||
|
// Wrap in scroll container for large contact sheets
|
||||||
|
scroll := container.NewScroll(img)
|
||||||
|
d := dialog.NewCustom("Contact Sheet", "Close", scroll, state.window)
|
||||||
|
// Adaptive dialog size that fits on 1280x768 screens
|
||||||
|
d.Resize(fyne.NewSize(700, 600))
|
||||||
|
d.Show()
|
||||||
|
}, false)
|
||||||
|
}()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, open folder
|
||||||
|
openFolder(outputDir)
|
||||||
|
})
|
||||||
|
viewResultsBtn.Importance = widget.MediumImportance
|
||||||
|
if state.thumbFile == nil {
|
||||||
|
viewResultsBtn.Disable()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings panel
|
||||||
|
settingsPanel := container.NewVBox(
|
||||||
|
widget.NewLabel("Settings:"),
|
||||||
|
widget.NewSeparator(),
|
||||||
|
contactSheetCheck,
|
||||||
|
settingsOptions,
|
||||||
|
widget.NewSeparator(),
|
||||||
|
generateNowBtn,
|
||||||
|
addQueueBtn,
|
||||||
|
viewQueueBtn,
|
||||||
|
viewResultsBtn,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Main content - split layout with preview on left, settings on right
|
||||||
|
leftColumn := container.NewVBox(
|
||||||
|
videoContainer,
|
||||||
|
)
|
||||||
|
|
||||||
|
rightColumn := container.NewVBox(
|
||||||
|
settingsPanel,
|
||||||
|
)
|
||||||
|
|
||||||
|
mainContent := container.New(&fixedHSplitLayout{ratio: 0.6}, leftColumn, rightColumn)
|
||||||
|
|
||||||
|
content := container.NewBorder(
|
||||||
|
container.NewVBox(instructions, widget.NewSeparator(), fileLabel, container.NewHBox(loadBtn, clearBtn)),
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
mainContent,
|
||||||
|
)
|
||||||
|
|
||||||
|
bottomBar := moduleFooter(thumbColor, layout.NewSpacer(), state.statsBar)
|
||||||
|
|
||||||
|
return container.NewBorder(topBar, bottomBar, nil, nil, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) executeThumbJob(ctx context.Context, job *queue.Job, progressCallback func(float64)) error {
|
||||||
|
cfg := job.Config
|
||||||
|
inputPath := cfg["inputPath"].(string)
|
||||||
|
outputDir := cfg["outputDir"].(string)
|
||||||
|
count := int(cfg["count"].(float64))
|
||||||
|
width := int(cfg["width"].(float64))
|
||||||
|
contactSheet := cfg["contactSheet"].(bool)
|
||||||
|
columns := int(cfg["columns"].(float64))
|
||||||
|
rows := int(cfg["rows"].(float64))
|
||||||
|
|
||||||
|
if progressCallback != nil {
|
||||||
|
progressCallback(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
generator := thumbnail.NewGenerator(platformConfig.FFmpegPath)
|
||||||
|
config := thumbnail.Config{
|
||||||
|
VideoPath: inputPath,
|
||||||
|
OutputDir: outputDir,
|
||||||
|
Count: count,
|
||||||
|
Width: width,
|
||||||
|
Format: "jpg",
|
||||||
|
Quality: 85,
|
||||||
|
ContactSheet: contactSheet,
|
||||||
|
Columns: columns,
|
||||||
|
Rows: rows,
|
||||||
|
ShowTimestamp: false, // Disabled to avoid font issues
|
||||||
|
ShowMetadata: contactSheet,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := generator.Generate(ctx, config)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("thumbnail generation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.Debug(logging.CatSystem, "generated %d thumbnails", len(result.Thumbnails))
|
||||||
|
|
||||||
|
if progressCallback != nil {
|
||||||
|
progressCallback(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
168
upscale_module.go
Normal file
168
upscale_module.go
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AI Helper Functions (smaller, manageable functions)
|
||||||
|
|
||||||
|
// detectAIUpscaleBackend returns the available Real-ESRGAN backend ("ncnn", "python", or "").
|
||||||
|
func detectAIUpscaleBackend() string {
|
||||||
|
if _, err := exec.LookPath("realesrgan-ncnn-vulkan"); err == nil {
|
||||||
|
return "ncnn"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("python3", "-c", "import realesrgan")
|
||||||
|
utils.ApplyNoWindow(cmd)
|
||||||
|
if err := cmd.Run(); err == nil {
|
||||||
|
return "python"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = exec.Command("python", "-c", "import realesrgan")
|
||||||
|
utils.ApplyNoWindow(cmd)
|
||||||
|
if err := cmd.Run(); err == nil {
|
||||||
|
return "python"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAIFaceEnhanceAvailable verifies whether face enhancement tooling is available.
|
||||||
|
func checkAIFaceEnhanceAvailable(backend string) bool {
|
||||||
|
if backend != "python" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cmd := exec.Command("python3", "-c", "import realesrgan, gfpgan")
|
||||||
|
utils.ApplyNoWindow(cmd)
|
||||||
|
if err := cmd.Run(); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
cmd = exec.Command("python", "-c", "import realesrgan, gfpgan")
|
||||||
|
utils.ApplyNoWindow(cmd)
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func aiUpscaleModelOptions() []string {
|
||||||
|
return []string{
|
||||||
|
"General (RealESRGAN_x4plus)",
|
||||||
|
"Anime/Illustration (RealESRGAN_x4plus_anime_6B)",
|
||||||
|
"Anime Video (realesr-animevideov3)",
|
||||||
|
"General Tiny (realesr-general-x4v3)",
|
||||||
|
"2x General (RealESRGAN_x2plus)",
|
||||||
|
"Clean Restore (realesrnet-x4plus)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func aiUpscaleModelID(label string) string {
|
||||||
|
switch label {
|
||||||
|
case "Anime/Illustration (RealESRGAN_x4plus_anime_6B)":
|
||||||
|
return "realesrgan-x4plus-anime"
|
||||||
|
case "Anime Video (realesr-animevideov3)":
|
||||||
|
return "realesr-animevideov3"
|
||||||
|
case "General Tiny (realesr-general-x4v3)":
|
||||||
|
return "realesr-general-x4v3"
|
||||||
|
case "2x General (RealESRGAN_x2plus)":
|
||||||
|
return "realesrgan-x2plus"
|
||||||
|
case "Clean Restore (realesrnet-x4plus)":
|
||||||
|
return "realesrnet-x4plus"
|
||||||
|
default:
|
||||||
|
return "realesrgan-x4plus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func aiUpscaleModelLabel(modelID string) string {
|
||||||
|
switch modelID {
|
||||||
|
case "realesrgan-x4plus-anime":
|
||||||
|
return "Anime/Illustration (RealESRGAN_x4plus_anime_6B)"
|
||||||
|
case "realesr-animevideov3":
|
||||||
|
return "Anime Video (realesr-animevideov3)"
|
||||||
|
case "realesr-general-x4v3":
|
||||||
|
return "General Tiny (realesr-general-x4v3)"
|
||||||
|
case "realesrgan-x2plus":
|
||||||
|
return "2x General (RealESRGAN_x2plus)"
|
||||||
|
case "realesrnet-x4plus":
|
||||||
|
return "Clean Restore (realesrnet-x4plus)"
|
||||||
|
case "realesrgan-x4plus":
|
||||||
|
return "General (RealESRGAN_x4plus)"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseResolutionPreset parses resolution preset strings and returns target dimensions and whether to preserve aspect.
|
||||||
|
// Special presets like "Match Source" and relative (2X/4X) use source dimensions to preserve AR.
|
||||||
|
func parseResolutionPreset(preset string, srcW, srcH int) (width, height int, preserveAspect bool, err error) {
|
||||||
|
// Default: preserve aspect
|
||||||
|
preserveAspect = true
|
||||||
|
|
||||||
|
// Sanitize source
|
||||||
|
if srcW < 1 || srcH < 1 {
|
||||||
|
srcW, srcH = 1920, 1080 // fallback to avoid zero division
|
||||||
|
}
|
||||||
|
|
||||||
|
switch preset {
|
||||||
|
case "", "Match Source":
|
||||||
|
return srcW, srcH, true, nil
|
||||||
|
case "2X (relative)":
|
||||||
|
return srcW * 2, srcH * 2, true, nil
|
||||||
|
case "4X (relative)":
|
||||||
|
return srcW * 4, srcH * 4, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
presetMap := map[string][2]int{
|
||||||
|
"720p (1280x720)": {1280, 720},
|
||||||
|
"1080p (1920x1080)": {1920, 1080},
|
||||||
|
"1440p (2560x1440)": {2560, 1440},
|
||||||
|
"4K (3840x2160)": {3840, 2160},
|
||||||
|
"8K (7680x4320)": {7680, 4320},
|
||||||
|
"720p": {1280, 720},
|
||||||
|
"1080p": {1920, 1080},
|
||||||
|
"1440p": {2560, 1440},
|
||||||
|
"4K": {3840, 2160},
|
||||||
|
"8K": {7680, 4320},
|
||||||
|
}
|
||||||
|
|
||||||
|
if dims, ok := presetMap[preset]; ok {
|
||||||
|
// Keep aspect by default: use target height and let FFmpeg derive width
|
||||||
|
return dims[0], dims[1], true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, 0, true, fmt.Errorf("unknown resolution preset: %s", preset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildUpscaleFilter builds FFmpeg scale filter string with selected method
|
||||||
|
func buildUpscaleFilter(targetWidth, targetHeight int, method string, preserveAspect bool) string {
|
||||||
|
// Ensure even dimensions for encoders
|
||||||
|
makeEven := func(v int) int {
|
||||||
|
if v%2 != 0 {
|
||||||
|
return v + 1
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
h := makeEven(targetHeight)
|
||||||
|
w := targetWidth
|
||||||
|
if preserveAspect || w <= 0 {
|
||||||
|
w = -2 // FFmpeg will derive width from height while preserving AR
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("scale=%d:%d:flags=%s", w, h, method)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeForPath creates a simple slug for filenames from user-visible labels
|
||||||
|
func sanitizeForPath(label string) string {
|
||||||
|
r := strings.NewReplacer(" ", "", "(", "", ")", "", "×", "x", "/", "-", "\\", "-", ":", "-", ",", "", ".", "", "_", "")
|
||||||
|
return strings.ToLower(r.Replace(label))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) showUpscaleView() {
|
||||||
|
s.stopPreview()
|
||||||
|
s.lastModule = s.active
|
||||||
|
s.active = "upscale"
|
||||||
|
s.setContent(buildUpscaleView(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildUpscaleView and executeUpscaleJob will be added here incrementally...
|
||||||
Loading…
Reference in New Issue
Block a user