Skip to content

fix(engine): a video clip shows a new source frame on every frame it is on screen - #7471

Merged
miguel-heygen merged 3 commits into
mainfrom
fix/clip-last-slot
Nov 10, 2026
Merged

miguel-heygen merged 3 commits into
mainfrom
fix/clip-last-slot

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

What

A video clip now gets a new source frame on every output frame it is on screen. Before this, a clip whose length was not a whole number of output frames, or that started between output frames, showed its second-to-last picture twice and never showed its last one. The coverage gate counted frames the same short way, so it never noticed.

Why

Extraction counted a constant-frame-rate window as its length in frames rounded to the nearest whole frame, which is where FFmpeg's -t stops. The render shows a clip on every output frame inside its window (exportClipWindow + isInClipWindow), which is the count rounded up. Two common cases fall short by one:

  • a clip that starts between output frames: extraction moves its start onto the next frame (alignClipStartToFirstOutputFrame), and the shortened window's fraction is often under one half
  • a clip whose length has a small fraction, for example 0.27 s at 30 fps covers 9 frames, but 8.1 rounds to 8

The render then holds the last extracted frame for the uncovered slot: one repeated picture at the end of the clip.

Reproduce on main: a 30 fps clip at data-start="0.01" with data-duration="0.27" over a counter video renders source frames 0, 1, 2, 3, 4, 5, 6, 6. With this change it renders 0, 1, 2, 3, 4, 5, 6, 7.

Related work

Stacked on #7469 (same extraction lines). Review after it; the diff against #7469's branch is this PR's change.

How

  • extractionFrameCountForDuration counts a CFR window as every output slot it covers. A tolerance of 1e-4 frames absorbs microsecond-rounded probe durations. It is deliberately below the export snap's 1e-3, so a window ending right at that snap edge gets a spare held copy, never a missing frame.
  • Every counted CFR window is extracted in segments. Segments already read one frame past their end and keep exactly their own frames, so -t rounding can no longer drop the last slot. The single-pass -t path remains only for counts it cannot make (a decimal rate).
  • The coverage gate uses the same count (its "nearest" mode is renamed "slots"), so a clip missing its last slot now fails the gate instead of passing.
  • Extraction cache schema hfcache-v9- to hfcache-v10-: the same key now yields one more frame for these windows. Locally, without the bump, a build of this branch failed the gate on 30 entries a fix(engine): short clips from Apple-encoded videos keep their last frame #7469 build had cached.

Size

Small on purpose: one count rule, one routing condition, one rename. It stays out of #7469, which fixes a different failure and is already in review.

Test plan

fails without the fix: videoFrameExtractor.slots.test.ts › a clip's frames match the output frames it is on screen › gives a 0.01-0.28 s clip a new source frame on every output frame it is on screen (expected [ +0, 1, 2, 3, 4, 5, 6 ] to deeply equal [ +0, 1, 2, 3, 4, 5, 6, 7 ]), and the 0-0.27 s case (expected [ +0, 1, 2, 3, 4, 5, 6, 7 ] to deeply equal [ +0, 1, 2, 3, 4, 5, 6, 7, 8 ])

fails without the fix: videoFrameCoverage.test.ts › rejects a short CFR clip missing the last output slot it covers (expectedFrames 7, not 8, with the old count)

  • The new extractor cases live in their own file, so the large videoFrameExtractor.test.ts changes only by the two assertions below; 10 runs in a row at about 2 s each.
  • Four tests that hard-coded the old count were updated to the covered-slot count, which is checked independently in each case: 0.616666 s at 30 fps is on screen at frames 0 to 18, so 19.
  • Frame counts before/after on 288 clips (24 per film; 12 sources: 24/25/29.97/30/60 fps, MOV and MP4 at 1/600, MKV, WebM, MPEG-TS, audio-first MP4): 12 of 24 clips per film gain exactly one frame (7 to 8, 8 to 9), the same 12 in every source; none ends below its covered slots. 72 clips that end at their source's end: captured equals expected for all.
  • Frame-accurate renders, built from this branch with a fresh cache. A counter page and counter videos are decoded on every output frame; a clip is wrong if any frame shows a source frame other than the one on screen at that time. 24-clip film: 12 wrong before, 0 after. 20-clip film (16 starting between frames): 12 before, 0 after. 48 clips of whole short files: 22 before, 0 after, counting a slot past a source's last frame as correct when it holds that frame. Gate coverage 1.0 on all three.
  • Engine suite: all pass except 4 ffprobe.test.ts cases that read git-lfs PNG fixtures missing from my checkout (the same 4 fail on main there). Producer unit lane: 1237 passed. Producer integration lane: passes. Engine and core typecheck clean. New tests: 3 runs in a row.
  • Comments follow CONTRIBUTING.md "Comments"

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES · 1 blocker · 0 important · 0 nits · head 81cb0036

  • blocker — oxfmt --check fails on packages/engine/src/services/videoFrameExtractor.ts (regression › Preflight (lint + format), run 42732587326). Dropping ...frameCap from the single-pass runFfmpeg([...]) call (~L1465) probably lets it fit on one line again, and oxfmt now wants it collapsed. This is stacked on fix/short-clip-coverage, so the CI workflow didn't run. Its required Format job will fail the same way once this targets main. Run bun run format.
