Add comprehensive encoder settings and fix window layout (v0.1.0-dev10)

Advanced Mode Encoder Settings:
- Added full video encoding controls: codec (H.264/H.265/VP9/AV1), encoder preset,
  manual CRF, bitrate modes (CRF/CBR/VBR), target resolution, frame rate,
  pixel format, hardware acceleration (nvenc/vaapi/qsv/videotoolbox), two-pass
- Added audio encoding controls: codec (AAC/Opus/MP3/FLAC), bitrate, channels
- Created organized UI sections in Advanced tab with 13 new control widgets
- Simple mode remains minimal with just Format, Output Name, and Quality preset

Snippet Generation Improvements:
- Optimized snippet generation to use stream copy for fast 2-second processing
- Added WMV detection to force re-encoding (WMV codecs can't stream-copy to MP4)
- Fixed FFmpeg argument order: moved `-t 20` after codec/mapping options
- Added progress dialog for snippets requiring re-encoding (WMV files)
- Snippets now skip deinterlacing for speed (full conversions still apply filters)

Window Layout Fixes:
- Fixed window jumping to second screen when loading videos
- Increased window size from 920x540 to 1120x640 to accommodate content
- Removed hardcoded background minimum size that conflicted with window size
- Wrapped main content in scroll container to prevent content from forcing resize
- Changed left column from VBox to VSplit (65/35 split) for proper vertical expansion
- Reduced panel minimum sizes from 520px to 400px to reduce layout pressure
- UI now fills workspace properly whether video is loaded or not
- Window allows manual resizing while preventing auto-resize from content changes

Technical Changes:
- Extended convertConfig struct with 14 new encoding fields
- Added determineVideoCodec() and determineAudioCodec() helper functions
- Updated buildConversionCommand() to use new encoder settings
- Updated generateSnippet() with WMV handling and optimized stream copy logic
- Modified buildConvertView() to use VSplit for flexible vertical layout

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Stu 2025-11-23 20:17:17 -05:00
parent 183602a302
commit 103d8ded83
2 changed files with 563 additions and 52 deletions

View File

@ -162,3 +162,112 @@ func TintedBar(col color.Color, body fyne.CanvasObject) fyne.CanvasObject {
padded := container.NewPadded(body)
return container.NewMax(rect, padded)
}
// DraggableVScroll creates a vertical scroll container with draggable track
type DraggableVScroll struct {
widget.BaseWidget
content fyne.CanvasObject
scroll *container.Scroll
}
// NewDraggableVScroll creates a new draggable vertical scroll container
func NewDraggableVScroll(content fyne.CanvasObject) *DraggableVScroll {
d := &DraggableVScroll{
content: content,
scroll: container.NewVScroll(content),
}
d.ExtendBaseWidget(d)
return d
}
// CreateRenderer creates the renderer for the draggable scroll
func (d *DraggableVScroll) CreateRenderer() fyne.WidgetRenderer {
return &draggableScrollRenderer{
scroll: d.scroll,
}
}
// Dragged handles drag events on the scrollbar track
func (d *DraggableVScroll) Dragged(ev *fyne.DragEvent) {
// Calculate the scroll position based on drag position
size := d.scroll.Size()
contentSize := d.content.MinSize()
if contentSize.Height <= size.Height {
return // No scrolling needed
}
// Calculate scroll ratio (0.0 to 1.0)
ratio := ev.Position.Y / size.Height
if ratio < 0 {
ratio = 0
}
if ratio > 1 {
ratio = 1
}
// Calculate target offset
maxOffset := contentSize.Height - size.Height
targetOffset := ratio * maxOffset
// Apply scroll offset
d.scroll.Offset = fyne.NewPos(0, targetOffset)
d.scroll.Refresh()
}
// DragEnd handles the end of a drag event
func (d *DraggableVScroll) DragEnd() {
// Nothing needed
}
// Tapped handles tap events on the scrollbar track
func (d *DraggableVScroll) Tapped(ev *fyne.PointEvent) {
// Jump to tapped position
size := d.scroll.Size()
contentSize := d.content.MinSize()
if contentSize.Height <= size.Height {
return
}
ratio := ev.Position.Y / size.Height
if ratio < 0 {
ratio = 0
}
if ratio > 1 {
ratio = 1
}
maxOffset := contentSize.Height - size.Height
targetOffset := ratio * maxOffset
d.scroll.Offset = fyne.NewPos(0, targetOffset)
d.scroll.Refresh()
}
// Scrolled handles scroll events (mouse wheel)
func (d *DraggableVScroll) Scrolled(ev *fyne.ScrollEvent) {
d.scroll.Scrolled(ev)
}
type draggableScrollRenderer struct {
scroll *container.Scroll
}
func (r *draggableScrollRenderer) Layout(size fyne.Size) {
r.scroll.Resize(size)
}
func (r *draggableScrollRenderer) MinSize() fyne.Size {
return r.scroll.MinSize()
}
func (r *draggableScrollRenderer) Refresh() {
r.scroll.Refresh()
}
func (r *draggableScrollRenderer) Destroy() {}
func (r *draggableScrollRenderer) Objects() []fyne.CanvasObject {
return []fyne.CanvasObject{r.scroll}
}

506
main.go
View File

@ -106,8 +106,27 @@ var formatOptions = []formatOption{
type convertConfig struct {
OutputBase string
SelectedFormat formatOption
Quality string
Mode string
Quality string // Preset quality (Draft/Standard/High/Lossless)
Mode string // Simple or Advanced
// Video encoding settings
VideoCodec string // H.264, H.265, VP9, AV1, Copy
EncoderPreset string // ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow
CRF string // Manual CRF value (0-51, or empty to use Quality preset)
BitrateMode string // CRF, CBR, VBR
VideoBitrate string // For CBR/VBR modes (e.g., "5000k")
TargetResolution string // Source, 720p, 1080p, 1440p, 4K, or custom
FrameRate string // Source, 24, 30, 60, or custom
PixelFormat string // yuv420p, yuv422p, yuv444p
HardwareAccel string // none, nvenc, vaapi, qsv, videotoolbox
TwoPass bool // Enable two-pass encoding for VBR
// Audio encoding settings
AudioCodec string // AAC, Opus, MP3, FLAC, Copy
AudioBitrate string // 128k, 192k, 256k, 320k
AudioChannels string // Source, Mono, Stereo, 5.1
// Other settings
InverseTelecine bool
InverseAutoNotes string
CoverArtPath string
@ -259,7 +278,7 @@ func (s *appState) applyInverseDefaults(src *videoSource) {
func (s *appState) setContent(body fyne.CanvasObject) {
bg := canvas.NewRectangle(backgroundColor)
bg.SetMinSize(fyne.NewSize(920, 540))
// Don't set a minimum size - let content determine layout naturally
if body == nil {
s.window.SetContent(bg)
return
@ -403,8 +422,8 @@ func runGUI() {
} else {
logging.Debug(logging.CatUI, "app icon not found; continuing without custom icon")
}
w.Resize(fyne.NewSize(920, 540))
logging.Debug(logging.CatUI, "window initialized (size 920x540)")
w.Resize(fyne.NewSize(1120, 640))
logging.Debug(logging.CatUI, "window initialized at 1120x640")
state := &appState{
window: w,
@ -413,6 +432,25 @@ func runGUI() {
SelectedFormat: formatOptions[0],
Quality: "Standard (CRF 23)",
Mode: "Simple",
// Video encoding defaults
VideoCodec: "H.264",
EncoderPreset: "medium",
CRF: "", // Empty means use Quality preset
BitrateMode: "CRF",
VideoBitrate: "5000k",
TargetResolution: "Source",
FrameRate: "Source",
PixelFormat: "yuv420p",
HardwareAccel: "none",
TwoPass: false,
// Audio encoding defaults
AudioCodec: "AAC",
AudioBitrate: "192k",
AudioChannels: "Source",
// Other defaults
InverseTelecine: true,
InverseAutoNotes: "Default smoothing for interlaced footage.",
OutputAspect: "Source",
@ -585,8 +623,8 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
}
}
videoPanel := buildVideoPane(state, fyne.NewSize(520, 300), src, updateCover)
metaPanel, metaCoverUpdate := buildMetadataPanel(state, src, fyne.NewSize(520, 160))
videoPanel := buildVideoPane(state, fyne.NewSize(400, 250), src, updateCover)
metaPanel, metaCoverUpdate := buildMetadataPanel(state, src, fyne.NewSize(400, 150))
updateMetaCover = metaCoverUpdate
var formatLabels []string
@ -695,6 +733,98 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
// Cover art display on one line
coverDisplay = widget.NewLabel("Cover Art: " + state.convert.CoverLabel())
// Video Codec selection
videoCodecSelect := widget.NewSelect([]string{"H.264", "H.265", "VP9", "AV1", "Copy"}, func(value string) {
state.convert.VideoCodec = value
logging.Debug(logging.CatUI, "video codec set to %s", value)
})
videoCodecSelect.SetSelected(state.convert.VideoCodec)
// Encoder Preset
encoderPresetSelect := widget.NewSelect([]string{"ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"}, func(value string) {
state.convert.EncoderPreset = value
logging.Debug(logging.CatUI, "encoder preset set to %s", value)
})
encoderPresetSelect.SetSelected(state.convert.EncoderPreset)
// Bitrate Mode
bitrateModeSelect := widget.NewSelect([]string{"CRF", "CBR", "VBR"}, func(value string) {
state.convert.BitrateMode = value
logging.Debug(logging.CatUI, "bitrate mode set to %s", value)
})
bitrateModeSelect.SetSelected(state.convert.BitrateMode)
// Manual CRF entry
crfEntry := widget.NewEntry()
crfEntry.SetPlaceHolder("Auto (from Quality preset)")
crfEntry.SetText(state.convert.CRF)
crfEntry.OnChanged = func(val string) {
state.convert.CRF = val
}
// Video Bitrate entry (for CBR/VBR)
videoBitrateEntry := widget.NewEntry()
videoBitrateEntry.SetPlaceHolder("5000k")
videoBitrateEntry.SetText(state.convert.VideoBitrate)
videoBitrateEntry.OnChanged = func(val string) {
state.convert.VideoBitrate = val
}
// Target Resolution
resolutionSelect := widget.NewSelect([]string{"Source", "720p", "1080p", "1440p", "4K"}, func(value string) {
state.convert.TargetResolution = value
logging.Debug(logging.CatUI, "target resolution set to %s", value)
})
resolutionSelect.SetSelected(state.convert.TargetResolution)
// Frame Rate
frameRateSelect := widget.NewSelect([]string{"Source", "24", "30", "60"}, func(value string) {
state.convert.FrameRate = value
logging.Debug(logging.CatUI, "frame rate set to %s", value)
})
frameRateSelect.SetSelected(state.convert.FrameRate)
// Pixel Format
pixelFormatSelect := widget.NewSelect([]string{"yuv420p", "yuv422p", "yuv444p"}, func(value string) {
state.convert.PixelFormat = value
logging.Debug(logging.CatUI, "pixel format set to %s", value)
})
pixelFormatSelect.SetSelected(state.convert.PixelFormat)
// Hardware Acceleration
hwAccelSelect := widget.NewSelect([]string{"none", "nvenc", "vaapi", "qsv", "videotoolbox"}, func(value string) {
state.convert.HardwareAccel = value
logging.Debug(logging.CatUI, "hardware accel set to %s", value)
})
hwAccelSelect.SetSelected(state.convert.HardwareAccel)
// Two-Pass encoding
twoPassCheck := widget.NewCheck("Enable Two-Pass Encoding", func(checked bool) {
state.convert.TwoPass = checked
})
twoPassCheck.Checked = state.convert.TwoPass
// Audio Codec
audioCodecSelect := widget.NewSelect([]string{"AAC", "Opus", "MP3", "FLAC", "Copy"}, func(value string) {
state.convert.AudioCodec = value
logging.Debug(logging.CatUI, "audio codec set to %s", value)
})
audioCodecSelect.SetSelected(state.convert.AudioCodec)
// Audio Bitrate
audioBitrateSelect := widget.NewSelect([]string{"128k", "192k", "256k", "320k"}, func(value string) {
state.convert.AudioBitrate = value
logging.Debug(logging.CatUI, "audio bitrate set to %s", value)
})
audioBitrateSelect.SetSelected(state.convert.AudioBitrate)
// Audio Channels
audioChannelsSelect := widget.NewSelect([]string{"Source", "Mono", "Stereo", "5.1"}, func(value string) {
state.convert.AudioChannels = value
logging.Debug(logging.CatUI, "audio channels set to %s", value)
})
audioChannelsSelect.SetSelected(state.convert.AudioChannels)
// Advanced mode options - full controls with organized sections
advancedOptions := container.NewVBox(
widget.NewLabelWithStyle("═══ OUTPUT ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
@ -705,14 +835,48 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
outputHint,
coverDisplay,
widget.NewSeparator(),
widget.NewLabelWithStyle("═══ VIDEO ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
widget.NewLabelWithStyle("═══ VIDEO ENCODING ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
widget.NewLabelWithStyle("Video Codec", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
videoCodecSelect,
widget.NewLabelWithStyle("Encoder Preset (speed vs quality)", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
encoderPresetSelect,
widget.NewLabelWithStyle("Quality Preset", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
qualitySelect,
widget.NewLabelWithStyle("Aspect Ratio", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
widget.NewLabelWithStyle("Bitrate Mode", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
bitrateModeSelect,
widget.NewLabelWithStyle("Manual CRF (overrides Quality preset)", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
crfEntry,
widget.NewLabelWithStyle("Video Bitrate (for CBR/VBR)", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
videoBitrateEntry,
widget.NewLabelWithStyle("Target Resolution", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
resolutionSelect,
widget.NewLabelWithStyle("Frame Rate", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
frameRateSelect,
widget.NewLabelWithStyle("Pixel Format", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
pixelFormatSelect,
widget.NewLabelWithStyle("Hardware Acceleration", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
hwAccelSelect,
twoPassCheck,
widget.NewSeparator(),
widget.NewLabelWithStyle("═══ ASPECT RATIO ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
widget.NewLabelWithStyle("Target Aspect Ratio", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
targetAspectSelect,
targetAspectHint,
aspectBox,
widget.NewLabelWithStyle("Deinterlacing", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
widget.NewSeparator(),
widget.NewLabelWithStyle("═══ AUDIO ENCODING ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
widget.NewLabelWithStyle("Audio Codec", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
audioCodecSelect,
widget.NewLabelWithStyle("Audio Bitrate", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
audioBitrateSelect,
widget.NewLabelWithStyle("Audio Channels", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
audioChannelsSelect,
widget.NewSeparator(),
widget.NewLabelWithStyle("═══ DEINTERLACING ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
inverseCheck,
inverseHint,
layout.NewSpacer(),
@ -770,10 +934,9 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
}
snippetHint := widget.NewLabel("Creates a 20s clip centred on the timeline midpoint.")
snippetRow := container.NewHBox(snippetBtn, layout.NewSpacer(), snippetHint)
leftColumn := container.NewVBox(
videoPanel,
container.NewMax(metaPanel),
)
// Use VSplit to make panels expand vertically and fill available space
leftColumn := container.NewVSplit(videoPanel, metaPanel)
leftColumn.Offset = 0.65 // Video pane gets 65% of space, metadata gets 35%
grid := container.NewGridWithColumns(2, leftColumn, optionsPanel)
mainArea := container.NewPadded(container.NewVBox(
grid,
@ -827,12 +990,15 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
actionInner := container.NewHBox(resetBtn, activity, statusLabel, layout.NewSpacer(), cancelBtn, convertBtn)
actionBar := ui.TintedBar(convertColor, actionInner)
// Wrap mainArea in a scroll container to prevent content from forcing window resize
scrollableMain := container.NewScroll(mainArea)
return container.NewBorder(
backBar,
container.NewVBox(widget.NewSeparator(), actionBar),
nil,
nil,
mainArea,
scrollableMain,
)
}
@ -1903,6 +2069,56 @@ func crfForQuality(q string) string {
}
}
// determineVideoCodec maps user-friendly codec names to FFmpeg codec names
func determineVideoCodec(cfg convertConfig) string {
switch cfg.VideoCodec {
case "H.264":
if cfg.HardwareAccel == "nvenc" {
return "h264_nvenc"
} else if cfg.HardwareAccel == "qsv" {
return "h264_qsv"
} else if cfg.HardwareAccel == "videotoolbox" {
return "h264_videotoolbox"
}
return "libx264"
case "H.265":
if cfg.HardwareAccel == "nvenc" {
return "hevc_nvenc"
} else if cfg.HardwareAccel == "qsv" {
return "hevc_qsv"
} else if cfg.HardwareAccel == "videotoolbox" {
return "hevc_videotoolbox"
}
return "libx265"
case "VP9":
return "libvpx-vp9"
case "AV1":
return "libaom-av1"
case "Copy":
return "copy"
default:
return "libx264"
}
}
// determineAudioCodec maps user-friendly codec names to FFmpeg codec names
func determineAudioCodec(cfg convertConfig) string {
switch cfg.AudioCodec {
case "AAC":
return "aac"
case "Opus":
return "libopus"
case "MP3":
return "libmp3lame"
case "FLAC":
return "flac"
case "Copy":
return "copy"
default:
return "aac"
}
}
func (s *appState) cancelConvert(cancelBtn, btn *widget.Button, spinner *widget.ProgressBarInfinite, status *widget.Label) {
if s.convertCancel == nil {
return
@ -1957,37 +2173,126 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But
args = append(args, "-i", cfg.CoverArtPath)
}
// Hardware acceleration
if cfg.HardwareAccel != "none" && cfg.HardwareAccel != "" {
switch cfg.HardwareAccel {
case "nvenc":
args = append(args, "-hwaccel", "cuda")
case "vaapi":
args = append(args, "-hwaccel", "vaapi")
case "qsv":
args = append(args, "-hwaccel", "qsv")
case "videotoolbox":
args = append(args, "-hwaccel", "videotoolbox")
}
logging.Debug(logging.CatFFMPEG, "hardware acceleration: %s", cfg.HardwareAccel)
}
// Video filters.
var vf []string
// Deinterlacing
if cfg.InverseTelecine {
vf = append(vf, "yadif")
}
// Scaling/Resolution
if cfg.TargetResolution != "" && cfg.TargetResolution != "Source" {
var scaleFilter string
switch cfg.TargetResolution {
case "720p":
scaleFilter = "scale=-2:720"
case "1080p":
scaleFilter = "scale=-2:1080"
case "1440p":
scaleFilter = "scale=-2:1440"
case "4K":
scaleFilter = "scale=-2:2160"
}
if scaleFilter != "" {
vf = append(vf, scaleFilter)
}
}
// Aspect ratio conversion
srcAspect := utils.AspectRatioFloat(src.Width, src.Height)
targetAspect := resolveTargetAspect(cfg.OutputAspect, src)
if targetAspect > 0 && srcAspect > 0 && !utils.RatiosApproxEqual(targetAspect, srcAspect, 0.01) {
vf = append(vf, aspectFilters(targetAspect, cfg.AspectHandling)...)
}
// Frame rate
if cfg.FrameRate != "" && cfg.FrameRate != "Source" {
vf = append(vf, "fps="+cfg.FrameRate)
}
if len(vf) > 0 {
args = append(args, "-vf", strings.Join(vf, ","))
}
// Video codec and quality.
args = append(args, "-c:v", cfg.SelectedFormat.VideoCodec)
crf := crfForQuality(cfg.Quality)
if cfg.SelectedFormat.VideoCodec == "libx264" || cfg.SelectedFormat.VideoCodec == "libx265" {
args = append(args, "-crf", crf, "-preset", "medium")
// Force yuv420p pixel format for H.264 compatibility (especially for WMV sources)
args = append(args, "-pix_fmt", "yuv420p")
}
// Audio: WMV files need re-encoding to AAC for MP4 compatibility
isWMV := strings.HasSuffix(strings.ToLower(src.Path), ".wmv")
isMP4Output := strings.EqualFold(cfg.SelectedFormat.Ext, ".mp4")
if isWMV && isMP4Output {
// WMV audio (wmav2) cannot be copied to MP4, must re-encode to AAC
args = append(args, "-c:a", "aac", "-b:a", "192k")
logging.Debug(logging.CatFFMPEG, "WMV source detected, re-encoding audio to AAC for MP4 compatibility")
// Video codec
videoCodec := determineVideoCodec(cfg)
if cfg.VideoCodec == "Copy" {
args = append(args, "-c:v", "copy")
} else {
// Copy audio if present
args = append(args, "-c:v", videoCodec)
// Bitrate mode and quality
if cfg.BitrateMode == "CRF" || cfg.BitrateMode == "" {
// Use CRF mode
crf := cfg.CRF
if crf == "" {
crf = crfForQuality(cfg.Quality)
}
if videoCodec == "libx264" || videoCodec == "libx265" || videoCodec == "libvpx-vp9" {
args = append(args, "-crf", crf)
}
} else if cfg.BitrateMode == "CBR" {
// Constant bitrate
if cfg.VideoBitrate != "" {
args = append(args, "-b:v", cfg.VideoBitrate, "-minrate", cfg.VideoBitrate, "-maxrate", cfg.VideoBitrate, "-bufsize", cfg.VideoBitrate)
}
} else if cfg.BitrateMode == "VBR" {
// Variable bitrate (2-pass if enabled)
if cfg.VideoBitrate != "" {
args = append(args, "-b:v", cfg.VideoBitrate)
}
}
// Encoder preset (speed vs quality tradeoff)
if cfg.EncoderPreset != "" && (videoCodec == "libx264" || videoCodec == "libx265") {
args = append(args, "-preset", cfg.EncoderPreset)
}
// Pixel format
if cfg.PixelFormat != "" {
args = append(args, "-pix_fmt", cfg.PixelFormat)
}
}
// Audio codec and settings
if cfg.AudioCodec == "Copy" {
args = append(args, "-c:a", "copy")
} else {
audioCodec := determineAudioCodec(cfg)
args = append(args, "-c:a", audioCodec)
// Audio bitrate
if cfg.AudioBitrate != "" && audioCodec != "flac" {
args = append(args, "-b:a", cfg.AudioBitrate)
}
// Audio channels
if cfg.AudioChannels != "" && cfg.AudioChannels != "Source" {
switch cfg.AudioChannels {
case "Mono":
args = append(args, "-ac", "1")
case "Stereo":
args = append(args, "-ac", "2")
case "5.1":
args = append(args, "-ac", "6")
}
}
}
// Map cover art as attached picture (must be before movflags and progress)
if hasCoverArt {
@ -2289,7 +2594,6 @@ func (s *appState) generateSnippet() {
args := []string{
"-ss", start,
"-i", src.Path,
"-t", "20",
}
// Add cover art if available
@ -2300,19 +2604,50 @@ func (s *appState) generateSnippet() {
logging.Debug(logging.CatFFMPEG, "snippet: added cover art input %s", s.convert.CoverArtPath)
}
// Build video filters (snippets should be fast - only apply essential filters)
var vf []string
// Skip deinterlacing for snippets - they're meant to be fast previews
// Full conversions will still apply deinterlacing
// Resolution scaling for snippets (only if explicitly set)
if s.convert.TargetResolution != "" && s.convert.TargetResolution != "Source" {
var scaleFilter string
switch s.convert.TargetResolution {
case "720p":
scaleFilter = "scale=-2:720"
case "1080p":
scaleFilter = "scale=-2:1080"
case "1440p":
scaleFilter = "scale=-2:1440"
case "4K":
scaleFilter = "scale=-2:2160"
}
if scaleFilter != "" {
vf = append(vf, scaleFilter)
}
}
// Check if aspect ratio conversion is needed
srcAspect := utils.AspectRatioFloat(src.Width, src.Height)
targetAspect := resolveTargetAspect(s.convert.OutputAspect, src)
aspectConversionNeeded := targetAspect > 0 && srcAspect > 0 && !utils.RatiosApproxEqual(targetAspect, srcAspect, 0.01)
if aspectConversionNeeded {
vf = append(vf, aspectFilters(targetAspect, s.convert.AspectHandling)...)
}
needsReencode := targetAspect > 0 && srcAspect > 0 && !utils.RatiosApproxEqual(targetAspect, srcAspect, 0.01)
// Frame rate conversion (only if explicitly set and different from source)
if s.convert.FrameRate != "" && s.convert.FrameRate != "Source" {
vf = append(vf, "fps="+s.convert.FrameRate)
}
if needsReencode {
// Apply aspect ratio filters
filters := aspectFilters(targetAspect, s.convert.AspectHandling)
if len(filters) > 0 {
filterStr := strings.Join(filters, ",")
args = append(args, "-vf", filterStr)
}
// WMV files must be re-encoded for MP4 compatibility (wmv3/wmav2 can't be copied to MP4)
isWMV := strings.HasSuffix(strings.ToLower(src.Path), ".wmv")
needsReencode := len(vf) > 0 || isWMV
if len(vf) > 0 {
filterStr := strings.Join(vf, ",")
args = append(args, "-vf", filterStr)
}
// Map streams (including cover art if present)
@ -2321,13 +2656,38 @@ func (s *appState) generateSnippet() {
logging.Debug(logging.CatFFMPEG, "snippet: mapped video, audio, and cover art")
}
// Set video codec
if needsReencode {
args = append(args, "-c:v", "libx264", "-crf", "23", "-pix_fmt", "yuv420p")
} else if hasCoverArt {
args = append(args, "-c:v:0", "copy")
// Set video codec - snippets should copy when possible for speed
if !needsReencode {
// No filters needed - use stream copy for fast snippets
if hasCoverArt {
args = append(args, "-c:v:0", "copy")
} else {
args = append(args, "-c:v", "copy")
}
} else {
args = append(args, "-c:v", "copy")
// Filters required - must re-encode
// Use configured codec or fallback to H.264 for compatibility
videoCodec := determineVideoCodec(s.convert)
if videoCodec == "copy" {
videoCodec = "libx264"
}
args = append(args, "-c:v", videoCodec)
// Use configured CRF or fallback to quality preset
crf := s.convert.CRF
if crf == "" {
crf = crfForQuality(s.convert.Quality)
}
if videoCodec == "libx264" || videoCodec == "libx265" {
args = append(args, "-crf", crf)
// Use faster preset for snippets
args = append(args, "-preset", "veryfast")
}
// Pixel format
if s.convert.PixelFormat != "" {
args = append(args, "-pix_fmt", s.convert.PixelFormat)
}
}
// Set cover art codec (must be PNG or MJPEG for MP4)
@ -2336,13 +2696,34 @@ func (s *appState) generateSnippet() {
logging.Debug(logging.CatFFMPEG, "snippet: set cover art codec to PNG")
}
// Set audio codec
isWMV := strings.HasSuffix(strings.ToLower(src.Path), ".wmv")
if needsReencode && isWMV {
args = append(args, "-c:a", "aac", "-b:a", "192k")
logging.Debug(logging.CatFFMPEG, "WMV snippet: re-encoding audio to AAC for MP4 compatibility")
} else {
// Set audio codec - snippets should copy when possible for speed
if !needsReencode {
// No video filters - use audio stream copy for fast snippets
args = append(args, "-c:a", "copy")
} else {
// Video is being re-encoded - may need to re-encode audio too
audioCodec := determineAudioCodec(s.convert)
if audioCodec == "copy" {
audioCodec = "aac"
}
args = append(args, "-c:a", audioCodec)
// Audio bitrate
if s.convert.AudioBitrate != "" && audioCodec != "flac" {
args = append(args, "-b:a", s.convert.AudioBitrate)
}
// Audio channels
if s.convert.AudioChannels != "" && s.convert.AudioChannels != "Source" {
switch s.convert.AudioChannels {
case "Mono":
args = append(args, "-ac", "1")
case "Stereo":
args = append(args, "-ac", "2")
case "5.1":
args = append(args, "-ac", "6")
}
}
}
// Mark cover art as attached picture
@ -2351,18 +2732,39 @@ func (s *appState) generateSnippet() {
logging.Debug(logging.CatFFMPEG, "snippet: set cover art disposition")
}
// Limit output duration to 20 seconds (must come after all codec/mapping options)
args = append(args, "-t", "20")
args = append(args, outPath)
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
logging.Debug(logging.CatFFMPEG, "snippet command: %s", strings.Join(cmd.Args, " "))
// Show progress dialog for snippets that need re-encoding (WMV, filters, etc.)
var progressDialog dialog.Dialog
if needsReencode {
progressDialog = dialog.NewCustom("Generating Snippet", "Cancel",
widget.NewLabel("Generating 20-second snippet...\nThis may take 20-30 seconds for WMV files."),
s.window)
progressDialog.Show()
}
// Run the snippet generation
if out, err := cmd.CombinedOutput(); err != nil {
logging.Debug(logging.CatFFMPEG, "snippet stderr: %s", string(out))
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
if progressDialog != nil {
progressDialog.Hide()
}
dialog.ShowError(fmt.Errorf("snippet failed: %w", err), s.window)
}, false)
return
}
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
if progressDialog != nil {
progressDialog.Hide()
}
dialog.ShowInformation("Snippet Created", fmt.Sprintf("Saved %s", outPath), s.window)
}, false)
}