Compare commits

...

5 Commits

360
main.go
View File

@ -504,6 +504,7 @@ type convertConfig struct {
Mode string // Simple or Advanced
UseAutoNaming bool
AutoNameTemplate string // Template for metadata-driven naming, e.g., "<actress> - <studio> - <scene>"
PreserveChapters bool
// Video encoding settings
VideoCodec string // H.264, H.265, VP9, AV1, Copy
@ -572,6 +573,7 @@ func defaultConvertConfig() convertConfig {
Mode: "Simple",
UseAutoNaming: false,
AutoNameTemplate: "<actress> - <studio> - <scene>",
PreserveChapters: true,
VideoCodec: "H.264",
EncoderPreset: "slow",
@ -1929,6 +1931,7 @@ func (s *appState) addConvertToQueueForSource(src *videoSource) error {
"selectedFormat": cfg.SelectedFormat,
"quality": cfg.Quality,
"mode": cfg.Mode,
"preserveChapters": cfg.PreserveChapters,
"videoCodec": adjustedCodec,
"encoderPreset": cfg.EncoderPreset,
"crf": cfg.CRF,
@ -1965,6 +1968,7 @@ func (s *appState) addConvertToQueueForSource(src *videoSource) error {
"sourceWidth": src.Width,
"sourceHeight": src.Height,
"sourceDuration": src.Duration,
"sourceBitrate": src.Bitrate,
"fieldOrder": src.FieldOrder,
"autoCompare": s.autoCompare, // Include auto-compare flag
}
@ -2672,9 +2676,10 @@ func (s *appState) batchAddToQueue(paths []string) {
"outputPath": outPath,
"outputBase": outputBase,
"selectedFormat": s.convert.SelectedFormat,
"quality": s.convert.Quality,
"mode": s.convert.Mode,
"videoCodec": s.convert.VideoCodec,
"quality": s.convert.Quality,
"mode": s.convert.Mode,
"preserveChapters": s.convert.PreserveChapters,
"videoCodec": s.convert.VideoCodec,
"encoderPreset": s.convert.EncoderPreset,
"crf": s.convert.CRF,
"bitrateMode": s.convert.BitrateMode,
@ -2701,7 +2706,7 @@ func (s *appState) batchAddToQueue(paths []string) {
"sourceWidth": src.Width,
"sourceHeight": src.Height,
"sourceBitrate": src.Bitrate,
"sourceDuration": src.Duration,
"sourceDuration": src.Duration,
"fieldOrder": src.FieldOrder,
}
@ -3689,6 +3694,10 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
}
}
isDVD := selectedFormat.Ext == ".mpg"
remux := strings.EqualFold(selectedFormat.VideoCodec, "copy")
if vc, ok := cfg["videoCodec"].(string); ok && strings.EqualFold(vc, "Copy") {
remux = true
}
// DVD presets: enforce compliant codecs and audio settings
// Note: We do NOT force resolution - user can choose Source or specific resolution
@ -3712,6 +3721,9 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
cfg["pixelFormat"] = "yuv420p"
}
if remux {
args = append(args, "-fflags", "+genpts")
}
args = append(args, "-i", inputPath)
// Add cover art if available
@ -3746,155 +3758,156 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
}
}
// Video filters
var vf []string
// Deinterlacing
shouldDeinterlace := false
deinterlaceMode, _ := cfg["deinterlace"].(string)
fieldOrder, _ := cfg["fieldOrder"].(string)
if deinterlaceMode == "Force" {
shouldDeinterlace = true
} else if deinterlaceMode == "Auto" || deinterlaceMode == "" {
// Auto-detect based on field order
if fieldOrder != "" && fieldOrder != "progressive" && fieldOrder != "unknown" {
shouldDeinterlace = true
}
}
// Legacy support
if inverseTelecine, _ := cfg["inverseTelecine"].(bool); inverseTelecine {
shouldDeinterlace = true
}
if shouldDeinterlace {
// Choose deinterlacing method
deintMethod, _ := cfg["deinterlaceMethod"].(string)
if deintMethod == "" {
deintMethod = "bwdif" // Default to bwdif (higher quality)
}
if deintMethod == "bwdif" {
vf = append(vf, "bwdif=mode=send_frame:parity=auto")
} else {
vf = append(vf, "yadif=0:-1:0")
}
}
// Auto-crop black bars (apply before scaling for best results)
if autoCrop, _ := cfg["autoCrop"].(bool); autoCrop {
cropWidth, _ := cfg["cropWidth"].(string)
cropHeight, _ := cfg["cropHeight"].(string)
cropX, _ := cfg["cropX"].(string)
cropY, _ := cfg["cropY"].(string)
if cropWidth != "" && cropHeight != "" {
cropW := strings.TrimSpace(cropWidth)
cropH := strings.TrimSpace(cropHeight)
cropXStr := strings.TrimSpace(cropX)
cropYStr := strings.TrimSpace(cropY)
// Default to center crop if X/Y not specified
if cropXStr == "" {
cropXStr = "(in_w-out_w)/2"
}
if cropYStr == "" {
cropYStr = "(in_h-out_h)/2"
}
cropFilter := fmt.Sprintf("crop=%s:%s:%s:%s", cropW, cropH, cropXStr, cropYStr)
vf = append(vf, cropFilter)
logging.Debug(logging.CatFFMPEG, "applying crop in queue job: %s", cropFilter)
}
}
// Scaling/Resolution
targetResolution, _ := cfg["targetResolution"].(string)
if targetResolution != "" && targetResolution != "Source" {
var scaleFilter string
switch targetResolution {
case "360p":
scaleFilter = "scale=-2:360"
case "480p":
scaleFilter = "scale=-2:480"
case "540p":
scaleFilter = "scale=-2:540"
case "720p":
scaleFilter = "scale=-2:720"
case "1080p":
scaleFilter = "scale=-2:1080"
case "1440p":
scaleFilter = "scale=-2:1440"
case "4K":
scaleFilter = "scale=-2:2160"
case "8K":
scaleFilter = "scale=-2:4320"
}
if scaleFilter != "" {
vf = append(vf, scaleFilter)
}
}
// Aspect ratio conversion
// Source metrics (used for filters and bitrate defaults)
sourceWidth, _ := cfg["sourceWidth"].(int)
sourceHeight, _ := cfg["sourceHeight"].(int)
// Get source bitrate if present
sourceBitrate := 0
if v, ok := cfg["sourceBitrate"].(float64); ok {
sourceBitrate = int(v)
}
srcAspect := utils.AspectRatioFloat(sourceWidth, sourceHeight)
outputAspect, _ := cfg["outputAspect"].(string)
aspectHandling, _ := cfg["aspectHandling"].(string)
// Create temp source for aspect calculation
tempSrc := &videoSource{Width: sourceWidth, Height: sourceHeight}
targetAspect := resolveTargetAspect(outputAspect, tempSrc)
if targetAspect > 0 && srcAspect > 0 && !utils.RatiosApproxEqual(targetAspect, srcAspect, 0.01) {
vf = append(vf, aspectFilters(targetAspect, aspectHandling)...)
}
// Video filters
var vf []string
if !remux {
// Deinterlacing
shouldDeinterlace := false
deinterlaceMode, _ := cfg["deinterlace"].(string)
fieldOrder, _ := cfg["fieldOrder"].(string)
// 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)
if deinterlaceMode == "Force" {
shouldDeinterlace = true
} else if deinterlaceMode == "Auto" || deinterlaceMode == "" {
// Auto-detect based on field order
if fieldOrder != "" && fieldOrder != "progressive" && fieldOrder != "unknown" {
shouldDeinterlace = true
}
}
}
// Frame rate
frameRate, _ := cfg["frameRate"].(string)
useMotionInterp, _ := cfg["useMotionInterpolation"].(bool)
if frameRate != "" && frameRate != "Source" {
if useMotionInterp {
// Use motion interpolation for smooth frame rate changes
vf = append(vf, fmt.Sprintf("minterpolate=fps=%s:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1", frameRate))
} else {
// Simple frame rate change (duplicates/drops frames)
vf = append(vf, "fps="+frameRate)
// Legacy support
if inverseTelecine, _ := cfg["inverseTelecine"].(bool); inverseTelecine {
shouldDeinterlace = true
}
}
if len(vf) > 0 {
args = append(args, "-vf", strings.Join(vf, ","))
if shouldDeinterlace {
// Choose deinterlacing method
deintMethod, _ := cfg["deinterlaceMethod"].(string)
if deintMethod == "" {
deintMethod = "bwdif" // Default to bwdif (higher quality)
}
if deintMethod == "bwdif" {
vf = append(vf, "bwdif=mode=send_frame:parity=auto")
} else {
vf = append(vf, "yadif=0:-1:0")
}
}
// Auto-crop black bars (apply before scaling for best results)
if autoCrop, _ := cfg["autoCrop"].(bool); autoCrop {
cropWidth, _ := cfg["cropWidth"].(string)
cropHeight, _ := cfg["cropHeight"].(string)
cropX, _ := cfg["cropX"].(string)
cropY, _ := cfg["cropY"].(string)
if cropWidth != "" && cropHeight != "" {
cropW := strings.TrimSpace(cropWidth)
cropH := strings.TrimSpace(cropHeight)
cropXStr := strings.TrimSpace(cropX)
cropYStr := strings.TrimSpace(cropY)
// Default to center crop if X/Y not specified
if cropXStr == "" {
cropXStr = "(in_w-out_w)/2"
}
if cropYStr == "" {
cropYStr = "(in_h-out_h)/2"
}
cropFilter := fmt.Sprintf("crop=%s:%s:%s:%s", cropW, cropH, cropXStr, cropYStr)
vf = append(vf, cropFilter)
logging.Debug(logging.CatFFMPEG, "applying crop in queue job: %s", cropFilter)
}
}
// Scaling/Resolution
targetResolution, _ := cfg["targetResolution"].(string)
if targetResolution != "" && targetResolution != "Source" {
var scaleFilter string
switch targetResolution {
case "360p":
scaleFilter = "scale=-2:360"
case "480p":
scaleFilter = "scale=-2:480"
case "540p":
scaleFilter = "scale=-2:540"
case "720p":
scaleFilter = "scale=-2:720"
case "1080p":
scaleFilter = "scale=-2:1080"
case "1440p":
scaleFilter = "scale=-2:1440"
case "4K":
scaleFilter = "scale=-2:2160"
case "8K":
scaleFilter = "scale=-2:4320"
}
if scaleFilter != "" {
vf = append(vf, scaleFilter)
}
}
// Aspect ratio conversion
srcAspect := utils.AspectRatioFloat(sourceWidth, sourceHeight)
outputAspect, _ := cfg["outputAspect"].(string)
aspectHandling, _ := cfg["aspectHandling"].(string)
// Create temp source for aspect calculation
tempSrc := &videoSource{Width: sourceWidth, Height: sourceHeight}
targetAspect := resolveTargetAspect(outputAspect, tempSrc)
if targetAspect > 0 && srcAspect > 0 && !utils.RatiosApproxEqual(targetAspect, srcAspect, 0.01) {
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
frameRate, _ := cfg["frameRate"].(string)
useMotionInterp, _ := cfg["useMotionInterpolation"].(bool)
if frameRate != "" && frameRate != "Source" {
if useMotionInterp {
// Use motion interpolation for smooth frame rate changes
vf = append(vf, fmt.Sprintf("minterpolate=fps=%s:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1", frameRate))
} else {
// Simple frame rate change (duplicates/drops frames)
vf = append(vf, "fps="+frameRate)
}
}
if len(vf) > 0 {
args = append(args, "-vf", strings.Join(vf, ","))
}
}
// Video codec
@ -4104,7 +4117,16 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
}
// Preserve chapters and metadata
args = append(args, "-map_chapters", "0", "-map_metadata", "0")
preserveChapters := true
if v, ok := cfg["preserveChapters"].(bool); ok {
preserveChapters = v
}
if preserveChapters {
args = append(args, "-map_chapters", "0")
} else {
args = append(args, "-map_chapters", "-1")
}
args = append(args, "-map_metadata", "0")
// Copy subtitle streams by default (don't re-encode)
args = append(args, "-c:s", "copy")
@ -4117,14 +4139,18 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
// DVD-specific parameters are set manually in the video codec section below.
// Fix VFR/desync issues - regenerate timestamps and enforce CFR
args = append(args, "-fflags", "+genpts")
frameRateStr, _ := cfg["frameRate"].(string)
sourceDuration, _ := cfg["sourceDuration"].(float64)
if frameRateStr != "" && frameRateStr != "Source" {
args = append(args, "-r", frameRateStr)
} else if sourceDuration > 0 {
// Calculate approximate source frame rate if available
args = append(args, "-r", "30") // Safe default
if !remux {
args = append(args, "-fflags", "+genpts")
frameRateStr, _ := cfg["frameRate"].(string)
sourceDuration, _ := cfg["sourceDuration"].(float64)
if frameRateStr != "" && frameRateStr != "Source" {
args = append(args, "-r", frameRateStr)
} else if sourceDuration > 0 {
// Calculate approximate source frame rate if available
args = append(args, "-r", "30") // Safe default
}
} else {
args = append(args, "-avoid_negative_ts", "make_zero")
}
// Progress feed
@ -5880,6 +5906,12 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
dvdAspectBox := container.NewVBox(dvdAspectLabel, dvdAspectSelect, dvdInfoLabel)
dvdAspectBox.Hide() // Hidden by default
// Chapter preservation
preserveChaptersCheck := widget.NewCheck("Keep chapters", func(checked bool) {
state.convert.PreserveChapters = checked
})
preserveChaptersCheck.SetChecked(state.convert.PreserveChapters)
// Placeholder for updateDVDOptions - will be defined after resolution/framerate selects are created
var updateDVDOptions func()
@ -7559,6 +7591,31 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
audioCodecSelect.Enable()
}
}
if remux {
state.convert.AspectUserSet = false
if syncAspect != nil {
syncAspect("Source", false)
}
if targetAspectSelectSimple != nil {
targetAspectSelectSimple.Disable()
}
if targetAspectSelect != nil {
targetAspectSelect.Disable()
}
aspectOptions.Disable()
aspectBox.Hide()
} else {
if targetAspectSelectSimple != nil {
targetAspectSelectSimple.Enable()
}
if targetAspectSelect != nil {
targetAspectSelect.Enable()
}
aspectOptions.Enable()
if updateAspectBoxVisibility != nil {
updateAspectBoxVisibility()
}
}
}
simpleEncodingSection = container.NewVBox(
@ -7578,6 +7635,7 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
widget.NewLabelWithStyle("Format", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
formatSelect,
chapterWarningLabel, // Warning when converting chapters to DVD
preserveChaptersCheck,
dvdAspectBox, // DVD options appear here when DVD format selected
widget.NewLabelWithStyle("Output Name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
outputEntry,
@ -7639,6 +7697,7 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
widget.NewLabelWithStyle("Format", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
formatSelect,
chapterWarningLabel, // Warning when converting chapters to DVD
preserveChaptersCheck,
dvdAspectBox, // DVD options appear here when DVD format selected
widget.NewLabelWithStyle("Output Name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
outputEntry,
@ -7709,6 +7768,7 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
autoNameTemplate.SetText(state.convert.AutoNameTemplate)
outputEntry.SetText(state.convert.OutputBase)
outputHint.SetText(fmt.Sprintf("Output file: %s", state.convert.OutputFile()))
preserveChaptersCheck.SetChecked(state.convert.PreserveChapters)
resolutionSelectSimple.SetSelected(state.convert.TargetResolution)
resolutionSelect.SetSelected(state.convert.TargetResolution)
frameRateSelect.SetSelected(state.convert.FrameRate)