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
|
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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user