From 38c0d3e62f9cfec97c25dda14ab657e5cc8f2110 Mon Sep 17 00:00:00 2001 From: Stu Leak Date: Wed, 7 Jan 2026 15:38:34 -0500 Subject: [PATCH] Enforce display aspect ratio in conversions --- internal/convert/types.go | 11 +++--- internal/utils/utils.go | 13 +++++++ main.go | 75 ++++++++++++++++++++++++++++++--------- 3 files changed, 77 insertions(+), 22 deletions(-) diff --git a/internal/convert/types.go b/internal/convert/types.go index 7f3fdd9..cb6701c 100644 --- a/internal/convert/types.go +++ b/internal/convert/types.go @@ -156,7 +156,7 @@ func FormatClock(sec float64) string { func ResolveTargetAspect(val string, src *VideoSource) float64 { if strings.EqualFold(val, "source") { if src != nil { - return utils.AspectRatioFloat(src.Width, src.Height) + return utils.DisplayAspectRatioFloat(src.Width, src.Height, src.SampleAspectRatio) } return 0 } @@ -245,19 +245,20 @@ func AspectFilters(target float64, mode string) []string { return nil } ar := fmt.Sprintf("%.6f", target) + setDAR := fmt.Sprintf("setdar=%s", ar) // Crop mode: center crop to target aspect ratio if strings.EqualFold(mode, "Crop") || strings.EqualFold(mode, "Auto") { // Crop to target aspect ratio with even dimensions for H.264 encoding // Use trunc/2*2 to ensure even dimensions crop := fmt.Sprintf("crop=w='trunc(if(gt(a,%[1]s),ih*%[1]s,iw)/2)*2':h='trunc(if(gt(a,%[1]s),ih,iw/%[1]s)/2)*2':x='(iw-out_w)/2':y='(ih-out_h)/2'", ar) - return []string{crop, "setsar=1"} + return []string{crop, setDAR, "setsar=1"} } // Stretch mode: just change the aspect ratio without cropping or padding if strings.EqualFold(mode, "Stretch") { scale := fmt.Sprintf("scale=w='trunc(ih*%[1]s/2)*2':h='trunc(iw/%[1]s/2)*2'", ar) - return []string{scale, "setsar=1"} + return []string{scale, setDAR, "setsar=1"} } // Blur Fill: create blurred background then overlay original video @@ -272,10 +273,10 @@ func AspectFilters(target float64, mode string) []string { // Filter: split[bg][fg]; [bg]scale=outW:outH,boxblur=20:5[blurred]; [blurred][fg]overlay=(W-w)/2:(H-h)/2 filterStr := fmt.Sprintf("split[bg][fg];[bg]scale=%s:%s:force_original_aspect_ratio=increase,boxblur=20:5[blurred];[blurred][fg]overlay=(W-w)/2:(H-h)/2", outW, outH) - return []string{filterStr, "setsar=1"} + return []string{filterStr, setDAR, "setsar=1"} } // Letterbox/Pillarbox: keep source resolution, just pad to target aspect with black bars pad := fmt.Sprintf("pad=w='trunc(max(iw,ih*%[1]s)/2)*2':h='trunc(max(ih,iw/%[1]s)/2)*2':x='(ow-iw)/2':y='(oh-ih)/2':color=black", ar) - return []string{pad, "setsar=1"} + return []string{pad, setDAR, "setsar=1"} } diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 6ab7fb9..b7b6457 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -200,6 +200,19 @@ func AspectRatioFloat(w, h int) float64 { return float64(w) / float64(h) } +// DisplayAspectRatioFloat calculates display aspect ratio using SAR when available. +func DisplayAspectRatioFloat(w, h int, sar string) float64 { + base := AspectRatioFloat(w, h) + if base <= 0 { + return 0 + } + sarVal := ParseAspectValue(strings.TrimSpace(sar)) + if sarVal <= 0 { + return base + } + return base * sarVal +} + // ParseAspectValue parses an aspect ratio string like "16:9" func ParseAspectValue(val string) float64 { val = strings.TrimSpace(val) diff --git a/main.go b/main.go index 7d06f55..31f812c 100644 --- a/main.go +++ b/main.go @@ -197,7 +197,7 @@ func (l *fixedHSplitLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { func resolveTargetAspect(val string, src *videoSource) float64 { if strings.EqualFold(val, "source") { if src != nil { - return utils.AspectRatioFloat(src.Width, src.Height) + return utils.DisplayAspectRatioFloat(src.Width, src.Height, src.SampleAspectRatio) } return 0 } @@ -7955,7 +7955,7 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject { return } target := resolveTargetAspect(state.convert.OutputAspect, src) - srcAspect := utils.AspectRatioFloat(src.Width, src.Height) + srcAspect := utils.DisplayAspectRatioFloat(src.Width, src.Height, src.SampleAspectRatio) if target == 0 || srcAspect == 0 || utils.RatiosApproxEqual(target, srcAspect, 0.01) { aspectBox.Hide() } else { @@ -9187,7 +9187,7 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject { // If aspect still unset, derive from source if targetAR == "" || strings.EqualFold(targetAR, "Source") { if src != nil { - if ar := utils.AspectRatioFloat(src.Width, src.Height); ar > 0 && ar < 1.6 { + if ar := utils.DisplayAspectRatioFloat(src.Width, src.Height, src.SampleAspectRatio); ar > 0 && ar < 1.6 { targetAR = "4:3" } else { targetAR = "16:9" @@ -10985,12 +10985,12 @@ func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover fu fullBtn := utils.MakeIconButton("⛶", "Toggle fullscreen", func() { // Placeholder: embed fullscreen toggle into playback surface later. }) - volBox := container.NewHBox(volIcon, container.NewMax(volSlider)) - progress := container.NewBorder(nil, nil, currentTime, totalTime, container.NewMax(slider)) - controls = container.NewVBox( - container.NewHBox(prevFrameBtn, playBtn, nextFrameBtn, fullBtn, coverBtn, saveFrameBtn, importBtn, layout.NewSpacer(), frameLabel, volBox), - progress, - ) + volBox := container.NewHBox(volIcon, container.NewMax(volSlider)) + progress := container.NewBorder(nil, nil, currentTime, totalTime, container.NewMax(slider)) + controls = container.NewVBox( + container.NewHBox(prevFrameBtn, playBtn, nextFrameBtn, fullBtn, coverBtn, saveFrameBtn, importBtn, layout.NewSpacer(), frameLabel, volBox), + progress, + ) } else { slider := widget.NewSlider(0, math.Max(1, float64(len(src.PreviewFrames)-1))) slider.Step = 1 @@ -13048,7 +13048,7 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But // Aspect ratio conversion (only if user explicitly changed from Source) if cfg.OutputAspect != "" && !strings.EqualFold(cfg.OutputAspect, "source") { - srcAspect := utils.AspectRatioFloat(src.Width, src.Height) + srcAspect := utils.DisplayAspectRatioFloat(src.Width, src.Height, src.SampleAspectRatio) targetAspect := resolveTargetAspect(cfg.OutputAspect, src) if targetAspect > 0 && srcAspect > 0 && !utils.RatiosApproxEqual(targetAspect, srcAspect, 0.01) { vf = append(vf, aspectFilters(targetAspect, cfg.AspectHandling)...) @@ -13056,6 +13056,11 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But } } + targetAspect := resolveTargetAspect(cfg.OutputAspect, src) + if targetAspect > 0 && len(vf) > 0 { + vf = appendAspectMetadata(vf, targetAspect) + } + // Flip horizontal if cfg.FlipHorizontal { vf = append(vf, "hflip") @@ -13633,19 +13638,20 @@ func aspectFilters(target float64, mode string) []string { return nil } ar := fmt.Sprintf("%.6f", target) + setDAR := fmt.Sprintf("setdar=%s", ar) // Crop mode: center crop to target aspect ratio if strings.EqualFold(mode, "Crop") || strings.EqualFold(mode, "Auto") { // Crop to target aspect ratio with even dimensions for H.264 encoding // Use trunc/2*2 to ensure even dimensions crop := fmt.Sprintf("crop=w='trunc(if(gt(a,%[1]s),ih*%[1]s,iw)/2)*2':h='trunc(if(gt(a,%[1]s),ih,iw/%[1]s)/2)*2':x='(iw-out_w)/2':y='(ih-out_h)/2'", ar) - return []string{crop, "setsar=1"} + return []string{crop, setDAR, "setsar=1"} } // Stretch mode: just change the aspect ratio without cropping or padding if strings.EqualFold(mode, "Stretch") { scale := fmt.Sprintf("scale=w='trunc(ih*%[1]s/2)*2':h='trunc(iw/%[1]s/2)*2'", ar) - return []string{scale, "setsar=1"} + return []string{scale, setDAR, "setsar=1"} } // Blur Fill: create blurred background then overlay original video @@ -13660,19 +13666,41 @@ func aspectFilters(target float64, mode string) []string { // Filter: split[bg][fg]; [bg]scale=outW:outH,boxblur=20:5[blurred]; [blurred][fg]overlay=(W-w)/2:(H-h)/2 filterStr := fmt.Sprintf("split[bg][fg];[bg]scale=%s:%s:force_original_aspect_ratio=increase,boxblur=20:5[blurred];[blurred][fg]overlay=(W-w)/2:(H-h)/2", outW, outH) - return []string{filterStr, "setsar=1"} + return []string{filterStr, setDAR, "setsar=1"} } // Letterbox/Pillarbox: pad with black bars (auto-detects direction based on aspect ratio change) // Also handles legacy "Letterbox" and "Pillarbox" options for backwards compatibility if strings.EqualFold(mode, "Letterbox/Pillarbox") || strings.EqualFold(mode, "Letterbox") || strings.EqualFold(mode, "Pillarbox") { pad := fmt.Sprintf("pad=w='trunc(max(iw,ih*%[1]s)/2)*2':h='trunc(max(ih,iw/%[1]s)/2)*2':x='(ow-iw)/2':y='(oh-ih)/2':color=black", ar) - return []string{pad, "setsar=1"} + return []string{pad, setDAR, "setsar=1"} } // Default fallback: same as Letterbox/Pillarbox pad := fmt.Sprintf("pad=w='trunc(max(iw,ih*%[1]s)/2)*2':h='trunc(max(ih,iw/%[1]s)/2)*2':x='(ow-iw)/2':y='(oh-ih)/2':color=black", ar) - return []string{pad, "setsar=1"} + return []string{pad, setDAR, "setsar=1"} +} + +func appendAspectMetadata(vf []string, dar float64) []string { + if dar <= 0 { + return vf + } + hasSetDAR := false + hasSetSAR := false + for _, f := range vf { + if strings.HasPrefix(f, "setdar=") { + hasSetDAR = true + } else if strings.HasPrefix(f, "setsar=") { + hasSetSAR = true + } + } + if !hasSetDAR { + vf = append(vf, fmt.Sprintf("setdar=%.6f", dar)) + } + if !hasSetSAR { + vf = append(vf, "setsar=1") + } + return vf } func (s *appState) generateSnippet() { @@ -13735,13 +13763,16 @@ func (s *appState) generateSnippet() { // Check if aspect ratio conversion is needed (only if user explicitly set OutputAspect) aspectExplicit := s.convert.OutputAspect != "" && !strings.EqualFold(s.convert.OutputAspect, "Source") if aspectExplicit { - srcAspect := utils.AspectRatioFloat(src.Width, src.Height) + srcAspect := utils.DisplayAspectRatioFloat(src.Width, src.Height, src.SampleAspectRatio) 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)...) } } + if targetAspect := resolveTargetAspect(s.convert.OutputAspect, src); targetAspect > 0 && len(vf) > 0 { + vf = appendAspectMetadata(vf, targetAspect) + } // Frame rate conversion (only if explicitly set and different from source) if s.convert.FrameRate != "" && s.convert.FrameRate != "Source" { @@ -15130,12 +15161,22 @@ func buildPlayerView(state *appState) fyne.CanvasObject { }) loadBtn.Importance = widget.HighImportance + // Clear video button + clearBtn := widget.NewButton("Clear Video", func() { + state.playerFile = nil + state.showPlayerView() + }) + clearBtn.Importance = widget.MediumImportance + + // Button container + buttonContainer := container.NewHBox(loadBtn, clearBtn) + // Main content mainContent := container.NewVBox( instructions, widget.NewSeparator(), fileLabel, - loadBtn, + buttonContainer, videoContainer, )