Skip to content

join.py on FFmpeg 7.1: a short H.264 clip in MP4/MOV is held 2 frames past its picture (stream starts read differently) #475

Open

Description

@kajisho5

Seen in. #473 CI, job test (debian-trixie container, py3.13, ffmpeg 7.1 apt, 3/3), on 1622dea and on 440d6eb.

What happened. test_join_clip_lengths_are_whole_frames_and_samples makes 60 clips of exactly 5 frames from one source (-c:v libx264 -qp 0 -preset ultrafast) and joins them with --transition none. The result reports clip_frames.

FFmpeg clip container / sound rate clip_frames frames out
6.1.1 (local) MP4 / AAC 30, 30000/1001 [5]*60 300
7.1 (CI, 1622dea) MP4 / AAC 30 not printed 418
7.1 (CI, 1622dea) MP4 / AAC 30000/1001 not printed 420
7.1 (CI, 440d6eb) MOV / PCM 30 59 × 7, last 5 418
7.1 (CI, 440d6eb) MOV / PCM 30000/1001 [7]*60 420

The sound in each clip is exactly 5 frames of samples, and with PCM it has no codec padding. So the extra two frames do not come from AAC; an earlier version of this issue blamed the AAC tail, which was wrong.

Likely cause (not confirmed: no 7.1 here). decision.av_clip_length() measures where the sound ends from the clip's first frame as a + a0 - v0 (stream_starts). It then holds the clip to that sound when it ends more than a frame past the picture (narration is never cut). For these H.264 clips in MP4/MOV, 7.1 seems to report a video start about two frames before the audio start (negative CTS or an edit-list difference). The sound then appears to end two frames after the picture, and every clip is lengthened from 5 to 7 frames, 0.2333 s. The rule predates #473; the old millisecond code used the same av_clip_length().

#473 switched the test to FFV1 + PCM in MOV, which has no reordering delay and exact frame times, so the test measures the cut alone.

To do.

  • On FFmpeg 7.1, print probe() → video.start_time, video.duration, audio.start_time, audio.duration and stream_starts() for one such clip, and compare with 6.1.
  • Fix stream_starts, or the sound-end computation, so a clip whose sound and picture cover the same frames is not lengthened. That probably means reading the first displayed frame's pts rather than the stream start.
  • Add a regression test with H.264 clips in MP4 that runs on the 7.1 and 8 jobs.

Activity

  1. changed the title [-]join.py on FFmpeg 7.1: a short clip's AAC tail is read as sound past the picture, and the clip is lengthened by 1–2 frames[/-] [+]join.py on FFmpeg 7.1: a short H.264 clip in MP4/MOV is held 2 frames past its picture (stream starts read differently)[/+] on Nov 12, 2026
  2. kajisho5 commented on Nov 12, 2026

    @kajisho5
    OwnerAuthor

    What FFmpeg 7.1 reported for one fixture clip (CI on bab3780). The clip was 5 FFV1 frames + 8000 PCM samples in MOV, written with -fps_mode passthrough from a 30 fps NUT source:

    video: duration 0.133333, start_time 0.0, fps 37.5, nb_frames 5
    audio: duration 0.166667, start_time 0.0
    stream_starts: (0.0, 0.0)
    join.py clip_frames: [7, 7, ..., 7, 5]
    

    So the stream starts are not the cause. The MOV carried no duration for the last frame, so the video reads one frame short (4 frame-times). probe()'s fps comes from avg_frame_rate = 5 / 0.1333 = 37.5.

    join.py then did two things:

    1. It took the first clip's fps as the join's rate. output_rate() only uses r_frame_rate when it agrees with fps, so the join ran at 75/2.
    2. At 37.5 fps it counted the sound, which lasts 0.1667 s, as running more than a frame past the 0.1333 s picture, so it held each clip to ceil(0.1667 × 37.5) = 7 frames.

    The earlier H.264/AAC MP4 fixture gave the same 7 frames, so this probably happens to any short clip whose container leaves the last frame's duration out.

    #473 writes its fixture with -r <rate> so that each frame carries its duration. The join.py side stays here:

    • The rate. Prefer the stream's declared r_frame_rate over an avg_frame_rate computed from a short duration, for example when nb_frames / r_frame_rate matches the sound better, or whenever r_frame_rate is a plain rate.
    • The video length. When it is (nb_frames - 1) / rate, read it as nb_frames / rate.
    • A test. Five-frame clips without a last-frame duration (-fps_mode passthrough into MOV) should join at the source rate, 5 frames each, on 7.1.

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions