Add snippet output mode: source format vs conversion format

Implements configurable snippet output mode with two options:
1. Source Format (default): Uses stream copy to preserve exact video/audio quality with source container format. Duration uses keyframe-level precision (may not be frame-perfect).
2. Conversion Format: Re-encodes to h264/AAC MP4 with frame-perfect duration control.

Adds checkbox control in snippet settings UI. Users can now compare source format snippets for merge testing and conversion format snippets for output preview.
This commit is contained in:
Stu Leak 2025-12-16 15:46:38 -05:00
parent f76b338ee5
commit e338ad2d0b

136
main.go
View File

@ -655,7 +655,8 @@ type appState struct {
upscaleFilterChain []string // Transferred filters from Filters module upscaleFilterChain []string // Transferred filters from Filters module
// Snippet settings // Snippet settings
snippetLength int // Length of snippet in seconds (default: 20) snippetLength int // Length of snippet in seconds (default: 20)
snippetSourceFormat bool // true = source format, false = conversion format (default: true)
// Interlacing detection state // Interlacing detection state
interlaceResult *interlace.DetectionResult interlaceResult *interlace.DetectionResult
@ -3339,6 +3340,12 @@ func (s *appState) executeSnippetJob(ctx context.Context, job *queue.Job, progre
snippetLength = int(lengthVal) snippetLength = int(lengthVal)
} }
// Get snippet mode, default to source format (true)
useSourceFormat := true
if modeVal, ok := cfg["useSourceFormat"].(bool); ok {
useSourceFormat = modeVal
}
// Probe video to get duration // Probe video to get duration
src, err := probeVideo(inputPath) src, err := probeVideo(inputPath)
if err != nil { if err != nil {
@ -3354,20 +3361,39 @@ func (s *appState) executeSnippetJob(ctx context.Context, job *queue.Job, progre
progressCallback(0) progressCallback(0)
} }
// Re-encode for precise duration control (stream copy can only cut at keyframes) var args []string
args := []string{
"-y", if useSourceFormat {
"-hide_banner", // Source format mode: Use stream copy for clean extraction
"-loglevel", "error", // Note: This uses keyframe cutting, so duration may not be frame-perfect
"-ss", start, args = []string{
"-i", inputPath, "-y",
"-t", fmt.Sprintf("%d", snippetLength), "-hide_banner",
"-c:v", "libx264", // Re-encode video for frame-accurate cutting "-loglevel", "error",
"-preset", "ultrafast", // Fast encoding for snippets "-ss", start,
"-crf", "18", // High quality "-i", inputPath,
"-c:a", "copy", // Copy audio without re-encoding "-t", fmt.Sprintf("%d", snippetLength),
"-map", "0", // Include all streams "-c", "copy", // Stream copy - no re-encoding
outputPath, "-map", "0", // Include all streams
outputPath,
}
} else {
// Conversion format mode: Re-encode for precise duration and format conversion
args = []string{
"-y",
"-hide_banner",
"-loglevel", "error",
"-ss", start,
"-i", inputPath,
"-t", fmt.Sprintf("%d", snippetLength),
"-c:v", "libx264", // Re-encode video to h264
"-preset", "ultrafast", // Fast encoding
"-crf", "18", // High quality
"-c:a", "aac", // Re-encode audio to AAC
"-b:a", "192k", // Audio bitrate
"-map", "0", // Include all streams
outputPath,
}
} }
logFile, logPath, _ := createConversionLog(inputPath, outputPath, args) logFile, logPath, _ := createConversionLog(inputPath, outputPath, args)
@ -5181,10 +5207,14 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
optionsRect.StrokeWidth = 1 optionsRect.StrokeWidth = 1
optionsPanel := container.NewMax(optionsRect, container.NewPadded(tabs)) optionsPanel := container.NewMax(optionsRect, container.NewPadded(tabs))
// Initialize snippet length default // Initialize snippet settings defaults
if state.snippetLength == 0 { if state.snippetLength == 0 {
state.snippetLength = 20 // Default to 20 seconds state.snippetLength = 20 // Default to 20 seconds
} }
// Default to source format if not set
if !state.snippetSourceFormat {
state.snippetSourceFormat = true
}
// Snippet length configuration // Snippet length configuration
snippetLengthLabel := widget.NewLabel(fmt.Sprintf("Snippet Length: %d seconds", state.snippetLength)) snippetLengthLabel := widget.NewLabel(fmt.Sprintf("Snippet Length: %d seconds", state.snippetLength))
@ -5196,9 +5226,22 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
snippetLengthLabel.SetText(fmt.Sprintf("Snippet Length: %d seconds", state.snippetLength)) snippetLengthLabel.SetText(fmt.Sprintf("Snippet Length: %d seconds", state.snippetLength))
} }
// Snippet output mode
snippetModeLabel := widget.NewLabel("Snippet Mode:")
snippetModeCheck := widget.NewCheck("Use Source Format (stream copy)", func(checked bool) {
state.snippetSourceFormat = checked
})
snippetModeCheck.SetChecked(state.snippetSourceFormat)
snippetModeHint := widget.NewLabel("Unchecked = Conversion format (re-encode)")
snippetModeHint.TextStyle = fyne.TextStyle{Italic: true}
snippetConfigRow := container.NewVBox( snippetConfigRow := container.NewVBox(
snippetLengthLabel, snippetLengthLabel,
snippetLengthSlider, snippetLengthSlider,
widget.NewSeparator(),
snippetModeLabel,
snippetModeCheck,
snippetModeHint,
) )
snippetBtn := widget.NewButton("Generate Snippet", func() { snippetBtn := widget.NewButton("Generate Snippet", func() {
@ -5211,20 +5254,39 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
return return
} }
src := state.source src := state.source
// Always use .mp4 for snippets since we're re-encoding to h264
outName := fmt.Sprintf("%s-snippet-%d.mp4", strings.TrimSuffix(src.DisplayName, filepath.Ext(src.DisplayName)), time.Now().Unix()) // Determine output extension based on mode
var ext string
if state.snippetSourceFormat {
// Source format: use source extension
ext = filepath.Ext(src.Path)
if ext == "" {
ext = ".mp4"
}
} else {
// Conversion format: always use .mp4
ext = ".mp4"
}
outName := fmt.Sprintf("%s-snippet-%d%s", strings.TrimSuffix(src.DisplayName, filepath.Ext(src.DisplayName)), time.Now().Unix(), ext)
outPath := filepath.Join(filepath.Dir(src.Path), outName) outPath := filepath.Join(filepath.Dir(src.Path), outName)
modeDesc := "conversion settings"
if state.snippetSourceFormat {
modeDesc = "source format"
}
job := &queue.Job{ job := &queue.Job{
Type: queue.JobTypeSnippet, Type: queue.JobTypeSnippet,
Title: "Snippet: " + filepath.Base(src.Path), Title: "Snippet: " + filepath.Base(src.Path),
Description: fmt.Sprintf("%ds snippet centred on midpoint (source settings)", state.snippetLength), Description: fmt.Sprintf("%ds snippet centred on midpoint (%s)", state.snippetLength, modeDesc),
InputFile: src.Path, InputFile: src.Path,
OutputFile: outPath, OutputFile: outPath,
Config: map[string]interface{}{ Config: map[string]interface{}{
"inputPath": src.Path, "inputPath": src.Path,
"outputPath": outPath, "outputPath": outPath,
"snippetLength": float64(state.snippetLength), "snippetLength": float64(state.snippetLength),
"useSourceFormat": state.snippetSourceFormat,
}, },
} }
state.jobQueue.Add(job) state.jobQueue.Add(job)
@ -5250,25 +5312,43 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
timestamp := time.Now().Unix() timestamp := time.Now().Unix()
jobsAdded := 0 jobsAdded := 0
modeDesc := "conversion settings"
if state.snippetSourceFormat {
modeDesc = "source format"
}
for _, src := range state.loadedVideos { for _, src := range state.loadedVideos {
if src == nil { if src == nil {
continue continue
} }
// Always use .mp4 for snippets since we're re-encoding to h264 // Determine output extension based on mode
outName := fmt.Sprintf("%s-snippet-%d.mp4", strings.TrimSuffix(src.DisplayName, filepath.Ext(src.DisplayName)), timestamp) var ext string
if state.snippetSourceFormat {
// Source format: use source extension
ext = filepath.Ext(src.Path)
if ext == "" {
ext = ".mp4"
}
} else {
// Conversion format: always use .mp4
ext = ".mp4"
}
outName := fmt.Sprintf("%s-snippet-%d%s", strings.TrimSuffix(src.DisplayName, filepath.Ext(src.DisplayName)), timestamp, ext)
outPath := filepath.Join(filepath.Dir(src.Path), outName) outPath := filepath.Join(filepath.Dir(src.Path), outName)
job := &queue.Job{ job := &queue.Job{
Type: queue.JobTypeSnippet, Type: queue.JobTypeSnippet,
Title: "Snippet: " + filepath.Base(src.Path), Title: "Snippet: " + filepath.Base(src.Path),
Description: fmt.Sprintf("%ds snippet centred on midpoint (source settings)", state.snippetLength), Description: fmt.Sprintf("%ds snippet centred on midpoint (%s)", state.snippetLength, modeDesc),
InputFile: src.Path, InputFile: src.Path,
OutputFile: outPath, OutputFile: outPath,
Config: map[string]interface{}{ Config: map[string]interface{}{
"inputPath": src.Path, "inputPath": src.Path,
"outputPath": outPath, "outputPath": outPath,
"snippetLength": float64(state.snippetLength), "snippetLength": float64(state.snippetLength),
"useSourceFormat": state.snippetSourceFormat,
}, },
} }
state.jobQueue.Add(job) state.jobQueue.Add(job)