Files
punktfunk/clients/apple/Tests/PunktfunkKitTests/VideoToolboxRoundTripTests.swift
T
enricobuehler 3678c182d5
apple / swift (push) Failing after 52s
windows-drivers / probe-and-proto (push) Successful in 50s
windows-drivers / driver-build (push) Successful in 1m20s
android / android (push) Failing after 2m55s
ci / web (push) Successful in 1m5s
release / apple (push) Successful in 3m38s
apple / screenshots (push) Has been skipped
ci / rust (push) Successful in 4m47s
ci / docs-site (push) Successful in 59s
deb / build-publish (push) Successful in 2m49s
decky / build-publish (push) Successful in 21s
windows-host / package (push) Successful in 7m35s
ci / bench (push) Successful in 5m10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m41s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m17s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m22s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m59s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 52s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m7s
flatpak / build-publish (push) Successful in 4m20s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m58s
docker / deploy-docs (push) Successful in 23s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m45s
feat(clients): codec preference on Windows/Apple/Android clients (Phase 2b)
Rounds out codec negotiation across the last three clients — each advertises what it can decode,
builds its decoder from the resolved Welcome.codec, and exposes a "Video codec" preference picker.

**Windows** (Rust, mirrors Linux): `decodable_codecs()` + `ffmpeg_codec_id()`; the D3D11VA and
software FFmpeg decoders (and the mid-session D3D11VA→software demotion) open the negotiated codec
instead of hardcoding HEVC; settings gain a `codec` field + reactor ComboBox; `--codec` CLI flag.

**Apple** (Swift/C-ABI): AnnexB is now codec-aware — a `VideoCodec` enum drives H.264 vs HEVC NAL
parsing / parameter-set extraction (`CMVideoFormatDescriptionCreateFromH264ParameterSets` for H.264,
no VPS) and AVCC repacking; `PunktfunkConnection` advertises H264|HEVC via `punktfunk_connect_ex7`,
reads `resolvedCodec` (`punktfunk_connection_codec`), and threads `videoCodec` into the stage-1/2
pipelines + `VideoDecoder`; SettingsView "Video codec" Picker (auto/HEVC/H.264). AV1 is left out
(hosts don't emit it on the native path, and it's not an AnnexB codec). Test call sites updated.

**Android** (Kotlin + Rust JNI): the JNI `nativeConnect` gains `preferredCodec`; the native decode
loop picks the AMediaCodec MIME (`video/hevc`|`video/avc`) from `connector.codec` and advertises
H264|HEVC; Settings `codec` field + Compose dropdown.

Core/host/probe/Linux clippy + tests green (unchanged from 2a). Windows/Apple/Android compile on
their platform CI (this Linux box can't build them — Windows toolchain / Xcode / the Android NDK's
opus-cmake toolchain). All follow the Linux client's validated pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:29:38 +00:00

221 lines
9.8 KiB
Swift