Scope

I read the diff against #7469's head 6607d9c7: 6 files, both commits. The count works out to ceil(x − 1e-4) slots, where x is the window length in output frames. That matches the [start,end) slots isInClipWindow shows (0.27 s at 30 fps → 9, 0.266667 → 8). The coverage gate reads the same function through "slots", on the start that alignClipStartToFirstOutputFrame already moved in place. Every counted CFR window now goes through segments, which bound their own frames, so removing #7469's frameCap is safe. Only a decimal rate (count 0) keeps the -t path. The cache bump to v10 is needed. The fails-without-the-fix lines are present. Outside the format check I found no correctness blocker. I didn't run tests locally.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE · 1 resolved, 0 open, 0 new · 81cb003 → a5f68e8

The format fix is formatter output only. Preflight and all 10 regression shards are green. Heads-up for when this reaches main: it adds two ffmpeg cases to videoFrameExtractor.test.ts. #7469's flake gate timed out on that file at 120 s and only passed once its test moved out, so put these in a separate file too, or the gate will fail again.

heygengenesis[bot]
heygengenesis Bot previously approved these changes Nov 10, 2026

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE · 0 open, 0 new · a5f68e8 → c470bdd

Range-diff against the previous head: the code matches a5f68e89. The only change is that the two ffmpeg cases moved to videoFrameExtractor.slots.test.ts, which builds its own fixture. The large test file now changes by count assertions only, and the fails-without-the-fix line points at the new file.

Base automatically changed from fix/short-clip-coverage to main November 10, 2026 01:57
@miguel-heygen
miguel-heygen dismissed heygengenesis[bot]’s stale review November 10, 2026 01:57

The base branch was changed.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE · 0 open, 0 new · c470bdd → 2c47507

Range-diff: all 3 commits are identical (=) after the rebase onto main. #7469 merged at 01:57Z. This is the first full CI run against main, and it was still running when I approved.

@github-actions

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 267 (base branch 267), smooth 107 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (8)

Unstable (1)

  • seqrepeat-none-px-r0-root-z100: tracking 0.05, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.05, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.05, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true

@miguel-heygen
miguel-heygen added this pull request to the merge queue Nov 10, 2026
Merged via the queue into main with commit 235c80b Nov 10, 2026
91 checks passed
@miguel-heygen
miguel-heygen deleted the fix/clip-last-slot branch November 10, 2026 02:36
@miguel-heygen miguel-heygen mentioned this pull request Nov 10, 2026
3 of 4 tasks
SanHsien added a commit to SanHsien/hyperframes that referenced this pull request Nov 10, 2026
…nce watermark

Seventeen commits, PRs 7469-7493, issues 7473 and 7486 and twelve pin moves
are recorded with their evidence in the continued ledger, DECISIONS,
UPSTREAM and ROADMAP. Adopted: heygen-com#7466, heygen-com#7479, heygen-com#7482, heygen-com#7490 and the heygen-com#7441 and
heygen-com#7440 docs. Deferred: heygen-com#7469, heygen-com#7471, heygen-com#7476, heygen-com#7480, heygen-com#7468, heygen-com#7487, heygen-com#7491.
Not applicable: heygen-com#7470, heygen-com#7351, heygen-com#7475, heygen-com#7483. The watermark moves to
61e720b, PR heygen-com#7493, issue heygen-com#7486; thirteen new PRs are pinned. FORK.md
records the subComposition test differences. The core packed-size roadmap
row trigger is not met.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant