gfx: GPU video interop layer (from #2109) - #2121
Open
jcelerier wants to merge 67 commits into
Open
Conversation
jcelerier
added a commit
that referenced
this pull request
Jul 17, 2026
FIX-ENC: - NV12.hpp: UV plane encodes into an R8 target (16-byte-tight rows), was mis-sized producing garbage chroma on readback - YUVPlanar.hpp: p420_10 -> RGBA8-packed 16-bit LE on the Qt<6.10 GL path Tests: test_unit_video_pixel_format (pixfmt plane math + avutil mapping); test_integration_encoder_matrix (every encoder on a real QRhi backend, byte-checked); test_regression_offscreen_teardown (full-app offscreen render + /stop + /exit exits 0 — guards 1228382 #2119 + 32ad555 #2121 teardown UAFs); VideoDecoderTester + video-decoder-sweep.sh; EncoderTester ctest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
jcelerier
added a commit
that referenced
this pull request
Jul 17, 2026
…indow Adding/removing a node (or opening the inspector texture preview) during playback triggers a full gfx-graph rebuild while a real window + vsync clock is live. Three regressions, one family: CRASH (deterministic, ASAN 2/2): a stray DeferredDelete reaching the shared_ptr-owned score::gfx::Window ran 'delete this' on a make_shared interior pointer (invalid free) and, by destroying Window::state, dropped the shared RenderState to RenderList-only ownership so the next Graph::createAllRenderLists freed it under the in-flight rebuild -> use-after- free in ScreenNode::updateGraphicsAPI. Fix: Window::event swallows DeferredDelete (the window is never owned by the QObject tree; the shared_ptr deleter destroys it at the right time). Also make ScreenNode::createOutput idempotent — a rebuild that re-enters it while the window's swapchain is still pending must not make_shared-replace the in-flight window (deliberate api/ sample recreation routes through destroyOutput() first). FREEZE-until-window-move: switching manual -> vsync mode only set the vsync callback; nothing kicked a first frame, so the window's self-perpetuating requestUpdate() chain stayed dead until a platform expose. ScreenNode:: setVSyncCallback now kicks a queued requestUpdate() on the null->non-null transition. UAF: Window::render() now executes a copy of onUpdate — a rebuild driven from inside it can tear down the very std::function being executed. Verified on DISPLAY=:0/xcb under ASAN with the DIAG repro (add/remove 2nd output mid-play): 0 ASAN errors across all phases (was 2/2 crash at add); 461 fps samples, no gap >500ms, 119.6 fps held across the manual->vsync transition (was: frozen). fps-drop symptom (2nd preview output flips whole context to manual rate) is left as a follow-up — it needs the per-output clock coexistence refactor (canDoVSync excluding manual-only preview outputs). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
jcelerier
added a commit
that referenced
this pull request
Jul 17, 2026
FIX-ENC: - NV12.hpp: UV plane encodes into an R8 target (16-byte-tight rows), was mis-sized producing garbage chroma on readback - YUVPlanar.hpp: p420_10 -> RGBA8-packed 16-bit LE on the Qt<6.10 GL path Tests: test_unit_video_pixel_format (pixfmt plane math + avutil mapping); test_integration_encoder_matrix (every encoder on a real QRhi backend, byte-checked); test_regression_offscreen_teardown (full-app offscreen render + /stop + /exit exits 0 — guards 1228382 #2119 + 32ad555 #2121 teardown UAFs); VideoDecoderTester + video-decoder-sweep.sh; EncoderTester ctest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
jcelerier
added a commit
that referenced
this pull request
Jul 17, 2026
…indow Adding/removing a node (or opening the inspector texture preview) during playback triggers a full gfx-graph rebuild while a real window + vsync clock is live. Three regressions, one family: CRASH (deterministic, ASAN 2/2): a stray DeferredDelete reaching the shared_ptr-owned score::gfx::Window ran 'delete this' on a make_shared interior pointer (invalid free) and, by destroying Window::state, dropped the shared RenderState to RenderList-only ownership so the next Graph::createAllRenderLists freed it under the in-flight rebuild -> use-after- free in ScreenNode::updateGraphicsAPI. Fix: Window::event swallows DeferredDelete (the window is never owned by the QObject tree; the shared_ptr deleter destroys it at the right time). Also make ScreenNode::createOutput idempotent — a rebuild that re-enters it while the window's swapchain is still pending must not make_shared-replace the in-flight window (deliberate api/ sample recreation routes through destroyOutput() first). FREEZE-until-window-move: switching manual -> vsync mode only set the vsync callback; nothing kicked a first frame, so the window's self-perpetuating requestUpdate() chain stayed dead until a platform expose. ScreenNode:: setVSyncCallback now kicks a queued requestUpdate() on the null->non-null transition. UAF: Window::render() now executes a copy of onUpdate — a rebuild driven from inside it can tear down the very std::function being executed. Verified on DISPLAY=:0/xcb under ASAN with the DIAG repro (add/remove 2nd output mid-play): 0 ASAN errors across all phases (was 2/2 crash at add); 461 fps samples, no gap >500ms, 119.6 fps held across the manual->vsync transition (was: frozen). fps-drop symptom (2nd preview output flips whole context to manual rate) is left as a follow-up — it needs the per-output clock coexistence refactor (canDoVSync excluding manual-only preview outputs). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
jcelerier
added a commit
that referenced
this pull request
Jul 17, 2026
…ss test Two shipped video-decoder bugs found by the P3-video decode-correctness matrix (both verified under ASAN on llvmpipe): - RGB24/BGR24 rendered ~15 dB too dark: RGB24Decoder's packed R8 data texture was flagged QRhiTexture::sRGB, so the sampler applied the sRGB EOTF to raw bytes on texelFetch. Drop the flag (data texture, not colour). 15 -> 99 dB. - RGBA64LE/BGRA64LE rendered pure black: routed to a half-float RGBA16F texture, but the data is 16-bit UNORM integer -> reinterpreted as halfs -> NaN. QRhi has no 4-channel 16-bit UNORM, so add RGBA64Decoder (R16 x w*4 packed + texelFetch reassembly, mirroring RGB48Decoder). black -> 51 dB. (RGBAF16LE, a genuine half-float format, still uses RGBA16F — unchanged.) Test: test_video_decode_correctness — every software-decodable pixel format encoded as a known pattern (ffprobe-verified pix_fmt), decoded through VideoDecoderTester --expect/--psnr, RGBA readback asserted vs ffmpeg's own decode (per-format PSNR bound) + truncated/garbage fuzz. 43 PASS / 0 XFAIL. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
jcelerier
force-pushed
the
plane/interop
branch
2 times, most recently
from
July 18, 2026 13:37
4498ad7 to
71731df
Compare
jcelerier
force-pushed
the
pland/scene
branch
2 times, most recently
from
July 19, 2026 21:59
bd66e34 to
0da070a
Compare
jcelerier
force-pushed
the
plane/interop
branch
2 times, most recently
from
July 20, 2026 23:09
76333c0 to
ca08bb3
Compare
jcelerier
force-pushed
the
pland/scene
branch
2 times, most recently
from
July 21, 2026 04:55
d3bcb48 to
864d930
Compare
score_static_plugins.hpp registers plug-ins behind
`#if __has_include(<score_addon_foo.hpp>)`. avnd_score_plugin_finalize wrote that
header into CMAKE_BINARY_DIR, which is on every target's include path, so the
guard evaluated true in translation units that never link the addon -- they then
emitted a reference to its constructor and failed at link.
Adding score-addon-synthimi to the tree broke 53 targets this way, all of them
small unit tests. score-addon-videoio escapes only because its header is a
source file rather than generated, so its guard is correctly false elsewhere.
The generated files now live in ${CMAKE_BINARY_DIR}/score_addons/<target>/, and
that directory is a PUBLIC include directory of the addon target: the addon's own
generated .cpp and anything linking it can find the header, nothing else can, and
the __has_include guard finally means what it says.
Measured: 53 failing targets before, 2 after -- and those two fail on an
unrelated libremidi/pipewire symbol.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
The IMX676 on the Orin NX rig delivers V4L2_PIX_FMT_SRGGB10 -- the 'RG10' both /dev/video0 and /dev/video1 enumerate. The vocabulary had Bayer at 8, 12 and 16 bits but nothing at 10, and V4L2PixelFormat mapped SRGGB8 and SRGGB16 only, so that fourcc resolved to Unknown and the camera could not be opened at all. Add the four orders V4L2 defines at this depth rather than only the one this sensor needs: the CFA order decides how a demosaic reads the mosaic, so a GRBG10 sensor silently resolving to Unknown is the same class of bug the explicit 8-bit orders already exist to prevent. They occupy a 16-bit little-endian container, so they are two bytes per sample and map to QRhiTexture::R16 -- the mosaic can be uploaded as one single-channel texture and demosaiced in a shader, with no CPU pass over a 25 MB frame. V4L2 defines the ten significant bits as right-aligned. The Tegra VI left-aligns them instead, which is a sixty-fourfold scale rather than a different layout, so it belongs to the demosaic rather than to a separate enumerator here; mapping SRGGB10 onto BayerRGGB16 would have made this one rig correct and every conforming driver sixty-four times too dark. Unbridged on the AV side, following BayerRG8/BayerRG12: FFmpeg spells Bayer at 8 and 16 bits only, and borrowing the 16-bit twin would lose the significant-bit count on the way back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
A Bayer sensor delivers one sample per pixel and no decoder unpacked that, so every CFA format resolved to a null decoder however well the capture side negotiated it. The 360 rig's two IMX676 are exactly this: no ISP touches the frames, so the mosaic arrives raw. Bilinear reconstruction, one pass, sampling the mosaic as a single-channel texture with nearest filtering -- linear would blend neighbouring colour sites together before the demosaic can separate them, the same hazard the byte-reassembling decoders already avoid. The CFA order is a parameter rather than a baked constant. A capture that crops to an odd origin flips the phase, and a wrong phase does not look broken: it looks like a colour cast, which is easy to mistake for white balance and chase in the wrong place. `sampleScale` covers a mosaic that does not fill its container: ten or twelve bits right-aligned in a 16-bit lane normalise to a fraction of full scale and need the same rescale Mono10 and Mono12 already carry. Black level, white balance and lens shading are deliberately not here. They are per-sensor corrections rather than part of turning a mosaic into RGB, and the frames will look milky until something applies them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
CaptureSyncGroup::publish() writes every member of a set and bumps the generation in one call, which fits a device that hands over all its sensors from one capture -- Argus' multi-sensor session, an SDI card with several inputs. Two CSI cameras are not that: separate file descriptors, separate threads, separate arrival times, and two threads calling publish() would interleave halves of different captures into one set. Nothing bridged that, so syncGroup() had no implementer and a rig of discrete cameras could not be frame-locked at all. Pairing is by arrival rather than by timestamp. The rig this exists for is frequency-locked, so consecutive arrivals correspond, and its eyes sit a constant offset apart that no matching removes. Timestamps ride along so the group reports the skew that happened rather than the one the hardware promised. A member that outruns its partners displaces its own previous offer and gets that slot straight back: holding it would starve the driver of buffers, which presents as a stall rather than as the drop it is. Only complete rows are published. Publishing partial ones does not keep the live members going, which is what it looks like it would do -- take() serves only the newest complete set, so a partial row advances the generation while the newest complete one stands still, and once that gap reaches the ring depth the last good set is condemned as lapped and every member goes dark. A stalled rig therefore holds, and shows up as displacedFrames() climbing at the frame rate. The test for this asserted the opposite until it was run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
A dma-buf imported on Tegra cannot be sampled as GL_TEXTURE_2D. Measured on
an Orin NX against a V4L2 export of the IMX676, with TegraDmaBufProbe added
here so it is re-runnable rather than a story:
R16 IMPORTED 3552x3556 TEXTURE_2D=err TEXTURE_EXTERNAL_OES=OK
R8 IMPORTED 7168x3556 TEXTURE_2D=err TEXTURE_EXTERNAL_OES=OK
ABGR IMPORTED 1776x3556 TEXTURE_2D=err TEXTURE_EXTERNAL_OES=OK
eglCreateImage accepts all three -- R16, R8, RG88 and GR32 are all in this
driver's importable list -- so the refusal is the bind target alone, which is
what the per-plane branch reports as "cannot sample fourcc ... as a 2D
texture" before declining the whole rung. The consequence was capture falling
back to staging 25 MB per frame out of uncached V4L2 pages, which does not
hold 30 fps at 3552x3556.
Two facts the probe settled, both load-bearing for the shader:
- the sample arrives in .r. An R16 external image reads back (v,0,0,1).
- the GPU sees what the CPU wrote: 1024 of 1024 sampled points matched the
CPU's view of the same pages, mean absolute error 0.50, which is the
8-bit quantisation of a 16-bit value and nothing else. Tegra does not
have the coherency gap that makes the desktop read zeros out of a
foreign dma-buf, so no flush and no gate are needed here. (The existing
NVIDIA gate is Vulkan-only and never applied to this path.)
texelFetch does not exist for samplerExternalOES, so the neighbourhood is
gathered by normalised coordinate with mat.texSz standing in for textureSize.
NEAREST is mandatory rather than preferred: LINEAR blends adjacent colour
sites before the demosaic can separate them.
toDrmFourcc had no Bayer row at all, so a mosaic -- byte-identical to the
greyscale of the same depth -- resolved to 0 and could not name itself to the
importer. Added in the to-DRM direction only: coming back, R8/R16 stay
Mono8/Mono16, since the fourcc cannot say which of the five enumerators
sharing it was meant.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
simple_texture_input_device carries one root node with one texture parameter,
and its make_child() returns {}, so a rig of N sensors could only be N
separate devices that a user has to keep configured consistently by hand --
matching resolutions, matching rates, and a member index typed twice.
This adds the multi-stream form: a root that holds no parameter of its own
and one child per stream, each child carrying the same texture parameter the
single-stream node has, bound to its own score::gfx::Node. A rig is then one
device addressed rig:/cam0, rig:/cam1.
Streams are added up front rather than created on demand, because each one
has to be handed the gfx node it renders; make_child() stays refused, which
is also what keeps a stray OSC address from conjuring a stream with no node
behind it.
This is the shape DMACaptureBackend::SyncMembership was written for -- "a
backend whose device drives several sensors from one capture returns the group
they all share, plus this stream's index in it" -- and the same structure the
per-sensor black level and white balance controls will hang off.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
The decoder has to be chosen before the ladder runs, because the strategy config points at its textures. A backend that asked for the whole-frame external image therefore already holds a decoder only that rung can feed, and when the rung declined, that decoder sampled a texture nothing ever uploaded to: a black frame from a path reporting itself engaged, on every host-staged fallback. The node now asks the backend whether its decoder depends on that rung, and if the ladder fell through to CPU staging, drops the request, remakes the decoder and repoints the strategy config at the new textures. An unnecessary copy is recoverable; a silently black fallback is not. Also here, two things that belong with it: deviceToJson() no longer takes the application down. A device tree can hold parameters the preset serializer cannot read -- a gfx device's texture parameters are the case that found this -- and it signals that by throwing, which nothing caught. Asking a graphics device for its tree from a script killed the process. It now reports which device and why, and returns nothing. X11Shot.cpp: the Jetson image ships no screenshot tool at all (xwd, import, scrot, xfce4-screenshooter, gnome-screenshot, ffmpeg all absent), so what score put on screen could not be checked -- and a rendering failure was inferred from a log line for hours as a direct result. XGetImage to a binary PPM, reading the visual's channel masks rather than assuming a byte order. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
The dlopen'd libv4l2 entry points lived as a private class inside CameraDevice.v4l2.cpp. The control tree needs the same three symbols, and a second copy of a dlopen singleton is how two copies drift apart -- the same argument that moved the V4L2 fourcc table into interop/V4L2PixelFormat, which these two files already share. Moved as-is except for one change: the constructor asserted every symbol resolved, which is a crash on a machine that has no libv4l2 and an assert away from being no check at all in release. It now reports `available()` so each caller can decide. Capture depends on the library's format emulation and declines without it; controls do not, and fall back to the raw syscall. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
Two unrelated things become settings under a video device: what the driver publishes -- discovered at runtime and different on every camera -- and what score itself offers, such as the Video process's scale mode or the demosaic's own corrections. They share no vocabulary, so this takes what they do have in common, a name and a type and a domain and something to do on write, and builds the nodes from that. Deliberately free of V4L2 so the score-side group is not obliged to describe itself as a fake driver control. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
A raw sensor frame is not a picture. The demosaic turns a mosaic into RGB and stops, deliberately, because everything after it is per-sensor -- the pedestal, the gains that make grey grey under this light, the curve that makes linear samples look right on a display. Until now there was nowhere to put any of it, so an IMX676 rendered green, dark and uncorrectable. Every correction is the identity by default, including the linear transfer curve. That curve is why a raw frame looks dark, and defaulting it to sRGB would have been the bigger improvement and the wrong call: it would silently restyle every existing project. It is offered, not imposed. The corrections live in the material block rather than baked into the shader, so moving a slider does not rebuild a pipeline. The block is a superset of VideoMaterialUBO with identical leading fields: a decoder whose shader declares only the short block reads the same buffer correctly, while extending the shared struct would have forced every renderer that allocates it -- both video paths included -- to grow in lockstep or bind one too small for its own shader. Both demosaicers share one copy of the maths, because a correction that differed between the host-staged and the external-image path would show up as the picture changing when the capture rung changed, which reads as a capture bug rather than a shader one. Scale mode arrives the same way: `mat.scale` already multiplies the quad in the vertex shader and the capture node simply never set it, so the frame was always drawn 1:1 regardless of the viewport. Publication is a generation counter, not a lock-free struct: the renderer pays one relaxed load per frame and only takes the lock when something moved. Reading the fields individually as atomics would let it see half of one setting and half of the next -- a visible colour flash while dragging two sliders. CaptureAdjustTest covers the maths against a CPU reference that is written as the specification the GLSL mirrors, the UBO offsets the shader reads through, the ordering that makes black level meaningful before gain, degenerate settings that must not produce NaN, and the slot under a concurrent writer. 944 assertions. It caught one real defect while being written: an unrecognised scale-mode name silently reset the fit to Original instead of leaving it alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
CaptureAdjustTest covers the reference and the UBO packing, which is everything except the part that draws. The two implementations are written to be the same maths, and "written to be" is the sort of claim that rots quietly: a divergence would surface as a colour shift on a camera, months later, and look like a capture bug. This runs the actual adjustCapture GLSL over 160 colours for each of eight settings and compares every channel against adjustCaptureReference. Measured on a desktop GL context: identity is bit-exact, and the worst delta anywhere else is 1/255 -- 8-bit quantisation, not arithmetic. Two things it deliberately does not do. It builds its QRhi directly instead of calling createRenderState, which reaches for score::AppContext() and so needs a whole application booted -- resources, settings, audio backend -- to compare two implementations of a fragment shader; the first attempt did that and died in resource loading. And it skips, exit 0, when no RHI can be created: a headless box with no GL cannot say anything about a shader, and reporting that as a failure would train people to ignore it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
take() serves only the newest complete set, so a capture published while the render thread was busy can never be chosen. Its slots were still lent to the group, and nothing released them: the producer lost a buffer per skipped capture until it had none left to lend and the rig stalled. Release them at pin time. They need no retirement delay, unlike the capture that was bound -- no member ever sampled them. That reads the skipped captures back out of the ring, so the ring now has to be deep enough to still hold them. Sized to the width of the return mask rather than to render latency: a member cannot lend a slot it has not got back, and there are at most that many slots, so an unreturned capture is always still resident however far behind the renderer falls. What cannot be recovered is counted rather than guessed at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
A backend offering a group cannot see the renderer decline it, and the renderer does decline: a rung that cannot bind a caller-chosen slot leaves the stream on the unsynchronised path. It has to know, because the two paths disagree about who owns a slot. Ungrouped, the strategy's publisher decides when one may go back to the device. Grouped, the group decides, and a backend still asking the publisher gives the device back the very frame the group has just bound -- silently, and only visible as a rare tear. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
The unit tests each cover one side of the handover, and the slot leak lived in between them: the correlator gave its offers back correctly, the group retired what it had bound correctly, and captures that fell between the two were held by neither. This drives the loop the V4L2 rig actually runs -- dequeue, offer, latch, requeue whatever comes back -- with the producers deliberately outrunning the renderer. Without the fix, member 0 runs out of buffers on the sixth pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
The generic sweeps hand every tester one fixed producer, but 35 of the .fs testers carry an explicit `Wire:` clause naming the chain they expect, and not one of the 40 .cs testers declares an OUTPUTS block -- they emit geometry or images, so an Image sink can never see what they wrote. Three harnesses cover what that leaves untested: - ShaderSweepCSFGeometry: the raster sweep inverted. The producer varies over a fixed rasterizer (raw-raster-basic, which the corpus itself names as the consumer), skipping any .cs without a geometry RESOURCE so the image half stays with the image sweep. - ShaderSweepWired: one hand-written fixture per constructible `Wire:` clause. - ShaderSweepScene: the threedim producers the scene testers need, built through oscr::GfxNode -- SceneFlattener is score::gfx::ScenePreprocessorNode, which was always constructible; what it lacked was an ossia::scene_spec source, and a Crousti-wrapped halp producer is exactly that. The fixture gains what these need: addNode() plus node port accessors for engine/Crousti nodes, bufferIn/bufferOut (there was no way to address a Types::Buffer port at all, which is why no uniform_input tester had ever been driven), a CableType on wire() with a wireFeedback() helper -- Graph.cpp's no_delay_edges filter keeps only the Immediate kinds, so an Immediate self-edge makes the graph cyclic -- and render() now pumps the Message so Crousti nodes see their controls. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cables csf-storage-rw.cs (a read_write storage RESOURCE, so a Types::Buffer output backed by a StorageBuffer) into each uniform_input consumer and asserts the graph builds, renders and reads back on every available backend. Covers all three consumers deliberately. isf-persistent-uniform-input and isf-multipass-uniform-input go through RenderedISFNode, which is what crashed; binding-uniform-input goes through SimpleRenderedISFNode, which never did, so a guard built only on it passes with or without the fix and guards nothing. Verified both directions: with the fix, 18 assertions pass on Vulkan and OpenGL; with the usage check disabled, both backends SIGSEGV. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three mapping tables plane/interop introduced or rewrote had no test between them, and this is the class of bug the branch's own history says is live: YVYU422 and VYUY422 were enumerated as supported and rendered nothing, and six planar layouts were refused at open() because only the encoder half of the table knew them. test_unit_wire_decoder_factory is the mirror of the WireEncoderFactory sweep in tests/integration/EncoderMatrixTest.cpp. It iterates allFormats() -- the declarative table the vocabulary is generated from -- rather than a second hand-written list of formats, so a format that gains or loses a decoder without the factory's claim moving is a failure that names the format. It also asserts that every encodable format is decodable apart from five named packed high-bit-depth RGB layouts, which is the assertion that would have caught the original 12-of-26 gap. test_unit_drm_fourcc covers DrmFourcc.hpp, which is a different header from the DrmPixelFormat.hpp that VideoPixelFormatTest sweeps -- the confusion is why this gap was invisible. Its three functions were rewritten from a hand-maintained table into delegations with nothing but a commit message asserting the answers had not moved, so the pins here are literal AVPixelFormat constants: routed through the vocabulary the assertions would agree with themselves no matter what it said. DRM_YVU420 answering AV_PIX_FMT_NONE and VideoPixelFormat::YVU420P is pinned explicitly, as is DRM_GR1616 == 'GR32'. test_unit_pipewire_formats sweeps every Tag through the ten inline mapping functions and cross-checks PipeWire's own tag -> fourcc table against the interop vocabulary. That cross-check is the one with teeth: the two tables already disagreed once, exporting RGB10A2 as the mirrored 'AB30', and nothing caught it because each was self-consistent on its own. frameBytes() is checked against av_image_get_buffer_size() and isPlanar() against av_pix_fmt_count_planes() for every tag with an AV equivalent. The round-trips that genuinely cannot close -- P210, which has no SPA enumerator, and YV12, which publishes as yuv420p -- are asserted as they are rather than excluded, so giving either a form of its own is a test failure and not a silent change. Covers 8fef549, c60f9a5, 80a9d8f, 7fa5882 and the mapping layer of f2096b1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
Four pieces of pure lifetime logic that no test reached: the slot handoff and borrowed-slot tracker in CaptureStrategyCommon.hpp, the format seqlock in VideoCaptureStrategy.hpp, PacedFramePump and HostFramePool. Nothing here needs a GPU, a card or an application, and every one of them is wrong in a way that does not crash -- a borrowed slot handed back one acquisition early is a frame the producer's device overwrites while the GPU is still sampling it, and a frame pointer that goes into the pump and never comes out is a pool slot that runs dry minutes later with nothing pointing at the cause. BorrowedSlotTracker is asserted on the half that matters: not that a slot eventually comes back, but that it does not come back at any of the retireDepth acquisitions before it is safe, checked at each one. The never-bound case (a slot displaced before the renderer ever polled) and the re-publish case (the same slot published twice, which is not a displacement) are asserted separately, since they differ by one comparison in ingest(). PacedFramePumpTest drives the vendor tick from the test rather than from a clock, so drain-to-newest, the timeout retry and the back-pressure wait are reached deterministically. Its central case counts pointers in and out: frames rejected at push, frames a newer one superseded and frames still queued at stop() are three different code paths, and the assertion is that their union is exactly what was pushed and their intersection is empty. HostFramePoolTest covers the contract the readback target relies on -- bytes == regionBase, granule alignment -- plus the two failure modes: an exhausted pool answering with an empty frame instead of allocating, and a pin that fails on the third frame unpinning exactly the two that succeeded. Covers the PacedFramePump and CaptureSlotPublisher parts of 0fddd0f, the format channel of ba72ce6, the discard hook of 5b1988c, b2d4e56, and 9d379a4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
Six commits on this branch each fixed one boolean or arithmetic decision in DmaBufImportCapture.hpp or NV12ExternalOES.cpp, and none of them left an assertion behind. Every one is silent when wrong: a derived chroma offset on an NvBufSurface points at the wrong address, a derived pitch on a padded producer shears the frame progressively rather than failing, a nominal-format pitch check rejects a buffer that was always fine, a zero plane count is refused before EGL is ever called, a driver-only Vulkan gate refuses the two allocators that measured byte-exact, and a reconstructed shader fails to compile at a token nobody is looking at. The five dma-buf decisions live inside init(), which wants a live QRhi, and read state that is private, so compiling the translation unit into the test -- what tests/gfx does for the engine sources it needs -- does not reach them. Each was lifted into a free function in the same header and init() now calls it: a mechanical extraction, one function per decision, no behaviour change. dmaBufVulkanRungRefused's driver id is still checked against VK_DRIVER_ID_NVIDIA_PROPRIETARY, now by static_assert. toExternalSamplerEssl needed no extraction -- it is already declared in the header and shared with BayerExternalOES -- but score_plugin_gfx is built with hidden visibility, so its TU is compiled into the test the way tests/gfx/CMakeLists.txt builds its engine glue lib. The assertions there are about what must SURVIVE the rewrite as much as what it inserts: the named uniform blocks, balanced braces, and every other line byte-identical. That is the shape of the bug it fixed, a reconstruction that sliced to the first `};` and cut the block name off. Covers 5201f7a, 8e9b3a1, 3f23bfa, ffb6ab6, 3e48fb9 and b52dc30. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
…0dccb) Both halves of the commit were unreachable from any test: they lived as private static members of `struct gstreamer_pipeline` inside GStreamerDevice.cpp. `tests/gfx/GfxGStreamerBusClassify.cpp` is about the output device's bus poller and shares no symbol with them. Mechanical extraction, no behaviour change: `AppsinkInfo`, the five ctre patterns, find_appsink_names / find_all_named_elements / last_int_of / classify_from_pipeline_string move verbatim to Gfx/GStreamer/GStreamerPipelineParse.hpp as free functions in the same namespace, and `AudioBuffer` to Gfx/GStreamer/GStreamerAudioBuffer.hpp. gstreamer_pipeline keeps member aliases so every existing use site is unchanged. Neither header needs a GStreamer library. GStreamerPipelineParseTest pins nearest-token-wins (the same pipeline with its audio and video branches swapped must give the same answers, following position rather than the order of the token arrays), the 640x480 RGBA fallback, the audio 2/48000 fallback, the `(int)`/`(string)` caps annotations, format= resolution through Video::gstreamerToLibav() and its case sensitivity, last-value-wins, and the parse_strict rejection that leaves the previous value on an int overflow. Two quirks are recorded because they are load-bearing and invisible: an unknown sink name scans the whole pipeline, and the unanchored `rate=` pattern matches inside a bare `framerate=30/1`. GStreamerAudioBufferTest pins the audio-thread invariant: after a per-tick resize every span must again point at its vector's storage with the new size, the reserved capacity must mean no reallocation, the clamp to max_block, both teardown states, ring ordering across a wrap, underrun silence and channel truncation. Negative controls: - dropping the position comparison in classify_from_pipeline_string turns "an appsink's media type follows the nearest preceding token" red (v.is_video false, width 0); - deleting the `if(resized && output_spans ...)` re-point turns five span assertions red across three cases (spans stuck at size 64). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
Covers 45d1202 (pland/scene). Before this file, `grep -rl 'SceneFilterNode\|FlattenedSceneFilterNode\|MergeGeometriesNode' tests/` was empty, while all three are user-facing processes at the top of the stack — tests/nodes/Processes.cpp round-trips their MODELS and nothing exercised the filtering, the merging or the predicate. The chain is a CSF geometry producer rather than a Threedim cube through ScenePreprocessorNode: the predicate reads geometry_spec metadata, which any geometry producer supplies, so no score_plugin_threedim, no document and no preprocessor are needed. A CSF producer emits filter_tag == 0 and filter_material_index == 0 — the same untagged values the preprocessor stamps — which makes the readback decisive: "equals 0" draws, "differs from 0" draws nothing. Each predicate pair is asserted in both directions with two different match values, so a predicate stuck on true or on false cannot pass. The empty match_str short-circuit gets its own case with the fact it depends on stated first: rapidhash of the empty string is non-zero, so hashing it would match nothing, and 0u is what an untagged producer emits. SceneFilterNode's tree-rewriting visitor lives in an anonymous namespace and takes a scene_spec, whose only sources are halp producers reached through the Crousti wrapper. Its port surface and control routing are asserted; the share-copy identity contract is left uncovered and the file says so. Negative controls, run under ctest with a real OpenGL backend: - `case 1: return true` in the predicate -> CHECK( dropped.drawn == 0 ) with expansion: 9 == 0 - dropping the empty-match_str short-circuit -> CHECK( untagged.drawn > 0 ) with expansion: 0 > 0 - scanning only port 0 in MergeGeometriesNode -> CHECK( onThree.drawn > 0 ) with expansion: 0 > 0 CHECK( onThree.drawn == onZero.drawn ) with expansion: 0 == 9
Covers 5ed56c9 (pland/scene). tests/gfx/GfxDynamicSlot.cpp calls sweepMeshSlabs() for its dynamic-texture side effect but never acquireMeshSlab(), so the whole slab path was inert during the one test that looked related, and GpuResourceRegistry's allocate / free / isLive / slotOffset / seedDefaults surface had no test at all. Two mechanisms are load-bearing and both fail silently: - the ABA generation guard on gpu_slot_ref. Producers stamp a slot ref on their component and the preprocessor validates it with isLive() before reading the arena. The freed index comes straight back off the free-list, so only the generation distinguishes a stale ref from the new occupant. - the mesh-slab grace queue. A slab re-acquired with different counts is enqueued rather than freed, because an indirect_draw_cmds entry issued last frame may still reference its byte offset. The enqueue must stamp released_frame = current_frame; stamping 0 collapses the guard to "wait grace frames after boot". The grace assertion is on the re-acquire, not on the sweeps: acquireMeshSlab drains the queue BEFORE its fresh allocate, so a release stamped 0 is already expired by then and its space gets recycled into the very allocation that displaced it. The allocator then gives back only the size difference. That is what the freeAfterFirst - freeAfterRealloc >= 200 * 16 check measures; the two sweep checks alone pass either way, which is worth knowing. Negative controls, run under ctest with a real OpenGL backend: - dropping ++slot_generations in free() -> CHECK( out.deadAfterFree ) with expansion: false - stamping pr.released_frame = 0 in the count-mismatch enqueue -> CHECK( out.freeAfterFirst - out.freeAfterRealloc >= 200u * 16u ) with expansion: 1600 (0x640) >= 3200 (0xc80) - disabling the age test in sweepMeshSlabs -> CHECK( out.evictedWhenUnseen ) with expansion: false
…9ca948, 0b6f3a3) The other half of the frame-stepping work: test/infra registers the shell harness, which needs the out-of-repo tests-scene corpus and therefore skips on most machines. This asserts the same property on tests/gfx/corpus, which first exists on this branch, so it runs anywhere with a display. It drives the application binary through QProcess because the mechanism only exists inside a real gfx document, and uses separate processes because anything cached in one would hide exactly the nondeterminism it looks for. Every run is checked for the "capturing the SCREEN" and "nothing rendered into" warnings and for a frame with more than one distinct colour before any comparison is made: two blank frames match trivially, and a screen grab of a quiet desktop is byte-stable. Five properties: two processes agree; frame 30 and frame 60 differ; ten renderFrames(1) land on the same frame as one renderFrames(10); renderFrames(0) and renderFrames(-1) change nothing; and setStepRate(30) moves the picture while setStepRate(60) -- the default, stated explicitly -- reproduces it exactly. That last pair replaces the proposal that setStepRate(30)+grabFrame(30) should equal the default-rate grabFrame(60): it must not, because at 30 fps the last frame's date is 29/30 s with TIMEDELTA 1/30 and at 60 fps it is 59/60 s with 1/60, and the shader draws both. setStepRate had zero callers anywhere in the tree, tests included. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
toExternalSamplerEssl anchors the GL_OES_EGL_image_external pragma to the line
after #version, and located that line with
glsl.indexOf('\n', glsl.indexOf("#version"))
which is wrong when the input carries no #version at all: the inner call yields
-1, and QByteArray::indexOf with a negative 'from' searches backwards from the
end, so the guard sees a valid index and lets the rewrite proceed. The pragma
then lands after whatever the last newline happened to be.
Cover the four shapes the guard has to separate: baked source, source with
newlines but no #version, empty, and a single line with no newline. Only the
second one actually regressed, which is why all four are pinned rather than the
obvious case alone.
An ISF that declares more than one OUTPUT does not draw into the
downstream render target the way a single-output ISF does. It draws into
its own multi-attachment target and SimpleRenderedISFNode::initMRTBlitPass
then copies the selected attachment across. That copy was not
orientation-neutral on OpenGL, so every MRT shader came out vertically
mirrored there while Vulkan was right.
Measured, not inferred. The cross-backend golden-render references show
exactly four divergent cases out of fifteen, all of them pure flips
(bit-identical after -flip); two are MRT. Within one backend the MRT frame
disagrees with the single-output frame on OpenGL and agrees on Vulkan, which
is what makes OpenGL the deviant rather than a matter of which grab path is
used. Vulkan also matches the convention every other case exhibits on both
backends: isf_FragNormCoord.y == 1 is the top row.
blit_vs negates gl_Position.y on HLSL/MSL only, and the ISF vertex stage it
has to stay in step with (libisf isf.cpp:50) negates on SPIRV as well. The
two obvious ways to close that gap were both tried and both rejected against
the tests here:
* adding QSHADER_SPIRV to blit_vs inverts Vulkan, which was correct
(MRT row 0 green 253 -> 2);
* giving the blit a flipped-texcoord mesh, the way
RenderedRawRasterPipelineNode.cpp:2111 does, is backend-independent and
inverts Vulkan too.
The defect is specific to the backend whose framebuffer is Y-up, so the
correction is conditioned on that and nothing else changes.
The compute storage-image half of the same family is NOT fixed here. A CSF
that only imageStore()s is still flipped on OpenGL; the one-line blit flip
that fixes it silently breaks CSFs that sample an upstream texture and relay
it, which are correct today because the read and the store cancel. Both
halves are pinned: the relay guard sits in the green group, the generator
sits in an isolated expected-RED target with the analysis.
tests: test_gfx_orientation asserts every row against the shader's
closed-form ramp on each backend, so a flip cannot pass the way it does a
corner probe or a luma check. Before this change, on OpenGL:
MRT ramp row 0 G=2 ... row 56 G=225, expected 253 ... 30
MRT vs single max channel diff 235
single-output row 0 G=253 ... row 56 G=30 (already correct)
after it, green on OpenGL and Vulkan alike, with the rest of tests/gfx
unchanged on both (test_gfx_multiview was already red on both backends
before this commit, at raw-raster program compilation).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
PipewireRoundtrip_s2s died with SIGSEGV inside pw_stream_destroy:
#4 pw_loop_check libpipewire-0.3
#5 pw_stream_destroy libpipewire-0.3
#6 PipewireProducer::stop() PipewireOutputDevice.cpp
#7 ~PipewireProducer()
#11 PipewireOutputNode::destroyOutput()
#12 score::gfx::Graph::~Graph()
The stream itself was fine; the loop it was built on was not. Breaking on
pw_thread_loop_destroy shows who freed it:
#0 pw_thread_loop_destroy
#1 libremidi::pipewire::context::tear_down(bool)
#2 libremidi::pipewire::context::reconnect()
#3 Gfx::PipeWire::InputStream::start()
Both PipeWire devices join the process-wide libremidi::pipewire::context and,
when they find it in connection_state::broken, call reconnect() on it.
reconnect() runs tear_down(), which does pw_core_disconnect, pw_context_destroy
and pw_thread_loop_destroy, then builds new ones. It notifies nobody. Every
pw_stream another holder created on that core keeps pointing at the freed loop,
so the producer's next pw_stream_dequeue_buffer or pw_stream_destroy faults.
The connection is flagged broken by on_core_error, which maps any -ENOENT reply
to a dead socket. The daemon sends exactly that for a per-object failure — a
stream whose autoconnect finds no target node yet, which is the normal state of
the s2s producer before its consumer starts. So the output device reliably
poisons the flag and the input device reliably tears the loop out from under it.
reconnect() is only safe for the sole strong holder of the context. Route both
acquisitions through acquireSharedContext(), which reconnects only at
use_count() == 1 and otherwise keeps the existing connection, saying so. A
connection several clients are actively streaming on is by definition still
serving them; a genuine outage still surfaces through the stream state machine.
Measured on the reproducer: 3/3 runs SIGSEGV/SIGABRT before, 3/3 exit 0 after,
with the full 14-cell s2s matrix completing. The pw2s input matrix stays at
14/14 PASS.
The same reconnect-under-peers pattern is still present in
score-plugin-audio/Audio/PipeWireInterface.cpp (make_engine,
setupSettingsWidget); it is not reachable from this test and is left alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
…urn code
An RDMA output rung engaged on evidence that cannot distinguish a working
card<->GPU P2P link from a silently dropped one. RdmaVideoOutput::init()
called the vendor's `verifyTransfer` hook only when the adapter happened to
supply one, and believed its boolean. A peer-to-peer read that the IOMMU or a
foreign host bridge discards still returns success from every SDK call, so the
rung engaged, the card played out whatever its framestore already held, and the
pump -- which only ever counts the frames it was handed -- reported a full frame
count at zero drops. A constant picture is the one outcome the counters cannot
see.
The capture direction has had a content-verified probe since
`ajaCaptureRdmaDelivers` (seed the pinned buffer, run a real transfer, check the
bytes changed). This is its playout counterpart, in score rather than in an
addon so every vendor inherits the same assertion:
- `VendorDmaRegistrar::readbackTransfer` reads back, into host memory, the
bytes `verifyTransfer` just pushed into the peer's scratch buffer.
- RdmaPlayoutProbe seeds the pinned buffer with a deterministic pattern that
is neither constant nor all-zero -- so a stale framestore, a zero-filled
scratch and a 0xFF one all mismatch -- and compares the readback 1:1.
- A vendor that supplies no `verifyTransfer` at all no longer gets the rung.
Of the two RdmaVideoOutput users, AjaRdmaOutputGL supplies one (and now
reports itself as unverified until it adopts the readback hook);
AjaRdmaOutputD3D11 supplies none, and its rung is refused rather than
engaged on a Windows `inRDMA` that AJA does not implement.
The GPU seeding is injected so the whole decision table runs with no card and
no CUDA driver: the accompanying test drives a fake peer that returns the bytes
it was given, one that returns a constant, one that returns nothing, and one
that offers no probe. Against the previous return-code-only semantics those
cases fail 14 assertions; against this change they pass.
`prepareNextFrame()` had the same shape of silence: two of its three failure
paths returned nullptr with no log, and DirectVideoOutputNode drops a null
without counting it -- another way for the picture to freeze at a clean drop
count. Those frames are now counted and reported.
Measured on ai-workstation-01 (2x Kona 5, Quadro RTX 4000 + RTX 4090): with the
Quadro pinned for CUDA and GL, AJARoundtrip 1080p60a YCbCr8 over RDMA-GL/T3
transmits real content -- 52.26 dB, 52193 distinct colours in the received frame
against 57121 in the reference -- and a standalone playout probe confirms the
card reads the pinned CUDA buffer byte-for-byte on both boards.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
The Sh4lt and Shmdata inputs read the producer's caps string and look the format up in Video::gstreamerToLibav() after upper-casing it. Four of that table's keys are mixed case -- "RGBx", "BGRx", "xRGB" and "xBGR", all with a lowercase x -- so caps saying format=(string)RGBx became "RGBX", matched nothing, and the device logged "unhandled format" and dropped a stream it supports. GStreamerDevice.cpp does the same lookup with the unmodified string and is unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
… leaves in notifyFormatChange() commits pixel_format = RGBA whenever the source format needs converting, because RGBA is what the renderer will be handed. on_data() then labelled the INCOMING buffer with that same pixel_format before laying it out, so a Y41B 640x480 segment of 460800 bytes was read as RGBA and initFrameFromRawData() gave it a 4*width stride -- 1228800 bytes, three quarters of a megabyte past the end of the shared mapping. Rescale::rescale() then saw a source format equal to its output format and rebuilt the sws context as RGBA->RGBA, so nothing was converted either. The source format now travels alongside, and the incoming frame is described with it. The same block freed its own frame and enqueued Rescale::rescale()'s output unconditionally. On the path where sws_getContext cannot be rebuilt, rescale() leaves read.frame pointing at its INPUT -- the frame that was just freed -- so the render thread dequeued a dangling pointer. It is only enqueued now when it is a different frame. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
…eader
Two CI failures that both start at this branch, fixed where the code they
touch was introduced rather than at the tip of the stack.
macOS (Dev/Brew/AppleSilicon, Release/Brew/AppleSilicon, Release/Brew/Intel):
qvulkanfunctions.h:20:10: fatal error: 'vulkan/vulkan.h' file not found
In file included from interop/VkHostImportUpload.cpp:8:
In file included from QtGui.framework/Headers/QVulkanFunctions:1
Homebrew's Qt reports QT_CONFIG(vulkan) as available while the runner has
no Vulkan SDK headers, so the bare config check is not a sufficient guard.
Every sibling in this plug-in already knows that and spells it
`QT_HAS_VULKAN || (QT_CONFIG(vulkan) && __has_include(<vulkan/vulkan.h>))`
-- RhiComputeBarrier, RhiTextureReadback, RhiClearBuffer, VulkanCudaBounce.
VkHostImportUpload was the one file left on the bare form. It surfaces only
in a unity build, where its includes leak into the TU it is concatenated
into, which is why a non-unity dev tree never showed it. The file already
has a complete non-Vulkan #else path (stubs returning false), so compiling
it out is safe.
Windows/Linux/macOS, every backend: "a reader never sees half of two
geometries" failed on `CHECK(reads.load() > 0)` in 0.03s. std::thread's
constructor does not promise the body has started, so when thread start-up
is slower than the writer's 100000 publishes the writer finished and set
stop before the reader ever entered its loop -- reads == 0, with nothing
actually wrong. Wait for the reader to complete one iteration before racing
it. The tearing check the test exists for is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The GPU video interop layer from #2109, on top of the scene rework (#2120). These commits are already granular and land cleanly on the scene state. With this PR, the score-plugin-gfx tree is byte-identical to #2109's, rebased onto current master.
Stacked on #2120; retargets to master as the stack merges.
Commits
Validation
ctest: 17/17.🤖 Generated with Claude Code