diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e648d624..f925802d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: test-macos: name: swift test (macOS) runs-on: xcode-27 - timeout-minutes: 30 + timeout-minutes: 50 env: AUTHORIZATION_TEST_SUITES: 'RefreshableHLSAuthorizationTests|RefreshableSubtitleAuthorizationTests|LiveTrustEvaluatorTests' steps: @@ -67,6 +67,21 @@ jobs: LIBDISPATCH_COOPERATIVE_POOL_STRICT: "1" run: swift test --skip-build --disable-xctest + # The optional components (TrueHDObjectAudio, DTSXObjectAudio, DualLayerDolbyVision) are off by + # default, so every step above builds and tests without them. This build and run keep the opt-in + # code compiling and its tests running. Steps in this job rather than a job of their own, to stay + # within the account's concurrent macOS runners. + - name: swift build (all traits) + run: swift build --enable-all-traits --build-tests --scratch-path .build-alltraits + + - name: swift test (all traits) + run: swift test --enable-all-traits --skip-build --scratch-path .build-alltraits + + - name: swift test (all traits, one-thread cooperative pool) + env: + LIBDISPATCH_COOPERATIVE_POOL_STRICT: "1" + run: swift test --enable-all-traits --skip-build --disable-xctest --scratch-path .build-alltraits + - name: Software VOD packet-cache regressions run: | bash Scripts/test-software-packet-coverage.sh @@ -86,10 +101,19 @@ jobs: - name: build the transport probe (AE#377) run: swift build --build-tests --package-path Probes/TransportProbe + # The pulse test's scripts are Python, shell and Swift nobody runs in CI; a syntax error in one is found + # at the television. Compiling them is all that can be checked without a phone recording. + - name: Compile the Dolby Vision pulse scripts + run: | + python3 -m py_compile Probes/DolbyVisionPulse/Scripts/*.py + zsh -n Probes/DolbyVisionPulse/Scripts/build.sh + zsh -n Probes/DolbyVisionPulse/Scripts/run_tv.sh + swiftc -typecheck Probes/DolbyVisionPulse/Scripts/dump_attachments.swift + build-tvos: name: xcodebuild (tvOS + visionOS Simulator) runs-on: xcode-27 - timeout-minutes: 30 + timeout-minutes: 45 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -127,6 +151,33 @@ jobs: -derivedDataPath .build/xcode-probe \ CODE_SIGNING_ALLOWED=NO + # The Dolby Vision pulse host is the other device harness, and the one that links the engine + # itself, so it is also the one that breaks when a public API it uses moves. It plays clips on an + # Apple TV that someone films, runs nowhere in CI, and would otherwise be found out by the person + # who needed a measurement that evening. It links the engine through PulseEngine/, which enables the + # DualLayerDolbyVision trait the host measures. EXCLUDED_ARCHS for the reason given above: an app cannot + # link the x86_64 slice. + - name: Build the Dolby Vision pulse host (Probes/DolbyVisionPulse) + run: | + xcodebuild build \ + -project Probes/DolbyVisionPulse/Device/DolbyVisionPulse.xcodeproj \ + -scheme DolbyVisionPulse \ + -destination 'generic/platform=tvOS Simulator' \ + -derivedDataPath .build/xcode-pulse \ + EXCLUDED_ARCHS=x86_64 \ + CODE_SIGNING_ALLOWED=NO + + # xcodebuild has no flag for a package's traits, so the opt-in components reach the device SDKs + # through Probes/AllTraits, a consumer package that enables all of them the way a host does. + - name: Build with every optional component for tvOS + working-directory: Probes/AllTraits + run: | + xcodebuild build \ + -scheme AetherEngineAllTraits \ + -destination 'generic/platform=tvOS' \ + -derivedDataPath ../../.build/xcode-alltraits-tvos \ + CODE_SIGNING_ALLOWED=NO + # #344: the package has declared visionOS since 6.0.0 and nothing here compiled for it, so an # availability list that named tvOS/iOS/macOS and then fell through to `*` broke the visionOS # build silently. `*` resolves to the declared visionOS floor (1.0), so any API newer than that @@ -144,7 +195,7 @@ jobs: build-ios: name: xcodebuild (iOS Simulator) + release aetherctl runs-on: xcode-27 - timeout-minutes: 30 + timeout-minutes: 45 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -165,6 +216,19 @@ jobs: - name: swift build -c release (aetherctl) run: swift build -c release --product aetherctl + - name: swift build -c release (aetherctl, all traits) + run: swift build -c release --enable-all-traits --product aetherctl --scratch-path .build-release-alltraits + + # See the tvOS job: the opt-in components for the iOS device SDK, through a consumer package. + - name: Build with every optional component for iOS + working-directory: Probes/AllTraits + run: | + xcodebuild build \ + -scheme AetherEngineAllTraits \ + -destination 'generic/platform=iOS' \ + -derivedDataPath ../../.build/xcode-alltraits-ios \ + CODE_SIGNING_ALLOWED=NO + # The docs site (aetherengine-website) validates every heading anchor at build time, but it # lives in another repo and only builds AFTER a push to main, so a broken anchor ships first # and is found second: `docs/api.md` linked `formats.md#dolby-vision` against a heading that diff --git a/.github/workflows/release-dmg.yml b/.github/workflows/release-dmg.yml index a0d03371c..799f4d73d 100644 --- a/.github/workflows/release-dmg.yml +++ b/.github/workflows/release-dmg.yml @@ -46,7 +46,9 @@ concurrency: jobs: build-and-upload: name: Build + notarize + upload .dmg - runs-on: macos-15 + # Same image as ci.yml: macos-15's latest-stable is Xcode 26.3, whose Swift 6.2 timed out + # type-checking FelResidualMeasure and failed the 7.37.0 DMG while CI on Xcode 27 was green. + runs-on: xcode-27 timeout-minutes: 30 steps: # workflow_dispatch checks out the ref picked in "Use workflow from" @@ -56,9 +58,16 @@ jobs: with: ref: refs/tags/${{ github.event.release.tag_name || inputs.tag }} - - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 - with: - xcode-version: latest-stable + # The packaging script follows the workflow, not the tag: the sources it builds are the tag's, + # but a re-run for an older tag needs today's script on today's runner (7.37.0's still looked + # for the binary where Swift 6.2 put it). + - name: Packaging script from the workflow's commit + run: | + git fetch --no-tags --depth=1 origin ${{ github.sha }} + git checkout FETCH_HEAD -- Examples/DemoPlayerMac/Scripts/build-dmg.sh + + - name: Show toolchain + run: xcodebuild -version - name: Resolve tag id: tag diff --git a/CHANGELOG.md b/CHANGELOG.md index 9856fdfa5..d85043d52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,13 +26,6 @@ the public-API contract. ### Fixed -- **A live GOP longer than the sealed target duration no longer breaks the playlist.** RFC 8216 keeps - `TARGETDURATION` fixed for a playlist's life, so a later segment above it made the playlist invalid - for AVPlayer (-12642). The engine now retires that playlist URL with its original target and an - end index before the long segment, and serves the same cache under a new `live-N/` namespace with - a fresh seal; the consumer item swaps to it in place, keeping source, cache, position (also behind - live) and play/pause intent. A media playlist failure no longer counts as evidence against the HDR - master (#748, thanks @yucelokan). - External subtitles on a remote HLS source no longer appear early when the origin's media timestamps do not start at the playlist's time 0. The WebVTT renditions the engine injects (#316) carried no `X-TIMESTAMP-MAP`, so AVPlayer pinned cue time 0 to timestamp 0, and an ffmpeg MPEG-TS transcode cut with `-copyts -max_delay 5000000` showed every cue 10 s early. The engine now reads the first media timestamp of the segment the load opens on, off the load path, and serves the renditions with a matching `X-TIMESTAMP-MAP`; an origin already on its playlist timeline, one whose segment starts before its playlist slot (a transcode restarted at the previous keyframe, which AE#616 corrects), or one it cannot read, keeps the plain WebVTT. - After a seek or a resume into densely typeset ASS (signs re-timed on every frame, several hundred events a second), embedded subtitles stayed missing until the passage ended. The drain decoded at most 256 packets per 0.5 s tick from 15 s behind the playhead, slower than such a passage plays, so it never caught up. A text track's tick now decodes through the playhead plus 12 s whatever the cap (`subtitleDrainUrgentLeadSeconds`, at most `subtitleDrainMaxCatchUpPacketsPerTick` packets); bitmap and teletext tracks keep the steady cap. Text cues from one tick are merged into the retained cues in one pass instead of one insert each, so a large batch after a seek back stays linear. - The #362 harvest-hole hold no longer stops the drain at the pump's edge when the forward prefetch has already read past it. Re-harvested packets take a fresh sequence, so the pump re-reading behind the prefetch broke the harvest order at whatever position it had reached, and that moving seam renewed the hold every tick and kept the drain a few seconds ahead of the playhead instead of its 60 s lead. An order break over a span that the prefetch's current run has read since its first packet, and that the store still holds, is a seam, not a hole. Otherwise the hold works as before. @@ -64,6 +57,478 @@ the public-API contract. - Rapid audio-track selections coalesce into serialized rebuilds. Stop/load invalidate queued work, and play/pause commands received during a rebuild supply its final transport intent. Native handover retains the old item until replacement unless media services were reset. - A `play()` or `pause()` that arrives while any session-preserving rebuild runs (an audio or disc-title switch, `reloadAtCurrentPosition()`, an engine-raised rebuild) now decides the transport it comes back in; before, only an audio pick honoured it and the others came back in the state they started from. +## [7.40.1] - 2026-10-18 + +### Fixed + +- **TrueHD Atmos that starts after the probe window is rendered again.** A track whose first packet + lies beyond `find_stream_info`'s budget (John Wick: Chapter 2 carries TrueHD from 22 s, 45.8 MB + into the file) kept an unknown profile, so `objectAudioRendering` played it through the channel + bridge. With rendering requested and the profile unknown, the engine now reads the track's major + sync where the first producer starts, the same test mlpdec applies (four substreams, top bit of + `substream_info`), and renders it when it is Atmos. Costs 679 ms and 44.7 MB of held packets from + the start of that file at the tvOS probe budget, 30 ms on a resume; nothing for any other track. + +## [7.40.0] - 2026-10-18 + +### Changed + +- **TrueHD Atmos is built from the stream's own channel presentation.** With `objectAudioRendering` + in a `TrueHDObjectAudio` build, every output is now a share of the 7.1 (or 5.1) bed Dolby renders + the same mix into, split per frequency bin between floor and heights by where the objects' energy + lies (`BedSplitter`). No object signal is mixed into an output; the objects are decoded without + their matrix interpolation (LibTrueHD 3.0 no longer compiles the interpolating decode at all) and + taken as points at their positions, without size, snap, zones, screen anchoring, the elevation + switch or metadata ramps. Measured against the previous exact render through Apple's APAC decode on + 72 windows of six films, 0.35 % of the energy lands on another speaker (median of the windows with + heights), Dolby's Unfold demo 3.3 %, the heights within about a dB. A 5.1 bed now covers a 7.1 + layout's rears as well as its sides, and the split's 2048-frame latency is drained at the end of + input and before every encoder restart. The exact render it is measured against is only built with + the measurement-only `TrueHDReferenceRender` trait (#731). +- **DTS:X plays its standard presentation only, into 7.1.4 or 9.1.6.** With LibDTSX 3.0, the heights + are removed from the 7.1 bed by the standard's own constant (each at -3 dB in the speaker under it) + instead of the gains the stream's metadata states, which is no longer read; on twelve DTS:X tracks + every frame stated exactly that fold, so the output is bit-identical. The alternate presentations, + which carry objects (some IMAX-labelled streams), are refused and play as 7.1, and DTS:X is rendered + only into the two layouts that carry the bed and the four heights one to one; any other layout + keeps the channel bridge. The object position mapping and the fold renderer are gone (#744). +- **The documentation no longer says TrueHD Atmos objects are always lost.** The Known limitations + entry in `docs/formats.md`, the `AudioDelivery.bridged` reference and the channel bridge's notes + predate `objectAudioRendering` (#731) and said objects and heights never survive. They now say the + channel bridge drops them and `objectAudioRendering = .apac(layout)` keeps them, in a build with the + `TrueHDObjectAudio` / `DTSXObjectAudio` traits (off by default since 7.39.0), rendered into a speaker + bed as lossy APAC that tvOS sends to an Atmos receiver as Dolby MAT. No behaviour change (#802, + thanks @aravindhsampath). + +### Added + +- **`LiveTelemetry.objectAudio`: how many Atmos objects play, and how many overhead.** While + `objectAudioRendering` plays TrueHD Atmos or DTS:X (a build with the `TrueHDObjectAudio` / + `DTSXObjectAudio` traits; without them it stays nil), the 1 Hz snapshot carries an + `ObjectAudioActivity`: the bed it renders into, the objects the presentation declares, how many of + them play and how many sit above ear level, and its bed channels on height speakers (DTS:X's standard + heights). These are the counts the `bed levels` log line has carried since #731, for a host's stats + panel without reading the log; they sit behind a lock of their own, so the sampler never waits for a + decode. `aetherctl play` takes `--atmos-bed` as `serve` does and prints them: on a TrueHD Atmos + remux, `obj=13/13 up=4 bed=7.1.4`. + +### Fixed + +- **The persistent HTTP/1.1 ingest holds the next segment request whenever the buffer is full.** + Backpressure applied only while a segment still had bytes left over, so a segment whose last chunk + was taken just under the 16 MB ceiling sent the next request at once, past the ceiling. The + producer now waits for room between segments either way (#732; a test pins the case, and the + timing test that caught it on CI no longer flakes). +- **A slow origin no longer fails large HEVC-in-TS segments on the persistent HTTP/1.1 ingest.** + Segment bodies stream to the demuxer instead of downloading whole first, the 45 s ceiling covers + only the wait for a response head, and a body ends on its size or on 30 s without a byte (typed + `.stalled` rather than the socket's Code=60). One request per origin at a time, so a seek never + opens a second connection beside a response in flight (#732). +- **The relay admits only http and https origins.** A playlist naming another scheme (`file://` + included) answers 404 instead of reaching URLSession. + +## [7.39.0] - 2026-10-17 + +**Not built by default any more: TrueHD Atmos and DTS:X object rendering, Profile 7 FEL dual-layer +composition, and Dolby Vision Profile 5 / 10.0 stills.** Each is now an optional component behind a +package trait that is off by default (`TrueHDObjectAudio`, `DTSXObjectAudio`, `DualLayerDolbyVision`, +`DolbyVisionStillReshaping`). A host that used any of them re-enables it on its package dependency: + +```swift +.package(url: "https://github.com/superuser404notfound/AetherEngine", from: "7.39.0", + traits: ["TrueHDObjectAudio", "DTSXObjectAudio", "DualLayerDolbyVision", "DolbyVisionStillReshaping"]) +``` + +or in Xcode's package dependency editor (XcodeGen cannot set traits; see +[README › Optional components](README.md#optional-components)). These components may be subject to +third-party patent licensing; integrators who enable them are responsible for their own licensing. + +### Changed + +- **Optional components, off by default.** Without its trait a component is not compiled, and a library + behind it is neither linked nor resolved. The public API is unchanged and the options become no-ops: + `objectAudioRendering` keeps the codec on the channel bridge, and `preferDualLayerDolbyVision` keeps a + Profile 7 source on the native route (the route line reads `not taken (built without the + DualLayerDolbyVision trait)`, and `reloadAtCurrentPosition(applying:)` no longer refuses flipping it on a + custom-reader session, since nothing would move). The two audio traits used to be on by default; the + dual-layer path (`DualLayerVideoDecoder`, the Metal composer, `OrderedDelivery`, the per-frame display + metadata and the FEL diagnostics) and the still reshaping (`DolbyVisionStillConverter`) were always + compiled. +- **A Dolby Vision Profile 5 / 10.0 source has no stills without `DolbyVisionStillReshaping`.** Its planes + are IPT, which the standard still path would read as YCbCr (the green / magenta picture of #103), so it + yields none: `scrubThumbnail` and `scrubPreviewFrame` return nil without decoding anything, + `supportsCacheBackedStills` is false, the I-frame rendition is absent (`dolbyVisionStillsNotBuiltIn`), + an audio switch holds no picture, and the session log says so once. Playback is unaffected, as are + Profile 8.x, HDR10, HLG and SDR stills. + +### Added + +- **`AetherEngine.OptionalComponent`**, with `isBuiltIn` and `traitName` per component, so a host can + hide a setting its build would ignore. +- **`Probes/AllTraits`**, a consumer package that enables every trait; CI builds it for tvOS and iOS, runs + the suite with all traits (including on a one-thread cooperative pool) and builds a release `aetherctl` + with them. The Dolby Vision pulse probe links the engine through `PulseEngine/` with + `DualLayerDolbyVision` and refuses to run its FEL arms without it. +- `aetherctl feltest`, `play --cycle-el` and `play --dv-pin` need `DualLayerDolbyVision` and exit 64 + without it; the `@_spi(Diagnostics)` FEL tooling (`felDecodeProbe`, `FelProbeResult`, + `felResidualEnabled`, `armDolbyVisionPinTest` and the pin queries) exists only with it. + +## [7.38.1] - 2026-10-16 + +### Fixed + +- **The HEVC-in-MPEG-TS HLS VOD ingest under `heldSourceConnection` no longer fails on a slow origin.** + A segment body now reaches the demuxer as it arrives instead of after its last byte, the carriage + probe reads the first segment's head and hands the still open response to the producer instead of + downloading it whole, and the 45 s ceiling on a whole exchange is gone: it now covers only the wait + for a response head, and a body is limited by its size and by 30 s without a byte, so a large + segment that keeps arriving is never cut off. That ceiling surfaced as `NSPOSIXErrorDomain Code=60`, + which named neither cause; a stall now says `body stalled, no bytes for 30 s after / + bytes in s`. A seek that abandons a body with at most 2 MB left, at a rate that brings it in within 3 s, + drains it and keeps the connection, and the next request waits for that instead of opening a + second connection beside it; a larger or slower remainder closes it at once. With the FIFO full the current segment is read to its + end, so a paused session leaves the connection idle between two requests, not mid-body. Measured on a + loopback origin at 200 KB/s with 11.6 MB segments: before, the probe died at 45.1 s with `Code=60` and + the session left the ingest; after, the ingest held and played. The h2 path's 45 s resource timeout + is lifted the same way (#732, thanks @qoli for the paired device runs). + +- **The package compiles in Release with Xcode 26 again.** `FelResidualMeasure` (the + `AETHER_FEL_MEASURE` diagnostic from 7.37.0) formatted its luma-band line inside the closure it + hands its queue, as one `+` chain of three `map`/`joined` expressions, and Xcode 26.3's type checker + gave up on that closure ("unable to type-check this expression in reasonable time", x86_64 Release + archive), so a host archiving a universal macOS app on Xcode 26 could not build 7.37.0 or 7.38.0. + Xcode 27 checked it in time, which is what this repository's CI runs. The closure now calls a + function, and the three parts are bound to typed constants first. No behaviour change. + +## [7.38.0] - 2026-10-16 + +### Changed + +- **A live stream on the software path starts near the newest media.** A continuous live stream over + HTTP opens with a burst of what the origin holds of the channel (8 to 18 s on one IPTV panel, at + link speed), and the software host armed its clock on that burst's first packet. Nothing on the + software path catches up, so the picture then ran the whole burst behind the broadcast for as long + as the channel played: measured 17 s behind on a channel whose native-path session ran 6 s behind. + The live feeder now waits for the burst to land (the ring growing no faster than real time over a + quarter second, from 0.3 s on, or 1.5 s at most) and starts 4 s behind the newest media, on a + keyframe; a source that delivers in real time, or a ring too shallow to be worth a later keyframe, + still starts from its first packet. The media behind the start stays in the rewind ring. + `SoftwareLiveJoinPolicy`, `SoftwareLiveJoinPlacementTests`. + +- **LibDTSX 2.0.0: `DTSX.framework` is a dynamic framework the app embeds, under the LGPL.** The + `dca` crate it compiles in is labelled Apache-2.0 but is a port of FFmpeg's LGPL-2.1-or-later + `dca` decoder, so the built library is LGPL-3.0-or-later and now ships, like the FFmpeg + frameworks, as a replaceable framework in `YourApp.app/Frameworks/`, exporting only the C API. + Same decoder, bit-identical output. A host with the `DTSXObjectAudio` trait on ships LibDTSX's + `NOTICE` and `LICENSES/` with the app (#798, thanks @tweakapps and @cmcpherson274). +- **LibTrueHD 2.3.1:** `truehd_atmos_version_string()` names the `truehd` commit actually built in; + it still named an older one. Same decoder (LibTrueHD#2, thanks @cmcpherson274). + +### Fixed + +- **A software start or seek no longer runs the clock while the decoder works up to the target + (#800).** The software host started its clock the moment the first packet went in (video-only) or + the first audio decoded, while the video decoder was still working from the keyframe before the + target up to it, so the first frames reached the layer late and were dropped: a 4K HEVC title + with no audio and 10 s GOPs ran its clock 0.9 s ahead of the first frame after a switch onto the + software path and dropped 5 to 7 frames on an Apple TV 4K, a 1080p H.264 title with audio 0.3 s + and 3. Before 7.32.2 the #724 re-anchor hid this by starting the clock a whole GOP behind. The + video preroll #774 added for the Profile 7 FEL route now holds every VOD software route: the + clock waits at rate 0 until frames from its position on are in the layer + (`SampleBufferRenderer.videoPrerollSeconds`, 0.125 s) and then starts `prerollStartLead` from + now. A live stream off the FEL route still starts at once. Measured with `aetherctl play --sw` on + a 4K 10 s GOP fixture: `preroll ready after 889 ms; starting at t=36.600`, where the clock used to + start at once. Reported with measurements by @cmcpherson274. `VideoPrerollTests`. + +- **A live reconnect whose audio returns first no longer publishes a picture twice.** When the + returning connection's audio opened the replay check before the outgoing connection's last pictures + left the PES parser (a flagged picture and its unflagged, truncated successor), those pictures were + not part of the held boundary. The returning copy of the first one committed the batch, the + returning copy of the truncated one arrived after it, and the producer published both and bumped the + second DTS, which poisons Apple's reference chain until the next IDR. Measured on one reconnect in + three on an Apple TV (AE#747, `superseded=0`). Such a tail now raises the retained video frontier, + bounded like a held damaged boundary, so the returning copies land in the verified batch and replace + the outgoing ones. `LivePacketReplayGuardTests`. + +- **A live stream read off its URL and refused for a while keeps its session.** The continuous + MPEG-TS form of an IPTV channel is one connection for as long as it plays, and a connection-limited + panel answers its reconnects with 509 while another device on the account holds the last slot, for + seconds to tens of seconds. The reader climbed its 0.5, 1, 2, 4, 8 s ladder, gave up after six + refusals, and each refusal armed the origin's request pacer (a quiet of up to 15 s, with each + request waiting up to 10 s on it), which the reload after it then waited out. A live stream's + 429/503/509 is now asked again every second, or after the origin's Retry-After up to 5 s, for up to + 60 s, the way the live HLS ingest asks for its playlist (#778); its refusals arm no pacer and lower + no budget, and the books still say the origin refused, so the revive arm tells metered from lost. A + live open the origin answers with a status fails typed with that status at once + (`AVIOReaderError.httpStatus`, `sourceRateLimited` or `sourceRefused` at the host), where the read + loop used to spend the open budget on the same request and hand back an untyped read failure. + A server error that is not a refusal (a 500 or 502 while a tuner spins up) is asked once more + first, as the read loop did. `LiveStreamRefusalRetryTests`. +- **Profile 7 FEL chroma residual is sampled where the base chroma sits.** With a quarter-resolution + enhancement layer (1080p under 4K), the chroma kernel read the enhancement chroma at texel centres, + a quarter of an EL chroma texel up and to the left of the co-sited (top-left) chroma location HDR + streams use. Base chroma sample n now reads EL texel n / 2, DoViBaker's phase. Same-resolution + enhancement layers and luma are unchanged. + +## [7.37.0] - 2026-10-15 + +### Added + +- **`TrackInfo.isDTSX`.** Set from libavcodec's DTS:X profiles ("DTS-HD MA + DTS:X", "... IMAX") at + probe time, and once LibDTSX confirms, while `objectAudioRendering` plays it, a track FFmpeg labelled + plain DTS-HD MA (FFmpeg reads only two of DTS:X's markers). The confirmation is kept per source like a + confirmed JOC flag, survives a track or title switch, and republishes `audioTracks`. Hosts can name a + DTS:X track the way they name TrueHD Atmos without parsing the profile string. + +- **A live session's request headers can be replaced without a rebuild (#792).** + `updateRequestHeaders(_:)` hands the session a new header set, and the next request each of its + source readers makes carries it: one store per load now feeds the playback demuxer, the producer's + reopens and revives, the I-frame and embedded-subtitle side readers, and the still extractor + `makeFrameExtractor()` returns for the session's source, which used to freeze a copy at their open. A media server whose bearer tokens expire under a long title no longer costs a + `reloadAtCurrentPosition(applying:)` teardown per rotation, and a reconnect already backing off over + a lapsed token heals on its next attempt once the host renews. The returned + `RequestHeaderUpdateOutcome` says `.installedForNextRebuild` where the requests are not the + engine's to change (`.remoteBypass`, an AVPlayer-hosted audio item, a custom `IOReader`, a remote + disc image). See [Rotating a credential without a rebuild](docs/api.md#rotating-a-credential-without-a-rebuild). + +- **A DVD-Video ripped as a `VIDEO_TS` folder plays over HTTP, as a `BDMV` folder does.** A + `DiscDirectoryReader` (or `HTTPDiscDirectoryReader`) whose listing holds `VIDEO_TS/` files is now read + as DVD-Video: the title sets, VMGI filtering, durations, chapters and languages come from the same code + as an image's (`DiscReader.dvdTitleSet`, now shared), the selected title's VOBs play back to back in + part order, and the cell timeline and VTS time map apply as on an image, since the VOBs in order are + the same run of bytes as their sectors. A listing without `VIDEO_TS/` is read as Blu-ray, as before, + and one with both trees plays its Blu-ray title, the order an image is recognized in. +- **Dolby Vision Profile 7 FEL plays as Dolby Vision on the software path.** Opt-in: with `LoadOptions.preferDualLayerDolbyVision` set to `true` (default `false`) and `dolbyVisionHandling` `.automatic`, `SoftwarePlaybackHost` routes a Profile 7 file whose enhancement layer is FEL through `DualLayerVideoDecoder`: the base layer and the enhancement layer (typically 4K and 1080p) decode on VideoToolbox, `DoviFelComposer` rebuilds the 12-bit picture on Metal, and each frame carries its RPU converted to Profile 8.1 (`DolbyVisionRPUData`, `DolbyCompatibilityID`, the latter a `CFString` `"1"`: tvOS parses it with `CFStringGetIntValue` and a `CFNumber` aborts the render server). A MEL enhancement layer keeps the native route. An `hev1` source whose `hvcC` carries no VPS / SPS / PPS (the usual Dolby Vision MP4 recipes) is classified from the parameter sets at the head of the stream, the rebuild the native path already does for its init.mp4 (#19). What the route gives up against the native one (system Picture in Picture, the I-frame playlist's scrub thumbnails, WebVTT renditions, AirPlay picture) is listed in the document. Details in [docs/dolby-vision-fel.md](docs/dolby-vision-fel.md). +- **The Profile 7 dual-layer route is FEL only and capability gated, and says why.** + `DualLayerRouteDecision` adds a real-time budget to the route: the base and enhancement layers + together may need at most UHD 3840x2160 at 30 fps plus a 1920x1080 enhancement layer at 30 fps (a 24p + UHD master uses 80%), otherwise the file keeps the native route, as MEL does and as does a box without + hardware HEVC decode (8.1 where the display takes Dolby Vision, the HDR10 base layer where it does + not). Every Profile 7 load logs one `DV P7 dual-layer route: taken (...)` or `not taken ()` + line; the line is new, and with the preference off it is the only change. + +- **`aetherctl play --fel`, `--surface`, `--cycle-el`; `aetherctl dvsurvey`, `feltest`.** Flip the residual on a timer over a visible surface, report a title's L1 display-metadata distribution, and check the FEL decode against real files. The probes behind them are `@_spi(Diagnostics)`, outside the SemVer contract. `play` runs the dual-layer route only with `--fel` (off by default, like the engine option); `--no-fel` is accepted and does nothing. +- **`AETHER_FEL_MEASURE` reports the signed FEL-minus-MEL mean per plane and luma band.** The diagnostic composes every Nth frame a second time with the residual off and compares the two; the signed mean says whether the residual brightens or darkens, which the absolute difference could not. It runs one sample at a time and skips a frame that arrives while one is in flight. Off by default; it is an environment-variable diagnostic, not API. +- **The dual-layer decoder pairs the two layers by picture order.** `DualLayerPictureAlignment` derives each layer's display slot from its POC (`HEVCPictureOrderCount`, no decoding) and stamps each enhancement-layer packet with the time of the base-layer picture in the same slot, so a stream whose enhancement layer is coded with B-frames over a base layer without them pairs the right pictures. A stream whose layers share one GOP keeps every packet's own time. After a seek a layer pairs by access unit until its next sequence-opening IRAP. +- **`Probes/DolbyVisionPulse`, a reproducible Dolby Vision pulse test.** Clips whose pixels are constant while only the RPU (or, for the enhancement-layer clip, only the residual) switches every 72 frames, counter clips whose base and enhancement layers each carry their own frame number (so a dropped or mispaired residual shows on screen), a tvOS host app that plays them through AVPlayer, VideoToolbox, and the FEL route, and scripts that build the clips, cut a phone recording per arm and read back whether the panel followed the metadata. It is the measurement behind [the FEL consumption evidence](docs/dolby-vision-fel.md#45-consumption-test-assets); the host builds in CI, the rest needs a television and a phone. + +### Changed + +- **The lossless bridge's documentation and route warning name the sinks it is for (#794).** An eARC + soundbar takes multichannel LPCM (measured: a Sonos Arc takes 6 channels, so `.lossless` reaches it as + lossless 5.1 where `.surroundCompat` gives lossy 5.1); only a sink behind legacy ARC needs E-AC-3. The + FLAC route warning no longer says soundbars take multichannel only as a bitstream. The engine still + does not pick the mode from the route: with tvOS's Continuous Audio Connection on, every HDMI route + reports 32 unlabelled channels whatever the sink takes. + +- **Dolby Vision Profile 7: a packet whose only NALs are a refused RPU and its EL now reports the + conversion as failed** (#767, thanks @pkajaba). The packet is left as it was, as before; the result used + to say success, which hid the dropped RPU from the one-shot conversion log and from `aetherctl dovitest`. + The same PR adds the Metal FEL composer and the Dolby Vision display metadata, which nothing selects yet. +- **`.verbose` engine log lines cost nothing unless the category's debug level is enabled.** `EngineLog` redacted and handed every `.verbose` line to OSLog even when nobody was reading debug output; it now checks `OSLog.isEnabled(type: .debug)` for the category first and returns early. `.info` lines and the host `handler` are unchanged (verbose lines never reached the handler). +- **`reloadAtCurrentPosition(applying:)` refuses a `preferDualLayerDolbyVision` change, or a `dolbyVisionHandling` change while the preference is on, on a custom `IOReader` source playing Profile 7**, with the new public case `SessionReloadRefusal.dualLayerRouteNeedsFreshLoad`, before any teardown. A host that switches over `SessionReloadRefusal` exhaustively has to handle the new case. The in-place rebuild of a custom source keeps the route the session was loaded with, so the change would have been logged as applied and ignored; `load()` again with the new option. URL sources re-run `load()` and are unaffected. + +- **LibTrueHD and LibDTSX can be left out.** Each object-audio decoder library is now behind a package + trait, `TrueHDObjectAudio` and `DTSXObjectAudio`, both on by default, so a host that does nothing + keeps today's build. A host that cannot ship one of them turns its trait off + (`.package(url:..., traits: [])`, or the package's traits in Xcode): the library is neither linked + nor resolved, and its codec keeps the channel bridge whatever `objectAudioRendering` asks, with the + reason in the session log, and the automatic audio pick no longer prefers it. The manifest moves to + swift-tools-version 6.1, which traits need. CI also builds the engine and its tests with both traits + off. See README › Package traits. + +### Fixed + +- **A credential refused mid-session is reported as one, in seconds, and heals when the host renews it + (#792).** A 401 or 403 after the session started was handled as a dropped connection: twelve + unproductive reconnects with the same refused header (about 80 s of `.stalled(reconnecting: true)`), + two revives that reopened with it again, and then `vodSourceFailed` / "Source read failed". The reader + now gives a credential refusal a short ladder of its own (about 3 s, the first retry re-resolving an + expired signed redirect through the source) and stamps it on the session's header store with the + header revision it was sent with. The first refusal of a header set is published on the new + `sourceAuthorizationRefused` (the status), so a host can renew and call `updateRequestHeaders(_:)` + while the reader is still retrying, which gives it a fresh ladder. Once the reader has given up, the + loopback session waits up to 10 s for new headers and rebuilds the producer at the playhead when they + arrive (up to three times); otherwise, and on the software path, the session ends with + `sourceRefused` and the status as `underlyingCode`. A revive is never spent on the header that was + just refused. + +- **A TrueHD track started or switched to mid-stream is no longer reported as a dead decoder** (#786, + thanks @ijuniorfu). TrueHD and MLP decode only from a major sync, so after a start, seek or track + switch past the opening the decoder skips up to about a hundred 40-frame access units before its + first frame. The channel bridge judged its decoder dead after 64 silent packets, about 53 ms of + TrueHD, so `AudioBridge` raised AE#396 / AE#641 and the session went to `.error` + (`.audioBridgeProducedNoOutput`) while it played, ignoring seeks after it. For these two codecs the + bound is now 1200 packets (one second of source), the bound `SpatialAudioBridge` already used for + the same wait; every other source keeps 64. + +- **Software VOD exposes retained cache ranges to host timelines.** `residentRanges` now covers + compressed video packets together with the selected audio stream, including retained history, + gaps and disk eviction. Cached seeks keep the same spans; cold seeks and teardown clear them. + Sampling continues while paused and runs off the main actor, with stale seek/stop results + discarded. Hosts no longer need to infer a cache band's start from the moving playhead. + VC-1 uses verified successor timestamps rather than rounded packet-duration islands. Valid + committed packets remain seekable after a source read error, which is still reported after drain; + storage failures remain unusable. Failed resume/seek positioning can no longer publish a false + successful landing. Bounded cold probes avoid speculative detour fetches, and foreground software + positioning has a finite 30 s budget for slow multi-probe remote keyframe searches. On tvOS's + in-memory packet store, history the system reclaims under memory pressure leaves the published + ranges at the next sample instead of only when a seek walks into it. + +- **A remuxed title declares a `BANDWIDTH` its densest segment fits under** (#783, thanks @hp561). The + master declared twice the container's average, and a VBR ending in an HEVC Matroska file with three + Opus tracks bridged to FLAC ran past it: AVPlayer logged -12318 "Segment exceeds specified bandwidth + for variant". When the plan's boundaries are container index entries, the byte positions the index + records at them now give each segment's source bitrate, and the highest of them (with 20% headroom, + plus the bridged track's ceiling: FLAC at its PCM rate, E-AC-3 and APAC at their configured rate) + raises the declaration, up to four times the average and never past 160 Mbit/s, so an implausible + measurement cannot leave a variant AVPlayer refuses to pick. Twice the average stays the floor, so no + declaration goes down. On a 60 s HEVC file averaging 20.1 Mbit/s whose densest served segment carried + 55.6 Mbit/s, the declaration went from 40.2 to 68.2 Mbit/s. The I-frame variant keeps reusing the + main figure, which bounds any keyframe of it. + +- **The package builds again on toolchains before Swift 6.4** (#787). `H264MatroskaSlotPermutation.CFRRewriter` (from #762) relied on Swift 6.4 leaving private stored properties with a default out of the memberwise initializer; earlier compilers make that initializer private, so `swift build` failed on Xcode 26. The initializer is now written out. No behavior change. + +- **A jump back opens one connection at the landing instead of three serial detour fetches** (#782, + thanks @tweakapps). On the native path a producer restart behind the reader's window was served the + way a parse read is: one 4 MB detour block per request, serially, until 8 MB had gone by, while the old + connection kept streaming ahead. A restart now tells the reader it repositioned playback, so the first + read it cannot serve from memory anchors the streaming connection there (a resident detour block still + serves first). Against a 400 ms, 40 Mbit/s origin a 64 s jump back landed in 3.3 s instead of 6.7 s, + with its first segment in 1.8 s instead of 4.2 s, the same cost as a long jump forward. + +- **H.264 VOD with in-band parameter changes recovers through compatible decoding** (#785, thanks @orut34iop). When an IDR + references SPS/PPS that differ from the AVC initialization, the producer stops before writing that + sample into the stale native segment. Automatic routing uses the existing software-path takeover, + preserving a pending seek or cold-resume position and the session transport. Repeated or unused + parameter sets keep the native route, and so does a change confined to the PPS, the VUI or the + level, which the native decoder takes in band (a TS recording remuxed to MKV or MP4 carries those); + only the fields a decoder session is built from, profile through cropping, take the software path. + An explicitly selected route or disabled escalation is honored. + +- **A live HLS source refused for a while keeps its session.** An IPTV panel that limits an account's + connections answers a live playlist refresh with 509 while another device holds the last slot, and + goes on refusing a session it evicted, while a fresh session from the loaded URL is admitted at + once. The ingest retried 1, 2 and 4 s apart and went terminal at 12 s, which on the software path + ended the session, and each refusal armed the origin's request pacer, so the host's reload then + waited up to 10 s for its first request. A refused refresh (429, 503, 509) is now asked again every + second, or after the origin's Retry-After up to 5 s, for up to 60 s, and so is a refused segment; an + unreachable origin or any other 5xx keeps the 12 s budget, so a dead channel or a lost network still + reaches the host's reopen or fallback as soon as before. A + session URL the loaded URL led to through redirects is asked for afresh through the loaded URL once + it has been refused twice, or at once when it answers as gone (a 4xx other than 429 and 408); the + playlist tracker follows MEDIA-SEQUENCE, so the segments continue where they left off. The live + ingest's refusals no longer arm the pacer or lower the origin's request budget; its own steady retry + is its pacing, and a ceiling the host declares or another path learns still binds it. +- **The Profile 7 dual-layer route recovers from a system session loss (#739).** A screen lock or sleep + invalidates both VideoToolbox sessions; the host's recovery reached only `HardwareVideoDecoder`, so on + the route audio played on over a frozen picture. `DualLayerVideoDecoder` now reports one loss per + event, rebuilds both sessions and re-adopts its feed epoch; the host reaches it through the new + internal `VideoSessionRebuilding` protocol. + +- **The Profile 7 dual-layer route shows the last frames at end of stream.** The end-of-source flush + dropped what VideoToolbox still owed, the base frames waiting for an enhancement layer and the + compositions in flight. `VideoDecodingPipeline.drainForEndOfStream()` (a no-op where `flush()` already + delivers) drains both sessions with pairing live, sends a waiting base frame out alone, and waits for + the composer; the host calls it before that flush. Seeks still discard. + +- **A failed `CMBlockBuffer` creation around a packet's payload no longer releases the packet reference + twice** when CoreMedia has already freed the block source from inside the failing call. + +- **The software path no longer drops frames on tvOS while it downloads.** Its compressed packet + cache was a temporary disk file; on an Apple TV 4K appends to it took 100 to 600 ms while a download + ran, the demux thread waited behind them, and video it had already parked reached the decoder late. + On tvOS the cache now lives in purgeable memory (96 MB forward, up to 128 MB of reclaimable played + history), as AVPlayer's own cache does there: a 4K title's 150 s soak went from 22 to 42 drops to + 0 to 3. macOS, iOS and visionOS keep the disk cache. + +- **Seeks on Matroska TrueHD titles hit the software packet cache again.** TrueHD packets carry no + duration and repeat their millisecond timestamps, so the cache never counted the audio as stored, + and every seek on the software path, even back to a spot just played, refetched from the source. + A duration-less audio packet now covers up to the next one's timestamp (gaps up to 0.5 s). + +- **FLAC with blocks under 192 samples plays instead of failing to open** (#764). An MKV whose FLAC used + small fixed blocks (`STREAMINFO` min = max = 144, 48 kHz stereo) died on the native path with + `-11829 "Cannot Open"` (underlying `-12848`), and the same stream in a plain `.flac` file failed the + audio-only route. CoreMedia's floor for `min_blocksize` is 192, not the specification's 16: measured on + macOS 26, a field of 191 drops the audio track even over real 4608-sample frames, and a field of 192 over + real 144-sample frames gets past that check and then hangs the decoder, so the field cannot simply be + raised. Such a stream is now decoded by libavcodec: the video route bridges it (bit-perfect for FLAC), + the audio-only route takes the FFmpeg host. A zeroed `min_blocksize` over a legal `max_blocksize` keeps + the AE#221 clamp and stays a stream copy. The stream-copy fallback packet duration for FLAC also uses + the block size `STREAMINFO` declares when min = max, instead of assuming 4096 samples. Covered by + `Issue764FLACSmallBlockTests`. + +- **`serveIFramePlaylist` shows scrub thumbnails for HDR10, HLG and Dolby Vision titles** (#682). The + stock tvOS scrubber fetched the first keyframe of a PQ rendition and then left the thumbnail box empty, + for Dolby Vision profile 5 and 8.1 as much as for plain HDR10, at 4K and at 1080p (measured on an Apple + TV 4K, tvOS 27). An HDR or Dolby Vision session now lists a Motion JPEG rendition instead + (`CODECS="mjpg"`, `VIDEO-RANGE=SDR`, 640 pixels wide), whose stills come from the engine's own still + pipeline: the tone map for PQ and HLG, the RPU reshaping for profile 5. SDR sessions keep serving their + own keyframes. The session log names the format: `i-frame rendition: served segments= + format=keyframes|jpeg`. Because an SDR rendition is the one thing AVPlayer can still play when it + refuses the HDR variant (an SDR panel without dynamic range matching), `iframe.m3u8` is answered only + once a media segment has been requested; otherwise it is withheld after 3 s, and the item's 404 counts + as the refusal it stands for, so the session still falls back to the media playlist instead of + playing the stills as its video. + +- **A 25 fps H.264 Matroska file with forward-clamped B-picture timestamps plays at a steady + cadence.** Its packet timestamps repeated display slots and left the preceding slots empty, so + decoded output alternated 0 ms and 80 ms frame intervals instead of 40 ms. The demuxer now + recognizes that shape only from an exact CFR lattice, complete picture-order ranks and two + corroborating clamps, then reconstructs PTS and DTS from the IDR anchor, picture order and decode + order. A seek starts from a new IDR, and overflow or any later timestamp outside the proven shape + disables the repair and leaves that packet untouched, as does a reconstruction that would present a + picture before its decode time. +- **H.264 MP4s with missing composition offsets keep display order across cadence changes and + hierarchical B pictures.** A corroborated missing-offset head with a changing decode cadence now + reuses the existing timestamp slots. Later zero-offset regions can prove a forward-coded anchor + farther ahead than the declared backward reorder delay, using a bounded complete-prefix check. + Original DTS, audio, payload and index remain unchanged; unsupported sequences still pass through. +- **`activeVideoDecoder` names the decoder used by the software host (#753).** HEVC decoded by + VideoToolbox now reports `VideoToolbox HEVC (HW)` on initial load and reload, instead of + `libavcodec HEVC (SW)`. Decoder selection and playback routing are unchanged. + +- **A live GOP longer than the sealed target duration no longer breaks the playlist.** RFC 8216 keeps + `TARGETDURATION` fixed for a playlist's life, so a later segment above it made the playlist invalid + for AVPlayer (-12642). The engine now retires that playlist URL with its original target and an + end index before the long segment, and serves the same cache under a new `live-N/` namespace with + a fresh seal; the consumer item swaps to it in place, keeping source, cache, position (also behind + live) and play/pause intent. A media playlist failure no longer counts as evidence against the HDR + master (#748, thanks @yucelokan). +- **A CRC-valid but hostile Dolby Vision RPU no longer aborts the process on any path found so far, + in parse, convert or write (#765, thanks @pkajaba).** libdovi is built panic=abort, so one panic ends + the process. `DoviRpuGuard` checks the CRC and pre-scans each RPU for the inputs that make libdovi 3.4 + panic or allocate unbounded: a polynomial piece with linear interpolation (`unimplemented!` in the + parser and the writer), a reshaping curve that mixes polynomial and MMR pieces (it parses, and a MEL + or residual-free RPU keeps its mapping through the mode 2 conversion, so `dovi_write_unspec62_nalu` + then indexes out of bounds), pivot and block counts the payload cannot hold, and display-management + block lengths the spec does not define (`unreachable!`). It also refuses an Exp-Golomb value of + 2^32 - 1 or more anywhere it reads one (libdovi reads 64 leading zeros differently from a plain shift, + so a crafted `mapping_idc` could slip past, and its writer allocates about 0.5 GB to write such an id + back). The guard knows these paths; it does not prove there are no others. Every RPU in dovi_tool's + test corpus (2333, Profile 5, 7 MEL and FEL, 8, CM v2.9 and v4.0) passes it and converts as before. +- **A refused live join says so, instead of reading as an unreadable source.** When the live HLS + ingest's playlist was refused at the join, the probe saw no bytes and the load published + `customSourceProbeFailed`, which a host reads as a stream it cannot play: one that falls back to + another route on it moved the channel there for the rest of its session over a 509 an IPTV panel + gives while another device holds the account's last slot. The load now publishes the origin's + answer, as an HTTP reader's failed open does: `sourceRateLimited` (429/503/509) or `sourceRefused` + with the status in `underlyingCode`, or `liveSourceUnavailable` when the playlist could not be + reached. + +- **A live channel on the HLS ingest follows a wireless AirPlay route change.** The ingest hands the + pipeline a forward-only custom source, and the route change rebuilt the session to serve it on the + device's LAN address. That rebuild is refused for such a source (#526) and the refusal was dropped, + so the receiver stayed on 127.0.0.1, which it cannot reach, and played nothing while the sender + reported external playback. The route change now swaps the player's item onto the receiver's + address (and back onto the loopback when the receiver lets go), as it already did for a sequential + origin. The session, its ingest and its segment cache carry on, so the source is asked for nothing + again; the swapped item rejoins its live playlist at the player's join point, as a live reload does + (`AirPlayPlaylistDecision.routeChangeHop`, #780, thanks @jpsts). The fallback for a receiver that + refuses the master (#227) rejoins a live playlist the same way, instead of seeking the media playlist + to the start of its window. + ## [7.36.0] - 2026-10-13 ### Added diff --git a/Examples/DemoPlayerMac/Scripts/build-dmg.sh b/Examples/DemoPlayerMac/Scripts/build-dmg.sh index 96dd1fe23..25a7b2877 100755 --- a/Examples/DemoPlayerMac/Scripts/build-dmg.sh +++ b/Examples/DemoPlayerMac/Scripts/build-dmg.sh @@ -66,7 +66,8 @@ mkdir -p "$BUILD_DIR" echo "==> [1/6] Building universal release..." swift build -c release --arch arm64 --arch x86_64 -BINARY=".build/apple/Products/Release/DemoPlayerMac" +# Swift 6.4 moved the multi-arch output from .build/apple to .build/out; ask SwiftPM instead of guessing. +BINARY="$(swift build -c release --arch arm64 --arch x86_64 --show-bin-path)/DemoPlayerMac" [ -f "$BINARY" ] || { echo "FAIL: $BINARY not produced"; exit 1; } echo " binary: $(file "$BINARY" | head -1 | sed 's/.*: //')" diff --git a/Examples/README.md b/Examples/README.md index c3fd89188..091ccb5be 100644 --- a/Examples/README.md +++ b/Examples/README.md @@ -18,7 +18,7 @@ Three samples covering different audiences: ``` https://github.com/superuser404notfound/AetherEngine ``` - Dependency Rule: Up to Next Major Version, starting from `7.36.0`. Add the `AetherEngine` library product to your app target. + Dependency Rule: Up to Next Major Version, starting from `7.40.1`. Add the `AetherEngine` library product to your app target. 3. **Drop the file in.** Replace the Xcode template's default `App.swift` (or whatever the generated `@main` file is called) with the contents of [`MinimalPlayerApp.swift`](MinimalPlayer/MinimalPlayerApp.swift). The file is self-contained: it defines both the `@main App` struct and the `ContentView`. diff --git a/Package.resolved b/Package.resolved index 7392d3c55..533276d54 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "186f06d8998fc1834706d0e0cb7e529a778f970a85f160f26a6ac2fc7c8e4ee2", + "originHash" : "7816b11b113ce741ab17b588aa7b4d9eb9f27d3c190e97a227916509bcc7ba8d", "pins" : [ { "identity" : "ffmpegbuild", @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/superuser404notfound/LibDTSX", "state" : { - "revision" : "9906f8fe9b72eb4d79a85754adbba6fa52cfe7b2", - "version" : "1.0.1" + "revision" : "7d68c8bbda348a10e169b9bb9619c8b2b7a0c642", + "version" : "3.0.0" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/superuser404notfound/LibTrueHD", "state" : { - "revision" : "577de495184254e2c660ebfd6ce3f7d97f45899d", - "version" : "2.3.0" + "revision" : "aebd5ffe1046dadbea833ce87a75d00fd8b1edd1", + "version" : "3.0.0" } }, { diff --git a/Package.swift b/Package.swift index 159fbf410..76b34b82e 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.1 import PackageDescription @@ -25,6 +25,37 @@ let package = Package( // platforms. The target is preserved below so `swift build` on // macOS still produces the CLI for upstream development. ], + // Four optional components, all off by default. A host that wants one enables its trait + // (`.package(url:..., traits: [...])`, or the package's traits in Xcode); without it the code is not + // compiled and the library behind it is not linked. These components may be subject to third-party + // patent licensing; an integrator that enables one is responsible for its own licensing. Off, the + // matching LoadOptions are accepted and change nothing: object audio keeps the channel bridge, a + // Profile 7 source keeps the native route, and a Dolby Vision Profile 5 / 10.0 source yields no + // scrub still or held picture. LibDovi, FFmpeg and everything else are unaffected. + traits: [ + .trait( + name: "TrueHDObjectAudio", + description: "Link LibTrueHD and the object renderer, so objectAudioRendering can render TrueHD Atmos objects to APAC." + ), + .trait( + name: "TrueHDReferenceRender", + description: "Measurement only: with TrueHDObjectAudio and a LibTrueHD built with matrix interpolation, AETHER_TRUEHD_REFERENCE_RENDER=1 decodes TrueHD Atmos exactly and mixes its objects, the reference the bed split is measured against. Never ship it.", + enabledTraits: ["TrueHDObjectAudio"] + ), + .trait( + name: "DTSXObjectAudio", + description: "Link LibDTSX and the object renderer, so objectAudioRendering can render DTS:X heights to APAC." + ), + .trait( + name: "DualLayerDolbyVision", + description: "Compile the Dolby Vision Profile 7 FEL dual-layer composer, so preferDualLayerDolbyVision can take that route." + ), + .trait( + name: "DolbyVisionStillReshaping", + description: "Compile the CPU reshaping that turns a Dolby Vision Profile 5 / 10.0 frame into a still (scrub thumbnails, I-frame stills, held pictures)." + ), + .default(enabledTraits: []), + ], dependencies: [ // Minimal FFmpeg build (avcodec, avformat, avutil, swresample only). // No network stack, we use custom AVIO + URLSession for HTTP streams. @@ -55,12 +86,13 @@ let package = Package( // TrueHD decoder that also decodes the Atmos object presentation (beds, objects, OAMD) for // `objectAudioRendering` (#731). The Rust `truehd` crate (Apache-2.0) behind a C API, prebuilt // as an xcframework like LibDovi so consumers need no Rust. Pinned to the minor for the same reason. - .package(url: "https://github.com/superuser404notfound/LibTrueHD", .upToNextMinor(from: "2.3.0")), // 2.3.0: decodes about a third faster again (harletty's truehd speed work: blocks decoded in place, table-driven Huffman, vectorised prediction filters, whole-block matrices; truehdd#42 included), byte-identical output; 2.2.1: rust_eh_personality exported weak, so it coalesces with LibDovi's copy and a host with its own Objective-C and C++ stays within compact unwind's three personality routines (#752); same decoder; 2.2.0: decodes about 30% faster (the truehd crate with its block and PCM buffers sized to the samples they hold, truehdd#42, from a fork of upstream main until that is merged), byte-identical output; 2.1.0: x86_64 in the iOS simulator slice, so a generic iOS Simulator build of a host links; same decoder; 2.0.0: transferred from Silo-Server/SiloObjectAudio and renamed (product TrueHD), rebuilt from source; same decoder as SiloObjectAudio 1.0.0 - // DTS:X decoder for `objectAudioRendering`: lossless DTS-HD MA with the encoder's height and object - // folds removed from the bed, the fixed heights, and the alternate presentations' objects placed in - // the same room coordinates as LibTrueHD's. The Rust `dca` crate (Apache-2.0) behind a C API, - // prebuilt as an xcframework like LibTrueHD. Pinned to the minor for the same reason. - .package(url: "https://github.com/superuser404notfound/LibDTSX", .upToNextMinor(from: "1.0.1")), // 1.0.1: rust_eh_personality weak (#752); dca 0.8.0 (harletty-bridge a717bae); the first two frames after a start or seek only prime the decoder + .package(url: "https://github.com/superuser404notfound/LibTrueHD", .upToNextMinor(from: "3.0.0")), // 3.0.0: API 2, the bed companion (the channel presentation decoded with the object presentation) and a per-handle interpolation switch; the interpolating decode is an opt-in build feature, off in the shipped binary (#731); 2.3.1: truehd_atmos_version_string() names the truehd commit actually built in (read from Cargo.lock, LibTrueHD#2), same decoder; 2.3.0: decodes about a third faster again (harletty's truehd speed work: blocks decoded in place, table-driven Huffman, vectorised prediction filters, whole-block matrices; truehdd#42 included), byte-identical output; 2.2.1: rust_eh_personality exported weak, so it coalesces with LibDovi's copy and a host with its own Objective-C and C++ stays within compact unwind's three personality routines (#752); same decoder; 2.2.0: decodes about 30% faster (the truehd crate with its block and PCM buffers sized to the samples they hold, truehdd#42, from a fork of upstream main until that is merged), byte-identical output; 2.1.0: x86_64 in the iOS simulator slice, so a generic iOS Simulator build of a host links; same decoder; 2.0.0: transferred from Silo-Server/SiloObjectAudio and renamed (product TrueHD), rebuilt from source; same decoder as SiloObjectAudio 1.0.0 + // DTS:X decoder for `objectAudioRendering`: lossless DTS-HD MA in its standard presentation, the + // four fixed heights removed from the 7.1 bed by the standard's constant fold and returned on their + // own channels; presentations with objects are refused. The Rust `dca` crate behind a C API, a port of FFmpeg's + // LGPL-2.1-or-later dca decoder, so the built library is LGPL-3.0-or-later and ships as a dynamic + // framework the app embeds, like FFmpegBuild's (#798). Pinned to the minor for the same reason. + .package(url: "https://github.com/superuser404notfound/LibDTSX", .upToNextMinor(from: "3.0.0")), // 3.0.0: the standard presentation only, its heights removed by the standard's constant fold without reading the metadata, presentations with objects refused; same output for standard DTS:X (#744); 2.0.0: DTSX.framework, dynamic, exporting only the C API, with dSYMs; NOTICE states the LGPL; same decoder, bit-identical output (#798); 1.0.1: rust_eh_personality weak (#752); dca 0.8.0 (harletty-bridge a717bae); the first two frames after a start or seek only prime the decoder ], targets: [ .target( @@ -68,8 +100,8 @@ let package = Package( dependencies: [ .product(name: "AetherFFmpegBuild", package: "FFmpegBuild"), .product(name: "Dovi", package: "LibDovi"), - .product(name: "TrueHD", package: "LibTrueHD"), - .product(name: "DTSX", package: "LibDTSX"), + .product(name: "TrueHD", package: "LibTrueHD", condition: .when(traits: ["TrueHDObjectAudio"])), + .product(name: "DTSX", package: "LibDTSX", condition: .when(traits: ["DTSXObjectAudio"])), ], // Silent E-AC-3 Atmos that `AtmosOutputActivator` loops beside an APAC track on tvOS. resources: [.copy("Resources/silent-atmos.mp4")], diff --git a/Probes/AllTraits/Package.swift b/Probes/AllTraits/Package.swift new file mode 100644 index 000000000..67e16f50e --- /dev/null +++ b/Probes/AllTraits/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 6.1 + +import PackageDescription + +// A consumer of the engine with every optional component enabled, the way a host enables them. `swift +// build` on the engine compiles its own traits, but xcodebuild has no flag for a package's traits, so CI +// builds this package for tvOS and iOS to keep the opt-in code compiling for the device SDKs. +let package = Package( + name: "AetherEngineAllTraits", + platforms: [ + .iOS(.v18), + .tvOS(.v18), + .macOS(.v15), + .visionOS(.v1), + ], + products: [ + .library(name: "AetherEngineAllTraits", targets: ["AetherEngineAllTraits"]), + ], + dependencies: [ + .package( + name: "AetherEngine", + path: "../..", + traits: ["TrueHDObjectAudio", "DTSXObjectAudio", "DualLayerDolbyVision", "DolbyVisionStillReshaping"] + ), + ], + targets: [ + .target( + name: "AetherEngineAllTraits", + dependencies: [.product(name: "AetherEngine", package: "AetherEngine")] + ), + ] +) diff --git a/Probes/AllTraits/Sources/AetherEngineAllTraits/AllTraits.swift b/Probes/AllTraits/Sources/AetherEngineAllTraits/AllTraits.swift new file mode 100644 index 000000000..d2b8582e1 --- /dev/null +++ b/Probes/AllTraits/Sources/AetherEngineAllTraits/AllTraits.swift @@ -0,0 +1,4 @@ +@_exported import AetherEngine + +/// Every optional component this consumer enabled, as the engine reports them at run time. +public let enabledOptionalComponents = AetherEngine.OptionalComponent.allCases.filter(\.isBuiltIn) diff --git a/Probes/DolbyVisionPulse/.gitignore b/Probes/DolbyVisionPulse/.gitignore new file mode 100644 index 000000000..be9c2cfe9 --- /dev/null +++ b/Probes/DolbyVisionPulse/.gitignore @@ -0,0 +1,11 @@ +# Everything the scripts generate: clips, RPU binaries and configs, logs, recordings. +out/ +*.mp4 +*.mov +*.MOV +*.hevc +*.bin +*.log +recordings/ +.dd/ +__pycache__/ diff --git a/Probes/DolbyVisionPulse/Device/DolbyVisionPulse.xcodeproj/project.pbxproj b/Probes/DolbyVisionPulse/Device/DolbyVisionPulse.xcodeproj/project.pbxproj new file mode 100644 index 000000000..3586b5e14 --- /dev/null +++ b/Probes/DolbyVisionPulse/Device/DolbyVisionPulse.xcodeproj/project.pbxproj @@ -0,0 +1,342 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 1CC1EA98D9C5F77AAC72F7CC /* PulseVideoToolbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9672B7938DDC09AAB435BC6 /* PulseVideoToolbox.swift */; }; + 77CB08AE958747D4126970C6 /* DolbyVisionPulseHostApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BCA97A8853B09B4E7B4B99A /* DolbyVisionPulseHostApp.swift */; }; + C3F88D052478C6D03F65C74C /* PulseEngine in Frameworks */ = {isa = PBXBuildFile; productRef = 5DDCF6DD2D95C3E73D4B2B5C /* PulseEngine */; }; + FB3139445F0E0031753BB397 /* PulseLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCACA2DF252349E564F73EAC /* PulseLog.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 6EC0C4402B0A4CFAB2376CAB /* PulseEngine */ = {isa = PBXFileReference; lastKnownFileType = folder; path = PulseEngine; sourceTree = SOURCE_ROOT; }; + 7BCA97A8853B09B4E7B4B99A /* DolbyVisionPulseHostApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DolbyVisionPulseHostApp.swift; sourceTree = ""; }; + 81B68F565101F4AD4C23902A /* DolbyVisionPulseHost.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = DolbyVisionPulseHost.app; sourceTree = BUILT_PRODUCTS_DIR; }; + B9672B7938DDC09AAB435BC6 /* PulseVideoToolbox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PulseVideoToolbox.swift; sourceTree = ""; }; + DCACA2DF252349E564F73EAC /* PulseLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PulseLog.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 2BC4135493426C6DF7CC3B11 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C3F88D052478C6D03F65C74C /* PulseEngine in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 877A8AC0E76ED624DD6E3BF6 /* Packages */ = { + isa = PBXGroup; + children = ( + 6EC0C4402B0A4CFAB2376CAB /* PulseEngine */, + ); + name = Packages; + sourceTree = ""; + }; + 91F1FECFC9FBC63638DFB9F0 = { + isa = PBXGroup; + children = ( + A0F3EC54F7CC8065A3A7DFAC /* Host */, + 877A8AC0E76ED624DD6E3BF6 /* Packages */, + A0369D737D33D357FE77916A /* Products */, + ); + sourceTree = ""; + }; + A0369D737D33D357FE77916A /* Products */ = { + isa = PBXGroup; + children = ( + 81B68F565101F4AD4C23902A /* DolbyVisionPulseHost.app */, + ); + name = Products; + sourceTree = ""; + }; + A0F3EC54F7CC8065A3A7DFAC /* Host */ = { + isa = PBXGroup; + children = ( + 7BCA97A8853B09B4E7B4B99A /* DolbyVisionPulseHostApp.swift */, + DCACA2DF252349E564F73EAC /* PulseLog.swift */, + B9672B7938DDC09AAB435BC6 /* PulseVideoToolbox.swift */, + ); + path = Host; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 1DDB0EFB1D0FFFD6D891F260 /* DolbyVisionPulseHost */ = { + isa = PBXNativeTarget; + buildConfigurationList = AF89BEB4939AE144202F15AF /* Build configuration list for PBXNativeTarget "DolbyVisionPulseHost" */; + buildPhases = ( + 3DBDD39040B8692C0BA1124E /* Sources */, + 2BC4135493426C6DF7CC3B11 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = DolbyVisionPulseHost; + packageProductDependencies = ( + 5DDCF6DD2D95C3E73D4B2B5C /* PulseEngine */, + ); + productName = DolbyVisionPulseHost; + productReference = 81B68F565101F4AD4C23902A /* DolbyVisionPulseHost.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 3E6283C4018EFBF976379C00 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 1DDB0EFB1D0FFFD6D891F260 = { + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 7345ED8B0955685E060F8EA6 /* Build configuration list for PBXProject "DolbyVisionPulse" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 91F1FECFC9FBC63638DFB9F0; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + A1D5E5D261EA7A828134329D /* XCLocalSwiftPackageReference "PulseEngine" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = A0369D737D33D357FE77916A /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 1DDB0EFB1D0FFFD6D891F260 /* DolbyVisionPulseHost */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + 3DBDD39040B8692C0BA1124E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 77CB08AE958747D4126970C6 /* DolbyVisionPulseHostApp.swift in Sources */, + FB3139445F0E0031753BB397 /* PulseLog.swift in Sources */, + 1CC1EA98D9C5F77AAC72F7CC /* PulseVideoToolbox.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 058CE76FD1067ADB93FFA4B8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; + ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Host/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(PULSE_BUNDLE_PREFIX).host"; + SDKROOT = appletvos; + TARGETED_DEVICE_FAMILY = 3; + }; + name = Release; + }; + 42160C4F45DACBA011092C36 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + PULSE_BUNDLE_PREFIX = de.superuser404.dolbyvisionpulse; + SDKROOT = appletvos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 18.0; + }; + name = Debug; + }; + 58026C6AF77395FFB0ED71C2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; + ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Host/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(PULSE_BUNDLE_PREFIX).host"; + SDKROOT = appletvos; + TARGETED_DEVICE_FAMILY = 3; + }; + name = Debug; + }; + 6CACE8A95F8A6D578B4F7D53 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + PULSE_BUNDLE_PREFIX = de.superuser404.dolbyvisionpulse; + SDKROOT = appletvos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 18.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 7345ED8B0955685E060F8EA6 /* Build configuration list for PBXProject "DolbyVisionPulse" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 42160C4F45DACBA011092C36 /* Debug */, + 6CACE8A95F8A6D578B4F7D53 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + AF89BEB4939AE144202F15AF /* Build configuration list for PBXNativeTarget "DolbyVisionPulseHost" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 58026C6AF77395FFB0ED71C2 /* Debug */, + 058CE76FD1067ADB93FFA4B8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + A1D5E5D261EA7A828134329D /* XCLocalSwiftPackageReference "PulseEngine" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = PulseEngine; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 5DDCF6DD2D95C3E73D4B2B5C /* PulseEngine */ = { + isa = XCSwiftPackageProductDependency; + productName = PulseEngine; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 3E6283C4018EFBF976379C00 /* Project object */; +} diff --git a/Probes/DolbyVisionPulse/Device/DolbyVisionPulse.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Probes/DolbyVisionPulse/Device/DolbyVisionPulse.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/Probes/DolbyVisionPulse/Device/DolbyVisionPulse.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Probes/DolbyVisionPulse/Device/DolbyVisionPulse.xcodeproj/xcshareddata/xcschemes/DolbyVisionPulse.xcscheme b/Probes/DolbyVisionPulse/Device/DolbyVisionPulse.xcodeproj/xcshareddata/xcschemes/DolbyVisionPulse.xcscheme new file mode 100644 index 000000000..5edd4a080 --- /dev/null +++ b/Probes/DolbyVisionPulse/Device/DolbyVisionPulse.xcodeproj/xcshareddata/xcschemes/DolbyVisionPulse.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Probes/DolbyVisionPulse/Device/Host/DolbyVisionPulseHostApp.swift b/Probes/DolbyVisionPulse/Device/Host/DolbyVisionPulseHostApp.swift new file mode 100644 index 000000000..0de3ebef6 --- /dev/null +++ b/Probes/DolbyVisionPulse/Device/Host/DolbyVisionPulseHostApp.swift @@ -0,0 +1,331 @@ +import AetherEngine +import AVFoundation +import AVKit +import CoreMedia +import SwiftUI +import UIKit + +// Device harness for the Dolby Vision pulse test (see ../../README.md). Every arm plays a clip whose +// pixels are constant while only the RPU (or, for the EL clip, only the residual) switches, so a +// camera pointed at the television can tell whether the metadata reached the panel. +// +// Arms come from the environment variable `PULSE_ARMS`, which `devicectl device process launch +// --environment-variables` does deliver to an app process: +// +// PULSE_ARMS="label|mode|url[|k=v;k=v],label|mode|url,..." +// +// Arms play back to back with a 5 s slate naming the arm in between, so one phone recording covers +// the whole sequence. Modes: +// +// - `avplayer`: plain AVPlayer + AVPlayerLayer on the file (Apple's own path, the positive control). +// - `vt`: AVAssetReader (VideoToolbox) output straight into AVSampleBufferDisplayLayer, with the +// attachments VideoToolbox set. +// - `vtcopy`: as `vt`, but every frame copied into a buffer from our own IOSurface pool, with all of +// VideoToolbox's propagated attachments (Dolby Vision RPU included) copied across byte for byte. +// - `vtstrip`: as `vtcopy`, without `DolbyVisionRPUData` / `DolbyCompatibilityID`. +// - `engine`: AetherEngine with `preferDualLayerDolbyVision: true` (a Profile 7 FEL clip takes the +// dual-layer route). +// - `enginenofel`: as `engine` with `preferDualLayerDolbyVision: false` (native 8.1 route, EL dropped). +// - `soak`: `engine` for `minutes=` with a `seeks=at@to,...` schedule, sampled once a second, ending in +// one `soak summary` line. +// +// The VT modes request Dolby Vision display criteria from the track's own format description, as an +// app on this path would. + +@main +struct DolbyVisionPulseHostApp: App { + var body: some Scene { + WindowGroup { + RootView() + .onAppear { + // About eleven minutes of playback, partly not AVPlayer: keep the screensaver away. + UIApplication.shared.isIdleTimerDisabled = true + } + } + } +} + +private struct RootView: View { + var body: some View { + if let arms = PulseArm.fromEnvironment() { + PulseHarnessView(arms: arms) + } else { + VStack(alignment: .leading, spacing: 24) { + Text("Dolby Vision pulse host").font(.title2) + Text("No PULSE_ARMS in the environment.").font(.title3) + Text(""" + Launch with devicectl device process launch --environment-variables \ + '{"PULSE_ARMS":"label|mode|url,..."}'. Scripts/run_tv.sh does it. + """) + .foregroundStyle(.secondary) + } + .padding(80) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } +} + +struct PulseArm { + let label: String + let mode: String + let url: URL + /// Optional 4th field, `key=value;key=value` (soak: `minutes=5;seeks=60@1800,150@600`). + let params: [String: String] + + static func fromEnvironment() -> [PulseArm]? { + guard let raw = ProcessInfo.processInfo.environment["PULSE_ARMS"] else { return nil } + return parse(raw) + } + + static func parse(_ raw: String) -> [PulseArm]? { + // Arms are comma separated, but a soak's `seeks=` list contains commas too: a new arm starts + // only at a piece shaped `label|mode|...`, anything else belongs to the previous arm's params. + var items: [String] = [] + for piece in raw.split(separator: ",", omittingEmptySubsequences: true).map(String.init) { + if piece.contains("|") || items.isEmpty { items.append(piece) } else { items[items.count - 1] += "," + piece } + } + let arms = items.compactMap { item -> PulseArm? in + let parts = item.split(separator: "|", maxSplits: 3).map(String.init) + guard parts.count >= 3, let url = URL(string: parts[2]) else { return nil } + var params: [String: String] = [:] + if parts.count == 4 { + for kv in parts[3].split(separator: ";") { + let p = kv.split(separator: "=", maxSplits: 1).map(String.init) + if p.count == 2 { params[p[0]] = p[1] } + } + } + return PulseArm(label: parts[0], mode: parts[1], url: url, params: params) + } + return arms.isEmpty ? nil : arms + } +} + +struct PulseHarnessView: View { + let arms: [PulseArm] + @State private var engine: AetherEngine? + @State private var slate: String = "" + @State private var host = PulseHostView() + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + PulseHostRepresentable(host: host).ignoresSafeArea() + if let engine { + AetherPlayerSurface(engine: engine).ignoresSafeArea() + } + if !slate.isEmpty { + VStack(spacing: 30) { + Text(slate).font(.system(size: 160, weight: .heavy)).foregroundColor(.white) + // Live UTC clock: one readable slate frame pins the video time to wall time. + TimelineView(.periodic(from: .now, by: 0.1)) { ctx in + Text(Self.clock(ctx.date)) + .font(.system(size: 96, weight: .bold).monospacedDigit()) + .foregroundColor(.white) + } + Text("dv-pulse").font(.system(size: 48)).foregroundColor(.gray) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + } + } + .task { await run() } + } + + private static func clock(_ d: Date) -> String { + let s = d.timeIntervalSince1970 + let day = s.truncatingRemainder(dividingBy: 86400) + return String(format: "%02d:%02d:%04.1f UTC", Int(day / 3600), Int(day / 60) % 60, + day.truncatingRemainder(dividingBy: 60)) + } + + @MainActor + private func run() async { + EngineLog.handler = { line in + PulseLog.shared.note(line) + print(line) + fflush(stdout) + } + let started = Date() + // Wall-clock epoch seconds on every line, so the phone video (whose creation date and the + // slate clock give its own wall-clock start) can be cut per arm afterwards. + let say: @Sendable (String) -> Void = { s in + let now = Date() + let line = String(format: "[Pulse] wall=%.3f t=%.1f %@", now.timeIntervalSince1970, + now.timeIntervalSince(started), s) + print(line) + fflush(stdout) + PulseLog.shared.note(line) + } + say("sequence: " + arms.map { "\($0.label)(\($0.mode))" }.joined(separator: " ")) + // Without the trait the engine accepts preferDualLayerDolbyVision and plays the native route, so + // every `engine` and `soak` arm would film the wrong thing. Refuse the whole sequence instead. + if arms.contains(where: { $0.mode == "engine" || $0.mode == "soak" }), + !AetherEngine.OptionalComponent.dualLayerDolbyVision.isBuiltIn { + slate = "AetherEngine was built without the DualLayerDolbyVision trait" + say("ERROR: the engine and soak arms need AetherEngine's DualLayerDolbyVision trait, which this " + + "build lacks (PulseEngine/Package.swift sets it); refusing the sequence") + try? await Task.sleep(nanoseconds: 10_000_000_000) + exit(70) + } + for (n, arm) in arms.enumerated() { + slate = "\(n + 1)/\(arms.count) \(arm.label)" + host.window?.avDisplayManager.preferredDisplayCriteria = nil + say("slate \(arm.label)") + try? await Task.sleep(nanoseconds: 5_000_000_000) + slate = "" + say("arm \(arm.label) start mode=\(arm.mode) url=\(arm.url.lastPathComponent)") + switch arm.mode { + case "avplayer": + await host.playAVPlayer(arm.url, log: say) + case "vt", "vtcopy", "vtstrip": + await host.playVT(arm.url, mode: arm.mode, log: say) + case "engine": + await playEngine(arm.url, fel: true, log: say) + case "enginenofel": + await playEngine(arm.url, fel: false, log: say) + case "soak": + await playSoak(arm, log: say) + default: + say("unknown mode \(arm.mode), skipped") + } + say("arm \(arm.label) end") + } + slate = "done" + host.window?.avDisplayManager.preferredDisplayCriteria = nil + try? await Task.sleep(nanoseconds: 3_000_000_000) + say("sequence done") + exit(0) + } + + @MainActor + private func playEngine(_ url: URL, fel: Bool, log: @Sendable (String) -> Void) async { + guard let e = try? AetherEngine() else { log("engine init failed"); return } + engine = e + do { + try await e.load(url: url, startPosition: nil, + options: LoadOptions(dolbyVisionHandling: .automatic, preferDualLayerDolbyVision: fel, + audioBridgeMode: .surroundCompat), + audioSourceStreamIndex: nil) + e.play() + } catch { + log("engine load failed: \(error)") + engine = nil + return + } + let deadline = Date().addingTimeInterval(80) + while Date() < deadline, e.state != .ended { + try? await Task.sleep(nanoseconds: 500_000_000) + } + log("engine state=\(e.state) t=\(e.currentTime)") + e.stop() + engine = nil + } + + /// Long-run FEL playback with a seek schedule. Measures only what the engine exposes publicly: + /// - droppedFrames: `liveTelemetry.droppedFrameCount` (render synchronizer's dropped frames on the + /// software path, session total; nil reads as -1). + /// - composeFailures: sum of `composeFailures=` over the engine's `[DualLayerVideoDecoder] summary` + /// lines (seen through EngineLog.handler; the counter itself is internal). The engine writes that + /// line every second at `.verbose`, which never reaches the handler, and at the default level only + /// for a second that counted a failure, so the sum is exact. Stays 0 if the FEL route never ran, + /// so `route=` is the thing to check first. + /// - footprint: task_vm_info phys_footprint, sampled each second. + @MainActor + private func playSoak(_ arm: PulseArm, log: @Sendable (String) -> Void) async { + let minutes = arm.params["minutes"].flatMap(Double.init) ?? 5 + var seeks: [(at: Double, to: Double)] = [] + for item in (arm.params["seeks"] ?? "").split(separator: ",") { + let p = item.split(separator: "@") + if p.count == 2, let at = Double(p[0]), let to = Double(p[1]) { seeks.append((at, to)) } + } + seeks.sort { $0.at < $1.at } + let tally = SoakTally() + let previous = EngineLog.handler + EngineLog.handler = { line in + previous?(line) + tally.ingest(line) + } + defer { EngineLog.handler = previous } + guard let e = try? AetherEngine() else { log("engine init failed"); return } + engine = e + do { + try await e.load(url: arm.url, startPosition: nil, + options: LoadOptions(dolbyVisionHandling: .automatic, preferDualLayerDolbyVision: true, + audioBridgeMode: .surroundCompat), + audioSourceStreamIndex: nil) + e.play() + } catch { + log("engine load failed: \(error)") + engine = nil + return + } + log("soak start minutes=\(minutes) seeks=\(seeks.count)") + let started = Date() + let deadline = started.addingTimeInterval(minutes * 60) + var peak = Self.footprintMB() + var played = 0.0 + var nSeeks = 0 + var lastTick = Date() + while Date() < deadline { + try? await Task.sleep(nanoseconds: 1_000_000_000) + let now = Date() + if e.state == .playing { played += now.timeIntervalSince(lastTick) } + lastTick = now + peak = max(peak, Self.footprintMB()) + if let next = seeks.first, now.timeIntervalSince(started) >= next.at { + seeks.removeFirst() + nSeeks += 1 + await e.seek(to: next.to) + log(String(format: "seek to=%.1f landed=%.1f", next.to, e.currentTime)) + lastTick = Date() + } + if e.state == .ended { log("soak ended early"); break } + } + let dropped = e.liveTelemetry?.droppedFrameCount ?? -1 + let route = tally.sawDualLayer ? "FEL" : "\(e.videoRoute.rawValue)-noDualLayerSummary" + log(String(format: "soak summary: played=%.0fs seeks=%d droppedFrames=%d composeFailures=%d peakFootprintMB=%d endFootprintMB=%d route=%@", + played, nSeeks, dropped, tally.composeFailures, peak, Self.footprintMB(), route)) + e.stop() + engine = nil + } + + /// `phys_footprint` (what jetsam judges), MB. + static func footprintMB() -> Int { + var info = task_vm_info_data_t() + var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + let kr = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count) + } + } + return kr == KERN_SUCCESS ? Int(info.phys_footprint / 1024 / 1024) : -1 + } +} + +/// Accumulates the engine's `[DualLayerVideoDecoder] summary: ... composeFailures=N ...` lines (per-second +/// deltas; only the seconds with a failure reach the handler). The FEL route is recognised by its +/// open-time line, since a healthy summary does not reach the handler. +private final class SoakTally: @unchecked Sendable { + private let lock = NSLock() + private var failures = 0 + private var seen = false + var composeFailures: Int { lock.lock(); defer { lock.unlock() }; return failures } + var sawDualLayer: Bool { lock.lock(); defer { lock.unlock() }; return seen } + func ingest(_ line: String) { + if line.contains("[DualLayerVideoDecoder] FEL stream detected") { + lock.lock(); seen = true; lock.unlock() + return + } + guard line.contains("[DualLayerVideoDecoder] summary:") else { return } + lock.lock(); defer { lock.unlock() } + seen = true + if let r = line.range(of: "composeFailures="), + let n = Int(line[r.upperBound...].prefix { $0.isNumber }) { failures += n } + } +} + +private struct PulseHostRepresentable: UIViewRepresentable { + let host: PulseHostView + func makeUIView(context: Context) -> PulseHostView { host } + func updateUIView(_ uiView: PulseHostView, context: Context) {} +} diff --git a/Probes/DolbyVisionPulse/Device/Host/Info.plist b/Probes/DolbyVisionPulse/Device/Host/Info.plist new file mode 100644 index 000000000..00222c321 --- /dev/null +++ b/Probes/DolbyVisionPulse/Device/Host/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Dolby Vision pulse + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/Probes/DolbyVisionPulse/Device/Host/PulseLog.swift b/Probes/DolbyVisionPulse/Device/Host/PulseLog.swift new file mode 100644 index 000000000..0d1a476d2 --- /dev/null +++ b/Probes/DolbyVisionPulse/Device/Host/PulseLog.swift @@ -0,0 +1,26 @@ +import Foundation + +/// A tiny file logger: every line the harness and the engine print also lands in +/// `Library/Caches/pulse-log.txt`, which `devicectl device copy from` can pull after a run, including +/// after a crash that took the console with it. Truncated at launch. +final class PulseLog: @unchecked Sendable { + static let shared = PulseLog() + + private let queue = DispatchQueue(label: "pulse.log") + private let url: URL + private var handle: FileHandle? + + private init() { + let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + url = caches.appendingPathComponent("pulse-log.txt") + FileManager.default.createFile(atPath: url.path, contents: nil) + handle = try? FileHandle(forWritingTo: url) + } + + func note(_ line: String) { + queue.async { [self] in + guard let data = (line + "\n").data(using: .utf8) else { return } + try? handle?.write(contentsOf: data) + } + } +} diff --git a/Probes/DolbyVisionPulse/Device/Host/PulseVideoToolbox.swift b/Probes/DolbyVisionPulse/Device/Host/PulseVideoToolbox.swift new file mode 100644 index 000000000..04f6a6bd4 --- /dev/null +++ b/Probes/DolbyVisionPulse/Device/Host/PulseVideoToolbox.swift @@ -0,0 +1,220 @@ +import AVFoundation +import AVKit +import CoreMedia +import UIKit + +// The AVPlayer and VideoToolbox arms of the pulse harness: the hosts the engine's own route is +// compared against. None of this touches AetherEngine. + +final class PulseHostView: UIView { + + override func layoutSubviews() { + super.layoutSubviews() + layer.sublayers?.forEach { $0.frame = bounds } + } + + @MainActor + func playAVPlayer(_ url: URL, log: @escaping @Sendable (String) -> Void) async { + let item = AVPlayerItem(url: url) + let player = AVPlayer(playerItem: item) + // AVPlayerLayer has no automatic criteria (that is AVPlayerViewController's); request them from the + // track's own format description exactly as the VT arms do. + if let track = try? await item.asset.loadTracks(withMediaType: .video).first, + let fd = try? await track.load(.formatDescriptions).first, + let fps = try? await track.load(.nominalFrameRate) { + await requestCriteria(fd: fd, fps: fps, log: log) + } + let pl = AVPlayerLayer(player: player) + pl.frame = bounds + pl.videoGravity = .resizeAspect + layer.addSublayer(pl) + player.play() + let deadline = Date().addingTimeInterval(80) + let duration = (try? await item.asset.load(.duration).seconds) ?? 66 + while Date() < deadline, item.status != .failed, player.currentTime().seconds < duration - 0.2 { + try? await Task.sleep(nanoseconds: 500_000_000) + } + log("avplayer status=\(item.status.rawValue) error=\(String(describing: item.error)) t=\(player.currentTime().seconds)") + player.pause() + pl.removeFromSuperlayer() + } + + @MainActor + func playVT(_ url: URL, mode: String, log: @escaping @Sendable (String) -> Void) async { + do { + // AVAssetReader only reads local files: fetch the clip into the app's temp dir first. + var localURL = url + if !url.isFileURL { + let (tmp, _) = try await URLSession.shared.download(from: url) + localURL = FileManager.default.temporaryDirectory.appendingPathComponent(url.lastPathComponent) + try? FileManager.default.removeItem(at: localURL) + try FileManager.default.moveItem(at: tmp, to: localURL) + log("downloaded \(url.lastPathComponent) to temp") + } + let asset = AVURLAsset(url: localURL) + guard let track = try await asset.loadTracks(withMediaType: .video).first, + let fd = try await track.load(.formatDescriptions).first else { log("no video track"); return } + let fps = try await track.load(.nominalFrameRate) + await requestCriteria(fd: fd, fps: fps, log: log) + + let reader = try AVAssetReader(asset: asset) + let out = AVAssetReaderTrackOutput(track: track, outputSettings: [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange, + kCVPixelBufferIOSurfacePropertiesKey as String: [String: Any]()]) + out.alwaysCopiesSampleData = false + reader.add(out) + guard reader.startReading() else { log("reader failed: \(String(describing: reader.error))"); return } + + let dl = AVSampleBufferDisplayLayer() + dl.frame = bounds + dl.videoGravity = .resizeAspect + var tb: CMTimebase? + CMTimebaseCreateWithSourceClock(allocator: kCFAllocatorDefault, sourceClock: CMClockGetHostTimeClock(), timebaseOut: &tb) + guard let tb else { log("timebase failed"); return } + dl.controlTimebase = tb + layer.addSublayer(dl) + + let renderer = dl.sampleBufferRenderer + let queue = DispatchQueue(label: "dv-pulse.feed") + let state = FeedState() + let copier = PulseFrameCopier() + nonisolated(unsafe) let output = out + nonisolated(unsafe) let r = renderer + renderer.requestMediaDataWhenReady(on: queue) { + while r.isReadyForMoreMediaData { + guard let sb = output.copyNextSampleBuffer() else { + r.stopRequestingMediaData() + state.finish() + return + } + let n = state.count + let pts = CMSampleBufferGetPresentationTimeStamp(sb) + if n == 0 { + CMTimebaseSetTime(tb, time: CMTimeSubtract(pts, CMTime(value: 1, timescale: 4))) + CMTimebaseSetRate(tb, rate: 1) + } + guard let enq = copier.transform(sb, mode: mode) else { state.fail(); continue } + if n < 2 || n == 150 { + let pb = CMSampleBufferGetImageBuffer(enq)! + let atts = CVBufferCopyAttachments(pb, .shouldPropagate) as? [String: Any] ?? [:] + let rpu = (atts["DolbyVisionRPUData"] as? Data)?.count ?? 0 + log("frame \(n) pts=\(String(format: "%.3f", pts.seconds)) rpu=\(rpu)B compat=\(atts["DolbyCompatibilityID"] ?? "nil") keys=\(atts.keys.sorted())") + } + r.enqueue(enq) + state.advance(lastPTS: pts) + } + } + let deadline = Date().addingTimeInterval(80) + while Date() < deadline { + try? await Task.sleep(nanoseconds: 500_000_000) + if state.done, CMTimebaseGetTime(tb) > state.lastPTS { break } + if dl.sampleBufferRenderer.status == .failed { + log("renderer failed: \(String(describing: dl.sampleBufferRenderer.error))") + break + } + } + log("vt \(mode): enqueued=\(state.count) copyFailures=\(state.failures) reader=\(reader.status.rawValue)") + renderer.stopRequestingMediaData() + reader.cancelReading() + renderer.flush(removingDisplayedImage: true, completionHandler: nil) + dl.removeFromSuperlayer() + } catch { + log("vt error: \(error)") + } + } + + @MainActor + private func requestCriteria(fd: CMFormatDescription, fps: Float, log: @Sendable (String) -> Void) async { + window?.avDisplayManager.preferredDisplayCriteria = AVDisplayCriteria(refreshRate: fps, formatDescription: fd) + log("criteria requested codec=\(fourCC(CMFormatDescriptionGetMediaSubType(fd))) fps=\(fps)") + let switchDeadline = Date().addingTimeInterval(10) + try? await Task.sleep(nanoseconds: 300_000_000) + while window?.avDisplayManager.isDisplayModeSwitchInProgress == true, Date() < switchDeadline { + try? await Task.sleep(nanoseconds: 100_000_000) + } + try? await Task.sleep(nanoseconds: 1_000_000_000) + log("mode switch settled") + } + + private func fourCC(_ v: FourCharCode) -> String { + String(bytes: [24, 16, 8, 0].map { UInt8((v >> $0) & 0xff) }, encoding: .ascii) ?? "\(v)" + } +} + + +/// Frame copy for the `vtcopy` / `vtstrip` arms, used on the feed queue. +nonisolated private final class PulseFrameCopier: @unchecked Sendable { + private var pool: CVPixelBufferPool? + private var poolKey: (Int, Int, OSType)? + + /// `vt`: unchanged. `vtcopy` / `vtstrip`: pixels copied into our own pool buffer, propagated + /// attachments copied across (minus the two Dolby Vision keys for `vtstrip`), new sample buffer + /// built from the image buffer the way AetherEngine's renderer builds its own. + func transform(_ sb: CMSampleBuffer, mode: String) -> CMSampleBuffer? { + if mode == "vt" { return sb } + guard let src = CMSampleBufferGetImageBuffer(sb), let dst = copyPixels(src) else { return nil } + var atts = CVBufferCopyAttachments(src, .shouldPropagate) as? [String: Any] ?? [:] + CVBufferRemoveAllAttachments(dst) + if mode == "vtstrip" { + atts["DolbyVisionRPUData"] = nil + atts["DolbyCompatibilityID"] = nil + } + CVBufferSetAttachments(dst, atts as CFDictionary, .shouldPropagate) + var fd: CMVideoFormatDescription? + guard CMVideoFormatDescriptionCreateForImageBuffer(allocator: nil, imageBuffer: dst, formatDescriptionOut: &fd) == noErr, + let fd else { return nil } + var timing = CMSampleTimingInfo() + CMSampleBufferGetSampleTimingInfo(sb, at: 0, timingInfoOut: &timing) + var out: CMSampleBuffer? + guard CMSampleBufferCreateReadyWithImageBuffer(allocator: nil, imageBuffer: dst, formatDescription: fd, + sampleTiming: &timing, sampleBufferOut: &out) == noErr else { return nil } + return out + } + + private func copyPixels(_ src: CVPixelBuffer) -> CVPixelBuffer? { + let w = CVPixelBufferGetWidth(src), h = CVPixelBufferGetHeight(src) + let fmt = CVPixelBufferGetPixelFormatType(src) + if pool == nil || poolKey.map({ $0 != (w, h, fmt) }) ?? true { + let attrs: [String: Any] = [ + kCVPixelBufferWidthKey as String: w, kCVPixelBufferHeightKey as String: h, + kCVPixelBufferPixelFormatTypeKey as String: fmt, + kCVPixelBufferIOSurfacePropertiesKey as String: [String: Any](), + kCVPixelBufferMetalCompatibilityKey as String: true] + CVPixelBufferPoolCreate(nil, [kCVPixelBufferPoolMinimumBufferCountKey as String: 12] as CFDictionary, + attrs as CFDictionary, &pool) + poolKey = (w, h, fmt) + } + guard let pool else { return nil } + var dst: CVPixelBuffer? + guard CVPixelBufferPoolCreatePixelBuffer(nil, pool, &dst) == kCVReturnSuccess, let dst else { return nil } + CVPixelBufferLockBaseAddress(src, .readOnly) + CVPixelBufferLockBaseAddress(dst, []) + defer { + CVPixelBufferUnlockBaseAddress(dst, []) + CVPixelBufferUnlockBaseAddress(src, .readOnly) + } + for p in 0..` decodes both code rows from raw 3840x2160 yuv420p +frames of any composer's output and prints `ok`, `MISMATCH` or `no-EL` per frame. + +The soak arm is not a pulse measurement. It plays a FEL title for several minutes through a seek +schedule and reports one line, `soak summary: played= seeks= droppedFrames= composeFailures= +peakFootprintMB= endFootprintMB= route=`. Check `route=` first: it reads `FEL` only if the engine's +dual-layer decoder reported a FEL stream (or a summary line with a failure), and a run that never took the route reports zero compose failures +for the wrong reason. + +## Run on an Apple TV + +The dual-layer route is an optional component of the engine, compiled only with its +`DualLayerDolbyVision` package trait. The host gets it through `Device/PulseEngine/Package.swift`, which +depends on the engine with that trait set, because XcodeGen cannot set a package's traits itself. Without +the trait every `engine` and `soak` arm would play the native route, so the host checks +`AetherEngine.OptionalComponent.dualLayerDolbyVision.isBuiltIn` before the first arm, shows a slate +saying the trait is missing, logs an `ERROR` line and exits with status 70. + +The host app takes its arms from the environment variable `PULSE_ARMS`: + +``` +label|mode|url[|k=v;k=v],label|mode|url,... +``` + +Arms are comma separated; a soak's `seeks=at@to,at@to` list contains commas too, so a piece that does +not look like `label|mode|...` continues the previous arm's parameters. +`xcrun devicectl device process launch --environment-variables` does deliver environment to an app +process. That is not true of an XCTest bundle on tvOS, which is why the transport probe cannot use it. + +```bash +DEVICE="" TEAM= Scripts/run_tv.sh +``` + +`xcrun devicectl list devices` names the devices. Options, all environment variables: + +| variable | default | what it is | +| --- | --- | --- | +| `DEVICE` | required | the Apple TV | +| `TEAM` | none | development team id for signing | +| `PULSE_BUNDLE_PREFIX` | `de.superuser404.dolbyvisionpulse` | the app's id is `.host`; set it when a team cannot register the default | +| `SEQ` | `main` | `main`, `el`, `vt`, `count` or `soak` | +| `SOAK_URL`, `SOAK_MIN`, `SOAK_SEEKS` | none, 5, `60@1800,150@600,240@3000` | the soak arm: a URL or a file under `out/`, minutes, `at@to` seek schedule in seconds | +| `PULSE_OUT` | `out/` | where the clips are | +| `IFACE` | first of `en0`, `en1` with an address | the network interface whose address the Apple TV fetches the clips from | +| `DEVELOPER_DIR` | `xcode-select -p` | the Xcode to build with | + +`run_tv.sh --no-build` plays without rebuilding. The script builds `Device/DolbyVisionPulse.xcodeproj`, +installs it, serves `out/` from this Mac over plain http (`serve.py`; the host allows it), waits ten +seconds so the recording can be started, launches with `--console`, and afterwards pulls the app's own +log (`Library/Caches/pulse-log.txt`) off the device. A launch that ends non-zero, because the app +crashed or was killed, still gets its log pulled. Pull a crash report from the device yourself when +that happens. + +In Xcode: open `Device/DolbyVisionPulse.xcodeproj`, pick the Apple TV, set `PULSE_ARMS` in the scheme's +environment variables and run. The host keeps the screen saver off, because an Apple TV that sleeps +mid-sequence suspends the process. + +Apple TV settings: Format **4K SDR**, Match Content with **Dynamic Range** and **Frame Rate** on, and +leave the HDMI chroma and range settings alone between runs. + +## Film it + +**Television.** Dolby Vision on an LG OLED (the C1 was used): picture mode **Dolby Vision Cinema**, +not *Cinema Home*, which follows the room light. AI Picture Pro, AI Brightness, Energy Saving, OLED +Motion Pro, TruMotion and OLED Care's Logo Luminance Adjustment all off; the last two matter because +black frame insertion flickers on a phone and the logo dimming darkens static areas, which this +pattern is. LG keeps separate Dolby Vision and HDR10 settings and the Dolby Vision ones can only be +changed while Dolby Vision is playing, so set them during any Dolby Vision title. Dark room, ten minutes +of warm-up. Note whether the Dolby Vision badge shows on each arm; it says only that the TV is in +Dolby Vision mode, and the pulse is what says the RPU is used. + +**Phone.** 4K at 30 fps (1080p 30 works), **HDR Video off** so the recording is not itself tone +mapped, Auto Low Light FPS off, Enhanced Stabilization off, Lock Camera on. On a tripod, with the +whole screen and all four corner squares filling most of the frame and nothing else bright in view. +Start recording before the launch; `run_tv.sh` waits ten seconds. Hands off until the sequence ends, +then stop. + +**Exposure must be locked.** With auto exposure the camera compensates for the very brightness change +being measured. The stock Camera app's AE/AF lock (long press) **did not hold** here: exposure moved +on a locked frame. Use a manual camera app with a fixed ISO, a fixed shutter (1/30 or 1/60) and a +fixed white balance (5600 K), set so the brightest patch (top row, right end) is not blown out. +Blackmagic Camera is one. If the stock app is all there is, lock during the first arm's pre-roll and +treat the first run as a shakedown; the closing P arm repeats it. + +## Cut and analyse + +`cut.py` splits one recording into one piece per arm using the `[Pulse] wall=...` lines in the console +log (or the pulled app log), so the labels do not depend on counting runs in order. + +```bash +python3 Scripts/cut.py recording.MOV tv_main_.log --slates +``` + +The video's start comes from its QuickTime creation date, to a second. `--slates` writes one still per +slate; read the UTC clock off one and pass it back for sub-second alignment: + +```bash +python3 Scripts/cut.py recording.MOV tv_main_.log --sync 12.5=17:08:31.2 +``` + +That is "the slate frame at 12.5 s of the video shows 17:08:31.2". The result is a table of arm, mode +and verdict, plus a `.mov` and `.json` per arm (and a `.png` when Pillow is installed) next to the recording. + +To analyse a recording directly, labelling runs in order: + +```bash +python3 Scripts/analyze.py recording.MOV --arms P,V1,V2,T,N,S,P1,T1,P --png result.png +``` + +## Report + +`report.py` reads the arms directory `cut.py` wrote (`summary.json` and every `NN_.json`) and prints +markdown: the change in camera brightness, B against A, per grey patch as a signed percent (FEL-route +rows in bold, a repeated arm labelled "(repeat)", an arm with no decoded run flagged), the number of +patches with |t| above 5 and the strongest t per arm, and the absolute phase-A levels of the arms that +show whether the panel was in Dolby Vision at all. `--png` draws every arm's trace in one figure. + +```bash +python3 Scripts/report.py recording_arms --md report.md --png report.png +python3 Scripts/report.py recording_arms --levels-arms V1,V2,T,S --patches g10,g100,g400,g1000,g4000,g10000 +``` + +## Check the clips before filming + +A steady EL arm only means something if the clip carries the residual. `check_el.py` demuxes the BL and EL +of the muxed `.mp4` (`dovi_tool demux`), or takes an EL-only `.hevc`, decodes the EL to raw +`yuv420p10le` (no range conversion), and checks that the median EL code inside every `EL_PATCHES` box is +512 in phase A and the `el_design()` code in phase B (within one code value). It prints the share of EL +pixels away from 512 per phase (0 in A, about the patch area in B), the composed 10-bit change each patch +should show, and for an `.mp4` that the RPU is the same on every frame. It exits 1 on a failure, so the +flat-EL control clip fails phase B by design. + +```bash +python3 Scripts/check_el.py out/p7fel_el.mp4 +``` + +`dump_attachments.swift` decodes a Profile 8.1 clip through AVAssetReader, that is VideoToolbox's own +decoder, prints every attachment on the chosen frames' pixel buffers, and hashes `DolbyVisionRPUData` on +every frame. It shows the payload the platform attaches itself, to compare with what the engine attaches; +on a pulse clip the hash differs between an A frame and a B frame. + +```bash +swift Scripts/dump_attachments.swift out/pulse_l2.mp4 0 200 +``` + +## Reading the verdicts + +For every patch and every B phase, the effect is `B - (A_before + A_after) / 2`, so slow drift cancels. +Per arm the output is a verdict, the eight patches that moved most (level, B-A change, relative change, +t statistic) and the lag in clip frames between the switch and the panel's response. + +- **PULSE**: at least three patches with |t| above 5. **weak**: one or two. **steady**: none. +- **Real Dolby Vision**: T is about the same as P, and much larger than N. +- **Fake Dolby Vision** (the mode switches, the metadata is ignored): T is about N while P pulses. +- **V1 pulses, V2 does not**: tvOS cares where the buffer came from. +- **V2 pulses, T does not**: the problem is in our RPU bytes or our path, not the platform. +- **TE pulses on the six patches, NE steady**: the residual reaches the panel. The six are the 10, 100, + 400 and 1000 nit greys, red and cyan (about +17 % in 10-bit luma code value after composition); the + other patches are in-frame controls and must not move. + +N and NE are the drift floor. A verdict only means something against them. + +## Proving the analysis first + +`sim_recording.py` writes a fake phone recording of `pulse_l2.mp4`: filmed at 30 fps with perspective, +vignetting and noise, B phases darkened with a three frame latency for a `pulse` run, nothing for a +`steady` one. + +```bash +python3 Scripts/sim_recording.py sim.mp4 pulse,steady # about two minutes +python3 Scripts/analyze.py sim.mp4 --arms pulse,steady # PULSE (28 patches), steady (0 patches) +``` + +`cut.py` can be exercised the same way with a hand-written log of `[Pulse] wall= t= slate +