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