diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 0e0ff06f..d4a03f06 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -35,6 +35,10 @@ jobs: - uses: actions/checkout@v4 - run: python3 scripts/generate-design-tokens.py --check - run: python3 scripts/generate-app-icons.py --check + - name: Check domain and framework dependency boundaries + run: python3 -B -I -m unittest discover -s tests -p test_architecture.py -v + - name: Check capture dependency and pixel integrity + run: python3 -B -I -m unittest discover -s tests -p test_component_lab.py -v - run: python3 scripts/check-component-lab.py - run: python3 -m unittest discover -s tests -v - run: shellcheck scripts/*.sh configs/lucid/*.sh @@ -82,6 +86,43 @@ jobs: - run: cargo fmt --manifest-path lucent/Cargo.toml --all --check - run: cargo clippy --manifest-path lucent/Cargo.toml --locked --workspace --all-targets -- -D warnings - run: cargo test --manifest-path lucent/Cargo.toml --locked --workspace + - name: Capture compiled release-gate package and test-name inventories + id: compiled-inventory + run: | + task_inventory="$RUNNER_TEMP/lucent-compiled-inventory" + mkdir -m 700 "$task_inventory" + git rev-parse HEAD > "$task_inventory/source-commit.txt" + cargo metadata --manifest-path lucent/Cargo.toml --offline --locked --no-deps --format-version 1 > "$task_inventory/packages.json" + # --list executes no tests or native fixtures. Keep each harness's + # stdout separate: workspace stdout/stderr can interleave summaries. + inventory() { + task_id="$1" + shift + cargo test --manifest-path lucent/Cargo.toml --offline --locked "$@" -- --list > "$task_inventory/$task_id-all.txt" + cargo test --manifest-path lucent/Cargo.toml --offline --locked "$@" -- --list --ignored > "$task_inventory/$task_id-ignored.txt" + } + inventory lucent-domain-tests -p lucent-domain --lib + inventory lucent-api-tests -p lucent-api --lib + inventory lucent-ui-tests -p lucent-ui --lib + inventory lucent-wayland-tests -p lucent-wayland --lib + inventory desktop-tests -p lucent-desktop --bin lucent-desktop + inventory video-tests -p lucent-services --lib 'video::' + inventory application-adapter-tests -p lucent-services --lib 'applications::' + inventory upload-tests -p lucent-render --lib 'stream_upload::tests::' + inventory desktop-bar-pixels-tests -p lucent-desktop --bin lucent-desktop bar_handoff_carriers_keep_identical_pixels + inventory allocation-tests -p lucent-render --lib 'allocation::tests::' + inventory video-allocation-example-tests -p lucent-services --example video-allocation-observer + inventory video-frame-tests -p lucent-video-frame --lib + inventory video-plane-vulkan-tests -p lucent-render --lib 'video_planes::tests::video_planes_gpu_' + inventory video-scene-vulkan-tests -p lucent-render --lib 'video_scene::tests::video_scene_gpu_' + inventory native-video-example-tests -p native-video-planes --bin native-video-planes + inventory component-lab-tests -p lucent-lab --lib + - name: Retain compiled inventories separately from execution evidence + uses: actions/upload-artifact@v4 + with: + name: compiled-test-inventory + path: ${{ runner.temp }}/lucent-compiled-inventory + if-no-files-found: error - run: dbus-run-session -- cargo test --manifest-path lucent/Cargo.toml --locked -p lucent-services notification_ -- --ignored - run: cargo test --manifest-path lucent/Cargo.toml --locked -p lucent-services audio_protocol -- --ignored - run: cargo test --manifest-path lucent/Cargo.toml --locked -p lucent-services bluetooth_protocol -- --ignored @@ -92,6 +133,8 @@ jobs: run: | export VK_DRIVER_FILES="$(find /usr/share/vulkan/icd.d -name '*lvp*' -print -quit)" test -n "$VK_DRIVER_FILES" + cargo test --manifest-path lucent/Cargo.toml --locked -p lucent-render video_planes_gpu_ -- --ignored --nocapture --test-threads=1 + cargo test --manifest-path lucent/Cargo.toml --locked -p lucent-render video_scene_gpu_ -- --ignored --nocapture --test-threads=1 cargo test --manifest-path lucent/Cargo.toml --locked -p lucent-render frozen_stream_snapshot_keeps_pixels -- --ignored --nocapture cargo test --manifest-path lucent/Cargo.toml --locked -p lucent-render trace_reports_final_bound_alias -- --ignored --nocapture cargo test --manifest-path lucent/Cargo.toml --locked -p lucent-render international_style_masks -- --ignored --nocapture diff --git a/docs/native-video-planes.md b/docs/native-video-planes.md new file mode 100644 index 00000000..f81f7f91 --- /dev/null +++ b/docs/native-video-planes.md @@ -0,0 +1,80 @@ +# Native video planes: opt-in prototype + +This prototype connects FFmpeg decoding to Vulkan rendering through neutral immutable values. The earlier independent painter passed hosted tests; the current candidate adds the real desktop paint path behind an explicit, default-off opt-in. Existing `MediaFrame`, `ImageData`, default playback, previews and posters keep their RGBA contracts. Integration source alone does not establish compilation, integrated GPU correctness, memory savings, 60 fps, VM behavior or the whole-shell 300 MiB target. + +The earlier same-source retarget experiments preserved decoder sessions but had variable retained-heap results. They remain separate evidence. This prototype explores removing RGBA conversion and publication storage, without changing the allocator or weakening source admission. + +## Concrete path and boundaries + +1. The independent `native-video-planes` example calls the existing `WallpaperPlaybackPort::prepare`. The real adapter imports immutable media and writes its validated poster/descriptor. It never fabricates an asset. +2. It explicitly calls the optional `play_native`. Existing adapters get a default implementation that delegates to RGBA playback with the same cadence, loop and target options; ordinary clients still call `play`. +3. `NativeVideoWallpapers` uses the existing bounded input, source clock, H.264 frame-thread configuration, EOF draining/rewind, callback stop and cancellation logic. The core delivery seam can carry either existing RGBA or `NativeMediaFrame::Yuv420`. +4. `video/native_planes.rs` admits only the supported native subset. Its FFmpeg ownership and unsafe access stay inside the concrete adapter. +5. `lucent-video-frame` is a small shared crate with checked `Plane` and `Yuv420Frame` values, explicit BT.709 range/transfer/chroma metadata, and immutable `Bytes` owners. It depends only on `bytes`; neither domain nor framework gains a codec, OS or GPU dependency. The API and domain re-export the same values. +6. `PlanarPainter` receives these values through the framework API. It uploads three reusable R8 textures, samples them in a shader, and draws one opaque video into the existing nonlinear-sRGB `Bgra8Unorm` target. Geometry or output-size changes do not require a new decoded source extent. No compositor calls or service operations enter a view. + +The independent painter fills its validation target. The current integration candidate below separately routes immutable descriptors through the actual component/layout/scene path, while preserving the default RGBA desktop. Neither path establishes native session or performance acceptance. + +## Ownership and initialized bytes + +The adapter checks all visible plane spans against real `AVBufferRef` owners before constructing a slice. It rejects invalid bounds and falls back for nonpositive strides. Admission **consumes the owned decoder frame**, moving its existing AVFrame and buffer references into a private immutable owner. Unsupported capability returns that same owned frame for software conversion. Neither `av_frame_ref` nor `Video::clone` is needed. A borrowed admission helper would leave the caller's mutable `Video` alive; its safe `data_mut` method does not enforce copy-on-write, so merely retaining another AVFrame header would not establish Rust immutability. [FFmpeg's frame API](https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html) documents its reference ownership and writable-buffer protocol. + +A packed plane (`stride == visible width`) can publish a `Bytes::from_owner` view. Its private owner retains the consumed AVFrame; no safe mutable Rust frame handle remains. The FFmpeg decoder's own reuse must respect the outstanding AVBuffer references. Returning from the decoder does not invalidate retained planes; the final byte owner releases its references. [Bytes' ownership API](https://docs.rs/bytes/1.12.1/bytes/struct.Bytes.html#method.from_owner) keeps the supplied owner alive through retained views. + +Allocated padding is not assumed initialized. Every padded plane instead copies only visible samples row by row into a packed buffer. No slice spans uninitialized row gaps. `referenced_bytes` and `copied_bytes` report these publication choices, not the full underlying AVBuffer allocation or process memory. Retained buffers can outlive decoding; consumers remain responsible for limiting retained frames. Normal streaming has no CPU RGBA publication in the admitted native branch, but GPU upload still uses wgpu staging copies. This is not zero-copy GPU import or hardware decoding. + +For an even 3840×2160 source, visible YUV4208 occupies 12,441,600 bytes; RGBA8 occupies 33,177,600. These are arithmetic payload sizes, not measured memory savings. Decoder reference frames, allocator arenas, staging, target buffers and driver memory are additional costs. + +## Explicit color contract + +Admission requires progressive 8-bit YUV420P, square pixels, no crop or decode error, explicit BT.709 matrix and primaries, known full/limited range, BT.709 or sRGB transfer, and left/center chroma siting. ICC, mastering, light-level and display-transform side data fall back. Unsupported/untagged/alpha formats retain the existing software RGBA branch; this prototype has no default color guess for ordinary untagged videos. + +The shader removes the range offset, reconstructs nonlinear RGB with the BT.709 matrix, then converts a tagged BT.709 transfer to sRGB. BT.709 and sRGB share the supported primaries and D65 white; this subset needs no chromatic-adaptation matrix. The BT.709 curve follows FFmpeg's continuous coefficients, and sRGB encoding follows W3C's conversion. [FFmpeg color utilities](https://github.com/FFmpeg/FFmpeg/blob/n8.1/libavutil/csp.c), [W3C sRGB conversion](https://www.w3.org/TR/css-color-4/#color-conversion-code). + +Lucent's current image textures use `Rgba8Unorm`, and its shader pipeline, snapshot target and actual Wayland `SurfaceConfiguration` use `Bgra8Unorm`. The video shader therefore writes **nonlinear sRGB code values**. Using this painter with an sRGB attachment would apply the transfer twice and is outside its contract. This does not establish color-managed output or HDR support. The old software RGBA converter may preserve a different transfer; byte equality to that converter is not the oracle for correctly tagged BT.709 conversion. + +Chroma coordinates derive from luma sample coordinates, explicit siting offsets and ceil-sized chroma planes. Reusing normalized luma UVs for odd extents would shift the chroma at edges. The current candidate explicitly loads four R8 texels per plane and interpolates in float32, clamping at each actual plane boundary. Its twelve loads per output pixel have a performance cost; there is no 60 fps or memory-gain claim. `Queue::write_texture` accepts checked non-256-aligned row strides; the separate readback path retains its required padded-buffer alignment. [wgpu layout requirements](https://docs.rs/wgpu-types/30.0.0/wgpu_types/struct.TexelCopyBufferLayout.html). + +This is **one-level bilinear sampling**, with no mip chain or proper minification filter. The small odd-size/downscale test checks coordinates and color math; it does not establish high-frequency image quality for 4K→small viewports. Production integration must preserve proper minification, HiDPI density and chroma siting, with actual downscale/odd-edge checks and native whole-path measurements. Reducing quality to meet the memory target is not an accepted tradeoff. + +## Validation scope + +Authored tests cover checked odd geometry/strides, shared owner lifetime, matrix/range/transfer anchors, FFmpeg buffer bounds before slicing, consumed packed ownership and retained immutable clones, padded-row copies, exact fallback-handle return, real tagged-H.264 EOF/timing/rewind/cancellation without RGBA scratch, and unchanged RGBA fallback pixels/timestamps. The example's parser stays inert without explicit `--run` and rejects ambiguous paths. + +Three explicitly ignored `video_planes_gpu_` tests exercise actual Vulkan texture sampling, independent f64 color/resampling expectations (matrix derived from luma weights), both transfers/ranges, odd edges/strides/siting, scaling, opaque output and texture reuse. They permit one output-code step for quantization/interpolation and are added to the existing hosted Vulkan suite. They have not been run locally. Old RGBA visual tests remain required, and no existing goldens/catalogue hashes were refreshed. + +The first compiled source, `e97c5c4`, passed hosted Clippy/workspace/protocol gates and all 18 new non-GPU tests. On actual llvmpipe Vulkan, two new GPU tests passed; the odd/scaled case failed the unchanged one-code tolerance with blue 54 versus the independent expected 52. A model using quantized interpolation fractions and staged UNORM rounding reproduces that result, which **suggests** filtered-R8 precision as a cause; the driver's exact algorithm is not proven. The subsequent explicit float32 candidate passed those same independent tests; the original failure remains recorded. No tolerance was raised. [Recorded hosted failure](https://github.com/xandreeddev/desktop-lab/actions/runs/40442642028/job/121986350957). + +Source `c4a87237ab96b41d0013265e3d8043286e18d64e` subsequently passed hosted fmt, workspace/all-target Clippy, workspace tests, protocol suites and the configured legacy Vulkan suite. All 21 new test names, including the three strict GPU cases, were individually verified. The GPU was llvmpipe CPU Vulkan; this is not native Wayland presentation or hardware performance acceptance. The site job passed; the overall workflow failed the unchanged Component Lab production-input roster. Its old architecture guards also needed an explicit role for the new neutral value crate; the integration branch adds constrained dependency checks and negative fixtures, with 30 Python architecture tests executed successfully. [Actual c4a hosted Rust result](https://github.com/xandreeddev/desktop-lab/actions/runs/40444349431/job/121991926419). + +The independent example can be run in an explicitly owned environment: + +```sh +cargo run --manifest-path lucent/Cargo.toml --locked -p native-video-planes -- \ + --run --source /absolute/tagged-video.mp4 \ + --state /absolute/new-plane-test-store --output /absolute/new-plane-test.png +``` + +It stops after the first frame, waits for the decoder body to return, then draws retained native planes with Vulkan and writes a validation-only PNG. Unsupported input writes the established RGBA fallback and prints that distinction. The RGBA snapshot/readback is deliberately excluded from any future normal-stream allocation claim. It creates an explicit import store but never selects a wallpaper or changes a running session. + +## Experimental desktop paint path + +The integration candidate carries checked `Yuv420Frame` values through `ImageStream::frame_yuv420` into immutable `VideoData`. The declarative client builds `Element::video`; layout emits `Paint::Video` using the same cover/contain geometry, rounded and diagonal clipping, tint and opacity as images. Rendering shares the existing 64 MiB soft resource cache, texture liveness and scene pinning rules. Native textures and uniforms contain no `Rc` cycle; temporary CPU frame owners live through deferred uploads, then submission commits only weak lifetime and stream revision metadata. Live resources may exceed the cache's soft budget, as with existing RGBA scenes; it is not a process-memory limit. + +The client keeps its bounded latest-frame mailbox and one stream identity across RGBA/native fallback. A frozen paused or outgoing frame shares immutable plane ownership but has independent GPU identity. Source timestamps, producer-revision rejection, last-good error presentation, source/FPS changes and output retirement retain the existing coordinator semantics. This does not add worker joins to UI code or establish strict non-overlap between replacement decoder workers. + +Native playback is off by default. For an explicitly owned test session, the composition root accepts `LUCENT_NATIVE_VIDEO_PLANES=1`; there is no persisted preference or automatic enablement. Only a player routed to one known output with known physical cover demand can request planes. Shared, mixed and unknown output routes use existing RGBA playback, including when a stale target exists before output enumeration. The playback adapter independently checks the actual target against source extent: any minification returns the existing fitted RGBA bytes/aspect/timing. Unsupported color metadata and alpha formats also preserve RGBA. This avoids using one aggregate target as evidence for multiple consumers. The client also checks retained live and paused native frames against the **current** route and physical demand before choosing them for paint. Smaller, shared or unknown demand selects the same asset's prepared static RGBA poster until a suitable replacement arrives, including after replacement errors. It preserves visible content and error state without UI-side decoding or conversion. + +Before a worker can admit native pixels, it must prepare that exact asset's RGBA poster through the existing `AssetPort`. Failure uses ordinary fitted RGBA playback instead. One asset/revision-stamped poster slot accompanies the existing latest-frame mailbox, so newer video frames cannot overwrite it before the wake is consumed. The active player validates both tags; late old-source or old-generation completions cannot replace the current handoff. Each native client frame also shares that asset's immutable RGBA poster, so frozen/current/outgoing background layers retain a safe handoff after their producer retires. Before reconciling incoming frames, `BackgroundPresentation` applies the current route's physical demand/capability to both retained layers, including when no new frame is available. Missing or retired producers authorize no native paint. The poster stays with its exact asset's frame or player, including pause/FPS replacement; a different source never inherits it. Default RGBA sessions do not start this extra preparation or retain a new poster. The extra static image and mip storage are real costs requiring whole-path measurement, not assumed memory savings. + +This strengthens the first integrated candidate's last-good behavior: the earlier `521c59b` source could keep unsuitable native pixels after failed replacement, as recorded in the intermediate `3f27b04` source regression. That behavior is not the intended production contract. A direct `Element::video` caller still owns cadence and suitable sampling density; the API does not automatically decode or fit it. + +Pending native replacement is shown as preparing, independently of the safe visible poster; failure status remains visible. Default RGBA status behavior is unchanged. Existing token-based diagonal reveal, clipping and submitted-first timing are preserved when a retained layer changes representation. + +New integration tests are authored for shared API revision/lifetime ownership; fitted-RGBA parity and source/upscale native admission; image/video layout equivalence; default/unknown/shared/output-retirement routing; current-paint RGBA handoff during resize/shared/unknown/paused replacement errors; poster coalescing, authority and preparation failure; independently retained current/outgoing layers and missing-source Desktop routing; native last-good errors, pause/FPS/stale wakes and diagonal transition state. Three additional ignored Vulkan cases exercise the actual Element→Paint→Canvas path at integer/fractional density, clip/tint/opacity parity, frozen/live/RGBA transitions, and input rejection before publication. The first integrated source `8281b87` stopped at two renderer Clippy lints; no Rust tests ran on that source. Its corrected `521c59b` passed the full hosted Rust job: workspace/all-target Clippy, workspace tests, protocol suites and configured legacy Vulkan checks, with all 11 new test names individually verified. All three new Element→Paint→Canvas Vulkan cases and the three earlier strict plane GPU cases passed on llvmpipe. The separate architecture step executed 30 passing Python tests; the site passed, while the unchanged Component Lab roster kept the overall workflow failed. These results qualify the initial paint integration, including its subsequently identified retained-frame handoff gap. [Actual 521c hosted Rust result](https://github.com/xandreeddev/desktop-lab/actions/runs/40451441129/job/122014850124). + +The stronger poster-handoff candidate has 18 new Rust tests relative to c4a: 15 ordinary and three explicitly ignored GPU tests. Those stronger behavior changes have not yet been compiled or executed at this source checkpoint. Qualification of a previous candidate does not execute its new behavior tests. + +## Remaining quality and performance gates + +Before production enablement we still need actual integrated GPU results and native streaming presentation; source-rate and whole-path memory comparisons with identical inputs/backends; ordinary metadata defaults consistent with established software behavior; and physical output/lifecycle testing. Native planes retain one-level bilinear sampling. The current slice protects minification through fitted RGBA fallback rather than claiming a native minification solution. A subsequent renderer implementation must choose filtering using each consumer's actual physical density/cover footprint, reuse bounded resources, preserve chroma siting/odd edges, and pass independent high-frequency downscale and HiDPI comparisons before it replaces that fallback. No heuristic multi-output aggregate can establish those guarantees. The twelve-load shader also requires actual throughput measurement before any source-rate claim. The retarget prototypes and recorded failures remain immutable. diff --git a/docs/wallpaper-retargeting.md b/docs/wallpaper-retargeting.md new file mode 100644 index 00000000..01e416a6 --- /dev/null +++ b/docs/wallpaper-retargeting.md @@ -0,0 +1,127 @@ +# Same-asset playback retargeting + +This is a separate library capability and an independent observer prototype. +The desktop still uses the existing static `WallpaperPlaybackPort::play` API. +This full-demand source extension has not been compiled or measured; it makes +no memory, cadence or improvement claim. Its authored tests and formatting/parser +checks are separate from the parent 0bec hosted-CI result and earlier observer +measurements. + +`RetargetablePlaybackOptions` contains only frame-rate and looping policy. +`WallpaperTargetPort` supplies the sole physical target as a +`WallpaperTargetDemand`: an optional checked `WallpaperPixelTarget` and a nonzero +`WallpaperTargetRevision`. These are transient domain values, independent of +monitor selection, saved preferences, Wayland and rendering. + +## Source-size demand and observation absence + +The inner `WallpaperTargetDemand.target` follows `PlaybackOptions.target`: +`None` requests source/native-size pixels, and `Some(checked_target)` requests +the existing physical-cover conversion. An admitted +`Some(WallpaperTargetDemand { target: None, .. })` is a complete source-size +snapshot. It is different from `WallpaperTargetPort::try_latest() == Ok(None)`, +which means no snapshot was available because of temporary contention. Initial +outer absence still fails admission; later outer absence keeps the previous +demand. It never changes a fitted target to source size or cancels playback. + +The pure `WallpaperTargetDemand::retarget` transition preserves a revision for +equal source-size or fitted demand, advances exactly once for either changed +direction, and fails without mutation at revision exhaustion. The latest-target +adapter performs compare-and-set under its own lock and delegates that policy +to the domain value. Both source-size and fitted snapshots retain their last +admitted value when a port refresh reports an error. + +Source → fitted → source and fitted → source → fitted transitions use the same +admitted decoder/input/timing owner. Conversion receives the optional demand +directly, so source size restores the existing native-size conversion rather +than substituting a small or focused-display target. Only actual converted +extent changes replace conversion scratch and the bounded publication pool. +Alpha-capable/paletted formats retain native pixels in every mode; their demand +revision still changes when the intent changes. + +Authored tests compare every decoded frame, timestamp and source aspect against +static references in both transition directions, including reordered EOF frames, +rewind and callback cancellation. Additional tests cover contention versus source +admission, CAS/no-op/exhaustion, alpha pixels, retired pool ownership and a consumer +retaining last-good pixels while rejecting stale frames and preserving terminal +failure. These tests require hosted execution before being reported as passing. + +The observer CLI still runs its existing FHD → 4K → FHD sequence; this extension +does not add a source-size native measurement or rewrite any frozen comparison +runner/receipt. The full source/target semantics must be included in a separately +bound native comparison before production integration. Startup unknown → FHD can +affect retained allocations and is not excluded from the final resource objective. +The desktop is unchanged. Its integration must keep producer generation, target +revision and streamed-image revision separate, preserve last-good/error visibility, +and await terminal `Ended` ownership before claiming non-overlapping replacement +workers. No UI-thread join or memory improvement is established here. + +`LatestWallpaperTargetAdapter` implements that port in services. A control +writer calls `set(expected_revision, target)`. A stale expected revision fails; +the same target with the current revision is a no-op. Changed demand increments +with `checked_next`, which fails before mutation at exhaustion. The adapter +stores one latest value, not a command queue. Decoder reads use `try_lock` and +return `None` during contention. Initial `None` is an explicit unavailable +error before decoder admission; later `None` keeps the last admitted demand. +Regressing snapshots and equal-revision/different-target snapshots fail. + +`play_retargetable` is an additional port method. Unsupported adapters report an +explicit unavailable capability error; they never call static play as a hidden +restart fallback. `NativeVideoWallpapers` validates the same owned asset as +static play and consumes target snapshots during its existing decode work. +There is no new control-polling thread or timer worker. + +Within a playback cycle, target changes keep the admitted input, decoder, +decoder threads, source shape, presentation timeline, source clock and I/O +deadline owner. Only a change in actual converted pixel extents retires the +converter and its bounded publication pool. The old pool drops before the new +one allocates; already published immutable pixel owners remain valid. Alpha, +palette and unknown-alpha formats keep the existing source-size fallback; +their changed target revision is still attached even when no converter resize +is needed. Source aspect, timestamps, straight alpha, row validation and +filtering policy are unchanged. + +Every published `TargetedMediaFrame` carries the revision captured before +conversion. A new demand can arrive during conversion. The consumer must compare +the result to its current intent and discard stale results; it must never +relabel old pixels with a newer revision. Converter lifetime no longer decides +whether a decoded frame is the first source prefix: source admission separately +tracks dimensions/format and the first decoded PTS. Retargeting therefore cannot +reclassify a later missing timestamp as an unseekable initial prefix. + +EOF behavior remains the existing source policy: fully drain reordered frames, +hold the final source duration, and reuse an eligible live H.264 session through +the bounded seek/flush path. Unsupported or failed EOF seeks retain the existing +reopen path. Target updates themselves do not reopen. One port call does not +claim that every codec/EOF condition retained a single native context. + +## Independent comparison + +The services `video-allocation-observer` accepts optional private JSON +`"retarget": true`. The default remains the measured three-reopen sequence. +The retarget mode makes one play call on one worker for FHD → 4K → FHD. The +coordinator updates only the target adapter and rejects stale revision results. +It keeps one latest allocation across phase boundaries until a valid +replacement arrives, then cancels, joins and drops the final latest once. +An unsupported, failed, early-returning or unjoined producer never authorizes +another playback worker. No phase output is emitted until cleanup completes. + +Each target has a bounded five-second readiness wait and three process-memory +points, with no allocator query while playback is alive. The observation period +starts after the first matching target frame is observed. These phase clocks +include different startup/readiness work from the old reopen mode, so +publication counts or elapsed-time differences are not FPS or a speedup. +Per-target RSS includes the live decoder and held latest frame. Those points +must not be compared directly with the old after-join/after-drop measurements. + +With `allocator_stats` enabled, the whole retarget run has three allocator +observations: before the worker, after its exact join with the latest held, +and after that latest drops. Only the final stopped-state observations offer a +comparable allocator-accounting scope. Free arena bytes remain distinct from +resident RSS; no allocator trimming, overrides, allocation padding, process +offloading, resolution reduction or cadence reduction is used. + +The new `retarget_phase`/`retarget_finished` output requires a separately +reviewed, source-bound runner. Existing frozen runners and receipts must not be +rewritten or reused as proof for this mode. Native output/pixel/EOF/cancellation +validation and the same-process memory comparison must precede desktop wiring. diff --git a/lucent/Cargo.lock b/lucent/Cargo.lock index a7681b54..a75cfa79 100644 --- a/lucent/Cargo.lock +++ b/lucent/Cargo.lock @@ -370,6 +370,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "calloop" version = "0.14.5" @@ -1373,6 +1379,7 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" name = "lucent-api" version = "0.1.0" dependencies = [ + "lucent-video-frame", "unicode-segmentation", ] @@ -1432,6 +1439,7 @@ dependencies = [ name = "lucent-domain" version = "0.1.0" dependencies = [ + "lucent-video-frame", "serde", "zeroize", ] @@ -1551,6 +1559,13 @@ dependencies = [ "lucent-domain", ] +[[package]] +name = "lucent-video-frame" +version = "0.1.0" +dependencies = [ + "bytes", +] + [[package]] name = "lucent-wayland" version = "0.1.0" @@ -1686,6 +1701,16 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "native-video-planes" +version = "0.1.0" +dependencies = [ + "image", + "lucent-domain", + "lucent-render", + "lucent-services", +] + [[package]] name = "ndk-sys" version = "0.6.0+11769913" diff --git a/lucent/apps/lucent-desktop/src/background_presentation.rs b/lucent/apps/lucent-desktop/src/background_presentation.rs index 46135227..d9804df1 100644 --- a/lucent/apps/lucent-desktop/src/background_presentation.rs +++ b/lucent/apps/lucent-desktop/src/background_presentation.rs @@ -1,7 +1,11 @@ //! Transient wallpaper presentation. Selection, decoding and persistence stay //! with their existing owners; this child borrows already prepared images. -use lucent_api::{Color, Element, ImageData, Motion}; +use crate::presentation_frame::PresentationFrame; +#[cfg(test)] +use lucent_api::ImageData; +use lucent_api::{Color, Element, Motion}; use lucent_design::motion; +#[cfg(test)] use std::sync::Arc; /// Content identity comes from the selected immutable asset, never a video's @@ -14,7 +18,7 @@ pub enum Source { struct Current { source: Source, - image: Arc, + image: PresentationFrame, } enum Reveal { @@ -41,7 +45,7 @@ impl Reveal { pub struct BackgroundPresentation { current: Option, - outgoing: Option>, + outgoing: Option, reveal: Reveal, } @@ -59,11 +63,20 @@ impl BackgroundPresentation { /// Admit prepared pixels for the current selection. A missing candidate /// retains the last good presentation while decoding/export is pending. /// Returns whether the root should invalidate this exact background. + #[cfg(test)] pub fn reconcile( &mut self, source: Option, image: Option>, now: f64, + ) -> bool { + self.reconcile_frame(source, image.map(PresentationFrame::from), now) + } + pub fn reconcile_frame( + &mut self, + source: Option, + image: Option, + now: f64, ) -> bool { let (Some(source), Some(image)) = (source, image) else { return false; @@ -103,19 +116,39 @@ impl BackgroundPresentation { // A different output may still consume this live stream. Give the // frozen outgoing frame independent GPU identity without copying // its immutable CPU pixels or making the UI prepare mip levels. - self.outgoing = Some(Arc::new(outgoing.snapshot())); + self.outgoing = Some(outgoing.snapshot()); self.reveal = Reveal::AwaitingSubmission { selected_at: now }; } self.current = Some(Current { source, image }); true } + /// Requalify both retained layers against current output demand, even when + /// no new source frame arrives. This only selects their prepared fallback. + pub fn constrain_native( + &mut self, + target: Option, + ) -> bool { + let mut changed = false; + for frame in self + .current + .iter_mut() + .map(|current| &mut current.image) + .chain(self.outgoing.iter_mut()) + { + let selected = frame.select_for_target(target); + changed |= selected.cache_id() != frame.cache_id(); + *frame = selected; + } + changed + } + pub fn view(&self, now: f64, fallback: Color) -> Element { let background = Element::empty().fill().background(fallback); let Some(current) = &self.current else { return background; }; - let image = |image: &Arc| Element::image(image.clone()).cover().fill(); + let image = |image: &PresentationFrame| image.element().cover().fill(); let mut layers = Vec::with_capacity(3); // The cover image spans this surface. Omit the solid underlay only // when the bottom image proves full coverage; transparent media and @@ -200,7 +233,7 @@ impl BackgroundPresentation { } } -fn same_stream(a: &ImageData, b: &ImageData) -> bool { +fn same_stream(a: &PresentationFrame, b: &PresentationFrame) -> bool { match (a.stream_revision(), b.stream_revision()) { (Some((a, _)), Some((b, _))) => a == b, _ => false, @@ -704,9 +737,181 @@ mod tests { let outgoing = presentation.outgoing.as_ref().unwrap(); assert!(outgoing.stream_revision().is_none()); assert_eq!(outgoing.cache_id(), first.cache_id()); - assert_eq!(outgoing.pixels().as_ptr(), first.pixels().as_ptr()); + assert_eq!( + outgoing.as_image().unwrap().pixels().as_ptr(), + first.pixels().as_ptr() + ); let second = stream.frame_rgba(1, 1, vec![0, 200, 0, 255]).unwrap(); assert_ne!(outgoing.cache_id(), second.cache_id()); - assert_eq!(outgoing.pixels(), [200, 0, 0, 255]); + assert_eq!(outgoing.as_image().unwrap().pixels(), [200, 0, 0, 255]); + } +} + +#[cfg(test)] +mod native_frame_tests { + use super::*; + use lucent_api::{ + ImageStream, Kind, + video_frame::{Bt709, Bytes, ChromaSiting, Plane, Range, Transfer, Yuv420Frame}, + }; + fn native(stream: &ImageStream, value: u8) -> PresentationFrame { + let p = |v| Plane::new(Bytes::from(vec![v]), 1, 1, 1).unwrap(); + let frame = Yuv420Frame::new( + 1, + 1, + [p(value), p(128), p(128)], + Bt709 { + range: Range::Full, + transfer: Transfer::Srgb, + chroma: ChromaSiting::Left, + }, + ) + .unwrap(); + PresentationFrame::native( + Arc::new(stream.frame_yuv420(frame).unwrap()), + Arc::new(ImageData::from_rgba("native-fallback", 1, 1, vec![90, 20, 10, 255]).unwrap()), + ) + } + #[test] + fn native_background_updates_without_restarting_reveal_and_freezes_only_on_source_change() { + let stream = ImageStream::new(); + let first = native(&stream, 30); + let second = native(&stream, 80); + let mut presentation = BackgroundPresentation::default(); + assert!(presentation.reconcile_frame(Some(Source::Live("first".into())), Some(first), 0.)); + assert!(presentation.reconcile_frame( + Some(Source::Live("first".into())), + Some(second.clone()), + 1. + )); + assert!(!presentation.active(1.)); + assert!(presentation.outgoing.is_none()); + let view: Element<()> = presentation.view(1., Color::hex(0)); + assert!(matches!(&view.children[0].kind, Kind::Video(_))); + let image = Arc::new(ImageData::from_rgba("next", 1, 1, vec![20, 40, 60, 255]).unwrap()); + presentation.reconcile_frame(Some(Source::Static("next".into())), Some(image.into()), 2.); + let old = presentation.outgoing.as_ref().unwrap(); + assert_eq!(old.cache_id(), second.cache_id()); + assert!(old.stream_revision().is_none()); + assert_eq!(old.representation(), "yuv420"); + let view: Element<()> = presentation.view(2., Color::hex(0)); + assert!(matches!(&view.children[0].kind, Kind::Video(_))); + assert_eq!( + view.children.last().unwrap().style.diagonal_reveal, + Some(0.) + ); + assert!(presentation.presented(2., 3.)); + assert!(presentation.active(3.)); + assert_eq!(presentation.inspect()["retained_images"], 2); + assert_eq!(presentation.inspect()["retained_frame_bytes"], 11); + } + fn native_with_poster(poster: Arc) -> PresentationFrame { + let y = Plane::new(Bytes::from(vec![160; 16]), 4, 4, 4).unwrap(); + let uv = Plane::new(Bytes::from(vec![128; 4]), 2, 2, 2).unwrap(); + let frame = Yuv420Frame::new( + 4, + 4, + [y, uv.clone(), uv], + Bt709 { + range: Range::Full, + transfer: Transfer::Srgb, + chroma: ChromaSiting::Left, + }, + ) + .unwrap(); + PresentationFrame::native( + Arc::new(ImageStream::new().frame_yuv420(frame).unwrap()), + poster, + ) + } + fn poster() -> Arc { + Arc::new( + ImageData::from_rgba("retained-native-poster", 2, 2, [90, 20, 10, 255].repeat(4)) + .unwrap(), + ) + } + fn assert_only_rgba_paint(presentation: &BackgroundPresentation) { + let view: Element<()> = presentation.view(2., Color::hex(0)); + let scene = lucent_ui::Layout::new(Arc::new(vec![])).build( + &view, + 8., + 8., + &lucent_ui::Interaction::default(), + 2., + ); + assert!( + scene + .paint + .iter() + .any(|paint| matches!(paint, lucent_ui::Paint::Image { .. })) + ); + assert!( + scene + .paint + .iter() + .all(|paint| !matches!(paint, lucent_ui::Paint::Video { .. })) + ); + } + #[test] + fn retained_native_current_uses_own_rgba_when_geometry_changes_without_incoming() { + for target in [ + None, + Some(lucent_domain::WallpaperPixelTarget::new(2, 2).unwrap()), + ] { + let poster = poster(); + let mut presentation = BackgroundPresentation::default(); + presentation.reconcile_frame( + Some(Source::Live("retired".into())), + Some(native_with_poster(poster.clone())), + 0., + ); + assert!(!presentation.constrain_native(Some( + lucent_domain::WallpaperPixelTarget::new(8, 8).unwrap() + ))); + assert!(presentation.constrain_native(target)); + assert!( + !presentation.reconcile_frame(None, None, 1.), + "missing incoming keeps already-qualified visible content" + ); + assert_eq!( + presentation.current.as_ref().unwrap().image.cache_id(), + poster.cache_id() + ); + assert_only_rgba_paint(&presentation); + assert!( + !presentation.constrain_native(target), + "fallback selection is idempotent" + ); + } + } + #[test] + fn retained_native_outgoing_uses_own_rgba_without_restarting_diagonal_reveal() { + for target in [ + None, + Some(lucent_domain::WallpaperPixelTarget::new(2, 2).unwrap()), + ] { + let poster = poster(); + let mut presentation = BackgroundPresentation::default(); + presentation.reconcile_frame( + Some(Source::Live("old".into())), + Some(native_with_poster(poster.clone())), + 0., + ); + let next = Arc::new(ImageData::from_rgba("next", 1, 1, vec![20, 40, 60, 255]).unwrap()); + presentation.reconcile_frame(Some(Source::Static("new".into())), Some(next.into()), 1.); + assert!(presentation.constrain_native(target)); + assert_eq!( + presentation.outgoing.as_ref().unwrap().cache_id(), + poster.cache_id() + ); + assert!(matches!( + presentation.reveal, + Reveal::AwaitingSubmission { selected_at } if selected_at==1. + )); + assert_only_rgba_paint(&presentation); + assert!(presentation.presented(1., 2.)); + assert_eq!(presentation.reveal.value(2.), 0.); + assert_eq!(presentation.reveal.value(2. + motion::WALLPAPER_REVEAL), 1.); + } } } diff --git a/lucent/apps/lucent-desktop/src/desktop.rs b/lucent/apps/lucent-desktop/src/desktop.rs index 98ff6852..96543192 100644 --- a/lucent/apps/lucent-desktop/src/desktop.rs +++ b/lucent/apps/lucent-desktop/src/desktop.rs @@ -498,20 +498,28 @@ impl Desktop { .live_wallpapers .selected(&target) .is_some_and(|profile| profile.enabled); - let live = self.live_wallpaper.frame_source(monitor.as_ref()); + let native_target = self + .live_wallpaper + .native_presentation_target(monitor.as_ref()); + let live = self.live_wallpaper.presentation_source(monitor.as_ref()); let (source, image) = match live { Some((path, frame)) => (Some(Source::Live(path.to_owned())), Some(frame.clone())), None if live_selected => (None, None), None => ( Some(Source::Static(self.wallpaper.asset_path().to_owned())), - self.wallpaper.background.clone(), + self.wallpaper + .background + .clone() + .map(crate::presentation_frame::PresentationFrame::from), ), }; let (_, presentation) = self .backgrounds .entry(instance.clone()) .or_insert_with(|| (generation, Default::default())); - if presentation.reconcile(source, image, effects.now) { + let constrained = presentation.constrain_native(native_target); + let admitted = presentation.reconcile_frame(source, image, effects.now); + if constrained || admitted { effects.redraw_surface(SurfaceAddress { role: "background", instance, diff --git a/lucent/apps/lucent-desktop/src/desktop/system_tests.rs b/lucent/apps/lucent-desktop/src/desktop/system_tests.rs index f5f03e31..747c284f 100644 --- a/lucent/apps/lucent-desktop/src/desktop/system_tests.rs +++ b/lucent/apps/lucent-desktop/src/desktop/system_tests.rs @@ -1045,3 +1045,83 @@ fn widget_keyboard_named_output_transition_cancels_dormant_legacy_intent() { .all(|spec| spec.keyboard == Keyboard::None) ); } + +#[test] +fn desktop_requalifies_retired_native_current_while_new_asset_has_no_frame() { + use crate::background_presentation::{BackgroundPresentation, Source}; + use crate::presentation_frame::PresentationFrame; + use lucent_api::{ + Color, Element, ImageData, ImageStream, + video_frame::{Bt709, Bytes, ChromaSiting, Plane, Range, Transfer, Yuv420Frame}, + }; + use lucent_domain::{LiveWallpaperAsset, LiveWallpaperSettings, Wallpaper}; + use std::sync::Arc; + let mut app = desktop(); + app.live_wallpaper.enable_native_planes(true); + let poster = Arc::new( + ImageData::from_rgba("retired-native-poster", 2, 2, [90, 20, 10, 255].repeat(4)).unwrap(), + ); + let y = Plane::new(Bytes::from(vec![160; 16]), 4, 4, 4).unwrap(); + let uv = Plane::new(Bytes::from(vec![128; 4]), 2, 2, 2).unwrap(); + let planes = Yuv420Frame::new( + 4, + 4, + [y, uv.clone(), uv], + Bt709 { + range: Range::Full, + transfer: Transfer::Srgb, + chroma: ChromaSiting::Left, + }, + ) + .unwrap(); + let frame = PresentationFrame::native( + Arc::new(ImageStream::new().frame_yuv420(planes).unwrap()), + poster.clone(), + ); + let mut presentation = BackgroundPresentation::default(); + presentation.reconcile_frame(Some(Source::Live("retired".into())), Some(frame), 0.); + app.backgrounds.insert(None, (0, presentation)); + let source = Wallpaper { + path: "/fixture/pending.mp4".into(), + name: "Pending source".into(), + }; + app.settings.live_wallpapers.default = Some(LiveWallpaperSettings { + asset: LiveWallpaperAsset { + source: source.clone(), + path: source.path, + poster: Wallpaper { + path: "/fixture/pending.png".into(), + name: "Pending poster".into(), + }, + }, + fps: Default::default(), + paused: false, + enabled: true, + }); + app.update(Message::Ignore, &mut Effects::default()); + let view: Element<()> = app.backgrounds[&None].1.view(1., Color::hex(0)); + let scene = lucent_ui::Layout::new(Arc::new(vec![])).build( + &view, + 8., + 8., + &lucent_ui::Interaction::default(), + 1., + ); + let images: Vec<_> = scene + .paint + .iter() + .filter_map(|paint| match paint { + lucent_ui::Paint::Image { data, .. } => Some(data), + _ => None, + }) + .collect(); + assert_eq!(images.len(), 1); + assert_eq!(images[0].cache_id(), poster.cache_id()); + assert!( + scene + .paint + .iter() + .all(|paint| !matches!(paint, lucent_ui::Paint::Video { .. })) + ); + assert_eq!(app.live_wallpaper.status(), "Preparing live wallpaper…"); +} diff --git a/lucent/apps/lucent-desktop/src/live_wallpaper.rs b/lucent/apps/lucent-desktop/src/live_wallpaper.rs index 60127e68..c757be64 100644 --- a/lucent/apps/lucent-desktop/src/live_wallpaper.rs +++ b/lucent/apps/lucent-desktop/src/live_wallpaper.rs @@ -1,6 +1,7 @@ //! Playback coordinator and bounded presentation handoff. The root owns saved //! preferences; this child owns pending import, active producers and panel state. use crate::ports::AssetPort; +use crate::presentation_frame::PresentationFrame; use lucent_api::{ Cancellation, Effects, ImageData, ImageStream, Subscription, TextEdit, TextFieldState, }; @@ -71,12 +72,20 @@ pub struct SaveState<'a> { pub writable: bool, } enum Delivery { - Frame(Arc), + Frame(PresentationFrame), Error(String), } +struct PreparedPoster { + asset: String, + revision: u64, + image: Arc, +} #[derive(Default)] struct Mailbox { latest: Option, + /// One worker-prepared same-asset RGBA poster, independent of replaceable + /// video frames so latest-frame coalescing cannot discard the safe handoff. + poster: Option, notified: bool, source_frames: Option, } @@ -89,6 +98,17 @@ impl Mailbox { } fn publish(&mut self, item: Delivery) -> bool { self.latest = Some(item); + self.notify() + } + fn publish_poster(&mut self, asset: &str, revision: u64, image: Arc) -> bool { + self.poster = Some(PreparedPoster { + asset: asset.into(), + revision, + image, + }); + self.notify() + } + fn notify(&mut self) -> bool { if self.notified { false } else { @@ -107,23 +127,40 @@ struct Player { fps: WallpaperFrameRate, target: Option, mailbox: Arc>, - frame: Option>, + frame: Option, + /// Only Image variants are stored here, prepared through AssetPort. Keep + /// this same-asset static/mipped image while native frames may be retained. + rgba_poster: Option, running: bool, + awaiting_frame: bool, + native_planes: bool, error: String, } +impl Player { + fn presentation<'a>(&'a self, frame: &'a PresentationFrame) -> Option<&'a PresentationFrame> { + if matches!(frame, PresentationFrame::Video { .. }) + && (!self.native_planes + || self.target.is_none_or(|target| { + !target + .cover_size(frame.width(), frame.height()) + .is_ok_and(|size| size == (frame.width(), frame.height())) + })) + { + self.rgba_poster.as_ref() + } else { + Some(frame) + } + } +} struct Route { key: String, pause: Option, /// A static GPU identity is essential when another output still updates /// the shared stream texture. Pixels remain shared immutable storage. - held: Option>, + held: Option, } -fn frozen_frame(frame: &Arc) -> Arc { - if frame.stream_revision().is_some() { - Arc::new(frame.snapshot()) - } else { - frame.clone() - } +fn frozen_frame(frame: &PresentationFrame) -> PresentationFrame { + frame.snapshot() } struct Import { revision: u64, @@ -145,6 +182,7 @@ pub struct LiveWallpaper { awaiting_first_frame: Option, first_frame_submitted: bool, source_frame_profiling: bool, + native_planes: bool, importing: Option, revision: u64, players: BTreeMap, @@ -182,6 +220,7 @@ impl LiveWallpaper { awaiting_first_frame: None, first_frame_submitted: false, source_frame_profiling: false, + native_planes: false, importing: None, revision: 0, players: BTreeMap::new(), @@ -209,12 +248,21 @@ impl LiveWallpaper { .source_frames = enabled.then(frame_trace::SourceFrames::default); } } + /// Default/legacy RGBA observation. Native presentation uses the typed + /// presentation_source below, never an empty or fabricated RGBA buffer. #[cfg(test)] pub fn frame(&self, monitor: Option<&MonitorId>) -> Option<&Arc> { self.frame_source(monitor).map(|(_, frame)| frame) } - /// Selected asset identity paired with the actual prepared frame. + #[cfg(test)] pub fn frame_source(&self, monitor: Option<&MonitorId>) -> Option<(&str, &Arc)> { + self.presentation_source(monitor) + .and_then(|(source, frame)| frame.as_image().map(|image| (source, image))) + } + pub fn presentation_source( + &self, + monitor: Option<&MonitorId>, + ) -> Option<(&str, &PresentationFrame)> { let target = monitor .cloned() .map_or(LiveWallpaperTarget::Default, LiveWallpaperTarget::Monitor); @@ -222,9 +270,33 @@ impl LiveWallpaper { let player = self.players.get(&route.key)?; Some(( player.asset.path.as_str(), - route.held.as_ref().or(player.frame.as_ref())?, + player.presentation( + route + .held + .as_ref() + .or(player.frame.as_ref()) + .or(player.rgba_poster.as_ref())?, + )?, )) } + /// Current route admission for independently retained background layers. + /// A missing/retired producer is unknown and must not authorize native paint. + pub fn native_presentation_target( + &self, + monitor: Option<&MonitorId>, + ) -> Option { + let target = monitor + .cloned() + .map_or(LiveWallpaperTarget::Default, LiveWallpaperTarget::Monitor); + let route = self.routes.get(&target)?; + let player = self.players.get(&route.key)?; + player.native_planes.then_some(player.target).flatten() + } + /// Composition-root experimental policy. Mixed/shared/unknown outputs keep + /// fitted RGBA; enable before the normal host reconciliation starts. + pub fn enable_native_planes(&mut self, enabled: bool) { + self.native_planes = enabled; + } pub fn reconcile( &mut self, settings: &LiveWallpapers, @@ -244,7 +316,12 @@ impl LiveWallpaper { }; let mut wanted: BTreeMap< String, - (LiveWallpaperSettings, bool, Option), + ( + LiveWallpaperSettings, + bool, + Option, + usize, + ), > = BTreeMap::new(); let mut routes = BTreeMap::new(); let mut seeds = BTreeMap::new(); @@ -278,7 +355,8 @@ impl LiveWallpaper { // Covered outputs retain their own frozen presentation frame. wanted .entry(key) - .and_modify(|(_, running, target)| { + .and_modify(|(_, running, target, count)| { + *count += 1; *running |= pause.is_none(); // Each connected consumer, including a paused output, // must retain cover density. Unknown geometry keeps @@ -292,7 +370,7 @@ impl LiveWallpaper { _ => None, }; }) - .or_insert((setting.clone(), pause.is_none(), pixels)); + .or_insert((setting.clone(), pause.is_none(), pixels, 1)); } } // A changed FPS/pause profile retires its producer identity, but keeps @@ -307,15 +385,32 @@ impl LiveWallpaper { .map(|frame| (player.asset.path.clone(), frame.clone())) }) .collect(); + let last_posters: BTreeMap<_, _> = self + .players + .values() + .filter_map(|player| { + player + .rgba_poster + .as_ref() + .map(|poster| (player.asset.path.clone(), poster.clone())) + }) + .collect(); self.players.retain(|key, _| wanted.contains_key(key)); self.routes = routes; - for (key, (setting, running, target)) in wanted.into_iter().take(4) { + for (key, (setting, running, target, count)) in wanted.into_iter().take(4) { + let native_planes = + self.native_planes && outputs_known && count == 1 && target.is_some(); if let Some(player) = self.players.get_mut(&key) { - if player.running != running || player.target != target { + if player.running != running + || player.target != target + || player.native_planes != native_planes + { self.revision = self.revision.wrapping_add(1); player.revision = self.revision; player.running = running; + player.awaiting_frame = true; player.target = target; + player.native_planes = native_planes; player.error.clear(); player.mailbox = Arc::new(Mutex::new(Mailbox::traced(self.source_frame_profiling))); @@ -326,7 +421,18 @@ impl LiveWallpaper { .get(&key) .cloned() .or_else(|| last_frames.get(&setting.asset.path).cloned()) - .or_else(|| self.prepared.get(&setting.asset.path).cloned()); + .or_else(|| { + self.prepared + .get(&setting.asset.path) + .cloned() + .map(PresentationFrame::from) + }); + let rgba_poster = last_posters.get(&setting.asset.path).cloned().or_else(|| { + native_planes + .then(|| self.prepared.get(&setting.asset.path).cloned()) + .flatten() + .map(PresentationFrame::from) + }); self.players.insert( key, Player { @@ -336,7 +442,10 @@ impl LiveWallpaper { target, mailbox: Arc::new(Mutex::new(Mailbox::traced(self.source_frame_profiling))), frame, + rgba_poster, running, + awaiting_frame: true, + native_planes, error: String::new(), }, ); @@ -367,10 +476,19 @@ impl LiveWallpaper { let fps = player.fps; let target = player.target; let running = player.running; + let native_planes = player.native_planes; + let prepared_poster = player + .rgba_poster + .as_ref() + .and_then(PresentationFrame::as_image) + .cloned(); let source_frame_profiling = self.source_frame_profiling; Subscription::stream(format!("live-wallpaper:{revision}"), move |out, cancel| { let stream = ImageStream::new(); let stop = Arc::new(PlaybackStopAdapter(cancel)); + if stop.cancelled() { + return; + } let publish = |delivery| { if stop.cancelled() { return; @@ -388,48 +506,111 @@ impl LiveWallpaper { // Delivery remains bounded and never carries pixels in // a runtime message. This finite worker retires on Wake. publish(match assets.background(&asset.poster.path, None) { - Ok(image) => Delivery::Frame(image), + Ok(image) => Delivery::Frame(image.into()), Err(error) => Delivery::Error(error.to_string()), }); return; } - adapter.play( - &asset, - PlaybackOptions { - fps, - looping: true, - target, - }, - &mut |result| { - if stop.cancelled() { - return false; + // Native pixels are admitted only after this worker has a + // same-asset static RGBA fallback. If poster preparation + // fails, use the existing fitted RGBA decoder instead. + let native_poster = if native_planes { + match prepared_poster + .map(Ok) + .unwrap_or_else(|| assets.background(&asset.poster.path, None)) + { + Ok(image) => { + if stop.cancelled() { + return; + } + let notify = mailbox + .lock() + .unwrap_or_else(|p| p.into_inner()) + .publish_poster(&asset.path, revision, image.clone()); + if notify { + out.send(Message::Wake(key.clone(), revision)); + } + Some(image) } - let delivery = match result.and_then(|frame| { - let timestamp = frame.timestamp; - let (w, h, bytes, aspect) = frame.into_shared_rgba_with_aspect(); - let image = stream - .frame_rgba_shared_with_aspect(w, h, bytes, aspect) - .map_err(|e| DomainError::Failed(e.to_string()))?; - if source_frame_profiling - && let Some(trace) = mailbox - .lock() - .unwrap_or_else(|p| p.into_inner()) - .source_frames - .as_mut() - && let Some(identity) = image.stream_revision() - { - trace.record(revision, identity, timestamp); + Err(_) => None, + } + } else { + None + }; + let mut deliver = |result: Result| { + if stop.cancelled() { + return false; + } + let delivery = match result.and_then(|frame| { + let timestamp = frame.timestamp(); + let image: PresentationFrame = match frame { + NativeMediaFrame::Rgba(frame) => { + let (w, h, bytes, aspect) = + frame.into_shared_rgba_with_aspect(); + Arc::new( + stream + .frame_rgba_shared_with_aspect(w, h, bytes, aspect) + .map_err(|e| DomainError::Failed(e.to_string()))?, + ) + .into() + } + NativeMediaFrame::Yuv420 { frame, .. } => { + PresentationFrame::native( + Arc::new( + stream + .frame_yuv420(frame) + .map_err(|e| DomainError::Failed(e.to_string()))?, + ), + native_poster.clone().ok_or_else(|| { + DomainError::Failed( + "Native delivery has no prepared RGBA handoff" + .into(), + ) + })?, + ) } - Ok(Arc::new(image)) - }) { - Ok(image) => Delivery::Frame(image), - Err(error) => Delivery::Error(error.to_string()), }; - publish(delivery); - !stop.cancelled() - }, - stop.clone(), - ); + if source_frame_profiling + && let Some(trace) = mailbox + .lock() + .unwrap_or_else(|p| p.into_inner()) + .source_frames + .as_mut() + && let Some(identity) = image.stream_revision() + { + trace.record(revision, identity, timestamp); + } + Ok(image) + }) { + Ok(frame) => Delivery::Frame(frame), + Err(error) => Delivery::Error(error.to_string()), + }; + publish(delivery); + !stop.cancelled() + }; + if native_poster.is_some() { + adapter.play_native( + &asset, + NativePlaybackOptions { + fps, + looping: true, + target, + }, + &mut deliver, + stop.clone(), + ); + } else { + adapter.play( + &asset, + PlaybackOptions { + fps, + looping: true, + target, + }, + &mut |value| deliver(value.map(NativeMediaFrame::Rgba)), + stop.clone(), + ); + } }) }) .collect(); @@ -712,13 +893,30 @@ impl LiveWallpaper { if let Some(player) = self.players.get_mut(&key) && player.revision == revision { - let item = player - .mailbox - .lock() - .unwrap_or_else(|p| p.into_inner()) - .take(); + let (poster, item) = { + let mut mailbox = player.mailbox.lock().unwrap_or_else(|p| p.into_inner()); + (mailbox.poster.take(), mailbox.take()) + }; + let poster = poster.filter(|poster| { + poster.asset == player.asset.path && poster.revision == revision + }); + let poster_changed = poster.is_some(); + if let Some(poster) = poster { + player.rgba_poster = Some(poster.image.into()); + } match item { Some(Delivery::Frame(image)) => { + if matches!(&image, PresentationFrame::Video { .. }) + && player.rgba_poster.is_none() + { + player.awaiting_frame = false; + player.error = "Native frame has no prepared RGBA handoff".into(); + return None; + } + if self.native_planes && player.awaiting_frame { + effects.redraw("live-wallpaper"); + } + player.awaiting_frame = false; player.frame = Some(image.clone()); return Some(Intent::FrameChanged( self.routes @@ -737,9 +935,24 @@ impl LiveWallpaper { .collect(), )); } - Some(Delivery::Error(error)) => player.error = error, + Some(Delivery::Error(error)) => { + if self.native_planes { + effects.redraw("live-wallpaper"); + } + player.awaiting_frame = false; + player.error = error; + } None => {} } + if poster_changed { + return Some(Intent::FrameChanged( + self.routes + .iter() + .filter(|(_, route)| route.key == key) + .map(|(target, _)| target.clone()) + .collect(), + )); + } } } Message::Pause(target, value) => { @@ -775,6 +988,7 @@ impl LiveWallpaper { { self.revision = self.revision.wrapping_add(1); player.revision = self.revision; + player.awaiting_frame = true; player.error.clear(); player.mailbox = Arc::new(Mutex::new(Mailbox::traced(self.source_frame_profiling))); @@ -788,6 +1002,7 @@ impl LiveWallpaper { .values_mut() .find(|player| player.revision == revision) { + player.awaiting_frame = false; player.error = reason; } } @@ -947,7 +1162,9 @@ impl LiveWallpaper { WallpaperPauseReason::Fullscreen => "Paused behind a fullscreen app", }; } - return if player.running { + return if self.native_planes && player.awaiting_frame && player.running { + "Preparing live wallpaper…" + } else if player.running { "Playing" } else { "Suspended" @@ -965,6 +1182,9 @@ impl LiveWallpaper { &self.error } else if let Some(player) = self.players.values().find(|p| !p.error.is_empty()) { &player.error + } else if self.native_planes && self.players.values().any(|p| p.running && p.awaiting_frame) + { + "Preparing live wallpaper…" } else if self.players.values().any(|p| p.running) { "Playing · bounded latest-frame delivery" } else if !self.players.is_empty() { @@ -974,15 +1194,29 @@ impl LiveWallpaper { } } pub fn inspect(&self) -> serde_json::Value { - let retained = crate::previews::usage( - self.players - .values() - .filter_map(|player| player.frame.as_ref()) - .chain(self.routes.values().filter_map(|route| route.held.as_ref())), - ); - serde_json::json!({"open":self.open,"awaiting_first_frame":self.awaiting_first_frame.is_some(),"importing":self.importing.is_some(),"library_loading":self.library_loading,"library_count":self.library.len(),"library_error":self.library_error,"selection":self.selection,"preview":self.preview.inspect(),"players":self.players.len(),"running":self.players.values().filter(|p|p.running&&p.error.is_empty()).count(),"retained_frame_bytes":retained.bytes,"producers":self.players.values().map(|player| { + let frames: Vec<_> = self + .players + .values() + .flat_map(|player| player.frame.iter().chain(player.rgba_poster.iter())) + .chain(self.routes.values().filter_map(|route| route.held.as_ref())) + .collect(); + let rgba = crate::previews::usage(frames.iter().filter_map(|frame| frame.as_image())); + let mut plane_views = BTreeMap::new(); + for frame in &frames { + if let PresentationFrame::Video { data, .. } = frame { + for plane in data.frame().planes() { + plane_views.insert( + (plane.bytes().as_ptr() as usize, plane.bytes().len()), + plane.bytes().len(), + ); + } + } + } + // Logical retained published views, not underlying AVBuffer capacity/RSS. + let retained_bytes = rgba.bytes + plane_views.values().sum::(); + serde_json::json!({"open":self.open,"awaiting_first_frame":self.awaiting_first_frame.is_some(),"importing":self.importing.is_some(),"library_loading":self.library_loading,"library_count":self.library.len(),"library_error":self.library_error,"selection":self.selection,"preview":self.preview.inspect(),"players":self.players.len(),"running":self.players.values().filter(|p|p.running&&p.error.is_empty()).count(),"retained_frame_bytes":retained_bytes,"native_planes_opt_in":self.native_planes,"producers":self.players.values().map(|player| { let source_frames=player.mailbox.lock().unwrap_or_else(|p|p.into_inner()).source_frames.as_ref().map(frame_trace::SourceFrames::json); - serde_json::json!({"revision":player.revision,"running":player.running,"target":player.target.map(WallpaperPixelTarget::size),"frame_size":player.frame.as_ref().map(|frame|(frame.width(),frame.height())),"source_aspect":player.frame.as_ref().map(|frame|frame.aspect_size()),"source_frames":source_frames}) + serde_json::json!({"revision":player.revision,"running":player.running,"native_requested":player.native_planes,"awaiting_frame":player.awaiting_frame,"representation":player.frame.as_ref().map(PresentationFrame::representation),"target":player.target.map(WallpaperPixelTarget::size),"frame_size":player.frame.as_ref().map(|frame|(frame.width(),frame.height())),"source_aspect":player.frame.as_ref().map(|frame|frame.aspect_size()),"source_frames":source_frames}) }).collect::>(),"error":self.status(),"outputs":self.routes.iter().map(|(target,route)| serde_json::json!({"monitor":target.monitor().map(MonitorId::as_str),"pause_reason":route.pause})).collect::>()}) } } diff --git a/lucent/apps/lucent-desktop/src/live_wallpaper/tests.rs b/lucent/apps/lucent-desktop/src/live_wallpaper/tests.rs index a6d3a067..25de17fc 100644 --- a/lucent/apps/lucent-desktop/src/live_wallpaper/tests.rs +++ b/lucent/apps/lucent-desktop/src/live_wallpaper/tests.rs @@ -5,12 +5,12 @@ fn mailbox_keeps_only_latest_frame_and_one_pending_wake() { let mut mailbox = Mailbox::default(); for n in 0..1000 { let frame = Arc::new(stream.frame_rgba(1, 1, vec![n as u8, 0, 0, 255]).unwrap()); - assert_eq!(mailbox.publish(Delivery::Frame(frame)), n == 0); + assert_eq!(mailbox.publish(Delivery::Frame(frame.into())), n == 0); } let Some(Delivery::Frame(image)) = mailbox.take() else { panic!("missing latest frame") }; - assert_eq!(image.pixels()[0], 999u16 as u8); + assert_eq!(image.as_image().unwrap().pixels()[0], 999u16 as u8); assert!(!mailbox.notified); assert!(mailbox.latest.is_none()); assert!(mailbox.publish(Delivery::Error("fixture".into()))); @@ -856,7 +856,7 @@ fn shared_streams_pause_without_workers_and_hotplug_rejects_old_frames() { .mailbox .lock() .unwrap() - .publish(Delivery::Frame(image)); + .publish(Delivery::Frame(image.into())); let mut effects = Effects::default(); assert!( matches!(controller.update(Message::Wake(key.clone(),revision),&mut effects),Some(Intent::FrameChanged(targets)) if targets.len()==2) @@ -1289,7 +1289,7 @@ fn cancelled_import_and_decoder_error_keep_configuration_and_last_frame_separate .mailbox .lock() .unwrap() - .publish(Delivery::Frame(image.clone())); + .publish(Delivery::Frame(image.clone().into())); controller.update(Message::Wake(key.clone(), revision), &mut effects); controller.players[&key] .mailbox @@ -1314,7 +1314,7 @@ fn put_frame(controller: &mut LiveWallpaper, target: &LiveWallpaperTarget) -> Ar .mailbox .lock() .unwrap() - .publish(Delivery::Frame(frame.clone())); + .publish(Delivery::Frame(frame.clone().into())); controller.update(Message::Wake(key, revision), &mut Effects::default()); frame } @@ -1587,7 +1587,7 @@ fn fullscreen_freezes_one_output_without_stopping_or_repainting_its_visible_peer .mailbox .lock() .unwrap() - .publish(Delivery::Frame(later.clone())); + .publish(Delivery::Frame(later.clone().into())); let notice = controller.update( Message::Wake(key.clone(), revision), &mut Effects::default(), @@ -1796,3 +1796,572 @@ fn session_lock_observation_lifetime_and_failure_preserve_manual_preferences() { "removing playback retires lock observation" ); } + +fn deliver_current_subscription(controller: &mut LiveWallpaper) { + let subscription = controller.subscriptions().pop().unwrap(); + deliver_subscription(controller, subscription); +} +#[derive(Default)] +struct NativePlanePlayback { + calls: Mutex)>>, + marker: std::sync::atomic::AtomicU8, +} +fn native_poster() -> Arc { + Arc::new(ImageData::from_rgba("native-safe-poster", 2, 2, [90, 20, 10, 255].repeat(4)).unwrap()) +} +fn neutral_planes(marker: u8) -> lucent_api::video_frame::Yuv420Frame { + use lucent_api::video_frame::{ + Bt709, Bytes, ChromaSiting, Plane, Range, Transfer, Yuv420Frame, + }; + Yuv420Frame::new( + 2, + 2, + [ + Plane::new(Bytes::from(vec![marker; 4]), 2, 2, 2).unwrap(), + Plane::new(Bytes::from(vec![128]), 1, 1, 1).unwrap(), + Plane::new(Bytes::from(vec![128]), 1, 1, 1).unwrap(), + ], + Bt709 { + range: Range::Full, + transfer: Transfer::Srgb, + chroma: ChromaSiting::Left, + }, + ) + .unwrap() +} +impl WallpaperPlaybackPort for NativePlanePlayback { + fn prepare(&self, _: &Wallpaper) -> Result { + Err(DomainError::Unavailable("unused".into())) + } + fn play( + &self, + _: &LiveWallpaperAsset, + options: PlaybackOptions, + emit: &mut dyn FnMut(Result) -> bool, + _: Arc, + ) { + self.calls.lock().unwrap().push((false, options.target)); + emit(MediaFrame::rgba( + 2, + 2, + [30, 40, 50, 255].repeat(4), + std::time::Duration::ZERO, + )); + } + fn play_native( + &self, + _: &LiveWallpaperAsset, + options: NativePlaybackOptions, + emit: &mut dyn FnMut(Result) -> bool, + _: Arc, + ) { + self.calls.lock().unwrap().push((true, options.target)); + let marker = 11 + + self + .marker + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + emit(Ok(NativeMediaFrame::Yuv420 { + frame: neutral_planes(marker), + timestamp: std::time::Duration::from_millis(120), + publication: PlanePublication { + referenced_bytes: 0, + copied_bytes: 6, + }, + })); + } +} +#[test] +fn native_plane_opt_in_keeps_default_unknown_and_shared_routes_on_rgba() { + let adapter = Arc::new(NativePlanePlayback::default()); + let mut controller = LiveWallpaper::new(adapter.clone(), Arc::new(Posters(native_poster()))); + let settings = LiveWallpapers { + default: Some(setting("/fixture/native.mp4")), + ..Default::default() + }; + let left = MonitorId::new("native-left").unwrap(); + let right = MonitorId::new("native-right").unwrap(); + let context = WallpaperPlaybackContext::default(); + let mut targets = BTreeMap::from([( + LiveWallpaperTarget::Monitor(left.clone()), + pixel_target(16, 16), + )]); + controller.reconcile( + &settings, + std::slice::from_ref(&left), + true, + &context, + &targets, + ); + deliver_current_subscription(&mut controller); + assert!(controller.frame(Some(&left)).is_some()); + assert!(!adapter.calls.lock().unwrap().last().unwrap().0); + controller.enable_native_planes(true); + let unobserved = BTreeMap::from([(LiveWallpaperTarget::Default, pixel_target(16, 16))]); + controller.reconcile(&settings, &[], false, &context, &unobserved); + deliver_current_subscription(&mut controller); + assert!( + !adapter.calls.lock().unwrap().last().unwrap().0, + "unobserved output enumeration stays RGBA even with a stale target" + ); + controller.reconcile( + &settings, + std::slice::from_ref(&left), + true, + &context, + &BTreeMap::new(), + ); + deliver_current_subscription(&mut controller); + assert!( + !adapter.calls.lock().unwrap().last().unwrap().0, + "unknown dimensions stay RGBA" + ); + controller.reconcile( + &settings, + std::slice::from_ref(&left), + true, + &context, + &targets, + ); + deliver_current_subscription(&mut controller); + let frame = controller.presentation_source(Some(&left)).unwrap().1; + assert_eq!(frame.representation(), "yuv420"); + assert!(controller.frame(Some(&left)).is_none()); + assert_eq!( + adapter.calls.lock().unwrap().last(), + Some(&(true, Some(pixel_target(16, 16)))) + ); + targets.insert( + LiveWallpaperTarget::Monitor(right.clone()), + pixel_target(8, 8), + ); + controller.reconcile(&settings, &[left.clone(), right], true, &context, &targets); + deliver_current_subscription(&mut controller); + assert!( + !adapter.calls.lock().unwrap().last().unwrap().0, + "a max aggregate cannot prove native density for each consumer" + ); + assert_eq!( + controller + .presentation_source(Some(&left)) + .unwrap() + .1 + .representation(), + "rgba" + ); + let retired = controller.subscriptions().pop().unwrap(); + controller.reconcile( + &settings, + std::slice::from_ref(&left), + true, + &context, + &targets, + ); + deliver_current_subscription(&mut controller); + let restored = controller + .presentation_source(Some(&left)) + .unwrap() + .1 + .cache_id(); + deliver_subscription(&mut controller, retired); + assert_eq!( + controller + .presentation_source(Some(&left)) + .unwrap() + .1 + .cache_id(), + restored, + "retired shared RGBA worker cannot overwrite the newly admitted native owner" + ); + assert_eq!( + controller + .presentation_source(Some(&left)) + .unwrap() + .1 + .representation(), + "yuv420" + ); +} +#[test] +fn native_plane_frames_keep_last_good_and_freeze_across_pause_fps_and_stale_wakes() { + let adapter = Arc::new(NativePlanePlayback::default()); + let mut controller = LiveWallpaper::new(adapter, Arc::new(Posters(native_poster()))); + controller.enable_native_planes(true); + let mut settings = LiveWallpapers { + default: Some(setting("/fixture/native-pause.mp4")), + ..Default::default() + }; + let id = MonitorId::new("native-only").unwrap(); + let monitors = std::slice::from_ref(&id); + let targets = BTreeMap::from([( + LiveWallpaperTarget::Monitor(id.clone()), + pixel_target(16, 16), + )]); + let context = WallpaperPlaybackContext::default(); + controller.reconcile(&settings, monitors, true, &context, &targets); + deliver_current_subscription(&mut controller); + let first = controller.presentation_source(Some(&id)).unwrap().1.clone(); + let late = controller.subscriptions().pop().unwrap(); + let key = controller.routes[&LiveWallpaperTarget::Monitor(id.clone())] + .key + .clone(); + let revision = controller.players[&key].revision; + controller.players[&key] + .mailbox + .lock() + .unwrap() + .publish(Delivery::Error("native fixture failed".into())); + controller.update( + Message::Wake(key.clone(), revision), + &mut Effects::default(), + ); + assert_eq!( + controller + .presentation_source(Some(&id)) + .unwrap() + .1 + .cache_id(), + first.cache_id() + ); + assert!(controller.status().contains("native fixture failed")); + settings.default.as_mut().unwrap().paused = true; + controller.reconcile(&settings, monitors, true, &context, &targets); + let held = controller.presentation_source(Some(&id)).unwrap().1.clone(); + assert_eq!(held.cache_id(), first.cache_id()); + assert!(held.stream_revision().is_none()); + assert!(controller.subscriptions().is_empty()); + settings.default.as_mut().unwrap().fps = + WallpaperFrameRate::Capped(WallpaperFps::new(60).unwrap()); + controller.reconcile(&settings, monitors, true, &context, &targets); + deliver_subscription(&mut controller, late); + assert_eq!( + controller + .presentation_source(Some(&id)) + .unwrap() + .1 + .cache_id(), + held.cache_id() + ); + let PresentationFrame::Video { data, .. } = + controller.presentation_source(Some(&id)).unwrap().1 + else { + panic!("native frozen frame lost") + }; + assert_eq!(data.frame().planes()[0].row(0).unwrap(), &[11, 11]); + settings.default.as_mut().unwrap().paused = false; + controller.reconcile(&settings, monitors, true, &context, &targets); + deliver_current_subscription(&mut controller); + assert!( + controller + .presentation_source(Some(&id)) + .unwrap() + .1 + .stream_revision() + .is_some() + ); + assert_ne!( + controller + .presentation_source(Some(&id)) + .unwrap() + .1 + .cache_id(), + held.cache_id() + ); +} + +fn assert_rgba_presentation(controller: &LiveWallpaper, id: &MonitorId, poster: &ImageData) { + let frame = controller.presentation_source(Some(id)).unwrap().1; + assert_eq!(frame.cache_id(), poster.cache_id()); + let view: lucent_api::Element<()> = frame.element().fill(); + let scene = lucent_ui::Layout::new(Arc::new(vec![])).build( + &view, + 8., + 8., + &lucent_ui::Interaction::default(), + 0., + ); + let lucent_ui::Paint::Image { data, .. } = &scene.paint[0] else { + panic!("current paint must use the prepared RGBA handoff") + }; + assert_eq!(data.cache_id(), poster.cache_id()); + assert_eq!(data.pixels(), poster.pixels()); +} +#[test] +fn native_geometry_changes_paint_same_asset_rgba_during_pending_or_failed_replacement() { + for case in [ + "smaller", + "shared", + "unobserved", + "withdrawn", + "paused-smaller", + "paused-withdrawn", + "paused-shared", + ] { + let adapter = Arc::new(NativePlanePlayback::default()); + let poster = native_poster(); + let mut controller = LiveWallpaper::new(adapter, Arc::new(Posters(poster.clone()))); + controller.enable_native_planes(true); + let mut settings = LiveWallpapers { + default: Some(setting("/fixture/native-safe.mp4")), + ..Default::default() + }; + let left = MonitorId::new("safe-left").unwrap(); + let right = MonitorId::new("safe-right").unwrap(); + let mut monitors = vec![left.clone()]; + let context = WallpaperPlaybackContext::default(); + let mut targets = BTreeMap::from([( + LiveWallpaperTarget::Monitor(left.clone()), + pixel_target(16, 16), + )]); + controller.reconcile(&settings, &monitors, true, &context, &targets); + deliver_current_subscription(&mut controller); + assert_eq!( + controller + .presentation_source(Some(&left)) + .unwrap() + .1 + .representation(), + "yuv420" + ); + let old = controller.subscriptions().pop().unwrap(); + if case.starts_with("paused-") { + settings.default.as_mut().unwrap().paused = true; + } + if case.ends_with("smaller") { + targets.insert( + LiveWallpaperTarget::Monitor(left.clone()), + pixel_target(1, 1), + ); + } + if case.ends_with("shared") { + monitors.push(right.clone()); + targets.insert(LiveWallpaperTarget::Monitor(right), pixel_target(16, 16)); + } + if case.ends_with("withdrawn") { + targets.clear(); + } + controller.reconcile( + &settings, + &monitors, + case != "unobserved", + &context, + &targets, + ); + assert_rgba_presentation(&controller, &left, &poster); + assert_eq!( + controller.selected_status(&settings, &LiveWallpaperTarget::Monitor(left.clone())), + if case.starts_with("paused-") { + "Paused" + } else { + "Preparing live wallpaper…" + } + ); + let key = controller.routes[&LiveWallpaperTarget::Monitor(left.clone())] + .key + .clone(); + let player = &controller.players[&key]; + let revision = player.revision; + player + .mailbox + .lock() + .unwrap() + .publish(Delivery::Error("replacement fixture failed".into())); + let mut failed = Effects::default(); + controller.update(Message::Wake(key, revision), &mut failed); + assert!( + failed.redraw.contains("live-wallpaper"), + "failure status must invalidate its visible panel" + ); + assert!( + controller.status().contains("replacement fixture failed"), + "{case}" + ); + assert_rgba_presentation(&controller, &left, &poster); + deliver_subscription(&mut controller, old); + assert_rgba_presentation(&controller, &left, &poster); + if case.starts_with("paused-") { + settings.default.as_mut().unwrap().fps = + WallpaperFrameRate::Capped(WallpaperFps::new(60).unwrap()); + controller.reconcile(&settings, &monitors, true, &context, &targets); + assert_rgba_presentation(&controller, &left, &poster); + } + } +} +#[test] +fn native_poster_survives_latest_frame_coalescing_before_one_generation_wake() { + let poster = native_poster(); + let mut controller = LiveWallpaper::new( + Arc::new(NativePlanePlayback::default()), + Arc::new(Posters(poster.clone())), + ); + controller.enable_native_planes(true); + let settings = LiveWallpapers { + default: Some(setting("/fixture/coalesced.mp4")), + ..Default::default() + }; + let left = MonitorId::new("coalesced-left").unwrap(); + let context = WallpaperPlaybackContext::default(); + let mut targets = BTreeMap::from([( + LiveWallpaperTarget::Monitor(left.clone()), + pixel_target(16, 16), + )]); + controller.reconcile( + &settings, + std::slice::from_ref(&left), + true, + &context, + &targets, + ); + let key = controller.routes[&LiveWallpaperTarget::Monitor(left.clone())] + .key + .clone(); + let player = &controller.players[&key]; + let revision = player.revision; + let stream = ImageStream::new(); + { + let mut mailbox = player.mailbox.lock().unwrap(); + assert!(mailbox.publish_poster(&player.asset.path, revision, poster.clone())); + for marker in 0..100 { + let data = Arc::new(stream.frame_yuv420(neutral_planes(marker)).unwrap()); + assert!(!mailbox.publish(Delivery::Frame(PresentationFrame::native( + data, + poster.clone() + )))); + } + } + controller.update(Message::Wake(key, revision), &mut Effects::default()); + let PresentationFrame::Video { data, .. } = + controller.presentation_source(Some(&left)).unwrap().1 + else { + panic!("latest native frame missing") + }; + assert_eq!(data.frame().planes()[0].row(0).unwrap(), &[99, 99]); + targets.clear(); + controller.reconcile( + &settings, + std::slice::from_ref(&left), + true, + &context, + &targets, + ); + assert_rgba_presentation(&controller, &left, &poster); +} +#[test] +fn native_poster_authority_rejects_other_assets_old_generations_and_retired_sources() { + let poster = native_poster(); + let mut controller = LiveWallpaper::new( + Arc::new(NativePlanePlayback::default()), + Arc::new(Posters(poster.clone())), + ); + controller.enable_native_planes(true); + let mut settings = LiveWallpapers { + default: Some(setting("/fixture/first.mp4")), + ..Default::default() + }; + let left = MonitorId::new("poster-authority").unwrap(); + let context = WallpaperPlaybackContext::default(); + let targets = BTreeMap::from([( + LiveWallpaperTarget::Monitor(left.clone()), + pixel_target(16, 16), + )]); + controller.reconcile( + &settings, + std::slice::from_ref(&left), + true, + &context, + &targets, + ); + let key = controller.routes[&LiveWallpaperTarget::Monitor(left.clone())] + .key + .clone(); + let revision = controller.players[&key].revision; + for (asset, tag) in [ + ("/fixture/other.mp4", revision), + ("/fixture/first.mp4", revision + 1), + ] { + controller.players[&key] + .mailbox + .lock() + .unwrap() + .publish_poster(asset, tag, poster.clone()); + controller.update( + Message::Wake(key.clone(), revision), + &mut Effects::default(), + ); + assert!(controller.players[&key].rgba_poster.is_none()); + assert!(controller.presentation_source(Some(&left)).is_none()); + } + let old = controller.subscriptions().pop().unwrap(); + settings.default = Some(setting("/fixture/second.mp4")); + controller.reconcile( + &settings, + std::slice::from_ref(&left), + true, + &context, + &targets, + ); + deliver_subscription(&mut controller, old); + assert!( + controller.presentation_source(Some(&left)).is_none(), + "old source worker cannot admit its fallback into a new asset" + ); + assert!(controller.players.values().all(|p| p.rgba_poster.is_none())); +} + +struct MissingNativePoster; +impl AssetPort for MissingNativePoster { + fn initial(&self) -> BTreeMap> { + BTreeMap::new() + } + fn application_icons(&self, _: &[Application]) -> crate::ports::Images { + vec![] + } + fn thumbnail(&self, _: &str) -> Result> { + Err(DomainError::Unavailable("missing fixture poster".into())) + } + fn preview(&self, image: &ImageData) -> Arc { + Arc::new(image.clone()) + } + fn background( + &self, + _: &str, + _: Option, + ) -> Result> { + Err(DomainError::Unavailable("missing fixture poster".into())) + } +} +#[test] +fn native_poster_failure_uses_existing_rgba_playback_without_admitting_native_pixels() { + let adapter = Arc::new(NativePlanePlayback::default()); + let mut controller = LiveWallpaper::new(adapter.clone(), Arc::new(MissingNativePoster)); + controller.enable_native_planes(true); + let settings = LiveWallpapers { + default: Some(setting("/fixture/missing-poster.mp4")), + ..Default::default() + }; + let left = MonitorId::new("missing-poster").unwrap(); + let context = WallpaperPlaybackContext::default(); + let targets = BTreeMap::from([( + LiveWallpaperTarget::Monitor(left.clone()), + pixel_target(16, 16), + )]); + controller.reconcile( + &settings, + std::slice::from_ref(&left), + true, + &context, + &targets, + ); + deliver_current_subscription(&mut controller); + assert_eq!( + controller + .presentation_source(Some(&left)) + .unwrap() + .1 + .representation(), + "rgba" + ); + assert_eq!( + adapter.calls.lock().unwrap().as_slice(), + &[(false, Some(pixel_target(16, 16)))] + ); +} diff --git a/lucent/apps/lucent-desktop/src/main.rs b/lucent/apps/lucent-desktop/src/main.rs index cbf2079e..31fc76da 100644 --- a/lucent/apps/lucent-desktop/src/main.rs +++ b/lucent/apps/lucent-desktop/src/main.rs @@ -22,6 +22,7 @@ mod persistence; mod platform; mod ports; mod power; +mod presentation_frame; mod previews; mod shell_layout; mod tray; @@ -39,6 +40,11 @@ fn main() -> Result<(), Box> { return lucent_services::displays::run_guard().map_err(Into::into); } let mut desktop = Desktop::new(platform::desktop_adapters()); + // Experimental composition policy, disabled for ordinary sessions. Services + // stay injected and views never inspect environment or open media files. + desktop.live_wallpaper.enable_native_planes( + std::env::var_os("LUCENT_NATIVE_VIDEO_PLANES").is_some_and(|value| value == "1"), + ); desktop.live_wallpaper.enable_frame_tracing( std::env::var_os("LUCENT_PROFILE_PRESENTATION").is_some_and(|value| value == "1"), ); diff --git a/lucent/apps/lucent-desktop/src/presentation_frame.rs b/lucent/apps/lucent-desktop/src/presentation_frame.rs new file mode 100644 index 00000000..40c59d3e --- /dev/null +++ b/lucent/apps/lucent-desktop/src/presentation_frame.rs @@ -0,0 +1,107 @@ +//! Client presentation state only. No codec, service operation or GPU handle. +use lucent_api::{Element, ImageData, VideoData}; +use std::sync::Arc; +#[derive(Clone, Debug)] +pub(crate) enum PresentationFrame { + Image(Arc), + Video { + data: Arc, + poster: Arc, + }, +} +impl From> for PresentationFrame { + fn from(value: Arc) -> Self { + Self::Image(value) + } +} +impl PresentationFrame { + pub fn native(data: Arc, poster: Arc) -> Self { + Self::Video { data, poster } + } + /// Pure client admission policy. Choosing a previously prepared fallback + /// performs no fitting, allocation of pixels, decoding or service operation. + pub fn select_for_target(&self, target: Option) -> Self { + match self { + Self::Video { data, poster } + if target.is_none_or(|target| { + !target + .cover_size(data.width(), data.height()) + .is_ok_and(|size| size == (data.width(), data.height())) + }) => + { + Self::Image(poster.clone()) + } + _ => self.clone(), + } + } + + pub fn as_image(&self) -> Option<&Arc> { + match self { + Self::Image(value) => Some(value), + Self::Video { .. } => None, + } + } + pub fn element(&self) -> Element { + match self { + Self::Image(value) => Element::image(value.clone()), + Self::Video { data, .. } => Element::video(data.clone()), + } + } + pub fn cache_id(&self) -> u64 { + match self { + Self::Image(value) => value.cache_id(), + Self::Video { data, .. } => data.cache_id(), + } + } + pub fn stream_revision(&self) -> Option<(u64, u64)> { + match self { + Self::Image(value) => value.stream_revision(), + Self::Video { data, .. } => data.stream_revision(), + } + } + pub fn width(&self) -> u32 { + match self { + Self::Image(value) => value.width(), + Self::Video { data, .. } => data.width(), + } + } + pub fn height(&self) -> u32 { + match self { + Self::Image(value) => value.height(), + Self::Video { data, .. } => data.height(), + } + } + pub fn aspect_size(&self) -> (u32, u32) { + match self { + Self::Image(value) => value.aspect_size(), + Self::Video { data, .. } => data.aspect_size(), + } + } + pub fn bytes(&self) -> usize { + match self { + Self::Image(value) => value.bytes(), + Self::Video { data, poster } => data.bytes() + poster.bytes(), + } + } + pub fn is_opaque(&self) -> bool { + match self { + Self::Image(value) => value.is_opaque(), + Self::Video { .. } => true, + } + } + pub fn representation(&self) -> &'static str { + match self { + Self::Image(_) => "rgba", + Self::Video { .. } => "yuv420", + } + } + pub fn snapshot(&self) -> Self { + if self.stream_revision().is_none() { + return self.clone(); + } + match self { + Self::Image(value) => Self::Image(Arc::new(value.snapshot())), + Self::Video { data, poster } => Self::native(Arc::new(data.snapshot()), poster.clone()), + } + } +} diff --git a/lucent/apps/lucent-lab/src/render.rs b/lucent/apps/lucent-lab/src/render.rs index f127f93f..10e27cb3 100644 --- a/lucent/apps/lucent-lab/src/render.rs +++ b/lucent/apps/lucent-lab/src/render.rs @@ -84,6 +84,7 @@ fn sources(root: &Path) -> Result, Box> { "lucent/crates/lucent-render/src", "lucent/crates/lucent-domain/src", "lucent/crates/lucent-usecases/src", + "lucent/crates/lucent-video-frame/src", "lucent/crates/lucent-ui/assets", ] { visit(root, Path::new(source), &mut result)?; @@ -101,6 +102,7 @@ fn sources(root: &Path) -> Result, Box> { "lucent/crates/lucent-render/Cargo.toml", "lucent/crates/lucent-domain/Cargo.toml", "lucent/crates/lucent-usecases/Cargo.toml", + "lucent/crates/lucent-video-frame/Cargo.toml", "lucent/apps/lucent-desktop/assets/LucentSans.ttf", "lucent/apps/lucent-desktop/assets/LucentDisplay.ttf", ] { @@ -265,6 +267,25 @@ mod tests { assert_eq!(count, 480); } + #[test] + fn export_fingerprints_neutral_video_values_and_their_dependency_manifest() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../..") + .canonicalize() + .unwrap(); + let inputs = sources(&root).unwrap(); + for path in [ + "lucent/crates/lucent-video-frame/src/lib.rs", + "lucent/crates/lucent-video-frame/Cargo.toml", + ] { + assert_eq!( + inputs.get(path), + Some(&sha256(&root.join(path)).unwrap()), + "indirect public video values must invalidate captures" + ); + } + } + #[test] fn export_requires_committed_producers_but_allows_unrelated_docs_work() { use std::sync::atomic::{AtomicU64, Ordering}; diff --git a/lucent/crates/lucent-api/Cargo.toml b/lucent/crates/lucent-api/Cargo.toml index c58e4789..fd6f08c1 100644 --- a/lucent/crates/lucent-api/Cargo.toml +++ b/lucent/crates/lucent-api/Cargo.toml @@ -4,5 +4,6 @@ version.workspace = true edition.workspace = true license.workspace = true [dependencies] +lucent-video-frame = { path = "../lucent-video-frame" } unicode-segmentation = "1.13.3" diff --git a/lucent/crates/lucent-api/src/image.rs b/lucent/crates/lucent-api/src/image.rs index 77f08570..2b0ebf94 100644 --- a/lucent/crates/lucent-api/src/image.rs +++ b/lucent/crates/lucent-api/src/image.rs @@ -146,14 +146,16 @@ impl ImageStream { }; self.prepared_frame(width, height, pixels, aspect_size, opaque) } - fn prepared_frame( + /// Prepare checked, immutable native video planes without CPU RGBA + /// conversion. The caller owns decode/timing; this descriptor owns no GPU. + pub fn frame_yuv420( &self, - width: u32, - height: u32, - pixels: Arc>, - aspect_size: (u32, u32), - opaque: bool, - ) -> Result { + frame: crate::video_frame::Yuv420Frame, + ) -> Result { + let (id, stream) = self.next_frame_identity()?; + Ok(crate::VideoData::prepared(frame, id, stream)) + } + fn next_frame_identity(&self) -> Result<(u64, (u64, u64)), ImageError> { let revision = self .0 .revision @@ -162,14 +164,28 @@ impl ImageStream { }) .map_err(|_| ImageError("Image stream revision exhausted"))? + 1; + Ok(( + NEXT_IMAGE.fetch_add(1, Ordering::Relaxed), + (self.0.id, revision), + )) + } + fn prepared_frame( + &self, + width: u32, + height: u32, + pixels: Arc>, + aspect_size: (u32, u32), + opaque: bool, + ) -> Result { + let (id, stream) = self.next_frame_identity()?; Ok(ImageData { key: "streamed-image".into(), aspect_size, prepared: Arc::new(Prepared { - id: NEXT_IMAGE.fetch_add(1, Ordering::Relaxed), + id, format: ImageFormat::PremultipliedRgba8, opaque, - stream: Some((self.0.id, revision)), + stream: Some(stream), levels: vec![ImageLevel { width, height, diff --git a/lucent/crates/lucent-api/src/lib.rs b/lucent/crates/lucent-api/src/lib.rs index d0234191..7b0be620 100644 --- a/lucent/crates/lucent-api/src/lib.rs +++ b/lucent/crates/lucent-api/src/lib.rs @@ -37,11 +37,15 @@ pub use image::{ ImageData, ImageError, ImageFormat, ImageLevel, ImageLifetime, ImageStream, reduce_alpha, reduce_premultiplied, }; +/// Neutral immutable video planes; codecs and GPU resources stay in adapters. +pub use lucent_video_frame as video_frame; +mod video; use std::{ collections::BTreeSet, sync::{Arc, Condvar, Mutex}, time::Duration, }; +pub use video::{VideoData, VideoLifetime}; #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct Rect { @@ -259,6 +263,7 @@ pub enum Kind { Text(String), RichText(Vec), Image(Arc), + Video(Arc), Input { value: String, placeholder: String, @@ -379,6 +384,12 @@ impl Element { pub fn image(image: Arc) -> Self { Self::new(Kind::Image(image)) } + /// Present immutable decoded planes with normal image geometry and clips. + /// This does not provide decoding, cadence or automatic minification/format + /// fallback; see [`VideoData`] for the current sampling contract. + pub fn video(frame: Arc) -> Self { + Self::new(Kind::Video(frame)) + } pub fn row(children: Vec) -> Self { let mut e = Self::new(Kind::Row); e.children = children; diff --git a/lucent/crates/lucent-api/src/video.rs b/lucent/crates/lucent-api/src/video.rs new file mode 100644 index 00000000..82ae23bd --- /dev/null +++ b/lucent/crates/lucent-api/src/video.rs @@ -0,0 +1,120 @@ +//! Immutable video presentation descriptors. Codec ownership remains hidden in +//! the neutral plane bytes; neither this API nor views perform decoding or I/O. +use crate::video_frame::Yuv420Frame; +use std::sync::{Arc, Weak}; + +/// Immutable, already-decoded video planes for presentation. This descriptor +/// does not decode, pace playback, choose physical output demand, or prepare +/// minification levels. The current plane renderer uses one-level bilinear +/// sampling; callers must arrange an appropriate fitted representation when +/// reducing the image, including after a retained last-good frame is resized. +#[derive(Clone, Debug)] +pub struct VideoData { + frame: Arc, + id: u64, + stream: Option<(u64, u64)>, +} +#[derive(Clone, Debug)] +pub struct VideoLifetime(Weak); +impl VideoLifetime { + /// Observes the shared frame owner without retaining any plane bytes. + pub fn is_alive(&self) -> bool { + self.0.strong_count() > 0 + } +} +impl VideoData { + pub(crate) fn prepared(frame: Yuv420Frame, id: u64, stream: (u64, u64)) -> Self { + Self { + frame: Arc::new(frame), + id, + stream: Some(stream), + } + } + pub fn frame(&self) -> &Yuv420Frame { + &self.frame + } + pub fn width(&self) -> u32 { + self.frame.dimensions().0 + } + pub fn height(&self) -> u32 { + self.frame.dimensions().1 + } + /// This checked 8-bit planar contract uses square pixels and no crop. + pub fn aspect_size(&self) -> (u32, u32) { + self.frame.dimensions() + } + pub fn cache_id(&self) -> u64 { + self.id + } + pub fn stream_revision(&self) -> Option<(u64, u64)> { + self.stream + } + pub fn lifetime(&self) -> VideoLifetime { + VideoLifetime(Arc::downgrade(&self.frame)) + } + pub fn bytes(&self) -> usize { + self.frame.byte_len() + } + /// Freeze GPU identity while sharing the same immutable plane owners. + pub fn snapshot(&self) -> Self { + Self { + frame: self.frame.clone(), + id: self.id, + stream: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + ImageStream, + video_frame::{Bt709, Bytes, ChromaSiting, Plane, Range, Transfer}, + }; + fn frame() -> Yuv420Frame { + let p = || Plane::new(Bytes::from(vec![128]), 1, 1, 1).unwrap(); + Yuv420Frame::new( + 1, + 1, + [p(), p(), p()], + Bt709 { + range: Range::Full, + transfer: Transfer::Srgb, + chroma: ChromaSiting::Center, + }, + ) + .unwrap() + } + #[test] + fn video_and_rgba_share_checked_stream_revisions_without_changing_immutable_identity() { + let stream = ImageStream::new(); + let clone = stream.clone(); + let first = stream.frame_rgba(1, 1, vec![1, 2, 3, 255]).unwrap(); + let second = clone.frame_yuv420(frame()).unwrap(); + let third = stream.frame_rgba(1, 1, vec![4, 5, 6, 255]).unwrap(); + let id = first.stream_revision().unwrap().0; + assert_eq!(first.stream_revision(), Some((id, 1))); + assert_eq!(second.stream_revision(), Some((id, 2))); + assert_eq!(third.stream_revision(), Some((id, 3))); + assert_ne!(first.cache_id(), second.cache_id()); + assert_ne!(second.cache_id(), third.cache_id()); + } + #[test] + fn frozen_video_preserves_planes_and_lifetime_without_a_mutable_stream_identity() { + let source = frame(); + let pointer = source.planes()[0].bytes().as_ptr(); + let data = ImageStream::new().frame_yuv420(source).unwrap(); + let life = data.lifetime(); + let frozen = data.snapshot(); + assert!(life.is_alive()); + assert_eq!(data.cache_id(), frozen.cache_id()); + assert!(frozen.stream_revision().is_none()); + assert_eq!(frozen.frame().planes()[0].bytes().as_ptr(), pointer); + drop(data); + assert!(life.is_alive()); + assert_eq!(frozen.bytes(), 3); + drop(frozen); + assert!(!life.is_alive()); + } +} diff --git a/lucent/crates/lucent-domain/Cargo.toml b/lucent/crates/lucent-domain/Cargo.toml index 195f2144..e9c5d716 100644 --- a/lucent/crates/lucent-domain/Cargo.toml +++ b/lucent/crates/lucent-domain/Cargo.toml @@ -5,5 +5,6 @@ edition.workspace = true license.workspace = true [dependencies] +lucent-video-frame = { path = "../lucent-video-frame" } serde = { version = "1", features = ["derive"] } zeroize = "1" diff --git a/lucent/crates/lucent-domain/src/lib.rs b/lucent/crates/lucent-domain/src/lib.rs index 5e83b4fd..fe2e4120 100644 --- a/lucent/crates/lucent-domain/src/lib.rs +++ b/lucent/crates/lucent-domain/src/lib.rs @@ -12,6 +12,8 @@ mod ports; pub use ports::*; mod media; pub use media::*; +mod native_video; +pub use native_video::*; mod audio; pub use audio::*; @@ -51,6 +53,8 @@ pub use values::*; mod live_wallpaper; pub use live_wallpaper::*; +mod wallpaper_targets; +pub use wallpaper_targets::*; mod displays; pub use displays::*; diff --git a/lucent/crates/lucent-domain/src/live_wallpaper.rs b/lucent/crates/lucent-domain/src/live_wallpaper.rs index aec586b8..4fead94b 100644 --- a/lucent/crates/lucent-domain/src/live_wallpaper.rs +++ b/lucent/crates/lucent-domain/src/live_wallpaper.rs @@ -1,5 +1,8 @@ //! Live wallpaper preferences and media data; no codec, GPU or filesystem types. -use crate::{DomainError, MonitorId, Result, StopSignal, Wallpaper}; +use crate::{ + DomainError, MonitorId, Result, RetargetablePlaybackOptions, StopSignal, TargetedMediaFrame, + Wallpaper, WallpaperTargetPort, +}; use serde::{Deserialize, Serialize}; use std::{ collections::{BTreeMap, BTreeSet}, @@ -469,6 +472,46 @@ pub trait WallpaperPlaybackPort: Send + Sync { emit: &mut dyn FnMut(Result) -> bool, stop: Arc, ); + /// Optional source-size planes. Unknown/unsupported metadata and cover + /// demands requiring minification preserve the existing fitted RGBA path. + /// Consumers must supply the actual known demand; None means source size. + fn play_native( + &self, + asset: &LiveWallpaperAsset, + options: crate::NativePlaybackOptions, + emit: &mut dyn FnMut(Result) -> bool, + stop: Arc, + ) { + self.play( + asset, + PlaybackOptions { + fps: options.fps, + looping: options.looping, + target: options.target, + }, + &mut |value| emit(value.map(crate::NativeMediaFrame::Rgba)), + stop, + ); + } + /// One asset/producer lifetime with latest-only source-size/target updates. + /// Target revisions tag results; consumers reject stale in-flight frames. + /// Returning ends the decoder body. An unsupported adapter must report that + /// capability failure explicitly; it must never emulate retarget by play + /// cancellation/restart. Static play remains an independent existing API. + fn play_retargetable( + &self, + _asset: &LiveWallpaperAsset, + _options: RetargetablePlaybackOptions, + _targets: Arc, + emit: &mut dyn FnMut(Result) -> bool, + stop: Arc, + ) { + if !stop.cancelled() { + emit(Err(DomainError::Unavailable( + "Retargetable wallpaper playback is unsupported".into(), + ))); + } + } /// Read a local source without importing it or changing saved preferences. /// One caller-owned worker receives owned, straight-alpha RGBA frames at /// source cadence, looping until cancellation or a false callback result. diff --git a/lucent/crates/lucent-domain/src/native_video.rs b/lucent/crates/lucent-domain/src/native_video.rs new file mode 100644 index 00000000..98b25c83 --- /dev/null +++ b/lucent/crates/lucent-domain/src/native_video.rs @@ -0,0 +1,165 @@ +//! Opt-in native video delivery. No codec/GPU/OS handles cross this boundary. +use crate::{DomainError, MediaFrame, Result, WallpaperFrameRate, WallpaperPixelTarget}; +pub use lucent_video_frame as video_frame; +use std::time::Duration; +pub use video_frame::Yuv420Frame; + +#[derive(Clone, Copy, Debug)] +pub struct NativePlaybackOptions { + pub fps: WallpaperFrameRate, + pub looping: bool, + /// Source-size planes are admitted only when this actual cover demand does + /// not require minification. Otherwise preserve the existing fitted RGBA. + /// None explicitly requests source size, not unknown desktop geometry. + pub target: Option, +} +impl Default for NativePlaybackOptions { + fn default() -> Self { + Self { + fps: WallpaperFrameRate::Source, + looping: true, + target: None, + } + } +} +/// Publication work only. These counters are not decoder allocation or RSS. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PlanePublication { + pub referenced_bytes: usize, + pub copied_bytes: usize, +} +#[derive(Debug)] +pub enum NativeMediaFrame { + Rgba(MediaFrame), + Yuv420 { + frame: Yuv420Frame, + timestamp: Duration, + publication: PlanePublication, + }, +} +impl NativeMediaFrame { + pub fn timestamp(&self) -> Duration { + match self { + Self::Rgba(frame) => frame.timestamp, + Self::Yuv420 { timestamp, .. } => *timestamp, + } + } + pub fn into_rgba(self) -> Result { + match self { + Self::Rgba(frame) => Ok(frame), + Self::Yuv420 { .. } => Err(DomainError::Invalid( + "Planar delivery entered an RGBA-only path".into(), + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + LiveWallpaperAsset, PlaybackOptions, StopSignal, Wallpaper, WallpaperFps, + WallpaperPlaybackPort, + }; + use std::sync::{Arc, Mutex}; + + struct Never; + impl StopSignal for Never { + fn cancelled(&self) -> bool { + false + } + fn wait(&self, _: Duration) {} + } + struct RgbaAdapter(Mutex>); + impl WallpaperPlaybackPort for RgbaAdapter { + fn prepare(&self, _: &Wallpaper) -> Result { + unreachable!("playback does not prepare") + } + fn play( + &self, + _: &LiveWallpaperAsset, + options: PlaybackOptions, + emit: &mut dyn FnMut(Result) -> bool, + _: Arc, + ) { + *self.0.lock().unwrap() = Some(options); + assert!(!emit(Ok(MediaFrame::rgba( + 1, + 1, + vec![1, 2, 3, 255], + Duration::from_millis(7) + ) + .unwrap()))); + } + } + #[test] + fn native_port_default_delegates_to_existing_rgba_with_exact_source_cadence_options() { + let adapter = RgbaAdapter(Mutex::new(None)); + let source = Wallpaper { + path: "/fixture/source.mp4".into(), + name: "Fixture".into(), + }; + let asset = LiveWallpaperAsset { + source: source.clone(), + path: source.path.clone(), + poster: source, + }; + let mut seen = 0; + adapter.play_native( + &asset, + NativePlaybackOptions { + fps: WallpaperFrameRate::Capped(WallpaperFps::new(60).unwrap()), + looping: false, + target: Some(WallpaperPixelTarget::new(64, 48).unwrap()), + }, + &mut |value| { + let frame = value.unwrap(); + assert_eq!(frame.timestamp(), Duration::from_millis(7)); + assert_eq!( + frame.into_rgba().unwrap().into_rgba().2.as_slice(), + &[1, 2, 3, 255] + ); + seen += 1; + false + }, + Arc::new(Never), + ); + assert_eq!(seen, 1); + let options = adapter.0.lock().unwrap().unwrap(); + assert_eq!( + options.target, + Some(WallpaperPixelTarget::new(64, 48).unwrap()) + ); + assert!(!options.looping); + assert_eq!( + options.fps, + WallpaperFrameRate::Capped(WallpaperFps::new(60).unwrap()) + ); + } + #[test] + fn native_planar_delivery_retains_timestamp_and_cannot_enter_an_rgba_only_consumer() { + use video_frame::{Bt709, Bytes, ChromaSiting, Plane, Range, Transfer}; + let plane = || Plane::new(Bytes::from(vec![128]), 1, 1, 1).unwrap(); + let frame = Yuv420Frame::new( + 1, + 1, + [plane(), plane(), plane()], + Bt709 { + range: Range::Full, + transfer: Transfer::Srgb, + chroma: ChromaSiting::Center, + }, + ) + .unwrap(); + let value = NativeMediaFrame::Yuv420 { + frame, + timestamp: Duration::from_millis(9), + publication: PlanePublication { + referenced_bytes: 0, + copied_bytes: 3, + }, + }; + assert_eq!(value.timestamp(), Duration::from_millis(9)); + assert!(value.into_rgba().is_err()); + } +} diff --git a/lucent/crates/lucent-domain/src/wallpaper_targets.rs b/lucent/crates/lucent-domain/src/wallpaper_targets.rs new file mode 100644 index 00000000..808ced4a --- /dev/null +++ b/lucent/crates/lucent-domain/src/wallpaper_targets.rs @@ -0,0 +1,129 @@ +//! Physical playback demand and immutable result identity; no native handles. +use crate::{DomainError, MediaFrame, Result, WallpaperFrameRate, WallpaperPixelTarget}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct WallpaperTargetRevision(u64); +impl WallpaperTargetRevision { + pub const fn initial() -> Self { + Self(1) + } + pub fn new(value: u64) -> Result { + if value == 0 { + return Err(DomainError::Invalid( + "Wallpaper target revision is zero".into(), + )); + } + Ok(Self(value)) + } + pub const fn get(self) -> u64 { + self.0 + } + pub fn checked_next(self) -> Result { + self.0 + .checked_add(1) + .map(Self) + .ok_or_else(|| DomainError::Invalid("Wallpaper target revision is exhausted".into())) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct WallpaperTargetDemand { + pub revision: WallpaperTargetRevision, + /// None explicitly requests source/native-size pixels, as PlaybackOptions + /// does. This is an admitted demand, not an absent port observation. + pub target: Option, +} +impl WallpaperTargetDemand { + /// Equal demand keeps its revision. A changed source-size/cover demand + /// advances exactly once, or fails before replacing the previous value. + pub fn retarget(self, target: Option) -> Result { + if self.target == target { + return Ok(self); + } + Ok(Self { + revision: self.revision.checked_next()?, + target, + }) + } +} + +/// The target port is the sole source of physical target demand. A read must +/// not block. None means a snapshot is temporarily contended, not cancellation. +/// Initial admission requires Some; later None retains the last admitted demand. +/// Some(WallpaperTargetDemand { target: None, .. }) explicitly requests source +/// pixels and remains distinct from that outer None observation. +pub trait WallpaperTargetPort: Send + Sync { + fn try_latest(&self) -> Result>; +} + +/// Dynamic playback policy intentionally contains no second target value. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RetargetablePlaybackOptions { + pub fps: WallpaperFrameRate, + pub looping: bool, +} +impl Default for RetargetablePlaybackOptions { + fn default() -> Self { + Self { + fps: WallpaperFrameRate::default(), + looping: true, + } + } +} + +/// Captured before conversion. A newer demand can arrive while conversion runs; +/// consumers must reject a result whose revision no longer matches their intent. +#[derive(Debug)] +pub struct TargetedMediaFrame { + pub revision: WallpaperTargetRevision, + pub frame: MediaFrame, +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn target_revisions_are_nonzero_ordered_and_never_wrap() { + assert!(WallpaperTargetRevision::new(0).is_err()); + let first = WallpaperTargetRevision::initial(); + assert_eq!(first.get(), 1); + assert!(first.checked_next().unwrap() > first); + let last = WallpaperTargetRevision::new(u64::MAX).unwrap(); + assert!(last.checked_next().is_err()); + assert_eq!(last.get(), u64::MAX); + } + + #[test] + fn source_and_cover_transitions_advance_once_while_equal_demands_keep_their_revision() { + let source = WallpaperTargetDemand { + revision: WallpaperTargetRevision::initial(), + target: None, + }; + assert_eq!(source.retarget(None).unwrap(), source); + let target = WallpaperPixelTarget::new(1920, 1080).unwrap(); + let cover = source.retarget(Some(target)).unwrap(); + assert_eq!(cover.target, Some(target)); + assert_eq!(cover.revision, source.revision.checked_next().unwrap()); + assert_eq!(cover.retarget(Some(target)).unwrap(), cover); + let restored = cover.retarget(None).unwrap(); + assert_eq!(restored.target, None); + assert_eq!(restored.revision, cover.revision.checked_next().unwrap()); + assert_eq!(restored.retarget(None).unwrap(), restored); + } + + #[test] + fn source_and_cover_revision_exhaustion_preserves_the_previous_demand() { + let target = WallpaperPixelTarget::new(1920, 1080).unwrap(); + for demand in [None, Some(target)] { + let previous = WallpaperTargetDemand { + revision: WallpaperTargetRevision::new(u64::MAX).unwrap(), + target: demand, + }; + let changed = if demand.is_none() { Some(target) } else { None }; + assert!(previous.retarget(changed).is_err()); + assert_eq!(previous.target, demand); + assert_eq!(previous.revision.get(), u64::MAX); + assert_eq!(previous.retarget(demand).unwrap(), previous); + } + } +} diff --git a/lucent/crates/lucent-render/src/lib.rs b/lucent/crates/lucent-render/src/lib.rs index 679df615..71e46c80 100644 --- a/lucent/crates/lucent-render/src/lib.rs +++ b/lucent/crates/lucent-render/src/lib.rs @@ -14,12 +14,16 @@ use wgpu::{rwh::*, *}; mod allocation; mod cache; mod extent; +mod planes; mod presentation; mod profiling; mod stream_trace; mod stream_upload; +mod video_planes; +mod video_scene; pub use profiling::{PaintProfile, RenderProfile, ResourceProfile}; pub use stream_trace::StreamTrace; +pub use video_planes::{PlanarPainter, PlanarUploadStats}; mod fallback; #[cfg(all(test, target_os = "linux"))] mod font_resources; @@ -37,20 +41,45 @@ struct TextureResource { group: BindGroup, width: f32, height: f32, - _texture: Texture, + storage: TextureStorage, revision: Cell, stream_id: Cell>, - image_lifetime: RefCell>, + lifetime: RefCell>, bytes: u64, /// Logical left bearing for text masks; ordinary image resources use zero. text_origin_x: f32, } +enum TextureStorage { + Image(Texture), + Video(Box), +} +enum ResourceLifetime { + Image(lucent_api::ImageLifetime), + Video(lucent_api::VideoLifetime), +} +impl ResourceLifetime { + fn is_alive(&self) -> bool { + match self { + Self::Image(value) => value.is_alive(), + Self::Video(value) => value.is_alive(), + } + } +} impl TextureResource { fn reusable(&self) -> bool { - self.image_lifetime + self.lifetime .borrow() .as_ref() - .is_none_or(lucent_api::ImageLifetime::is_alive) + .is_none_or(ResourceLifetime::is_alive) + } + fn is_video(&self) -> bool { + matches!(&self.storage, TextureStorage::Video(_)) + } + fn image_texture(&self) -> &Texture { + match &self.storage { + TextureStorage::Image(texture) => texture, + TextureStorage::Video(_) => unreachable!("native resource in RGBA upload"), + } } } /// One GPU device is shared by all native surfaces in an application. @@ -60,6 +89,7 @@ pub struct Gpu { device: Device, queue: Queue, pipeline: RenderPipeline, + video_pipeline: RefCell>, texture_layout: BindGroupLayout, frame_layout: BindGroupLayout, sampler: Sampler, @@ -70,6 +100,58 @@ pub struct Gpu { device_failure: Arc>>, pub adapter_description: String, } +fn scene_pipeline( + device: &Device, + texture_layout: &BindGroupLayout, + frame_layout: &BindGroupLayout, + module: &ShaderModule, +) -> RenderPipeline { + let layout = device.create_pipeline_layout(&PipelineLayoutDescriptor { + label: None, + bind_group_layouts: &[Some(texture_layout), Some(frame_layout)], + immediate_size: 0, + }); + let attributes = vertex_attr_array![0=>Float32x4,1=>Float32x4,2=>Float32x4,3=>Float32x4,4=>Float32x4,5=>Float32x4]; + let constants = [( + "SHADOW_OUTSET", + f64::from(lucent_api::Shadow::blur_outset(1.)), + )]; + device.create_render_pipeline(&RenderPipelineDescriptor { + label: Some("framework primitives"), + layout: Some(&layout), + vertex: VertexState { + module, + entry_point: Some("vs"), + compilation_options: PipelineCompilationOptions { + constants: &constants, + ..Default::default() + }, + buffers: &[Some(VertexBufferLayout { + array_stride: scene::INSTANCE_BYTES, + step_mode: VertexStepMode::Instance, + attributes: &attributes, + })], + }, + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + fragment: Some(FragmentState { + module, + entry_point: Some("fs"), + compilation_options: PipelineCompilationOptions { + constants: &constants, + ..Default::default() + }, + targets: &[Some(ColorTargetState { + format: TextureFormat::Bgra8Unorm, + blend: Some(BlendState::PREMULTIPLIED_ALPHA_BLENDING), + write_mask: ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }) +} impl Gpu { pub fn new( display: impl HasDisplayHandle + std::fmt::Debug + Send + Sync + 'static, @@ -159,58 +241,20 @@ impl Gpu { mipmap_filter: MipmapFilterMode::Linear, ..Default::default() }); - let module = device.create_shader_module(include_wgsl!("present.wgsl")); - let layout = device.create_pipeline_layout(&PipelineLayoutDescriptor { - label: None, - bind_group_layouts: &[Some(&texture_layout), Some(&frame_layout)], - immediate_size: 0, - }); - let attributes = vertex_attr_array![0=>Float32x4,1=>Float32x4,2=>Float32x4,3=>Float32x4,4=>Float32x4,5=>Float32x4]; - let constants = [( - "SHADOW_OUTSET", - f64::from(lucent_api::Shadow::blur_outset(1.)), - )]; - let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor { - label: Some("framework primitives"), - layout: Some(&layout), - vertex: VertexState { - module: &module, - entry_point: Some("vs"), - compilation_options: PipelineCompilationOptions { - constants: &constants, - ..Default::default() - }, - buffers: &[Some(VertexBufferLayout { - array_stride: scene::INSTANCE_BYTES, - step_mode: VertexStepMode::Instance, - attributes: &attributes, - })], - }, - primitive: Default::default(), - depth_stencil: None, - multisample: Default::default(), - fragment: Some(FragmentState { - module: &module, - entry_point: Some("fs"), - compilation_options: PipelineCompilationOptions { - constants: &constants, - ..Default::default() - }, - targets: &[Some(ColorTargetState { - format: TextureFormat::Bgra8Unorm, - blend: Some(BlendState::PREMULTIPLIED_ALPHA_BLENDING), - write_mask: ColorWrites::ALL, - })], - }), - multiview_mask: None, - cache: None, + let module = device.create_shader_module(ShaderModuleDescriptor { + label: Some("framework scene"), + source: ShaderSource::Wgsl( + concat!(include_str!("present.wgsl"), include_str!("scene.wgsl")).into(), + ), }); + let pipeline = scene_pipeline(&device, &texture_layout, &frame_layout, &module); Ok(Rc::new(Self { instance, adapter, device, queue, pipeline, + video_pipeline: RefCell::new(None), texture_layout, frame_layout, sampler, @@ -304,10 +348,10 @@ impl Gpu { group, width: width as f32, height: height as f32, - _texture: texture, + storage: TextureStorage::Image(texture), revision: Cell::new(0), stream_id: Cell::new(None), - image_lifetime: RefCell::new(None), + lifetime: RefCell::new(None), bytes: levels.iter().map(|level| level.pixels.len() as u64).sum(), text_origin_x: 0., }); @@ -364,7 +408,7 @@ impl Gpu { profile.image_misses += 1; } let texture = self.texture(data.levels(), data.format(), profile, enabled); - *texture.image_lifetime.borrow_mut() = Some(data.lifetime()); + *texture.lifetime.borrow_mut() = Some(ResourceLifetime::Image(data.lifetime())); if let Some((id, revision)) = streamed { texture.stream_id.set(Some(id)); texture.revision.set(revision); @@ -454,6 +498,7 @@ struct PreparedScene { textures: Vec>, geometry: scene::Geometry, stream_updates: stream_upload::StreamUpdates, + video_updates: video_scene::Updates, } struct ValidatedScene<'a> { width: u32, @@ -564,6 +609,13 @@ impl Canvas { .. } = *validated; let scale = density.factor(); + let mut video_updates = video_scene::Updates::default(); + video_updates.reserve( + paint + .iter() + .filter(|p| matches!(p, Paint::Video { .. })) + .count(), + )?; let mut stream_updates = stream_upload::StreamUpdates::default(); stream_updates.reserve( paint @@ -651,6 +703,28 @@ impl Canvas { &mut stream_updates, ), ), + Paint::Video { + rect, + clip, + data, + radius, + opacity, + tint, + .. + } => ( + *rect, + clip, + tint.alpha(tint.3 * *opacity), + *radius, + 1., + 0., + self.gpu.video( + data, + &mut profile.resources, + self.profiling, + &mut video_updates, + ), + ), Paint::Text { rect, clip, @@ -729,7 +803,7 @@ impl Canvas { ); } let uv = match primitive { - Paint::Image { uv, .. } => *uv, + Paint::Image { uv, .. } | Paint::Video { uv, .. } => *uv, _ => Rect::new(0., 0., 1., 1.), }; let bounds = clip.bounds; @@ -770,6 +844,7 @@ impl Canvas { textures, geometry, stream_updates, + video_updates, }) } /// Draw a scene to a compatible BGRA8 target from this canvas's device. @@ -812,6 +887,7 @@ impl Canvas { textures, geometry, stream_updates, + video_updates, } = match self.prepare(&validated, &mut profile) { Ok(prepared) => prepared, Err(error) => { @@ -870,10 +946,13 @@ impl Canvas { .collect(); self.gpu.queue.write_buffer(&self.viewport, 0, &view_bytes); let mut encoder = self.gpu.device.create_command_encoder(&Default::default()); - let upload_started = profiling::start(self.profiling && !stream_updates.is_empty()); + let upload_started = profiling::start( + self.profiling && (!stream_updates.is_empty() || !video_updates.is_empty()), + ); let mut pool = self.gpu.stream_uploads.borrow_mut(); let mut uploads = pool.begin(&self.gpu.device, !stream_updates.is_empty()); - let uploaded_bytes = stream_updates.encode(&self.gpu, &mut uploads, &mut encoder); + let uploaded_bytes = stream_updates.encode(&self.gpu, &mut uploads, &mut encoder) + + video_updates.encode(&self.gpu); if self.profiling { profile.resources.texture_upload += profiling::elapsed(upload_started); profile.resources.texture_upload_bytes += uploaded_bytes; @@ -881,6 +960,7 @@ impl Canvas { profile.prepare = validated.validation + profiling::elapsed(started); let started = profiling::start(self.profiling); { + let native_pipeline = self.gpu.video_pipeline.borrow(); let mut pass = encoder.begin_render_pass(&RenderPassDescriptor { color_attachments: &[Some(RenderPassColorAttachment { view, @@ -893,10 +973,23 @@ impl Canvas { })], ..Default::default() }); - pass.set_pipeline(&self.gpu.pipeline); pass.set_vertex_buffer(0, self.instances.slice(..)); - pass.set_bind_group(1, &self.frame_group, &[]); + let mut current_pipeline = None; for (i, texture) in textures.iter().enumerate() { + let native = texture.is_video(); + if current_pipeline != Some(native) { + let pipeline = if native { + &native_pipeline + .as_ref() + .expect("native scene prepared its pipeline") + .pipeline + } else { + &self.gpu.pipeline + }; + pass.set_pipeline(pipeline); + pass.set_bind_group(1, &self.frame_group, &[]); + current_pipeline = Some(native); + } pass.set_bind_group(0, &texture.group, &[]); pass.draw(0..6, i as u32..i as u32 + 1); } @@ -910,6 +1003,7 @@ impl Canvas { drop(uploads); drop(pool); stream_updates.commit(); + video_updates.commit(); profile.submit = profiling::elapsed(started); let started = profiling::start(self.profiling); // Keep each surface's current scene resident, including while other @@ -977,59 +1071,59 @@ impl Canvas { view_formats: &[], }); self.paint_validated(&texture.create_view(&Default::default()), validated)?; - self.readback(&texture, readback, rgba) + readback_rgba(&self.gpu, &texture, readback, rgba) } - fn readback( - &self, - texture: &Texture, - readback: ReadbackLayout, - mut rgba: Vec, - ) -> Result, Error> { - let extent = texture.size(); - let buffer = self.gpu.device.create_buffer(&BufferDescriptor { - label: Some("visual snapshot readback"), - size: readback.buffer_bytes, - usage: BufferUsages::COPY_DST | BufferUsages::MAP_READ, - mapped_at_creation: false, - }); - let mut encoder = self.gpu.device.create_command_encoder(&Default::default()); - encoder.copy_texture_to_buffer( - TexelCopyTextureInfo { - texture, - mip_level: 0, - origin: Origin3d::ZERO, - aspect: TextureAspect::All, - }, - TexelCopyBufferInfo { - buffer: &buffer, - layout: TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(readback.stride), - rows_per_image: Some(extent.height), - }, +} +fn readback_rgba( + gpu: &Gpu, + texture: &Texture, + readback: ReadbackLayout, + mut rgba: Vec, +) -> Result, Error> { + let extent = texture.size(); + let buffer = gpu.device.create_buffer(&BufferDescriptor { + label: Some("visual snapshot readback"), + size: readback.buffer_bytes, + usage: BufferUsages::COPY_DST | BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = gpu.device.create_command_encoder(&Default::default()); + encoder.copy_texture_to_buffer( + TexelCopyTextureInfo { + texture, + mip_level: 0, + origin: Origin3d::ZERO, + aspect: TextureAspect::All, + }, + TexelCopyBufferInfo { + buffer: &buffer, + layout: TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(readback.stride), + rows_per_image: Some(extent.height), }, - extent, - ); - self.gpu.queue.submit([encoder.finish()]); - let (sender, receiver) = std::sync::mpsc::sync_channel(1); - buffer.slice(..).map_async(MapMode::Read, move |result| { - let _ = sender.send(result); - }); - self.gpu.device.poll(PollType::Wait { - submission_index: None, - timeout: Some(std::time::Duration::from_secs(10)), - })?; - receiver.recv_timeout(std::time::Duration::from_secs(10))??; - let mapped = buffer.slice(..).get_mapped_range()?; - for row in mapped.chunks_exact(readback.stride as usize) { - for pixel in row[..readback.row_bytes].as_chunks::<4>().0 { - rgba.extend_from_slice(&[pixel[2], pixel[1], pixel[0], pixel[3]]); - } + }, + extent, + ); + gpu.queue.submit([encoder.finish()]); + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + buffer.slice(..).map_async(MapMode::Read, move |result| { + let _ = sender.send(result); + }); + gpu.device.poll(PollType::Wait { + submission_index: None, + timeout: Some(std::time::Duration::from_secs(10)), + })?; + receiver.recv_timeout(std::time::Duration::from_secs(10))??; + let mapped = buffer.slice(..).get_mapped_range()?; + for row in mapped.chunks_exact(readback.stride as usize) { + for pixel in row[..readback.row_bytes].as_chunks::<4>().0 { + rgba.extend_from_slice(&[pixel[2], pixel[1], pixel[0], pixel[3]]); } - drop(mapped); - buffer.unmap(); - Ok(rgba) } + drop(mapped); + buffer.unmap(); + Ok(rgba) } impl Drop for Canvas { fn drop(&mut self) { @@ -1218,16 +1312,16 @@ mod resource_tests { }); let view = texture.create_view(&Default::default()); let read = |canvas: &Canvas| { - canvas - .readback( - &texture, - PhysicalExtent::new(16, 16, 1, 8192) - .unwrap() - .readback(4096) - .unwrap(), - Vec::with_capacity(16 * 16 * 4), - ) - .unwrap() + readback_rgba( + &canvas.gpu, + &texture, + PhysicalExtent::new(16, 16, 1, 8192) + .unwrap() + .readback(4096) + .unwrap(), + Vec::with_capacity(16 * 16 * 4), + ) + .unwrap() }; canvas .paint(&view, 16, 16, 1, &[image("current", 1, [255, 0, 0, 255])]) diff --git a/lucent/crates/lucent-render/src/planes.rs b/lucent/crates/lucent-render/src/planes.rs new file mode 100644 index 00000000..7a5a0a25 --- /dev/null +++ b/lucent/crates/lucent-render/src/planes.rs @@ -0,0 +1,170 @@ +//! Shared native-plane GPU resources, without an Rc ownership cycle. +use super::*; +use lucent_api::video_frame::{ChromaSiting, Range, Transfer, Yuv420Frame}; + +pub(super) fn layout(gpu: &Gpu) -> BindGroupLayout { + let mut entries: Vec<_> = (0..3) + .map(|binding| BindGroupLayoutEntry { + binding, + visibility: ShaderStages::FRAGMENT, + ty: BindingType::Texture { + sample_type: TextureSampleType::Float { filterable: false }, + view_dimension: TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }) + .collect(); + entries.push(BindGroupLayoutEntry { + binding: 3, + visibility: ShaderStages::FRAGMENT, + ty: BindingType::Buffer { + ty: BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: BufferSize::new(32), + }, + count: None, + }); + gpu.device + .create_bind_group_layout(&BindGroupLayoutDescriptor { + label: Some("native YUV420 planes"), + entries: &entries, + }) +} +pub(super) struct PlaneResources { + pub dimensions: (u32, u32), + pub group: BindGroup, + planes: [Texture; 3], + uniform: Buffer, +} +impl PlaneResources { + /// Frame extents must already be checked against this device by scene + /// validation (or the standalone painter). No CPU pixels are uploaded yet. + pub fn new(gpu: &Gpu, layout: &BindGroupLayout, frame: &Yuv420Frame) -> Self { + let planes: [_; 3] = std::array::from_fn(|index| { + let (width, height) = frame.planes()[index].dimensions(); + gpu.device.create_texture(&TextureDescriptor { + label: Some("native video R8 plane"), + size: Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: TextureDimension::D2, + format: TextureFormat::R8Unorm, + usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST, + view_formats: &[], + }) + }); + let views: [_; 3] = + std::array::from_fn(|index| planes[index].create_view(&Default::default())); + let uniform = gpu.device.create_buffer(&BufferDescriptor { + label: Some("video color and siting"), + size: 32, + usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let group = gpu.device.create_bind_group(&BindGroupDescriptor { + label: Some("native video planes"), + layout, + entries: &[ + BindGroupEntry { + binding: 0, + resource: BindingResource::TextureView(&views[0]), + }, + BindGroupEntry { + binding: 1, + resource: BindingResource::TextureView(&views[1]), + }, + BindGroupEntry { + binding: 2, + resource: BindingResource::TextureView(&views[2]), + }, + BindGroupEntry { + binding: 3, + resource: uniform.as_entire_binding(), + }, + ], + }); + Self { + dimensions: frame.dimensions(), + group, + planes, + uniform, + } + } + pub fn texture_bytes(&self) -> u64 { + self.planes + .iter() + .map(|p| { + let size = p.size(); + u64::from(size.width) * u64::from(size.height) + }) + .sum() + } + pub fn payload_bytes(&self) -> u64 { + self.texture_bytes() + self.uniform.size() + } + /// Deferred until the validated scene is submitted. Queue writes precede + /// that scene's encoder; each resource owns its own input planes/uniform. + pub fn upload(&self, gpu: &Gpu, frame: &Yuv420Frame) -> u64 { + debug_assert_eq!(self.dimensions, frame.dimensions()); + let mut bytes = 0; + for (plane, texture) in frame.planes().iter().zip(&self.planes) { + let (width, height) = plane.dimensions(); + gpu.queue.write_texture( + TexelCopyTextureInfo { + texture, + mip_level: 0, + origin: Origin3d::ZERO, + aspect: TextureAspect::All, + }, + plane.bytes(), + TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(plane.stride()), + rows_per_image: Some(height), + }, + Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + bytes += u64::from(width) * u64::from(height); + } + let color = frame.color(); + let (w, h) = frame.dimensions(); + let (cw, ch) = frame.planes()[1].dimensions(); + let values = [ + w as f32, + h as f32, + cw as f32, + ch as f32, + if color.range == Range::Limited { + 1. + } else { + 0. + }, + if color.transfer == Transfer::Bt709 { + 1. + } else { + 0. + }, + if color.chroma == ChromaSiting::Left { + 0. + } else { + 0.5 + }, + 0.5, + ]; + let mut metadata = [0; 32]; + for (slot, value) in metadata.as_chunks_mut::<4>().0.iter_mut().zip(values) { + slot.copy_from_slice(&value.to_le_bytes()); + } + gpu.queue.write_buffer(&self.uniform, 0, &metadata); + bytes + } +} diff --git a/lucent/crates/lucent-render/src/present.wgsl b/lucent/crates/lucent-render/src/present.wgsl index bf1030c2..2e585dc1 100644 --- a/lucent/crates/lucent-render/src/present.wgsl +++ b/lucent/crates/lucent-render/src/present.wgsl @@ -1,71 +1,3 @@ @group(0) @binding(0) var image: texture_2d; @group(0) @binding(1) var filtering: sampler; -@group(1) @binding(0) var viewport: vec4; -struct ClipShape { rect: vec4, params: vec4 }; -@group(1) @binding(1) var clips: array; -override SHADOW_OUTSET: f32; -struct Vertex { - @builtin(position) position: vec4, - @location(0) local: vec2, - @location(1) rect: vec4, - @location(2) color: vec4, - @location(3) params: vec4, - @location(4) clip: vec4, - @location(5) uv: vec4, - @location(6) @interpolate(flat) clip_range: vec2, -}; -@vertex fn vs(@builtin(vertex_index) index:u32,@location(0) rect:vec4,@location(1) color:vec4,@location(2) params:vec4,@location(3) clip:vec4,@location(4) uv:vec4,@location(5) clip_range:vec4)->Vertex { - let corners=array,6>(vec2(0.0,0.0),vec2(1.0,0.0),vec2(0.0,1.0),vec2(0.0,1.0),vec2(1.0,0.0),vec2(1.0,1.0)); - let pad=select(0.0,params.z*SHADOW_OUTSET,params.y>1.5&¶ms.y<2.5); - let local=corners[index]*(rect.zw+vec2(pad*2.0))-vec2(pad); - let point=rect.xy+local; - var out:Vertex; - out.position=vec4(point.x/viewport.x*2.0-1.0,1.0-point.y/viewport.y*2.0,0.0,1.0); - out.local=local;out.rect=rect;out.color=color;out.params=params;out.clip=clip; - out.uv=uv;out.clip_range=vec2(clip_range.xy);return out; -} -fn round_distance(point:vec2,rect:vec4,requested_radius:f32)->f32 { - let radius=min(requested_radius,min(rect.z,rect.w)*0.5); - let q=abs(point-rect.xy-rect.zw*0.5)-rect.zw*0.5+vec2(radius); - return length(max(q,vec2(0.0)))+min(max(q.x,q.y),0.0)-radius; -} -@fragment fn fs(v:Vertex)->@location(0) vec4 { - // Use the actual sample center for clip boundaries, avoiding interpolation - // drift when a fractional boundary lands exactly on a physical pixel. - let point=v.position.xy/viewport.zw; - let d=round_distance(v.local,vec4(0.0,0.0,v.rect.zw),v.params.x); - // Default derivatives may use a coarse quad estimate on one GPU and fine - // differences on another. Coverage needs the same per-fragment choice. - let aa=max(fwidthFine(d),0.3); - var coverage=1.0-smoothstep(-aa,aa,d); - // Axis-aligned texture quads already have exact rasterized bounds. Fading - // their straight edges creates seams on full-surface wallpaper images. - if v.params.y>0.5&&v.params.y<1.5&&v.params.x<=0.0 {coverage=1.0;} - if v.params.y>1.5&&v.params.y<2.5&&v.params.z>0.0 {coverage=exp(-max(d,0.0)*max(d,0.0)/(v.params.z*v.params.z));} - if v.params.y>2.5 {coverage*=smoothstep(-aa,aa,d+v.params.z);} - var clip_distance=-1e6; - for(var i=0u;i0.5 { - let local=point-shape.rect.xy; - // Logical signed distance to the generic x+y reveal boundary. - // Keep derivatives and image sampling outside discard branches. - distance=(local.x+local.y-shape.params.x*(shape.rect.z+shape.rect.w))*inverseSqrt(2.0); - } - clip_distance=max(clip_distance,distance); - } - let clip_aa=max(fwidthFine(clip_distance),0.3); - coverage=min(coverage,1.0-smoothstep(-clip_aa,clip_aa,clip_distance)); - var pixel=vec4(1.0); - if v.params.y>0.5&&v.params.y<1.5 { - pixel=textureSample(image,filtering,v.uv.xy+(v.local/v.rect.zw)*v.uv.zw); - // Single-channel masks sample coverage from R8. Reconstruct the same - // premultiplied white RGBA that tint/opacity used before compression. - if v.params.z>0.5 {pixel=vec4(pixel.r);} - } - // Keep clipped lanes through derivative evaluation and implicit-LOD sampling. - // Early discard faded the last corner of odd-sized images on llvmpipe. - if any(point=v.clip.xy+v.clip.zw){discard;} - return vec4(pixel.rgb*v.color.rgb*v.color.a,pixel.a*v.color.a)*coverage; -} +fn sample_color(uv:vec2)->vec4 {return textureSample(image,filtering,uv);} diff --git a/lucent/crates/lucent-render/src/scene.rs b/lucent/crates/lucent-render/src/scene.rs index 2420df60..6fb279c9 100644 --- a/lucent/crates/lucent-render/src/scene.rs +++ b/lucent/crates/lucent-render/src/scene.rs @@ -74,6 +74,23 @@ fn color(color: Color) -> Result<(), Error> { } Ok(()) } +fn sampled_image( + uv: Rect, + radius: f32, + opacity: f32, + tint: Color, + size: (u32, u32), + limit: u32, +) -> Result { + positive(radius)?; + positive(opacity)?; + rect(uv)?; + color(tint)?; + if size.0 > limit || size.1 > limit { + return Err("image exceeds device texture dimension limit".into()); + } + Ok(tint.alpha(tint.3 * opacity)) +} pub(super) fn validate( paint: &[Paint], @@ -135,18 +152,38 @@ pub(super) fn validate( radius, opacity, tint, - } => { - positive(*radius)?; - positive(*opacity)?; - rect(*uv)?; - if data.width() > limits.max_texture_dimension_2d - || data.height() > limits.max_texture_dimension_2d - { - return Err("image exceeds device texture dimension limit".into()); - } - color(*tint)?; - (*bounds, clip, tint.alpha(tint.3 * *opacity)) - } + } => ( + *bounds, + clip, + sampled_image( + *uv, + *radius, + *opacity, + *tint, + (data.width(), data.height()), + limits.max_texture_dimension_2d, + )?, + ), + Paint::Video { + rect: bounds, + clip, + data, + uv, + radius, + opacity, + tint, + } => ( + *bounds, + clip, + sampled_image( + *uv, + *radius, + *opacity, + *tint, + (data.width(), data.height()), + limits.max_texture_dimension_2d, + )?, + ), Paint::Text { rect, clip, diff --git a/lucent/crates/lucent-render/src/scene.wgsl b/lucent/crates/lucent-render/src/scene.wgsl new file mode 100644 index 00000000..f3bba6c7 --- /dev/null +++ b/lucent/crates/lucent-render/src/scene.wgsl @@ -0,0 +1,69 @@ +@group(1) @binding(0) var viewport: vec4; +struct ClipShape { rect: vec4, params: vec4 }; +@group(1) @binding(1) var clips: array; +override SHADOW_OUTSET: f32; +struct Vertex { + @builtin(position) position: vec4, + @location(0) local: vec2, + @location(1) rect: vec4, + @location(2) color: vec4, + @location(3) params: vec4, + @location(4) clip: vec4, + @location(5) uv: vec4, + @location(6) @interpolate(flat) clip_range: vec2, +}; +@vertex fn vs(@builtin(vertex_index) index:u32,@location(0) rect:vec4,@location(1) color:vec4,@location(2) params:vec4,@location(3) clip:vec4,@location(4) uv:vec4,@location(5) clip_range:vec4)->Vertex { + let corners=array,6>(vec2(0.0,0.0),vec2(1.0,0.0),vec2(0.0,1.0),vec2(0.0,1.0),vec2(1.0,0.0),vec2(1.0,1.0)); + let pad=select(0.0,params.z*SHADOW_OUTSET,params.y>1.5&¶ms.y<2.5); + let local=corners[index]*(rect.zw+vec2(pad*2.0))-vec2(pad); + let point=rect.xy+local; + var out:Vertex; + out.position=vec4(point.x/viewport.x*2.0-1.0,1.0-point.y/viewport.y*2.0,0.0,1.0); + out.local=local;out.rect=rect;out.color=color;out.params=params;out.clip=clip; + out.uv=uv;out.clip_range=vec2(clip_range.xy);return out; +} +fn round_distance(point:vec2,rect:vec4,requested_radius:f32)->f32 { + let radius=min(requested_radius,min(rect.z,rect.w)*0.5); + let q=abs(point-rect.xy-rect.zw*0.5)-rect.zw*0.5+vec2(radius); + return length(max(q,vec2(0.0)))+min(max(q.x,q.y),0.0)-radius; +} +@fragment fn fs(v:Vertex)->@location(0) vec4 { + // Use the actual sample center for clip boundaries, avoiding interpolation + // drift when a fractional boundary lands exactly on a physical pixel. + let point=v.position.xy/viewport.zw; + let d=round_distance(v.local,vec4(0.0,0.0,v.rect.zw),v.params.x); + // Default derivatives may use a coarse quad estimate on one GPU and fine + // differences on another. Coverage needs the same per-fragment choice. + let aa=max(fwidthFine(d),0.3); + var coverage=1.0-smoothstep(-aa,aa,d); + // Axis-aligned texture quads already have exact rasterized bounds. Fading + // their straight edges creates seams on full-surface wallpaper images. + if v.params.y>0.5&&v.params.y<1.5&&v.params.x<=0.0 {coverage=1.0;} + if v.params.y>1.5&&v.params.y<2.5&&v.params.z>0.0 {coverage=exp(-max(d,0.0)*max(d,0.0)/(v.params.z*v.params.z));} + if v.params.y>2.5 {coverage*=smoothstep(-aa,aa,d+v.params.z);} + var clip_distance=-1e6; + for(var i=0u;i0.5 { + let local=point-shape.rect.xy; + // Logical signed distance to the generic x+y reveal boundary. + // Keep derivatives and image sampling outside discard branches. + distance=(local.x+local.y-shape.params.x*(shape.rect.z+shape.rect.w))*inverseSqrt(2.0); + } + clip_distance=max(clip_distance,distance); + } + let clip_aa=max(fwidthFine(clip_distance),0.3); + coverage=min(coverage,1.0-smoothstep(-clip_aa,clip_aa,clip_distance)); + var pixel=vec4(1.0); + if v.params.y>0.5&&v.params.y<1.5 { + pixel=sample_color(v.uv.xy+(v.local/v.rect.zw)*v.uv.zw); + // Single-channel masks sample coverage from R8. Reconstruct the same + // premultiplied white RGBA that tint/opacity used before compression. + if v.params.z>0.5 {pixel=vec4(pixel.r);} + } + // Keep clipped lanes through derivative evaluation and implicit-LOD sampling. + // Early discard faded the last corner of odd-sized images on llvmpipe. + if any(point=v.clip.xy+v.clip.zw){discard;} + return vec4(pixel.rgb*v.color.rgb*v.color.a,pixel.a*v.color.a)*coverage; +} diff --git a/lucent/crates/lucent-render/src/stream_upload.rs b/lucent/crates/lucent-render/src/stream_upload.rs index eea3beba..30848d87 100644 --- a/lucent/crates/lucent-render/src/stream_upload.rs +++ b/lucent/crates/lucent-render/src/stream_upload.rs @@ -113,7 +113,7 @@ impl StreamUpdates { if !uploads.copy(encoder, &update.texture, data) { gpu.queue.write_texture( TexelCopyTextureInfo { - texture: &update.texture._texture, + texture: update.texture.image_texture(), mip_level: 0, origin: Origin3d::ZERO, aspect: TextureAspect::All, @@ -138,7 +138,8 @@ impl StreamUpdates { pub(super) fn commit(self) { for update in self.0 { update.texture.revision.set(update.revision); - *update.texture.image_lifetime.borrow_mut() = Some(update.data.lifetime()); + *update.texture.lifetime.borrow_mut() = + Some(super::ResourceLifetime::Image(update.data.lifetime())); } } } @@ -354,7 +355,7 @@ impl Submission<'_> { }, }, TexelCopyTextureInfo { - texture: &texture._texture, + texture: texture.image_texture(), mip_level: 0, origin: Origin3d::ZERO, aspect: TextureAspect::All, diff --git a/lucent/crates/lucent-render/src/stream_upload/tests.rs b/lucent/crates/lucent-render/src/stream_upload/tests.rs index 8661b8fa..f20256c6 100644 --- a/lucent/crates/lucent-render/src/stream_upload/tests.rs +++ b/lucent/crates/lucent-render/src/stream_upload/tests.rs @@ -225,7 +225,7 @@ fn stream_upload_abandoned_prepare_keeps_revision_lifetime_and_pixels() { pending.revision(&texture), newer.stream_revision().unwrap().1 ); - assert!(texture.image_lifetime.borrow().as_ref().unwrap().is_alive()); + assert!(texture.lifetime.borrow().as_ref().unwrap().is_alive()); drop(pending); assert_eq!(texture.revision.get(), first.stream_revision().unwrap().1); assert_solid( diff --git a/lucent/crates/lucent-render/src/video_color.wgsl b/lucent/crates/lucent-render/src/video_color.wgsl new file mode 100644 index 00000000..1f171691 --- /dev/null +++ b/lucent/crates/lucent-render/src/video_color.wgsl @@ -0,0 +1,49 @@ +// Explicit BT.709 matrix/primaries; known BT.709 or sRGB transfer only. +// Output is nonlinear sRGB for Bgra8Unorm, NOT an sRGB render attachment. +struct Metadata {extent: vec4, color: vec4}; +@group(0) @binding(0) var y_plane: texture_2d; +@group(0) @binding(1) var u_plane: texture_2d; +@group(0) @binding(2) var v_plane: texture_2d; +@group(0) @binding(3) var metadata: Metadata; +fn display_srgb(signal: vec3) -> vec3 { + if metadata.color.y == 0. {return signal;} + // Invert BT.709 OETF, then encode sRGB. Matching primaries/white need no + // chromatic-adaptation matrix; no hardware sRGB conversion follows this. + let linear = select(pow((signal+vec3(.09929683))/1.0992968,vec3(1./.45)),signal/4.5,signal, at: vec2) -> f32 { + let dimensions = vec2(textureDimensions(plane)); + let last = dimensions-vec2(1); + let pixel = clamp(at,vec2(0.),vec2(last)); + let lower = vec2(floor(pixel)); + let upper = min(lower+vec2(1),last); + let fraction = fract(pixel); + let a = textureLoad(plane,lower,0).r; + let b = textureLoad(plane,vec2(upper.x,lower.y),0).r; + let c = textureLoad(plane,vec2(lower.x,upper.y),0).r; + let d = textureLoad(plane,upper,0).r; + return mix(mix(a,b,fraction.x),mix(c,d,fraction.x),fraction.y); +} +fn sample_color(uv: vec2) -> vec4 { + // Work in luma sample coordinates. Ceil-sized chroma extents and siting + // offsets matter at odd source edges and when scaling; plain UV reuse fails. + let luma_index = uv*metadata.extent.xy-vec2(.5); + let chroma_index = (luma_index-metadata.color.zw)*.5; + let y_sample = sample_plane(y_plane,luma_index); + let u_sample = sample_plane(u_plane,chroma_index); + let v_sample = sample_plane(v_plane,chroma_index); + var y = y_sample; + var cb = (u_sample*255.-128.)/255.; + var cr = (v_sample*255.-128.)/255.; + if metadata.color.x != 0. { + y = (y_sample*255.-16.)/219.; + cb = (u_sample*255.-128.)/224.; + cr = (v_sample*255.-128.)/224.; + } + let signal = clamp(vec3(y+1.5748*cr,y-.18732427*cb-.46812427*cr,y+1.8556*cb),vec3(0.),vec3(1.)); + return vec4(display_srgb(signal),1.); +} diff --git a/lucent/crates/lucent-render/src/video_planes.rs b/lucent/crates/lucent-render/src/video_planes.rs new file mode 100644 index 00000000..d0b2bd4f --- /dev/null +++ b/lucent/crates/lucent-render/src/video_planes.rs @@ -0,0 +1,179 @@ +//! Opt-in native-plane rendering. Existing RGBA scenes are unchanged. +//! This helper draws one opaque video into a BGRA8-unorm target for +//! the standalone validation consumer. Normal UI scenes use VideoData/Paint::Video. +use super::*; +use crate::planes::PlaneResources; +use lucent_api::video_frame::Yuv420Frame; + +/// Texture payload and work performed by this painter, not total GPU memory/RSS. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PlanarUploadStats { + pub texture_payload_bytes: u64, + pub texture_sets_created: u64, + pub frames_uploaded: u64, + pub bytes_uploaded: u64, +} +/// Reuses three R8 textures and one pipeline for a source extent. GPU uploads +/// still copy into wgpu staging; retaining native bytes is not DMA-BUF import. +pub struct PlanarPainter { + gpu: Rc, + pipeline: RenderPipeline, + layout: BindGroupLayout, + textures: Option, + stats: PlanarUploadStats, +} +impl PlanarPainter { + pub fn new(gpu: Rc) -> Self { + let layout = planes::layout(&gpu); + let pipeline_layout = gpu + .device + .create_pipeline_layout(&PipelineLayoutDescriptor { + label: Some("native video"), + bind_group_layouts: &[Some(&layout)], + immediate_size: 0, + }); + let shader = gpu.device.create_shader_module(ShaderModuleDescriptor { + label: Some("native video color"), + source: ShaderSource::Wgsl( + concat!( + include_str!("video_color.wgsl"), + include_str!("video_planes.wgsl") + ) + .into(), + ), + }); + let pipeline = gpu + .device + .create_render_pipeline(&RenderPipelineDescriptor { + label: Some("BT709 video to nonlinear sRGB BGRA8"), + layout: Some(&pipeline_layout), + vertex: VertexState { + module: &shader, + entry_point: Some("vs"), + compilation_options: Default::default(), + buffers: &[], + }, + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + fragment: Some(FragmentState { + module: &shader, + entry_point: Some("fs"), + compilation_options: Default::default(), + targets: &[Some(ColorTargetState { + format: TextureFormat::Bgra8Unorm, + blend: None, + write_mask: ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }); + Self { + gpu, + pipeline, + layout, + textures: None, + stats: Default::default(), + } + } + pub fn stats(&self) -> PlanarUploadStats { + self.stats + } + fn upload(&mut self, frame: &Yuv420Frame) -> Result<(), Error> { + let dimensions = frame.dimensions(); + let limit = self.gpu.device.limits().max_texture_dimension_2d; + if dimensions.0 > limit || dimensions.1 > limit { + return Err("Video exceeds device texture extent".into()); + } + if self + .textures + .as_ref() + .is_none_or(|value| value.dimensions != dimensions) + { + let textures = PlaneResources::new(&self.gpu, &self.layout, frame); + self.stats.texture_payload_bytes = textures.texture_bytes(); + self.stats.texture_sets_created += 1; + self.textures = Some(textures); + } + let textures = self + .textures + .as_ref() + .ok_or("Video texture admission failed")?; + self.stats.bytes_uploaded += textures.upload(&self.gpu, frame); + self.stats.frames_uploaded += 1; + Ok(()) + } + /// Fill a single-sample `Bgra8Unorm` render attachment with opaque video. + /// The target MUST NOT be an sRGB texture: the shader already emits nonlinear + /// sRGB values, matching Lucent's current explicit unorm presentation target. + pub fn draw_bgra8_unorm( + &mut self, + frame: &Yuv420Frame, + target: &TextureView, + ) -> Result<(), Error> { + self.upload(frame)?; + let textures = self + .textures + .as_ref() + .ok_or("Video texture admission failed")?; + let mut encoder = self.gpu.device.create_command_encoder(&Default::default()); + { + let mut pass = encoder.begin_render_pass(&RenderPassDescriptor { + label: Some("native video"), + color_attachments: &[Some(RenderPassColorAttachment { + view: target, + depth_slice: None, + resolve_target: None, + ops: Operations { + load: LoadOp::Clear(wgpu::Color::BLACK), + store: StoreOp::Store, + }, + })], + ..Default::default() + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &textures.group, &[]); + pass.draw(0..3, 0..1); + } + self.gpu.queue.submit([encoder.finish()]); + Ok(()) + } + /// Validation-only readback. Normal draws do not allocate CPU RGBA pixels. + pub fn snapshot( + &mut self, + frame: &Yuv420Frame, + width: u32, + height: u32, + ) -> Result, Error> { + let limits = self.gpu.device.limits(); + let extent = PhysicalExtent::density( + width, + height, + Density::integer(1)?, + limits.max_texture_dimension_2d, + )?; + let readback = extent.readback(limits.max_buffer_size)?; + let mut rgba = Vec::new(); + rgba.try_reserve_exact(readback.rgba_bytes)?; + let target = self.gpu.device.create_texture(&TextureDescriptor { + label: Some("native video validation readback"), + size: Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: TextureDimension::D2, + format: TextureFormat::Bgra8Unorm, + usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::COPY_SRC, + view_formats: &[], + }); + self.draw_bgra8_unorm(frame, &target.create_view(&Default::default()))?; + readback_rgba(&self.gpu, &target, readback, rgba) + } +} + +#[cfg(test)] +mod tests; diff --git a/lucent/crates/lucent-render/src/video_planes.wgsl b/lucent/crates/lucent-render/src/video_planes.wgsl new file mode 100644 index 00000000..6777ef4a --- /dev/null +++ b/lucent/crates/lucent-render/src/video_planes.wgsl @@ -0,0 +1,7 @@ +struct Vertex { @builtin(position) position: vec4, @location(0) uv: vec2 }; +@vertex fn vs(@builtin(vertex_index) index: u32) -> Vertex { + let positions = array, 3>(vec2(-1.,1.), vec2(3.,1.), vec2(-1.,-3.)); + let position = positions[index]; + return Vertex(vec4(position,0.,1.), vec2((position.x+1.)*.5,(1.-position.y)*.5)); +} +@fragment fn fs(vertex: Vertex) -> @location(0) vec4 {return sample_color(vertex.uv);} diff --git a/lucent/crates/lucent-render/src/video_planes/tests.rs b/lucent/crates/lucent-render/src/video_planes/tests.rs new file mode 100644 index 00000000..101f6aa9 --- /dev/null +++ b/lucent/crates/lucent-render/src/video_planes/tests.rs @@ -0,0 +1,213 @@ +use super::*; +use lucent_api::video_frame::{Bt709, Bytes, ChromaSiting, Plane, Range, Transfer}; + +fn frame( + width: u32, + height: u32, + samples: [Vec; 3], + color: Bt709, + padding: u32, +) -> Yuv420Frame { + let planes = samples + .into_iter() + .enumerate() + .map(|(index, samples)| { + let (w, h) = if index == 0 { + (width, height) + } else { + (width.div_ceil(2), height.div_ceil(2)) + }; + assert_eq!(samples.len(), (w * h) as usize); + let mut stored = Vec::new(); + for (row, values) in samples.chunks_exact(w as usize).enumerate() { + stored.extend_from_slice(values); + if row + 1 < h as usize { + stored.extend(std::iter::repeat_n(255, padding as usize)); + } + } + Plane::new(Bytes::from(stored), w, h, w + padding).unwrap() + }) + .collect::>() + .try_into() + .unwrap(); + Yuv420Frame::new(width, height, planes, color).unwrap() +} +fn color(range: Range, transfer: Transfer, chroma: ChromaSiting) -> Bt709 { + Bt709 { + range, + transfer, + chroma, + } +} +fn painter() -> PlanarPainter { + PlanarPainter::new(Gpu::headless(Arc::new(Vec::new())).unwrap()) +} + +// Independent f64 oracle: matrix is derived from Kr/Kb instead of copying shader +// coefficients. Spatial interpolation sums four explicit weights and code values. +fn reference(frame: &Yuv420Frame, x: u32, y: u32, width: u32, height: u32) -> [u8; 4] { + let uv = [ + (x as f64 + 0.5) / width as f64, + (y as f64 + 0.5) / height as f64, + ]; + let (w, h) = frame.dimensions(); + let luma = [uv[0] * w as f64 - 0.5, uv[1] * h as f64 - 0.5]; + let offset = if frame.color().chroma == ChromaSiting::Left { + [0., 0.5] + } else { + [0.5, 0.5] + }; + let chroma = [(luma[0] - offset[0]) / 2., (luma[1] - offset[1]) / 2.]; + let sample = |plane: &Plane, at: [f64; 2]| { + let (w, h) = plane.dimensions(); + let x = at[0].clamp(0., w as f64 - 1.); + let y = at[1].clamp(0., h as f64 - 1.); + let (left, top) = (x.floor() as u32, y.floor() as u32); + let (right, bottom) = ((left + 1).min(w - 1), (top + 1).min(h - 1)); + let (fx, fy) = (x.fract(), y.fract()); + [ + (left, top, (1. - fx) * (1. - fy)), + (right, top, fx * (1. - fy)), + (left, bottom, (1. - fx) * fy), + (right, bottom, fx * fy), + ] + .into_iter() + .map(|(x, y, weight)| f64::from(plane.row(y).unwrap()[x as usize]) * weight) + .sum::() + }; + let y = sample(&frame.planes()[0], luma); + let cb = sample(&frame.planes()[1], chroma) - 128.; + let cr = sample(&frame.planes()[2], chroma) - 128.; + let (y, cb, cr) = if frame.color().range == Range::Limited { + ((y - 16.) / 219., cb / 224., cr / 224.) + } else { + (y / 255., cb / 255., cr / 255.) + }; + let (kr, kb) = (0.2126, 0.0722); + let kg = 1. - kr - kb; + let red = y + 2. * (1. - kr) * cr; + let blue = y + 2. * (1. - kb) * cb; + let green = (y - kr * red - kb * blue) / kg; + let rgb = [red, green, blue].map(|signal: f64| { + let signal = signal.clamp(0., 1.); + let encoded = if frame.color().transfer == Transfer::Srgb { + signal + } else { + let alpha = 1.099296826809442; + let beta = 0.018053968510807; + let linear = if signal < 4.5 * beta { + signal / 4.5 + } else { + ((signal + alpha - 1.) / alpha).powf(1. / 0.45) + }; + if linear <= 0.0031308 { + 12.92 * linear + } else { + 1.055 * linear.powf(1. / 2.4) - 0.055 + } + }; + (encoded * 255.).round() as u8 + }); + [rgb[0], rgb[1], rgb[2], 255] +} +fn assert_pixels(frame: &Yuv420Frame, actual: &[u8], width: u32, height: u32) { + assert_eq!(actual.len(), (width * height * 4) as usize); + for y in 0..height { + for x in 0..width { + let expected = reference(frame, x, y, width, height); + let begin = ((y * width + x) * 4) as usize; + for channel in 0..4 { + assert!( + actual[begin + channel].abs_diff(expected[channel]) <= 1, + "source {:?}, output {width}x{height}, {:?}, pixel ({x},{y}) channel {channel}: actual {:?}, independent expected {expected:?}", + frame.dimensions(), + frame.color(), + &actual[begin..begin + 4] + ); + } + } + } +} + +#[test] +#[ignore = "requires Vulkan; run explicitly with --ignored"] +fn video_planes_gpu_matrix_range_transfer_and_opaque_output_match_independent_vectors() { + let mut painter = painter(); + for range in [Range::Full, Range::Limited] { + for transfer in [Transfer::Srgb, Transfer::Bt709] { + for (y, u, v) in [ + (0, 128, 128), + (16, 128, 128), + (235, 128, 128), + (255, 128, 128), + (45, 128, 128), + (128, 128, 128), + (63, 102, 240), + (173, 42, 26), + (32, 240, 118), + ] { + let frame = frame( + 2, + 2, + [vec![y; 4], vec![u], vec![v]], + color(range, transfer, ChromaSiting::Left), + 0, + ); + let pixels = painter.snapshot(&frame, 4, 4).unwrap(); + assert_pixels(&frame, &pixels, 4, 4); + } + } + } + assert_eq!(painter.stats().texture_sets_created, 1); + assert_eq!(painter.stats().frames_uploaded, 36); +} +#[test] +#[ignore = "requires Vulkan; run explicitly with --ignored"] +fn video_planes_gpu_odd_edges_strides_and_chroma_siting_match_independent_resampling() { + let mut painter = painter(); + for siting in [ChromaSiting::Left, ChromaSiting::Center] { + let frame = frame( + 5, + 3, + [ + vec![ + 64, 85, 106, 127, 148, 79, 100, 121, 142, 163, 94, 115, 136, 157, 178, + ], + vec![64, 128, 192, 96, 160, 224], + vec![192, 128, 64, 224, 160, 96], + ], + color(Range::Limited, Transfer::Bt709, siting), + 3, + ); + for (w, h) in [(5, 3), (13, 7), (3, 2)] { + let pixels = painter.snapshot(&frame, w, h).unwrap(); + assert_pixels(&frame, &pixels, w, h); + } + } + assert_eq!(painter.stats().texture_sets_created, 1); + assert_eq!(painter.stats().texture_payload_bytes, 27); + assert_eq!(painter.stats().bytes_uploaded, 27 * 6); +} +#[test] +#[ignore = "requires Vulkan; run explicitly with --ignored"] +fn video_planes_gpu_same_extent_reuses_resources_and_replacement_drops_old_pixels() { + let mut painter = painter(); + let c = color(Range::Full, Transfer::Srgb, ChromaSiting::Center); + for y in [32, 224, 128] { + let frame = frame(2, 2, [vec![y; 4], vec![128], vec![128]], c, 0); + let pixels = painter.snapshot(&frame, 3, 3).unwrap(); + assert_pixels(&frame, &pixels, 3, 3); + } + assert_eq!(painter.stats().texture_sets_created, 1); + let frame = frame(5, 1, [vec![180; 5], vec![128; 3], vec![128; 3]], c, 2); + let pixels = painter.snapshot(&frame, 5, 1).unwrap(); + assert_pixels(&frame, &pixels, 5, 1); + assert_eq!(painter.stats().texture_sets_created, 2); + assert_eq!(painter.stats().texture_payload_bytes, 11); + assert!(painter.snapshot(&frame, 0, 1).is_err()); + assert_eq!( + painter.stats().frames_uploaded, + 4, + "rejected output must not upload" + ); +} diff --git a/lucent/crates/lucent-render/src/video_scene.rs b/lucent/crates/lucent-render/src/video_scene.rs new file mode 100644 index 00000000..b4c85fb7 --- /dev/null +++ b/lucent/crates/lucent-render/src/video_scene.rs @@ -0,0 +1,124 @@ +//! Native planes use normal scene geometry/clips and the same bounded cache. +//! GPU resources contain no Rc and retain no CPU pixels after submission. +use super::*; +use lucent_api::VideoData; + +pub(super) struct Pipeline { + pub pipeline: RenderPipeline, + layout: BindGroupLayout, +} +impl Pipeline { + fn new(gpu: &Gpu) -> Self { + let layout = planes::layout(gpu); + let shader = gpu.device.create_shader_module(ShaderModuleDescriptor { + label: Some("native video scene"), + source: ShaderSource::Wgsl( + concat!(include_str!("video_color.wgsl"), include_str!("scene.wgsl")).into(), + ), + }); + let pipeline = scene_pipeline(&gpu.device, &layout, &gpu.frame_layout, &shader); + Self { pipeline, layout } + } +} +struct Update { + texture: Rc, + data: VideoData, + revision: u64, +} +#[derive(Default)] +pub(super) struct Updates(Vec); +impl Updates { + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + pub fn reserve(&mut self, count: usize) -> Result<(), Error> { + self.0.try_reserve_exact(count)?; + Ok(()) + } + fn revision(&self, texture: &Rc) -> u64 { + self.0 + .iter() + .find(|update| Rc::ptr_eq(&update.texture, texture)) + .map_or_else(|| texture.revision.get(), |update| update.revision) + } + fn defer(&mut self, texture: &Rc, data: &VideoData, revision: u64) { + if let Some(update) = self + .0 + .iter_mut() + .find(|update| Rc::ptr_eq(&update.texture, texture)) + { + update.data = data.clone(); + update.revision = revision; + } else { + self.0.push(Update { + texture: texture.clone(), + data: data.clone(), + revision, + }); + } + } + pub fn encode(&self, gpu: &Gpu) -> u64 { + self.0 + .iter() + .map(|update| match &update.texture.storage { + TextureStorage::Video(planes) => planes.upload(gpu, update.data.frame()), + TextureStorage::Image(_) => unreachable!("image resource in native upload"), + }) + .sum() + } + pub fn commit(self) { + for update in self.0 { + update.texture.revision.set(update.revision); + *update.texture.lifetime.borrow_mut() = + Some(ResourceLifetime::Video(update.data.lifetime())); + } + } +} +impl Gpu { + pub(super) fn video( + &self, + data: &VideoData, + profile: &mut ResourceProfile, + enabled: bool, + updates: &mut Updates, + ) -> Rc { + let streamed = data.stream_revision(); + let key = streamed.map_or_else( + || format!("video:{}", data.cache_id()), + |(id, _)| format!("video-stream:{id}"), + ); + if let Some(texture) = self.cache.borrow_mut().get(&key) + && texture.width == data.width() as f32 + && texture.height == data.height() as f32 + { + let revision = streamed.map_or(1, |(_, revision)| revision); + if updates.revision(&texture) != revision { + updates.defer(&texture, data, revision); + } + return texture; + } + if enabled { + profile.image_misses += 1; + } + let mut pipeline = self.video_pipeline.borrow_mut(); + let pipeline = pipeline.get_or_insert_with(|| Pipeline::new(self)); + let planes = planes::PlaneResources::new(self, &pipeline.layout, data.frame()); + let bytes = planes.payload_bytes(); + let texture = Rc::new(TextureResource { + group: planes.group.clone(), + width: data.width() as f32, + height: data.height() as f32, + storage: TextureStorage::Video(Box::new(planes)), + revision: Cell::new(0), + stream_id: Cell::new(streamed.map(|(id, _)| id)), + lifetime: RefCell::new(Some(ResourceLifetime::Video(data.lifetime()))), + bytes, + text_origin_x: 0., + }); + self.cache.borrow_mut().insert(key, texture.clone(), bytes); + updates.defer(&texture, data, streamed.map_or(1, |(_, revision)| revision)); + texture + } +} +#[cfg(test)] +mod tests; diff --git a/lucent/crates/lucent-render/src/video_scene/tests.rs b/lucent/crates/lucent-render/src/video_scene/tests.rs new file mode 100644 index 00000000..72ea334d --- /dev/null +++ b/lucent/crates/lucent-render/src/video_scene/tests.rs @@ -0,0 +1,223 @@ +use super::*; +use lucent_api::{ + Clip, Color, Element, ImageData, ImageStream, + video_frame::{Bt709, Bytes, ChromaSiting, Plane, Range, Transfer, Yuv420Frame}, +}; +use lucent_ui::{Interaction, Layout}; +fn native(stream: &ImageStream, grey: u8) -> Arc { + let y = Plane::new(Bytes::from(vec![grey; 4]), 2, 2, 2).unwrap(); + let uv = Plane::new(Bytes::from(vec![128]), 1, 1, 1).unwrap(); + Arc::new( + stream + .frame_yuv420( + Yuv420Frame::new( + 2, + 2, + [y, uv.clone(), uv], + Bt709 { + range: Range::Full, + transfer: Transfer::Srgb, + chroma: ChromaSiting::Left, + }, + ) + .unwrap(), + ) + .unwrap(), + ) +} +fn video(data: Arc, rect: Rect) -> Paint { + Paint::Video { + rect, + uv: Rect::new(0., 0., 1., 1.), + clip: rect.into(), + data, + radius: 0., + opacity: 1., + tint: Color::hex(0xffffff), + } +} +fn image(data: Arc, rect: Rect) -> Paint { + Paint::Image { + rect, + uv: Rect::new(0., 0., 1., 1.), + clip: rect.into(), + data, + radius: 0., + opacity: 1., + tint: Color::hex(0xffffff), + } +} +#[test] +#[ignore = "requires Vulkan; run explicitly with --ignored"] +fn video_scene_gpu_element_uses_existing_clip_tint_opacity_and_fractional_density() { + let gpu = Gpu::headless(Arc::new(vec![])).unwrap(); + let mut canvas = Canvas::new(gpu); + let data = native(&ImageStream::new(), 128); + let rgba = Arc::new( + ImageData::from_rgba("opaque comparison", 2, 2, [128, 128, 128, 255].repeat(4)).unwrap(), + ); + let layout = Layout::new(Arc::new(vec![])); + let tree = |node: Element<()>| { + Element::stack(vec![ + node.cover() + .fill() + .radius(4.) + .opacity(0.7) + .tint(Color::hex(0x80a0c0).alpha(0.6)) + .diagonal_reveal(0.55), + ]) + .fill() + .padding(3.) + .radius(6.) + .clip() + .background(Color::hex(0x102030)) + }; + let native = layout.build( + &tree(Element::video(data)), + 32., + 24., + &Interaction::default(), + 0., + ); + let rgba = layout.build( + &tree(Element::image(rgba)), + 32., + 24., + &Interaction::default(), + 0., + ); + for density in [ + Density::integer(1).unwrap(), + Density::new(5, 4).unwrap(), + Density::integer(2).unwrap(), + ] { + let expected = canvas + .snapshot_scaled(32, 24, density, &rgba.paint) + .unwrap(); + let actual = canvas + .snapshot_scaled(32, 24, density, &native.paint) + .unwrap(); + assert_eq!( + actual, expected, + "shared geometry/coverage must preserve actual pixels at {density:?}" + ); + } + assert_eq!(canvas.gpu.cache_stats().budget_bytes, 64 * 1024 * 1024); +} +#[test] +#[ignore = "requires Vulkan; run explicitly with --ignored"] +fn video_scene_gpu_frozen_planes_survive_live_updates_and_rgba_format_fallback() { + let gpu = Gpu::headless(Arc::new(vec![])).unwrap(); + let mut canvas = Canvas::new(gpu.clone()); + canvas.profiling = true; + canvas.stream_tracing = true; + let stream = ImageStream::new(); + let first = native(&stream, 32); + let frozen = Arc::new(first.snapshot()); + let frozen_id = frozen.cache_id(); + let id = first.stream_revision().unwrap().0; + let left = Rect::new(0., 0., 4., 4.); + let right = Rect::new(4., 0., 4., 4.); + for grey in [128, 224] { + let current = native(&stream, grey); + let revision = current.stream_revision().unwrap().1; + let pixels = canvas + .snapshot( + 8, + 4, + 1, + &[video(frozen.clone(), left), video(current, right)], + ) + .unwrap(); + for y in 0..4 { + for x in 0..8 { + let at = ((y * 8 + x) * 4) as usize; + let expected = if x < 4 { 32 } else { grey }; + assert_eq!(&pixels[at..at + 4], &[expected, expected, expected, 255]); + } + } + assert_eq!( + canvas.last_stream_trace.unwrap().revisions(), + &[(id, revision)] + ); + } + let fallback = Arc::new(stream.frame_rgba(2, 2, [255, 0, 0, 255].repeat(4)).unwrap()); + let revision = fallback.stream_revision().unwrap().1; + let pixels = canvas + .snapshot( + 8, + 4, + 1, + &[video(frozen.clone(), left), image(fallback, right)], + ) + .unwrap(); + assert_eq!(&pixels[0..4], &[32, 32, 32, 255]); + assert_eq!(&pixels[16..20], &[255, 0, 0, 255]); + assert_eq!( + canvas.last_stream_trace.unwrap().revisions(), + &[(id, revision)] + ); + assert_eq!(frozen.cache_id(), frozen_id); + assert!(frozen.stream_revision().is_none()); + assert!( + gpu.cache_stats().bytes < 128, + "one shared cache counts native planes/uniform and RGBA payload" + ); +} +#[test] +#[ignore = "requires Vulkan; run explicitly with --ignored"] +fn video_scene_gpu_rejects_invalid_scene_before_publishing_native_upload_revision() { + let gpu = Gpu::headless(Arc::new(vec![])).unwrap(); + let mut canvas = Canvas::new(gpu.clone()); + let stream = ImageStream::new(); + let first = native(&stream, 32); + let full = Rect::new(0., 0., 4., 4.); + let target = gpu.device.create_texture(&TextureDescriptor { + label: Some("native validation preservation"), + size: Extent3d { + width: 4, + height: 4, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: TextureDimension::D2, + format: TextureFormat::Bgra8Unorm, + usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&Default::default()); + canvas.paint(&view, 4, 4, 1, &[video(first, full)]).unwrap(); + let resource = canvas.resources[0].clone(); + assert!(resource.is_video()); + assert_eq!(resource.revision.get(), 1); + let second = native(&stream, 224); + let invalid = [ + video(second, full), + Paint::Shape { + rect: Rect::new(f32::NAN, 0., 1., 1.), + clip: Clip::from(full), + color: Color::hex(0), + radius: 0., + }, + ]; + assert!(canvas.paint(&view, 4, 4, 1, &invalid).is_err()); + assert_eq!(resource.revision.get(), 1); + let pixels = readback_rgba( + &gpu, + &target, + PhysicalExtent::density(4, 4, Density::integer(1).unwrap(), 8192) + .unwrap() + .readback(4096) + .unwrap(), + Vec::new(), + ) + .unwrap(); + assert!( + pixels + .as_chunks::<4>() + .0 + .iter() + .all(|p| p == &[32, 32, 32, 255]) + ); +} diff --git a/lucent/crates/lucent-services/examples/video-allocation-observer/README.md b/lucent/crates/lucent-services/examples/video-allocation-observer/README.md index 69ca65b4..a601b70c 100644 --- a/lucent/crates/lucent-services/examples/video-allocation-observer/README.md +++ b/lucent/crates/lucent-services/examples/video-allocation-observer/README.md @@ -4,8 +4,8 @@ This example consumes `WallpaperPlaybackPort` directly. It uses an already prepared owned asset and never imports media, changes the desktop, or creates a renderer, GPU device, Wayland surface or application window. It runs three finite phases in one process: 1920×1080, 3840×2160, then 1920×1080 again. -Each phase retains only its latest domain frame, or optionally the existing SDK -shared image bridge. All pixel owners from the previous phase are dropped only +By default, each phase retains only its latest domain frame, or optionally the +existing SDK shared image bridge. All pixel owners from the previous phase are dropped only after its producer has observed cancellation and its exact worker has joined. No arguments prints an inert plan. `--run` reads at most 64 KiB of JSON from @@ -24,7 +24,8 @@ private stdin. Supply the actual existing prepared descriptor, for example: }, "phase_seconds": 10, "sdk_bridge": false, - "allocator_stats": false + "allocator_stats": false, + "retarget": false } ``` @@ -119,3 +120,25 @@ space, versus about 17 MiB after the first FHD phase. Accounted in-use grows onl free arena retention in the independent consumer, without identifying resident page owners, a codec leak or the desktop's full memory cost. Original desktop, media and owned-process cleanup checks pass. No allocator policy was changed. + +## One-play retarget comparison + +Set `"retarget": true` to select the separate one-worker capability described in +[same-asset playback retargeting](../../../../../docs/wallpaper-retargeting.md). +The default remains three separate static play calls. In retarget mode the +physical target changes FHD → 4K → FHD through the injected latest-demand +adapter. Stale revision results are rejected; one latest frame remains held +until its valid replacement arrives. No desktop preferences or renderer change. + +There are three bounded process-memory points per target. Its observation +period starts after a matching frame is observed, following at most five seconds +of readiness work. These clocks and publication counts do not establish FPS or +a speedup. Per-target memory includes a live decoder and must not be compared +directly to the old mode's stopped-worker samples. + +Allocator statistics run only before the whole worker and after its final join +and final latest drop: three whole-run snapshots, with none at intermediate +target boundaries. The `retarget_phase` and `retarget_finished` rows are a new +diagnostic schema. A new reviewed build and source-bound native runner are +required; this source-only prototype is not a measured memory improvement and +does not enable desktop integration. diff --git a/lucent/crates/lucent-services/examples/video-allocation-observer/main.rs b/lucent/crates/lucent-services/examples/video-allocation-observer/main.rs index 63bb40c5..253d3a11 100644 --- a/lucent/crates/lucent-services/examples/video-allocation-observer/main.rs +++ b/lucent/crates/lucent-services/examples/video-allocation-observer/main.rs @@ -28,6 +28,7 @@ const PHASES: [(&str, u32, u32); 3] = [ ("restored_fhd", 1920, 1080), ]; type Check = std::result::Result; +mod retarget; #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -39,6 +40,8 @@ struct Input { sdk_bridge: bool, #[serde(default)] allocator_stats: bool, + #[serde(default)] + retarget: bool, } fn local(path: &Path) -> bool { @@ -449,6 +452,17 @@ fn observe( allocator_query: &mut dyn FnMut() -> Value, emit: &mut dyn FnMut(Value) -> Check<()>, ) -> Check<()> { + if request.retarget { + return retarget::observe( + adapter, + request, + duration, + join_timeout, + sample, + allocator_query, + emit, + ); + } let mut samples = Samples { memory: sample, allocator: request.allocator_stats.then_some(allocator_query), diff --git a/lucent/crates/lucent-services/examples/video-allocation-observer/retarget.rs b/lucent/crates/lucent-services/examples/video-allocation-observer/retarget.rs new file mode 100644 index 00000000..3174d2e3 --- /dev/null +++ b/lucent/crates/lucent-services/examples/video-allocation-observer/retarget.rs @@ -0,0 +1,588 @@ +//! Independent one-play observation; the desktop never selects this path. +use super::*; +use lucent_domain::{ + RetargetablePlaybackOptions, TargetedMediaFrame, WallpaperTargetPort, WallpaperTargetRevision, +}; +use lucent_services::video::LatestWallpaperTargetAdapter; + +const TARGET_READY_TIMEOUT: Duration = Duration::from_secs(5); + +fn admitted_ready_elapsed(ready: bool, elapsed: Duration) -> Check> { + if elapsed >= TARGET_READY_TIMEOUT { + return Err("target_not_observed"); + } + Ok(ready.then_some(elapsed)) +} + +struct State { + expected: WallpaperTargetRevision, + publications: Publications, + stale: u64, +} +impl State { + fn accept(&mut self, value: TargetedMediaFrame, stream: Option<&ImageStream>) -> Check<()> { + if value.revision != self.expected { + self.stale = self.stale.checked_add(1).ok_or("counter_exhausted")?; + return Ok(()); + } + self.publications.accept(value.frame, stream) + } + fn request( + &mut self, + control: &LatestWallpaperTargetAdapter, + target: Option, + ) -> Check<()> { + if let Some(error) = self.publications.error { + return Err(error); + } + let next = control + .set(self.expected, target) + .map_err(|_| "target_control_failed")?; + if next.revision != self.expected { + // Only metrics reset. Keep the old latest allocation until its + // valid replacement arrives, as an ordinary latest-only consumer. + let latest = self.publications.latest.take(); + self.publications = Publications { + latest, + ..Publications::default() + }; + self.expected = next.revision; + } + Ok(()) + } +} + +fn point(samples: &mut Samples<'_>, label: &'static str, elapsed: Duration) -> Value { + // Per-target points are memory-only even when allocator_stats is enabled. + json!({"moment":label,"elapsed_ms":elapsed.as_millis(),"memory":(samples.memory)()}) +} + +pub(super) fn observe( + adapter: Arc, + request: Input, + duration: Duration, + join_timeout: Duration, + memory: &mut dyn FnMut() -> Value, + allocator_query: &mut dyn FnMut() -> Value, + emit: &mut dyn FnMut(Value) -> Check<()>, +) -> Check<()> { + let mut samples = Samples { + memory, + allocator: request.allocator_stats.then_some(allocator_query), + }; + let total = duration + .checked_mul(3) + .and_then(|value| value.checked_add(TARGET_READY_TIMEOUT * 3)) + .ok_or("observation_deadline_overflow")?; + let started = Instant::now(); + let mut whole = vec![samples.capture(Moment::BeforePlay, 0)]; + let target = + WallpaperPixelTarget::new(PHASES[0].1, PHASES[0].2).map_err(|_| "invalid_target")?; + let control = Arc::new(LatestWallpaperTargetAdapter::new(Some(target))); + let initial = control + .try_latest() + .map_err(|_| "target_control_failed")? + .ok_or("initial_target_unavailable")?; + let stop = Arc::new(Stop::new(total)); + let state = Arc::new(Mutex::new(State { + expected: initial.revision, + publications: Publications::default(), + stale: 0, + })); + let observed = state.clone(); + let cancelled = stop.clone(); + let targets = control.clone(); + let worker = thread::spawn(move || { + let stream = request.sdk_bridge.then(ImageStream::new); + adapter.play_retargetable( + &request.asset, + RetargetablePlaybackOptions { + fps: WallpaperFrameRate::Source, + looping: true, + }, + targets, + &mut |value| { + if cancelled.cancelled() { + return false; + } + let Ok(mut state) = observed.lock() else { + cancelled.cancel(); + return false; + }; + let result = value + .map_err(|_| "adapter_error") + .and_then(|value| state.accept(value, stream.as_ref())); + if let Err(error) = result { + state.publications.error = Some(error); + cancelled.cancel(); + return false; + } + true + }, + cancelled.clone(), + ); + cancelled.cancelled() + }); + // No output is emitted while the producer is alive. Every failure first + // cancels and attempts the exact worker join; no later phase/watch starts. + let results: Check> = (|| { + let mut results = Vec::new(); + for (index, (label, width, height)) in PHASES.into_iter().enumerate() { + let mut points = vec![point(&mut samples, "before_target", started.elapsed())]; + let requested = Instant::now(); + if index != 0 { + state + .lock() + .map_err(|_| "publication_state_unavailable")? + .request( + &control, + Some( + WallpaperPixelTarget::new(width, height) + .map_err(|_| "invalid_target")?, + ), + )?; + } + let ready_elapsed = loop { + let ready = { + let state = state.lock().map_err(|_| "publication_state_unavailable")?; + if let Some(error) = state.publications.error { + return Err(error); + } + let ready = state.publications.count != 0; + // Validate a fresh clock after state/lock validation. A + // publication observed after a delayed wake is not ready + // within budget, even if it arrived before we got the lock. + admitted_ready_elapsed(ready, requested.elapsed())? + }; + if let Some(elapsed) = ready { + break elapsed; + } + if worker.is_finished() { + return Err("worker_ended_before_finite_observation"); + } + if stop.cancelled() { + return Err("retarget_sequence_cancelled"); + } + stop.wait( + SAMPLE_INTERVAL.min(TARGET_READY_TIMEOUT.saturating_sub(requested.elapsed())), + ); + }; + let ready_ms = ready_elapsed.as_millis(); + points.push(point(&mut samples, "target_observed", started.elapsed())); + stop.wait(duration); + if worker.is_finished() { + return Err("worker_ended_before_finite_observation"); + } + if stop.cancelled() { + return Err("retarget_sequence_cancelled"); + } + points.push(point( + &mut samples, + "end_target_observation", + started.elapsed(), + )); + let state = state.lock().map_err(|_| "publication_state_unavailable")?; + let p = &state.publications; + if let Some(error) = p.error { + return Err(error); + } + results.push(json!({"kind":"retarget_phase","phase":label, + "revision":state.expected.get(),"requested_size":[width,height], + "target_ready_elapsed_ms":ready_ms,"elapsed_ms":requested.elapsed().as_millis(), + "publications":p.count,"first_size":p.first_size,"last_size":p.last_size, + "geometry_changes":p.geometry_changes,"maximum_published_capacity_bytes":p.max_capacity, + "latest_capacity_bytes":p.latest.as_ref().map(|value|value.capacity), + "memory_samples":points})); + } + Ok(results) + })(); + stop.cancel(); + let join_deadline = Instant::now() + join_timeout; + while !worker.is_finished() && Instant::now() < join_deadline { + thread::sleep(Duration::from_millis(2)); + } + if !worker.is_finished() { + return Err("worker_join_timeout"); + } + let cancellation_observed = worker.join().map_err(|_| "worker_panicked")?; + let results = results?; + if !cancellation_observed { + return Err("worker_ended_before_finite_observation"); + } + let mut state = state.lock().map_err(|_| "publication_state_unavailable")?; + if let Some(error) = state.publications.error { + return Err(error); + } + whole.push(samples.capture(Moment::JoinedLatestHeld, started.elapsed().as_millis())); + let last_size = state.publications.latest.as_ref().map(Latest::dimensions); + let last_capacity = state + .publications + .latest + .as_ref() + .map(|value| value.capacity); + state.publications.latest = None; + whole.push(samples.capture(Moment::LatestDropped, started.elapsed().as_millis())); + for result in results { + emit(result)?; + } + emit( + json!({"kind":"retarget_finished","one_play_call":true,"same_process_sequence":true, + "watch_joined":true,"cancellation_observed":true,"stop_requested":true, + "consumer_held_pixel_capacity_after_drop":0,"last_size":last_size, + "latest_capacity_bytes_before_drop":last_capacity,"stale_frames_rejected":state.stale, + "memory_samples":whole,"renderer_used":false,"performance_accepted":false}), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use lucent_domain::{DomainError, Result, Wallpaper}; + use std::sync::atomic::AtomicUsize; + + #[test] + fn readiness_admission_rejects_late_publications_and_keeps_the_validated_clock() { + let before = TARGET_READY_TIMEOUT - Duration::from_nanos(1); + assert_eq!(admitted_ready_elapsed(true, before), Ok(Some(before))); + assert_eq!(admitted_ready_elapsed(false, before), Ok(None)); + assert_eq!( + admitted_ready_elapsed(true, Duration::ZERO), + Ok(Some(Duration::ZERO)) + ); + for elapsed in [ + TARGET_READY_TIMEOUT, + TARGET_READY_TIMEOUT + Duration::from_nanos(1), + ] { + for ready in [false, true] { + assert_eq!( + admitted_ready_elapsed(ready, elapsed), + Err("target_not_observed") + ); + } + } + } + + fn request() -> Input { + input( + &serde_json::to_vec(&json!({"state":"/private/state","phase_seconds":1, + "allocator_stats":true,"retarget":true, + "asset":{"source":{"name":"Private","path":"/unavailable/original.mp4"}, + "path":"/private/state/media-owned/source.mp4", + "poster":{"name":"Private","path":"/private/state/media-owned/poster.png"}}})) + .unwrap(), + ) + .unwrap() + } + fn frame(revision: WallpaperTargetRevision, pixels: Arc>) -> TargetedMediaFrame { + TargetedMediaFrame { + revision, + frame: MediaFrame::rgba_shared_with_aspect(1, 1, pixels, (1, 1), Duration::ZERO) + .unwrap(), + } + } + #[derive(Default)] + struct Fake { + active: AtomicUsize, + calls: AtomicUsize, + seen: Mutex>, + ignore_stop: AtomicBool, + error: bool, + early: bool, + } + impl WallpaperPlaybackPort for Fake { + fn prepare(&self, _: &Wallpaper) -> Result { + panic!("never import") + } + fn play( + &self, + _: &LiveWallpaperAsset, + _: PlaybackOptions, + _: &mut dyn FnMut(Result) -> bool, + _: Arc, + ) { + panic!("retargeting must never fall back to static play/restart") + } + fn play_retargetable( + &self, + _: &LiveWallpaperAsset, + options: RetargetablePlaybackOptions, + targets: Arc, + emit: &mut dyn FnMut(Result) -> bool, + stop: Arc, + ) { + assert_eq!(self.active.fetch_add(1, Ordering::SeqCst), 0); + self.calls.fetch_add(1, Ordering::SeqCst); + assert_eq!(options.fps, WallpaperFrameRate::Source); + assert!(options.looping); + if self.error { + emit(Err(DomainError::Failed("private backend error".into()))); + } else { + while !stop.cancelled() || self.ignore_stop.load(Ordering::Acquire) { + if let Some(demand) = targets.try_latest().unwrap() { + let mut seen = self.seen.lock().unwrap(); + if seen.last().is_none_or(|last| last.0 != demand.revision) { + seen.push(( + demand.revision, + demand + .target + .expect("observer phase has a physical target") + .size(), + )); + } + drop(seen); + if !emit(Ok(frame(demand.revision, Arc::new(vec![10, 20, 30, 255])))) + && !self.ignore_stop.load(Ordering::Acquire) + { + break; + } + if self.early { + break; + } + } + if self.ignore_stop.load(Ordering::Acquire) { + thread::sleep(Duration::from_millis(1)); + } else { + stop.wait(Duration::from_millis(1)); + } + } + } + self.active.fetch_sub(1, Ordering::SeqCst); + } + } + + #[test] + fn one_play_owns_all_targets_and_allocator_queries_only_run_outside_that_worker() { + for sdk in [false, true] { + let adapter = Arc::new(Fake::default()); + let mut input = request(); + input.sdk_bridge = sdk; + let mut queries = 0; + let mut memory = 0; + let mut rows = Vec::new(); + crate::observe( + adapter.clone(), + input, + Duration::from_millis(10), + Duration::from_secs(1), + &mut || { + memory += 1; + json!({"rss_kib":4}) + }, + &mut || { + assert_eq!(adapter.active.load(Ordering::SeqCst), 0); + queries += 1; + json!({"available":false}) + }, + &mut |value| { + assert_eq!(adapter.active.load(Ordering::SeqCst), 0); + rows.push(value); + Ok(()) + }, + ) + .unwrap(); + assert_eq!(adapter.calls.load(Ordering::SeqCst), 1); + assert_eq!(queries, 3); + assert_eq!(memory, 12); + assert_eq!( + *adapter.seen.lock().unwrap(), + vec![ + (WallpaperTargetRevision::new(1).unwrap(), (1920, 1080)), + (WallpaperTargetRevision::new(2).unwrap(), (3840, 2160)), + (WallpaperTargetRevision::new(3).unwrap(), (1920, 1080)) + ] + ); + assert_eq!(rows.len(), 4); + for row in rows.iter().take(3) { + assert_eq!(row["kind"], "retarget_phase"); + assert!(row["publications"].as_u64().unwrap() > 0); + assert!( + row["memory_samples"] + .as_array() + .unwrap() + .iter() + .all(|sample| sample.get("allocator").is_none()) + ); + } + assert_eq!(rows[3]["kind"], "retarget_finished"); + assert_eq!(rows[3]["one_play_call"], true); + assert_eq!(rows[3]["consumer_held_pixel_capacity_after_drop"], 0); + assert_eq!(rows[3]["performance_accepted"], false); + } + } + + #[test] + fn stale_results_never_relabel_pixels_and_old_latest_retires_only_on_valid_replacement() { + let control = + LatestWallpaperTargetAdapter::new(Some(WallpaperPixelTarget::new(1920, 1080).unwrap())); + let first = control.try_latest().unwrap().unwrap(); + let mut state = State { + expected: first.revision, + publications: Publications::default(), + stale: 0, + }; + let old = Arc::new(vec![1, 2, 3, 255]); + let old_weak = Arc::downgrade(&old); + state.accept(frame(first.revision, old), None).unwrap(); + state + .request( + &control, + Some(WallpaperPixelTarget::new(3840, 2160).unwrap()), + ) + .unwrap(); + assert_eq!(old_weak.strong_count(), 1); + assert_eq!(state.publications.count, 0); + let stale = Arc::new(vec![4, 5, 6, 255]); + let stale_weak = Arc::downgrade(&stale); + state.accept(frame(first.revision, stale), None).unwrap(); + assert_eq!(state.stale, 1); + assert!(stale_weak.upgrade().is_none()); + assert_eq!(old_weak.strong_count(), 1); + state + .accept(frame(state.expected, Arc::new(vec![7, 8, 9, 255])), None) + .unwrap(); + assert!(old_weak.upgrade().is_none()); + assert_eq!(state.publications.count, 1); + } + + #[test] + fn source_size_transitions_keep_last_good_reject_stale_frames_and_preserve_failure() { + let control = LatestWallpaperTargetAdapter::new(None); + let source = control.try_latest().unwrap().unwrap(); + let mut state = State { + expected: source.revision, + publications: Publications::default(), + stale: 0, + }; + let old = Arc::new(vec![1, 2, 3, 255]); + let old_weak = Arc::downgrade(&old); + state.accept(frame(source.revision, old), None).unwrap(); + state + .request( + &control, + Some(WallpaperPixelTarget::new(1920, 1080).unwrap()), + ) + .unwrap(); + let covered = state.expected; + assert_eq!(old_weak.strong_count(), 1); + state.request(&control, None).unwrap(); + let restored = state.expected; + assert!(restored > covered); + assert_eq!(control.try_latest().unwrap().unwrap().target, None); + assert_eq!(old_weak.strong_count(), 1); + for revision in [source.revision, covered] { + state + .accept(frame(revision, Arc::new(vec![4, 5, 6, 255])), None) + .unwrap(); + } + assert_eq!(state.stale, 2); + assert_eq!(state.publications.count, 0); + assert_eq!(old_weak.strong_count(), 1); + let fresh = Arc::new(vec![7, 8, 9, 255]); + let fresh_weak = Arc::downgrade(&fresh); + state.accept(frame(restored, fresh), None).unwrap(); + assert!(old_weak.upgrade().is_none()); + let admitted = control.try_latest().unwrap(); + state.publications.error = Some("playback_failed"); + assert_eq!( + state.request( + &control, + Some(WallpaperPixelTarget::new(3840, 2160).unwrap()) + ), + Err("playback_failed") + ); + assert_eq!(state.expected, restored); + assert_eq!(control.try_latest().unwrap(), admitted); + assert_eq!(state.publications.error, Some("playback_failed")); + assert_eq!(fresh_weak.strong_count(), 1); + } + + #[test] + fn backend_error_or_early_return_never_advances_to_the_next_target() { + for error in [true, false] { + let adapter = Arc::new(Fake { + error, + early: !error, + ..Fake::default() + }); + let mut calls = 0; + let result = crate::observe( + adapter.clone(), + request(), + Duration::from_millis(10), + Duration::from_secs(1), + &mut || json!({}), + &mut || { + calls += 1; + json!({}) + }, + &mut |_| panic!("failed sequence cannot emit success"), + ); + assert!(result.is_err()); + assert_eq!(adapter.calls.load(Ordering::SeqCst), 1); + assert_eq!(adapter.active.load(Ordering::SeqCst), 0); + assert_eq!(calls, 1); + assert!(adapter.seen.lock().unwrap().len() <= 1); + } + } + + #[test] + fn a_stop_ignoring_worker_fails_join_bound_without_another_play_or_postjoin_stats() { + let adapter = Arc::new(Fake::default()); + adapter.ignore_stop.store(true, Ordering::Release); + let mut calls = 0; + let result = crate::observe( + adapter.clone(), + request(), + Duration::from_millis(10), + Duration::from_millis(2), + &mut || json!({}), + &mut || { + calls += 1; + json!({}) + }, + &mut |_| panic!("unjoined worker cannot emit success"), + ); + assert_eq!(result, Err("worker_join_timeout")); + assert_eq!(calls, 1); + assert_eq!(adapter.calls.load(Ordering::SeqCst), 1); + adapter.ignore_stop.store(false, Ordering::Release); + let deadline = Instant::now() + Duration::from_secs(1); + while adapter.active.load(Ordering::SeqCst) != 0 && Instant::now() < deadline { + thread::sleep(Duration::from_millis(1)); + } + assert_eq!(adapter.active.load(Ordering::SeqCst), 0); + } + + #[test] + fn unsupported_default_is_explicit_and_never_calls_static_play() { + struct StaticOnly(AtomicUsize); + impl WallpaperPlaybackPort for StaticOnly { + fn prepare(&self, _: &Wallpaper) -> Result { + panic!("no import") + } + fn play( + &self, + _: &LiveWallpaperAsset, + _: PlaybackOptions, + _: &mut dyn FnMut(Result) -> bool, + _: Arc, + ) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + let adapter = Arc::new(StaticOnly(AtomicUsize::new(0))); + assert_eq!( + crate::observe( + adapter.clone(), + request(), + Duration::from_millis(10), + Duration::from_secs(1), + &mut || json!({}), + &mut || json!({}), + &mut |_| panic!("unsupported") + ), + Err("adapter_error") + ); + assert_eq!(adapter.0.load(Ordering::SeqCst), 0); + } +} diff --git a/lucent/crates/lucent-services/examples/video-allocation-observer/tests.rs b/lucent/crates/lucent-services/examples/video-allocation-observer/tests.rs index 0fe291b3..d365b2b5 100644 --- a/lucent/crates/lucent-services/examples/video-allocation-observer/tests.rs +++ b/lucent/crates/lucent-services/examples/video-allocation-observer/tests.rs @@ -78,6 +78,19 @@ fn input_is_bounded_known_schema_and_allows_legitimate_local_names() { #[test] fn allocator_statistics_require_a_boolean_opt_in_and_default_to_disabled() { assert!(!request().allocator_stats); + assert!(!request().retarget); + let mut retarget = descriptor(); + retarget["retarget"] = json!(true); + assert!( + input(&serde_json::to_vec(&retarget).unwrap()) + .unwrap() + .retarget + ); + retarget["retarget"] = json!("true"); + assert_eq!( + input(&serde_json::to_vec(&retarget).unwrap()).err(), + Some("invalid_input") + ); for enabled in [false, true] { let mut value = descriptor(); value["allocator_stats"] = json!(enabled); diff --git a/lucent/crates/lucent-services/src/video.rs b/lucent/crates/lucent-services/src/video.rs index 2040a412..03a797b4 100644 --- a/lucent/crates/lucent-services/src/video.rs +++ b/lucent/crates/lucent-services/src/video.rs @@ -16,9 +16,12 @@ use std::{ const SOURCE_BYTES: u64 = 512 * 1024 * 1024; const LIBRARY_RESULTS: usize = 32; const LIBRARY_ENTRIES: usize = 1024; +mod native_planes; mod pixels; mod preview; mod store; +mod targets; +pub use targets::LatestWallpaperTargetAdapter; static NEXT: AtomicU64 = AtomicU64::new(1); static INIT: OnceLock = OnceLock::new(); #[derive(Clone)] @@ -263,6 +266,56 @@ impl WallpaperPlaybackPort for NativeVideoWallpapers { emit(Err(error)); } } + fn play_retargetable( + &self, + asset: &LiveWallpaperAsset, + options: RetargetablePlaybackOptions, + targets: Arc, + emit: &mut dyn FnMut(Result) -> bool, + stop: Arc, + ) { + let result = self.owned(asset).and_then(|()| { + decode_retargetable( + Path::new(&asset.path), + options, + targets, + &mut |frame| emit(Ok(frame)), + stop.clone(), + ) + }); + if let Err(error) = result + && !stop.cancelled() + { + emit(Err(error)); + } + } + fn play_native( + &self, + asset: &LiveWallpaperAsset, + options: NativePlaybackOptions, + emit: &mut dyn FnMut(Result) -> bool, + stop: Arc, + ) { + let result = self.owned(asset).and_then(|()| { + decode_output( + Path::new(&asset.path), + PlaybackOptions { + fps: options.fps, + looping: options.looping, + target: options.target, + }, + DecodePurpose::NativePlanes, + &mut |frame, _| emit(Ok(frame)), + stop.clone(), + None, + ) + }); + if let Err(error) = result + && !stop.cancelled() + { + emit(Err(error)); + } + } } fn dimensions(width: u32, height: u32) -> Result<()> { if width == 0 @@ -425,18 +478,82 @@ impl FrameConverter { ) } } + +fn admit_decoded_source( + shape: (u32, u32, ffmpeg::format::Pixel), + timestamp: Option, + admitted: &mut Option<(u32, u32, ffmpeg::format::Pixel)>, + rewind_at: &mut Option, +) -> Result<()> { + dimensions(shape.0, shape.1)?; + if let Some(original) = admitted { + if *original != shape { + return Err(DomainError::Invalid( + "Video changes dimensions or pixel format during playback".into(), + )); + } + } else { + // A missing first decoded PTS can be an unseekable prefix. This fact + // belongs to the source admission, never to a replaceable converter. + if timestamp.is_none() { + *rewind_at = None; + } + *admitted = Some(shape); + } + Ok(()) +} + +fn live_converter_for_target( + converter: &mut Option, + frame: &ffmpeg::util::frame::video::Video, + target: Option, +) -> Result<()> { + if let Some(old) = converter.as_ref() { + old.validate(frame)?; + } + let size = conversion_dimensions(frame.format(), frame.width(), frame.height(), target)?; + if converter + .as_ref() + .is_some_and(|old| (old.scaler.output().width, old.scaler.output().height) != size) + { + // Drop only size-dependent scratch/pool ownership before replacement. + // Shared published pixels remain valid until their final owner drops. + *converter = None; + } + if converter.is_none() { + *converter = Some(FrameConverter::new_live(frame, target)?); + } + Ok(()) +} +fn convert_live_target( + converter: &mut Option, + frame: &ffmpeg::util::frame::video::Video, + demand: WallpaperTargetDemand, + timestamp: Duration, +) -> Result { + live_converter_for_target(converter, frame, demand.target)?; + Ok(TargetedMediaFrame { + revision: demand.revision, + frame: converter + .as_mut() + .ok_or_else(failed)? + .convert(frame, timestamp)?, + }) +} /// Adapter-local decoder policy. Poster extraction and read-only previews keep /// their serial decoder even when the underlying source uses H.264. #[derive(Clone, Copy)] enum DecodePurpose { Poster, LiveWallpaper, + NativePlanes, Preview(WallpaperPreviewOptions), } impl DecodePurpose { fn threading(self, codec: ffmpeg::codec::Id) -> ffmpeg::codec::threading::Config { use ffmpeg::codec::threading::{Config, Type}; - let parallel = matches!(self, Self::LiveWallpaper) && codec == ffmpeg::codec::Id::H264; + let parallel = matches!(self, Self::LiveWallpaper | Self::NativePlanes) + && codec == ffmpeg::codec::Id::H264; let mut config = Config::count(if parallel { 2 } else { 1 }); // A count alone leaves FFmpeg's threading kind at None. Restrict the // measured frame-threading trial to live H.264; other codecs stay serial. @@ -477,6 +594,109 @@ fn decode_inner( purpose: DecodePurpose, emit: &mut dyn FnMut(MediaFrame) -> bool, stop: Arc, +) -> Result<()> { + decode_controlled( + path, + options, + purpose, + &mut |frame, _| emit(frame), + stop, + None, + ) +} + +struct PlaybackTargets { + port: Arc, + current: WallpaperTargetDemand, +} +impl PlaybackTargets { + fn new(port: Arc) -> Result { + let current = port.try_latest()?.ok_or_else(|| { + DomainError::Unavailable("Initial wallpaper target is unavailable".into()) + })?; + Ok(Self { port, current }) + } + fn refresh(&mut self) -> Result<()> { + if let Some(next) = self.port.try_latest()? { + if next.revision < self.current.revision + || (next.revision == self.current.revision && next.target != self.current.target) + { + return Err(DomainError::Invalid( + "Wallpaper target revision regressed or was reused".into(), + )); + } + self.current = next; + } + Ok(()) + } +} + +fn decode_retargetable( + path: &Path, + options: RetargetablePlaybackOptions, + targets: Arc, + emit: &mut dyn FnMut(TargetedMediaFrame) -> bool, + stop: Arc, +) -> Result<()> { + let targets = PlaybackTargets::new(targets)?; + let mut invalid_identity = false; + decode_controlled( + path, + PlaybackOptions { + fps: options.fps, + looping: options.looping, + target: None, + }, + DecodePurpose::LiveWallpaper, + &mut |frame, revision| { + if let Some(revision) = revision { + emit(TargetedMediaFrame { revision, frame }) + } else { + invalid_identity = true; + false + } + }, + stop, + Some(targets), + )?; + if invalid_identity { + return Err(failed()); + } + Ok(()) +} + +fn decode_controlled( + path: &Path, + options: PlaybackOptions, + purpose: DecodePurpose, + emit: &mut dyn FnMut(MediaFrame, Option) -> bool, + stop: Arc, + targets: Option, +) -> Result<()> { + let mut invalid = false; + decode_output( + path, + options, + purpose, + &mut |frame, revision| match frame.into_rgba() { + Ok(frame) => emit(frame, revision), + Err(_) => { + invalid = true; + false + } + }, + stop, + targets, + )?; + if invalid { Err(failed()) } else { Ok(()) } +} +fn decode_output( + path: &Path, + options: PlaybackOptions, + purpose: DecodePurpose, + emit: &mut dyn FnMut(NativeMediaFrame, Option) -> bool, + stop: Arc, + mut targets: Option, ) -> Result<()> { if !*INIT.get_or_init(|| { ffmpeg::util::log::set_level(ffmpeg::util::log::Level::Quiet); @@ -494,10 +714,8 @@ fn decode_inner( session = Some(DecoderSession::open(path, purpose, stop.clone())?); } let active = session.as_mut().ok_or_else(failed)?; - if !active.cycle(options, purpose, emit, stop.as_ref())? - || stop.cancelled() - || !options.looping - { + let keep = active.cycle_output(options, purpose, emit, stop.as_ref(), targets.as_mut())?; + if !keep || stop.cancelled() || !options.looping { return Ok(()); } if active.rewind(stop.as_ref()) { @@ -516,6 +734,9 @@ struct DecoderSession { input: ffmpeg::format::context::Input, decoder: ffmpeg::decoder::Video, converter: Option, + // Independent from converter lifetime: retargeting must not reinterpret a + // later missing timestamp as the first decoded prefix or admit a new source. + decoded_source: Option<(u32, u32, ffmpeg::format::Pixel)>, index: usize, time_base: ffmpeg::Rational, rate: f64, @@ -573,8 +794,10 @@ impl DecoderSession { .ok_or_else(failed)?; let context = ffmpeg::codec::context::Context::from_parameters(stream.parameters()) .map_err(|_| failed())?; - let reuse = matches!(purpose, DecodePurpose::LiveWallpaper) - && context.id() == ffmpeg::codec::Id::H264; + let reuse = matches!( + purpose, + DecodePurpose::LiveWallpaper | DecodePurpose::NativePlanes + ) && context.id() == ffmpeg::codec::Id::H264; ( stream.index(), stream.time_base(), @@ -596,6 +819,7 @@ impl DecoderSession { input, decoder, converter: None, + decoded_source: None, index, time_base, rate, @@ -627,17 +851,55 @@ impl DecoderSession { /// Returns false only when delivery/cancellation retires the body. EOF is /// fully drained and the final source hold completes before a loop rewind. + #[cfg(test)] fn cycle( &mut self, options: PlaybackOptions, purpose: DecodePurpose, emit: &mut dyn FnMut(MediaFrame) -> bool, stop: &dyn StopSignal, + ) -> Result { + self.cycle_controlled(options, purpose, &mut |frame, _| emit(frame), stop, None) + } + + #[cfg(test)] + fn cycle_controlled( + &mut self, + options: PlaybackOptions, + purpose: DecodePurpose, + emit: &mut dyn FnMut(MediaFrame, Option) -> bool, + stop: &dyn StopSignal, + targets: Option<&mut PlaybackTargets>, + ) -> Result { + let mut invalid = false; + let keep = self.cycle_output( + options, + purpose, + &mut |frame, revision| match frame.into_rgba() { + Ok(frame) => emit(frame, revision), + Err(_) => { + invalid = true; + false + } + }, + stop, + targets, + )?; + if invalid { Err(failed()) } else { Ok(keep) } + } + fn cycle_output( + &mut self, + options: PlaybackOptions, + purpose: DecodePurpose, + emit: &mut dyn FnMut(NativeMediaFrame, Option) -> bool, + stop: &dyn StopSignal, + mut targets: Option<&mut PlaybackTargets>, ) -> Result { let Self { input, decoder, converter, + decoded_source, index, time_base, rate, @@ -664,26 +926,29 @@ impl DecoderSession { if stop.cancelled() { return Ok(false); } - if converter.is_none() { - // A demuxer may report start_time=0 while initial decoded - // frames have no PTS. Seeking to that later known timestamp - // need not reproduce this prefix; retain the old reopen - // path rather than guessing away any source frames. - if frame.timestamp().is_none() { - *rewind_at = None; - } + admit_decoded_source( + (frame.width(), frame.height(), frame.format()), + frame.timestamp(), + decoded_source, + rewind_at, + )?; + if converter.is_none() + && targets.is_none() + && !matches!(purpose, DecodePurpose::NativePlanes) + { *converter = Some(match purpose { DecodePurpose::Preview(preview) => { FrameConverter::new_sized(&frame, None, Some(preview))? } DecodePurpose::Poster => FrameConverter::new(&frame, options.target)?, - DecodePurpose::LiveWallpaper => { + DecodePurpose::LiveWallpaper | DecodePurpose::NativePlanes => { FrameConverter::new_live(&frame, options.target)? } }); } - let converter = converter.as_mut().ok_or_else(failed)?; - converter.validate(&frame)?; + if let Some(converter) = converter.as_ref() { + converter.validate(&frame)?; + } let (timestamp, hold) = timeline.frame(frame.timestamp(), frame.packet().duration)?; end_time = end_time.max(timestamp.saturating_add(hold)); @@ -697,8 +962,55 @@ impl DecoderSession { if stop.cancelled() { return Ok(false); } + if matches!(purpose, DecodePurpose::NativePlanes) { + let source_size = (frame.width(), frame.height()); + let native_size = options + .target + .map(|target| target.cover_size(source_size.0, source_size.1)) + .transpose()?; + // This one-level plane renderer must never replace the + // existing fitted RGBA path when actual cover needs reduction. + if native_size.is_none_or(|size| size == source_size) { + match native_planes::admit(frame)? { + native_planes::Admission::Native(planes, publication) => { + *converter = None; + delivered = true; + if !emit( + NativeMediaFrame::Yuv420 { + frame: planes, + timestamp, + publication, + }, + None, + ) { + return Ok(false); + } + continue; + } + native_planes::Admission::Rgba(source) => frame = source, + } + } + // Unknown/alpha/unsupported metadata and minification keep + // established software fitting; no guessed color or filters. + if converter.is_none() { + *converter = Some(FrameConverter::new_live(&frame, options.target)?); + } + } + let (converted, revision) = if let Some(targets) = targets.as_deref_mut() { + targets.refresh()?; + let value = convert_live_target(converter, &frame, targets.current, timestamp)?; + (value.frame, Some(value.revision)) + } else { + ( + converter + .as_mut() + .ok_or_else(failed)? + .convert(&frame, timestamp)?, + None, + ) + }; delivered = true; - if !emit(converter.convert(&frame, timestamp)?) { + if !emit(NativeMediaFrame::Rgba(converted), revision) { return Ok(false); } } diff --git a/lucent/crates/lucent-services/src/video/native_planes.rs b/lucent/crates/lucent-services/src/video/native_planes.rs new file mode 100644 index 00000000..3c701f11 --- /dev/null +++ b/lucent/crates/lucent-services/src/video/native_planes.rs @@ -0,0 +1,286 @@ +//! Private FFmpeg ownership adapter. Only initialized visible YUV samples leave +//! here. Row padding is never assumed initialized merely because it is in bounds. +use super::*; +use lucent_domain::video_frame::{Bt709, Bytes, ChromaSiting, Plane, Range, Transfer, Yuv420Frame}; + +struct DecodedFrameOwner(ffmpeg::frame::Video); +struct PackedOwner { + frame: Arc, + plane: usize, + length: usize, +} +impl AsRef<[u8]> for PackedOwner { + fn as_ref(&self) -> &[u8] { + // SAFETY: admit validates a reference-counted AVBuffer containing this + // span. A packed span consists ONLY of initialized decoded samples, + // with no row gaps. Admission CONSUMES the only Rust Video handle and + // moves it into this private immutable owner. No caller retains a safe + // data_mut path. FFmpeg decoder reuse must respect its AVBufferRefs. + unsafe { + std::slice::from_raw_parts((*self.frame.0.as_ptr()).data[self.plane], self.length) + } + } +} +fn color(frame: &ffmpeg::frame::Video) -> Option { + use ffmpeg::util::{chroma, color}; + if frame.color_space() != color::Space::BT709 + || frame.color_primaries() != color::Primaries::BT709 + { + return None; + } + let range = match frame.color_range() { + color::Range::MPEG => Range::Limited, + color::Range::JPEG => Range::Full, + _ => return None, + }; + let transfer = match frame.color_transfer_characteristic() { + color::TransferCharacteristic::BT709 => Transfer::Bt709, + color::TransferCharacteristic::IEC61966_2_1 => Transfer::Srgb, + _ => return None, + }; + let chroma = match frame.chroma_location() { + chroma::Location::Left => ChromaSiting::Left, + chroma::Location::Center => ChromaSiting::Center, + _ => return None, + }; + Some(Bt709 { + range, + transfer, + chroma, + }) +} +pub(super) enum Admission { + Native(Yuv420Frame, PlanePublication), + Rgba(ffmpeg::frame::Video), +} +/// Admission consumes the mutable decoder-frame handle. Returning immutable +/// views while retaining a caller's Video would be unsound: ffmpeg-next's SAFE +/// data_mut does not perform COW. Unsupported capability returns the SAME owned +/// frame for RGBA conversion, without cloning or guessing color metadata. +pub(super) fn admit(source: ffmpeg::frame::Video) -> Result { + if source.format() != ffmpeg::format::Pixel::YUV420P + || source.is_interlaced() + || source.aspect_ratio() != ffmpeg::Rational(1, 1) + { + return Ok(Admission::Rgba(source)); + } + let Some(color) = color(&source) else { + return Ok(Admission::Rgba(source)); + }; + use ffmpeg::util::frame::side_data::Type; + if [ + Type::IccProfile, + Type::MasteringDisplayMetadata, + Type::ContentLightLevel, + Type::DisplayMatrix, + ] + .iter() + .any(|kind| source.side_data(*kind).is_some()) + { + return Ok(Admission::Rgba(source)); + } + dimensions(source.width(), source.height())?; + // SAFETY: source is an owned frame returned by the decoder (or an initialized + // adapter test fixture); the pointer is used only while source is borrowed. + let raw = unsafe { &*source.as_ptr() }; + if raw.crop_left != 0 + || raw.crop_top != 0 + || raw.crop_right != 0 + || raw.crop_bottom != 0 + || raw.decode_error_flags != 0 + || source.is_corrupt() + || raw.buf[0].is_null() + { + return Ok(Admission::Rgba(source)); + } + let sizes = [ + (source.width(), source.height()), + (source.width().div_ceil(2), source.height().div_ceil(2)), + (source.width().div_ceil(2), source.height().div_ceil(2)), + ]; + for (index, (width, height)) in sizes.into_iter().enumerate() { + if raw.linesize[index] <= 0 { + return Ok(Admission::Rgba(source)); + } + let stride = raw.linesize[index] as usize; + if stride < width as usize { + return Err(failed()); + } + let span = (height as usize - 1) + .checked_mul(stride) + .and_then(|v| v.checked_add(width as usize)) + .ok_or_else(failed)?; + // SAFETY: the borrowed frame is valid; this read-only API identifies its + // reference-counted plane owner. Bounds do NOT imply initialized padding. + let buffer = + unsafe { ffmpeg::ffi::av_frame_get_plane_buffer(source.as_ptr(), index as i32) }; + if buffer.is_null() || raw.data[index].is_null() { + return Ok(Admission::Rgba(source)); + } + let buffer = unsafe { &*buffer }; + let offset = (raw.data[index] as usize) + .checked_sub(buffer.data as usize) + .ok_or_else(failed)?; + if offset.checked_add(span).is_none_or(|end| end > buffer.size) { + return Err(failed()); + } + } + let (width, height) = (source.width(), source.height()); + // Move the existing, reference-counted decoded AVFrame into its private + // immutable owner. av_frame_ref would leave a mutable alias in a borrowed + // caller; Video::clone would deep-copy pixels. Neither is needed here. + let owner = Arc::new(DecodedFrameOwner(source)); + let mut planes = Vec::with_capacity(3); + let mut publication = PlanePublication::default(); + for (index, (width, height)) in sizes.into_iter().enumerate() { + let stride = raw.linesize[index] as usize; + let length = width as usize * height as usize; + let bytes = if stride == width as usize { + publication.referenced_bytes += length; + Bytes::from_owner(PackedOwner { + frame: owner.clone(), + plane: index, + length, + }) + } else { + let mut packed = Vec::new(); + packed.try_reserve_exact(length).map_err(|_| failed())?; + for row in 0..height as usize { + // SAFETY: only visible samples in this decoder-initialized row + // are read. Padding and bytes after the final visible row are + // excluded, even if FFmpeg allocated them without initialization. + let visible = unsafe { + std::slice::from_raw_parts(raw.data[index].add(row * stride), width as usize) + }; + packed.extend_from_slice(visible); + } + publication.copied_bytes += length; + Bytes::from(packed) + }; + planes.push( + Plane::new(bytes, width, height, width) + .map_err(|e| DomainError::Invalid(e.to_string()))?, + ); + } + let planes: [Plane; 3] = planes.try_into().map_err(|_| failed())?; + let frame = Yuv420Frame::new(width, height, planes, color) + .map_err(|e| DomainError::Invalid(e.to_string()))?; + Ok(Admission::Native(frame, publication)) +} + +#[cfg(test)] +mod tests { + use super::*; + fn native(source: ffmpeg::frame::Video) -> (Yuv420Frame, PlanePublication) { + match admit(source).unwrap() { + Admission::Native(frame, publication) => (frame, publication), + Admission::Rgba(_) => panic!("known fixture must admit native ownership"), + } + } + fn initialized(width: u32, height: u32) -> ffmpeg::frame::Video { + let mut frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::YUV420P, width, height); + frame.set_color_space(ffmpeg::color::Space::BT709); + frame.set_color_primaries(ffmpeg::color::Primaries::BT709); + frame.set_color_range(ffmpeg::color::Range::MPEG); + frame + .set_color_transfer_characteristic(ffmpeg::color::TransferCharacteristic::IEC61966_2_1); + // FFmpeg exposes no safe chroma-location setter; initialize header-only + // fields before publication. Visible rows are initialized with raw writes + // without ever constructing a Rust slice over uninitialized padding. + unsafe { + let raw = &mut *frame.as_mut_ptr(); + raw.chroma_location = ffmpeg::ffi::AVChromaLocation::AVCHROMA_LOC_LEFT; + raw.sample_aspect_ratio = ffmpeg::ffi::AVRational { num: 1, den: 1 }; + for (plane, (w, h)) in [ + (width, height), + (width.div_ceil(2), height.div_ceil(2)), + (width.div_ceil(2), height.div_ceil(2)), + ] + .into_iter() + .enumerate() + { + assert!(raw.linesize[plane] >= w as i32 && !raw.data[plane].is_null()); + for row in 0..h as usize { + std::ptr::write_bytes( + raw.data[plane].add(row * raw.linesize[plane] as usize), + [64, 128, 192][plane], + w as usize, + ); + } + } + } + frame + } + #[test] + fn native_packed_yuv_moves_the_only_rust_frame_owner_and_survives_adapter_return() { + let source = initialized(64, 4); + let pointers = unsafe { (*source.as_ptr()).data }; + let (frame, stats) = native(source); + assert_eq!(stats.copied_bytes, 0); + assert_eq!(stats.referenced_bytes, 64 * 4 * 3 / 2); + for (plane, pointer) in frame.planes().iter().zip(pointers) { + assert_eq!(plane.bytes().as_ptr(), pointer as *const u8); + } + assert_eq!(frame.planes()[0].row(3).unwrap(), &[64; 64]); + assert_eq!(frame.planes()[2].row(1).unwrap(), &[192; 32]); + } + #[test] + fn native_odd_padded_rows_copy_only_initialized_visible_samples() { + let source = initialized(5, 3); + let (frame, stats) = native(source); + assert_eq!(stats.referenced_bytes, 0); + assert_eq!(stats.copied_bytes, 27); + assert_eq!(frame.byte_len(), 27); + for (plane, value) in frame.planes().iter().zip([64, 128, 192]) { + assert_eq!(plane.stride(), plane.dimensions().0); + assert!(plane.bytes().iter().all(|v| *v == value)); + } + } + #[test] + fn native_consumed_owner_leaves_only_immutable_views_until_the_last_clone_drops() { + let (frame, _) = native(initialized(64, 4)); + let first = frame.planes()[0].bytes().clone(); + let last = first.clone(); + assert_eq!(first.as_ptr(), last.as_ptr()); + drop(frame); + assert_eq!(first.as_ref(), &[64; 64 * 4]); + drop(first); + assert_eq!(last.as_ref(), &[64; 64 * 4]); + // There is deliberately no mutable Video handle left: admission moved + // it, and neither the domain value nor Bytes exposes the private owner. + } + #[test] + fn native_unknown_tags_alpha_crop_or_negative_stride_keep_capability_fallback() { + let mut source = initialized(64, 4); + source.set_color_space(ffmpeg::color::Space::Unspecified); + let original = unsafe { (source.as_ptr(), (*source.as_ptr()).data) }; + let Admission::Rgba(recovered) = admit(source).unwrap() else { + panic!("unknown metadata must return the owned source"); + }; + assert_eq!(unsafe { recovered.as_ptr() }, original.0); + assert_eq!(unsafe { (*recovered.as_ptr()).data }, original.1); + let mut source = initialized(64, 4); + unsafe { + (*source.as_mut_ptr()).crop_left = 1; + } + assert!(matches!(admit(source).unwrap(), Admission::Rgba(_))); + let mut source = initialized(64, 4); + unsafe { + (*source.as_mut_ptr()).linesize[0] = -64; + } + assert!(matches!(admit(source).unwrap(), Admission::Rgba(_))); + let rgba = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBA, 4, 4); + assert!(matches!(admit(rgba).unwrap(), Admission::Rgba(_))); + } + #[test] + fn native_plane_bounds_are_rejected_before_a_slice_is_constructed() { + let mut source = initialized(64, 4); + unsafe { + let buffer = ffmpeg::ffi::av_frame_get_plane_buffer(source.as_ptr(), 0); + (*source.as_mut_ptr()).data[0] = (*buffer).data.add((*buffer).size - 1); + // AVFrame release owns the unchanged AVBufferRef; it never frees + // this deliberately invalid visible-data pointer independently. + assert!(admit(source).is_err()); + } + } +} diff --git a/lucent/crates/lucent-services/src/video/targets.rs b/lucent/crates/lucent-services/src/video/targets.rs new file mode 100644 index 00000000..eb2f93b9 --- /dev/null +++ b/lucent/crates/lucent-services/src/video/targets.rs @@ -0,0 +1,152 @@ +//! Latest demand adapter. Decoder reads never wait for a control writer. +use lucent_domain::*; +use std::sync::{Mutex, TryLockError}; + +pub struct LatestWallpaperTargetAdapter { + value: Mutex, +} +impl LatestWallpaperTargetAdapter { + pub fn new(target: Option) -> Self { + Self { + value: Mutex::new(WallpaperTargetDemand { + revision: WallpaperTargetRevision::initial(), + target, + }), + } + } + /// Compare-and-set rejects stale commands. Equal demand is a true no-op; + /// a changed source-size/physical target receives one checked increment. + pub fn set( + &self, + expected: WallpaperTargetRevision, + target: Option, + ) -> Result { + let mut value = self.value.lock().map_err(|_| unavailable())?; + if value.revision != expected { + return Err(DomainError::Invalid( + "Wallpaper target command is stale".into(), + )); + } + *value = value.retarget(target)?; + Ok(*value) + } +} +impl WallpaperTargetPort for LatestWallpaperTargetAdapter { + fn try_latest(&self) -> Result> { + match self.value.try_lock() { + Ok(value) => Ok(Some(*value)), + Err(TryLockError::WouldBlock) => Ok(None), + Err(TryLockError::Poisoned(_)) => Err(unavailable()), + } + } +} +fn unavailable() -> DomainError { + DomainError::Unavailable("Wallpaper target control is unavailable".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn compare_and_set_keeps_one_latest_value_and_noop_does_not_consume_revision() { + let first = WallpaperPixelTarget::new(1920, 1080).unwrap(); + let large = WallpaperPixelTarget::new(3840, 2160).unwrap(); + let adapter = LatestWallpaperTargetAdapter::new(Some(first)); + let initial = adapter.try_latest().unwrap().unwrap(); + assert_eq!(adapter.set(initial.revision, Some(first)).unwrap(), initial); + let next = adapter.set(initial.revision, Some(large)).unwrap(); + assert_eq!(next.revision, initial.revision.checked_next().unwrap()); + assert!(adapter.set(initial.revision, Some(first)).is_err()); + assert_eq!(adapter.try_latest().unwrap(), Some(next)); + let guard = adapter.value.lock().unwrap(); + assert_eq!( + adapter.try_latest().unwrap(), + None, + "reader never waits on writer" + ); + drop(guard); + let restored = adapter.set(next.revision, Some(first)).unwrap(); + assert_eq!(adapter.try_latest().unwrap(), Some(restored)); + } + #[test] + fn poisoned_control_is_unavailable_instead_of_an_absent_or_stale_snapshot() { + let target = WallpaperPixelTarget::new(32, 24).unwrap(); + let adapter = LatestWallpaperTargetAdapter::new(Some(target)); + let initial = adapter.try_latest().unwrap().unwrap(); + let result = std::panic::catch_unwind(|| { + let _guard = adapter.value.lock().unwrap(); + panic!("Control writer failed"); + }); + assert!(result.is_err()); + assert!(matches!( + adapter.try_latest(), + Err(DomainError::Unavailable(_)) + )); + assert!(matches!( + adapter.set( + initial.revision, + Some(WallpaperPixelTarget::new(64, 48).unwrap()) + ), + Err(DomainError::Unavailable(_)) + )); + assert_eq!(*adapter.value.lock().unwrap_err().into_inner(), initial); + } + + #[test] + fn exhausted_revision_does_not_mutate_the_current_target() { + let first = WallpaperPixelTarget::new(1, 1).unwrap(); + let adapter = LatestWallpaperTargetAdapter::new(Some(first)); + let last = WallpaperTargetDemand { + revision: WallpaperTargetRevision::new(u64::MAX).unwrap(), + target: Some(first), + }; + *adapter.value.lock().unwrap() = last; + assert!( + adapter + .set( + last.revision, + Some(WallpaperPixelTarget::new(2, 2).unwrap()) + ) + .is_err() + ); + assert_eq!(adapter.try_latest().unwrap(), Some(last)); + assert_eq!(adapter.set(last.revision, Some(first)).unwrap(), last); + } + + #[test] + fn source_size_is_an_admitted_snapshot_and_cas_rejects_stale_return_to_source() { + let adapter = LatestWallpaperTargetAdapter::new(None); + let source = adapter.try_latest().unwrap().unwrap(); + assert_eq!(source.target, None); + assert_eq!(adapter.set(source.revision, None).unwrap(), source); + let target = WallpaperPixelTarget::new(1920, 1080).unwrap(); + let cover = adapter.set(source.revision, Some(target)).unwrap(); + assert!(adapter.set(source.revision, None).is_err()); + assert_eq!(adapter.try_latest().unwrap(), Some(cover)); + let restored = adapter.set(cover.revision, None).unwrap(); + assert_eq!(restored.target, None); + assert!(restored.revision > cover.revision); + assert_eq!(adapter.try_latest().unwrap(), Some(restored)); + } + + #[test] + fn contended_source_snapshot_is_not_an_admitted_source_size_update() { + let adapter = LatestWallpaperTargetAdapter::new(None); + let source = adapter.try_latest().unwrap().unwrap(); + let guard = adapter.value.lock().unwrap(); + assert_eq!(adapter.try_latest().unwrap(), None); + drop(guard); + assert_eq!(adapter.try_latest().unwrap(), Some(source)); + let cover = adapter + .set( + source.revision, + Some(WallpaperPixelTarget::new(32, 24).unwrap()), + ) + .unwrap(); + let guard = adapter.value.lock().unwrap(); + assert_eq!(adapter.try_latest().unwrap(), None); + drop(guard); + assert_eq!(adapter.try_latest().unwrap(), Some(cover)); + assert_eq!(adapter.set(cover.revision, None).unwrap().target, None); + } +} diff --git a/lucent/crates/lucent-services/src/video/tests.rs b/lucent/crates/lucent-services/src/video/tests.rs index 7518bfd3..6d975335 100644 --- a/lucent/crates/lucent-services/src/video/tests.rs +++ b/lucent/crates/lucent-services/src/video/tests.rs @@ -1,5 +1,473 @@ use super::*; +struct TargetSequence(std::sync::Mutex>>); +impl WallpaperTargetPort for TargetSequence { + fn try_latest(&self) -> Result> { + Ok(self.0.lock().unwrap().pop_front().unwrap_or(None)) + } +} + +#[test] +fn dynamic_target_admission_refuses_initial_absence_reused_or_regressing_revisions() { + let target = WallpaperPixelTarget::new(32, 24).unwrap(); + let initial = WallpaperTargetDemand { + revision: WallpaperTargetRevision::new(2).unwrap(), + target: Some(target), + }; + let port = |values: Vec>| { + Arc::new(TargetSequence(std::sync::Mutex::new(values.into()))) + as Arc + }; + assert!(PlaybackTargets::new(port(vec![None])).is_err()); + for invalid in [ + WallpaperTargetDemand { + revision: WallpaperTargetRevision::initial(), + target: Some(target), + }, + WallpaperTargetDemand { + revision: initial.revision, + target: Some(WallpaperPixelTarget::new(64, 48).unwrap()), + }, + ] { + let mut state = PlaybackTargets::new(port(vec![Some(initial), Some(invalid)])).unwrap(); + assert!(state.refresh().is_err()); + assert_eq!(state.current, initial); + } + let mut state = PlaybackTargets::new(port(vec![Some(initial), None, Some(initial)])).unwrap(); + state.refresh().unwrap(); + assert_eq!(state.current, initial); + state.refresh().unwrap(); + assert_eq!(state.current, initial); +} + +#[test] +fn admitted_source_size_survives_contention_and_rejects_reused_source_cover_revisions() { + let source = WallpaperTargetDemand { + revision: WallpaperTargetRevision::initial(), + target: None, + }; + let cover = source + .retarget(Some(WallpaperPixelTarget::new(32, 24).unwrap())) + .unwrap(); + let restored = cover.retarget(None).unwrap(); + let port = |values: Vec>| { + Arc::new(TargetSequence(std::sync::Mutex::new(values.into()))) + as Arc + }; + let mut state = PlaybackTargets::new(port(vec![ + Some(source), + None, + Some(cover), + None, + Some(restored), + ])) + .unwrap(); + assert_eq!(state.current, source, "source-size is an initial admission"); + for expected in [source, cover, cover, restored] { + state.refresh().unwrap(); + assert_eq!(state.current, expected); + } + for (previous, target) in [(source, cover.target), (cover, source.target)] { + let reused = WallpaperTargetDemand { + revision: previous.revision, + target, + }; + let mut state = PlaybackTargets::new(port(vec![Some(previous), Some(reused)])).unwrap(); + assert!(state.refresh().is_err()); + assert_eq!(state.current, previous); + } +} + +#[test] +fn dynamic_target_port_errors_preserve_the_last_admitted_demand() { + struct FailsAfterAdmission(std::sync::Mutex, Option); + impl WallpaperTargetPort for FailsAfterAdmission { + fn try_latest(&self) -> Result> { + let mut called = self.0.lock().unwrap(); + if *called { + return Err(DomainError::Unavailable("Target source retired".into())); + } + *called = true; + Ok(Some(WallpaperTargetDemand { + revision: WallpaperTargetRevision::initial(), + target: self.1, + })) + } + } + for target in [None, Some(WallpaperPixelTarget::new(32, 24).unwrap())] { + let mut targets = PlaybackTargets::new(Arc::new(FailsAfterAdmission( + std::sync::Mutex::new(false), + target, + ))) + .unwrap(); + let admitted = targets.current; + assert!(matches!( + targets.refresh(), + Err(DomainError::Unavailable(_)) + )); + assert_eq!(targets.current, admitted); + } +} + +#[test] +fn absent_initial_target_fails_before_opening_a_decoder_or_delivering_pixels() { + let absent = Arc::new(TargetSequence(std::sync::Mutex::new( + [None].into_iter().collect(), + ))); + assert!(matches!( + decode_retargetable( + Path::new("/unavailable/retarget-source.mp4"), + RetargetablePlaybackOptions::default(), + absent, + &mut |_| panic!("No frame can precede target admission"), + Arc::new(Never), + ), + Err(DomainError::Unavailable(_)) + )); +} + +#[test] +fn converter_retirement_does_not_reclassify_a_later_missing_pts_or_new_source() { + let source = (64, 48, ffmpeg::format::Pixel::YUV420P); + let mut admitted = None; + let mut rewind = Some(0); + admit_decoded_source(source, Some(0), &mut admitted, &mut rewind).unwrap(); + // Converter replacement never changes the source-admission fact. + admit_decoded_source(source, None, &mut admitted, &mut rewind).unwrap(); + assert_eq!(rewind, Some(0)); + assert!(admit_decoded_source((32, 24, source.2), Some(1), &mut admitted, &mut rewind).is_err()); + assert_eq!(admitted, Some(source)); + let mut prefix = None; + admit_decoded_source(source, None, &mut prefix, &mut rewind).unwrap(); + assert_eq!(rewind, None); +} + +#[test] +fn dynamic_alpha_and_paletted_demand_preserves_source_size_and_captured_revision() { + use ffmpeg::{format::Pixel, util::frame::video::Video}; + let mut source = Video::new(Pixel::RGBA, 14, 10); + source.data_mut(0).fill(127); + let mut converter = None; + let first = WallpaperTargetDemand { + revision: WallpaperTargetRevision::initial(), + target: Some(WallpaperPixelTarget::new(7, 5).unwrap()), + }; + let old = convert_live_target(&mut converter, &source, first, Duration::ZERO).unwrap(); + let original = old.frame.into_shared_rgba_with_aspect().2; + let copy = original.as_slice().to_vec(); + let native = unsafe { converter.as_ref().unwrap().scaler.as_ptr() as usize }; + let second = WallpaperTargetDemand { + revision: first.revision.checked_next().unwrap(), + target: Some(WallpaperPixelTarget::new(14, 10).unwrap()), + }; + let next = + convert_live_target(&mut converter, &source, second, Duration::from_millis(17)).unwrap(); + assert_eq!(next.revision, second.revision); + assert_eq!(next.frame.dimensions(), (14, 10)); + assert_eq!(next.frame.aspect_size(), (14, 10)); + assert_eq!( + unsafe { converter.as_ref().unwrap().scaler.as_ptr() as usize }, + native + ); + assert!(converter.as_ref().unwrap().pixels.is_none()); + assert_eq!(original.as_slice(), copy); + assert_eq!( + conversion_dimensions(Pixel::PAL8, 14, 10, first.target).unwrap(), + (14, 10) + ); +} + +#[test] +fn source_cover_source_keeps_alpha_pixels_native_and_stamps_every_demand() { + use ffmpeg::{format::Pixel, util::frame::video::Video}; + let mut source = Video::new(Pixel::RGBA, 14, 10); + source.data_mut(0).fill(127); + let mut converter = None; + let mut held: Vec>> = Vec::new(); + let mut native = None; + for (index, target) in [None, Some(WallpaperPixelTarget::new(7, 5).unwrap()), None] + .into_iter() + .enumerate() + { + let demand = WallpaperTargetDemand { + revision: WallpaperTargetRevision::new(index as u64 + 1).unwrap(), + target, + }; + let timestamp = Duration::from_millis(index as u64 * 17); + let result = convert_live_target(&mut converter, &source, demand, timestamp).unwrap(); + assert_eq!(result.revision, demand.revision); + assert_eq!(result.frame.dimensions(), (14, 10)); + assert_eq!(result.frame.aspect_size(), (14, 10)); + assert_eq!(result.frame.timestamp, timestamp); + let address = unsafe { converter.as_ref().unwrap().scaler.as_ptr() as usize }; + assert_eq!(*native.get_or_insert(address), address); + assert!(converter.as_ref().unwrap().pixels.is_none()); + assert_eq!( + conversion_dimensions(Pixel::PAL8, 14, 10, target).unwrap(), + (14, 10) + ); + let pixels = result.frame.into_shared_rgba_with_aspect().2; + assert_eq!(pixels.as_slice(), &[127; 14 * 10 * 4]); + for previous in &held { + assert_eq!(previous.as_slice(), pixels.as_slice()); + } + held.push(pixels); + } + drop(converter); + assert!(held.iter().all(|pixels| Arc::strong_count(pixels) == 1)); +} + +#[test] +fn source_cover_source_retires_each_old_pool_and_preserves_held_native_pixels() { + use ffmpeg::{format::Pixel, util::frame::video::Video}; + let mut source = Video::new(Pixel::YUV420P, 14, 10); + source.data_mut(0).fill(81); + source.data_mut(1).fill(90); + source.data_mut(2).fill(240); + let mut converter = None; + let mut held: Vec<(Arc>, Vec)> = Vec::new(); + for (index, target) in [None, Some(WallpaperPixelTarget::new(7, 5).unwrap()), None] + .into_iter() + .enumerate() + { + let demand = WallpaperTargetDemand { + revision: WallpaperTargetRevision::new(index as u64 + 1).unwrap(), + target, + }; + let result = convert_live_target( + &mut converter, + &source, + demand, + Duration::from_millis(index as u64 * 17), + ) + .unwrap(); + assert_eq!( + result.frame.dimensions(), + if target.is_some() { (7, 5) } else { (14, 10) } + ); + assert_eq!(result.frame.aspect_size(), (14, 10)); + for (previous, original) in &held { + assert_eq!( + Arc::strong_count(previous), + 1, + "retired pool no longer owns pixels" + ); + assert_eq!(previous.as_slice(), original); + } + let pixels = result.frame.into_shared_rgba_with_aspect().2; + assert_eq!( + Arc::strong_count(&pixels), + 2, + "one current publication slot plus consumer" + ); + held.push((pixels.clone(), pixels.as_slice().to_vec())); + } + assert_eq!(held[0].0.as_slice(), held[2].0.as_slice()); + let weak: Vec<_> = held + .iter() + .map(|(pixels, _)| Arc::downgrade(pixels)) + .collect(); + drop(converter); + assert!( + held.iter() + .all(|(pixels, original)| Arc::strong_count(pixels) == 1 + && pixels.as_slice() == original) + ); + drop(held); + assert!(weak.iter().all(|pixels| pixels.upgrade().is_none())); +} + +#[test] +fn one_h264_cycle_retargets_conversion_without_replacing_decoder_source_or_timing() { + assert_h264_cycle_retarget( + Some(WallpaperPixelTarget::new(32, 24).unwrap()), + Some(WallpaperPixelTarget::new(64, 48).unwrap()), + ); +} + +#[test] +fn one_h264_cycle_source_cover_source_preserves_native_owner_pixels_and_timing() { + assert_h264_cycle_retarget(None, Some(WallpaperPixelTarget::new(32, 24).unwrap())); +} + +#[test] +fn one_h264_cycle_cover_source_cover_preserves_native_owner_pixels_and_timing() { + assert_h264_cycle_retarget(Some(WallpaperPixelTarget::new(32, 24).unwrap()), None); +} + +fn assert_h264_cycle_retarget( + first_target: Option, + second_target: Option, +) { + let root = Directory::new(); + let file = h264_fixture(&root.0); + ffmpeg::init().unwrap(); + let mut references = Vec::new(); + for target in [first_target, second_target] { + let mut frames = Vec::new(); + decode( + &file, + PlaybackOptions { + fps: WallpaperFrameRate::Source, + looping: false, + target, + }, + DecodePurpose::Poster, + &mut |frame| { + frames.push(frame_snapshot(frame)); + true + }, + Arc::new(Never), + ) + .unwrap(); + references.push(frames); + } + let control = Arc::new(LatestWallpaperTargetAdapter::new(first_target)); + let mut targets = PlaybackTargets::new(control.clone()).unwrap(); + let mut session = + DecoderSession::open(&file, DecodePurpose::LiveWallpaper, Arc::new(Never)).unwrap(); + // SAFETY: Read addresses of still-owned native handles; never dereference. + let native = unsafe { + ( + session.input.as_ptr() as usize, + session.decoder.as_ptr() as usize, + ) + }; + let origin = session.origin; + let deadline = Arc::as_ptr(&session.deadline); + let mut frames = Vec::new(); + let mut revisions = Vec::new(); + let mut old = None; + let mut revision = WallpaperTargetRevision::initial(); + let policy = PlaybackOptions { + fps: WallpaperFrameRate::Source, + looping: true, + target: None, + }; + assert!( + session + .cycle_controlled( + policy, + DecodePurpose::LiveWallpaper, + &mut |frame, captured| { + let timestamp = frame.timestamp; + let (width, height, pixels, aspect) = frame.into_shared_rgba_with_aspect(); + if old.is_none() { + old = Some((pixels.clone(), pixels.as_slice().to_vec())); + } + let frame = MediaFrame::rgba_shared_with_aspect( + width, height, pixels, aspect, timestamp, + ) + .unwrap(); + frames.push(frame_snapshot(frame)); + revisions.push(captured.unwrap()); + if frames.len() == 2 { + revision = control.set(revision, second_target).unwrap().revision; + } + if frames.len() == 4 { + revision = control.set(revision, first_target).unwrap().revision; + } + true + }, + &Never, + Some(&mut targets) + ) + .unwrap() + ); + assert_eq!( + frames.len(), + 8, + "retargeting must preserve drained/reordered EOF frames" + ); + for (index, actual) in frames.iter().enumerate() { + assert_eq!( + actual, + &references[usize::from((2..4).contains(&index))][index] + ); + assert_eq!( + revisions[index].get(), + if index < 2 { + 1 + } else if index < 4 { + 2 + } else { + 3 + } + ); + } + assert_eq!( + unsafe { + ( + session.input.as_ptr() as usize, + session.decoder.as_ptr() as usize, + ) + }, + native + ); + assert_eq!(session.origin, origin); + assert_eq!(Arc::as_ptr(&session.deadline), deadline); + assert_eq!(session_allocations(&session).0, native.0); + assert!(session.rewind(&Never)); + let mut calls = 0; + assert!( + !session + .cycle_controlled( + policy, + DecodePurpose::LiveWallpaper, + &mut |_, captured| { + calls += 1; + assert_eq!(captured, Some(revision)); + false + }, + &Never, + Some(&mut targets) + ) + .unwrap() + ); + assert_eq!(calls, 1); + let (held, copy) = old.unwrap(); + drop(session); + assert_eq!(held.as_slice(), copy); +} + +#[test] +fn dynamic_callback_cancellation_retires_pool_without_invalidating_held_pixels() { + assert_dynamic_callback_cancellation(Some(WallpaperPixelTarget::new(32, 24).unwrap())); +} + +#[test] +fn dynamic_source_size_callback_cancellation_retires_pool_without_invalidating_native_pixels() { + assert_dynamic_callback_cancellation(None); +} + +fn assert_dynamic_callback_cancellation(target: Option) { + let root = Directory::new(); + let file = h264_fixture(&root.0); + let control = Arc::new(LatestWallpaperTargetAdapter::new(target)); + let mut held = None; + let mut calls = 0; + decode_retargetable( + &file, + RetargetablePlaybackOptions { + fps: WallpaperFrameRate::Source, + looping: true, + }, + control, + &mut |value| { + calls += 1; + held = Some(value.frame.into_shared_rgba_with_aspect().2); + false + }, + Arc::new(Never), + ) + .unwrap(); + assert_eq!(calls, 1); + let held = held.unwrap(); + assert_eq!(Arc::strong_count(&held), 1); + let (width, height) = target.map_or((64, 48), WallpaperPixelTarget::size); + assert_eq!(held.len(), width as usize * height as usize * 4); +} + #[test] fn video_publication_pool_is_exclusive_to_proven_opaque_live_converters() { use ffmpeg::{format::Pixel, util::frame::video::Video}; @@ -219,6 +687,7 @@ fn video_frame_threading_is_exclusive_to_live_h264() { DecodePurpose::Poster, DecodePurpose::Preview(WallpaperPreviewOptions::new(64, 48).unwrap()), DecodePurpose::LiveWallpaper, + DecodePurpose::NativePlanes, ] { for codec in [ Id::H264, @@ -230,7 +699,10 @@ fn video_frame_threading_is_exclusive_to_live_h264() { Id::GIF, ] { let config = purpose.threading(codec); - let parallel = matches!(purpose, DecodePurpose::LiveWallpaper) && codec == Id::H264; + let parallel = matches!( + purpose, + DecodePurpose::LiveWallpaper | DecodePurpose::NativePlanes + ) && codec == Id::H264; assert_eq!(config.kind, if parallel { Type::Frame } else { Type::None }); assert_eq!(config.count, if parallel { 2 } else { 1 }); } @@ -1869,3 +2341,293 @@ fn video_native_reused_conversion_preserves_retained_frame_bytes_and_odd_row_str } } } + +fn tagged_h264_fixture(root: &Path) -> PathBuf { + h264_fixture_with_options( + root, + "tagged-bt709.mp4", + &[ + "-colorspace", + "bt709", + "-color_primaries", + "bt709", + "-color_trc", + "bt709", + "-color_range", + "tv", + "-chroma_sample_location", + "left", + "-vf", + "setsar=1", + ], + ) +} +#[test] +fn video_native_planes_real_port_drains_tagged_h264_eof_and_preserves_retained_pixels() { + let root = Directory::new(); + let file = tagged_h264_fixture(&root.0); + let adapter = NativeVideoWallpapers { + state: root.0.join("store"), + library: root.0.clone(), + }; + let asset = adapter + .prepare(&Wallpaper { + path: file.to_string_lossy().into(), + name: "Tagged fixture".into(), + }) + .unwrap(); + let mut retained = None; + let mut original = Vec::new(); + let mut times = Vec::new(); + adapter.play_native( + &asset, + NativePlaybackOptions { + looping: false, + ..Default::default() + }, + &mut |value| { + let NativeMediaFrame::Yuv420 { + frame, + timestamp, + publication, + } = value.unwrap() + else { + panic!("explicit BT709 fixture fell back") + }; + assert_eq!(frame.dimensions(), (64, 48)); + assert_eq!(frame.byte_len(), 64 * 48 * 3 / 2); + assert_eq!( + publication.referenced_bytes + publication.copied_bytes, + frame.byte_len() + ); + if retained.is_none() { + original = frame.planes().iter().map(|p| p.bytes().to_vec()).collect(); + retained = Some(frame.clone()); + } + times.push(timestamp); + true + }, + Arc::new(Never), + ); + assert_eq!( + times, + (0..8) + .map(|i| Duration::from_millis(i * 100)) + .collect::>() + ); + drop(adapter); + for (plane, expected) in retained.unwrap().planes().iter().zip(original) { + assert_eq!(plane.bytes().as_ref(), expected); + } +} +#[test] +fn video_native_planes_rewinds_one_session_without_rgba_scratch_and_honors_callback_cancel() { + ffmpeg::init().unwrap(); + let root = Directory::new(); + let file = tagged_h264_fixture(&root.0); + let mut session = + DecoderSession::open(&file, DecodePurpose::NativePlanes, Arc::new(Never)).unwrap(); + let first_context = unsafe { session.decoder.as_ptr() }; + for _ in 0..2 { + let mut times = Vec::new(); + assert!( + session + .cycle_output( + PlaybackOptions { + looping: true, + ..Default::default() + }, + DecodePurpose::NativePlanes, + &mut |value, revision| { + assert!(revision.is_none()); + assert!(matches!(&value, NativeMediaFrame::Yuv420 { .. })); + times.push(value.timestamp()); + true + }, + &Never, + None + ) + .unwrap() + ); + assert_eq!(times.len(), 8); + assert_eq!(times[0], Duration::ZERO); + assert!( + session.converter.is_none(), + "admitted native frames must never allocate the RGBA converter/pool" + ); + assert!(session.rewind(&Never)); + assert_eq!(unsafe { session.decoder.as_ptr() }, first_context); + } + let mut count = 0; + assert!( + !session + .cycle_output( + PlaybackOptions::default(), + DecodePurpose::NativePlanes, + &mut |value, _| { + assert!(matches!(&value, NativeMediaFrame::Yuv420 { .. })); + count += 1; + false + }, + &Never, + None + ) + .unwrap() + ); + assert_eq!(count, 1); + assert!(session.converter.is_none()); + let stopped = Stop(AtomicBool::new(true)); + let mut called = false; + decode_output( + &file, + PlaybackOptions::default(), + DecodePurpose::NativePlanes, + &mut |_, _| { + called = true; + false + }, + Arc::new(stopped), + None, + ) + .unwrap(); + assert!(!called); +} +#[test] +fn video_native_planes_untagged_or_alpha_fallback_preserves_existing_rgba_bytes_and_timing() { + for extension in ["mp4", "gif"] { + let root = Directory::new(); + let file = fixture(&root.0, extension); + let adapter = NativeVideoWallpapers { + state: root.0.join("store"), + library: root.0.clone(), + }; + let asset = adapter + .prepare(&Wallpaper { + path: file.to_string_lossy().into(), + name: "Fallback fixture".into(), + }) + .unwrap(); + let mut normal = Vec::new(); + adapter.play( + &asset, + PlaybackOptions { + looping: false, + ..Default::default() + }, + &mut |value| { + normal.push(frame_snapshot(value.unwrap())); + true + }, + Arc::new(Never), + ); + let mut native = Vec::new(); + adapter.play_native( + &asset, + NativePlaybackOptions { + looping: false, + ..Default::default() + }, + &mut |value| { + let NativeMediaFrame::Rgba(frame) = value.unwrap() else { + panic!("unsupported metadata/alpha must retain RGBA") + }; + native.push(frame_snapshot(frame)); + true + }, + Arc::new(Never), + ); + assert_eq!(native, normal); + assert_eq!(native.len(), 3); + } +} + +#[test] +fn video_native_planes_minification_returns_exact_existing_fitted_rgba() { + let root = Directory::new(); + let file = tagged_h264_fixture(&root.0); + let adapter = NativeVideoWallpapers { + state: root.0.join("store"), + library: root.0.clone(), + }; + let asset = adapter + .prepare(&Wallpaper { + path: file.to_string_lossy().into(), + name: "Minification fixture".into(), + }) + .unwrap(); + let target = Some(WallpaperPixelTarget::new(32, 24).unwrap()); + let mut expected = Vec::new(); + adapter.play( + &asset, + PlaybackOptions { + target, + looping: false, + ..Default::default() + }, + &mut |value| { + expected.push(frame_snapshot(value.unwrap())); + true + }, + Arc::new(Never), + ); + let mut actual = Vec::new(); + adapter.play_native( + &asset, + NativePlaybackOptions { + target, + looping: false, + ..Default::default() + }, + &mut |value| { + let NativeMediaFrame::Rgba(frame) = value.unwrap() else { + panic!("one-level native sampling must not replace proper fit") + }; + actual.push(frame_snapshot(frame)); + true + }, + Arc::new(Never), + ); + assert_eq!(actual, expected); + assert_eq!(actual.len(), 8); + assert!( + actual + .iter() + .all(|row| row.1 == 32 && row.2 == 24 && row.4 == (64, 48)) + ); +} +#[test] +fn video_native_planes_source_or_upscaled_cover_keeps_owned_source_planes() { + let root = Directory::new(); + let file = tagged_h264_fixture(&root.0); + let adapter = NativeVideoWallpapers { + state: root.0.join("store"), + library: root.0.clone(), + }; + let asset = adapter + .prepare(&Wallpaper { + path: file.to_string_lossy().into(), + name: "Native cover fixture".into(), + }) + .unwrap(); + for target in [None, Some(WallpaperPixelTarget::new(128, 96).unwrap())] { + let mut count = 0; + adapter.play_native( + &asset, + NativePlaybackOptions { + target, + looping: false, + ..Default::default() + }, + &mut |value| { + let NativeMediaFrame::Yuv420 { frame, .. } = value.unwrap() else { + panic!("source or upscale has no minification requirement") + }; + assert_eq!(frame.dimensions(), (64, 48)); + count += 1; + false + }, + Arc::new(Never), + ); + assert_eq!(count, 1); + } +} diff --git a/lucent/crates/lucent-ui/src/lib.rs b/lucent/crates/lucent-ui/src/lib.rs index ba31b002..2447faf6 100644 --- a/lucent/crates/lucent-ui/src/lib.rs +++ b/lucent/crates/lucent-ui/src/lib.rs @@ -61,6 +61,15 @@ pub enum Paint { opacity: f32, tint: Color, }, + Video { + rect: Rect, + uv: Rect, + clip: Clip, + data: Arc, + radius: f32, + opacity: f32, + tint: Color, + }, } #[derive(Clone)] pub struct Hit { @@ -269,7 +278,7 @@ impl Layout { ) .height, ), - Kind::Image(_) => (32., 32.), + Kind::Image(_) | Kind::Video(_) => (32., 32.), Kind::Slider(_) => (160., e.style.slider.thumb_diameter), Kind::Row => ( children.iter().map(|c| c.0).sum::() + gaps, @@ -722,6 +731,19 @@ impl Layout { tint: e.style.image_tint, }); } + Kind::Video(data) => { + let (width, height) = data.aspect_size(); + let (rect, uv) = image_geometry(inner, width, height, e.style.image_fit); + scene.paint.push(Paint::Video { + rect, + uv, + clip: own_clip.intersect(inner), + data: data.clone(), + radius: e.style.radius, + opacity: alpha, + tint: e.style.image_tint, + }); + } _ => {} } if let Some(scroll) = scroll_hit { @@ -1959,3 +1981,70 @@ mod tests { assert_eq!(i.release(40., 20.), Some(3)); } } + +#[cfg(test)] +mod native_video_layout_tests { + use super::*; + use lucent_api::{ + ImageStream, + video_frame::{Bt709, Bytes, ChromaSiting, Plane, Range, Transfer, Yuv420Frame}, + }; + #[test] + fn video_uses_image_cover_contain_clip_radius_opacity_and_tint_geometry() { + let y = Plane::new(Bytes::from(vec![128; 65 * 37]), 65, 37, 65).unwrap(); + let uv = Plane::new(Bytes::from(vec![128; 33 * 19]), 33, 19, 33).unwrap(); + let frame = Yuv420Frame::new( + 65, + 37, + [y, uv.clone(), uv], + Bt709 { + range: Range::Full, + transfer: Transfer::Srgb, + chroma: ChromaSiting::Center, + }, + ) + .unwrap(); + let video = Arc::new(ImageStream::new().frame_yuv420(frame).unwrap()); + let image = + Arc::new(ImageData::from_rgba("comparison", 65, 37, vec![128; 65 * 37 * 4]).unwrap()); + let layout = Layout::new(Arc::new(vec![])); + for fit in [ImageFit::Cover, ImageFit::Contain] { + let mut a = Element::<()>::image(image.clone()).size(23., 17.); + let mut b = Element::<()>::video(video.clone()).size(23., 17.); + for node in [&mut a, &mut b] { + node.style.image_fit = fit; + node.style.radius = 3.; + node.style.opacity = 0.7; + node.style.image_tint = Color::hex(0x507090); + } + let a = layout.build(&a, 23., 17., &Interaction::default(), 0.); + let b = layout.build(&b, 23., 17., &Interaction::default(), 0.); + let Paint::Image { + rect: ar, + uv: au, + clip: ac, + radius: aa, + opacity: ao, + tint: at, + .. + } = &a.paint[0] + else { + panic!("image primitive") + }; + let Paint::Video { + rect: br, + uv: bu, + clip: bc, + radius: ba, + opacity: bo, + tint: bt, + .. + } = &b.paint[0] + else { + panic!("video primitive") + }; + assert_eq!((ar, au, ac, aa, ao, at), (br, bu, bc, ba, bo, bt)); + assert_eq!(a.regions, b.regions); + } + } +} diff --git a/lucent/crates/lucent-ui/src/text_area.rs b/lucent/crates/lucent-ui/src/text_area.rs index f86d8335..0dec6640 100644 --- a/lucent/crates/lucent-ui/src/text_area.rs +++ b/lucent/crates/lucent-ui/src/text_area.rs @@ -1145,7 +1145,8 @@ mod tests { | Paint::Shape { rect, clip, .. } | Paint::Shadow { rect, clip, .. } | Paint::Text { rect, clip, .. } - | Paint::Image { rect, clip, .. } => (rect, &clip.bounds), + | Paint::Image { rect, clip, .. } + | Paint::Video { rect, clip, .. } => (rect, &clip.bounds), }; for bounds in [rect, clip] { assert!( diff --git a/lucent/crates/lucent-video-frame/Cargo.toml b/lucent/crates/lucent-video-frame/Cargo.toml new file mode 100644 index 00000000..a13347eb --- /dev/null +++ b/lucent/crates/lucent-video-frame/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "lucent-video-frame" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +bytes = "=1.12.1" diff --git a/lucent/crates/lucent-video-frame/src/lib.rs b/lucent/crates/lucent-video-frame/src/lib.rs new file mode 100644 index 00000000..1bf8db0f --- /dev/null +++ b/lucent/crates/lucent-video-frame/src/lib.rs @@ -0,0 +1,347 @@ +//! Immutable, checked video data. No codec, OS, GPU or client dependencies. +//! Byte owners must expose initialized bytes; native adapters never expose +//! uninitialized row padding. Existing RGBA image contracts are independent. +pub use bytes::Bytes; +use std::fmt; + +pub const MAX_EDGE: u32 = 4096; +const MAX_BYTES: usize = MAX_EDGE as usize * MAX_EDGE as usize * 4; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FrameError(pub &'static str); +impl fmt::Display for FrameError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.0) + } +} +impl std::error::Error for FrameError {} +type Result = std::result::Result; + +/// Row stride may include initialized padding. The final row need only contain +/// its visible samples. All layout arithmetic is checked before publication. +#[derive(Clone)] +pub struct Plane { + data: Bytes, + width: u32, + height: u32, + stride: u32, +} +impl fmt::Debug for Plane { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Plane") + .field("width", &self.width) + .field("height", &self.height) + .field("stride", &self.stride) + .field("bytes", &self.data.len()) + .finish() + } +} +impl Plane { + pub fn new(data: Bytes, width: u32, height: u32, stride: u32) -> Result { + if width == 0 + || height == 0 + || width > MAX_EDGE + || height > MAX_EDGE + || stride < width + || stride as usize > MAX_BYTES + { + return Err(FrameError("Invalid video plane dimensions or stride")); + } + let required = (height as usize - 1) + .checked_mul(stride as usize) + .and_then(|n| n.checked_add(width as usize)) + .ok_or(FrameError("Video plane extent overflow"))?; + if required > MAX_BYTES || data.len() < required || data.len() > MAX_BYTES { + return Err(FrameError("Invalid video plane storage extent")); + } + Ok(Self { + data: data.slice(..required), + width, + height, + stride, + }) + } + pub fn dimensions(&self) -> (u32, u32) { + (self.width, self.height) + } + pub fn stride(&self) -> u32 { + self.stride + } + pub fn bytes(&self) -> &Bytes { + &self.data + } + pub fn row(&self, index: u32) -> Option<&[u8]> { + if index >= self.height { + return None; + } + let begin = index as usize * self.stride as usize; + self.data.get(begin..begin + self.width as usize) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Range { + Limited, + Full, +} +/// Both transfers use BT.709/sRGB primaries and D65 white. Other primaries, +/// matrices and HDR transfers are outside this initial native capability. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Transfer { + Bt709, + Srgb, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChromaSiting { + Left, + Center, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Bt709 { + pub range: Range, + pub transfer: Transfer, + pub chroma: ChromaSiting, +} +/// The matrix is explicitly BT.709; an unspecified/other matrix cannot be +/// mistaken for supported metadata by a default enum value. +#[derive(Clone, Debug)] +pub struct Yuv420Frame { + width: u32, + height: u32, + planes: [Plane; 3], + color: Bt709, +} +impl Yuv420Frame { + pub fn new(width: u32, height: u32, planes: [Plane; 3], color: Bt709) -> Result { + if width == 0 || height == 0 || width > MAX_EDGE || height > MAX_EDGE { + return Err(FrameError("Invalid video frame dimensions")); + } + let chroma = (width.div_ceil(2), height.div_ceil(2)); + if planes[0].dimensions() != (width, height) + || planes[1].dimensions() != chroma + || planes[2].dimensions() != chroma + { + return Err(FrameError("YUV420 plane geometry does not match frame")); + } + let bytes = planes + .iter() + .try_fold(0usize, |n, p| n.checked_add(p.bytes().len())) + .ok_or(FrameError("Video storage extent overflow"))?; + if bytes > MAX_BYTES { + return Err(FrameError("Video frame storage exceeds bounds")); + } + Ok(Self { + width, + height, + planes, + color, + }) + } + pub fn dimensions(&self) -> (u32, u32) { + (self.width, self.height) + } + pub fn planes(&self) -> &[Plane; 3] { + &self.planes + } + pub fn color(&self) -> Bt709 { + self.color + } + pub fn byte_len(&self) -> usize { + self.planes.iter().map(|p| p.bytes().len()).sum() + } + /// Pure fixture oracle; conversion policy is not part of the public DTO API. + #[cfg(test)] + fn sample_srgb(&self, u: f32, v: f32) -> [f32; 3] { + let y = sample( + &self.planes[0], + u * self.width as f32 - 0.5, + v * self.height as f32 - 0.5, + ); + let location = match self.color.chroma { + ChromaSiting::Left => [0., 0.5], + ChromaSiting::Center => [0.5, 0.5], + }; + let x = (u * self.width as f32 - 0.5 - location[0]) * 0.5; + let yy = (v * self.height as f32 - 0.5 - location[1]) * 0.5; + to_srgb( + y, + sample(&self.planes[1], x, yy), + sample(&self.planes[2], x, yy), + self.color, + ) + } +} +#[cfg(test)] +fn sample(plane: &Plane, x: f32, y: f32) -> f32 { + let x = x.clamp(0., plane.width as f32 - 1.); + let y = y.clamp(0., plane.height as f32 - 1.); + let left = x.floor() as u32; + let top = y.floor() as u32; + let right = (left + 1).min(plane.width - 1); + let bottom = (top + 1).min(plane.height - 1); + let at = |x: u32, y: u32| plane.row(y).expect("checked plane row")[x as usize] as f32 / 255.; + let first = at(left, top) + (at(right, top) - at(left, top)) * x.fract(); + let second = at(left, bottom) + (at(right, bottom) - at(left, bottom)) * x.fract(); + first + (second - first) * y.fract() +} +#[cfg(test)] +fn to_srgb(y: f32, u: f32, v: f32, color: Bt709) -> [f32; 3] { + let (y, u, v) = match color.range { + Range::Limited => ( + (y * 255. - 16.) / 219., + (u * 255. - 128.) / 224., + (v * 255. - 128.) / 224., + ), + Range::Full => (y, (u * 255. - 128.) / 255., (v * 255. - 128.) / 255.), + }; + let rgb = [ + y + 1.5748 * v, + y - 0.18732427 * u - 0.46812427 * v, + y + 1.8556 * u, + ]; + rgb.map(|value| { + let value = value.clamp(0., 1.); + match color.transfer { + Transfer::Srgb => value, + Transfer::Bt709 => { + // Continuous BT.709 curve used by FFmpeg's color utilities; + // see docs/native-video-planes.md for the independent sources. + let linear = if value < 0.08124286 { + value / 4.5 + } else { + ((value + 0.09929683) / 1.0992968).powf(1. / 0.45) + }; + if linear <= 0.0031308 { + 12.92 * linear + } else { + 1.055 * linear.powf(1. / 2.4) - 0.055 + } + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + fn color(range: Range) -> Bt709 { + Bt709 { + range, + transfer: Transfer::Srgb, + chroma: ChromaSiting::Center, + } + } + #[test] + fn plane_validates_odd_stride_and_last_row_without_requiring_padding() { + let plane = Plane::new(Bytes::from(vec![1, 2, 3, 99, 4, 5, 6]), 3, 2, 4).unwrap(); + assert_eq!(plane.row(0), Some(&[1, 2, 3][..])); + assert_eq!(plane.row(1), Some(&[4, 5, 6][..])); + assert!(plane.row(2).is_none()); + assert!(Plane::new(Bytes::from(vec![1; 6]), 3, 2, 4).is_err()); + assert!(Plane::new(Bytes::from(vec![1; 6]), 3, 2, 2).is_err()); + assert!(Plane::new(Bytes::new(), 0, 2, 4).is_err()); + assert!(Plane::new(Bytes::from(vec![1; 8]), 1, MAX_EDGE, u32::MAX).is_err()); + assert!(Plane::new(Bytes::from(vec![1]), 1, 1, u32::MAX).is_err()); + } + #[test] + fn odd_420_geometry_rounds_each_chroma_edge_up_and_stays_bounded() { + let y = Plane::new(Bytes::from(vec![128; 15]), 5, 3, 5).unwrap(); + let uv = Plane::new(Bytes::from(vec![128; 6]), 3, 2, 3).unwrap(); + let frame = Yuv420Frame::new( + 5, + 3, + [y.clone(), uv.clone(), uv.clone()], + color(Range::Full), + ) + .unwrap(); + assert_eq!(frame.byte_len(), 27); + assert_eq!(frame.dimensions(), (5, 3)); + let wrong = Plane::new(Bytes::from(vec![128; 4]), 2, 2, 2).unwrap(); + assert!(Yuv420Frame::new(5, 3, [y, wrong, uv], color(Range::Full)).is_err()); + } + #[test] + fn limited_and_full_neutral_extremes_produce_black_white_and_preserve_grey() { + for (range, black, white) in [(Range::Limited, 16., 235.), (Range::Full, 0., 255.)] { + assert!( + to_srgb(black / 255., 128. / 255., 128. / 255., color(range)) + .iter() + .all(|v| v.abs() < 1e-6) + ); + assert!( + to_srgb(white / 255., 128. / 255., 128. / 255., color(range)) + .iter() + .all(|v| (v - 1.).abs() < 1e-6) + ); + } + let grey = to_srgb(0.5, 128. / 255., 128. / 255., color(Range::Full)); + assert!(grey.iter().all(|v| (v - 0.5).abs() < 1e-6)); + } + #[test] + fn bt709_matrix_recovers_primary_vectors_before_display_transfer() { + for rgb in [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.]] { + let y = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]; + let u = (rgb[2] - y) / 1.8556; + let v = (rgb[0] - y) / 1.5748; + let decoded = to_srgb(y, u + 128. / 255., v + 128. / 255., color(Range::Full)); + assert!(decoded.iter().zip(rgb).all(|(a, b)| (a - b).abs() < 2e-6)); + } + } + #[test] + fn bt709_transfer_is_converted_once_into_the_existing_nonlinear_srgb_target() { + let mut c = color(Range::Full); + c.transfer = Transfer::Bt709; + let signal = 4.5 * 0.01; + let expected = 1.055 * 0.01f32.powf(1. / 2.4) - 0.055; + let rgb = to_srgb(signal, 128. / 255., 128. / 255., c); + assert!(rgb.iter().all(|v| (v - expected).abs() < 1e-6)); + assert!((rgb[0] - signal).abs() > 0.04); + } + #[test] + fn siting_and_odd_right_edge_follow_luma_sample_coordinates() { + let y = Plane::new(Bytes::from(vec![128; 5]), 5, 1, 5).unwrap(); + let u = Plane::new(Bytes::from(vec![64, 128, 192]), 3, 1, 3).unwrap(); + let v = Plane::new(Bytes::from(vec![128; 3]), 3, 1, 3).unwrap(); + let mut c = color(Range::Full); + c.chroma = ChromaSiting::Left; + let left = Yuv420Frame::new(5, 1, [y.clone(), u.clone(), v.clone()], c).unwrap(); + c.chroma = ChromaSiting::Center; + let center = Yuv420Frame::new(5, 1, [y, u, v], c).unwrap(); + assert!(left.sample_srgb(0.3, 0.5)[2] > center.sample_srgb(0.3, 0.5)[2]); + let expected = to_srgb(128. / 255., 192. / 255., 128. / 255., color(Range::Full)); + let edge = left.sample_srgb(0.9, 0.5); + assert!(edge.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-6)); + } + #[test] + fn neutral_owner_lives_until_the_last_plane_view_and_contains_no_codec_type() { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + struct Owner { + bytes: Vec, + drops: Arc, + } + impl AsRef<[u8]> for Owner { + fn as_ref(&self) -> &[u8] { + &self.bytes + } + } + impl Drop for Owner { + fn drop(&mut self) { + self.drops.fetch_add(1, Ordering::SeqCst); + } + } + let drops = Arc::new(AtomicUsize::new(0)); + let data = Bytes::from_owner(Owner { + bytes: vec![1, 2, 3, 4], + drops: drops.clone(), + }); + let plane = Plane::new(data, 2, 2, 2).unwrap(); + let retained = plane.clone(); + drop(plane); + assert_eq!(drops.load(Ordering::SeqCst), 0); + assert_eq!(retained.row(1), Some(&[3, 4][..])); + drop(retained); + assert_eq!(drops.load(Ordering::SeqCst), 1); + } +} diff --git a/lucent/crates/lucent-wayland/src/focus_request.rs b/lucent/crates/lucent-wayland/src/focus_request.rs index 9fa0bc60..7d2b9f74 100644 --- a/lucent/crates/lucent-wayland/src/focus_request.rs +++ b/lucent/crates/lucent-wayland/src/focus_request.rs @@ -4,6 +4,7 @@ use super::*; use smithay_client_toolkit::registry::RegistryHandler; use wayland_client::{ Dispatch, + backend::WaylandError, globals::GlobalList, protocol::wl_callback::{self, WlCallback}, }; @@ -29,6 +30,16 @@ use wire::{ const TIMEOUT: Duration = Duration::from_secs(2); const INTERFACE: &str = "hyprland_focus_grab_manager_v1"; +/// Try once after an entire ordered control transaction. Backpressure belongs +/// to the normal Wayland source's next flush; it is neither an acknowledgement +/// nor a reason to wait, retry here, or claim keyboard ownership. +fn flush_control(flush: impl FnOnce() -> Result<(), WaylandError>) -> Result<(), WaylandError> { + match flush() { + Err(WaylandError::Io(error)) if error.kind() == std::io::ErrorKind::WouldBlock => Ok(()), + result => result, + } +} + pub(super) struct Origin { pub address: api::SurfaceAddress, pub surface: wl_surface::WlSurface, @@ -241,6 +252,17 @@ fn already_owned( } impl State { + fn flush_focus_control(&mut self) { + if let Err(error) = flush_control(|| self.connection.flush()) { + // Invalidate pending receipts before another already-queued native + // event can complete them on a failed connection. The existing + // runtime failure path still owns shutdown and surface teardown. + self.focus_request_end(api::KeyboardFocusOutcome::Unavailable, false); + self.error = Some(error.to_string()); + self.exit = true; + } + } + pub(super) fn focus_requests_pending_for(&self, address: &api::SurfaceAddress) -> bool { self.focus_requests .pending @@ -443,6 +465,9 @@ impl State { // The keyboard-only role commit above precedes this request on the same // Wayland connection; acquiring never depends on an own frame callback. grab.commit(); + // Do not leave this already-complete transaction behind unrelated + // callbacks/layout work while WaylandSource drains its pending queue. + self.flush_focus_control(); } pub(super) fn focus_request_end( @@ -571,6 +596,9 @@ impl State { phase: Phase::Retaining(callback), ..pending }); + // Destroy and the following sync must travel together, after the + // receipt is installed. Flush success itself never reports Focused. + self.flush_focus_control(); } pub(super) fn focus_request_lifecycle( @@ -702,6 +730,51 @@ mod tests { EventQueue, protocol::{wl_compositor::WlCompositor, wl_registry::WlRegistry}, }; + #[test] + fn control_flush_attempts_success_once_without_an_ownership_receipt() { + let calls = std::cell::Cell::new(0); + assert!( + flush_control(|| { + calls.set(calls.get() + 1); + Ok(()) + }) + .is_ok() + ); + assert_eq!(calls.get(), 1); + } + #[test] + fn control_flush_defers_would_block_without_retrying_or_waiting() { + let calls = std::cell::Cell::new(0); + assert!( + flush_control(|| { + calls.set(calls.get() + 1); + Err(WaylandError::Io(std::io::ErrorKind::WouldBlock.into())) + }) + .is_ok() + ); + assert_eq!(calls.get(), 1); + } + #[test] + fn control_flush_preserves_fatal_io_errors() { + let result = flush_control(|| Err(WaylandError::Io(std::io::ErrorKind::BrokenPipe.into()))); + assert!(matches!(result, Err(WaylandError::Io(error)) + if error.kind() == std::io::ErrorKind::BrokenPipe)); + } + #[test] + fn control_flush_preserves_protocol_failure() { + let result = flush_control(|| { + Err(WaylandError::Protocol( + wayland_client::backend::protocol::ProtocolError { + code: 1, + object_id: 1, + object_interface: "wl_display".into(), + message: "test refusal".into(), + }, + )) + }); + assert!(matches!(result, Err(WaylandError::Protocol(error)) + if error.code == 1 && error.object_interface == "wl_display")); + } fn valid() -> Admission { Admission { secure: false, @@ -1160,7 +1233,9 @@ mod tests { grab.commit(); let grab_id = grab.id().protocol_id(); let _sync = begin_retention(&connection, &qh, grab, SyncData { id: 1, native }); - connection.flush().unwrap(); + // Use the production control flush, without running an event source or + // dispatching a callback. The server must already see teardown + sync. + flush_control(|| connection.flush()).unwrap(); let mut observed = vec![]; loop { let mut header = [0u8; 8]; @@ -1189,6 +1264,47 @@ mod tests { ); } #[test] + fn control_flush_sends_complete_acquisition_before_peer_dispatch() { + let (connection, queue, mut server, compositor, _) = wire_socket(); + let qh = queue.handle(); + let registry = connection.display().get_registry(&qh, ()); + let manager = registry.bind::(3, 1, &qh, ()); + let surface = compositor.create_surface(&qh, ()); + let grab = manager.create_grab( + &qh, + GrabData { + id: 1, + native: api::NativeSurfaceToken::new(), + }, + ); + surface.commit(); + grab.add_surface(&surface); + grab.commit(); + flush_control(|| connection.flush()).unwrap(); + let mut observed = vec![]; + loop { + let mut header = [0u8; 8]; + server.read_exact(&mut header).unwrap(); + let object = u32::from_ne_bytes(header[..4].try_into().unwrap()); + let word = u32::from_ne_bytes(header[4..].try_into().unwrap()); + let opcode = word as u16; + let mut payload = vec![0; (word >> 16) as usize - 8]; + server.read_exact(&mut payload).unwrap(); + if object == surface.id().protocol_id() { + assert_eq!(opcode, 6, "Only the queued surface commit is allowed"); + observed.push(("surface", opcode)); + } + if object == grab.id().protocol_id() { + observed.push(("grab", opcode)); + if opcode == 2 { + break; + } + } + } + assert_eq!(observed, [("surface", 6), ("grab", 0), ("grab", 2)]); + // No Enter/Done was injected or dispatched: flushing is transport only. + } + #[test] fn owned_sync_teardown_retires_proxy_and_accepts_later_delete_id() { let mut f = fixture(api::Cancellation::default()); let callback_id = f.callback.id().protocol_id(); diff --git a/lucent/examples/native-video-planes/Cargo.toml b/lucent/examples/native-video-planes/Cargo.toml new file mode 100644 index 00000000..f6cfe1b8 --- /dev/null +++ b/lucent/examples/native-video-planes/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "native-video-planes" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +lucent-domain = { path = "../../crates/lucent-domain" } +lucent-services = { path = "../../crates/lucent-services" } +lucent-render = { path = "../../crates/lucent-render" } +image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/lucent/examples/native-video-planes/src/main.rs b/lucent/examples/native-video-planes/src/main.rs new file mode 100644 index 00000000..5338c395 --- /dev/null +++ b/lucent/examples/native-video-planes/src/main.rs @@ -0,0 +1,169 @@ +//! Explicit one-frame decoder→native-plane→Vulkan validation consumer. +//! Inert unless --run; no session, compositor, wallpaper preference or UI changes. +use image::ImageEncoder; +use lucent_domain::{ + NativeMediaFrame, NativePlaybackOptions, StopSignal, Wallpaper, WallpaperPlaybackPort, +}; +use lucent_services::video::NativeVideoWallpapers; +use std::{ + path::PathBuf, + sync::Arc, + time::{Duration, Instant}, +}; +type Error = Box; +struct Request { + source: PathBuf, + state: PathBuf, + output: PathBuf, +} +fn request(args: &[String]) -> Result, Error> { + if args.is_empty() || args == ["--help"] { + return Ok(None); + } + if args.len() != 7 + || args[0] != "--run" + || args[1] != "--source" + || args[3] != "--state" + || args[5] != "--output" + { + return Err("Expected --run --source ABSOLUTE_MEDIA --state NEW_ABSOLUTE_DIRECTORY --output NEW_ABSOLUTE_PNG".into()); + } + let value = Request { + source: PathBuf::from(&args[2]), + state: PathBuf::from(&args[4]), + output: PathBuf::from(&args[6]), + }; + if [&value.source, &value.state, &value.output] + .iter() + .any(|p| !p.is_absolute()) + { + return Err("All paths must be absolute".into()); + } + if value.source == value.output + || value.source.starts_with(&value.state) + || value.output.starts_with(&value.state) + { + return Err("Source, new import store and output must be separate".into()); + } + Ok(Some(value)) +} +struct Deadline(Instant); +impl StopSignal for Deadline { + fn cancelled(&self) -> bool { + Instant::now() >= self.0 + } + fn wait(&self, value: Duration) { + std::thread::sleep(value.min(self.0.saturating_duration_since(Instant::now()))); + } +} +fn main() -> Result<(), Error> { + let Some(request) = request(&std::env::args().skip(1).collect::>())? else { + println!( + "Prepared only. To validate one frame: --run --source ABSOLUTE_MEDIA --state NEW_ABSOLUTE_DIRECTORY --output NEW_ABSOLUTE_PNG" + ); + return Ok(()); + }; + if request.state.exists() || request.output.exists() { + return Err("Store/output must be new; existing files are preserved".into()); + } + let adapter = NativeVideoWallpapers { + state: request.state, + library: request + .source + .parent() + .ok_or("Source needs a parent")? + .into(), + }; + let asset = adapter.prepare(&Wallpaper { + path: request + .source + .to_str() + .ok_or("Source path must be UTF-8")? + .into(), + name: "Native plane validation".into(), + })?; + let mut first = None; + adapter.play_native( + &asset, + NativePlaybackOptions { + looping: false, + ..Default::default() + }, + &mut |value| { + first = Some(value); + false + }, + Arc::new(Deadline(Instant::now() + Duration::from_secs(15))), + ); + // All decoder/session owners have returned before rendering. Retained plane + // buffers must remain valid independently. This is not playback FPS evidence. + let (width, height, pixels) = match first.ok_or("No frame before cancellation")?? { + NativeMediaFrame::Yuv420 { + frame, publication, .. + } => { + let (width, height) = frame.dimensions(); + let mut painter = lucent_render::PlanarPainter::new(lucent_render::Gpu::headless( + Arc::new(Vec::new()), + )?); + let pixels = painter.snapshot(&frame, width, height)?; + println!( + "Native YUV4208: referenced={} copied={} publication bytes; {:?}. RGBA readback is validation-only.", + publication.referenced_bytes, + publication.copied_bytes, + painter.stats() + ); + (width, height, pixels) + } + NativeMediaFrame::Rgba(frame) => { + println!( + "Unsupported native metadata/format: existing RGBA fallback; no GPU-plane claim." + ); + frame.into_rgba() + } + }; + let file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(request.output)?; + image::codecs::png::PngEncoder::new(file).write_image( + &pixels, + width, + height, + image::ExtendedColorType::Rgba8, + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn args(source: &str, state: &str, output: &str) -> Vec { + [ + "--run", "--source", source, "--state", state, "--output", output, + ] + .into_iter() + .map(str::to_string) + .collect() + } + #[test] + fn native_validation_is_inert_by_default_and_requires_explicit_separate_absolute_paths() { + assert!(request(&[]).unwrap().is_none()); + assert!(request(&["--help".into()]).unwrap().is_none()); + assert!( + request(&args("/input.mp4", "/new-store", "/output.png")) + .unwrap() + .is_some() + ); + for invalid in [ + args("relative.mp4", "/store", "/out.png"), + args("/store/in.mp4", "/store", "/out.png"), + args("/input.mp4", "/store", "/store/out.png"), + args("/input.mp4", "/store", "/input.mp4"), + ] { + assert!(request(&invalid).is_err()); + } + let mut invalid = args("/input.mp4", "/store", "/out.png"); + invalid[0] = "--prepare".into(); + assert!(request(&invalid).is_err()); + } +} diff --git a/scripts/check-component-lab.py b/scripts/check-component-lab.py index 14d64e6d..e1350877 100644 --- a/scripts/check-component-lab.py +++ b/scripts/check-component-lab.py @@ -68,13 +68,15 @@ def validate(root=ROOT, catalog=None): producer_directories = ('lucent/apps/lucent-lab', 'lucent/apps/lucent-menu', 'lucent/crates/lucent-api', 'lucent/crates/lucent-design', 'lucent/crates/lucent-ui', 'lucent/crates/lucent-render', - 'lucent/crates/lucent-domain', 'lucent/crates/lucent-usecases') + 'lucent/crates/lucent-domain', 'lucent/crates/lucent-usecases', + 'lucent/crates/lucent-video-frame') required.add('lucent/Cargo.toml') required.update(directory + '/Cargo.toml' for directory in producer_directories) for directory in ('lucent/apps/lucent-lab/src', 'lucent/apps/lucent-menu/src', 'lucent/crates/lucent-api/src', 'lucent/crates/lucent-design/src', 'lucent/crates/lucent-ui/src', 'lucent/crates/lucent-render/src', - 'lucent/crates/lucent-domain/src', 'lucent/crates/lucent-usecases/src'): + 'lucent/crates/lucent-domain/src', 'lucent/crates/lucent-usecases/src', + 'lucent/crates/lucent-video-frame/src'): required.update(p.relative_to(root).as_posix() for p in (root / directory).rglob('*') if p.is_file() and p.suffix in ('.rs', '.wgsl')) required.update(p.relative_to(root).as_posix() diff --git a/tests/test_architecture.py b/tests/test_architecture.py index 2db42def..1bf81336 100644 --- a/tests/test_architecture.py +++ b/tests/test_architecture.py @@ -11,6 +11,7 @@ ROOT = Path(__file__).resolve().parents[1] KINDS = ('dependencies', 'build-dependencies', 'dev-dependencies') +NEUTRAL_VIDEO = 'lucent-video-frame' def dependency_names(manifest, workspace): @@ -50,7 +51,69 @@ def sources(directory, excluded=()): return [p for p in directory.rglob('*.rs') if p.relative_to(directory).as_posix() not in excluded] +def neutral_video_violations(manifest, workspace, graph, texts, automatic_build=False): + """An explicit pure-value role, not an exemption for arbitrary libraries. + + The dependency resolver covers aliases and every target/build/dev table. + Source checks are deliberately conservative lexical guards, not a Rust + semantic analyzer. This DTO crate needs neither platform imports nor a + build/proc-macro execution hook. + """ + errors = [] + if set(dependency_names(manifest, workspace)) != {'bytes'}: + errors.append('only_external_bytes_dependency') + if reachable(graph, NEUTRAL_VIDEO): + errors.append('no_indirect_workspace_dependencies') + if automatic_build or manifest['package'].get('build', False) is not False: + errors.append('no_build_script') + if manifest.get('lib', {}).get('proc-macro', False): + errors.append('no_proc_macro') + std_modules = r'(?:process|fs|net|os|env|thread|io)' + patterns = ( + r'\bstd\s*::\s*' + std_modules + r'\b', + r'\buse\s+std\s*::\s*\{[^;]*\b' + std_modules + r'\b', + r'\b(?:use|extern\s+crate)\s+std\s+as\b', + r'\blucent_(?!video_frame\b)\w+\b', + r'\b(?:wgpu|wayland_client|smithay_client_toolkit|ffmpeg_next|libc|gtk|gtk4|qml|qt_core)\b', + ) + for name, text in texts.items(): + if any(re.search(pattern, text) for pattern in patterns): + errors.append('no_platform_render_client_service_import:' + name) + return errors + + class BoundaryGuardFixtures(unittest.TestCase): + def test_neutral_video_role_accepts_only_owned_value_bytes(self): + manifest = tomllib.loads('[package]\nname="lucent-video-frame"\n[dependencies]\nbytes="1"') + graph = {NEUTRAL_VIDEO: {'bytes'}} + self.assertEqual(neutral_video_violations(manifest, {}, graph, + {'src/lib.rs': 'pub use bytes::Bytes; use std::fmt;'}), []) + + def test_neutral_video_role_rejects_platform_aliases_in_every_dependency_kind_and_target(self): + workspace = {'dependencies': {'hidden': {'package': 'lucent-services', 'path': '../lucent-services'}}} + for scope in ('dependencies', 'build-dependencies', 'dev-dependencies', + "target.'cfg(target_os = \"other\")'.dependencies", + "target.'cfg(unix)'.build-dependencies", "target.'cfg(unix)'.dev-dependencies"): + with self.subTest(scope=scope): + manifest = tomllib.loads('[package]\nname="lucent-video-frame"\n' + '[dependencies]\nbytes="1"\n' + ('hidden={workspace=true}\n' if scope == 'dependencies' + else '[' + scope + ']\nhidden={workspace=true}\n')) + graph = {NEUTRAL_VIDEO: set(dependency_names(manifest, workspace)), 'lucent-services': set()} + self.assertIn('only_external_bytes_dependency', + neutral_video_violations(manifest, workspace, graph, {'src/lib.rs': ''})) + + def test_neutral_video_role_rejects_io_grouped_imports_build_hooks_and_hidden_workspace_bytes(self): + manifest = tomllib.loads('[package]\nname="lucent-video-frame"\n[dependencies]\nbytes="1"') + graph = {NEUTRAL_VIDEO: {'bytes'}} + for text in ('use std::fs;', 'use std::{sync::Arc, process::Command};', + 'use std as platform;', 'use lucent_render::Gpu;', 'use ffmpeg_next as codec;'): + with self.subTest(text=text): + self.assertTrue(neutral_video_violations(manifest, {}, graph, {'src/nested.rs': text})) + self.assertIn('no_build_script', neutral_video_violations(manifest, {}, graph, {}, automatic_build=True)) + hooked = {**manifest, 'package': {**manifest['package'], 'build': '../platform.rs'}} + self.assertIn('no_build_script', neutral_video_violations(hooked, {}, graph, {})) + shadowed = {NEUTRAL_VIDEO: {'bytes'}, 'bytes': {'lucent-render'}, 'lucent-render': set()} + self.assertIn('no_indirect_workspace_dependencies', neutral_video_violations(manifest, {}, shadowed, {})) def test_resolves_renamed_workspace_target_build_and_dev_dependencies(self): workspace = tomllib.loads(''' [dependencies] @@ -100,17 +163,30 @@ def assert_no_imports(self, paths, forbidden): pattern = r'\s*::\s*'.join(map(re.escape, token.split('::'))) self.assertIsNone(re.search(pattern, text), f'{path.relative_to(ROOT)} imports {token}') + def assert_neutral_video_role(self): + directory = ROOT / 'lucent/crates' / NEUTRAL_VIDEO + manifest = tomllib.loads((directory / 'Cargo.toml').read_text()) + workspace = tomllib.loads((ROOT / 'lucent/Cargo.toml').read_text())['workspace'] + texts = {p.relative_to(directory).as_posix(): p.read_text() for p in sources(directory)} + self.assertEqual(neutral_video_violations(manifest, workspace, self.graph, texts, + automatic_build=(directory / 'build.rs').exists()), []) + + def test_neutral_video_values_do_not_smuggle_platform_or_rendering_dependencies(self): + self.assert_neutral_video_role() + def test_domain_and_usecases_do_not_depend_on_platform_or_rendering(self): - self.assertLessEqual(self.graph['lucent-domain'], {'serde', 'zeroize'}) + self.assert_neutral_video_role() + self.assertLessEqual(self.graph['lucent-domain'], {'serde', 'zeroize', NEUTRAL_VIDEO}) self.assertEqual(self.graph['lucent-usecases'], {'lucent-domain'}) for crate in ('lucent-domain', 'lucent-usecases'): self.assert_no_platform_operations(sources(ROOT / 'lucent/crates' / crate / 'src')) def test_framework_has_no_indirect_client_service_or_design_dependencies(self): + self.assert_neutral_video_role() framework = {'lucent-api', 'lucent-ui', 'lucent-wayland', 'lucent-render'} for name in framework: - self.assertLessEqual(reachable(self.graph, name), framework, name) - self.assertEqual(reachable(self.graph, 'lucent-usecases'), {'lucent-domain'}) + self.assertLessEqual(reachable(self.graph, name), framework | {NEUTRAL_VIDEO}, name) + self.assertEqual(reachable(self.graph, 'lucent-usecases'), {'lucent-domain', NEUTRAL_VIDEO}) def test_component_policy_does_not_choose_production_adapters(self): self.assert_no_platform_operations(sources(ROOT / 'lucent/apps/lucent-desktop/src', diff --git a/tests/test_component_lab.py b/tests/test_component_lab.py index a37661ca..278b9de8 100644 --- a/tests/test_component_lab.py +++ b/tests/test_component_lab.py @@ -31,12 +31,13 @@ def setUp(self): 'lucent/crates/lucent-design/src/lib.rs', 'lucent/crates/lucent-design/src/tokens.rs', 'lucent/crates/lucent-api/src/lib.rs', 'lucent/crates/lucent-ui/src/lib.rs', 'lucent/crates/lucent-render/src/lib.rs', 'lucent/apps/lucent-menu/src/lib.rs', - 'lucent/apps/lucent-lab/src/lib.rs', + 'lucent/apps/lucent-lab/src/lib.rs', 'lucent/crates/lucent-video-frame/src/lib.rs', 'lucent/apps/lucent-desktop/assets/LucentSans.ttf', 'lucent/apps/lucent-desktop/assets/LucentDisplay.ttf', *(f'lucent/{directory}/Cargo.toml' for directory in ( 'apps/lucent-lab', 'apps/lucent-menu', 'crates/lucent-api', 'crates/lucent-design', - 'crates/lucent-ui', 'crates/lucent-render', 'crates/lucent-domain', 'crates/lucent-usecases'))) + 'crates/lucent-ui', 'crates/lucent-render', 'crates/lucent-domain', 'crates/lucent-usecases', + 'crates/lucent-video-frame'))) for path in paths: self.put(path, b'fixture source\n') self.put('design/tokens.json', json.dumps({'radius': {'control': {'$value': 12}}}).encode()) @@ -86,6 +87,22 @@ def test_new_nested_producer_module_cannot_be_omitted(self): with self.assertRaisesRegex(ValueError, 'omits a production'): CHECK.validate(self.root) + def test_neutral_video_sources_and_manifest_cannot_be_omitted(self): + for path in ('lucent/crates/lucent-video-frame/src/lib.rs', + 'lucent/crates/lucent-video-frame/Cargo.toml'): + with self.subTest(path=path): + original = self.data['renderer']['inputs'].pop(path) + self.save() + with self.assertRaisesRegex(ValueError, 'omits a production'): + CHECK.validate(self.root) + self.data['renderer']['inputs'][path] = original + self.save() + + def test_changed_neutral_video_value_requires_new_captures(self): + self.put('lucent/crates/lucent-video-frame/src/lib.rs', b'changed plane layout contract') + with self.assertRaisesRegex(ValueError, 'Stale native capture input'): + CHECK.validate(self.root) + def test_pixel_hash_and_density_are_checked(self): image = self.data['stories'][0]['variants'][0]['image'] image['width'] += 1