Repository navigation
fix(video): repair an MP4 whose writer forced the decode ladder monotonic - #896
sunghyun-k wants to merge 7 commits into
Conversation
…onic A writer that drops ctts and then pushes each B picture 1001 ticks past the picture decoded before it leaves PTS == DTS with no single ladder step, so the superuser404notfound#409 probe declined it and both routes showed decode order. Take the step from the stream's average frame rate, remeasure it on the anchors and rebuild DTS from each picture's decode slot, under the same picture-order checks. Adds a twin-pair test, a fixture script and docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Automated review
The PR extends the #409 missing-ctts probe to a writer that forced its decode-order timestamps to be monotonic. It takes the step from avg_frame_rate, remeasures it on the anchors and rebuilds DTS from decode slots. The direction is right: the fix is in the engine, it addresses the cause, it runs only after the existing uniform, quantized and missing-head paths have declined, and it keeps every existing picture-order check.
Findings
- Should fix: After a seek that lands on an open-GOP keyframe, the synthesized-ladder re-anchor is off by the number of leading pictures. The landing keyframe's container DTS is its presentation time here, not its decode slot. (
Sources/AetherEngine/Video/H264CompositionOffsetRepair.swift:613) - Should fix (question): If
anchorStepdeclines, the plan goes ahead on the roundedavg_frame_ratestep, which the PR itself describes as unreliable. Consider failing closed there. (Sources/AetherEngine/Video/H264CompositionOffsetRepair.swift:323)
Checklist: CHANGELOG yes, docs yes, tests yes (twin fixtures, seek, anchor step, variable-rate head; nothing yet covers an open-GOP landing or a failed anchor measurement), traits both ways n.a.
Needs a device run: an AVPlayer-route check of a seek into an open-GOP file of this shape, if one exists in the wild. The closed-GOP twin is already covered by the PR's own measurements.
Thanks for the careful write-up, the twin fixture and the script that reproduces the timestamp shape.
Automated review by Claude of c040521. It reads the code, it does not build or run it; a maintainer reviews and merges.
| return overflow ? nil : base | ||
| } | ||
| awaitingReanchor = false | ||
| decodeOrdinal = anchorSlots |
There was a problem hiding this comment.
On a synthesized ladder this re-anchor uses the regular ladder's formula, and that formula does not hold here. It computes anchor = dts - (landingIndex - leading) * step. That is right when the container DTS is the landing's decode slot. But in the shape this PR targets, an anchor keeps its own presentation time: an open-GOP I picture displays after everything decoded before it, so max(PTS, PREV_OUTDTS+1001) leaves it alone. Its container DTS is then trueAnchor + landingIndex * step, so the computed anchor comes out leading * step too late. Every PTS in that sequence would then be shifted by the number of leading pictures, and so would the new DTS through slot.
It only bites after a seek onto a non-IDR keyframe with leading pictures (POC != 0, noteLanding(leadingPictures:) > 0). The closed-GOP fixtures do not reach it. A possible fix: when plan.synthesizesDecodeLadder, set anchor = dts - landingIndex * step and keep decodeOrdinal = anchorSlots. A unit test on Rewriter with a hand-built open-GOP landing would pin it.
| // The average frame rate is only an estimate (short files, rounded durations: a 60-picture | ||
| // 29.97 clip reports 29.80). The writer's anchors carry their own presentation times untouched, | ||
| // so when they agree they give the exact step. | ||
| if synthesizesDecodeLadder, let measured = anchorStep(samples: samples, pocStep: pocStep) { |
There was a problem hiding this comment.
When anchorStep returns nil, step stays at the rounded avg_frame_rate value. The comment just above says that value can be wrong (29.80 for a 29.97 clip, i.e. 3020 against 3003). Within a coded video sequence PTS is anchor + displayIndex * step and DTS is anchor + slot * step, so the error grows by 17 ticks a picture until the next IDR. On a long GOP that reaches more than a frame, which could misplace the pictures that pass the pts >= newDTS guard against the audio and against the next sequence's anchor.
The anchors are the only evidence this path has that the stream really runs at one rate. Should a nil here return .inconclusive("anchors do not agree on a step"), so the probe fails closed like the rest of classify? If there is a measured case where anchors disagree and the nominal step still repairs correctly, a comment quoting it would help.
On this ladder a keyframe kept its own presentation time, so a seek landing re-anchors from its display rank, not its decode slot. After the seek the parser is reset and an H.264 non-IDR landing reads a relative picture order (-4 on the twin), which the non-negative guard left in decode order; a sequence anchored on a landing now accepts relative counts. A head whose anchors disagree on a step is left untouched instead of falling back to the rounded average rate. Adds open-GOP twin tests (play-through and seek) and a disagreeing-anchors test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks, both findings were real. Fixed in the new commit. Open-GOP landing. Reproduced with an open-GOP twin pair (
The new tests embed the open-GOP twins (64x64, 60 pictures): a full play-through and the seek onto the open-GOP keyframe both match the healthy twin packet for packet. Failed anchor measurement. Agreed. If the anchors do not agree on a step, the head is now left untouched ( Not covered: a device run of an open-GOP file of this shape. The open-GOP case is only checked with the generated twins. |
There was a problem hiding this comment.
Automated review
This is a re-review at d6bd646. The PR extends the #409 missing-ctts probe to a writer that forced its decode ladder to be monotonic. The direction is still right: the repair is in the engine, runs only after the existing uniform, quantized and missing-head paths have declined, and keeps every picture-order check.
Findings
- Fixed: the open-GOP landing re-anchor.
originSlotsis nowlandingIndexon a synthesized ladder, and the decode slot stayslandingIndex - leading. A play-through test and a seek test on an open-GOP twin cover it. - Fixed: a failed anchor measurement. The probe now returns
anchors do not agree on a stepinstead of falling back to the rounded rate. A test covers it, anddocs/formats.mddocuments it. - No new findings. One change to note: on a synthesized ladder anchored on a seek landing, the
pictureOrderCount >= 0guard is relaxed. The relaxation is limited to that case (synthesizesDecodeLadder && anchoredOnLanding), it is reset at the next IDR and onnoteSeek, and every picture still passes thepts >= newDTScheck.
Checklist: CHANGELOG yes, docs yes, tests yes, traits both ways n.a.
Needs a device run: the PR's device and AVPlayer-route measurements cover the closed-GOP case. Only the generated twins check the open-GOP case, so a device run of a real open-GOP file of this shape would settle it, if one exists.
Thanks for the quick, well-tested follow-up on both points.
Automated review by Claude of d6bd646. It reads the code, it does not build or run it; a maintainer reviews and merges.
… pushed its anchors too
The reporting file pushes its P pictures 1001 ticks past the picture before them just like
the B pictures, so no two anchors agree on a step and the probe left it untouched ("anchors
do not agree on a step"). When the anchors give no step, the nominal-rate repair now uses the
standard frame rate nearest the average rate, if it is within 1% and a whole number of ticks;
a head with neither is still left alone.
Scripts/make-clamped-ladder-fixture.sh also writes pushed-anchors.mp4 with that shape, and the
tests embed a 60-picture twin of it (plays and seeks onto the healthy twin's timestamps; both
fail without this change).
On the reporting file (23 min, 41487 pictures) every presentation time is now distinct and on
the 3003 lattice, and seeks to five positions land on the continuous run's times.
…ample past a leading P run
A second reporting file (H.264 High, 23 fps, GOP 90, 53 min) opens every GOP on twelve P
pictures, so the twelve-picture head never reordered and the probe left it untouched ("picture
order never regresses"). A head with PTS == DTS throughout that has not reordered by then is now
sampled on to its first regression plus a mini-GOP, up to 48 pictures.
Both reporting files also push their keyframes, always later, 0 to 4 pictures past the end of
the GOP before them. The repair anchored each GOP on its keyframe, which left a gap or put the
next GOP in front of the last pictures of the previous one on 522 of 823 boundaries of the
second file. A later IDR now continues the sequence before it, unless it is later than the
reorder delay plus two pictures or within half a tick per picture; an earlier one keeps its own
time, so a chain started on a pushed keyframe after a seek moves back into place.
The standard rates now include every whole rate up to 240 and the NTSC ones, rounded to a tick
(23 fps is 3913.04 ticks at 90 kHz).
Measured: every presentation time of both files distinct and in order (one gap left at the end
of the second), seeks settle within 0.42 pictures of the continuous run. New twins: a pushed
keyframe (continues on the healthy axis) and a leading P run (sampled until it reorders); both
fail without this change. The fixture script writes p-run-clamped.mp4 when the x264 CLI is
available.
…and a four-picture push A third reporting file (H.264 High 720p, 29 fps, GOP 90, 19 min) was left untouched: its head runs exactly the reorder delay plus two pictures ahead, and with 29 fps rounded from 3103.45 to 3103 ticks the span missed the nominal-rate bound by 3 ticks. The span check now allows a tick per picture each way. The same file keeps its keyframes four pictures (plus up to 31 ticks) past the GOP before them, which sat just past the continuation bound and left a five-picture gap at 3.1 s and 100 s. The bound is now the reorder delay plus two and a half pictures. Measured: every presentation time of the file distinct and on the 3103 lattice (the last picture aside), seeks settle within 0.34 pictures; the other two reporting files and the fixture twins are unchanged.
…notfound#409 ladder and cut HLS on moved keyframes The nominal-rate ladder now takes its rate from the bitstream's VUI timing when it agrees with the stream within 1% (snapped to a standard rate within 0.01%), so a short 24 fps clip is no longer read as 23.976, and places pictures on that rate as an exact fraction of a tick. A continued sequence also leans toward the earliest origin the pictures before it point to, by at most an eighth of a picture per GOP: the 29 fps reporting file's writer runs at 29.003 fps, and counting alone drifted 61 ms behind it over 19 minutes and overlapped three GOPs. A seek landing holds its first 8 pictures and takes the earliest origin they point to, never further back than a continued sequence could. A keyframe placed back onto its sequence sits up to the reorder delay plus two and a half pictures before its index entry, and the HLS segment plan is built from the index. The VOD cutter and the restart gate now reach that far for the keyframe each boundary names, bounded per boundary by the keyframe before it. Before, segverify found 25 of 40 segments of the 23 fps file and 22 of 40 of the 29.97 fps file starting mid-GOP; all are independent now, including restarted ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Four more commits, from two more real files of the same writer family and a generated sweep. The description is updated; the short version: One decision reversed. d6bd646 left a head untouched when the anchors disagree on a step. The first reporting file turned out to push its P pictures too (each anchor 1001 ticks past the picture before it), so no two anchors agree and the file was no longer repaired. What the other two files showed.
HLS cut. A keyframe continued this way sits up to the reorder delay plus two and a half pictures before its index entry, which the segment plan is built from, so Measured on the three files and 144 generated pairs (H.264 and HEVC, eight rates, three GOP lengths, three push patterns): demuxed timelines in order with no gaps, every verified segment independent, sequential and restarted. Numbers are in the description. The open-GOP device run the last review asked for is still open. |
There was a problem hiding this comment.
Automated review
This is a re-review at 8bbb7cd. Since d6bd646 the PR adds keyframe continuation with a clock lean, an extended head sample for a GOP that opens on a run of P pictures, a VUI or standard-rate cadence, and a per-boundary reach for the HLS cutter and restart gate. The direction is still right. The repair stays in the engine, keeps every picture-order check, and leaves every other source with a reach of 0.
Findings
- Earlier findings: the open-GOP re-anchor fix still holds. The "fail closed when anchors disagree" point was reversed with evidence (the first reporting file pushes its P pictures too). The replacement uses the VUI rate within 1%, or else a standard rate within 1%, and stays gated by
nominalLadderFits. That answer holds. Whether this second fallback is acceptable is the maintainer's call. - Should fix:
planKeyframeReachTicksis computed from the plan beforecollapseShortSegmentsmerges it, but it is indexed against the collapsedsegmentPlan. Two keyframes a few pictures apart are exactly what makes a sub-second segment that gets folded. After the fold, the reaches near that point are off by one boundary. One boundary loses the reach it needs, and the one before gets a reach that lets the earlier keyframe open it. (Sources/AetherEngine/Video/HLSVideoEngine.swift:1566)
Checklist: CHANGELOG yes, docs yes, tests yes (nothing covers the reach after a collapsed plan), traits both ways n.a.
Needs a device run: the PR already lists the pending device run of 8bbb7cd on the three files. A real open-GOP file of this shape is also still unchecked. A segverify run on the 23 fps asset over the range that holds its four-picture keyframe pair would confirm the reach fix.
Thanks for the follow-up and for bringing measurements to back the reversed decision.
Automated review by Quint (Claude) of 8bbb7cd. It reads the code, it does not build or run it; a maintainer reviews and merges.
| // stamping. Read from the demuxer that produced them, never from the URL or the | ||
| // host's metadata: on a remux session the delivered container is the one indexed. | ||
| planBoundaryAxis = PlanBoundaryAxis.forContainer(formatName: dem.containerFormatName) | ||
| planKeyframeReachTicks = Self.keyframeReachTicks( |
There was a problem hiding this comment.
The reach array is built from plan before line 1893 runs collapseShortSegments. makeProducer then indexes the reach array against the collapsed segmentPlan (dropFirst(baseIndex), then local index = boundary index). collapseShortSegments removes the start boundary of every segment shorter than 1 s. So after the first merge, each reach belongs to the boundary one later in the original plan.
The case this PR describes triggers exactly that. buildKeyframeSegmentPlan lags segIdx behind a 15 s GOP, so every keyframe becomes a boundary. A pair K_a, K_b four pictures apart therefore gives a 0.17 s segment, and K_a is folded away. After the fold:
- K_b's slot reads K_a's reach (the full
advance). K_a's DTS + 17 608 is past K_b, so K_a opens K_b's segment, four pictures before its advertised start. - The next boundary, 15 s later, reads K_b's reach (
gap - 1, about 15 651). A keyframe the writer pushed by more than that stays in the previous segment, which is the mid-GOP open this change exists to prevent. - Each later boundary is also shifted by one. That is harmless only while every reach there is
advance.
Suggested fix: compute the reach after the collapse from the final boundaries, e.g. Self.keyframeReachTicks(advance:keyframes:boundaries: segmentPlan.map(\.startPts)), keeping advance and the sorted keyframes from this branch. A test that feeds keyframeReachTicks a plan that collapseShortSegments shortens would pin it.
The reach for each boundary was computed on the keyframe plan before `collapseShortSegments` folded it, then indexed against the folded plan. A pair of keyframes a few pictures apart is what makes a folded segment, so from that fold on every boundary took the reach of the one before: the earlier keyframe of the pair could open the boundary after it, and the next boundary lost the reach its own keyframe needs. `finalizedPlan` collapses first and reads the reach off what it leaves. The 23 fps reporting asset folds three times; all 788 of its segments decode on their own either way, so this is caught by the new test rather than by that file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Fixed in On the 23 fps asset the plan folds three times (791 to 788 segments). |
There was a problem hiding this comment.
Automated review
This is a re-review at 734477e. The PR extends the #409 missing-ctts probe to a writer that forced its decode ladder to be monotonic. The direction is still right: the repair is in the engine, and every other source keeps a reach of 0.
Findings
- Fixed: the reach is now built from the plan that
collapseShortSegmentsreturns.finalizedPlanfolds the plan first, then callskeyframeReachTickson the boundaries that are left. The engine assigns both in one statement (HLSVideoEngine.swift:1895). I checked that the fold puts the short segment into the one before it, so the later keyframe of a pair stays as the boundary. That matches whatreachFollowsCollapsedPlanexpects. The reach is recomputed on every load, and it is empty for a live session or a plan with no advance. - No new findings.
Checklist: CHANGELOG yes, docs yes, tests yes (the reach after a fold is now covered), traits both ways n.a.
Needs a device run: the PR still lists the device run of the three reporting files as pending. A real open-GOP file of this shape has also not been checked on a device.
Thanks for the quick fix and the fold test.
Automated review by Quint (Claude) of 734477e. It reads the code, it does not build or run it; a maintainer reviews and merges.
|
Device run done: all three reporting files play in order at |
Summary
An MP4 whose writer dropped
cttsand then forced the decode-order presentation times to be monotonic plays with its B pictures out of order on both routes (and in QuickTime and the Files app): every sample reportsPTS == DTS, but the ladder has no single step, so the #409 probe skips it as "decode ladder is not uniform". This lets that probe rebuild both axes from the picture order and the stream's picture rate, under the same picture-order checks.The bitstream itself is fine: an FFmpeg-based player (IINA, on mpv) reorders by picture order count and plays the same file correctly, so only the container's timestamps are wrong, and AVFoundation trusts them.
Refs #409 (this is the shape its uniform-ladder probe declines).
What changed
H264CompositionOffsetRepair.classifytakes an optionalnominalStep(fromavg_frame_rateandtime_base). For a non-uniform ladder it is used only when the sampled span matches the time its pictures take at that rate (within the reorder delay plus two pictures, plus a tick per picture for rounding) and every existing check still passes (PTS == DTS, POC 0 keyframe start, picture order regresses, ranks distinct and fill the window).Plan.pictureCadence), so 23 fps (3913.04 ticks) does not lose a tick every 25 pictures.Plan.synthesizesDecodeLadder: the rewriter rebuilds DTS from each picture's decode slot instead of shifting it. A pushed B picture's DTS can sit after its own presentation time, whichPTS >= DTSwould reject.Plan.keyframeAdvanceLimit); an earlier one keeps its own time. A continued sequence also leans toward the earliest origin the pictures before it point to, by at most an eighth of a picture per GOP, because the writer's clock is not exactly the stated rate (one file states 29.0002 fps and writes 29.003). A seek landing holds its first 8 pictures and takes the earliest origin they point to.VODSegmentCutterand the restart gate now reach back for the keyframe each boundary names (Demuxer.indexedKeyframeAdvanceTicks), bounded per boundary below the keyframe before it (HLSVideoEngine.keyframeReachTicks). 0 for every other source, so nothing else moves.start_time - first index DTS, clamped tomaximumNominalShiftPictures(16) steps.decide(): the first pass runs withoutnominalStep, so the existing uniform, quantized and missing-head (fix(video): repair partial H.264 MP4 composition offsets #513) paths keep first claim. The log line gainsladder=nominal-rate.H264ForcedMonotonicLadderRepairTests(embedded 64x64 twin pairs: clamped, open-GOP, pushed anchors, pushed keyframe, leading P run; each demuxes to its healthy twin's timestamps, also after a seek; rate selection; the lean; a moved keyframe stays within the reach) andRepairedKeyframeSegmentReachTests(cutter, per-boundary reach, restart gate).DocumentedConstantsTestspins 16, 48 and 8.Scripts/make-clamped-ladder-fixture.shgenerates 1280x720 twins (clamped-ladder,pushed-anchors, andp-runwith the x264 CLI) and, with--check, prints the probe verdict andswdecode's backward steps.docs/formats.md,docs/architecture.md,CHANGELOG.md.Test plan
Device / OS: iPhone Duo, iOS 27.2 RC (a host app playing through the native route); macOS 27 (
aetherctl).Source media: three real H.264 MP4s from writers with this shape (not shareable; the script reproduces the shape):
Plus generated twins, and a sweep of 144 generated pairs: H.264 and HEVC, 23.976 / 24 / 25 / 29.97 / 50 / 59.94 / 23 / 29 fps, GOP 30 / 90 / 250, three push patterns (B only, B and anchors, every third picture including keyframes).
Result:
0bed68c,8bbb7cdand734477e).aetherctl pktdump(whole file): no duplicate or out-of-order presentation time, no gap or overlap except at the final picture of files 2 and 3; the timeline keeps a constant distance to the writer's own (lower envelope of its timestamps) from start to end. Before the lean, file 3 drifted 61 ms behind and overlapped 3 times.aetherctl segverify: 40/40 segments independently decodable from 0, and 3/3 from a restart at segments 60, 150 and 230, on all three;Scripts/segment-spans.pycontiguous. Without the cutter reach, 25 and 22 of 40 segments of files 2 and 1 opened mid-GOP.aetherctl serve, frame number read from each picture): 169 backward steps before, 0 after. Software route (aetherctl swdecode --frames 300): 44 of 105 before, 0 after.cttsthat already repaired, partially missingctts, HEVC, healthy, interlaced).swift teston macOS 27: XCTest 801 passed; Swift Testing 5050 with 1 failure inIssue848AtmosActivatorPauseGraceTests(timing, unrelated, passes alone 3 of 3).Scripts/check-doc-links.pyclean. The all-traits configuration was not run locally.Checklist
CHANGELOG.mdupdatedfeat(...),fix(...),chore(...))internal)🤖 Generated with Claude Code