From 919599112898d50859caac29c7803a29c76db23c Mon Sep 17 00:00:00 2001 From: Jeremy Schoemaker Date: Thu, 30 Jul 2026 22:11:52 -0500 Subject: [PATCH] fix: honor Set Framerate on overlay input streams The window bar and both margin-fill sources default to 25fps and sit in the *main* slot of their overlays, so ffmpeg clamps the whole render to 25fps no matter what `Set Framerate` says. Measured on a 50fps tape: 25fps/62 frames before, 50fps/123 frames after, with duration unchanged. Half of every captured frame was being discarded silently. MarginFill defaults to the theme background, so this affects every render, not only tapes that set a margin. Fixes #750 --- ffmpeg.go | 20 ++++++++++++++++++-- ffmpeg_test.go | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 ffmpeg_test.go diff --git a/ffmpeg.go b/ffmpeg.go index d35823af..a67a34bc 100644 --- a/ffmpeg.go +++ b/ffmpeg.go @@ -11,6 +11,7 @@ import ( type FilterComplexBuilder struct { filterComplex *strings.Builder style *StyleOptions + framerate int termWidth int termHeight int prevStageName string @@ -50,6 +51,7 @@ func NewVideoFilterBuilder(videoOpts *VideoOptions) *FilterComplexBuilder { termHeight: termHeight, termWidth: termWidth, style: videoOpts.Style, + framerate: videoOpts.Framerate, prevStageName: "padded", } } @@ -104,6 +106,18 @@ func calcTermDimensions(style StyleOptions) (int, int) { return width, height } +// fps pins a secondary stream to the target framerate. The window bar and +// margin fill sources (lavfi color, looped images) all default to 25fps, and +// they are the *main* input of the overlays below, so without this the whole +// render is forced to 25fps regardless of `Set Framerate`. +// Returns an empty string for screenshots, which have no framerate. +func (fb *FilterComplexBuilder) fps() string { + if fb.framerate <= 0 { + return "" + } + return fmt.Sprintf(",fps=%d", fb.framerate) +} + // WithWindowBar adds window bar options to ffmepg filter_complex. func (fb *FilterComplexBuilder) WithWindowBar(barStream int) *FilterComplexBuilder { if fb.style.WindowBar != "" { @@ -111,10 +125,11 @@ func (fb *FilterComplexBuilder) WithWindowBar(barStream int) *FilterComplexBuild _, _ = fmt.Fprintf( fb.filterComplex, ` - [%d]loop=-1[loopbar]; + [%d]loop=-1%s[loopbar]; [loopbar][%s]overlay=0:%d[withbar] `, barStream, + fb.fps(), fb.prevStageName, fb.style.WindowBarSize, ) @@ -154,12 +169,13 @@ func (fb *FilterComplexBuilder) WithMarginFill(marginStream int) *FilterComplexB _, _ = fmt.Fprintf( fb.filterComplex, ` - [%d]scale=%d:%d[bg]; + [%d]scale=%d:%d%s[bg]; [bg][%s]overlay=(W-w)/2:(H-h)/2:shortest=1[withbg] `, marginStream, fb.style.Width, fb.style.Height, + fb.fps(), fb.prevStageName, ) fb.prevStageName = "withbg" diff --git a/ffmpeg_test.go b/ffmpeg_test.go new file mode 100644 index 00000000..5a2acc63 --- /dev/null +++ b/ffmpeg_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "strings" + "testing" +) + +// The margin fill and window bar sources default to 25fps and are the *main* +// input of their overlay, so they must be pinned to the target framerate or +// they silently override `Set Framerate`. +func TestSecondaryStreamsUseFramerate(t *testing.T) { + opts := DefaultVideoOptions() + opts.Framerate = 60 + opts.Style = DefaultStyleOptions() + opts.Style.WindowBar = "Colorful" + opts.Style.MarginFill = "#000000" + + got := strings.Join(NewVideoFilterBuilder(&opts). + WithWindowBar(2). + WithMarginFill(3). + Build(), " ") + + for _, want := range []string{"loop=-1,fps=60[loopbar]", ",fps=60[bg]"} { + if !strings.Contains(got, want) { + t.Errorf("filter_complex missing %q\ngot: %s", want, got) + } + } +} + +// Screenshots have no framerate; an fps filter there would be `fps=0`. +func TestScreenshotOmitsFramerate(t *testing.T) { + style := DefaultStyleOptions() + style.WindowBar = "Colorful" + + got := strings.Join(NewScreenshotFilterComplexBuilder(style). + WithWindowBar(2). + WithMarginFill(3). + Build(), " ") + + if strings.Contains(got, "fps=") { + t.Errorf("screenshot filter should not set fps, got: %s", got) + } +}