Skip to content

fix(demux): restore Matroska H264 presentation timestamp ownership - #511

Closed
orut34iop wants to merge 1 commit into
superuser404notfound:mainfrom
orut34iop:codex/pr-matroska-timestamps-20260907
Closed

orut34iop wants to merge 1 commit into
superuser404notfound:mainfrom
orut34iop:codex/pr-matroska-timestamps-20260907

Conversation

@orut34iop

@orut34iop orut34iop commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Companion submissions, independently reviewable against main:

Repair positively identified Matroska H.264 streams whose packet timestamps were assigned in coding order rather than presentation order. Assign the existing presentation slots to parsed POC; keep hardware/native playback, original compressed bytes, audio timing and the seek-index axis.

This is distinct from missing MP4 ctts in #409 / #413 / #415 / #419: FFmpeg can synthesize unequal DTS/PTS for this MKV defect, so the existing ISO-BMFF equality guard correctly does not repair it.

What changed

  • Independent Matroska policy: require a complete IDR-bounded progressive sequence, strictly rising near-CFR input ladder, reordered POC, and a complete unique even-POC-to-slot bijection.
  • Permute original PTS slots. Retain their rational rounding and first-picture hold rather than generating a new nominal-FPS clock. Move DTS and the container index by one confirmed constant decode lead, retained across seeks.
  • Bound packet ownership across all streams. Healthy/unproven samples pass unchanged. An unsupported sequence after activation fails explicitly instead of silently reverting a published timestamp axis.
  • Share only packet ownership/seek protocol with the existing MP4 session; its detection and rewrite policy remain unchanged.
  • Add numeric-only fixture, policy tests, pinned-FFmpeg parser/session/dual-decoder tests, and publicly generated solid-colour/AAC healthy and broken controls; run scripts in CI.
  • Based on main 1af43f017ffca973e35f8fec1e700852f2953d55. The accepted downstream algorithm is unchanged; downstream-only public diagnostic types were replaced with an internal numeric summary. No host UI, recovery-point route, software-cache implementation or dependency-pin changes.

Reproduction and measured result

Reported MKV: H.264 Main level 4.0, progressive 1920×1080, 8-bit 4:2:0, nominal 30000/1001 fps, time base 1/1000, video_delay=1; AAC-LC 48 kHz stereo. Duration 7148.085 s. No HDR/DV signaling reported.

The measured first timestamp slots begin 0, 40, 73, 107, 140, ...; original decoder output began 0, 73, 107, 140, 40, .... Complete sequences have 60 pictures. The repair preserves every slot while using a 34 ms decode lead.

Pinned FFmpegBuild 421e13be7061de67d91b85ac34a6b22a002b164f, actual parser/session and two decoders:

Read position Decoded frames Original PTS regressions Repaired regressions
Head 181 56 0
700 s 181 45 0
3600 s 181 49 0
7143 s through EOF 206 52 0

Each run checks unchanged packet payload, side data, flags, duration, position, audio and frame count, exact preservation of the sorted presentation-time multiset, constant seek decode offset and zero outstanding tracked packets. Lifecycle tests cover seek with both pending/ready packets, active failure and the all-stream packet ceiling.

