3ff1973d7f
docker / deploy-docs (push) Successful in 11s
flatpak / build-publish (push) Successful in 6m25s
deb / build-publish (push) Successful in 9m7s
windows-host / package (push) Successful in 16m12s
deb / build-publish-host (push) Successful in 9m31s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 6m41s
ci / rust (push) Successful in 25m11s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 8m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m59s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m45s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 8m2s
apple / swift (push) Successful in 1m15s
release / apple (push) Successful in 9m36s
apple / screenshots (push) Successful in 6m43s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 50s
android / android (push) Successful in 13m41s
decky / build-publish (push) Successful in 22s
arch / build-publish (push) Successful in 14m23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 57s
ci / bench (push) Successful in 5m58s
Phases 4+5 of design/pyrowave-444-hdr.md. The Metal decoder needs NO new ABI: every frame's sequence header carries chroma (444) and, since the Phase-3 stamps, the PQ/BT.2020 bits — so the decoder self-configures per session. Decoder: WaveletLayout grows the 4:4:4 block space (chroma runs the full pyramid like luma — no level-0 skip, no early half-res emit; the Metal kernels were already chroma-agnostic, only the dispatch structure changes); the parser accepts chroma_resolution=444, reads the PQ transfer bit, and lifts the even-dims rule for 444; the plane ring allocates full-res chroma and r16Unorm for PQ streams; CSC rows switch to depth-10 MSB-packed. Presenter: planar HDR passthrough reuses pf_frag_planar on an rgba16Float drawable (itur_2100_PQ + EDR metadata interpret the samples — same split as pf_frag/pf_frag_hdr), plus a new pf_frag_planar_tm PQ->SDR tone-map (shared pqToSdr tail refactored out of pf_frag_hdr_tv) for tvOS-without-headroom AND macOS WINDOWED sessions, whose IOSurface present path (the DCP-panic mitigation) is BGRA8-only. SessionModel stops stripping the HDR/10-bit/444 caps on the PyroWave opt-in. New golden: au-dense444 + upstream's own 4:4:4 reference planes (regenerated via the extended pyrowave_dump_golden); Metal decode matches at 64-67 dB (420 fixtures re-verify 77-88 dB). Full Apple suite 157 tests green on a real M-series GPU. Docs updated: the 8-bit-SDR-only wording is gone, the Windows host is no longer 'on the roadmap', bpp scaling documented. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
304 lines
14 KiB
Swift
304 lines
14 KiB
Swift
// PyroWave Metal decoder tests — two layers:
|
||
//
|
||
// 1. Bitstream/window-walk parser tests (pure CPU): hand-crafted packet streams assert the
|
||
// exact wire semantics of pyrowave_decoder.cpp's push_packet walk + the Phase-4
|
||
// chunk-aligned framing (4-byte window prefix, FRAG chains, zeroed missing shards).
|
||
//
|
||
// 2. Golden-frame PSNR tests (Metal GPU): host-encoded fixtures (crates/punktfunk-host
|
||
// encode/linux/pyrowave.rs `pyrowave_dump_golden`, run on a Vulkan box) decoded by the
|
||
// Metal port and PSNR-matched against upstream's own decoder output. Float wavelet math is
|
||
// not bit-exact across implementations (upstream ships precision variants), so the gate is
|
||
// PSNR, not equality. This is the §4.7 validation oracle for the hand-ported kernels —
|
||
// the gather/mirror addressing in idwt is the spot most likely to drift.
|
||
|
||
#if canImport(Metal)
|
||
import Metal
|
||
import XCTest
|
||
|
||
@testable import PunktfunkKit
|
||
|
||
final class PyroWaveParserTests: XCTestCase {
|
||
// 256x144 → aligned 256x160; block space identical to the committed fixtures.
|
||
private let width = 256
|
||
private let height = 144
|
||
|
||
/// A BitstreamSequenceHeader (START_OF_FRAME) for `width`x`height`, 4:2:0 BT.709 limited.
|
||
private func sof(totalBlocks: Int, sequence: UInt32 = 1) -> [UInt8] {
|
||
let word0 =
|
||
UInt32(width - 1) | (UInt32(height - 1) << 14) | (sequence << 28) | (1 << 31)
|
||
// code=0 (SOF), chroma=0 (420), primaries/trc/matrix=0 (BT.709), range=1 (LIMITED),
|
||
// siting=0.
|
||
let word1 = UInt32(totalBlocks) | (1 << 30)
|
||
return le32(word0) + le32(word1)
|
||
}
|
||
|
||
/// A minimal coefficient packet: ballot=0 (all 8x8 blocks empty — legal and decodable),
|
||
/// payload_words=2 (header only).
|
||
private func packet(blockIndex: Int, sequence: UInt32 = 1) -> [UInt8] {
|
||
let word0 = UInt32(0) | (2 << 16) | (sequence << 28)
|
||
let word1 = UInt32(0) | (UInt32(blockIndex) << 8)
|
||
return le32(word0) + le32(word1)
|
||
}
|
||
|
||
private func le32(_ v: UInt32) -> [UInt8] {
|
||
[UInt8(v & 0xff), UInt8((v >> 8) & 0xff), UInt8((v >> 16) & 0xff), UInt8(v >> 24)]
|
||
}
|
||
|
||
/// Wrap bodies into `windowSize`-sized windows with the 4-byte used/kind prefix.
|
||
private func window(_ body: [UInt8], kind: UInt16, size: Int) -> [UInt8] {
|
||
precondition(body.count + 4 <= size)
|
||
var out = [UInt8(body.count & 0xff), UInt8(body.count >> 8)]
|
||
out += [UInt8(kind & 0xff), UInt8(kind >> 8)]
|
||
out += body
|
||
out += [UInt8](repeating: 0, count: size - out.count)
|
||
return out
|
||
}
|
||
|
||
func testLayoutMatchesUpstreamBlockSpace() {
|
||
// init_block_meta's walk for 256x144 (aligned 256x160): level extents halve from
|
||
// 128x80; per (comp,level,band) count32 = ceil(ceil(w/8)/4) * ceil(ceil(h/8)/4).
|
||
let layout = WaveletLayout(width: width, height: height, chroma444: false)
|
||
XCTAssertEqual(layout.alignedWidth, 256)
|
||
XCTAssertEqual(layout.alignedHeight, 160)
|
||
XCTAssertEqual(layout.levelWidth(0), 128)
|
||
XCTAssertEqual(layout.levelHeight(0), 80)
|
||
XCTAssertEqual(layout.levelWidth(4), 8)
|
||
XCTAssertEqual(layout.levelHeight(4), 5)
|
||
// Hand-summed: L4 (8x5 → 1 block) × 3 comps × 4 bands = 12; L3 (16x10 → 1) × 9 = 9;
|
||
// L2 (32x20 → 1) × 9 = 9; L1 (64x40 → 2x2=4... ) — trust the invariant instead:
|
||
// every band's count is ceil(w8/4)*ceil(h8/4) and the total is their sum.
|
||
var expected = 0
|
||
for level in stride(from: 4, through: 0, by: -1) {
|
||
let w8 = (layout.levelWidth(level) + 7) / 8
|
||
let h8 = (layout.levelHeight(level) + 7) / 8
|
||
let per = ((w8 + 3) / 4) * ((h8 + 3) / 4)
|
||
for component in 0..<3 {
|
||
if level == 0 && component != 0 { continue }
|
||
expected += per * (level == 4 ? 4 : 3)
|
||
}
|
||
}
|
||
XCTAssertEqual(layout.blockCount32, expected)
|
||
// The finest luma level's stride is its 32-block row width.
|
||
XCTAssertEqual(layout.blockMeta[0][0][1].stride, (128 + 31) / 32)
|
||
// Level-0 chroma is not coded in 4:2:0.
|
||
XCTAssertEqual(layout.blockMeta[1][0][1].offset, -1)
|
||
}
|
||
|
||
func testDenseParseFillsOffsetsAndCountsBlocks() throws {
|
||
let layout = WaveletLayout(width: width, height: height, chroma444: false)
|
||
var au = sof(totalBlocks: 4)
|
||
au += packet(blockIndex: 0)
|
||
au += packet(blockIndex: 3)
|
||
au += packet(blockIndex: 3) // duplicate — first wins, not double-counted
|
||
au += packet(blockIndex: layout.blockCount32 - 1)
|
||
let frame = try XCTUnwrap(
|
||
WaveletBitstream.parse(au: Data(au), chunkAligned: false, windowSize: 0))
|
||
XCTAssertEqual(frame.layout.width, width)
|
||
XCTAssertEqual(frame.totalBlocks, 4)
|
||
XCTAssertEqual(frame.decodedBlocks, 3)
|
||
XCTAssertEqual(frame.offsets[0], 0)
|
||
XCTAssertEqual(frame.offsets[3], 2) // u32 words: each header-only packet is 2 words
|
||
XCTAssertEqual(frame.offsets[1], UInt32.max)
|
||
XCTAssertEqual(frame.payload.count, 6)
|
||
XCTAssertFalse(frame.bt2020)
|
||
XCTAssertFalse(frame.fullRange) // range bit 1 = LIMITED
|
||
}
|
||
|
||
func testHalfOrFewerBlocksIsDropped() {
|
||
var au = sof(totalBlocks: 4)
|
||
au += packet(blockIndex: 0)
|
||
au += packet(blockIndex: 1)
|
||
// 2 of 4 decoded = exactly half — upstream requires MORE than half.
|
||
XCTAssertNil(WaveletBitstream.parse(au: Data(au), chunkAligned: false, windowSize: 0))
|
||
}
|
||
|
||
func testMissingSOFIsDropped() {
|
||
let au = packet(blockIndex: 0) + packet(blockIndex: 1)
|
||
XCTAssertNil(WaveletBitstream.parse(au: Data(au), chunkAligned: false, windowSize: 0))
|
||
}
|
||
|
||
func testTruncatedPacketIsRejected() {
|
||
var au = sof(totalBlocks: 1)
|
||
// Claims 4 payload words but only the 8-byte header follows.
|
||
let word0 = UInt32(0) | (4 << 16) | (1 << 28)
|
||
au += le32(word0) + le32(0)
|
||
XCTAssertNil(WaveletBitstream.parse(au: Data(au), chunkAligned: false, windowSize: 0))
|
||
}
|
||
|
||
func testWindowWalkPackedFragAndMissingShard() throws {
|
||
let size = 64
|
||
// Window 1: SOF + one packet, PACKED. Window 2: a FRAG chain carrying one packet split
|
||
// across two windows. Window 3: all zeros (a lost shard of a partial frame). Window 4:
|
||
// a PACKED packet — the chain break must not eat it.
|
||
let fragPacket = packet(blockIndex: 2)
|
||
var au = window(sof(totalBlocks: 3) + packet(blockIndex: 0), kind: 0, size: size)
|
||
au += window(Array(fragPacket[0..<5]), kind: 1, size: size)
|
||
au += window(Array(fragPacket[5...]), kind: 3, size: size)
|
||
au += [UInt8](repeating: 0, count: size) // missing shard
|
||
au += window(packet(blockIndex: 1), kind: 0, size: size)
|
||
let frame = try XCTUnwrap(
|
||
WaveletBitstream.parse(au: Data(au), chunkAligned: true, windowSize: size))
|
||
XCTAssertEqual(frame.decodedBlocks, 3)
|
||
XCTAssertEqual(frame.offsets[0], 0)
|
||
XCTAssertEqual(frame.offsets[2], 2)
|
||
XCTAssertEqual(frame.offsets[1], 4)
|
||
}
|
||
|
||
func testBrokenFragChainIsDiscarded() throws {
|
||
let size = 64
|
||
let fragPacket = packet(blockIndex: 2)
|
||
var au = window(sof(totalBlocks: 1) + packet(blockIndex: 0), kind: 0, size: size)
|
||
au += window(Array(fragPacket[0..<5]), kind: 1, size: size)
|
||
au += [UInt8](repeating: 0, count: size) // the chain's middle shard was lost
|
||
au += window(Array(fragPacket[5...]), kind: 3, size: size) // dangling LAST — dropped
|
||
let frame = try XCTUnwrap(
|
||
WaveletBitstream.parse(au: Data(au), chunkAligned: true, windowSize: size))
|
||
XCTAssertEqual(frame.decodedBlocks, 1)
|
||
XCTAssertEqual(frame.offsets[2], UInt32.max)
|
||
}
|
||
}
|
||
|
||
/// Golden-frame decode against the committed host-encoder fixtures. Skipped when the machine
|
||
/// has no Metal device (headless CI) — everywhere else this is the hand-ported kernels' guard.
|
||
final class PyroWaveGoldenTests: XCTestCase {
|
||
private static let fixtureDir = "PyroWaveFixtures"
|
||
|
||
private func fixture(_ name: String) throws -> Data {
|
||
let url = try XCTUnwrap(
|
||
Bundle.module.url(
|
||
forResource: name, withExtension: "bin", subdirectory: Self.fixtureDir),
|
||
"missing fixture \(name).bin — regenerate with pyrowave_dump_golden")
|
||
return try Data(contentsOf: url)
|
||
}
|
||
|
||
/// Completion box — the decode callback lands on a Metal thread.
|
||
private final class ResultBox: @unchecked Sendable {
|
||
let lock = NSLock()
|
||
var planes: WaveletPlanes?
|
||
}
|
||
|
||
/// Decode `au` synchronously and read all three planes back to CPU bytes.
|
||
private func decode(
|
||
au: Data, chunkAligned: Bool, windowSize: Int
|
||
) throws -> (y: [UInt8], cb: [UInt8], cr: [UInt8]) {
|
||
let device = try XCTUnwrap(MTLCreateSystemDefaultDevice())
|
||
let queue = try XCTUnwrap(device.makeCommandQueue())
|
||
let decoder = try XCTUnwrap(MetalWaveletDecoder(device: device, queue: queue))
|
||
let done = expectation(description: "decode completes")
|
||
let box = ResultBox()
|
||
let submitted = decoder.decode(
|
||
au: au, chunkAligned: chunkAligned, windowSize: windowSize
|
||
) { planes in
|
||
box.lock.lock()
|
||
box.planes = planes
|
||
box.lock.unlock()
|
||
done.fulfill()
|
||
}
|
||
XCTAssertTrue(submitted, "the fixture AU must parse")
|
||
wait(for: [done], timeout: 10)
|
||
box.lock.lock()
|
||
let result = box.planes
|
||
box.lock.unlock()
|
||
let planes = try XCTUnwrap(result, "the GPU pass must complete without error")
|
||
return (
|
||
try readback(planes.y, device: device, queue: queue),
|
||
try readback(planes.cb, device: device, queue: queue),
|
||
try readback(planes.cr, device: device, queue: queue)
|
||
)
|
||
}
|
||
|
||
private func readback(
|
||
_ texture: MTLTexture, device: MTLDevice, queue: MTLCommandQueue
|
||
) throws -> [UInt8] {
|
||
let bytesPerRow = texture.width
|
||
let length = bytesPerRow * texture.height
|
||
let buffer = try XCTUnwrap(device.makeBuffer(length: length, options: .storageModeShared))
|
||
let cmd = try XCTUnwrap(queue.makeCommandBuffer())
|
||
let blit = try XCTUnwrap(cmd.makeBlitCommandEncoder())
|
||
blit.copy(
|
||
from: texture, sourceSlice: 0, sourceLevel: 0,
|
||
sourceOrigin: MTLOrigin(x: 0, y: 0, z: 0),
|
||
sourceSize: MTLSize(width: texture.width, height: texture.height, depth: 1),
|
||
to: buffer, destinationOffset: 0, destinationBytesPerRow: bytesPerRow,
|
||
destinationBytesPerImage: length)
|
||
blit.endEncoding()
|
||
cmd.commit()
|
||
cmd.waitUntilCompleted()
|
||
return [UInt8](UnsafeRawBufferPointer(start: buffer.contents(), count: length))
|
||
}
|
||
|
||
private func psnr(_ a: [UInt8], _ b: [UInt8]) -> Double {
|
||
precondition(a.count == b.count)
|
||
var sse = 0.0
|
||
for i in 0..<a.count {
|
||
let d = Double(a[i]) - Double(b[i])
|
||
sse += d * d
|
||
}
|
||
if sse == 0 { return .infinity }
|
||
let mse = sse / Double(a.count)
|
||
return 10 * log10(255.0 * 255.0 / mse)
|
||
}
|
||
|
||
private func assertMatchesReference(
|
||
_ decoded: (y: [UInt8], cb: [UInt8], cr: [UInt8]), prefix: String,
|
||
file: StaticString = #filePath, line: UInt = #line
|
||
) throws {
|
||
for (name, plane, ref) in [
|
||
("y", decoded.y, try fixture("\(prefix)-y")),
|
||
("cb", decoded.cb, try fixture("\(prefix)-cb")),
|
||
("cr", decoded.cr, try fixture("\(prefix)-cr")),
|
||
] {
|
||
XCTAssertEqual(plane.count, ref.count, file: file, line: line)
|
||
let db = psnr(plane, [UInt8](ref))
|
||
print("pyrowave golden \(prefix) \(name): \(db) dB")
|
||
// The Metal port and upstream's decoder run the same math at the same precision
|
||
// tier; residual differences are float rounding + the gather/mirror edge handling.
|
||
// Well-matched ports measure ≫50 dB; 45 catches a real divergence long before it
|
||
// is visible.
|
||
XCTAssertGreaterThan(db, 45.0, "plane PSNR \(db) dB", file: file, line: line)
|
||
}
|
||
}
|
||
|
||
func testDenseGoldenFrame() throws {
|
||
try XCTSkipIf(!MetalWaveletDecoder.supported, "no capable Metal device")
|
||
let au = try fixture("au-dense")
|
||
let decoded = try decode(au: au, chunkAligned: false, windowSize: 0)
|
||
try assertMatchesReference(decoded, prefix: "ref-dense")
|
||
}
|
||
|
||
func testChunkAlignedGoldenFrame() throws {
|
||
try XCTSkipIf(!MetalWaveletDecoder.supported, "no capable Metal device")
|
||
let au = try fixture("au-chunked")
|
||
let decoded = try decode(au: au, chunkAligned: true, windowSize: 1408)
|
||
try assertMatchesReference(decoded, prefix: "ref-chunked")
|
||
}
|
||
|
||
/// 4:4:4: the chroma components run the full pyramid like luma (no level-0 skip, no
|
||
/// early half-res emit) — the layout + dispatch structure Phase 4 added
|
||
/// (design/pyrowave-444-hdr.md). The fixture comes from the 4:4:4 host encoder; the
|
||
/// reference is upstream's own 4:4:4 decode (full-res chroma planes).
|
||
func testDense444GoldenFrame() throws {
|
||
try XCTSkipIf(!MetalWaveletDecoder.supported, "no capable Metal device")
|
||
let au = try fixture("au-dense444")
|
||
let decoded = try decode(au: au, chunkAligned: false, windowSize: 0)
|
||
try assertMatchesReference(decoded, prefix: "ref-dense444")
|
||
}
|
||
|
||
/// Phase-4 partial delivery: zero a mid-AU window (a lost shard) — the frame must still
|
||
/// decode (blocks > half) and stay recognizably the same picture (holes reconstruct as
|
||
/// localized blur, not garbage).
|
||
func testPartialFrameStillDecodes() throws {
|
||
try XCTSkipIf(!MetalWaveletDecoder.supported, "no capable Metal device")
|
||
var au = try fixture("au-chunked")
|
||
let windows = au.count / 1408
|
||
try XCTSkipIf(windows < 3, "fixture too small to punch a hole in")
|
||
let hole = (windows / 2) * 1408
|
||
au.replaceSubrange(hole..<(hole + 1408), with: [UInt8](repeating: 0, count: 1408))
|
||
let decoded = try decode(au: au, chunkAligned: true, windowSize: 1408)
|
||
let ref = try fixture("ref-chunked-y")
|
||
let db = psnr(decoded.y, [UInt8](ref))
|
||
XCTAssertGreaterThan(db, 25.0, "lossy frame should still resemble the source (\(db) dB)")
|
||
}
|
||
}
|
||
#endif
|