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. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6f82641018
commit
e5d1ecfc06
136
main.go
136
main.go
|
|
@ -655,7 +655,8 @@ type appState struct {
|
|||
upscaleFilterChain []string // Transferred filters from Filters module
|
||||
|
||||
// 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
|
||||
interlaceResult *interlace.DetectionResult
|
||||
|
|
@ -3339,6 +3340,12 @@ func (s *appState) executeSnippetJob(ctx context.Context, job *queue.Job, progre
|
|||
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
|
||||
src, err := probeVideo(inputPath)
|
||||
if err != nil {
|
||||
|
|
@ -3354,20 +3361,39 @@ func (s *appState) executeSnippetJob(ctx context.Context, job *queue.Job, progre
|
|||
progressCallback(0)
|
||||
}
|
||||
|
||||
// Re-encode for precise duration control (stream copy can only cut at keyframes)
|
||||
args := []string{
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-ss", start,
|
||||
"-i", inputPath,
|
||||
"-t", fmt.Sprintf("%d", snippetLength),
|
||||
"-c:v", "libx264", // Re-encode video for frame-accurate cutting
|
||||
"-preset", "ultrafast", // Fast encoding for snippets
|
||||
"-crf", "18", // High quality
|
||||
"-c:a", "copy", // Copy audio without re-encoding
|
||||
"-map", "0", // Include all streams
|
||||
outputPath,
|
||||
var args []string
|
||||
|
||||
if useSourceFormat {
|
||||
// Source format mode: Use stream copy for clean extraction
|
||||
// Note: This uses keyframe cutting, so duration may not be frame-perfect
|
||||
args = []string{
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-ss", start,
|
||||
"-i", inputPath,
|
||||
"-t", fmt.Sprintf("%d", snippetLength),
|
||||
"-c", "copy", // Stream copy - no re-encoding
|
||||
"-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)
|
||||
|
|
@ -5181,10 +5207,14 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
|
|||
optionsRect.StrokeWidth = 1
|
||||
optionsPanel := container.NewMax(optionsRect, container.NewPadded(tabs))
|
||||
|
||||
// Initialize snippet length default
|
||||
// Initialize snippet settings defaults
|
||||
if state.snippetLength == 0 {
|
||||
state.snippetLength = 20 // Default to 20 seconds
|
||||
}
|
||||
// Default to source format if not set
|
||||
if !state.snippetSourceFormat {
|
||||
state.snippetSourceFormat = true
|
||||
}
|
||||
|
||||
// Snippet length configuration
|
||||
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))
|
||||
}
|
||||
|
||||
// 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(
|
||||
snippetLengthLabel,
|
||||
snippetLengthSlider,
|
||||
widget.NewSeparator(),
|
||||
snippetModeLabel,
|
||||
snippetModeCheck,
|
||||
snippetModeHint,
|
||||
)
|
||||
|
||||
snippetBtn := widget.NewButton("Generate Snippet", func() {
|
||||
|
|
@ -5211,20 +5254,39 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
|
|||
return
|
||||
}
|
||||
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)
|
||||
|
||||
modeDesc := "conversion settings"
|
||||
if state.snippetSourceFormat {
|
||||
modeDesc = "source format"
|
||||
}
|
||||
|
||||
job := &queue.Job{
|
||||
Type: queue.JobTypeSnippet,
|
||||
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,
|
||||
OutputFile: outPath,
|
||||
Config: map[string]interface{}{
|
||||
"inputPath": src.Path,
|
||||
"outputPath": outPath,
|
||||
"snippetLength": float64(state.snippetLength),
|
||||
"inputPath": src.Path,
|
||||
"outputPath": outPath,
|
||||
"snippetLength": float64(state.snippetLength),
|
||||
"useSourceFormat": state.snippetSourceFormat,
|
||||
},
|
||||
}
|
||||
state.jobQueue.Add(job)
|
||||
|
|
@ -5250,25 +5312,43 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
|
|||
timestamp := time.Now().Unix()
|
||||
jobsAdded := 0
|
||||
|
||||
modeDesc := "conversion settings"
|
||||
if state.snippetSourceFormat {
|
||||
modeDesc = "source format"
|
||||
}
|
||||
|
||||
for _, src := range state.loadedVideos {
|
||||
if src == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 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)), timestamp)
|
||||
// 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)), timestamp, ext)
|
||||
outPath := filepath.Join(filepath.Dir(src.Path), outName)
|
||||
|
||||
job := &queue.Job{
|
||||
Type: queue.JobTypeSnippet,
|
||||
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,
|
||||
OutputFile: outPath,
|
||||
Config: map[string]interface{}{
|
||||
"inputPath": src.Path,
|
||||
"outputPath": outPath,
|
||||
"snippetLength": float64(state.snippetLength),
|
||||
"inputPath": src.Path,
|
||||
"outputPath": outPath,
|
||||
"snippetLength": float64(state.snippetLength),
|
||||
"useSourceFormat": state.snippetSourceFormat,
|
||||
},
|
||||
}
|
||||
state.jobQueue.Add(job)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user