Test plan

  • Device / OS: physical Apple TV 4K (3rd generation), tvOS 26.6 beta (23L773).
  • Physical: accepted downstream core 8b3001c85d38512c14ed7a06e5db441372c62220, candidate 20260907140332; hardware H.264/native route remained active. User passed startup, forward/backward and repeated seeks, pause/resume and extended playback.
  • Extended native log sessions spanned 319 s and 611 s including operations. Settled measured FPS medians 30.330 and 30.405, minimums 29.607 and 29.746; dropped/stall counters stayed zero. These are logged spans, not a claim of uninterrupted pixel-measured playback.
  • Healthy separately encoded MP4 was also checked offline; it is not bit-identical to the MKV and no physical A/B claim is made.
  • Local policy: bash Scripts/test-h264-matroska-timestamps.sh passed.
  • Local controls: bash Scripts/test-h264-timestamp-controls.sh passed. Generates healthy MP4/MKV, missing-ctts MP4, and coding-order MKV from solid colour + AAC silence; no private fixture required. Generated broken MKV has 87 → 0 regressions at head and after seek.
  • The runtime script accepts AETHER_FFMPEG_CHECKOUT for an existing exact pinned checkout, defaulting to .build/checkouts/FFmpegBuild.
  • Local arm64 tvOS package build (Xcode 26.6), docs links and diff checks passed. Complete Swift package tests are left to upstream CI; this isolated upstream branch was not separately installed on the device.

Scope / limitations

This intentionally does not guess for fields, open-GOP leading pictures, incomplete/duplicate POC, VFR ladders or unknown timestamps. It adds one bounded sequence of startup hold to positively matching candidates. Later violations fail closed. It does not rewrite the original source file. Numeric measurements only are public: no private media, filenames/paths, network identifiers, raw logs or screenshots are distributed.

Checklist

  • CHANGELOG.md and format documentation updated
  • Conventional Commit
  • Demux-layer fix; no host-side workaround
  • No new public API

superuser404notfound pushed a commit that referenced this pull request Sep 7, 2026
…s there

Matroska block timestamps are presentation timestamps by specification, and
the format has no composition-offset table to lose. A writer that fills them
packet by packet while the bitstream reorders pictures therefore loses
nothing: every presentation slot is still in the file, each one just arrived
attached to the picture DECODED at that position rather than the one
DISPLAYED there. The reporting asset's first slots are 0, 40, 73, 107, 140
and the decoder emitted them as 0, 73, 107, 140, 40, which is one
stepped-back presentation clock per mini-GOP for the length of the file.
Measured here through the engine's own software decoder on a generated twin:
15 of 30 frame times stepped backwards before, 0 after.

The repair permutes rather than reconstructs. A picture carries the slot its
own display rank owns, and that slot is READ from the file instead of being
computed, which buys three things a rank-arithmetic rewrite cannot have. The
slots keep their own rounding, so a ladder quantized from a fractional frame
rate is exact rather than within a tick, and the one slot the reporting
asset's writer clamped onto its cluster origin (7 ticks below the 1001/30
lattice its other 59 slots sit on) survives as written; fitting that lattice
would have needed 21 slots at 30000/1001 and 61 at 60000/1001 before its
phase is unique at all. The decode timestamps are already right, because
libavformat derives them from the rising slot ladder, so nothing moves them
and nothing moves the container index either, which holds keyframe slots and
a keyframe is the first picture of its own sequence. And the slot a picture
needs is a packet away, not a plan away: a picture coded ahead of its slot
waits for the packet carrying it, three video packets on the reporting asset,
never the end of a sequence.

Detection is fail-closed and costs a healthy file almost nothing: one
stepped-back slot is the container doing what the format says, and it ends
the sample on what is normally the third packet. PTS != DTS is not an
eligibility test here, because libavformat synthesizes a decode ladder from a
rising presentation ladder just as readily as from a reordered one, which is
why the ISO-BMFF policy cannot be reused. A stream that later stops being
that shape, or a wait no mini-GOP explains, hands its packets back exactly as
they arrived rather than permuting half a sequence.

