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:
parent
183602a302
commit
103d8ded83
|
|
@ -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
506
main.go
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user