Skip to content

fix(video): repair partial H.264 MP4 composition offsets - #513

Merged
superuser404notfound merged 1 commit into
superuser404notfound:mainfrom
orut34iop:codex/pr-partial-composition-20260907
Sep 7, 2026
Merged

superuser404notfound merged 1 commit into
superuser404notfound:mainfrom
orut34iop:codex/pr-partial-composition-20260907

Conversation

@orut34iop

Copy link
Copy Markdown
Contributor

Summary

Fix persistent judder when an H.264 MP4 has valid composition offsets at its head but zero offsets in later reordered IDR sequences. The existing healthy-head verdict disables repair for the entire session, so a seek into the malformed region escapes detection.

This is an independent follow-up to #409, not a dependency on #510, #511 or #512. It changes no host UI or decoder routing and leaves the existing whole-file missing-ctts policy intact.

Evidence

The reported source is progressive SDR H.264 High level 4.0, 1920x1080 in MP4, nominal 30000/1001, time base 1/90000, with AAC-LC 48 kHz. Direct sample-table inspection found valid offsets for the first 3272 video samples, then 279588 zero offsets. At about 700 seconds all 240 inspected packet PTS equalled DTS while parsed POC reversed 68 times; a 120-frame decode had 34 original frame-PTS regressions. Native telemetry still reported about 30 FPS, sufficient buffer and no aggregate drops/stalls.

The first faulty GOP changes from 3600-tick to 3003/3004-tick intervals inside the sequence, so a short constant-cadence guess does not safely repair the continuous boundary.

What changed

  • A corroborated healthy origin enables bounded detection of later zero-offset IDRs.
  • Complete progressive closed POC sequences assign their existing DTS slots plus the corroborated presentation lead by display rank. Original DTS, audio and the published keyframe index never move.
  • Healthy offsets resume unchanged delivery. Unsupported POC, missing timestamps, overflow and insufficient lead are not guessed; after confirmation unsupported zero-offset sequences fail explicitly.
  • Hold bounds cover video count, all interleaved packets and bytes; seek/EOF/teardown ownership is tested.
  • Generated solid-colour/tone fixtures cover malformed tails and malformed middle sections, with actual bundled-FFmpeg parser/session/two-decoder comparisons. No private media, identifiers, paths, URLs or raw logs are published.
  • Updated changelog, format documentation, documented-constant pins and CI regression entry. No new public API.

Test plan

  • Device/OS: reporter verified the downstream implementation on Apple TV 4K (3rd generation), tvOS 26.6 beta (23L773), native hardware H.264 route. This is evidence for the downstream implementation, not a claim that a separately installed upstream PR build was tested.
  • Original source checks at 0/700/0/107/112/3600/9400 seconds: original frame-PTS regressions 0/49/0/20/50/48/46, all repaired results zero. Exact DTS, payload/metadata/non-video preservation and zero tracked packet balance asserted. The 107-second check crosses the first malformed region continuously.
  • bash Scripts/test-h264-partial-composition-controls.sh: passed on this branch, including healthy→broken→healthy, EOF, abandoned pending/ready queues, malformed post-confirmation timestamps and all-stream bounds.
  • Independent arm64 tvOS package build: passed. Documentation links and whitespace checks: passed.
  • Full swift test and other platform builds were not run locally; the existing CI jobs plus the new generated regression remain the upstream gate. Generated fixtures prove the timestamp regression, not the original movie's visual judder.

See docs/partial-composition-regression.md for reproduction, measurements and limitations. This is a narrow compatibility policy, not a general VFR timestamp reconstructor.

Checklist

  • CHANGELOG.md updated
  • Conventional Commit
  • Fix lives in the engine
  • No new public API; behavior and bounds documented