// Real-bitstream proof of the decode-prep path: VTCompressionSession encodes HEVC, we
// rebuild the host's wire shape (Annex-B AU with in-band VPS/SPS/PPS exactly what
// punktfunk-host emits on every IDR), run it through AnnexB, and hand the result to a real
// VTDecompressionSession. Pixels out = the whole client decode path is sound.
import AVFoundation
import CoreMedia
import VideoToolbox
import XCTest
@testable import PunktfunkKit
/// Sendable holder for the values the (background-thread) decode callback writes.
private final class FrameBox: @unchecked Sendable {
let lock = NSLock()
var frame: ReadyFrame?
var error: OSStatus?
}
final class VideoToolboxRoundTripTests: XCTestCase {
private let width = 320
private let height = 240
func testEncodeAnnexBDecodeRoundTrip() throws {
let (formatDesc, avccSample) = try encodeOneHEVCKeyframe()
// Rebuild the host's wire format: Annex-B AU, parameter sets in-band before the VCL.
let annexB = try annexBAU(formatDesc: formatDesc, avccSample: avccSample)
// 1) Parameter-set extraction format description.
let rebuilt = try XCTUnwrap(
AnnexB.formatDescription(fromIDR: annexB, codec: .hevc),
"in-band VPS/SPS/PPS should yield a format description")
let dims = CMVideoFormatDescriptionGetDimensions(rebuilt)
XCTAssertEqual(Int(dims.width), width)
XCTAssertEqual(Int(dims.height), height)
// 2) Annex-B AVCC re-pack must reproduce the encoder's own sample bytes.
XCTAssertEqual(AnnexB.avcc(from: annexB, codec: .hevc), avccSample)
// 3) Sample buffer real decoder pixels.
let au = AccessUnit(data: annexB, ptsNs: 1_000_000, frameIndex: 0, flags: 0)
let sample = try XCTUnwrap(AnnexB.sampleBuffer(au: au, format: rebuilt, codec: .hevc))
var session: VTDecompressionSession?
XCTAssertEqual(
VTDecompressionSessionCreate(
allocator: nil, formatDescription: rebuilt, decoderSpecification: nil,
imageBufferAttributes: nil, outputCallback: nil,
decompressionSessionOut: &session),
noErr)
let decoder = try XCTUnwrap(session)
defer { VTDecompressionSessionInvalidate(decoder) }
var decoded: CVImageBuffer?
var decodeStatus: OSStatus = -1
// No async flag the handler runs before DecodeFrame returns.
VTDecompressionSessionDecodeFrame(
decoder, sampleBuffer: sample, flags: [], infoFlagsOut: nil
) { status, _, imageBuffer, _, _ in
decodeStatus = status
decoded = imageBuffer
}
XCTAssertEqual(decodeStatus, noErr)
let pixels = try XCTUnwrap(decoded) // CVImageBuffer and CVPixelBuffer are the same CF type
XCTAssertEqual(CVPixelBufferGetWidth(pixels), width)
XCTAssertEqual(CVPixelBufferGetHeight(pixels), height)
}
/// Stage-2 decode half: the same known IDR through `VideoDecoder` assert its async output
/// callback fires with a CVPixelBuffer of the right dimensions, the pts round-trips, and
/// decode-completion is stamped.
func testVideoDecoderAsyncCallbackDeliversPixels() throws {
let (formatDesc, avccSample) = try encodeOneHEVCKeyframe()
let annexB = try annexBAU(formatDesc: formatDesc, avccSample: avccSample)
let format = try XCTUnwrap(AnnexB.formatDescription(fromIDR: annexB, codec: .hevc))
let au = AccessUnit(data: annexB, ptsNs: 42_000_000, frameIndex: 0, flags: 0)
let box = FrameBox()
let done = DispatchSemaphore(value: 0)
let decoder = VideoDecoder(
onDecoded: { frame in
box.lock.lock(); box.frame = frame; box.lock.unlock()
done.signal()
},
onDecodeError: { status in
box.lock.lock(); box.error = status; box.lock.unlock()
done.signal()
})
XCTAssertTrue(decoder.decode(au: au, format: format), "frame submit should succeed")
XCTAssertEqual(done.wait(timeout: .now() + 10), .success, "the decode callback must fire")
decoder.reset()
box.lock.lock()
let frame = box.frame
let error = box.error
box.lock.unlock()
XCTAssertNil(error.map { "decode error \($0)" })
let ready = try XCTUnwrap(frame, "the async output callback must deliver a ReadyFrame")
XCTAssertEqual(CVPixelBufferGetWidth(ready.pixelBuffer), width)
XCTAssertEqual(CVPixelBufferGetHeight(ready.pixelBuffer), height)
XCTAssertEqual(ready.ptsNs, 42_000_000, "pts round-trips through the decoder")
XCTAssertGreaterThan(ready.decodedNs, 0, "decode-completion is stamped")
}
// MARK: - encode helpers
/// One forced-IDR HEVC frame; returns its format description and raw AVCC sample bytes.
private func encodeOneHEVCKeyframe() throws -> (CMVideoFormatDescription, Data) {
var session: VTCompressionSession?
let rc = VTCompressionSessionCreate(
allocator: nil, width: Int32(width), height: Int32(height),
codecType: kCMVideoCodecType_HEVC, encoderSpecification: nil,
imageBufferAttributes: nil, compressedDataAllocator: nil,
outputCallback: nil, refcon: nil, compressionSessionOut: &session)
guard rc == noErr, let encoder = session else {
throw XCTSkip("no HEVC encoder available (\(rc))")
}
defer { VTCompressionSessionInvalidate(encoder) }
VTSessionSetProperty(encoder, key: kVTCompressionPropertyKey_RealTime, value: kCFBooleanTrue)
VTSessionSetProperty(
encoder, key: kVTCompressionPropertyKey_AllowFrameReordering, value: kCFBooleanFalse)
let lock = NSLock()
var output: CMSampleBuffer?
let done = expectation(description: "encoded")
VTCompressionSessionEncodeFrame(
encoder, imageBuffer: try gradientPixelBuffer(),
presentationTimeStamp: CMTime(value: 0, timescale: 30),
duration: CMTime(value: 1, timescale: 30),
frameProperties: [kVTEncodeFrameOptionKey_ForceKeyFrame: kCFBooleanTrue] as CFDictionary,
infoFlagsOut: nil
) { status, _, sample in
XCTAssertEqual(status, noErr)
lock.lock()
output = sample
lock.unlock()
done.fulfill()
}
VTCompressionSessionCompleteFrames(encoder, untilPresentationTimeStamp: .invalid)
wait(for: [done], timeout: 10)
lock.lock()
defer { lock.unlock() }
let sample = try XCTUnwrap(output)
let desc = try XCTUnwrap(CMSampleBufferGetFormatDescription(sample))
let block = try XCTUnwrap(CMSampleBufferGetDataBuffer(sample))
var bytes = Data(count: CMBlockBufferGetDataLength(block))
try bytes.withUnsafeMutableBytes { raw in
let rc = CMBlockBufferCopyDataBytes(
block, atOffset: 0, dataLength: raw.count,
destination: raw.baseAddress!)
if rc != noErr { throw NSError(domain: "CMBlockBuffer", code: Int(rc)) }
}
return (desc, bytes)
}
/// The host's wire shape: 4-byte start codes, VPS/SPS/PPS in-band, then the VCL NALs.
private func annexBAU(formatDesc: CMVideoFormatDescription, avccSample: Data) throws -> Data {
var au = Data()
var psCount = 0
var nalHeaderLen: Int32 = 0
XCTAssertEqual(
CMVideoFormatDescriptionGetHEVCParameterSetAtIndex(
formatDesc, parameterSetIndex: 0, parameterSetPointerOut: nil,
parameterSetSizeOut: nil, parameterSetCountOut: &psCount,
nalUnitHeaderLengthOut: &nalHeaderLen),
noErr)
XCTAssertEqual(nalHeaderLen, 4, "AnnexB.avcc assumes 4-byte NAL length prefixes")
for i in 0..<psCount {
var ptr: UnsafePointer<UInt8>?
var size = 0
XCTAssertEqual(
CMVideoFormatDescriptionGetHEVCParameterSetAtIndex(
formatDesc, parameterSetIndex: i, parameterSetPointerOut: &ptr,
parameterSetSizeOut: &size, parameterSetCountOut: nil,
nalUnitHeaderLengthOut: nil),
noErr)
au.append(contentsOf: [0, 0, 0, 1])
au.append(Data(bytes: try XCTUnwrap(ptr), count: size))
}
// AVCC sample (4-byte BE length per NAL) start codes.
var i = avccSample.startIndex
while i + 4 <= avccSample.endIndex {
let len = avccSample[i..<i + 4].reduce(0) { ($0 << 8) | Int($1) }
let body = avccSample.index(i, offsetBy: 4)
guard let end = avccSample.index(body, offsetBy: len, limitedBy: avccSample.endIndex)
else { break }
au.append(contentsOf: [0, 0, 0, 1])
au.append(avccSample[body..<end])
i = end
}
return au
}
private func gradientPixelBuffer() throws -> CVPixelBuffer {
var pb: CVPixelBuffer?
let attrs = [kCVPixelBufferIOSurfacePropertiesKey: [:]] as CFDictionary
XCTAssertEqual(
CVPixelBufferCreate(nil, width, height, kCVPixelFormatType_32BGRA, attrs, &pb),
kCVReturnSuccess)
let buf = try XCTUnwrap(pb)
CVPixelBufferLockBaseAddress(buf, [])
defer { CVPixelBufferUnlockBaseAddress(buf, []) }
let base = try XCTUnwrap(CVPixelBufferGetBaseAddress(buf))
let stride = CVPixelBufferGetBytesPerRow(buf)
for y in 0..<height {
let row = base.advanced(by: y * stride).assumingMemoryBound(to: UInt8.self)
for x in 0..<width {
row[x * 4 + 0] = UInt8(x & 0xFF) // B
row[x * 4 + 1] = UInt8(y & 0xFF) // G
row[x * 4 + 2] = UInt8((x ^ y) & 0xFF) // R
row[x * 4 + 3] = 0xFF
}
}
return buf
}
}