Skip to content

gfx: standalone fixes + the video pixel-format vocabulary - #2160

Open
jcelerier wants to merge 20 commits into
test/infrafrom
split/gfx-fixes-and-formats
Open

gfx: standalone fixes + the video pixel-format vocabulary#2160
jcelerier wants to merge 20 commits into
test/infrafrom
split/gfx-fixes-and-formats

Conversation

@jcelerier

Copy link
Copy Markdown
Member

Six self-contained changes lifted out of the plane/interop stack (#2121) so they
can land without waiting for it. Each applies to current master with no conflicts
and no dependency on the rest of the stack — verified by cherry-picking them onto
master individually and as a sequence.

Fixes

  • prefer EGL on xcb and force a desktop OpenGL context — EGL is what makes
    dma-buf import zero-copy. Sets QT_XCB_GL_INTEGRATION only when the user has not
    already chosen, so an explicit override sticks.
  • do not return a Vulkan instance when creation failed — the factory returned a
    half-built instance on failure instead of nothing.
  • don't create a Vulkan QRhi without an instance — the render-state path did not
    degrade when the backend was unavailable, it crashed. Turns a segfault under a
    headless/offscreen QPA into a clean skip.
  • gstreamer: classify appsinks from the pipeline string, follow the engine
    quantum
    — appsink classification was guessed rather than read from the pipeline.
  • export ISFNode — it was not exported, so out-of-plugin users could not link it.

Video pixel format vocabulary

A vendor-neutral VideoPixelFormat enum plus a libav bridge
(VideoPixelFormatAV). All new files; adds itself to CMakeLists.txt. This is the
common vocabulary the capture/encode paths in the stack are written against, and it
is useful on its own — it is what lets a wire format be named once rather than
re-derived per vendor.

Validation

  • Cherry-picks cleanly onto master (both individually and as a sequence).
  • The two new translation units syntax-check against master's headers, with the
    real compile flags from compile_commands.json (PCH stripped, include paths
    retargeted).
  • Not built end-to-end locally against master — that is what CI is for here.

The encoders and decoders were deliberately not included: they depend on
pland's render-pipeline rework and on plane's texture-interop layer, so they
cannot be split off without those. They stay in the stack's bottom-up order.

🤖 Generated with Claude Code

https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE

@coveralls

coveralls commented Jul 28, 2026

Copy link
Copy Markdown

Coverage Status

Coverage is 16.424%split/gfx-fixes-and-formats into test/infra. No base build found for test/infra.

@jcelerier
jcelerier force-pushed the split/gfx-fixes-and-formats branch from ecfeb84 to 04a2068 Compare July 31, 2026 00:17
@jcelerier
jcelerier force-pushed the split/gfx-fixes-and-formats branch 3 times, most recently from 1675076 to b69fa53 Compare August 16, 2026 03:42
@jcelerier
jcelerier changed the base branch from master to test/infra August 16, 2026 03:43
jcelerier added a commit that referenced this pull request Aug 16, 2026
staticVulkanInstance() could hand back an instance whose create() had failed;
return null instead so callers fall back cleanly rather than crashing.

Moved down from split/gfx-fixes-and-formats (35eb25e, itself folding
6b0b345 and 25d183b) so the Vulkan hardening sits once, at the bottom of
the stack, where headless configurations reach it. #2160 keeps the identical
patch; rebasing it onto this branch drops the duplicate by patch-id.

Note this covers create() failing, not the probe above it: supportedApiVersion()
is called eighteen lines earlier and still crashes under a platform with no
Vulkan support. That is a separate defect, unfixed here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jcelerier
jcelerier force-pushed the split/gfx-fixes-and-formats branch from b69fa53 to 6aa4b39 Compare August 16, 2026 13:56
jcelerier added a commit that referenced this pull request Aug 16, 2026
staticVulkanInstance() could hand back an instance whose create() had failed;
return null instead so callers fall back cleanly rather than crashing.

Moved down from split/gfx-fixes-and-formats (35eb25e, itself folding
6b0b345 and 25d183b) so the Vulkan hardening sits once, at the bottom of
the stack, where headless configurations reach it. #2160 keeps the identical
patch; rebasing it onto this branch drops the duplicate by patch-id.

Note this covers create() failing, not the probe above it: supportedApiVersion()
is called eighteen lines earlier and still crashes under a platform with no
Vulkan support. That is a separate defect, unfixed here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jcelerier
jcelerier force-pushed the split/gfx-fixes-and-formats branch from 6aa4b39 to cc756f0 Compare August 16, 2026 16:33
jcelerier added a commit that referenced this pull request Aug 16, 2026
staticVulkanInstance() could hand back an instance whose create() had failed;
return null instead so callers fall back cleanly rather than crashing.

Moved down from split/gfx-fixes-and-formats (35eb25e, itself folding
6b0b345 and 25d183b) so the Vulkan hardening sits once, at the bottom of
the stack, where headless configurations reach it. #2160 keeps the identical
patch; rebasing it onto this branch drops the duplicate by patch-id.

Note this covers create() failing, not the probe above it: supportedApiVersion()
is called eighteen lines earlier and still crashes under a platform with no
Vulkan support. That is a separate defect, unfixed here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jcelerier
jcelerier force-pushed the split/gfx-fixes-and-formats branch from cc756f0 to 93801c4 Compare August 16, 2026 18:08
jcelerier added a commit that referenced this pull request Aug 16, 2026
staticVulkanInstance() could hand back an instance whose create() had failed;
return null instead so callers fall back cleanly rather than crashing.

Moved down from split/gfx-fixes-and-formats (35eb25e, itself folding
6b0b345 and 25d183b) so the Vulkan hardening sits once, at the bottom of
the stack, where headless configurations reach it. #2160 keeps the identical
patch; rebasing it onto this branch drops the duplicate by patch-id.

Note this covers create() failing, not the probe above it: supportedApiVersion()
is called eighteen lines earlier and still crashes under a platform with no
Vulkan support. That is a separate defect, unfixed here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jcelerier
jcelerier force-pushed the split/gfx-fixes-and-formats branch 3 times, most recently from 2f72efa to c6b09de Compare August 17, 2026 02:51
@jcelerier
jcelerier force-pushed the split/gfx-fixes-and-formats branch 2 times, most recently from 5552446 to 6c3686a Compare August 17, 2026 13:51
jcelerier and others added 5 commits August 17, 2026 14:31
Request the xcb EGL integration on X11 so that dma-buf import can stay
zero-copy, and pin a desktop-GL (not GLES) core-profile context with a
stencil buffer on the desktop code path.

(cherry picked from commit 38cd38d)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
The plugin builds with -fvisibility=hidden, so an un-exported class has no
externally linkable constructor. DecklinkRoundtrip uses ISFNode for its GPU
test-pattern path and failed to link against it; every other node the
harnesses construct already carries the export.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
A backend-neutral pixel-format description plus conversions to and from
ffmpeg's AVPixelFormat, shared by the video encoders, decoders and the
GPU interop strategies.

(cherry picked from commit c835069)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
The enum, the descriptor table and the name lookup were three hand-maintained
lists of the same thing, so they could disagree - and did: a format could be
declared in the enum with no descriptor at all, leaving formatInfo() returning
the "unknown" sentinel and bytesPerFrame() returning 0 for it.

Generate all of them from a single X-macro table instead. Adding a row adds the
enum value, the descriptor and the entry allFormats() exposes at once, so
declaring a format without describing it is no longer expressible.

Two modelling changes come with it:

- Sizing is block-based (blockPixels/blockBytes) rather than an averaged
  bits-per-pixel. The average could not express v210 at all - 128 bytes per 48
  pixels is 21.33 bits, which was truncated into a uint8_t and then special
  cased in the stride function - and it over-computed planar strides, which is
  why a separate bytesPerPrimarySample field had to exist alongside it. One
  block spec covers packed, semi-planar and planar uniformly, and v210 s SMPTE
  stride now falls out of the general expression.

- bool isYuv becomes a ColorModel enum, because greyscale and Bayer are neither
  YUV nor RGB, and both are produced by the industrial-camera paths. hasAlpha
  and byteOrder become queryable instead of living in prose: "forced opaque" and
  the endianness of the 10/12-bit layouts were only recorded in comments.

defaultStrideAlignment is renamed preferredStrideAlignment and documented as a
fallback for callers with no constraint of their own: alignment is a property of
a device or allocator, not of a pixel format. alignedRowBytes() is the form
device paths should use, and rowBytes() gives the unpadded row.

Verified size-identical: a dump of stride at 64/720/1920/1921/3840/7680 and
frame size at 720p/1080p for all 40 formats is byte-for-byte unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
jcelerier and others added 15 commits August 17, 2026 14:31
The GPU-interop branch added 29 enum values for the V4L2 camera layouts (the
X-padded RGB variants, NV21/NV16/NV61/NV24/NV42, the packed 4:4:4 YUV orders,
the sub-byte RGB/YUV group, YVU420P, YUV422P12, Mono16BE) but only one
descriptor, so formatInfo() answered "unknown" for the other 28. rowBytes() and
bytesPerFrame() therefore returned 0 for formats the V4L2 capture path actively
produces -- 16 of the 28 are reachable from it today. Nothing crashed only
because the single consumer, HostPinnedRing, prefers the stride the device
reports and falls back to the computed one; a caller that omitted the stride
would have allocated a zero-byte ring.

Since a format can no longer be declared without being described, the values
move up here where the vocabulary lives, each with its block geometry, colour
model, alpha and byte order.

Also map to AVPixelFormat the ones FFmpeg can express exactly, taking the number
of twins from 26 to 42: the 0-prefixed/suffixed X-variants,
NV21/NV16/NV24/NV42, VUYA/VUYX, the RGB565/555/444 pairs, GRAY16BE and
yuv422p12le. The rest stay deliberately unmapped, each with its reason recorded,
and two of those reasons matter:

- YVU420P has no plane-swapped twin. FFmpeg spells YV12 as yuv420p with the U
  and V pointers exchanged, so returning yuv420p here would silently swap
  chroma, which is precisely the bug this bridge exists to prevent.
- AV_PIX_FMT_RGB8/BGR8 are 2:3:3, not the 3:3:2 that V4L2 calls RGB332.

The switch is now exhaustive over the enum, so -Wswitch makes a new format state
its FFmpeg relationship rather than silently defaulting to none.

Verified: 69 formats, no descriptor holes, no zero-size results, 42 AV twins
with no asymmetric round-trip, and plane count, subsampling, planarity, alpha
and RGB-ness all agree with the FFmpeg descriptor for every twin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
The GStreamer format map had A444_10BE and A444_10LE both resolving to
AV_PIX_FMT_YUVA444P10BE, so a little-endian 10-bit 4:4:4-with-alpha stream was
decoded as big-endian: every 10-bit sample byte-swapped. This reaches the
GStreamer, Sh4lt and Shmdata inputs, which all resolve caps through this map.

An audit of the whole table for key/value endianness agreement found this as the
only live mismatch; the GBR/GBRA rows that also look inconsistent are commented
out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
…he table

The suite lived on the GPU-interop branch, three PRs downstream of the header it
guards, so the PR that introduces a format never ran it. Move it here.

It also could not have caught the descriptor holes it was written to prevent: it
kept its own 41-entry allKnownFormats[] list and asserted that each entry had a
descriptor, which is a tautology with respect to the table. A format declared in
the enum but present in neither list -- the state 28 formats were in -- was
invisible to it.

Every sweep now iterates allFormats(), the same table the vocabulary is
generated from, so a format is covered the moment it is declared.

New invariants beyond the previous coverage:
  - values as well as names are unique, and none reuses the 0 sentinel
  - colour-model classification: exactly one of RGB / YUV / achromatic holds,
    and neither RGB nor greyscale claims chroma subsampling
  - alignedRowBytes pads without ever losing data and never wastes a whole
    alignment unit, for 13 widths x 6 alignments x every format
  - rowBytes is exactly the block count and is monotonic in width
  - byte order is declared wherever a sample spans more than one byte
  - the FFmpeg cross-check now also compares alpha, RGB-ness and endianness,
    which the old bool isYuv/bitsPerPixel model could not express

Verified by injection: wrongly setting YUV420P to 2 planes, and dropping a
blockBytes to 0, each fail the suite; the correct table passes 19078 assertions
across 17 cases.

One assertion from the old suite was itself wrong and is corrected here: 10-bit
planar is exactly double its 8-bit twin only for tight rows. Alignment padding
is not linear in the sample size -- at 1920 the 8-bit luma row pads 1920 -> 2048
while the 10-bit row is already 256-aligned at 3840 -- so the padded sizes are
merely ordered, and the doubling is asserted on tight rows instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
Auditing the three camera backends against the vocabulary turned up formats none
of them could describe:

  V4L2    YUV411P, YUV410P, YVU410
  Win32   YV16 (YVU422P), Y210, Y216, P216, Y410 (XV30), Y416 (AYUV64),
          Y41P (UYYVYY411)
  AVF     422YpCbCr16 (YUV422P16), 4444YpCbCrA8 (YUVA444P)

Add all thirteen, plus P416 to complete the semi-planar 16-bit set. Ten have an
exact AVPixelFormat and are bridged; three do not and say why:

  YVU410P, YVU422P  plane-swapped, and FFmpeg expresses those by exchanging the
                    U and V pointers rather than with a distinct pixel format
  Y216              no AV_PIX_FMT_Y216 exists in any libav we support

YUVA444P is the first four-plane format in the table, which exercises the alpha
plane arm of bytesPerFrame that until now had no format reaching it. YUV410P is
the first with vertical subsampling 4; the suite asserted 1 or 2, so it would
have rejected a correct 4:1:0 descriptor.

XV30 deliberately reports no alpha: its top two bits are padding, not an alpha
channel, and FFmpeg agrees - the cross-check against AV_PIX_FMT_FLAG_ALPHA
passes only because of that distinction.

82 formats, no descriptor holes, no zero-size results, 52 AV twins with no
asymmetric round-trip, and no disagreement with the FFmpeg descriptor for any
twin. 22670 assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
BayerRG8/BayerRG12 named a generic mosaic, but the CFA order decides which
sensor cell a demosaic reads as red: interpreting BGGR data as RGGB swaps red
and blue. V4L2 exposes the four 8-bit orders as distinct fourccs and FFmpeg has
a pixel format for each, so a single generic Bayer format cannot describe what
the camera is actually sending.

Add the four 8-bit orders plus the two 16-bit ones FFmpeg carries, all bridged.
BayerRG8/BayerRG12 stay for the Ximea path, which reports its order out of band.

The FFmpeg cross-check needed a real relationship rather than an equality here:
FFmpeg sets AV_PIX_FMT_FLAG_RGB on Bayer, because a mosaic is made of R/G/B
filters, while we keep Bayer as its own colour model because a demosaic has to
run before the samples are RGB in any usable sense. The suite now asserts
"RGB-like", which is what the flag actually means, instead of silently disagreeing.

88 formats, 58 AV twins, no descriptor holes, no asymmetric round-trip, no
disagreement with the FFmpeg descriptor. 24366 assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
The V4L2 fourcc mapping existed twice: a table vendored from libavdevice in the
camera enumeration, and a second one in the DMA capture backend. They had
already drifted -- the deprecated V4L2_PIX_FMT_RGB32 resolved to XRGB in one and
RGBX in the other, which is a red/blue swap for whichever caller was wrong.

Put the layouts in interop/V4L2PixelFormat, shared by both, and derive the
AVPixelFormat through the existing bridge rather than maintaining a second
opinion about it. The deprecated RGB32/BGR32 pair resolves the way libavdevice
has long assumed, which is also what the kernel documents.

The compressed fourccs stay in the camera enumeration as a five-entry table,
because MJPEG, H.264, MPEG-4 and CPIA are codecs, not pixel formats. Mixing the
two axes in one table is what made the old one describe a raw layout and a
compressed stream with the same row shape.

Also adds chromaSwappedTwin(). The V-before-U layouts (YV12, YV16, YVU9) have no
AVPixelFormat of their own, so before this the enumeration named their U-first
twin and forgot, which silently exchanges red and blue. Naming the twin
*and* recording the relationship is strictly more information: the enumeration
keeps offering those cameras exactly as before, and a consumer that wants
correct chroma now has something to ask.

Verified non-regressing by construction: all 28 raw entries of the vendored
table produce the same AVPixelFormat through the new path, checked
programmatically rather than by reading. New guardrails assert that every
V4L2-mapped format round-trips, that compressed fourccs resolve to no layout,
and that a swapped layout and its twin agree on every geometric field and on
frame size. 24477 assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
bytesPerFrame computed the chroma plane dimensions as width / hsub and
height / vsub. A 4:2:0 frame of 1081 rows has 541 chroma rows, not 540, so the
reported size was short by a whole chroma row:

  YUV420P 1920x1081   3319808 reported   3321856 needed   -2048 B
  NV12    1920x1081                                       -2048 B
  P010    1920x1081                                       -3840 B
  YUV410P 1920x1081   270 chroma rows instead of 271

A caller sizing a buffer from this and then letting a capture card or decoder
write the ceiling row count overflows the allocation. Stride padding hides the
analogous odd-*width* truncation, but nothing hides a missing row.

This predates the vocabulary rework -- the original carried the same expression,
which is why comparing the rewrite against a captured baseline could not see it:
both sides truncated identically. It took mutation testing the suite to surface
it, and the reason the suite could not was that bytesPerFrame was only ever
asserted at 1920x1080, where both dimensions divide evenly. The odd widths in
the width sweep were used by rowBytes only.

The new case sweeps every format at odd widths and heights, and computes the
expectation with ceiling arithmetic rather than restating the implementation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
An adversarial audit of every table row against the vendor specifications found
two rows whose block geometry produced row sizes the hardware does not accept.

r210 pads to a 64-pixel, 256-byte row, and that padding is part of the format
rather than a preference -- DeckLink documents rowBytes as
((width + 63) / 64) * 256. It was described as 1 pixel per 4 bytes with a
preferred 256-byte alignment, so the tight rowBytes() was 7684 at width 1921,
which is not a legal r210 row. Putting the 64/256 group in the block, exactly as
v210 already does with its 48/128 group, makes every row legal by construction
and leaves aligned widths unchanged.

R12B and R12L claimed 8 pixels per 36 bytes. 36 bits per pixel is exactly 2
pixels per 9 bytes, which is both the finer correct granularity and what the
vendor formula (width * 36) / 8 implies; the coarser block over-reported by 27
bytes at width 1922. RGB12P, the same 36-bit packing, was already 2/9 in the
same table -- so one layout had two different geometries in one file. The suite
had frozen both answers, which is why it agreed with each.

RGB12P also declared ByteOrder::NA, in violation of the rule stated a few lines
above it: 12-bit components straddle byte boundaries, and R12B/R12L are
identical rows differing only in that column, so a consumer handed RGB12P could
not tell which packing it held.

Corrected alongside: the header called 10-bit ARGB "A2-ARGB10", which describes
a 4-byte A2R10G10B10 and would have justified "fixing" the correct 5-byte row.
AJA packs it as four 10-bit components including a real 10-bit alpha. The
plane-count and subsampling field comments were stale (a four-plane format and
subsampling of 4 both exist now), and BayerRG8/BayerRG12 are now documented as
the PFNC spellings of the RGGB order, deliberately left unbridged so that no two
enumerators claim one AVPixelFormat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
Mutation-testing the suite with 33 injected defects caught 12 and missed 21. The
headline miss: both bridges were verified for *symmetry* and never for
*correctness*. Swapping two formats of identical geometry in both directions --
BGRA8 with RGBA8, NV12 with NV21 -- satisfies the round-trip tests, because those
only compose the two functions, and satisfies the FFmpeg descriptor cross-check
too, since such pairs agree on plane count, subsampling, planarity, alpha and
endianness. Component-order confusion is the one bug class this vocabulary exists
to prevent, and it was the class the suite could not see: a mutant that makes
every decoded frame in score red/blue-swapped kept it green.

Pin every mapping instead:
  - all 58 AVPixelFormat mappings to the FFmpeg format NAME, resolved through
    av_get_pix_fmt() so the expectation comes from FFmpeg rather than from a
    retyped enumerator
  - all 54 V4L2 mappings to the kernel constant
  - plus the four one-way aliases a score->fourcc->score sweep can never reach,
    including the deprecated RGB32/BGR32 pair where the two old tables disagreed
Both cases also assert that no mapping exists which the pin list omits, so adding
one without pinning it fails rather than going unverified.

Verified against the two headline mutants: each is now caught.

Also closed:
  - exact counts replace floors (>= 69, >= 40) which let batches of mappings be
    deleted silently -- 15 could go and the suite stayed green
  - positive coverage for chromaSwappedTwin, whose sweep skipped anything
    returning Unknown, so deleting all six cases passed vacuously
  - rowBytes cross-checked against av_image_get_linesize for every mapped format,
    which catches a wrong blockBytes mechanically; the existing rowBytes sweep
    derives its expectation from blockBytes and so agrees with any value there

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
… 88 values

Closes the last three holes the mutation audit found.

The 30 formats with no AVPixelFormat had nothing verifying them. The design goal
of keeping no second list of formats is what makes the other 58 checkable against
FFmpeg for free, and it is also exactly what left these 30 unverifiable: mutating
any field changes the code and every expectation derived from it together. They
now have a golden row each, freezing colour model, plane count, subsampling,
block geometry, alpha, byte order and preferred alignment. The values were
audited against the vendor specifications rather than copied from the table, and
the case asserts that the golden set plus the 58 mapped ones is the whole
vocabulary, so a new wire-only format has to be frozen before it passes.

The byte-order rule was only asserted in one direction, so a multi-byte format
could silently declare NA -- and FFmpeg's descriptor cannot see that, since its
flag only marks big-endian. Two attempts at deriving the rule were wrong and are
worth recording: component depth does not decide it (RGB565 has 5- and 6-bit
components but is packed into a 16-bit word, so order matters) and neither does
bytes-per-pixel (UYVY422 is 4 bytes per 2 pixels with four whole-byte components
and correctly declares NA). FFmpeg's naming convention encodes precisely the
distinction, spelling an le/be pair only where the order is part of the identity,
so it is the authority for the mapped formats.

All 88 serialized values are now frozen as literals, up from 14. The table header
promises that a renumbering breaks the build; it was true for 16% of the
vocabulary. Deliberately written out rather than generated from
SCORE_VIDEO_PIXEL_FORMATS, which would be a tautology. Verified by renumbering
one format and confirming the build fails.

26940 assertions in 26 cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
Every YUV MEDIASUBTYPE is {fourcc, 0x0000, 0x0010, {0x80,0,0,0xaa,0,0x38,0x9b,0x71}},
so the subtype reduces to the fourcc in Data1. Putting the table in those terms
makes it portable and unit-testable on any host, which matters because the
mapping it replaces lived in Windows-only code and had gone unexercised long
enough to accumulate:

  YV12 -> AV_PIX_FMT_YUV420P    V before U, so red and blue were exchanged
  YVU9 -> AV_PIX_FMT_YUV410P    same swap at 4:1:0
  V216 -> AV_PIX_FMT_Y216LE     a different component order, and marked "not sure"

All three now resolve through the shared table to YVU420P, YVU410P and V216, and
the enumeration falls back to chromaSwappedTwin for the V-first layouts so those
cameras stay offered exactly as before while the swap is recorded rather than
lost. The superseded branches are removed rather than left dead and wrong; IF09,
which the shared table does not cover, keeps its own branch.

Compression is now detected from the fourcc instead of from guidToPixelFormat
happening to answer AV_PIX_FMT_YUVJ420P. That sentinel worked, but it meant a
camera genuinely offering YUVJ420P would have been taken for MJPEG, and it put a
codec on the pixel-format axis. H.264 and the three historical Motion-JPEG
spellings are recognised alongside MJPG.

The RGB subtypes are genuine SDK GUIDs rather than fourccs and keep their GUID
comparisons. ARGB1555 still drops its single alpha bit, because FFmpeg has no
5:5:5 format carrying alpha; that is now stated rather than silent.

Testable here despite being Windows data: 30 fourccs pinned by literal
four-character code, and the GUID extraction verified against the real
MEDIASUBTYPE constants for YV12, V216, MJPG and an RGB subtype, confirming the
RGB GUIDs are not mistaken for fourccs. Compiling CameraDevice.win32.cpp itself
still needs the Windows box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
DRM was the second format hub: dma-buf and PipeWire reached AVPixelFormat through
their own table, and PipeWire got there in two hops via DRM, so nothing connected
either to the layout vocabulary the capture cards use.

Add the DRM fourcc mapping next to the V4L2 and DirectShow ones, computing the
fourccs from their characters so it needs neither libdrm nor libspa and is
testable on any host.

The mapping states memory order in every comment, because a DRM fourcc reads in
machine-word order and the memory bytes are its reverse: DRM_ARGB8888 is B,G,R,A
in memory, so it is BGRA8, not ARGB8. That inversion is the most common source of
red/blue swaps on the dma-buf path, and the suite asserts the direction
explicitly rather than relying on a round-trip, which would accept either
reading.

Format modifiers are deliberately absent. Tiling and compression are not part of
a pixel format -- the same fourcc arrives linear or tiled -- and folding one in
would repeat the mistake of baking stride alignment into a descriptor. Callers
pass modifiers alongside.

Two rows were needed to make the DRM path lossless rather than dropping to
Unknown: X2RGB10 and X2BGR10, the ARGB2101010/ABGR2101010 layouts, whose top two
bits are padding rather than alpha.

Adding those two immediately failed the exact-count and pin-completeness
guardrails added earlier, which is what they are for: the new formats could not
pass until they were pinned and frozen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
Spout hands over a D3D11 texture, Syphon an IOSurface, dma-buf import an
EGLImage. None of those is a CPU buffer, so forcing them through the buffer
vocabulary is a category error -- the same mistake as baking stride alignment
into a format descriptor. What they share is a third axis: what a GPU texture
holds.

Map between the two, and be explicit that it is not a bijection:

  - a planar layout needs one texture per plane, so the answer is per plane; a
    semi-planar chroma plane is RG8/RG16, a fully planar one R8/R16
  - a packed YUV layout samples as a wider RGBA8 texture and is unpacked in a
    shader, because QRhi has no YUV format. Its texture format therefore says
    nothing about its colour model, and UYVY422 is 960 texels wide for a
    1920-pixel row
  - the wire-only layouts (v210, r210, DPX, 12-bit packed) have no texture at
    all: they must be decoded first, and answer UnknownFormat
  - the reverse direction only covers the unambiguous RGB formats, since a
    shader-unpacked YUV texture is RGBA8 like any other

Chroma plane dimensions round up, matching the buffer arithmetic, so an odd
raster does not lose its last chroma row or column here either.

Kept in its own header so the vocabulary stays free of Qt and RHI includes,
exactly as the libav bridge is kept separate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
Sh4lt and Shmdata hand over shared-memory *buffers*, not streams, and resolve
their caps through Video::gstreamerToLibav() to an AVPixelFormat -- after which a
format the decode path does not take natively is CPU-converted to RGBA. Removing
that conversion needs the layout and a stride, which is what a VideoPixelFormat
is, so they need an edge into the vocabulary.

The rest of the 54-entry gst map stays AV-first, as agreed: most of what arrives
over GStreamer is a stream format and the decode pipeline is AV-based. This table
covers the buffer layouts only.

It is a direct table rather than the existing map composed with the libav bridge,
and that is the point. Routing through AVPixelFormat loses the V-before-U
layouts, because FFmpeg has no pixel format for them: "YV12" would arrive as
YUV420P with red and blue exchanged, and "YVU9" likewise. The suite asserts that
YV12 and I420 do not collapse onto each other and that the swap is recorded.

This is the mapping, not the rewiring. Pointing Sh4lt and Shmdata at host-import
instead of the rescaler is the follow-up, and it wants those transports actually
running to validate, which needs a producer this machine does not have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
The V4L2 round-trip case was inside #if defined(__linux__), but the pinned-
constants case added alongside it was not, so every V4L2_PIX_FMT_* reference in
it broke the build anywhere without linux/videodev2.h.

Found by compiling on Windows, which is the only thing that could have found it:
the Linux build is green either way, and nothing in review flags a missing guard
on a block that sits next to a guarded one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
@jcelerier
jcelerier force-pushed the split/gfx-fixes-and-formats branch from 6c3686a to f5e872d Compare August 17, 2026 19:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants