Merge remote-tracking branch 'origin/main'
android / android (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
windows-host / package (push) Has been cancelled
android / android (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
windows-host / package (push) Has been cancelled
This commit is contained in:
@@ -84,15 +84,6 @@ public final class InputCapture {
|
|||||||
/// its Esc suppression need it in both states).
|
/// its Esc suppression need it in both states).
|
||||||
private var cmdKeysDown: Set<UInt32> = []
|
private var cmdKeysDown: Set<UInt32> = []
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
/// Previous raw `NSEvent.modifierFlags.rawValue` (LOW 16 bits intact — those carry the
|
|
||||||
/// device-dependent L/R bits). Modifier keys never fire keyDown/keyUp on macOS; they
|
|
||||||
/// arrive as flagsChanged, which doesn't carry down-vs-up — we recover that by diffing
|
|
||||||
/// this snapshot. Resynced (not diffed) while forwarding is off so a modifier held
|
|
||||||
/// across a capture toggle can't produce a phantom transition on re-engage.
|
|
||||||
private var prevModFlags: UInt = 0
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// While true, mouse/keyboard flow to the host and key NSEvents are swallowed
|
/// While true, mouse/keyboard flow to the host and key NSEvents are swallowed
|
||||||
/// locally; while false the user is interacting with the local UI (dragging the
|
/// locally; while false the user is interacting with the local UI (dragging the
|
||||||
/// window, clicking the HUD) and nothing is forwarded. Main-queue only.
|
/// window, clicking the HUD) and nothing is forwarded. Main-queue only.
|
||||||
@@ -279,12 +270,6 @@ public final class InputCapture {
|
|||||||
residualY = 0
|
residualY = 0
|
||||||
residualScrollX = 0
|
residualScrollX = 0
|
||||||
residualScrollY = 0
|
residualScrollY = 0
|
||||||
#if os(macOS)
|
|
||||||
// Drop the modifier snapshot too: a flagsChanged transition can be missed if focus
|
|
||||||
// leaves mid-chord, and the next handleFlagsChanged resyncs from a clean slate (it
|
|
||||||
// resyncs while released anyway, but this keeps stuck state from outliving a blur).
|
|
||||||
prevModFlags = 0
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Release any held MOUSE buttons host-side, leaving keyboard state untouched. Used when
|
/// Release any held MOUSE buttons host-side, leaving keyboard state untouched. Used when
|
||||||
@@ -359,39 +344,52 @@ public final class InputCapture {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// NSEvent modifier path (macOS): modifier keys never fire keyDown/keyUp — they arrive
|
/// NSEvent modifier path (macOS): modifier keys never fire keyDown/keyUp — they arrive
|
||||||
/// as flagsChanged, which carries no down-vs-up. We diff the raw flags against the prior
|
/// as flagsChanged, which carries no down-vs-up. `keyCode` names the key that changed
|
||||||
/// snapshot to recover each transition, and the changed key's L/R identity from the
|
/// (kVK_Control & co., already L/R-specific); `resolveModifier` recovers the direction
|
||||||
/// device-dependent bits in the LOW 16 bits (the .deviceIndependentFlagsMask the ⌘⎋
|
/// from the flags. Fed `event.keyCode` + `UInt(event.modifierFlags.rawValue)` — LOW 16
|
||||||
/// monitor uses deliberately strips exactly these — do NOT pre-mask here). Each side maps
|
/// bits intact, they carry the device-dependent L/R bits (the .deviceIndependentFlagsMask
|
||||||
/// to the same L/R modifier VK `hidToVK` already emits, so the host needs no change.
|
/// the ⌘⎋ monitor uses deliberately strips exactly these — do NOT pre-mask here).
|
||||||
/// Fed `UInt(event.modifierFlags.rawValue)`.
|
public func handleFlagsChanged(keyCode: UInt16, rawFlags: UInt) {
|
||||||
public func handleFlagsChanged(_ rawFlags: UInt) {
|
if inputDebug {
|
||||||
// While released we only resync the snapshot, so a modifier held across a capture
|
inputLog.debug(
|
||||||
// toggle doesn't show up as a spurious transition the moment forwarding re-engages.
|
"flagsChanged keyCode \(keyCode, privacy: .public) flags 0x\(String(rawFlags, radix: 16), privacy: .public) forwarding \(self.forwarding, privacy: .public)")
|
||||||
guard forwarding else {
|
|
||||||
prevModFlags = rawFlags
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// (device-dependent mask, VK). LOW-16-bit masks from IOLLEvent.h (NX_DEVICE*MASK):
|
guard forwarding else { return }
|
||||||
// Lshift 0x2 Rshift 0x4 | Lctrl 0x1 Rctrl 0x2000 | Lalt 0x20 Ralt 0x40 | Lcmd 0x8 Rcmd 0x10.
|
guard let (vk, down) = Self.resolveModifier(
|
||||||
let table: [(UInt, UInt32)] = [
|
keyCode: keyCode, rawFlags: rawFlags, isDown: { pressedVKs.contains($0) })
|
||||||
(0x2, 0xA0), (0x4, 0xA1), // VK_LSHIFT / VK_RSHIFT
|
else { return } // Fn / Caps Lock / unknown — nothing the host consumes on this path
|
||||||
(0x1, 0xA2), (0x2000, 0xA3), // VK_LCONTROL / VK_RCONTROL
|
|
||||||
(0x20, 0xA4), (0x40, 0xA5), // VK_LMENU / VK_RMENU (left/right alt-option)
|
|
||||||
(0x8, 0x5B), (0x10, 0x5C), // VK_LWIN / VK_RWIN (left/right command)
|
|
||||||
]
|
|
||||||
for (mask, vk) in table {
|
|
||||||
let now = (rawFlags & mask) != 0
|
|
||||||
let was = (prevModFlags & mask) != 0
|
|
||||||
guard now != was else { continue }
|
|
||||||
// Keep cmdKeysDown in step (the ⌘⎋ toggle + Esc suppression read it); sendKey
|
// Keep cmdKeysDown in step (the ⌘⎋ toggle + Esc suppression read it); sendKey
|
||||||
// adds the VK to pressedVKs so releaseAll/blur flushes a held modifier cleanly.
|
// adds the VK to pressedVKs so releaseAll/blur flushes a held modifier cleanly.
|
||||||
if vk == 0x5B || vk == 0x5C {
|
if vk == 0x5B || vk == 0x5C {
|
||||||
if now { cmdKeysDown.insert(vk) } else { cmdKeysDown.remove(vk) }
|
if down { cmdKeysDown.insert(vk) } else { cmdKeysDown.remove(vk) }
|
||||||
}
|
}
|
||||||
sendKey(vk, down: now)
|
sendKey(vk, down: down)
|
||||||
}
|
}
|
||||||
prevModFlags = rawFlags
|
|
||||||
|
/// Resolve one flagsChanged transition to (Windows VK, down). The changed key is
|
||||||
|
/// `keyCode`; the direction comes from the flags. The device-dependent L/R bits (LOW
|
||||||
|
/// 16 bits, NX_DEVICE*KEYMASK) disambiguate the two same-class keys, but some
|
||||||
|
/// keyboards ship flagsChanged WITHOUT them — only the device-independent class
|
||||||
|
/// bit (NX_CONTROLMASK & co.) is set. A pure diff of the device bits silently drops
|
||||||
|
/// those keys (seen live: Control never forwarded), so this is keyCode-driven with the
|
||||||
|
/// flags as evidence: class bit clear → the key went up; device bits present → they
|
||||||
|
/// say which side is held now; class bit set with NO device bits → flip the held state
|
||||||
|
/// we track (`isDown`, from pressedVKs — SDL ships the same fallback). Each keyCode
|
||||||
|
/// maps to the L/R modifier VK `hidToVK` already emits, so the host needs no change.
|
||||||
|
/// Returns nil for modifiers the host doesn't consume on this path (Fn, Caps Lock).
|
||||||
|
static func resolveModifier(
|
||||||
|
keyCode: UInt16, rawFlags: UInt, isDown: (UInt32) -> Bool
|
||||||
|
) -> (vk: UInt32, down: Bool)? {
|
||||||
|
guard let mod = modifierBits[keyCode] else { return nil }
|
||||||
|
let down: Bool
|
||||||
|
if rawFlags & mod.classMask == 0 {
|
||||||
|
down = false
|
||||||
|
} else if rawFlags & (mod.deviceBit | mod.siblingBit) != 0 {
|
||||||
|
down = rawFlags & mod.deviceBit != 0
|
||||||
|
} else {
|
||||||
|
down = !isDown(mod.vk)
|
||||||
|
}
|
||||||
|
return (mod.vk, down)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|||||||
@@ -98,5 +98,23 @@ extension InputCapture {
|
|||||||
m[0x47] = 0x90 // KP clear sits where NumLock is → VK_NUMLOCK. (KP equals 0x51 dropped.)
|
m[0x47] = 0x90 // KP clear sits where NumLock is → VK_NUMLOCK. (KP equals 0x51 dropped.)
|
||||||
return m
|
return m
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// NSEvent.keyCode of each modifier key (kVK_Shift & co. — modifiers arrive only as
|
||||||
|
/// flagsChanged) → its Windows VK plus the `NSEvent.modifierFlags` bits that describe
|
||||||
|
/// it: `classMask` is the device-INDEPENDENT NX_*MASK for the modifier class,
|
||||||
|
/// `deviceBit`/`siblingBit` the device-dependent bits (LOW 16 bits, NX_DEVICE*KEYMASK
|
||||||
|
/// in IOLLEvent.h) for this key and its opposite-side twin. Consumed by
|
||||||
|
/// `resolveModifier`, which explains why both kinds of bit are needed.
|
||||||
|
static let modifierBits:
|
||||||
|
[UInt16: (vk: UInt32, classMask: UInt, deviceBit: UInt, siblingBit: UInt)] = [
|
||||||
|
56: (0xA0, 0x2_0000, 0x2, 0x4), // left shift → VK_LSHIFT
|
||||||
|
60: (0xA1, 0x2_0000, 0x4, 0x2), // right shift → VK_RSHIFT
|
||||||
|
59: (0xA2, 0x4_0000, 0x1, 0x2000), // left control → VK_LCONTROL
|
||||||
|
62: (0xA3, 0x4_0000, 0x2000, 0x1), // right control → VK_RCONTROL
|
||||||
|
58: (0xA4, 0x8_0000, 0x20, 0x40), // left option → VK_LMENU
|
||||||
|
61: (0xA5, 0x8_0000, 0x40, 0x20), // right option → VK_RMENU
|
||||||
|
55: (0x5B, 0x10_0000, 0x8, 0x10), // left command → VK_LWIN
|
||||||
|
54: (0x5C, 0x10_0000, 0x10, 0x8), // right command → VK_RWIN
|
||||||
|
]
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -346,10 +346,13 @@ public final class StreamLayerView: NSView {
|
|||||||
super.keyUp(with: event)
|
super.keyUp(with: event)
|
||||||
}
|
}
|
||||||
/// Modifier keys (shift/control/option/command) arrive ONLY as flagsChanged on macOS,
|
/// Modifier keys (shift/control/option/command) arrive ONLY as flagsChanged on macOS,
|
||||||
/// never keyDown/keyUp — InputCapture diffs the raw flags to recover each L/R down/up.
|
/// never keyDown/keyUp — the changed key is `event.keyCode`; InputCapture resolves the
|
||||||
|
/// down-vs-up direction from the flags (diffing the device-dependent flag bits alone
|
||||||
|
/// proved unreliable — some keyboards omit them, which silently dropped Control).
|
||||||
public override func flagsChanged(with event: NSEvent) {
|
public override func flagsChanged(with event: NSEvent) {
|
||||||
if captured, let inputCapture {
|
if captured, let inputCapture {
|
||||||
inputCapture.handleFlagsChanged(UInt(event.modifierFlags.rawValue))
|
inputCapture.handleFlagsChanged(
|
||||||
|
keyCode: event.keyCode, rawFlags: UInt(event.modifierFlags.rawValue))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
super.flagsChanged(with: event)
|
super.flagsChanged(with: event)
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
|
/// Pins the macOS flagsChanged → modifier-VK resolution (InputCapture.resolveModifier).
|
||||||
|
/// Modifier keys arrive only as flagsChanged, which carries no down-vs-up: the changed key
|
||||||
|
/// is the event's keyCode, and the direction is recovered from the flag bits — with a
|
||||||
|
/// held-state fallback for keyboards that omit the device-dependent L/R bits (the gap that
|
||||||
|
/// used to silently drop Control when the transition was diffed from those bits alone).
|
||||||
|
final class ModifierResolveTests: XCTestCase {
|
||||||
|
/// Resolve with a fixed already-held answer for the fallback path.
|
||||||
|
private func resolve(
|
||||||
|
keyCode: UInt16, rawFlags: UInt, held: Bool = false
|
||||||
|
) -> (vk: UInt32, down: Bool)? {
|
||||||
|
InputCapture.resolveModifier(keyCode: keyCode, rawFlags: rawFlags) { _ in held }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Keyboards that report the device-dependent L/R bits (the common case)
|
||||||
|
|
||||||
|
func testControlPressAndReleaseWithDeviceBits() {
|
||||||
|
// Real left-Control down: NX_CONTROLMASK | NX_DEVICELCTLKEYMASK (+ misc low bits).
|
||||||
|
let down = resolve(keyCode: 59, rawFlags: 0x4_0101)
|
||||||
|
XCTAssertEqual(down?.vk, 0xA2) // VK_LCONTROL
|
||||||
|
XCTAssertEqual(down?.down, true)
|
||||||
|
// Release: the class mask is gone entirely.
|
||||||
|
let up = resolve(keyCode: 59, rawFlags: 0x100)
|
||||||
|
XCTAssertEqual(up?.vk, 0xA2)
|
||||||
|
XCTAssertEqual(up?.down, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRightControlUsesItsOwnDeviceBit() {
|
||||||
|
let down = resolve(keyCode: 62, rawFlags: 0x4_2000)
|
||||||
|
XCTAssertEqual(down?.vk, 0xA3) // VK_RCONTROL
|
||||||
|
XCTAssertEqual(down?.down, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReleasingOneOfTwoHeldControls() {
|
||||||
|
// Left goes up while right stays held: class mask still set, right device bit
|
||||||
|
// still set, LEFT device bit cleared — the left key must resolve as UP.
|
||||||
|
let leftUp = resolve(keyCode: 59, rawFlags: 0x4_2000, held: true)
|
||||||
|
XCTAssertEqual(leftUp?.vk, 0xA2)
|
||||||
|
XCTAssertEqual(leftUp?.down, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEverySideMapsToItsOwnVK() {
|
||||||
|
XCTAssertEqual(resolve(keyCode: 56, rawFlags: 0x2_0002)?.vk, 0xA0) // VK_LSHIFT
|
||||||
|
XCTAssertEqual(resolve(keyCode: 60, rawFlags: 0x2_0004)?.vk, 0xA1) // VK_RSHIFT
|
||||||
|
XCTAssertEqual(resolve(keyCode: 58, rawFlags: 0x8_0020)?.vk, 0xA4) // VK_LMENU
|
||||||
|
XCTAssertEqual(resolve(keyCode: 61, rawFlags: 0x8_0040)?.vk, 0xA5) // VK_RMENU
|
||||||
|
XCTAssertEqual(resolve(keyCode: 55, rawFlags: 0x10_0008)?.vk, 0x5B) // VK_LWIN
|
||||||
|
XCTAssertEqual(resolve(keyCode: 54, rawFlags: 0x10_0010)?.vk, 0x5C) // VK_RWIN
|
||||||
|
for (_, down) in [56, 60, 58, 61, 55, 54].compactMap({
|
||||||
|
self.resolve(keyCode: UInt16($0), rawFlags: 0xFF_FFFF)
|
||||||
|
}) {
|
||||||
|
XCTAssertTrue(down)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Keyboards that DON'T report the device bits (the bug this resolver fixes)
|
||||||
|
|
||||||
|
func testControlPressWithoutDeviceBitsFallsBackToHeldState() {
|
||||||
|
// Only NX_CONTROLMASK, no low bits at all: a flag diff of the device bits sees no
|
||||||
|
// transition and drops the key — the fallback must infer DOWN from "not held yet".
|
||||||
|
let down = resolve(keyCode: 59, rawFlags: 0x4_0000, held: false)
|
||||||
|
XCTAssertEqual(down?.vk, 0xA2)
|
||||||
|
XCTAssertEqual(down?.down, true)
|
||||||
|
// And the mirror release (class cleared) still resolves as UP.
|
||||||
|
let up = resolve(keyCode: 59, rawFlags: 0, held: true)
|
||||||
|
XCTAssertEqual(up?.down, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClassBitStillSetButKeyAlreadyHeldResolvesUp() {
|
||||||
|
// Device-bit-less keyboard, second same-class key still holding the class bit:
|
||||||
|
// the best available answer for the key that changed is to flip its held state.
|
||||||
|
let up = resolve(keyCode: 59, rawFlags: 0x4_0000, held: true)
|
||||||
|
XCTAssertEqual(up?.down, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Modifiers the host doesn't consume on this path
|
||||||
|
|
||||||
|
func testFnAndCapsLockResolveToNothing() {
|
||||||
|
XCTAssertNil(resolve(keyCode: 63, rawFlags: 0x80_0000)) // Fn / Globe
|
||||||
|
XCTAssertNil(resolve(keyCode: 57, rawFlags: 0x1_0000)) // Caps Lock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -546,17 +546,40 @@ fn open_video_backend(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
backend @ (WindowsBackend::Amf | WindowsBackend::Qsv) => {
|
WindowsBackend::Amf => {
|
||||||
// AMD AMF / Intel QSV via libavcodec (the Windows analogue of the Linux VAAPI path).
|
// AMD: the native AMF SDK encoder, unconditionally (design/native-amf-encoder.md
|
||||||
|
// Phase 3). The libavcodec AMF fallback and the `PUNKTFUNK_AMF_FFMPEG` hatch were
|
||||||
|
// removed once the native path was validated — two permanently-maintained AMF
|
||||||
|
// paths double the driver-matrix burden, and the one kept "for safety" is exactly
|
||||||
|
// the one with the wedge/latency pathology. No build feature: amfrt64.dll resolves
|
||||||
|
// at runtime like NVENC's DLL. A missing/ancient runtime fails HERE with the
|
||||||
|
// "install/update the AMD driver" message `AmfEncoder::open` raises (§6), rather
|
||||||
|
// than silently degrading — FFmpeg now serves QSV only.
|
||||||
|
amf::AmfEncoder::open(
|
||||||
|
codec,
|
||||||
|
format,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fps,
|
||||||
|
bitrate_bps,
|
||||||
|
bit_depth,
|
||||||
|
chroma,
|
||||||
|
)
|
||||||
|
.map(|e| Box::new(e) as Box<dyn Encoder>)
|
||||||
|
.map_err(|e| {
|
||||||
|
e.context(
|
||||||
|
"native AMF encode failed to open (update the AMD driver / amfrt64.dll \
|
||||||
|
runtime)",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
WindowsBackend::Qsv => {
|
||||||
|
// Intel QSV via libavcodec (stays on FFmpeg — design/native-amf-encoder.md §2:
|
||||||
|
// async_depth=1 + low_power VDEnc is already near the hardware latency floor).
|
||||||
#[cfg(feature = "amf-qsv")]
|
#[cfg(feature = "amf-qsv")]
|
||||||
{
|
{
|
||||||
let vendor = if matches!(backend, WindowsBackend::Amf) {
|
|
||||||
ffmpeg_win::WinVendor::Amf
|
|
||||||
} else {
|
|
||||||
ffmpeg_win::WinVendor::Qsv
|
|
||||||
};
|
|
||||||
ffmpeg_win::FfmpegWinEncoder::open(
|
ffmpeg_win::FfmpegWinEncoder::open(
|
||||||
vendor,
|
ffmpeg_win::WinVendor::Qsv,
|
||||||
codec,
|
codec,
|
||||||
format,
|
format,
|
||||||
width,
|
width,
|
||||||
@@ -570,11 +593,10 @@ fn open_video_backend(
|
|||||||
}
|
}
|
||||||
#[cfg(not(feature = "amf-qsv"))]
|
#[cfg(not(feature = "amf-qsv"))]
|
||||||
{
|
{
|
||||||
let _ = backend;
|
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"AMD/Intel (AMF/QSV) encode requested/detected but this host was built \
|
"Intel (QSV) encode requested/detected but this host was built without \
|
||||||
without it — rebuild with `--features amf-qsv` (needs ffmpeg-next + a \
|
it — rebuild with `--features amf-qsv` (needs ffmpeg-next + a FFMPEG_DIR \
|
||||||
FFMPEG_DIR with the AMF/QSV encoders at build time)"
|
with the QSV encoders at build time)"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -785,14 +807,13 @@ pub fn can_encode_444(codec: Codec) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
WindowsBackend::Amf | WindowsBackend::Qsv => {
|
// AMD: native AMF never encodes 4:4:4 — VCN hardware limit, permanent, no probe
|
||||||
|
// needed (design/native-amf-encoder.md §3.5, Phase 3).
|
||||||
|
WindowsBackend::Amf => false,
|
||||||
|
WindowsBackend::Qsv => {
|
||||||
#[cfg(feature = "amf-qsv")]
|
#[cfg(feature = "amf-qsv")]
|
||||||
{
|
{
|
||||||
let vendor = match windows_resolved_backend() {
|
ffmpeg_win::probe_can_encode_444(ffmpeg_win::WinVendor::Qsv, codec)
|
||||||
WindowsBackend::Qsv => ffmpeg_win::WinVendor::Qsv,
|
|
||||||
_ => ffmpeg_win::WinVendor::Amf,
|
|
||||||
};
|
|
||||||
ffmpeg_win::probe_can_encode_444(vendor, codec)
|
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "amf-qsv"))]
|
#[cfg(not(feature = "amf-qsv"))]
|
||||||
{
|
{
|
||||||
@@ -859,16 +880,18 @@ pub(crate) fn windows_resolved_backend() -> WindowsBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if the active Windows backend is the libavcodec AMF/QSV path (so the codec advertisement
|
/// True if the active Windows backend's codec advertisement comes from a **real GPU probe**
|
||||||
/// consults a real GPU probe rather than the NVENC static superset). Always false when the
|
/// ([`windows_codec_support`]) rather than the NVENC static superset. AMF always qualifies — the
|
||||||
/// `amf-qsv` feature is off — there's then no ffmpeg backend to probe.
|
/// native factory probe (`amf::probe_can_encode`) needs no build feature — while QSV still needs
|
||||||
|
/// the `amf-qsv` (libavcodec) build. Formerly `windows_backend_is_ffmpeg`, renamed when the
|
||||||
|
/// native AMF probe replaced the ffmpeg open-probe (design/native-amf-encoder.md §4, Phase 2).
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub fn windows_backend_is_ffmpeg() -> bool {
|
pub fn windows_backend_is_probed() -> bool {
|
||||||
cfg!(feature = "amf-qsv")
|
match windows_resolved_backend() {
|
||||||
&& matches!(
|
WindowsBackend::Amf => true,
|
||||||
windows_resolved_backend(),
|
WindowsBackend::Qsv => cfg!(feature = "amf-qsv"),
|
||||||
WindowsBackend::Amf | WindowsBackend::Qsv
|
WindowsBackend::Nvenc | WindowsBackend::Software => false,
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detect the encode-GPU vendor from the **selected render adapter** ([`crate::gpu::selected_gpu`]:
|
/// Detect the encode-GPU vendor from the **selected render adapter** ([`crate::gpu::selected_gpu`]:
|
||||||
@@ -897,32 +920,55 @@ fn windows_gpu_vendor() -> Option<GpuVendor> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Probe the active Windows AMF/QSV backend for its encodable codecs (opens a tiny encoder per
|
/// Probe the active Windows AMF/QSV backend for its encodable codecs (cached **per (backend,
|
||||||
/// codec; cached **per (backend, selected GPU)** — a web-console preference change re-probes on the
|
/// selected GPU)** — a web-console preference change re-probes on the newly selected adapter
|
||||||
/// newly selected adapter instead of serving the old GPU's answer for the process lifetime).
|
/// instead of serving the old GPU's answer for the process lifetime). Mirrors
|
||||||
/// Mirrors [`vaapi_codec_support`]; called only when [`windows_backend_is_ffmpeg`] is true. AV1 is
|
/// [`vaapi_codec_support`]; called only when [`windows_backend_is_probed`] is true. AV1 is narrow
|
||||||
/// narrow (AMD RDNA3+, Intel Arc/Xe2+), so it must be probed, not assumed.
|
/// (AMD RDNA3+, Intel Arc/Xe2+), so it must be probed, not assumed.
|
||||||
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
|
///
|
||||||
|
/// Mirrors the session dispatch (design/native-amf-encoder.md Phase 3): **AMD advertises from the
|
||||||
|
/// native AMF factory probe alone** (`amf::probe_can_encode`, on the selected adapter — the same
|
||||||
|
/// path the session opens, so the advertisement can never claim a codec the session can't emit);
|
||||||
|
/// **Intel/QSV uses the libavcodec probe** (all-`false` without the `amf-qsv` feature, matching a
|
||||||
|
/// build that cannot open QSV at all).
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
pub fn windows_codec_support() -> CodecSupport {
|
pub fn windows_codec_support() -> CodecSupport {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
static CACHE: OnceLock<Mutex<HashMap<String, CodecSupport>>> = OnceLock::new();
|
static CACHE: OnceLock<Mutex<HashMap<String, CodecSupport>>> = OnceLock::new();
|
||||||
let vendor = match windows_resolved_backend() {
|
let backend = windows_resolved_backend();
|
||||||
WindowsBackend::Qsv => ffmpeg_win::WinVendor::Qsv,
|
let key = format!("{backend:?}:{}", crate::gpu::selection_key());
|
||||||
_ => ffmpeg_win::WinVendor::Amf,
|
|
||||||
};
|
|
||||||
let key = format!("{vendor:?}:{}", crate::gpu::selection_key());
|
|
||||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
if let Some(c) = cache.lock().unwrap().get(&key) {
|
if let Some(c) = cache.lock().unwrap().get(&key) {
|
||||||
return *c;
|
return *c;
|
||||||
}
|
}
|
||||||
|
let probe_one = |codec: Codec| -> bool {
|
||||||
|
match backend {
|
||||||
|
// AMD: the native factory probe is authoritative — it opens exactly the component the
|
||||||
|
// session will, so the advertisement matches what the encoder can emit by construction.
|
||||||
|
WindowsBackend::Amf => amf::probe_can_encode(codec),
|
||||||
|
WindowsBackend::Qsv => {
|
||||||
|
#[cfg(feature = "amf-qsv")]
|
||||||
|
{
|
||||||
|
ffmpeg_win::probe_can_encode(ffmpeg_win::WinVendor::Qsv, codec)
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "amf-qsv"))]
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Callers gate on `windows_backend_is_probed` — defensively answer "nothing probed"
|
||||||
|
// (the advertisement then falls back to the static superset).
|
||||||
|
WindowsBackend::Nvenc | WindowsBackend::Software => false,
|
||||||
|
}
|
||||||
|
};
|
||||||
let caps = CodecSupport {
|
let caps = CodecSupport {
|
||||||
h264: ffmpeg_win::probe_can_encode(vendor, Codec::H264),
|
h264: probe_one(Codec::H264),
|
||||||
h265: ffmpeg_win::probe_can_encode(vendor, Codec::H265),
|
h265: probe_one(Codec::H265),
|
||||||
av1: ffmpeg_win::probe_can_encode(vendor, Codec::Av1),
|
av1: probe_one(Codec::Av1),
|
||||||
};
|
};
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
backend = ?vendor,
|
?backend,
|
||||||
h264 = caps.h264,
|
h264 = caps.h264,
|
||||||
h265 = caps.h265,
|
h265 = caps.h265,
|
||||||
av1 = caps.av1,
|
av1 = caps.av1,
|
||||||
@@ -933,8 +979,14 @@ pub fn windows_codec_support() -> CodecSupport {
|
|||||||
caps
|
caps
|
||||||
}
|
}
|
||||||
|
|
||||||
// Goal-1 stage 6: GPU/CPU encoders confined to `encode/windows/` (NVENC, AMF/QSV ffmpeg, software) and
|
// Goal-1 stage 6: GPU/CPU encoders confined to `encode/windows/` (NVENC, native AMF, AMF/QSV
|
||||||
// `encode/linux/` (NVENC/CUDA + VAAPI); `#[path]` keeps the `crate::encode::*` module names flat.
|
// ffmpeg, software) and `encode/linux/` (NVENC/CUDA + VAAPI); `#[path]` keeps the
|
||||||
|
// `crate::encode::*` module names flat.
|
||||||
|
// Native AMF (direct SDK, design/native-amf-encoder.md): compiled unconditionally on Windows —
|
||||||
|
// no build feature, the driver-installed amfrt64.dll resolves at runtime like NVENC's DLL.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "encode/windows/amf.rs"]
|
||||||
|
mod amf;
|
||||||
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
|
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
|
||||||
#[path = "encode/windows/ffmpeg_win.rs"]
|
#[path = "encode/windows/ffmpeg_win.rs"]
|
||||||
mod ffmpeg_win;
|
mod ffmpeg_win;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,15 @@
|
|||||||
//! AMD **AMF** and Intel **QSV** hardware encode on Windows via `ffmpeg-next` — the Windows
|
//! Intel **QSV** (and, retained-but-no-longer-dispatched, AMD **AMF**) hardware encode on Windows
|
||||||
//! analogue of the Linux [`super::vaapi`] backend (one libavcodec backend per vendor, selected by
|
//! via `ffmpeg-next` — the Windows analogue of the Linux [`super::vaapi`] backend (one libavcodec
|
||||||
//! encoder name: `*_amf` / `*_qsv`). This is the sibling of the direct-SDK [`super::nvenc`] path
|
//! backend per vendor, selected by encoder name: `*_qsv` / `*_amf`). Sibling of the direct-SDK
|
||||||
//! behind the shared [`Encoder`] trait, selected in [`super::open_video`] (NVIDIA → NVENC,
|
//! [`super::nvenc`] path behind the shared [`Encoder`] trait.
|
||||||
//! AMD → AMF, Intel → QSV).
|
//!
|
||||||
|
//! **Dispatch (design/native-amf-encoder.md Phase 3):** [`super::open_video`] routes AMD to the
|
||||||
|
//! direct-SDK [`super::amf`] encoder, not this module — the libavcodec AMF wrapper's ~2-frame
|
||||||
|
//! output hold and its silent-wedge failure mode are exactly why the native path exists. So in
|
||||||
|
//! production this file serves **QSV only**. The `WinVendor::Amf` machinery is kept (not deleted)
|
||||||
|
//! because it is the comparator in the native-vs-libavcodec latency A/B (`amf::tests::
|
||||||
|
//! amf_latency_ab_bench`), and excising it would churn the shared, Intel-unvalidated QSV code for
|
||||||
|
//! no production benefit. Treat every `WinVendor::Amf` arm below as benchmark-only.
|
||||||
//!
|
//!
|
||||||
//! The capturer hands a `FramePayload::D3d11` texture (NV12/P010 from the D3D11 video processor, or
|
//! The capturer hands a `FramePayload::D3d11` texture (NV12/P010 from the D3D11 video processor, or
|
||||||
//! BGRA/Rgb10a2 as a fallback) on the capturer's own `ID3D11Device`. Two input paths, chosen lazily
|
//! BGRA/Rgb10a2 as a fallback) on the capturer's own `ID3D11Device`. Two input paths, chosen lazily
|
||||||
|
|||||||
@@ -77,9 +77,10 @@ fn base_codec_mode_support() -> u32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Windows AMD/Intel (AMF/QSV): advertise only what the GPU actually encodes (AV1 is narrow, an
|
// Windows AMD/Intel (AMF/QSV): advertise only what the GPU actually encodes (AV1 is narrow, an
|
||||||
// old iGPU might lack HEVC). NVENC and the GPU-less software path keep the static superset.
|
// old iGPU might lack HEVC). AMF probes natively (no build feature needed); QSV needs the
|
||||||
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
|
// libavcodec build. NVENC and the GPU-less software path keep the static superset.
|
||||||
if crate::encode::windows_backend_is_ffmpeg() {
|
#[cfg(target_os = "windows")]
|
||||||
|
if crate::encode::windows_backend_is_probed() {
|
||||||
if let Some(m) = probed_mask(crate::encode::windows_codec_support()) {
|
if let Some(m) = probed_mask(crate::encode::windows_codec_support()) {
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
@@ -91,7 +92,7 @@ fn base_codec_mode_support() -> u32 {
|
|||||||
/// or `None` if the probe found nothing — meaning the GPU wasn't usable at probe time (GPU-less CI,
|
/// or `None` if the probe found nothing — meaning the GPU wasn't usable at probe time (GPU-less CI,
|
||||||
/// a misconfigured/wrong-vendor host), NOT that it encodes zero codecs; the caller then advertises
|
/// a misconfigured/wrong-vendor host), NOT that it encodes zero codecs; the caller then advertises
|
||||||
/// the static superset (pre-probe behaviour) rather than claiming nothing.
|
/// the static superset (pre-probe behaviour) rather than claiming nothing.
|
||||||
#[cfg(any(target_os = "linux", all(target_os = "windows", feature = "amf-qsv")))]
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
fn probed_mask(caps: crate::encode::CodecSupport) -> Option<u32> {
|
fn probed_mask(caps: crate::encode::CodecSupport) -> Option<u32> {
|
||||||
use super::{SCM_AV1_MAIN8, SCM_H264, SCM_HEVC};
|
use super::{SCM_AV1_MAIN8, SCM_H264, SCM_HEVC};
|
||||||
let mut m = 0;
|
let mut m = 0;
|
||||||
|
|||||||
@@ -1,11 +1,28 @@
|
|||||||
# Native AMF encoder — handoff design
|
# Native AMF encoder — handoff design
|
||||||
|
|
||||||
> **Status: PROPOSED (handoff, 2026-07-06).** Design + decision record for replacing the
|
> **Status: PHASES 1 + 2 + 3 IMPLEMENTED (2026-07-06).** `encode/windows/amf.rs` ships the
|
||||||
> libavcodec `*_amf` path with a direct AMF SDK encoder on Windows AMD
|
> direct-SDK encoder per §3 — FFI pinned to AMF headers v1.4.36, bounded poll, native `reset()`.
|
||||||
> (`encode/windows/amf.rs`, the AMD analogue of the direct-SDK `encode/windows/nvenc.rs`).
|
> Phase 2: **AV1** (open-time probe gate; per-codec enum divergences honored — AV1 swaps the
|
||||||
> Written for an implementer without prior context; every in-repo seam is named. Companion
|
> ULL/LL usage values and uses GOP=0 + FORCE_FRAME_TYPE_KEY=1), **intra-refresh**
|
||||||
> context: the encode-stall watchdog + `Encoder::reset()` (punktfunk1.rs / encode.rs, shipped
|
> (`PUNKTFUNK_INTRA_REFRESH` opt-in mirroring Linux NVENC; `caps().intra_refresh` reflects the
|
||||||
> 2026-07-06) and the AMF/QSV backend module docs in `encode/windows/ffmpeg_win.rs`.
|
> driver's actual acceptance), **in-band HDR mastering/CLL metadata** (`*InHDRMetadata` host
|
||||||
|
> buffer; HEVC + AV1), and the **native codec probe**. Phase 3: **the ffmpeg-AMF dispatch
|
||||||
|
> fallback + `PUNKTFUNK_AMF_FFMPEG` hatch are deleted** — AMD dispatch / codec advertisement /
|
||||||
|
> 4:4:4 answer are native-only; FFmpeg serves QSV only (`ffmpeg_win.rs`'s AMF machinery is kept
|
||||||
|
> solely as the A/B comparator). `windows_backend_is_ffmpeg` → `windows_backend_is_probed`. **The
|
||||||
|
> §7 field-silence gate on Phase 3 was pre-empted on explicit direction** — see the §7 gate note
|
||||||
|
> for what that costs (VP-format-fallback now fails the session; AMFVideoConverter is the owed
|
||||||
|
> native fix).
|
||||||
|
> Live-validated on the lab Ryzen iGPU (VCN 3): AVC + HEVC batches across a native in-place reset
|
||||||
|
> (Annex-B IDR contract, FIFO pairing); HEVC Main10 P010 with the mastering + CLL prefix SEIs
|
||||||
|
> **confirmed present in the encoded IDR**; intra-refresh property accepted on both codecs; probe
|
||||||
|
> honestly answers h264/h265=true, av1=false on this RDNA2 part. The **§5.2 latency A/B is
|
||||||
|
> measured** (`amf_latency_ab_bench`, 1080p60 HEVC): native `encode_us` p50 **5.18 ms (0.31 frame
|
||||||
|
> periods)** vs libavcodec-AMF **16.9 ms (1.01)** — 3.3× lower, the frame-hold gone. Owed: §5.3
|
||||||
|
> on-glass session behaviors + soak (macOS-client on-glass test in progress), and field
|
||||||
|
> validation on ≥2 VCN generations (AV1/RDNA3 has no lab hardware).
|
||||||
|
> Companion context: the encode-stall watchdog + `Encoder::reset()` (punktfunk1.rs / encode.rs,
|
||||||
|
> shipped 2026-07-06) and the QSV backend module docs in `encode/windows/ffmpeg_win.rs`.
|
||||||
|
|
||||||
## 1. Why (measured, not speculative)
|
## 1. Why (measured, not speculative)
|
||||||
|
|
||||||
@@ -142,8 +159,21 @@ log-and-continue, not fatal — availability varies by VCN generation/driver.
|
|||||||
|
|
||||||
### 3.5 Error + stall semantics (interplay with the 2026-07-06 watchdog)
|
### 3.5 Error + stall semantics (interplay with the 2026-07-06 watchdog)
|
||||||
|
|
||||||
- `SubmitInput` → `AMF_INPUT_FULL`: return `Err` from `submit` — the session loop's
|
- `SubmitInput` → `AMF_INPUT_FULL`: **back-pressure, NOT a wedge — drain and retry, do not
|
||||||
submit-failure path runs the bounded in-place reset. Any other non-OK result: `Err`.
|
reset.** (Original prescription "return `Err` → in-place reset" was **wrong**, disproven
|
||||||
|
on-glass 2026-07-06: at throughput-ceiling loads — 5120x1440@240 P010 on the lab iGPU —
|
||||||
|
`INPUT_FULL` → reset → forced IDR → a bigger keyframe → worse overload → a ~320 ms
|
||||||
|
reset/IDR cascade, strictly worse than the libavcodec path's 16-deep input queue riding it
|
||||||
|
out as latency. The log showed dozens of `submit failed … AMF_INPUT_FULL … rebuilt in place`
|
||||||
|
and **zero** watchdog stalls.) The shipped handling: `submit` bounds in-flight surfaces below
|
||||||
|
the input ring depth (`pending.len() < RING`) by draining finished AUs (buffered in a `ready`
|
||||||
|
deque for `poll`, FIFO-preserved) to free a slot *before* reusing it, and treats a stray
|
||||||
|
`INPUT_FULL` from `SubmitInput` the same way (drain + retry the surface). Only a drain that
|
||||||
|
makes NO progress for a bounded budget (`INPUT_DRAIN_BUDGET`, 200 ms — well under the
|
||||||
|
session watchdog's ~2 s) is a genuine wedge that escalates to `Err` → the in-place reset. This
|
||||||
|
also closed a **latent corruption**: the old path let in-flight grow to AMF's internal input
|
||||||
|
queue limit (16) against a ring of 4, so surfaces referenced ring slots already overwritten —
|
||||||
|
the reset masked it. Any other non-OK `SubmitInput` result: `Err`.
|
||||||
- `QueryOutput` → `AMF_REPEAT`: keep spinning within the poll budget, then `Ok(None)`.
|
- `QueryOutput` → `AMF_REPEAT`: keep spinning within the poll budget, then `Ok(None)`.
|
||||||
`AMF_EOF`: `Ok(None)` after flush. Anything else: `Err` (the loop's poll-error path
|
`AMF_EOF`: `Ok(None)` after flush. Anything else: `Err` (the loop's poll-error path
|
||||||
resets).
|
resets).
|
||||||
@@ -189,13 +219,27 @@ Baseline first, on the ffmpeg path (already deployed 2026-07-06 with the watchdo
|
|||||||
long session on the iGPU with `PUNKTFUNK_PERF=1`, record `wait_us_p50/p99`,
|
long session on the iGPU with `PUNKTFUNK_PERF=1`, record `wait_us_p50/p99`,
|
||||||
`encode_us`, client-measured latency, and whether the watchdog ever fires. Then per phase:
|
`encode_us`, client-measured latency, and whether the watchdog ever fires. Then per phase:
|
||||||
|
|
||||||
1. Open/probe smoke per codec (AVC, HEVC, HEVC-10) on the iGPU.
|
1. Open/probe smoke per codec (AVC, HEVC, HEVC-10) on the iGPU. **DONE** — the gated live
|
||||||
|
tests in `amf.rs` (`amf_encode_live_smoke` AVC+HEVC+AV1-probe, `amf_hdr_encode_live_smoke`,
|
||||||
|
`amf_native_probe_live`, `amf_intra_refresh_property_live`) pass on the lab Ryzen iGPU
|
||||||
|
(VCN3/RDNA2): both codecs across a native `reset()`, HEVC Main10 IDR carrying the
|
||||||
|
mastering(137)+CLL(144) SEIs byte-verified, intra-refresh property accepted, probe honestly
|
||||||
|
`h264/h265=true, av1=false`.
|
||||||
2. A/B the encode latency: expect `encode_us` p50 ~2 frame periods → ≤ 1 frame period
|
2. A/B the encode latency: expect `encode_us` p50 ~2 frame periods → ≤ 1 frame period
|
||||||
(see §3.3 for why `wait_us` is the wrong metric on the ffmpeg side); client
|
(see §3.3 for why `wait_us` is the wrong metric on the ffmpeg side). **MEASURED**
|
||||||
capture→glass p50 drops by ~30 ms at 60 Hz / ~17 ms at 120 Hz. Zero-copy baseline for
|
2026-07-06 by the gated `amf_latency_ab_bench` (`PUNKTFUNK_AMF_BENCH=1`, 1080p60 HEVC,
|
||||||
the input side already measured 2026-07-06 on the lab iGPU (1080p120 HDR P010):
|
180 paced frames, same D3D11 NV12 input to both encoders, lab iGPU, debug build):
|
||||||
`submit_us` p50 2.7–2.9 ms (system readback) → **0.26 ms** (zero-copy D3D11), p99
|
native `encode_us` p50 **5.18 ms (0.31 frame periods)** / p99 5.81 ms vs libavcodec-AMF
|
||||||
6.6 ms → 0.5 ms.
|
p50 **16.9 ms (1.01 frame periods)** / p99 17.5 ms — **3.3× lower, ~11.7 ms/frame saved**,
|
||||||
|
and the native path is decisively sub-frame (the ~2-frame hold that used to live in
|
||||||
|
`encode_us` is gone). Note the ffmpeg baseline came in at ~1 frame period, not the ~2 this
|
||||||
|
plan projected: the shipping ffmpeg config already sets AMF `latency=true` (a ~1-frame
|
||||||
|
hold), so the realized win is 3.3× / ~12 ms rather than the ~30 ms projected against an
|
||||||
|
un-tuned 2-frame baseline; direction and sub-frame collapse are exactly as §3.3 described.
|
||||||
|
Release builds should show a lower native number still (debug charges host-side
|
||||||
|
surface-create + copy-submit into the 5.18 ms). Zero-copy baseline for the input side
|
||||||
|
already measured 2026-07-06 on the lab iGPU (1080p120 HDR P010): `submit_us` p50 2.7–2.9 ms
|
||||||
|
(system readback) → **0.26 ms** (zero-copy D3D11), p99 6.6 ms → 0.5 ms.
|
||||||
3. Behavior: IDR on connect; mode switch mid-stream; HDR session (PQ VUI + 0xCE
|
3. Behavior: IDR on connect; mode switch mid-stream; HDR session (PQ VUI + 0xCE
|
||||||
convergence); client keyframe-request recovery; encoder `reset()` under an injected
|
convergence); client keyframe-request recovery; encoder `reset()` under an injected
|
||||||
failure; ≥30 min soak for the freeze class (watchdog log line
|
failure; ≥30 min soak for the freeze class (watchdog log line
|
||||||
@@ -221,8 +265,23 @@ long session on the iGPU with `PUNKTFUNK_PERF=1`, record `wait_us_p50/p99`,
|
|||||||
|
|
||||||
## 7. Phasing
|
## 7. Phasing
|
||||||
|
|
||||||
| Phase | Scope | Exit criterion |
|
| Phase | Scope | Exit criterion | Status |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 1 | FFI layer + AVC/HEVC (SDR + 10-bit HDR), bounded poll, native `reset()`, dispatch with ffmpeg fallback + `PUNKTFUNK_AMF_FFMPEG` hatch | §5.2–5.3 pass on the lab iGPU |
|
| 1 | FFI layer + AVC/HEVC (SDR + 10-bit HDR), bounded poll, native `reset()`, dispatch with ffmpeg fallback + `PUNKTFUNK_AMF_FFMPEG` hatch | §5.2–5.3 pass on the lab iGPU | **DONE** 2026-07-06 (§5.2 measured; §5.3 on-glass in progress) |
|
||||||
| 2 | Intra-refresh (`caps().intra_refresh`), in-band HDR SEI (`supports_hdr_metadata`), AV1, native codec probe | field-validated on ≥2 VCN generations |
|
| 2 | Intra-refresh (`caps().intra_refresh`), in-band HDR SEI (`supports_hdr_metadata`), AV1, native codec probe | field-validated on ≥2 VCN generations | **CODE DONE** 2026-07-06 (lab VCN3 only; AV1/RDNA3 + 2nd VCN gen still owed) |
|
||||||
| 3 | Delete the ffmpeg-AMF dispatch arm + hatch; FFmpeg remains QSV-only | one release of field silence on the fallback label |
|
| 3 | Delete the ffmpeg-AMF dispatch arm + hatch; FFmpeg remains QSV-only | one release of field silence on the fallback label | **DONE** 2026-07-06 — see the gate note below |
|
||||||
|
|
||||||
|
**Phase 3 gate note (honesty):** the stated exit criterion (one release of field silence on the
|
||||||
|
fallback label) was **NOT met** — Phase 3 was cut the same day the native path was written, on
|
||||||
|
explicit direction, alongside a live macOS-client on-glass test. What Phase 3 removed: the
|
||||||
|
`WindowsBackend::Amf` libavcodec fallback arm, the `PUNKTFUNK_AMF_FFMPEG` hatch, and the
|
||||||
|
AMF→ffmpeg routes in `windows_codec_support` / `can_encode_444`. AMD dispatch, codec
|
||||||
|
advertisement, and the 4:4:4 answer are all native-only now; FFmpeg (`ffmpeg_win.rs`) is reached
|
||||||
|
only for QSV in production (its `WinVendor::Amf` machinery is retained solely as the
|
||||||
|
`amf_latency_ab_bench` comparator, not deleted — excising it would churn the Intel-unvalidated
|
||||||
|
QSV code for no gain). **Residual risk this pre-emption carries:** with the ffmpeg readback path
|
||||||
|
gone, an AMD box whose capturer can't produce video-processor NV12/P010 (falls back to
|
||||||
|
Bgra/Rgb10a2, or hands DDA CPU frames) now **fails the session** instead of degrading — the
|
||||||
|
design's answer is the native AMFVideoConverter front-end (§3.2), owed if that fallback is ever
|
||||||
|
seen in the field. Not observed on lab hardware (the VP yields NV12/P010). Reverting Phase 3 is a
|
||||||
|
small, localized diff if field data turns up trouble.
|
||||||
|
|||||||
Reference in New Issue
Block a user