superuser404notfound pushed a commit that referenced this pull request Sep 7, 2026
… still delivers (#513)

The merged policy is exact: measured against a healthy twin whose later ctts
offsets were zeroed in place, 1800 of 1800 packet times and every DTS come back
identical. Two things around it were not.

The hold grew with the sequence. Packets were published only at the next IDR, so
a coded video sequence was held whole, across ALL streams, and the budget behind
it (1024 interleaved packets) was reached by an ordinary GOP: measured at 30 fps
with one stereo AAC track, 390 pictures survived and 420 did not, because 420
video packets carry about 656 audio packets with them. The advertised ceiling of
512 pictures was therefore never the one that governed.

And reaching it threw. After the first sequence had confirmed, a refusal ended
the source read instead of the repair, which on a scene-cut fixture (a 30-picture
zero-offset sequence, then 420) killed the session before it drew anything:
`state=error("Source read failed (code -1)")` with `hasFirstFrameReadyForDisplay
=false`, where main plays the same file through with the judder the repair
exists to remove.

Both follow from holding a plan instead of reading a slot. A picture's slot is a
packet away: rank r needs slot r, so a picture coded ahead of its own slot waits
exactly the mini-GOP the reorder created and nothing waits for a sequence to end.
`SlotLadder` records the decode slots as they arrive and hands each picture the
one its rank owns; the FIFO drains from the front, so the container's own order
is what leaves the demuxer. Measured deepest wait: 6 packets, on the same
420-picture sequence, against 154 for a 60-picture one before. There is no
sequence length this cannot repair now, so the ceiling that governed is gone
rather than raised.

Every refusal hands the held packets back exactly as they arrived and lets the
rest of that sequence stream through; the next IDR is a fresh candidate. A
repair that cannot say where a picture belongs still has to deliver it.

The regression fixture his suite could not reach is in the controls script now:
a scene-cut GOP structure, because a uniform one-second GOP cannot cross a
ceiling and therefore cannot show one gone. His numeric ladder, including the
25 to 29.97 transition, stays the unit fixture and now also pins the wait depth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tQ8MerNbZQdWBpDStitoD
@superuser404notfound
superuser404notfound merged commit 0c113df into superuser404notfound:main Sep 7, 2026
superuser404notfound pushed a commit that referenced this pull request Sep 7, 2026
superuser404notfound pushed a commit that referenced this pull request Sep 7, 2026
The merge left its conflict markers in the workflow file, which GitHub rejects
before any job starts. Keeps the software packet-cache step and the partial
composition-offset step side by side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tQ8MerNbZQdWBpDStitoD
@superuser404notfound

Copy link
Copy Markdown
Owner

Merged, with the delivery around the policy rebuilt on top in bd906a15 (branch merged to main as 075df436, CI green). Thank you for this one, the diagnosis and the policy are both right, and the numeric ladder from the PR is now the unit fixture.

The policy is exact. Measured against a healthy twin, the same file with the later ctts offsets zeroed in place (box lengths preserved, stco patched, so the pair differs in nothing else): 1800 of 1800 packet times and every DTS come back identical to the healthy axis, on a uniform-GOP fixture and on a variable-GOP one. Reading the slot instead of fitting a cadence is the right call, and it is the same conclusion the Matroska repair in #511 landed on.

Two things around it did not hold up, and both trace back to holding a plan rather than reading a slot.

The hold grew with the sequence. publishPending() runs only from finishSequence, so a coded video sequence was held whole, across all streams, and the 1024 interleaved-packet budget was reached by an ordinary GOP. Measured at 30 fps with one stereo AAC track: 390 pictures survive, 420 do not, because 420 video packets carry about 656 audio packets with them. The advertised ceiling of 512 pictures was therefore never the one that governed.

And reaching it threw. refuse() after confirmation ended the source read rather than the repair. On a scene-cut fixture, a 30-picture zero-offset sequence followed by a longer one:

this branch: final t=4.00s state=error("Source read failed (code -1)")
             FIRSTFRAME hasFirstFrameReadyForDisplay=false        <- nothing drawn at all
main:        final t=22.30s state=playing                          VERDICT: OK

The loopback segment producer reads ahead, so it met the throw before the player had drawn a frame. Same file on main plays through, with the judder the repair exists to remove.

What changed. A picture's slot is a packet away, not a plan away: rank r needs slot r, so a picture coded ahead of its own slot waits exactly the mini-GOP the reorder created. Measured on both twins, max(rank - index) = 2 = video_delay. SlotLadder now records the decode slots as they arrive and hands each picture the one its rank owns; the queue drains from the front, so the container's own order is still what leaves the demuxer. Deepest wait on the 420-picture sequence: 6 packets, against 154 held for a 60-picture one before. There is no sequence length this cannot repair now, so the ceiling is gone rather than raised, and presentationTimes with its nextDTS argument is no longer needed.

Every refusal now hands the held packets back exactly as they arrived and lets the rest of that sequence stream through; the next IDR is a fresh candidate. A repair that cannot say where a picture belongs still has to deliver it, because the judder is a far smaller failure than a session that stops.

Two notes on the harness, since both are why this was not visible from it:

  • stop = videoRead >= 180 && key caps every position at 180 pictures, so no run could reach the budget. It reads 1200 now.
  • the fixture is -g 30 -x264-params scenecut=0, a uniform one-second GOP. A uniform GOP cannot cross a ceiling and therefore cannot show one gone either. Scripts/test-h264-partial-composition-controls.sh builds a scene-cut fixture as well now, 30 pictures then 420, and the fixture generator takes its boundaries as arguments.

The numeric ladder, including the 25 to 29.97 transition, is unchanged as the unit fixture and now also pins the wait depth. Full suite green on both runners, and the runtime controls pass on all three fixtures in CI.

Not released yet. A retest on the original source once it is would be welcome, and one thing worth knowing from it: whether any sequence in that file is long enough that the old ceiling would have been reached.

@superuser404notfound

Copy link
Copy Markdown
Owner

Released in 6.74.0 (0c113df9 plus the hardening in bd906a15). Both consumers are bumped.

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.

2 participants