Add horizontal/vertical flip and rotation transformations to Convert module

Implements video transformation features:
- Horizontal flip (mirror effect) using hflip filter
- Vertical flip (upside down) using vflip filter
- Rotation support: 90°, 180°, 270° clockwise using transpose filters

UI additions in Advanced mode:
- New "VIDEO TRANSFORMATIONS" section
- Two checkboxes for flip controls with descriptive labels
- Dropdown selector for rotation angles
- Hint text explaining transformation purpose

Filter implementation:
- Applied after aspect ratio conversion, before frame rate conversion
- Works in both queue-based and direct conversion paths
- Uses FFmpeg standard filters: hflip, vflip, transpose

Addresses user request to add flip/rotation capabilities inspired by Jake's script using -vf hflip.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Stu Leak 2025-12-06 01:18:38 -05:00
parent 1b0ec5b90e
commit fb9b01de0b

609
main.go
View File

@ -30,6 +30,7 @@ import (
"fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container" "fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/driver/desktop"
"fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/storage" "fyne.io/fyne/v2/storage"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
@ -107,6 +108,7 @@ type formatOption struct {
var formatOptions = []formatOption{ var formatOptions = []formatOption{
{"MP4 (H.264)", ".mp4", "libx264"}, {"MP4 (H.264)", ".mp4", "libx264"},
{"MP4 (H.265)", ".mp4", "libx265"},
{"MKV (H.265)", ".mkv", "libx265"}, {"MKV (H.265)", ".mkv", "libx265"},
{"MOV (ProRes)", ".mov", "prores_ks"}, {"MOV (ProRes)", ".mov", "prores_ks"},
{"DVD-NTSC (MPEG-2)", ".mpg", "mpeg2video"}, {"DVD-NTSC (MPEG-2)", ".mpg", "mpeg2video"},
@ -118,6 +120,8 @@ type convertConfig struct {
SelectedFormat formatOption SelectedFormat formatOption
Quality string // Preset quality (Draft/Standard/High/Lossless) Quality string // Preset quality (Draft/Standard/High/Lossless)
Mode string // Simple or Advanced Mode string // Simple or Advanced
UseAutoNaming bool
AutoNameTemplate string // Template for metadata-driven naming, e.g., "<actress> - <studio> - <scene>"
// Video encoding settings // Video encoding settings
VideoCodec string // H.264, H.265, VP9, AV1, Copy VideoCodec string // H.264, H.265, VP9, AV1, Copy
@ -140,6 +144,9 @@ type convertConfig struct {
CropHeight string // Manual crop height (empty = use auto-detect) CropHeight string // Manual crop height (empty = use auto-detect)
CropX string // Manual crop X offset (empty = use auto-detect) CropX string // Manual crop X offset (empty = use auto-detect)
CropY string // Manual crop Y offset (empty = use auto-detect) CropY string // Manual crop Y offset (empty = use auto-detect)
FlipHorizontal bool // Flip video horizontally (mirror)
FlipVertical bool // Flip video vertically (upside down)
Rotation string // 0, 90, 180, 270 (clockwise rotation in degrees)
// Audio encoding settings // Audio encoding settings
AudioCodec string // AAC, Opus, MP3, FLAC, Copy AudioCodec string // AAC, Opus, MP3, FLAC, Copy
@ -592,6 +599,18 @@ func (s *appState) refreshQueueView() {
s.clearVideo() s.clearVideo()
s.refreshQueueView() // Refresh s.refreshQueueView() // Refresh
}, },
func(id string) { // onCopyError
job, err := s.jobQueue.Get(id)
if err != nil {
logging.Debug(logging.CatSystem, "copy error text failed: %v", err)
return
}
text := strings.TrimSpace(job.Error)
if text == "" {
text = fmt.Sprintf("%s: no error message available", job.Title)
}
s.window.Clipboard().SetContent(text)
},
utils.MustHex("#4CE870"), // titleColor utils.MustHex("#4CE870"), // titleColor
gridColor, // bgColor gridColor, // bgColor
textColor, // textColor textColor, // textColor
@ -623,7 +642,10 @@ func (s *appState) addConvertToQueue() error {
} }
src := s.source src := s.source
outputBase := s.resolveOutputBase(src, true)
s.convert.OutputBase = outputBase
cfg := s.convert cfg := s.convert
cfg.OutputBase = outputBase
outDir := filepath.Dir(src.Path) outDir := filepath.Dir(src.Path)
outName := cfg.OutputFile() outName := cfg.OutputFile()
@ -635,6 +657,19 @@ func (s *appState) addConvertToQueue() error {
outPath = filepath.Join(outDir, "converted-"+outName) outPath = filepath.Join(outDir, "converted-"+outName)
} }
// Align codec choice with the selected format when the preset implies a codec change.
adjustedCodec := s.convert.VideoCodec
if preset := s.convert.SelectedFormat.VideoCodec; preset != "" {
if friendly := friendlyCodecFromPreset(preset); friendly != "" {
if adjustedCodec == "" ||
(strings.EqualFold(adjustedCodec, "H.264") && friendly == "H.265") ||
(strings.EqualFold(adjustedCodec, "H.265") && friendly == "H.264") {
adjustedCodec = friendly
s.convert.VideoCodec = friendly
}
}
}
// Create job config map // Create job config map
config := map[string]interface{}{ config := map[string]interface{}{
"inputPath": src.Path, "inputPath": src.Path,
@ -643,7 +678,7 @@ func (s *appState) addConvertToQueue() error {
"selectedFormat": cfg.SelectedFormat, "selectedFormat": cfg.SelectedFormat,
"quality": cfg.Quality, "quality": cfg.Quality,
"mode": cfg.Mode, "mode": cfg.Mode,
"videoCodec": cfg.VideoCodec, "videoCodec": adjustedCodec,
"encoderPreset": cfg.EncoderPreset, "encoderPreset": cfg.EncoderPreset,
"crf": cfg.CRF, "crf": cfg.CRF,
"bitrateMode": cfg.BitrateMode, "bitrateMode": cfg.BitrateMode,
@ -663,6 +698,9 @@ func (s *appState) addConvertToQueue() error {
"cropHeight": cfg.CropHeight, "cropHeight": cfg.CropHeight,
"cropX": cfg.CropX, "cropX": cfg.CropX,
"cropY": cfg.CropY, "cropY": cfg.CropY,
"flipHorizontal": cfg.FlipHorizontal,
"flipVertical": cfg.FlipVertical,
"rotation": cfg.Rotation,
"audioCodec": cfg.AudioCodec, "audioCodec": cfg.AudioCodec,
"audioBitrate": cfg.AudioBitrate, "audioBitrate": cfg.AudioBitrate,
"audioChannels": cfg.AudioChannels, "audioChannels": cfg.AudioChannels,
@ -902,14 +940,14 @@ func (s *appState) batchAddToQueue(paths []string) {
// Create job config // Create job config
outDir := filepath.Dir(path) outDir := filepath.Dir(path)
baseName := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) outputBase := s.resolveOutputBase(src, false)
outName := baseName + "-converted" + s.convert.SelectedFormat.Ext outName := outputBase + s.convert.SelectedFormat.Ext
outPath := filepath.Join(outDir, outName) outPath := filepath.Join(outDir, outName)
config := map[string]interface{}{ config := map[string]interface{}{
"inputPath": path, "inputPath": path,
"outputPath": outPath, "outputPath": outPath,
"outputBase": baseName + "-converted", "outputBase": outputBase,
"selectedFormat": s.convert.SelectedFormat, "selectedFormat": s.convert.SelectedFormat,
"quality": s.convert.Quality, "quality": s.convert.Quality,
"mode": s.convert.Mode, "mode": s.convert.Mode,
@ -1074,7 +1112,21 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
} }
// Check if this is a DVD format (special handling required) // Check if this is a DVD format (special handling required)
selectedFormat, _ := cfg["selectedFormat"].(formatOption) selectedFormat := formatOptions[0]
switch v := cfg["selectedFormat"].(type) {
case formatOption:
selectedFormat = v
case map[string]interface{}:
if label, ok := v["Label"].(string); ok {
selectedFormat.Label = label
}
if ext, ok := v["Ext"].(string); ok {
selectedFormat.Ext = ext
}
if codec, ok := v["VideoCodec"].(string); ok {
selectedFormat.VideoCodec = codec
}
}
isDVD := selectedFormat.Ext == ".mpg" isDVD := selectedFormat.Ext == ".mpg"
// DVD presets: enforce compliant codecs and audio settings // DVD presets: enforce compliant codecs and audio settings
@ -1231,6 +1283,31 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
vf = append(vf, aspectFilters(targetAspect, aspectHandling)...) vf = append(vf, aspectFilters(targetAspect, aspectHandling)...)
} }
// Flip horizontal
flipH, _ := cfg["flipHorizontal"].(bool)
if flipH {
vf = append(vf, "hflip")
}
// Flip vertical
flipV, _ := cfg["flipVertical"].(bool)
if flipV {
vf = append(vf, "vflip")
}
// Rotation
rotation, _ := cfg["rotation"].(string)
if rotation != "" && rotation != "0" {
switch rotation {
case "90":
vf = append(vf, "transpose=1") // 90 degrees clockwise
case "180":
vf = append(vf, "transpose=1,transpose=1") // 180 degrees
case "270":
vf = append(vf, "transpose=2") // 90 degrees counter-clockwise (= 270 clockwise)
}
}
// Frame rate // Frame rate
frameRate, _ := cfg["frameRate"].(string) frameRate, _ := cfg["frameRate"].(string)
if frameRate != "" && frameRate != "Source" { if frameRate != "" && frameRate != "Source" {
@ -1243,6 +1320,14 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
// Video codec // Video codec
videoCodec, _ := cfg["videoCodec"].(string) videoCodec, _ := cfg["videoCodec"].(string)
if friendly := friendlyCodecFromPreset(selectedFormat.VideoCodec); friendly != "" {
if videoCodec == "" ||
(strings.EqualFold(videoCodec, "H.264") && friendly == "H.265") ||
(strings.EqualFold(videoCodec, "H.265") && friendly == "H.264") {
videoCodec = friendly
cfg["videoCodec"] = friendly
}
}
if videoCodec == "Copy" && !isDVD { if videoCodec == "Copy" && !isDVD {
args = append(args, "-c:v", "copy") args = append(args, "-c:v", "copy")
} else { } else {
@ -1270,8 +1355,9 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
} else { } else {
// Standard bitrate mode and quality for non-DVD // Standard bitrate mode and quality for non-DVD
bitrateMode, _ := cfg["bitrateMode"].(string) bitrateMode, _ := cfg["bitrateMode"].(string)
crfStr := ""
if bitrateMode == "CRF" || bitrateMode == "" { if bitrateMode == "CRF" || bitrateMode == "" {
crfStr, _ := cfg["crf"].(string) crfStr, _ = cfg["crf"].(string)
if crfStr == "" { if crfStr == "" {
quality, _ := cfg["quality"].(string) quality, _ := cfg["quality"].(string)
crfStr = crfForQuality(quality) crfStr = crfForQuality(quality)
@ -1314,19 +1400,40 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
} }
} }
pixelFormat, _ := cfg["pixelFormat"].(string)
h264Profile, _ := cfg["h264Profile"].(string)
// Encoder preset // Encoder preset
if encoderPreset, _ := cfg["encoderPreset"].(string); encoderPreset != "" && (actualCodec == "libx264" || actualCodec == "libx265") { if encoderPreset, _ := cfg["encoderPreset"].(string); encoderPreset != "" && (actualCodec == "libx264" || actualCodec == "libx265") {
args = append(args, "-preset", encoderPreset) args = append(args, "-preset", encoderPreset)
} }
// Enforce true lossless for software HEVC when CRF is 0
if actualCodec == "libx265" && crfStr == "0" {
args = append(args, "-x265-params", "lossless=1")
}
// H.264 lossless requires High 4:4:4 profile and yuv444p pixel format
if actualCodec == "libx264" && crfStr == "0" {
if h264Profile == "" || strings.EqualFold(h264Profile, "auto") ||
strings.EqualFold(h264Profile, "baseline") ||
strings.EqualFold(h264Profile, "main") ||
strings.EqualFold(h264Profile, "high") {
h264Profile = "high444"
}
if pixelFormat == "" || strings.EqualFold(pixelFormat, "yuv420p") {
pixelFormat = "yuv444p"
}
}
// Pixel format // Pixel format
if pixelFormat, _ := cfg["pixelFormat"].(string); pixelFormat != "" { if pixelFormat != "" {
args = append(args, "-pix_fmt", pixelFormat) args = append(args, "-pix_fmt", pixelFormat)
} }
// H.264 profile and level for compatibility // H.264 profile and level for compatibility
if videoCodec == "H.264" && (strings.Contains(actualCodec, "264") || strings.Contains(actualCodec, "h264")) { if videoCodec == "H.264" && (strings.Contains(actualCodec, "264") || strings.Contains(actualCodec, "h264")) {
if h264Profile, _ := cfg["h264Profile"].(string); h264Profile != "" && h264Profile != "Auto" { if h264Profile != "" && h264Profile != "Auto" {
// Use :v:0 if cover art is present to avoid applying to PNG stream // Use :v:0 if cover art is present to avoid applying to PNG stream
if hasCoverArt { if hasCoverArt {
args = append(args, "-profile:v:0", h264Profile) args = append(args, "-profile:v:0", h264Profile)
@ -1398,26 +1505,6 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
args = append(args, "-disposition:v:1", "attached_pic") args = append(args, "-disposition:v:1", "attached_pic")
} }
// Format-specific settings (already parsed above for DVD check)
switch v := cfg["selectedFormat"].(type) {
case formatOption:
selectedFormat = v
case map[string]interface{}:
// Reconstruct from map (happens when loading from JSON)
if label, ok := v["Label"].(string); ok {
selectedFormat.Label = label
}
if ext, ok := v["Ext"].(string); ok {
selectedFormat.Ext = ext
}
if codec, ok := v["VideoCodec"].(string); ok {
selectedFormat.VideoCodec = codec
}
default:
// Fallback to MP4
selectedFormat = formatOptions[0]
}
if strings.EqualFold(selectedFormat.Ext, ".mp4") || strings.EqualFold(selectedFormat.Ext, ".mov") { if strings.EqualFold(selectedFormat.Ext, ".mp4") || strings.EqualFold(selectedFormat.Ext, ".mov") {
args = append(args, "-movflags", "+faststart") args = append(args, "-movflags", "+faststart")
} }
@ -1703,6 +1790,8 @@ func runGUI() {
SelectedFormat: formatOptions[0], SelectedFormat: formatOptions[0],
Quality: "Standard (CRF 23)", Quality: "Standard (CRF 23)",
Mode: "Simple", Mode: "Simple",
UseAutoNaming: false,
AutoNameTemplate: "<actress> - <studio> - <scene>",
// Video encoding defaults // Video encoding defaults
VideoCodec: "H.264", VideoCodec: "H.264",
@ -1996,22 +2085,6 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
// Placeholder for updateDVDOptions - will be defined after resolution/framerate selects are created // Placeholder for updateDVDOptions - will be defined after resolution/framerate selects are created
var updateDVDOptions func() var updateDVDOptions func()
// Create formatSelect with callback that updates DVD options
formatSelect := widget.NewSelect(formatLabels, func(value string) {
for _, opt := range formatOptions {
if opt.Label == value {
logging.Debug(logging.CatUI, "format set to %s", value)
state.convert.SelectedFormat = opt
outputHint.SetText(fmt.Sprintf("Output file: %s", state.convert.OutputFile()))
if updateDVDOptions != nil {
updateDVDOptions() // Show/hide DVD options and auto-set resolution
}
break
}
}
})
formatSelect.SetSelected(state.convert.SelectedFormat.Label)
qualitySelect := widget.NewSelect([]string{"Draft (CRF 28)", "Standard (CRF 23)", "High (CRF 18)", "Lossless"}, func(value string) { qualitySelect := widget.NewSelect([]string{"Draft (CRF 28)", "Standard (CRF 23)", "High (CRF 18)", "Lossless"}, func(value string) {
logging.Debug(logging.CatUI, "quality preset %s", value) logging.Debug(logging.CatUI, "quality preset %s", value)
state.convert.Quality = value state.convert.Quality = value
@ -2020,11 +2093,50 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
outputEntry := widget.NewEntry() outputEntry := widget.NewEntry()
outputEntry.SetText(state.convert.OutputBase) outputEntry.SetText(state.convert.OutputBase)
var updatingOutput bool
outputEntry.OnChanged = func(val string) { outputEntry.OnChanged = func(val string) {
if updatingOutput {
return
}
state.convert.OutputBase = val state.convert.OutputBase = val
outputHint.SetText(fmt.Sprintf("Output file: %s", state.convert.OutputFile())) outputHint.SetText(fmt.Sprintf("Output file: %s", state.convert.OutputFile()))
} }
applyAutoName := func(force bool) {
if !force && !state.convert.UseAutoNaming {
return
}
newBase := state.resolveOutputBase(src, false)
updatingOutput = true
state.convert.OutputBase = newBase
outputEntry.SetText(newBase)
updatingOutput = false
outputHint.SetText(fmt.Sprintf("Output file: %s", state.convert.OutputFile()))
}
autoNameCheck := widget.NewCheck("Auto-name from metadata", func(checked bool) {
state.convert.UseAutoNaming = checked
applyAutoName(true)
})
autoNameCheck.Checked = state.convert.UseAutoNaming
autoNameTemplate := widget.NewEntry()
autoNameTemplate.SetPlaceHolder("<actress> - <studio> - <scene>")
autoNameTemplate.SetText(state.convert.AutoNameTemplate)
autoNameTemplate.OnChanged = func(val string) {
state.convert.AutoNameTemplate = val
if state.convert.UseAutoNaming {
applyAutoName(true)
}
}
autoNameHint := widget.NewLabel("Tokens: <actress>, <studio>, <scene>, <title>, <series>, <date>, <filename>")
autoNameHint.Wrapping = fyne.TextWrapWord
if state.convert.UseAutoNaming {
applyAutoName(true)
}
inverseCheck := widget.NewCheck("Smart Inverse Telecine", func(checked bool) { inverseCheck := widget.NewCheck("Smart Inverse Telecine", func(checked bool) {
state.convert.InverseTelecine = checked state.convert.InverseTelecine = checked
}) })
@ -2094,6 +2206,47 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
autoCropHint := widget.NewLabel("Removes black bars to reduce file size (15-30% typical reduction)") autoCropHint := widget.NewLabel("Removes black bars to reduce file size (15-30% typical reduction)")
autoCropHint.Wrapping = fyne.TextWrapWord autoCropHint.Wrapping = fyne.TextWrapWord
// Flip and Rotation controls
flipHorizontalCheck := widget.NewCheck("Flip Horizontal (Mirror)", func(checked bool) {
state.convert.FlipHorizontal = checked
logging.Debug(logging.CatUI, "flip horizontal set to %v", checked)
})
flipHorizontalCheck.Checked = state.convert.FlipHorizontal
flipVerticalCheck := widget.NewCheck("Flip Vertical (Upside Down)", func(checked bool) {
state.convert.FlipVertical = checked
logging.Debug(logging.CatUI, "flip vertical set to %v", checked)
})
flipVerticalCheck.Checked = state.convert.FlipVertical
rotationSelect := widget.NewSelect([]string{"0°", "90° CW", "180°", "270° CW"}, func(value string) {
var rotation string
switch value {
case "0°":
rotation = "0"
case "90° CW":
rotation = "90"
case "180°":
rotation = "180"
case "270° CW":
rotation = "270"
}
state.convert.Rotation = rotation
logging.Debug(logging.CatUI, "rotation set to %s", rotation)
})
if state.convert.Rotation == "" {
state.convert.Rotation = "0"
}
rotationMap := map[string]string{"0": "0°", "90": "90° CW", "180": "180°", "270": "270° CW"}
if label, ok := rotationMap[state.convert.Rotation]; ok {
rotationSelect.SetSelected(label)
} else {
rotationSelect.SetSelected("0°")
}
transformHint := widget.NewLabel("Apply flips and rotation to correct video orientation")
transformHint.Wrapping = fyne.TextWrapWord
aspectTargets := []string{"Source", "16:9", "4:3", "1:1", "9:16", "21:9"} aspectTargets := []string{"Source", "16:9", "4:3", "1:1", "9:16", "21:9"}
targetAspectSelect := widget.NewSelect(aspectTargets, func(value string) { targetAspectSelect := widget.NewSelect(aspectTargets, func(value string) {
logging.Debug(logging.CatUI, "target aspect set to %s", value) logging.Debug(logging.CatUI, "target aspect set to %s", value)
@ -2146,87 +2299,6 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
state.convert.AspectHandling = value state.convert.AspectHandling = value
} }
// Settings management for batch operations
settingsInfoLabel := widget.NewLabel("Settings persist across videos. Change them anytime to affect all subsequent videos.")
// Don't wrap - let text scroll or truncate if needed
settingsInfoLabel.Alignment = fyne.TextAlignCenter
resetSettingsBtn := widget.NewButton("Reset to Defaults", func() {
// Reset to default settings
state.convert = convertConfig{
SelectedFormat: formatOptions[0],
OutputBase: "converted",
Quality: "Standard (CRF 23)",
InverseTelecine: false,
OutputAspect: "Source",
AspectHandling: "Auto",
VideoCodec: "H.264",
EncoderPreset: "medium",
BitrateMode: "CRF",
CRF: "",
VideoBitrate: "",
TargetResolution: "Source",
FrameRate: "Source",
PixelFormat: "yuv420p",
HardwareAccel: "none",
AudioCodec: "AAC",
AudioBitrate: "192k",
AudioChannels: "Source",
}
logging.Debug(logging.CatUI, "settings reset to defaults")
// Refresh all UI elements to show new settings
formatSelect.SetSelected(state.convert.SelectedFormat.Label)
qualitySelect.SetSelected(state.convert.Quality)
outputEntry.SetText(state.convert.OutputBase)
})
resetSettingsBtn.Importance = widget.LowImportance
// Create collapsible batch settings section
settingsContent := container.NewVBox(
settingsInfoLabel,
resetSettingsBtn,
)
settingsContent.Hide() // Hidden by default
// Use a pointer to track visibility state
settingsVisible := false
var toggleSettingsBtn *widget.Button
toggleSettingsBtn = widget.NewButton("Show Batch Settings", func() {
if settingsVisible {
settingsContent.Hide()
toggleSettingsBtn.SetText("Show Batch Settings")
settingsVisible = false
} else {
settingsContent.Show()
toggleSettingsBtn.SetText("Hide Batch Settings")
settingsVisible = true
}
})
toggleSettingsBtn.Importance = widget.LowImportance
settingsBox := container.NewVBox(
toggleSettingsBtn,
settingsContent,
widget.NewSeparator(),
)
// Simple mode options - minimal controls, aspect locked to Source
simpleOptions := container.NewVBox(
widget.NewLabelWithStyle("═══ OUTPUT ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
widget.NewLabelWithStyle("Format", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
formatSelect,
dvdAspectBox, // DVD options appear here when DVD format selected
widget.NewLabelWithStyle("Output Name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
outputEntry,
outputHint,
widget.NewSeparator(),
widget.NewLabelWithStyle("═══ QUALITY ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
qualitySelect,
widget.NewLabel("Aspect ratio will match source video"),
layout.NewSpacer(),
)
// Cover art display on one line // Cover art display on one line
coverDisplay = widget.NewLabel("Cover Art: " + state.convert.CoverLabel()) coverDisplay = widget.NewLabel("Cover Art: " + state.convert.CoverLabel())
@ -2237,6 +2309,47 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
}) })
videoCodecSelect.SetSelected(state.convert.VideoCodec) videoCodecSelect.SetSelected(state.convert.VideoCodec)
// Map format preset codec names to the UI-facing codec selector value
mapFormatCodec := func(codec string) string {
codec = strings.ToLower(codec)
switch {
case strings.Contains(codec, "265") || strings.Contains(codec, "hevc"):
return "H.265"
case strings.Contains(codec, "264"):
return "H.264"
case strings.Contains(codec, "vp9"):
return "VP9"
case strings.Contains(codec, "av1"):
return "AV1"
case strings.Contains(codec, "mpeg2"):
return "MPEG-2"
default:
return state.convert.VideoCodec
}
}
formatSelect := widget.NewSelect(formatLabels, func(value string) {
for _, opt := range formatOptions {
if opt.Label == value {
logging.Debug(logging.CatUI, "format set to %s", value)
state.convert.SelectedFormat = opt
outputHint.SetText(fmt.Sprintf("Output file: %s", state.convert.OutputFile()))
if updateDVDOptions != nil {
updateDVDOptions() // Show/hide DVD options and auto-set resolution
}
// Keep the codec selector aligned with the chosen format by default
newCodec := mapFormatCodec(opt.VideoCodec)
if newCodec != "" {
state.convert.VideoCodec = newCodec
videoCodecSelect.SetSelected(newCodec)
}
break
}
}
})
formatSelect.SetSelected(state.convert.SelectedFormat.Label)
// Encoder Preset with hint // Encoder Preset with hint
encoderPresetHint := widget.NewLabel("") encoderPresetHint := widget.NewLabel("")
encoderPresetHint.Wrapping = fyne.TextWrapWord encoderPresetHint.Wrapping = fyne.TextWrapWord
@ -2276,6 +2389,78 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
encoderPresetSelect.SetSelected(state.convert.EncoderPreset) encoderPresetSelect.SetSelected(state.convert.EncoderPreset)
updateEncoderPresetHint(state.convert.EncoderPreset) updateEncoderPresetHint(state.convert.EncoderPreset)
// Simple mode preset dropdown
simplePresetSelect := widget.NewSelect([]string{"ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"}, func(value string) {
state.convert.EncoderPreset = value
logging.Debug(logging.CatUI, "simple preset set to %s", value)
updateEncoderPresetHint(value)
})
simplePresetSelect.SetSelected(state.convert.EncoderPreset)
// Settings management for batch operations
settingsInfoLabel := widget.NewLabel("Settings persist across videos. Change them anytime to affect all subsequent videos.")
settingsInfoLabel.Alignment = fyne.TextAlignCenter
resetSettingsBtn := widget.NewButton("Reset to Defaults", func() {
state.convert = convertConfig{
SelectedFormat: formatOptions[0],
OutputBase: "converted",
Quality: "Standard (CRF 23)",
InverseTelecine: false,
OutputAspect: "Source",
AspectHandling: "Auto",
VideoCodec: "H.264",
EncoderPreset: "medium",
BitrateMode: "CRF",
CRF: "",
VideoBitrate: "",
TargetResolution: "Source",
FrameRate: "Source",
PixelFormat: "yuv420p",
HardwareAccel: "none",
AudioCodec: "AAC",
AudioBitrate: "192k",
AudioChannels: "Source",
UseAutoNaming: false,
AutoNameTemplate: "<actress> - <studio> - <scene>",
}
logging.Debug(logging.CatUI, "settings reset to defaults")
formatSelect.SetSelected(state.convert.SelectedFormat.Label)
videoCodecSelect.SetSelected(state.convert.VideoCodec)
qualitySelect.SetSelected(state.convert.Quality)
simplePresetSelect.SetSelected(state.convert.EncoderPreset)
autoNameCheck.SetChecked(state.convert.UseAutoNaming)
autoNameTemplate.SetText(state.convert.AutoNameTemplate)
outputEntry.SetText(state.convert.OutputBase)
})
resetSettingsBtn.Importance = widget.LowImportance
settingsContent := container.NewVBox(
settingsInfoLabel,
resetSettingsBtn,
)
settingsContent.Hide()
settingsVisible := false
var toggleSettingsBtn *widget.Button
toggleSettingsBtn = widget.NewButton("Show Batch Settings", func() {
if settingsVisible {
settingsContent.Hide()
toggleSettingsBtn.SetText("Show Batch Settings")
} else {
settingsContent.Show()
toggleSettingsBtn.SetText("Hide Batch Settings")
}
settingsVisible = !settingsVisible
})
toggleSettingsBtn.Importance = widget.LowImportance
settingsBox := container.NewVBox(
toggleSettingsBtn,
settingsContent,
widget.NewSeparator(),
)
// Bitrate Mode // Bitrate Mode
bitrateModeSelect := widget.NewSelect([]string{"CRF", "CBR", "VBR", "Target Size"}, func(value string) { bitrateModeSelect := widget.NewSelect([]string{"CRF", "CBR", "VBR", "Target Size"}, func(value string) {
state.convert.BitrateMode = value state.convert.BitrateMode = value
@ -2516,6 +2701,30 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
dvdAspectBox.Hide() dvdAspectBox.Hide()
} }
} }
updateDVDOptions()
// Simple mode options - minimal controls, aspect locked to Source
simpleOptions := container.NewVBox(
widget.NewLabelWithStyle("═══ OUTPUT ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
widget.NewLabelWithStyle("Format", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
formatSelect,
dvdAspectBox, // DVD options appear here when DVD format selected
widget.NewLabelWithStyle("Output Name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
outputEntry,
autoNameCheck,
autoNameTemplate,
autoNameHint,
outputHint,
widget.NewSeparator(),
widget.NewLabelWithStyle("═══ QUALITY ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
qualitySelect,
widget.NewLabelWithStyle("Encoder Speed/Quality", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
widget.NewLabel("Choose slower for better compression, faster for speed"),
widget.NewLabelWithStyle("Encoder Preset", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
simplePresetSelect,
widget.NewLabel("Aspect ratio will match source video"),
layout.NewSpacer(),
)
// Advanced mode options - full controls with organized sections // Advanced mode options - full controls with organized sections
advancedOptions := container.NewVBox( advancedOptions := container.NewVBox(
@ -2525,6 +2734,9 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
dvdAspectBox, // DVD options appear here when DVD format selected dvdAspectBox, // DVD options appear here when DVD format selected
widget.NewLabelWithStyle("Output Name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), widget.NewLabelWithStyle("Output Name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
outputEntry, outputEntry,
autoNameCheck,
autoNameTemplate,
autoNameHint,
outputHint, outputHint,
coverDisplay, coverDisplay,
widget.NewSeparator(), widget.NewSeparator(),
@ -2580,6 +2792,14 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
autoCropHint, autoCropHint,
widget.NewSeparator(), widget.NewSeparator(),
widget.NewLabelWithStyle("═══ VIDEO TRANSFORMATIONS ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
flipHorizontalCheck,
flipVerticalCheck,
widget.NewLabelWithStyle("Rotation", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
rotationSelect,
transformHint,
widget.NewSeparator(),
widget.NewLabelWithStyle("═══ DEINTERLACING ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), widget.NewLabelWithStyle("═══ DEINTERLACING ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
inverseCheck, inverseCheck,
inverseHint, inverseHint,
@ -2588,10 +2808,7 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
// Create tabs for Simple/Advanced modes // Create tabs for Simple/Advanced modes
// Wrap simple options with settings box at top // Wrap simple options with settings box at top
simpleWithSettings := container.NewVBox( simpleWithSettings := container.NewVBox(settingsBox, simpleOptions)
settingsBox,
simpleOptions,
)
// Keep Simple lightweight; wrap Advanced in its own scroll to avoid bloating MinSize. // Keep Simple lightweight; wrap Advanced in its own scroll to avoid bloating MinSize.
simpleScrollBox := simpleWithSettings simpleScrollBox := simpleWithSettings
@ -2745,6 +2962,30 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
addQueueBtn.Enable() addQueueBtn.Enable()
} }
// Keyboard shortcut: Ctrl+Enter (Cmd+Enter on macOS maps to Super) -> Convert Now
if c := state.window.Canvas(); c != nil {
triggerNow := func() {
if convertBtn != nil && !convertBtn.Disabled() {
if convertBtn.OnTapped != nil {
convertBtn.OnTapped()
}
}
}
c.AddShortcut(&desktop.CustomShortcut{KeyName: fyne.KeyReturn, Modifier: fyne.KeyModifierControl}, func(fyne.Shortcut) {
triggerNow()
})
c.AddShortcut(&desktop.CustomShortcut{KeyName: fyne.KeyEnter, Modifier: fyne.KeyModifierControl}, func(fyne.Shortcut) {
triggerNow()
})
// macOS Command+Enter is reported as Super+Enter
c.AddShortcut(&desktop.CustomShortcut{KeyName: fyne.KeyReturn, Modifier: fyne.KeyModifierSuper}, func(fyne.Shortcut) {
triggerNow()
})
c.AddShortcut(&desktop.CustomShortcut{KeyName: fyne.KeyEnter, Modifier: fyne.KeyModifierSuper}, func(fyne.Shortcut) {
triggerNow()
})
}
// Auto-compare checkbox // Auto-compare checkbox
autoCompareCheck := widget.NewCheck("Compare After", func(checked bool) { autoCompareCheck := widget.NewCheck("Compare After", func(checked bool) {
state.autoCompare = checked state.autoCompare = checked
@ -3160,6 +3401,40 @@ func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover fu
} }
}) })
saveFrameBtn := utils.MakeIconButton("💾", "Save current frame as PNG", func() {
framePath, err := state.captureCoverFromCurrent()
if err != nil {
dialog.ShowError(err, state.window)
return
}
dlg := dialog.NewFileSave(func(w fyne.URIWriteCloser, err error) {
if err != nil {
dialog.ShowError(err, state.window)
return
}
if w == nil {
return
}
defer w.Close()
data, readErr := os.ReadFile(framePath)
if readErr != nil {
dialog.ShowError(readErr, state.window)
return
}
if _, writeErr := w.Write(data); writeErr != nil {
dialog.ShowError(writeErr, state.window)
return
}
}, state.window)
dlg.SetFilter(storage.NewExtensionFileFilter([]string{".png"}))
if src != nil {
name := strings.TrimSuffix(src.DisplayName, filepath.Ext(src.DisplayName)) + "-frame.png"
dlg.SetFileName(name)
}
dlg.Show()
})
importBtn := utils.MakeIconButton("⬆", "Import cover art file", func() { importBtn := utils.MakeIconButton("⬆", "Import cover art file", func() {
dlg := dialog.NewFileOpen(func(r fyne.URIReadCloser, err error) { dlg := dialog.NewFileOpen(func(r fyne.URIReadCloser, err error) {
if err != nil { if err != nil {
@ -3290,7 +3565,7 @@ func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover fu
volBox := container.NewHBox(volIcon, container.NewMax(volSlider)) volBox := container.NewHBox(volIcon, container.NewMax(volSlider))
progress := container.NewBorder(nil, nil, currentTime, totalTime, container.NewMax(slider)) progress := container.NewBorder(nil, nil, currentTime, totalTime, container.NewMax(slider))
controls = container.NewVBox( controls = container.NewVBox(
container.NewHBox(playBtn, fullBtn, coverBtn, importBtn, layout.NewSpacer(), volBox), container.NewHBox(playBtn, fullBtn, coverBtn, saveFrameBtn, importBtn, layout.NewSpacer(), volBox),
progress, progress,
) )
} else { } else {
@ -3327,7 +3602,7 @@ func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover fu
volSlider.Disable() volSlider.Disable()
progress := container.NewBorder(nil, nil, currentTime, totalTime, container.NewMax(slider)) progress := container.NewBorder(nil, nil, currentTime, totalTime, container.NewMax(slider))
controls = container.NewVBox( controls = container.NewVBox(
container.NewHBox(playBtn, coverBtn, importBtn, layout.NewSpacer(), widget.NewLabel("🔇"), container.NewMax(volSlider)), container.NewHBox(playBtn, coverBtn, saveFrameBtn, importBtn, layout.NewSpacer(), widget.NewLabel("🔇"), container.NewMax(volSlider)),
progress, progress,
) )
if len(src.PreviewFrames) > 1 { if len(src.PreviewFrames) > 1 {
@ -4096,8 +4371,7 @@ func (s *appState) loadVideo(path string) {
s.currentFrame = "" s.currentFrame = ""
} }
s.applyInverseDefaults(src) s.applyInverseDefaults(src)
base := strings.TrimSuffix(src.DisplayName, filepath.Ext(src.DisplayName)) s.convert.OutputBase = s.resolveOutputBase(src, false)
s.convert.OutputBase = base + "-convert"
// Use embedded cover art if present, otherwise clear // Use embedded cover art if present, otherwise clear
if src.EmbeddedCoverArt != "" { if src.EmbeddedCoverArt != "" {
s.convert.CoverArtPath = src.EmbeddedCoverArt s.convert.CoverArtPath = src.EmbeddedCoverArt
@ -4276,8 +4550,7 @@ func (s *appState) switchToVideo(index int) {
} }
s.applyInverseDefaults(src) s.applyInverseDefaults(src)
base := strings.TrimSuffix(src.DisplayName, filepath.Ext(src.DisplayName)) s.convert.OutputBase = s.resolveOutputBase(src, false)
s.convert.OutputBase = base + "-convert"
if src.EmbeddedCoverArt != "" { if src.EmbeddedCoverArt != "" {
s.convert.CoverArtPath = src.EmbeddedCoverArt s.convert.CoverArtPath = src.EmbeddedCoverArt
@ -4437,6 +4710,25 @@ func determineVideoCodec(cfg convertConfig) string {
} }
} }
// friendlyCodecFromPreset maps a preset codec string (e.g., "libx265") to the UI-friendly codec name.
func friendlyCodecFromPreset(preset string) string {
preset = strings.ToLower(preset)
switch {
case strings.Contains(preset, "265") || strings.Contains(preset, "hevc"):
return "H.265"
case strings.Contains(preset, "264"):
return "H.264"
case strings.Contains(preset, "vp9"):
return "VP9"
case strings.Contains(preset, "av1"):
return "AV1"
case strings.Contains(preset, "mpeg2"):
return "MPEG-2"
default:
return ""
}
}
// determineAudioCodec maps user-friendly codec names to FFmpeg codec names // determineAudioCodec maps user-friendly codec names to FFmpeg codec names
func determineAudioCodec(cfg convertConfig) string { func determineAudioCodec(cfg convertConfig) string {
switch cfg.AudioCodec { switch cfg.AudioCodec {
@ -4653,6 +4945,28 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But
} }
} }
// Flip horizontal
if cfg.FlipHorizontal {
vf = append(vf, "hflip")
}
// Flip vertical
if cfg.FlipVertical {
vf = append(vf, "vflip")
}
// Rotation
if cfg.Rotation != "" && cfg.Rotation != "0" {
switch cfg.Rotation {
case "90":
vf = append(vf, "transpose=1") // 90 degrees clockwise
case "180":
vf = append(vf, "transpose=1,transpose=1") // 180 degrees
case "270":
vf = append(vf, "transpose=2") // 90 degrees counter-clockwise (= 270 clockwise)
}
}
// Frame rate // Frame rate
if cfg.FrameRate != "" && cfg.FrameRate != "Source" { if cfg.FrameRate != "" && cfg.FrameRate != "Source" {
vf = append(vf, "fps="+cfg.FrameRate) vf = append(vf, "fps="+cfg.FrameRate)
@ -5397,6 +5711,7 @@ type videoSource struct {
GOPSize int // GOP size / keyframe interval GOPSize int // GOP size / keyframe interval
HasChapters bool // Whether file has embedded chapters HasChapters bool // Whether file has embedded chapters
HasMetadata bool // Whether file has title/copyright/etc metadata HasMetadata bool // Whether file has title/copyright/etc metadata
Metadata map[string]string
} }
func (v *videoSource) DurationString() string { func (v *videoSource) DurationString() string {
@ -5473,6 +5788,7 @@ func probeVideo(path string) (*videoSource, error) {
Duration string `json:"duration"` Duration string `json:"duration"`
FormatName string `json:"format_name"` FormatName string `json:"format_name"`
BitRate string `json:"bit_rate"` BitRate string `json:"bit_rate"`
Tags map[string]interface{} `json:"tags"`
} `json:"format"` } `json:"format"`
Streams []struct { Streams []struct {
Index int `json:"index"` Index int `json:"index"`
@ -5509,6 +5825,13 @@ func probeVideo(path string) (*videoSource, error) {
src.Duration = val src.Duration = val
} }
} }
if len(result.Format.Tags) > 0 {
src.Metadata = normalizeTags(result.Format.Tags)
if len(src.Metadata) > 0 {
src.HasMetadata = true
}
}
// Track if we've found the main video stream (not cover art) // Track if we've found the main video stream (not cover art)
foundMainVideo := false foundMainVideo := false
var coverArtStreamIndex int = -1 var coverArtStreamIndex int = -1
@ -5582,6 +5905,21 @@ func probeVideo(path string) (*videoSource, error) {
return src, nil return src, nil
} }
func normalizeTags(tags map[string]interface{}) map[string]string {
normalized := make(map[string]string, len(tags))
for k, v := range tags {
key := strings.ToLower(strings.TrimSpace(k))
if key == "" {
continue
}
val := strings.TrimSpace(fmt.Sprint(v))
if val != "" {
normalized[key] = val
}
}
return normalized
}
// CropValues represents detected crop parameters // CropValues represents detected crop parameters
type CropValues struct { type CropValues struct {
Width int Width int
@ -6396,6 +6734,7 @@ func buildInspectView(state *appState) fyne.CanvasObject {
return container.NewBorder(topBar, bottomBar, nil, nil, content) return container.NewBorder(topBar, bottomBar, nil, nil, content)
} }
// buildCompareFullscreenView creates fullscreen side-by-side comparison with synchronized controls // buildCompareFullscreenView creates fullscreen side-by-side comparison with synchronized controls
func buildCompareFullscreenView(state *appState) fyne.CanvasObject { func buildCompareFullscreenView(state *appState) fyne.CanvasObject {
compareColor := moduleColor("compare") compareColor := moduleColor("compare")