Diagnosed by @orut34iop on PR #511, whose numeric ladder is the regression
fixture and whose session protocol this keeps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tQ8MerNbZQdWBpDStitoD
superuser404notfound pushed a commit that referenced this pull request Sep 7, 2026
… the picture that displays there (#511)

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

Thank you for the diagnosis and for the numeric ladder. Both went into the fix, which is on main in 1e1db64, so this is the reasoning for taking the analysis and not the branch.

Why not the branch

The fail-closed path turns a file that plays today into one that does not play at all. Three fixtures, all built the way your own control builds one (-c copy -bsf:v 'setts=pts=DTS' into MKV), differing only in GOP structure:

fixture main this branch
60-picture GOPs throughout plays, first frame 0.16 s plays, repaired
60-picture GOP, then 899 (scene cut at 2 s, -g 2000 -sc_threshold 40) plays, first frame 0.16 s no first frame at all, Source read failed (code -1)
same, video only, second GOP 660 pictures plays same error
same, one AAC track, second GOP 419 pictures (14 s) plays same error
[Demuxer] Matroska H264 coding-order timestamps confirmed: pictures=60 poc_regressions=29 decode_lead=68
[Demuxer] Matroska H264 timestamp repair stopped: sequence no longer satisfies confirmed policy
[PktDump] readPacket threw after 60 video packets: sequenceNoLongerRepairable

The trigger is refuse() throwing once lead != nil, and the ceilings that reach it are ordinary rather than exotic: videoCount > 512, and pending.count >= 1024 counted across all streams, which with one stereo AAC track at 30 fps is any GOP longer than about 13 seconds. The session confirms on a short opening GOP and then dies on a normal-length one, and since the segment producer reads ahead, the error lands before the first segment is served.

The second half is the hold. publishPending() runs only from finishSequence (the next IDR) and from refuse, so the demuxer holds a complete sequence of every stream permanently, not once at startup as the description says. Your own harness prints held_packets=152 held_bytes=1975 on the generated control, and after every seek nothing is emitted until the next IDR has been read.

What is merged

Your reading that the slots must not be regenerated is the part I kept, and the way to keep it without the hold is to read the slot rather than compute it. A picture carries the slot its own display rank owns, and that slot is a packet away rather than a sequence away: a picture coded ahead of its slot waits for the packet carrying it, which on your ladder is three video packets in a 60-picture sequence.

Everything your permutation was protecting falls out of that. Nothing fits a cadence, so the rounding survives exactly and so does the first slot the writer clamped onto its cluster origin, 7 ticks below the 1001/30 lattice its other 59 slots sit on. That clamp is also why a lattice was the wrong tool here: on your ladder one phase only reproduces the window after 21 slots. Nothing moves the decode timestamps, because libavformat derives them from the rising ladder and they are already the decode order the stream has, and the container index is untouched for the same reason, since it holds keyframe slots and a keyframe is the first picture of its own sequence. A stream that later stops being that shape hands its packets back exactly as they arrived instead of failing the session.

Measured through the engine's own software decoder on a generated twin: 15 of 30 frame times stepped backwards before, 0 after. The healthy twin is 0 both ways and is never sampled past its third packet, because one stepped-back slot is the container doing what the format says and ends the sample there. Your PTS != DTS observation is in the code as the reason the ISO-BMFF eligibility test cannot be reused.

Two smaller notes

Scripts/test-h264-timestamp-controls.sh asserts repaired_regressions=0 and nothing about where the axis sits, which cannot see a constant offset. On my twin pair the repaired axis lands 20 to 21 ms from the healthy one, unrecoverable in that fixture because setts=pts=DTS destroys the anchor, but the assertion would not have reported it either way. And the CI step installs ffmpeg from brew and generates its fixtures with an unpinned libx264, while the runtime script checks the FFmpegBuild revision strictly.

Your ladder is the regression fixture in Issue511MatroskaCodingOrderTests, credited there, in docs/formats.md and in the changelog, and the session protocol is the one this PR proposed. Thanks again for the measurement work behind it.

@superuser404notfound

Copy link
Copy Markdown
Owner

Closing in favour of 1e1db64 on main, which keeps the diagnosis, the numeric ladder and the session protocol from here.

@superuser404notfound

Copy link
Copy Markdown
Owner

Released in 6.74.0 (1e1db64b). Both consumers are bumped, so a TestFlight or DMG build from either picks it up.

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