Releases: superuser404notfound/AetherEngine
Release list
6.59.0 - A VFW-carried track keeps the decoder's picture order
VC-1 in Matroska played with a permanent judder that no counter could see. Frame spacing was even, nothing was dropped, nothing arrived late, nothing was corrupt, and the pictures were still in the wrong order. This release finds why. Reported by @classicjazz (#407), who carried it through three rounds of instrumented captures across an Apple TV 4K, an iPad, an iPhone and a Mac Studio, on two independent hosts.
The container carries no presentation timestamps
A Matroska track written as V_MS/VFW/FOURCC has no PTS at all. matroskadec puts the block timecode on pkt->dts and leaves pkt->pts unset. That is the carriage every VC-1 remux uses, because VC-1 has no native Matroska mapping.
AetherEngine opens every source with fflags=+genpts, so libavformat fills the gap it finds. That reconstruction takes a following packet's DTS as this packet's PTS, which assumes decode order and presentation order are the same sequence. On a stream with B pictures they are not, and what comes out is a uniform pts = dts + one frame ladder: decode-order timestamps, monotonic and perfectly even.
libavcodec then hands its pictures out in presentation order while each one carries the timestamp of the packet it was decoded from, so a B picture wears the following P picture's time and the P wears the B's. SampleBufferRenderer sorts its reorder buffer by PTS, and thereby puts the pictures back into decode order. Motion steps forward, back, forward, back for the length of the title.
Why every instrument read healthy
enq counts decoder callbacks, disp counts layer handoffs, lost counts refusals, and layerDrop / delay / corr are the layer's verdict relative to the timestamps the samples carry. None of them can see a picture paired with the wrong timestamp. dpts cannot either: the timestamps genuinely are evenly spaced, which is exactly what it reports.
So this release also adds the instrument that can. SoftwareDecodeProbeResult.frameTimesSeconds records the decoded ladder in decoder output order, and aetherctl swdecode prints it with a backwards-step count:
aetherctl swdecode <file.mkv>
Measured on the WVC1 sample from samples.ffmpeg.org, remuxed with a plain ffmpeg -c copy:
| ladder in presentation order | steps backwards | |
|---|---|---|
| before | 0.040 0.120 0.080 0.200 0.160 |
49 of 98 |
| after | 0.040 0.080 0.120 0.160 0.200 |
0 of 98 |
Picture types across that run are I B P B P, so every B/P pair was transposed.
The fix, and the shape of its gate
Such a stream now has its invented PTS cleared, and best_effort_timestamp places the picture. That is the repair layer added in 6.38.0 for untimed frames, whose note at the time read "with genpts on it never fires".
The gate is an equivalence rather than a heuristic. matroskadec sets ms_compat and par->codec_tag out of the same VFW header, and no natively mapped Matroska track carries a codec tag: WVC1 is 0x31435657 on the VFW track and 0 on H.264 and VP9 in the same container. On a Matroska input codec_tag != 0 is therefore exactly the carriage that withholds PTS, and any PTS present on such a track was necessarily invented. video_delay > 0 narrows that to the streams where an invented axis can transpose anything.
H.264, HEVC and AV1 are held out on purpose. Those can stay on the native path, which is the one route where packets reach the fMP4 muxer, and that muxer refuses a timestamp-less packet outright. A wrongly ordered picture is a bad picture; a refused packet is no playback at all. #409 already covers the reordering defect on the native path.
What this release does not change
The keyframe flag on such a track really is unreliable, as reported: libavformat delivers 1451 of 1451 packets flagged as sync samples on the sample above. That happens in the parser path (matroskadec.c forces need_parsing on every non-HEVC video track, and demux.c then rewrites the packet flags), which runs after the seek index is built. The index itself is built from the real keyframe bit and is correct, and the file on disk is correctly marked in two independent places: 10 SimpleBlock keyframe bits and 10 Cues, matching the 10 decoded I pictures exactly.
Seeks land accordingly. aetherctl pktdump --at 20 on that sample lands at dts=15000, which is Cue #3 and a real I picture, because avformat_seek_file with min_ts = INT64_MIN derives AVSEEK_FLAG_BACKWARD on its own. So the software path is not entering mid-GOP here, and no pre-roll backoff was added.
That leaves one open question from the report: these measurements show the transposition from the first frame, not only after a reposition. If a title looks clean from a cold start and judders only after a seek, aetherctl swdecode on it is the thing to run.
Full diff: 6.58.0...6.59.0
6.58.0 - A Profile 8.1 is served as a Profile 5 where the display has no Dolby Vision
A display that cannot do Dolby Vision still receives a Profile 8.1 source as its HDR10 base layer with one static grade, while a Profile 5 source on that same display is composed by the Apple TV itself, per frame. The dynamic metadata sits in the file either way; only one of the two routes has ever read it. This release adds an opt-in that serves the 8.1 the way the 5 is served. Reported and diagnosed by @DrHurt (#455).
LoadOptions.forceDolbyVisionOnNonDVDisplay
Default off. On a display with no Dolby Vision, an HEVC Profile 8.1 source is routed like a Profile 5: dvh1 sample entry, container dvcC rewritten to dv_profile=5 / dv_bl_signal_compatibility_id=0 / el_present_flag=0, CODECS="dvh1.05.<dvLevel>" in the master, no SUPPLEMENTAL-CODECS. The video bitstream is not touched; what moves is the container's claim about it.
That claim holds up because a Profile 8.1 RPU already carries the mapping out of its own HDR10 base layer, so the composer does not need the container to describe that layer. Inert on a display that does Dolby Vision, where the existing dvh1.08.<level>/db1p supplemental already gets the panel the real thing.
Measured, not assumed
Dolby's browser test kit ships the same graded content as Profile 5 and as 8.1, which makes the P5 leg a positive control for what RPU application even looks like on a given panel. On an Apple TV 4K running tvOS 26.6 at a Samsung HDR10+ panel without Dolby Vision:
| stream | result |
|---|---|
| genuine DV Profile 5 | strobes |
| Profile 8.1, opt-in off (today's route) | no strobe |
| Profile 8.1, opt-in on | strobes, and looks like the P5 |
So the composition engages rather than the dvh1 track merely being tone-mapped as PQ. A first attempt without that control compared only off against on, on an arbitrary title, and found nothing: an A/B alone cannot separate "no effect" from "an effect that is small on this title", because what is visible is the distance between a title's dynamic metadata and its own static grade.
Locally, a genuine Dolby P5 and a masqueraded 8.1 are indistinguishable to AVPlayer in both the manifest and the init segment, so the packaging cannot be what a refusing composer objected to.
Why it stays opt-in
One panel and one OS version is what has been measured. A decoder that read the container's profile field instead of the RPU would decode an HDR10 base layer as IPT, which is a green / violet cast over the whole picture (#4, #176). Profile 8.1 only: Profile 8.4's base layer is HLG, and a profile-5 dvcC over an HLG colr is a container that contradicts itself.
Reports from non-Samsung displays without Dolby Vision are the thing this needs most. LG, Sony, Philips, TCL and Hisense sets are all unmeasured here, as are plain HDR10 panels without HDR10+.
Also in this release
One policy value decides the dvcC's fate, not three exclusive Bools. stripDolbyVisionMetadata, rewriteDoviConfigTo81 and "neither" were carried separately across the route, the producer and the muxer, with their mutual exclusivity stated only in doc comments; a caller that asked for two got whichever the muxer's if-chain reached first. They are now MP4SegmentMuxer.DoviConfigPolicy. Internal, but it turned up a real defect: the audio-route pre-flight probeWriteHeader copied the strip and not the rewrite, so for P7-on-a-DV-panel and the malformed "P8.6" it probed a header the session would never write.
Scripts/fetch-fixtures.sh survives a missing AV1 encoder. It hardcoded libaom-av1 under set -e, so on an ffmpeg that ships only libsvtav1 the AV1 step took the remaining eight fixtures down with it and left the corpus silently half regenerated.
Full diff: 6.57.1...6.58.0
6.57.1 - A rejoin's axis is stated by the playlist
A follow-up to the placement work in 6.56.8. That release moved a live rejoin's position into the manifest the fresh item loads, so the item never joins the edge to be corrected off it. Retested on a device on 6.57.0 (thanks again, @tschuegy): three of four seams read 0.000s and retired the correcting seek exactly as designed. The session's first swap did not, and it found a defect a layer below the one it was testing.
The seam that was correct and moved anyway
The item was blameless. Its first requests were the consumer's own segments, and it came up at the served TIME-OFFSET to the millisecond. The check moved it anyway, by 6.76 s, and every number the session published afterwards was computed the same way, so the log reported the place the viewer held while the picture ran 6.76 s ahead of it. Once per session, invisible to the session's own numbers.
What decided was the axis a live item's clock sits on, and that axis was a difference between two independently sampled quantities: the segment cache's resident floor and the item's own reported seekable start. It was latched for the item's whole life on the first tick that produced any number at all.
Fed the range of the item that just left, the difference collapses to exactly 0. That is not a coincidence of one capture: the item a session starts with has the session's own axis, so its seekable start IS the playlist floor, and floor minus floor is zero. Zero then reads as "this item has no offset", and the log line that would have named the axis is guarded on > 0.01, so a collapsed measurement and an absent one look identical in a field capture.
The playlist knew the answer
It computes the placement offset from the segments it lists, so the same build also states where the item's timeline begins. Both numbers were being thrown away.
They are now recorded when they are served. The readiness check compares against the value the playlist actually stated, which is a statement about content and needs no axis at all, and the axis itself is the stated one rather than a reconstruction of it. A statement overrules a measurement even when a tick got there first, because the serve and the engine's 100 ms tick are not ordered and a latch lasts for the item's whole life.
#454 the playlist this item loaded begins 45.00s into the session, so that is the axis it came up
on; stated by the manifest that placed it rather than measured off the cache afterwards
#454 the playlist already placed this item at its own 51.90s, 0.000s from the 51.90s the playlist
served; no correcting seek
The check now names its own provenance (the playlist served against the rejoin asked for), so a field log says which number decided.
Measured on the harness across both manifest routes, media-direct and behind a master: the stated axis is the same 45.00 s the measurement produced where the measurement was right, and it is fixed before readiness instead of depending on where a tick falls.
A mirrored range belongs to the item it was read from
Found while instrumenting the first defect, and independent of it. NativeAVPlayerHost reset seekableEnd on attach and left seekableStart carrying the retired item's window, and a KVO notification already in flight can land after a swap. Observed in a harness log as range=30.0..0.0 under the fresh item's generation. Both ends now reset together, and a reading is dropped unless it belongs to the item under the host.
What is not claimed
The device case did not reproduce on the harness: five freeze legs latched the axis correctly, so the window a real origin opens is wider than loopback's. The arithmetic is reproduced instead, in the tests, with the reporter's own numbers.
2409 tests in 328 suites, plus 606 XCTest. Freeze legs green on both manifest routes; tvOS simulator build green.
Full changelog: 6.57.0...6.57.1
6.57.0 - The FFmpeg frameworks carry an Aether prefix
The engine can now sit in an app that already has an FFmpeg. Nothing about playback changes: same n8.1.2 binaries, same decoders, same paths. What changes is what the FFmpeg modules and frameworks are called, and that is what an app with a second player was blocked on.
The collision
Every FFmpeg packaged for Apple platforms declares the same six target names, and SwiftPM target names are unique across the whole dependency graph. An app whose player keeps KSPlayer, mpv or MobileVLCKit as a rung could therefore not resolve a graph holding this engine at all:
error: multiple similar targets 'Libavcodec', 'Libavfilter', 'Libavformat' and 3 others
appear in package 'ffmpegbuild' and 'ffmpegkit'
Neither remedy the message offers applies. moduleAliases renames Swift source targets, not binary ones, and a mirror would substitute one FFmpeg for the other, which is wrong when one has no network stack and the other is a full build.
Behind it sat a second collision: two Libavcodec.framework bundles cannot both live at App.app/Frameworks/ under one @rpath/Libavcodec.framework/Libavcodec.
What changed
FFmpegBuild 3.0.0 renames the targets, products, framework bundles, install names and module maps to AetherLibavcodec, AetherLibavformat, AetherLibavutil, AetherLibswresample, AetherLibswscale, AetherLibavfilter, AetherLibdav1d, AetherLibzimg, AetherLibzvbi, under the umbrella product AetherFFmpegBuild. The engine imports those.
For hosts this is a no-op. The public API never exposed an FFmpeg type, so no signature moves. Only a host that imports Libav* itself has a line to change.
The FFmpeg C API is untouched. avcodec_open2 is still avcodec_open2.
The case a rename cannot cover
Two dynamic framework sets coexist on their own: the two-level namespace records per reference which dylib a symbol came from, so the engine reaches its libavcodec and the other player reaches its own, in one process.
A static FFmpeg in the same executable does not work that way. Its symbols become definitions inside the executable and win for everything linked beside them, the engine included. docs/api.md › One FFmpeg now carries the recipe: link the engine into a dynamic framework of your own, and its _av* bind at that framework's link instead.
The prefix also makes the diagnostic answerable. From an app built against this release:
$ nm -m Sodalite.debug.dylib | grep _avcodec_open2
(undefined) external _avcodec_open2 (from AetherLibavcodec)
That line used to say Libavcodec and proved nothing about whose it was.
Upgrading
Take the release and rebuild. If you carried the rename as a private fork of FFmpegBuild, this is that fork upstream: drop it and take the pin.
swift test: 2404 tests, 328 suites, 0 failures. Sodalite builds and links the prefixed frameworks end to end on tvOS.
Reported by KIPTV.
Full diff: 6.56.9...6.57.0
6.56.9 - The served placement names itself
A follow-up to 6.56.8, and the line its retest needs. Take this one rather than 6.56.8: it carries the same fix plus the witness for it.
The served placement names itself (AE#454)
6.56.8 puts a rejoin's placement into the manifest the fresh item loads. A field log could see the engine ARM that placement and could not see whether the playlist actually offered one, or at what depth, which is the difference between "the tag was not served" and "the client ignored it".
The served tag now says so, and the pair is self-checking: the offset the server writes and the position the fresh item reports at readiness are the same number.
#454 placing the fresh item at 81.70s in its own playlist: segment 15 + 4.95s (output 80.27s)
#454 serving #EXT-X-START:TIME-OFFSET=50.143,PRECISE=YES for segment 15 + 4.95s, 24 segment(s) listed from seg6
#454 the playlist already placed this item at its own 50.14s, 0.003s from the 50.15s the rejoin asked for; no correcting seek
Bounded by the arm: off on every build except the ones between a rejoin swap and the item it placed running, so an ordinary live session logs none of it.
swift test: 2404 tests, 328 suites, 0 failures.
Full diff: 6.56.8...6.56.9
6.56.8 - A rejoin places the item in its playlist
A rejoin after a live outage landed on the place the viewer held, thirteen times out of thirteen, and played somewhere else on the way there every single time. That gap is not a timing problem, it is a missing instruction.
A rejoin places the item in its playlist (AE#454)
A rejoin is two operations, attaching an item and placing it, and only the first was ever stated to AVPlayer at the swap. The item went out with startPos=nil, so it did what a live playlist tells any client to do, joined at its own edge and started playing there, and the place the viewer held arrived afterwards as the deferred seek:
17:15:07.626 #446 the window ran out and the source is delivering again; swapping the item ... at 24185.75s, the place it held
17:15:07.717 replaying deferred pre-ready seek to 24185.75s (#127)
17:15:07.718 CTRL enginePhase=seeking t=24203.18 <- 17.4 s ahead of the held place
17:15:07.876 seek#4 programmatic landed rendered=24185.75
Reported from an Apple TV on a starving origin (thanks to @tschuegy for the capture and the table): thirteen swaps, every rejoin correct, every one of them 4 to 37 s ahead of the held place for 140 to 220 ms first. With a seam every 25 to 40 s, a channel that recovers perfectly reads as a channel jumping around.
The playlist is ours, and HLS has a tag for exactly this question. The placement now goes into the manifest the fresh item loads, so it never joins the edge to be corrected off it:
#454 placing the fresh item at 81.70s in its own playlist: segment 15 + 4.95s (output 80.27s)
#454 the playlist already placed this item at its own 50.14s, 0.003s from the 50.15s the rejoin asked for; no correcting seek
Measured on the harness with one instrument across both arms, same seed, same leg:
| before | after | |
|---|---|---|
| fresh item's FIRST request | seg26, eleven above the consumer's seg15 | the segment the consumer was on |
| reported position across the hand-off | 50.27 s above the held place | never left it |
| correcting seek | 158 ms, always | retired (0.003 s from the target) |
Three things make it hold rather than merely work. The arm names content, a segment index plus an offset into it, because the window slides between arming the placement and the fresh item fetching the playlist that carries it, and a segment index does not renumber; the served TIME-OFFSET is recomputed on every build. Every term is taken at the resolution the playlist serves, since an item's timeline is the sum of the printed #EXTINFs and accumulating raw doubles drifts away from the playlist the item is reading. And the deferred seek stays, as the fallback for a client that ignores the tag or honours it only to a segment boundary, so the failure mode of this change is the behaviour it replaces.
Verified on both manifest routes. A live session behind a master playlist, which is what a real host gets for any session with a subtitle track and, on tvOS, for every HEVC one, honours the variant's placement exactly as a media-direct one does.
An item's axis offset was measured on the item it was measured on (AE#454)
Reported alongside, and the more general defect. The offset is latched per item and re-measured when the item under the host changes, but the re-measurement needs the fresh item to have reported a seekable range of its own, and for the first ~150 ms after a swap it has none. Until then the retired item's offset was folded into the fresh item's clock, which reads about zero, so the session published the retired item's zero as its position: 70 to 80 s below the place it held in the field, 80.27 s below it on the harness. It flowed straight into the live window's edge, which is a running maximum.
A number nobody can read is not a reading. The published playhead and the live window now hold across the hand-off, bounded by the placement rather than by readiness, so whether a hand-off is reported does not depend on where a 100 ms tick happens to fall inside it. A cold join publishes exactly as before.
The hold is not the fix, and it could not have been. Measured with the placement disabled and the hold left on: the session reported the held place throughout while the item demonstrably played 50 s further forward. Pinning the clock would have made the log agree with itself and the picture disagree with both.
Harness
The freeze leg's own loop ticks at 1 Hz, which is the cadence the whole hand-off lives inside, so it could only ever find the session already settled and call the run healthy. It now samples the reported position at 20 ms between the swap and the placement landing, and names the fresh item's first request, which is a statement about content that no sampler can miss and no clock policy can dress up.
aetherctl live --force-master routes a live leg behind its master playlist. Every live leg so far ran useMaster=false, so a question about how a client treats what the manifest says had only ever been asked of the manifest a device usually does not read.
swift test: 2404 tests, 328 suites, 0 failures.
Full diff: 6.56.7...6.56.8
6.56.7 - A composition lands on the base, not on the axis
The retest of 6.56.6 came back with every previous failure mode gone and one frame left over on each correction. That frame is not noise: it is where a composition lands.
A composition lands on the base, not on the axis (AE#418)
6.56.6 measures the base AVPlayer composed onto out of the item's own loaded ranges and adopts it. The reporter's three corrections then all carried the same residual, on a 23.976 fps asset, 1450 s of media apart, constant rather than growing:
seg125 residual +0.041s
seg197 residual +0.042s
seg489 residual +0.042s
The fixture pair says what it is. Scripts/timecode-fixture.sh writes two clips that differ only in -bf 3, and the same arm on both:
| fixture | placement 2 lands at | picture reads |
|---|---|---|
tc-bframes.mkv |
item 61.083 | axisErr=-18.083 |
tc-drought.mkv |
item 61.000 | axisErr=-18.000 |
The composition predicted 61.000 on both. The difference is the gating sample's own presentation lead, and the rule behind it is that the two placements are not the same kind of event: the FIRST placement into an item's timeline puts the segment's first PRESENTED sample at its advertised start (measured base 0.000 on every arm of the fixture), and every later one puts its first DECODED sample there instead. So a composition lands one lead under the axis it composes onto, on every epoch of a B-frame source, once per composition.
The producer now publishes that lead (pts - dts of the gating sample) alongside the shift, and the composition subtracts it:
video gate open: actual=42917 anchorPts=43000 target=52000 ... presentedShift=-9000 lead=83
#418 seg13 placed (advertised 52.000s, worth -9.000s, lead 0.083s): axis shift -9.000s -> -18.083s from item 61.083s
#418 seg13 placement confirmed: AVPlayer holds it from item 61.083s, base -9.083s as published
Where 6.56.6 printed a correction on every second placement, both now print placement confirmed, 3 of 3 runs, and the picture is unchanged at -18.083. On a source without frame reordering the lead is zero and nothing about the arithmetic moves.
What it pays for is the placement that cannot be measured at all. A seek burst that reopens backwards inside the buffer leaves the reading inadmissible by design (a run whose start walked down is that run backfilling), so the composition is all the session has. Measured on that arm, against the picture:
| compositions | 6.56.6 kept | 6.56.7 keeps | picture |
|---|---|---|---|
| two | -23.083 | -23.166 | -23.166 |
| three | -27.000 | -27.166 | -27.166 |
One lead per composition, which is why an unmeasured chain drifted and a measured one did not.
Repaired: two fixture-backed axis tests
Issue259A53CaptionAxisTests and Issue260FrameTimeAxisTests have been failing since 6.56.6. Both guarded against a vacuous witness by requiring playlistShiftSeconds != 0, and 6.56.6 made that number state where AVPlayer PLACES a segment rather than what the muxer wrote, which is 0 on a clip started at its own beginning however far the output axis moved. They now read the separation off the muxed pair itself, which is the quantity they were always about.
swift test: 2394 tests, 327 suites, 0 failures.
Full diff: 6.56.6...6.56.7
6.56.6 - A measurement that may only agree is still a prediction
One retest, three failure modes, and one root cause behind all of them: the check that was supposed to measure the axis was only ever allowed to agree with the prediction it was checking.
A measurement that may only agree is still a prediction (AE#418)
6.56.1 stopped predicting the VOD axis and started reading it out of AVPlayerItem.loadedTimeRanges: the range holding the playhead begins where AVPlayer PLACED that run, so advertised - rangeStart inverts the placement. That part works, and the retest caught a real error with it:
seg372 placed on base -6.173s, not -14.556s (AVPlayer holds it from item 1494.876s): axis -14.556s -> -6.173s
What it then did with the reading is the defect. The measured base was collapsed onto the nearest axis the session had already published, which makes the prediction the yardstick for the measurement meant to check it. Two ways that fails, both measured in the field on a Mac Catalyst and an Apple TV 4K over the same asset:
A reading that matched no prediction was thrown away. Two readings 400 s of media apart both said -10.93 while the session composed its way out to -26.152. The Apple TV ended at -45.045 against a measured -2.411, a 42.6 s error, permanent until playback restarted: audio out of sync, audio muting for many seconds at a time, subtitles far behind.
A reading one or two frames off the prediction was called a confirmation, so that difference stayed in the axis and the next placement composed on top of it. Six confirmations walked the error 0.000, 0.083, 0.125, 0.209, 0.283, 0.290, past a 0.25 s tolerance the first four had already spent 84 percent of, after which every reading was refused for the rest of the session.
The reading is now the axis. What decides whether it describes THIS placement is where it came from, not whether it agrees:
- The baseline is what the item held when the placement was recorded, which is before AVPlayer can have the bytes. A range whose start the baseline already reported is the run that was already there; its start inverts to an OLDER placement's base, and reading it was what produced the false confirmations.
- A start that walked DOWNWARD is that same run backfilling, which AVPlayer does after a run opens. Measured in the retest: a run that opened at 1522.6 read 1507.1 fifteen seconds later.
- A run that opened ABOVE the baseline, or disjoint from it, cannot be either, and that is the one this reads.
A placement counted twice across a producer restart is then undone by the next reading rather than carried, and a placement superseded before its window closes says so instead of falling silent.
Log vocabulary, all under [HLSVideoEngine]: #418 segN placement confirmed, #418 segN placed on base Xs, not Ys (... residual Zs), #418 segN opened no run of its own to measure, #418 segN superseded before it was measured.
The gate's offset is measured on the sample that is PRESENTED (AE#418)
Found while reproducing the per-placement residual, and it is a defect on its own. The gate published its offset from the first packet's DECODE time. A segment opens on a random-access point in decode order, and with B-frames that sample is presented video_delay frames after it is decoded, so the published axis sat that far under the truth on every epoch of a B-frame source, which is most real content.
The gate's own line carried both numbers all along, and the segment bytes agree:
video gate open: actual=42917 anchorPts=43000 target=52000 ... presentedShift=-9083
tfdt baseMediaDecodeTime=686672, sample0 composition offset 1328 (timescale 16000) = 0.083s
Scripts/timecode-fixture.sh now writes a second fixture with B-frames (tc-bframes.mkv), identical to the existing one in every other respect, so the pair isolates exactly this. Mean capErr per axis over 39 ticks of the two-epoch arm, twice:
| arm | epoch 1 | epoch 2 |
|---|---|---|
| before | +0.113 s | +0.113 s |
| after | +0.031 s | +0.031 s |
| same fixture without B-frames | +0.030 s | +0.030 s |
+0.030 is the picture probe's own quantisation, so both epochs now read the truth. The second epoch is also where the two halves of this release meet: the composition predicts -18.000 and the reading corrects it to -18.083, which 6.56.1 would have called a confirmation and discarded.
The three documented control arms on the fixture without B-frames are unchanged (-9.000, -18.000, -14.000).
A resampler reads a frame per what it was built for (AE#452)
A live transport-stream splice from 5.1 to stereo left a long-lived SwrContext reading six planes from a frame carrying two, and the read past the end of the frame's plane array crashed the session at the program boundary. The configuration a resampler was built from is a claim about every later frame, so it is re-checked per frame and the context rebuilt when it no longer holds, at all three sites that keep one.
Fix contributed by @tschuegy in #453, hardened across the remaining sites here.
swift test: 2388 tests, 327 suites, 0 failures.
6.56.5 - An item's zero is where its playlist began
One report about a seek that was refused, and underneath it two things the refusal had been standing in front of. The last of them is not about rejoins at all: it is about which timeline a live item is even on.
A live item's zero is where its playlist began, not where the producer began (AE#446)
AVPlayer places a live playlist's content by the PLAYLIST it was handed, so an item's timeline starts at the first segment that playlist listed. For the item a session starts with, that is the producer's first segment and the two axes are the same one. For any item attached after the window has slid, which an in-place swap does routinely, it is not.
Three readings that had only ever been available separately, on one line:
#446 placement audit: item clock 70.01s in item range 0.00..42.00s, shift 1.40s
-> session 71.41s; the producer holds 51.40..111.40s
An item clock reading 70.01 while its own seekable range ends at 42.00 is not one timeline. The published playhead said 71.41 while the picture was at 121.4, and the rejoin to the place the viewer held aimed 50 s past it, onto the live edge, skipping the whole outage backlog. Every DVR leg the harness has ever run passed only because an 1800 s window does not slide inside a single run.
The offset is measured rather than assumed, and it verifies itself: one rule sizes both the playlist's first visible segment and the cache's eviction (LiveWindowSizing), so the producer's floor and the item's floor slide together and their difference holds still.
| sample | producer holds | item reports | difference |
|---|---|---|---|
| +1.5 s | 51.40..116.40 | 0.00..42.00 | 50.00 |
| +6.7 s | 56.40..116.40 | 5.00..47.00 | 50.00 |
| +13.0 s | 66.40..126.40 | 15.00..57.00 | 50.00 |
It is latched per item, because it is a property of the playlist that item loaded, and re-measured when the item under the host changes. It reads 0 for the item a session starts with, so a live session that never swaps an item is unchanged by this release. Folded into the four conversions that cross the two axes: the published playhead, the live edge, a seek's landing, and the sampled edge a host scrub clamps against.
Harness verdict, live-only freeze leg with a 28 s outage, judged in segments:
before: consumed through seg13, resumed into seg18, 4 skipped, advanced 14.00s
VERDICT: POSITION LOST
after: consumed through seg13, resumed into seg11, 0 skipped,
re-fetched 3 below (lookback allows 3), advanced 71.80s
VERDICT: position held
The engine's own rejoin is not the host's scrubber (AE#446)
seek(to:) refuses a live seek on a session with no DVR window. That is defence-in-depth for a host that draws a scrubber it should have hidden, and it was reported by a client that keeps its rewind outside the engine and therefore loads with dvrWindowSeconds nil: the outage swap carried the place the viewer held, and the replay of that position was rejected before it could land.
The rejoin was never party to that contract. It picked its position out of content this same session cut and served. A seek now carries its origin and only a host scrub is refused. seekableLiveRange still reads nil on such a session, so nothing a host is told has moved.
The reporter's own suggestion, carrying the position in as the reload's start position, was not taken: LiveReloadPolicy.skipInitialSeek exists because of a device-verified wedge, where a start seek against a re-served backlog parked the reloaded item in waitingToPlay for the rest of the session.
A rejoin's landing is measured against what the producer holds (AE#446)
Lifting the refusal alone made things worse, which is worth stating because it is also why the reported case looked benign: the carried 71.40 s then landed at 43.40 s, four segments below the consumer's own last fetch.
At the moment a rejoin runs, neither edge the engine publishes is true. LiveWindow.edgeTime is a running maximum that an outage freezes BELOW the playhead that legitimately ran past it, and a freshly swapped item's seekableEnd is a range it has not finished reporting. The resident range is sampled at the seek instead (residentCeilingOutputSeconds is the other end of AE#441's floor), because the only thing that can disqualify a position the session itself served is eviction.
Added
NativeAVPlayerHost.seekableStart, the start of the item's seekable range. Only the end was ever mirrored, which follows a live edge and cannot answer whether a position is inside the item at all, which is the whole question a rejoin asks.aetherctl live --live-onlyloads with no DVR window. Because live-only retention is a sliding 60 s, it is the only arm in which an item's own axis is observable at all.- A bounded placement audit after a swap, printing the item clock, the item's range, the shift, the offset and the producer's range together. That these were only ever available separately is why an item's clock and an item's range could disagree for a whole investigation without anyone being able to say so.
Thanks to @cmcpherson274, who flagged the refusal as an interaction rather than asserting it as a defect. Asserting it would have got the refusal lifted and nothing else, and the refusal was the least of it.
Full notes: CHANGELOG.md · 6.56.4...6.56.5
6.56.4 - A declared ceiling is false while a lower one is silent
Two reports about a ceiling that was not where it was declared, and one bookkeeping record that outlived the thing it described.
A third concurrent reader against one origin was parked, not slowed (AE#450)
Reported from four live tiles against one Jellyfin origin: tiles 3 and 4 received zero bytes while three concurrent curl pulls of the same endpoints flowed at full rate, on the simulator and on an Apple TV 4K.
The anchor the reporter named was exact. httpMaximumConnectionsPerHost = 2 on the reader's persistent session, and because that session is a static let, those two connections are the whole PROCESS's allowance to one origin, not one surface's. The pump, the subtitle side reader (subtitleSideDemuxer inherits avioPrefetch: true, so it is on the same path), the forward prefetcher and one more pump per playback surface all draw on the same pair. One surface with a side reader running can already hold both.
Reproduced in isolation before the reader was touched: three open-ended bytes=0- requests against a local origin that logs every request it receives. The origin sees two. The third is not refused and not slowed, it never arrives.
On the BOUNDED pool that number throttles, because every request on it ends, so a request that waits there waits for one that is finishing. On the open-ended pool nothing ahead of the third request is going to end, so URLSession parks it with no callback, no error and no metrics, the open spends its full 15 s deadline in awaitFirstPersistentData, and the load reports a source that will not open for a queue the engine built itself.
None of the three proposed fixes was taken literally. A session per instance gives up connection reuse across the reader's own 32 MB ranges, making every one of them a fresh TLS handshake, which is why the pool is shared at all. A LoadOptions knob for connections-per-host cannot work, because the session is built once per process and the first load to touch it would fix the value for every load after it. The knob that does work already exists: LoadOptions.maxConcurrentSourceRequests (AE#377) counts requests rather than connections, which is what an origin meters and the only thing that means anything over HTTP/2, it waits with a budget, it says that it waited, and it lowers itself when the origin answers 429/503/509. The long-lived pool no longer caps; the request budget is the only ceiling. nil there is documented as "counts but does not cap", and a lower ceiling underneath it made that untrue from the third concurrent open-ended read on.
One thing worth knowing before reading a trace this way: httpMaximumConnectionsPerHost bounds nothing over HTTP/2, since URLSession multiplexes a session's requests onto one connection. The engine records the negotiated protocol once per origin (grep 'origin transport'); the reporter's Jellyfin answered http/1.1, where the cap binds exactly.
A connection that never delivers a first byte says so (AE#450)
The only witness for a connection that delivers nothing was armed at the stall threshold, 20 s, which is longer than the arcs that give up on a source first. The one outcome it could never describe was the one where the first byte never comes, and that case therefore could not print anything about itself.
A generation that has been on the link with nothing delivered is now reported at a quarter of the threshold, capped at 5 s:
[AVIOReader] pump gen=1 no first byte after 5.0s at offset 0; 3 request(s) open to this
origin, transport pool allows 64 connections per host
The line carries the pair that separates the causes: a request parked in the transport and an origin sitting on a request are identical from every vantage point downstream, and they take different fixes. It reports, it never acts, so nothing about when a connection ends has moved.
A concurrent session's segment cache is no longer swept away for being an hour old (AE#451)
Reported as a code read, and reproduced before anything was changed: with a sibling's directory backdated past the cutoff, constructing a second SegmentCache deletes the running session's directory and every segment in it.
A session directory's creation date is when the session STARTED, so at an hour in, a live session and a crashed one read identically. Each cache now holds an flock(2) on <sessionDir>/session.lock for its whole life, and the sweep skips a candidate whose marker is still held. flock rather than fcntl: flock locks belong to the open file description, so a second cache in the SAME process is refused too, which is the reported case. The kernel drops the lock when the process dies, so a crashed sibling sweeps on age exactly as before, and a directory with no marker is decided by age as before.
The reporter proposed an in-process registry. That is not enough: a non-sandboxed process shares NSTemporaryDirectory with its siblings per USER, not per process, so a second aetherctl run is a sibling the registry cannot see.
A segment the cache no longer holds stops being advertised as one (AE#451)
The second half is the failure mode, and it is worse than a re-fetch. The bookkeeping outlived the file: entries[index] still named a deleted segment, so the server took the file path, stat'ed nothing, and answered 404 for an in-range VOD index. AVPlayer treats that as terminal loadFailed, not as something to fetch again. The AE#50 in-range rule lived only in the data path.
An entry now stops answering where it is redeemed (peekURL / fetch drop it, and the byte ledger with it), the file path is bound to the same classification as the data path (in range is 503, never 404), and a store into a directory that vanished restores the directory instead of leaving the session permanently unable to write.
Thanks to @cmcpherson274 for both reports. The three-curl control on AE#450 is what made the origin falsifiable as the cause, and the second was a defect found by reading rather than by hitting it.
Full notes: CHANGELOG.md · 6.56.3...6.56.4