Compare commits
2 Commits
f5a162b440
...
4e449f8748
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e449f8748 | |||
| b02cd844c4 |
|
|
@ -135,20 +135,17 @@ Comprehensive metadata viewer and editor:
|
|||
|
||||
**Current Status:** Basic metadata viewing implemented, advanced features planned.
|
||||
|
||||
### Rip 🔄 PLANNED
|
||||
### Rip ✅ IMPLEMENTED
|
||||
Extract and convert content from optical media and disc images:
|
||||
- ⏳ Rip directly from DVD/Blu-ray drives to video files
|
||||
- ⏳ Extract from ISO, IMG, and other disc image formats
|
||||
- ⏳ Title and chapter selection
|
||||
- ⏳ Preserve or transcode during extraction
|
||||
- ⏳ Handle copy protection (via libdvdcss/libaacs when available)
|
||||
- ⏳ Subtitle and audio track selection
|
||||
- ⏳ Batch ripping of multiple titles
|
||||
- ⏳ Output to lossless or compressed formats
|
||||
- ✅ Rip from VIDEO_TS folders
|
||||
- ✅ Extract from ISO images (requires `xorriso` or `bsdtar`)
|
||||
- ✅ Default lossless DVD → MKV (stream copy)
|
||||
- ✅ Optional H.264 MKV/MP4 outputs
|
||||
- ✅ Queue-based execution with logs and progress
|
||||
|
||||
**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
|
||||
Professional Blu-ray Disc authoring and encoding system:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# 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
|
||||
|
||||
|
|
@ -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)*
|
||||
- [Audio](audio/) - Audio track operations *(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)
|
||||
- [Subtitle](subtitle/) - Subtitle management *(planned)*
|
||||
|
|
|
|||
|
|
@ -1,297 +1,48 @@
|
|||
# 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
|
||||
|
||||
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
|
||||
- **DVD** - Standard DVDs with VOB structure
|
||||
- **Blu-ray** - BD structure with M2TS files
|
||||
- **CD** - Video CDs (VCD/SVCD)
|
||||
- Direct drive access for ripping
|
||||
### Behavior Notes
|
||||
- Uses a queue job with progress and logs.
|
||||
- No online lookups or network calls.
|
||||
- ISO extraction is performed to a temporary working folder before FFmpeg runs.
|
||||
- Default output naming is based on the source name.
|
||||
|
||||
#### Disc Images
|
||||
- **ISO** - Standard disc image format
|
||||
- **IMG** - Raw disc images
|
||||
- **BIN/CUE** - CD image pairs
|
||||
- Mount and extract without burning
|
||||
## Not Yet Implemented
|
||||
- Direct ripping from physical drives (DVD/Blu-ray)
|
||||
- Multi-title selection from ISO contents
|
||||
- Auto metadata lookup
|
||||
- Subtitle/audio track selection UI
|
||||
|
||||
### Title Selection
|
||||
## Usage
|
||||
|
||||
#### Auto-Detection
|
||||
- Scan disc for all titles
|
||||
- Identify main feature (longest title)
|
||||
- List all extras/bonus content
|
||||
- Show duration and chapter count for each
|
||||
1. Open the Rip module.
|
||||
2. Drag a VIDEO_TS folder or an ISO into the drop area.
|
||||
3. Choose the output mode (lossless MKV or H.264 MKV/MP4).
|
||||
4. Start the rip job and monitor the log/progress.
|
||||
|
||||
#### Manual Selection
|
||||
- Preview titles before ripping
|
||||
- Select multiple titles for batch rip
|
||||
- Choose specific chapters from titles
|
||||
- Merge chapters from different titles
|
||||
## Dependencies
|
||||
|
||||
### Track Management
|
||||
- `ffmpeg`
|
||||
- `xorriso` or `bsdtar` for ISO extraction
|
||||
|
||||
#### Video Tracks
|
||||
- Select video angle (for multi-angle DVDs)
|
||||
- Choose video quality/stream
|
||||
## Example FFmpeg Flow (conceptual)
|
||||
|
||||
#### Audio Tracks
|
||||
- List all audio tracks with language
|
||||
- Select which tracks to include
|
||||
- Reorder track priority
|
||||
- Convert audio format during rip
|
||||
- VIDEO_TS: concatenate VOBs then stream copy to MKV.
|
||||
- ISO: extract VIDEO_TS from the ISO, then follow the same flow.
|
||||
|
||||
#### 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
|
||||
|
|
|
|||
382
main.go
382
main.go
|
|
@ -44,7 +44,6 @@ import (
|
|||
"git.leaktechnologies.dev/stu/VideoTools/internal/player"
|
||||
"git.leaktechnologies.dev/stu/VideoTools/internal/queue"
|
||||
"git.leaktechnologies.dev/stu/VideoTools/internal/sysinfo"
|
||||
"git.leaktechnologies.dev/stu/VideoTools/internal/thumbnail"
|
||||
"git.leaktechnologies.dev/stu/VideoTools/internal/ui"
|
||||
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
|
||||
"github.com/hajimehoshi/oto"
|
||||
|
|
@ -4277,49 +4276,6 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
|
|||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (s *appState) executeSnippetJob(ctx context.Context, job *queue.Job, progressCallback func(float64)) error {
|
||||
cfg := job.Config
|
||||
inputPath := cfg["inputPath"].(string)
|
||||
|
|
@ -9275,7 +9231,6 @@ func (p *playSession) runVideo(offset float64) {
|
|||
if p.fps > 0 {
|
||||
frameDur = time.Duration(float64(time.Second) / math.Max(p.fps, 0.1))
|
||||
}
|
||||
nextFrameAt := time.Now()
|
||||
p.videoCmd = cmd
|
||||
frameSize := p.targetW * p.targetH * 3
|
||||
buf := make([]byte, frameSize)
|
||||
|
|
@ -9290,7 +9245,6 @@ func (p *playSession) runVideo(offset float64) {
|
|||
}
|
||||
if p.paused {
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
nextFrameAt = time.Now().Add(frameDur)
|
||||
continue
|
||||
}
|
||||
_, err := io.ReadFull(stdout, buf)
|
||||
|
|
@ -12729,342 +12683,6 @@ func buildCompareView(state *appState) fyne.CanvasObject {
|
|||
return container.NewBorder(topBar, bottomBar, nil, nil, content)
|
||||
}
|
||||
|
||||
// buildThumbView creates the thumbnail generation UI
|
||||
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()
|
||||
topBar := ui.TintedBar(thumbColor, container.NewHBox(backBtn, layout.NewSpacer(), 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 the 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 the 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)
|
||||
}
|
||||
|
||||
// buildPlayerView creates the VT_Player UI
|
||||
func buildPlayerView(state *appState) fyne.CanvasObject {
|
||||
playerColor := moduleColor("player")
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user