Compare commits

...

4 Commits

Author SHA1 Message Date
fefe3ddd50 Update snippet mode labels to clarify default vs output format
Changes checkbox label from "Use Source Format (stream copy)" to "Snippet to Default Format (preserves source quality)". Unchecked state is now "Snippet to Output Format (uses conversion settings)". This clarifies that default format preserves the source file's quality, bitrate, codec, and container without any conversion artifacts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-16 23:01:52 -05:00
610e75df33 Make snippet conversion mode use actual conversion settings
Updates snippet conversion mode to use configured video/audio codecs, presets, CRF, and bitrates from the Convert tab instead of hardcoded h264/AAC. Output extension now matches selected format (e.g., .mkv, .webm, .mp4). This allows true comparison between source format snippets and conversion preview snippets using user's exact conversion settings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-16 22:58:56 -05:00
e5d1ecfc06 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>
2025-12-16 15:46:38 -05:00
6f82641018 Fix snippet duration by using .mp4 container format
Changes snippet generation to always output .mp4 files instead of preserving source extension. This fixes duration accuracy issues caused by container/codec mismatch (e.g., h264 video in .wmv container). MP4 is the proper container for h264-encoded video and ensures FFmpeg respects the -t duration parameter correctly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-16 15:39:11 -05:00

221
main.go
View File

@ -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,118 @@ 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: Use configured conversion settings
// This allows previewing what the final converted output will look like
conv := s.convert
args = []string{
"-y",
"-hide_banner",
"-loglevel", "error",
"-ss", start,
"-i", inputPath,
"-t", fmt.Sprintf("%d", snippetLength),
}
// Apply video codec settings
videoCodec := strings.ToLower(conv.VideoCodec)
switch videoCodec {
case "h.264", "":
args = append(args, "-c:v", "libx264")
if conv.EncoderPreset != "" {
args = append(args, "-preset", conv.EncoderPreset)
} else {
args = append(args, "-preset", "medium")
}
if conv.CRF != "" {
args = append(args, "-crf", conv.CRF)
} else {
args = append(args, "-crf", "23")
}
case "h.265":
args = append(args, "-c:v", "libx265")
if conv.EncoderPreset != "" {
args = append(args, "-preset", conv.EncoderPreset)
} else {
args = append(args, "-preset", "medium")
}
if conv.CRF != "" {
args = append(args, "-crf", conv.CRF)
} else {
args = append(args, "-crf", "28")
}
case "vp9":
args = append(args, "-c:v", "libvpx-vp9")
if conv.CRF != "" {
args = append(args, "-crf", conv.CRF)
} else {
args = append(args, "-crf", "31")
}
case "av1":
args = append(args, "-c:v", "libsvtav1")
if conv.CRF != "" {
args = append(args, "-crf", conv.CRF)
} else {
args = append(args, "-crf", "35")
}
case "copy":
args = append(args, "-c:v", "copy")
default:
// Fallback to h264
args = append(args, "-c:v", "libx264", "-preset", "medium", "-crf", "23")
}
// Apply audio codec settings
audioCodec := strings.ToLower(conv.AudioCodec)
switch audioCodec {
case "aac", "":
args = append(args, "-c:a", "aac")
if conv.AudioBitrate != "" {
args = append(args, "-b:a", conv.AudioBitrate)
} else {
args = append(args, "-b:a", "192k")
}
case "opus":
args = append(args, "-c:a", "libopus")
if conv.AudioBitrate != "" {
args = append(args, "-b:a", conv.AudioBitrate)
} else {
args = append(args, "-b:a", "128k")
}
case "mp3":
args = append(args, "-c:a", "libmp3lame")
if conv.AudioBitrate != "" {
args = append(args, "-b:a", conv.AudioBitrate)
} else {
args = append(args, "-b:a", "192k")
}
case "flac":
args = append(args, "-c:a", "flac")
case "copy":
args = append(args, "-c:a", "copy")
default:
// Fallback to AAC
args = append(args, "-c:a", "aac", "-b:a", "192k")
}
args = append(args, outputPath)
}
logFile, logPath, _ := createConversionLog(inputPath, outputPath, args)
@ -5181,10 +5286,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 +5305,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 Output:")
snippetModeCheck := widget.NewCheck("Snippet to Default Format (preserves source quality)", func(checked bool) {
state.snippetSourceFormat = checked
})
snippetModeCheck.SetChecked(state.snippetSourceFormat)
snippetModeHint := widget.NewLabel("Unchecked = Snippet to Output Format (uses conversion settings)")
snippetModeHint.TextStyle = fyne.TextStyle{Italic: true}
snippetConfigRow := container.NewVBox(
snippetLengthLabel,
snippetLengthSlider,
widget.NewSeparator(),
snippetModeLabel,
snippetModeCheck,
snippetModeHint,
)
snippetBtn := widget.NewButton("Generate Snippet", func() {
@ -5211,24 +5333,42 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
return
}
src := state.source
// Use same extension as source file since we're using stream copy
ext := filepath.Ext(src.Path)
if ext == "" {
ext = ".mp4"
// 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: use configured output format
ext = state.convert.SelectedFormat.Ext
if ext == "" {
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)
@ -5254,29 +5394,46 @@ 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
}
// Use same extension as source file since we're using stream copy
ext := filepath.Ext(src.Path)
if ext == "" {
ext = ".mp4"
// 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: use configured output format
ext = state.convert.SelectedFormat.Ext
if ext == "" {
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)