Commit 361174e5ea for ffmpeg
commit 361174e5ea6bd65e34554afa1d098dda2a9bcbba
Author: Skanda Vyas <skandavyas20@gmail.com>
Date: Wed Sep 16 19:15:04 2026 -0700
avfilter/vf_fps: preserve first output frame at EOF
When the input duration rounds to zero in the output timebase, the
rounded EOF timestamp can equal next_pts before any frame has been
output. In that case, do not discard a buffered frame whose timestamp
matches next_pts.
Keep the selected EOF timestamp rounding, but allow this first-frame
exception to extend output by one output frame period. Preserve start_time
trimming by checking the last input frame and EOF before rounding. A frame
located at start_time remains eligible even when its duration is zero.
Document the first-frame exception and add FATE coverage for a short
single-frame input and trimming at start_time, including a frame spanning
the start and a zero-duration frame at the start.
Fixes ticket #7361.
Signed-off-by: Skanda Vyas <skandavyas20@gmail.com>
Assisted-by: Fairy
diff --git a/doc/filters.texi b/doc/filters.texi
index 81e9e280ed..406ead7198 100644
--- a/doc/filters.texi
+++ b/doc/filters.texi
@@ -14818,6 +14818,12 @@ Pass through last frame if input duration has not been reached yet.
@end table
The default is @code{round}.
+If timestamp rounding would otherwise discard every frame, one frame is
+retained when its rounded timestamp equals both the first output timestamp
+and the rounded end of input. This exception extends the output beyond the
+rounded end of input by one output frame period. Frames whose entire interval
+is trimmed by @option{start_time} remain discarded.
+
@end table
Alternatively, the options can be specified as a flat string:
diff --git a/libavfilter/vf_fps.c b/libavfilter/vf_fps.c
index 0257a8ef78..e81d7b1668 100644
--- a/libavfilter/vf_fps.c
+++ b/libavfilter/vf_fps.c
@@ -83,6 +83,8 @@ typedef struct FPSContext {
/* Runtime state */
int status; ///< buffered input status
int64_t status_pts; ///< buffered input status timestamp
+ int64_t last_in_pts; ///< last frame timestamp in input timebase
+ int last_frame_trimmed; ///< last frame ends at or before start_time
AVFrame *frames[2]; ///< buffered frames
int frames_count; ///< number of buffered frames
@@ -245,7 +247,7 @@ static int read_frame(AVFilterContext *ctx, FPSContext *s, AVFilterLink *inlink,
/* Convert frame pts to output timebase.
* The dance with offsets is required to match the rounding behaviour of the
* previous version of the fps filter when using the start_time option. */
- in_pts = frame->pts;
+ in_pts = s->last_in_pts = frame->pts;
frame->pts = s->out_pts_off + av_rescale_q_rnd(in_pts - s->in_pts_off,
inlink->time_base, outlink->time_base,
s->rounding | AV_ROUND_PASS_MINMAX);
@@ -264,6 +266,8 @@ static int read_frame(AVFilterContext *ctx, FPSContext *s, AVFilterLink *inlink,
static int write_frame(AVFilterContext *ctx, FPSContext *s, AVFilterLink *outlink, int *again)
{
AVFrame *frame;
+ int eof_before_or_at_next;
+ int first_output_due;
av_assert1(s->frames_count == 2 || (s->status && s->frames_count == 1));
@@ -282,13 +286,20 @@ static int write_frame(AVFilterContext *ctx, FPSContext *s, AVFilterLink *outlin
}
}
+ eof_before_or_at_next = s->status && s->status_pts <= s->next_pts;
+ first_output_due = !s->frames_out && !s->cur_frame_out &&
+ !s->last_frame_trimmed &&
+ s->frames[0]->pts == s->next_pts &&
+ s->status_pts == s->next_pts;
+
/* There are two conditions where we want to drop a frame:
* - If we have two buffered frames and the second frame is acceptable
* as the next output frame, then drop the first buffered frame.
* - If we have status (EOF) set, drop frames when we hit the
- * status timestamp. */
+ * status timestamp, unless no frame has been output yet and the
+ * buffered frame is due at the next output timestamp. */
if ((s->frames_count == 2 && s->frames[1]->pts <= s->next_pts) ||
- (s->status && s->status_pts <= s->next_pts)) {
+ (eof_before_or_at_next && !first_output_due)) {
frame = shift_frame(ctx, s);
av_frame_free(&frame);
@@ -317,6 +328,14 @@ static int write_frame(AVFilterContext *ctx, FPSContext *s, AVFilterLink *outlin
static void update_eof_pts(AVFilterContext *ctx, FPSContext *s, AVFilterLink *inlink, AVFilterLink *outlink, int64_t status_pts)
{
int eof_rounding = (s->eof_action == EOF_ACTION_PASS) ? AV_ROUND_UP : s->rounding;
+
+ /* Check trimming before rounding can collapse a frame before start_time
+ * and the end of its interval onto the first output timestamp. A frame
+ * starting at start_time is still eligible even with zero duration. */
+ s->last_frame_trimmed = s->start_time != DBL_MAX &&
+ s->start_time != AV_NOPTS_VALUE &&
+ s->last_in_pts < s->in_pts_off &&
+ status_pts <= s->in_pts_off;
s->status_pts = av_rescale_q_rnd(status_pts, inlink->time_base, outlink->time_base,
eof_rounding | AV_ROUND_PASS_MINMAX);
diff --git a/tests/fate/filter-video.mak b/tests/fate/filter-video.mak
index b746f6a0e8..564c970e60 100644
--- a/tests/fate/filter-video.mak
+++ b/tests/fate/filter-video.mak
@@ -501,7 +501,7 @@ fate-filter-mpdecimate-mode-1: CMD = framecrc -lavfi testsrc2=r=4:d=5,fps=5,mpde
FATE_FILTER-$(call FILTERFRAMECRC, TESTSRC2 FPS MPDECIMATE) += fate-filter-mpdecimate-mode-1-min-3
fate-filter-mpdecimate-mode-1-min-3: CMD = framecrc -lavfi testsrc2=r=2:d=7,fps=7,mpdecimate=mode=1:min=3 -pix_fmt yuv420p
-FATE_FILTER-$(call FILTERFRAMECRC, FPS TESTSRC2) += $(addprefix fate-filter-fps-, up up-round-down up-round-up down down-round-down down-round-up down-eof-pass start-drop start-fill)
+FATE_FILTER-$(call FILTERFRAMECRC, FPS TESTSRC2) += $(addprefix fate-filter-fps-, up up-round-down up-round-up down down-round-down down-round-up down-eof-pass start-drop start-fill single)
fate-filter-fps-up: CMD = framecrc -lavfi testsrc2=r=3:d=2,fps=7
fate-filter-fps-up-round-down: CMD = framecrc -lavfi testsrc2=r=3:d=2,fps=7:round=down
fate-filter-fps-up-round-up: CMD = framecrc -lavfi testsrc2=r=3:d=2,fps=7:round=up
@@ -511,6 +511,17 @@ fate-filter-fps-down-round-up: CMD = framecrc -lavfi testsrc2=r=7:d=3.5,fps=3:ro
fate-filter-fps-down-eof-pass: CMD = framecrc -lavfi testsrc2=r=7:d=3.5,fps=3:eof_action=pass
fate-filter-fps-start-drop: CMD = framecrc -lavfi testsrc2=r=7:d=3.5,fps=3:start_time=1.5
fate-filter-fps-start-fill: CMD = framecrc -lavfi testsrc2=r=7:d=1.5,setpts=PTS+14,fps=3:start_time=1.5
+fate-filter-fps-single: CMD = framecrc -lavfi testsrc2=s=16x16:r=25:d=0.04,fps=1
+
+FATE_FILTER-$(call FILTERFRAMECRC, FPS TESTSRC2 SETPTS) += fate-filter-fps-single-before-start fate-filter-fps-single-end-at-start fate-filter-fps-single-zero-duration
+fate-filter-fps-single-before-start: CMD = framecrc -lavfi testsrc2=s=16x16:r=25:d=0.04,setpts=PTS-2,fps=1:start_time=0
+fate-filter-fps-single-end-at-start: CMD = framecrc -lavfi testsrc2=s=16x16:r=25:d=0.04,setpts=PTS-1,fps=1:start_time=0
+fate-filter-fps-single-zero-duration: CMD = framecrc -lavfi testsrc2=s=16x16:r=25:d=0.04,setpts=0:strip_fps=1,fps=1:start_time=0
+fate-filter-fps-single-zero-duration: REF = $(SRC_PATH)/tests/ref/fate/filter-fps-single
+
+FATE_FILTER-$(call FILTERFRAMECRC, FPS TESTSRC2 SETPTS SETTB) += fate-filter-fps-single-cross-start
+fate-filter-fps-single-cross-start: CMD = framecrc -lavfi testsrc2=s=16x16:r=25/2:d=0.08,settb=1/25,setpts=PTS-1,fps=1:start_time=0
+fate-filter-fps-single-cross-start: REF = $(SRC_PATH)/tests/ref/fate/filter-fps-single
FATE_LIBAVFILTER-yes += fate-filter-drawutils
fate-filter-drawutils: libavfilter/tests/drawutils$(EXESUF)
diff --git a/tests/ref/fate/filter-fps-single b/tests/ref/fate/filter-fps-single
new file mode 100644
index 0000000000..d315556861
--- /dev/null
+++ b/tests/ref/fate/filter-fps-single
@@ -0,0 +1,6 @@
+#tb 0: 1/1
+#media_type 0: video
+#codec_id 0: rawvideo
+#dimensions 0: 16x16
+#sar 0: 1/1
+0, 0, 0, 1, 384, 0x92e591ca
diff --git a/tests/ref/fate/filter-fps-single-before-start b/tests/ref/fate/filter-fps-single-before-start
new file mode 100644
index 0000000000..2d09a982e2
--- /dev/null
+++ b/tests/ref/fate/filter-fps-single-before-start
@@ -0,0 +1,5 @@
+#tb 0: 1/1
+#media_type 0: video
+#codec_id 0: rawvideo
+#dimensions 0: 16x16
+#sar 0: 1/1
diff --git a/tests/ref/fate/filter-fps-single-end-at-start b/tests/ref/fate/filter-fps-single-end-at-start
new file mode 100644
index 0000000000..2d09a982e2
--- /dev/null
+++ b/tests/ref/fate/filter-fps-single-end-at-start
@@ -0,0 +1,5 @@
+#tb 0: 1/1
+#media_type 0: video
+#codec_id 0: rawvideo
+#dimensions 0: 16x16
+#sar 0: 1/1