Compare commits

...
Author SHA1 Message Date
enricobuehler da3c0308c5 docs(windows): LGPL notice still named n7.1 after the 8.1 bump
The notice backs the written offer of corresponding source in the signed
installer and the MSIX, so the release it names has to match the DLLs the
provisioning script actually ships.
2026-08-05 10:31:16 +02:00
enricobuehler abc6d790bd feat(client): make concealed decodes visible instead of silently erasing them
libavcodec reports reference damage by LOGGING and then concealing: HEVC's `Error
constructing the frame RPS`, `First slice in a frame missing`, `Previous slice
segment missing` (hevcdec.c) and H.264's reference-list equivalents all emit at
AV_LOG_ERROR and then hand back a frame and a success code. Every one of them means
the picture on screen was built from references the decoder could not resolve.

We threw all of it away. `quiet_ffmpeg_log()` set libavcodec's level to fatal-only,
which silences its stderr sink, and we installed no callback — so the messages went
nowhere. Worse, `decode_frame`'s Ok arm then RESET the failure streak, so a decoder
concealing every other frame looked healthier than one erroring occasionally: it
never asked for an IDR, and under the infinite GOP nothing else would, so the damage
stayed for the life of the session.

Now: a real av_log callback routes libavcodec into tracing (these lines are decode
evidence and belong in the log a field report ships us) and counts ERROR-and-worse.
`decode_frame` brackets each AU; a backend that returns a frame while that counter
moved decoded something libavcodec itself called broken, and we ask for a keyframe.

Concealment gets its OWN counter, deliberately not the hardware-demotion streak. An
ordinary packet loss conceals every AU until the requested IDR lands — at 120 fps a
100-300 ms round trip is 12-36 frames, well past VAAPI_DEMOTE_AFTER and past
HW_DEMOTE_MIN_STREAK too if that IDR is itself lost. Feeding it there would demote a
healthy decoder for surviving a lossy second.

Scope, stated plainly: this catches the class libavcodec KNOWS about. It does not
catch a driver that returns wrong pixels without complaint, which is what the
Windows FFmpeg-Vulkan reports look like — and that class has no in-band signal at
all today. Verified against FFmpeg n8.1 source: vulkan_decode.c calls
ff_vk_exec_pool_init(..., nb_queries=0, ...), so VK_QUERY_TYPE_RESULT_STATUS_ONLY_KHR
— the only channel a Vulkan driver has to report a failed decode — is never read;
and neither h264dec.c nor hevcdec.c ever sets AV_FRAME_FLAG_CORRUPT. Closing that
needs an upstream patch, not a client change.

Verified on BOTH platforms, because the callback's va_list parameter is the one part
whose ABI differs and a wrong one faults inside libavcodec at call time rather than
failing to build: Linux 117/117 (linux/amd64 container) + clippy --all-targets
-D warnings clean; Windows 109/109 against FFmpeg n8.1.2 on the CI runner, where
`installing_the_log_callback_is_safe_and_idempotent` drives a real av_log through
libavcodec's dispatcher into our callback.
2026-08-05 09:03:07 +02:00
enricobuehler d026e50a4b build(ci): FFmpeg 7.1 -> 8.1 for the Windows host + client trees
FFmpeg's Vulkan Video hwaccel is the youngest code in our decode chain (merged
around 6.1/7.0) and 7.1 is a stabilisation branch that does not receive its ongoing
fixes. Both field reports of silent inter-frame corruption on Windows — Intel B580
(2026-07) and AMD Xbox Ally X (2026-08) — sit on that hwaccel, while the mature
d3d11va one streams clean on the same boxes. Getting onto current FFmpeg is the
cheapest thing that can move that, and it keeps Vulkan Video as the default rather
than demoting a whole vendor to DXVA.

Not new ground for our API usage: Ubuntu 26.04 already ships avcodec 62 (FFmpeg
8.0.1) and pf-client-core clippies clean there today. ffmpeg-sys-next 8.1.0 accepts
avcodec 56..63, so the binding needs no change.

The presence check is now version-stamped. It used to test only for
`lib\avcodec.lib`, which meant editing the pin here would have done NOTHING on every
already-provisioned runner — CI would have gone on building against 7.1 while this
file said 8.1, and the bump would have looked applied without being applied. A
`.punktfunk-ffmpeg-version` marker, written only after a successful extract, makes
runners re-provision themselves when the pin moves.

Still lgpl-shared (the licensing posture is unchanged) and still SHA-256 pinned
fail-closed; both pins re-captured 2026-08-05 from the current n8.1 assets.

Verified on the CI runner against a SEPARATE tree (C:\temp\ff81 — the canonical
C:\Users\Public\ffmpeg is shared with every other branch's jobs and was left alone):
ffmpeg n8.1.2, `cargo check -p pf-client-core --all-targets` and
`cargo check -p pf-encode --features amf-qsv,qsv --all-targets` both clean.
2026-08-05 08:38:19 +02:00
enricobuehler d6b9462092 fix(client): pad_audio reached for a module name Windows doesn't have
`audio_wasapi.rs` is mounted as `crate::audio` (lib.rs, `#[cfg(windows)]
#[path = "audio_wasapi.rs"] pub mod audio;`), so there is no `crate::audio_wasapi`
path to reach it by. `pad_audio.rs` used one anyway, which means pf-client-core has
not compiled for Windows since 35285afa landed in the pad-audio merge (8983ec04) —
E0433, so every Windows client build on main is red, not just this call site.

Found while validating the FFmpeg bump on the CI runner; unrelated to it, hence its
own commit.
2026-08-05 08:38:05 +02:00
5 changed files with 274 additions and 22 deletions
+4 -2
View File
@@ -15,8 +15,10 @@
# target with host tools, so no ARM64 runner is needed — the cc/cmake crates pick the ARM64
# compiler from the target triple (SDL3 + libopus build-from-source cross-compile fine). The one
# arch-specific external dep is FFmpeg's import libs: the runner keeps an x64 tree at
# C:\Users\Public\ffmpeg and an ARM64 tree at C:\Users\Public\ffmpeg-arm64 (both FFmpeg 7.x /
# avcodec-61); the matrix points FFMPEG_DIR at the right one. aarch64 can't *run* on the x64 host,
# C:\Users\Public\ffmpeg and an ARM64 tree at C:\Users\Public\ffmpeg-arm64 (both FFmpeg 8.1 /
# avcodec-62 — version pinned in scripts/ci/provision-windows-punktfunk-extras.ps1, which
# re-provisions a runner automatically when that pin moves); the matrix points FFMPEG_DIR at the
# right one. aarch64 can't *run* on the x64 host,
# so fmt + test run only for x64.
#
# The MSVC/WinUI/FFmpeg toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, the x64 FFmpeg,
+5 -5
View File
@@ -840,7 +840,7 @@ struct PadOut {
#[cfg(windows)]
impl PadOut {
/// Correlate (HID container → endpoint id) and open a shared event-driven render stream ON
/// that endpoint (`audio_wasapi::render_thread`'s shape — autoconvert, default period).
/// that endpoint (`audio::render_thread`'s shape — autoconvert, default period).
fn open() -> anyhow::Result<PadOut> {
use anyhow::{anyhow, Context};
let hid_path =
@@ -921,10 +921,10 @@ fn pad_render_thread(
const BLOCK_ALIGN: usize = PAD_CHANNELS * 4; // f32 interleaved
let enumerator = wasapi::DeviceEnumerator::new().context("DeviceEnumerator")?;
// Not `get_device`: that helper resolves through a freed string — see
// [`crate::audio_wasapi::device_by_id`].
let device =
crate::audio_wasapi::device_by_id(&enumerator, &Direction::Render, endpoint_id)
.map_err(|e| anyhow!("correlated endpoint not found: {e:#}"))?;
// [`crate::audio::device_by_id`]. (`audio_wasapi.rs` is mounted as `crate::audio`
// on Windows via `#[path]`, so it has no `crate::audio_wasapi` name to reach it by.)
let device = crate::audio::device_by_id(&enumerator, &Direction::Render, endpoint_id)
.map_err(|e| anyhow!("correlated endpoint not found: {e:#}"))?;
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
// FL|FR|BL|BR: front pair = the pad's speaker, back pair = the voice coils.
let desired = WaveFormat::new(32, 32, &SampleType::Float, 48_000, PAD_CHANNELS, Some(0x33));
+235 -5
View File
@@ -286,6 +286,9 @@ pub struct Decoder {
/// The pump drains it and asks the host — under the infinite GOP there is no periodic
/// keyframe, so a rebuilt/erroring decoder would otherwise stay gray/frozen forever.
want_keyframe: bool,
/// Consecutive frames libavcodec concealed rather than decoded — see
/// [`Decoder::note_concealed`]. Separate from [`Self::vaapi_fails`] on purpose.
concealed_run: u32,
/// The presenter has the win32 external-memory import path, so D3D11VA frames can reach
/// the screen — kept for the mid-session Vulkan→D3D11VA demotion rung (the Windows
/// analog of Linux's Vulkan→VAAPI rung).
@@ -435,12 +438,78 @@ pub fn decodable_codecs_for(vk: Option<&VulkanDecodeDevice>) -> u8 {
bits
}
/// Count of libavcodec messages at `AV_LOG_ERROR` or worse since process start, written
/// by [`pf_av_log`]. [`Decoder::decode_frame`] samples it around each AU: a backend that
/// returns a frame while this moved decoded something libavcodec itself called broken.
///
/// Process-global because `av_log_set_callback` is. A second concurrent session would make
/// the attribution fuzzy (both sessions' errors land in one counter) — the consequence is a
/// spurious keyframe request on the other session, which is exactly what it would do for a
/// real error anyway, so it is not worth a per-context registry.
static AVCODEC_ERRORS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Does an `av_log` level mean "this decode is wrong", as opposed to chatter?
///
/// libavcodec's ladder is PANIC 0 / FATAL 8 / ERROR 16 / WARNING 24 / INFO 32 / VERBOSE 40.
/// The cut is at ERROR deliberately: the reference-damage messages we are hunting
/// (`Error constructing the frame RPS`, `First slice in a frame missing`, `Previous slice
/// segment missing`) are all ERROR, while WARNING is full of benign noise like swscale's
/// "deprecated pixel format used" — counting that would request a keyframe on every frame
/// of a perfectly good session.
fn counts_as_decode_error(level: std::os::raw::c_int) -> bool {
const AV_LOG_ERROR: std::os::raw::c_int = 16;
level <= AV_LOG_ERROR
}
/// libavcodec's `av_log` sink.
///
/// The `va_list` argument is deliberately typed `*mut c_void` and NEVER read — formatting
/// it would need the unstable `c_variadic` feature, and we only want the level and the
/// message identity. `fmt` is the static format string (`"Error constructing the frame
/// RPS.\n"`), which is enough to say what happened; only the substituted values are lost.
///
/// # Safety
/// Called by libavcodec from decoder threads. `fmt` is a NUL-terminated static string
/// (libavcodec passes only string literals). We do not touch `avcl` or `vl`.
unsafe extern "C" fn pf_av_log(
_avcl: *mut std::os::raw::c_void,
level: std::os::raw::c_int,
fmt: *const std::os::raw::c_char,
_vl: *mut std::os::raw::c_void,
) {
if counts_as_decode_error(level) {
AVCODEC_ERRORS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
if fmt.is_null() {
return;
}
// SAFETY: libavcodec only ever passes a NUL-terminated static format string here.
let msg = unsafe { std::ffi::CStr::from_ptr(fmt) }
.to_string_lossy()
.trim_end()
.to_string();
// Route into tracing rather than the raw stderr libavcodec would otherwise write to:
// these lines are decode evidence and belong in the log a field report ships us.
if counts_as_decode_error(level) {
tracing::debug!(target: "ffmpeg", level, "{msg}");
} else {
tracing::trace!(target: "ffmpeg", level, "{msg}");
}
}
/// libavcodec logs reference-frame recovery to the process stderr very verbosely
/// (`First slice in a frame missing`, `Could not find ref with POC …`, `Error
/// constructing the frame RPS`) — normal chatter while the decoder waits for a keyframe
/// after loss, but a raw flood in the user's terminal (it bypasses our tracing). Default
/// it to fatal-only; `PUNKTFUNK_FFMPEG_LOG=<quiet|error|warning|info|debug>` restores it
/// for decode debugging. Process-global; set once per decoder build (idempotent).
/// after loss, but a raw flood in the user's terminal (it bypasses our tracing).
///
/// Two jobs. It sets the level (default fatal-only;
/// `PUNKTFUNK_FFMPEG_LOG=<quiet|error|warning|info|debug>` restores it for decode
/// debugging) AND installs [`pf_av_log`], which is what makes those messages *countable*.
/// The level only gates libavcodec's own default sink; a custom callback is handed every
/// message regardless, so quieting the terminal no longer means throwing the signal away —
/// which is what it meant before, for the whole life of this decoder.
///
/// Process-global; set once per decoder build (idempotent).
fn quiet_ffmpeg_log() {
use ffmpeg::util::log::Level;
let level = match std::env::var("PUNKTFUNK_FFMPEG_LOG").ok().as_deref() {
@@ -452,6 +521,33 @@ fn quiet_ffmpeg_log() {
_ => Level::Fatal,
};
ffmpeg::util::log::set_level(level);
let cb: unsafe extern "C" fn(
*mut std::os::raw::c_void,
std::os::raw::c_int,
*const std::os::raw::c_char,
*mut std::os::raw::c_void,
) = pf_av_log;
// The turbofish clippy asks for cannot be written here: the target type is whatever
// bindgen generated for `va_list` on THIS target (`*mut __va_list_tag` on Linux, a
// different type on Windows), so naming it would need a cfg ladder per platform and
// per arch — the exact portability problem this signature avoids.
#[allow(clippy::missing_transmute_annotations)]
// SAFETY: `av_log_set_callback` stores a function pointer libavcodec calls for every
// message; `pf_av_log` is a `extern "C"` fn with static lifetime, so it stays valid for
// the process. The transmute only retypes the 4th parameter from our `*mut c_void` to
// whatever bindgen named `va_list` on this target — that parameter is pointer-sized on
// every target we build (x86-64/aarch64 SysV pass the va_list struct indirectly; the
// Windows x64/arm64 ABI defines `va_list` as a plain `char *`), and `pf_av_log` never
// dereferences it, so no ABI-visible difference remains.
unsafe {
ffmpeg::ffi::av_log_set_callback(Some(std::mem::transmute(cb)))
};
}
/// Snapshot of [`AVCODEC_ERRORS`], for bracketing one decode call.
fn avcodec_error_count() -> u64 {
AVCODEC_ERRORS.load(std::sync::atomic::Ordering::Relaxed)
}
impl Decoder {
@@ -492,6 +588,7 @@ impl Decoder {
vaapi_fails: 0,
first_fail: None,
want_keyframe: false,
concealed_run: 0,
#[cfg(windows)]
d3d11_import,
#[cfg(windows)]
@@ -711,6 +808,7 @@ impl Decoder {
vaapi_fails: 0,
first_fail: None,
want_keyframe: false,
concealed_run: 0,
// A PyroWave session never demotes (nothing else decodes it — a failure
// renegotiates the codec instead), so the D3D11VA rebuild facts are unused
// here; keep them well-formed rather than plumbing them in for nothing.
@@ -743,6 +841,47 @@ impl Decoder {
Ok(())
}
/// A decode that **succeeded loudly**: libavcodec logged an error and then concealed,
/// handing back a frame and a success code. HEVC does this for `Error constructing the
/// frame RPS` / `First slice in a frame missing` / `Previous slice segment missing`,
/// H.264 for its reference-list equivalents — every one of them means the picture was
/// built on references the decoder could not resolve, i.e. it is wrong on screen.
///
/// Before this existed the `Ok` arm reset the streak, so this class was not merely
/// undetected but actively *erased* the evidence of the errors around it: a decoder
/// concealing every second frame looked perfectly healthy, never asked for an IDR, and
/// under the infinite GOP kept the damage for the life of the session.
///
/// The response is the IDR request, which is the thing that actually repairs the
/// picture. It deliberately does NOT feed [`Self::vaapi_fails`], the hardware-demotion
/// streak: an ordinary packet loss makes the decoder conceal every AU until the
/// requested IDR lands, and at 120 fps a 100300 ms round trip is 1236 of them — far
/// past [`VAAPI_DEMOTE_AFTER`], and past [`HW_DEMOTE_MIN_STREAK`] too if that IDR is
/// itself lost. Counting concealment there would demote a perfectly good decoder for
/// the crime of surviving a lossy second. Its own counter keeps the evidence (and the
/// log line a field report needs) without arming that trigger.
fn note_concealed(&mut self) {
self.want_keyframe = true;
self.concealed_run = self.concealed_run.saturating_add(1);
// Every AU of a loss burst comes through here, so this is debug, not warn — the
// run length is the interesting number and it is on the line.
tracing::debug!(
run = self.concealed_run,
"decoder concealed a damaged frame (libavcodec logged an error but returned \
success) — requesting a keyframe"
);
}
/// Consecutive concealed frames, reset by the first clean decode. A healthy session
/// shows short runs that end when the requested IDR lands; a run that keeps climbing
/// across many IDR cycles is a decoder producing wrong pictures from good input, which
/// is the shape of the Windows FFmpeg-Vulkan field reports. Exposed so the pump can put
/// it on the stats line — nothing else can see it, because libavcodec reports this by
/// logging rather than by failing.
pub fn concealed_run(&self) -> u32 {
self.concealed_run
}
/// Feed one access unit; returns the decoded frame (the host's streams are
/// one-in/one-out). A software decode error after packet loss is survivable — log
/// upstream and keep feeding. A VAAPI error re-requests an IDR and retries the hardware
@@ -769,6 +908,10 @@ impl Decoder {
user_flags: u32,
complete: bool,
) -> Result<Option<DecodedImage>> {
// Bracket the decode: libavcodec reports reference damage by LOGGING and then
// concealing, returning a frame and a success code. Without this the whole class is
// invisible to us — see `pf_av_log` and `note_concealed`.
let errors_before = avcodec_error_count();
let result = match &mut self.backend {
Backend::Vulkan(v) => {
debug_assert!(complete, "partial AUs are pyrowave-only");
@@ -792,8 +935,19 @@ impl Decoder {
};
match result {
Ok(f) => {
self.vaapi_fails = 0;
self.first_fail = None;
if avcodec_error_count() > errors_before {
self.note_concealed();
} else {
if self.concealed_run > 0 {
tracing::debug!(
run = self.concealed_run,
"decoder recovered — clean frame after a concealment run"
);
self.concealed_run = 0;
}
self.vaapi_fails = 0;
self.first_fail = None;
}
Ok(f)
}
Err(e) => {
@@ -1132,6 +1286,82 @@ mod tests {
assert!(!decode_device(0x8086, "Intel(R) Arc(TM) Pro Graphics").prefer_vulkan_first());
}
/// The cut that decides whether a libavcodec message arms a keyframe request. ERROR and
/// worse mean the picture is wrong; WARNING and below are chatter. Getting this wrong is
/// not subtle in either direction — too low and every session requests keyframes forever
/// off swscale's "deprecated pixel format used", too high and the concealment class this
/// whole mechanism exists to catch goes back to being invisible.
#[test]
fn only_error_and_worse_count_as_a_bad_decode() {
// PANIC / FATAL / ERROR
assert!(counts_as_decode_error(0));
assert!(counts_as_decode_error(8));
assert!(counts_as_decode_error(16));
// WARNING / INFO / VERBOSE / DEBUG / TRACE
assert!(!counts_as_decode_error(24));
assert!(!counts_as_decode_error(32));
assert!(!counts_as_decode_error(40));
assert!(!counts_as_decode_error(48));
assert!(!counts_as_decode_error(56));
}
/// The callback itself, through the same pointer libavcodec will call it by — the FFI
/// signature and the counter increment, not just the classifier. Deltas rather than
/// absolute values because the counter is process-global and tests run in parallel.
#[test]
fn the_log_callback_counts_errors_and_ignores_chatter() {
let msg = c"pf test message\n";
let before = avcodec_error_count();
// SAFETY: exactly what libavcodec does — a NUL-terminated static format string, a
// null context, and a va_list `pf_av_log` never reads (null is therefore fine).
unsafe { pf_av_log(std::ptr::null_mut(), 16, msg.as_ptr(), std::ptr::null_mut()) };
assert!(
avcodec_error_count() > before,
"an ERROR-level message must be counted"
);
let mid = avcodec_error_count();
// SAFETY: as above.
unsafe { pf_av_log(std::ptr::null_mut(), 24, msg.as_ptr(), std::ptr::null_mut()) };
assert_eq!(
avcodec_error_count(),
mid,
"a WARNING-level message must NOT be counted"
);
// A null fmt must not be dereferenced (defensive: libavcodec always passes one).
let pre_null = avcodec_error_count();
// SAFETY: the null-fmt path returns before any dereference — that is what is under test.
unsafe {
pf_av_log(
std::ptr::null_mut(),
16,
std::ptr::null(),
std::ptr::null_mut(),
)
};
assert_eq!(avcodec_error_count(), pre_null + 1);
}
/// Installing the callback must succeed on whatever this platform's `va_list` is — the
/// transmute in `quiet_ffmpeg_log` is the one place the FFI signature could be wrong,
/// and a wrong one is a crash inside libavcodec rather than a compile error.
#[test]
fn installing_the_log_callback_is_safe_and_idempotent() {
quiet_ffmpeg_log();
quiet_ffmpeg_log();
// Drive a real message through libavcodec's own dispatcher, which now routes to
// `pf_av_log`: this is the end-to-end proof that the installed pointer is callable.
let before = avcodec_error_count();
// SAFETY: `av_log` with a literal format string and no varargs to substitute.
unsafe { ffmpeg::ffi::av_log(std::ptr::null_mut(), 16, c"pf install probe\n".as_ptr()) };
assert!(
avcodec_error_count() > before,
"libavcodec must reach our callback after quiet_ffmpeg_log()"
);
}
/// Lock the DRM FourCC magic numbers against typos — these are the exact values
/// `<drm_fourcc.h>` defines, and a wrong one is what painted the Steam Deck green.
#[test]
@@ -29,7 +29,7 @@ Source code
The bundled binaries are unmodified builds produced by the BtbN/FFmpeg-Builds
project. The exact source for the FFmpeg release used is available from:
* FFmpeg project source: https://ffmpeg.org/download.html (release n7.1)
* FFmpeg project source: https://ffmpeg.org/download.html (release n8.1)
* Exact build recipe: https://github.com/BtbN/FFmpeg-Builds
A copy of the corresponding FFmpeg source for the version shipped here is
@@ -32,20 +32,39 @@ if (Test-Path $rustup) {
# the separate BSD-2 openh264 crate; NVENC is the direct NVIDIA SDK). lgpl-shared keeps the
# bundled DLLs LGPL-2.1+ (dynamic linking satisfies the relink duty) rather than GPL, so the
# shipped installer/MSIX stay consistent with punktfunk's MIT OR Apache-2.0 posture.
# MIGRATION: a runner previously provisioned with the old *gpl-shared* trees must be
# re-provisioned - delete C:\Users\Public\ffmpeg and C:\Users\Public\ffmpeg-arm64, then re-run.
# VERSION: n8.1 (libavcodec 62). Bumped from n7.1 on 2026-08-05 — FFmpeg's **Vulkan Video
# hwaccel** is the youngest code in our decode chain (merged ~6.1/7.0), 7.1 is a stabilisation
# branch that does not receive its ongoing fixes, and the two field reports of silent inter-frame
# corruption on Windows (Intel B580 2026-07, AMD Xbox Ally X 2026-08) both sit on that hwaccel
# while the mature d3d11va one is clean. Linux already ships avcodec 62 (Ubuntu 26.04 = 8.0.1) and
# pf-client-core compiles clean against it, so 8.x is not new ground for our API usage.
# MIGRATION is AUTOMATIC and must stay that way: the presence check below keys off $Version, so a
# runner provisioned with an older tree re-provisions itself on the next CI job. It used to test
# only for `lib\avcodec.lib`, which meant a version bump here silently did NOTHING on every
# already-provisioned runner — CI would keep building against the old tree while this file claimed
# otherwise. If you change the layout, keep the check version-derived.
# These DLLs are bundled verbatim into the code-signed host installer/MSIX, so the download is
# SHA-256-pinned (like VB-CABLE below): BtbN's `latest` tag is a ROLLING release whose assets are
# re-uploaded over time, so an unverified fetch would let a hijacked/MITM'd upstream asset land
# signed DLLs in users' installs. The pins below were captured 2026-07-10 from the then-current
# n7.1 lgpl-shared build. When BtbN re-rolls `latest`, this fetch FAILS CLOSED (hash mismatch) —
# signed DLLs in users' installs. The pins below were captured 2026-08-05 from the then-current
# n8.1 lgpl-shared build. When BtbN re-rolls `latest`, this fetch FAILS CLOSED (hash mismatch) —
# that is intentional: re-download, re-verify the new archive, and update the two pins here.
# Refresh a pin: (Get-FileHash .\ffmpeg-<tag>.zip -Algorithm SHA256).Hash
$ffmpegVersion = 'n8.1'
function Get-BtbnFfmpeg {
param([string]$Dir, [string]$ZipTag, [string]$Sha) # ZipTag: 'win64' (x64) or 'winarm64' (ARM64 cross tree)
if (Test-Path (Join-Path $Dir 'lib\avcodec.lib')) { info "FFmpeg ($ZipTag) already present at $Dir"; return }
info "fetching FFmpeg ($ZipTag, BtbN lgpl-shared, SHA-256 pinned)"
$url = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n7.1-latest-$ZipTag-lgpl-shared-7.1.zip"
# Version-stamped marker, NOT a bare file-existence test — see the MIGRATION note above. Written
# only after a successful extract, so a half-finished provision re-runs rather than being
# mistaken for a good tree.
$stamp = Join-Path $Dir '.punktfunk-ffmpeg-version'
$short = $ffmpegVersion.TrimStart('n')
if ((Test-Path (Join-Path $Dir 'lib\avcodec.lib')) -and
(Test-Path $stamp) -and
((Get-Content $stamp -Raw).Trim() -eq $ffmpegVersion)) {
info "FFmpeg $ffmpegVersion ($ZipTag) already present at $Dir"; return
}
info "fetching FFmpeg $ffmpegVersion ($ZipTag, BtbN lgpl-shared, SHA-256 pinned)"
$url = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-$ffmpegVersion-latest-$ZipTag-lgpl-shared-$short.zip"
$zip = "$Dir.zip"; $tmp = "$Dir-extract"
Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing
$got = (Get-FileHash $zip -Algorithm SHA256).Hash
@@ -58,10 +77,11 @@ function Get-BtbnFfmpeg {
$inner = Get-ChildItem $tmp -Directory | Select-Object -First 1
if (Test-Path $Dir) { Remove-Item -Recurse -Force $Dir }
Move-Item -Path $inner.FullName -Destination $Dir
Set-Content -Path $stamp -Value $ffmpegVersion -Encoding ascii
Remove-Item -Force $zip; Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
}
Get-BtbnFfmpeg -Dir "C:\Users\Public\ffmpeg" -ZipTag 'win64' -Sha '89F3469706E5D53AEA5CF34AEE63E62CE746E6159D7AEE473D330B02A47558E6'
Get-BtbnFfmpeg -Dir "C:\Users\Public\ffmpeg-arm64" -ZipTag 'winarm64' -Sha 'D96B4CE08CEBDCC6AD0E3934A3F962915E440EEFB9D73831AFEA4D80E35129A5'
Get-BtbnFfmpeg -Dir "C:\Users\Public\ffmpeg" -ZipTag 'win64' -Sha '0D0F7449A5600AB5DF9AF19DA861B24CA1534279EDE099D6541F1FEFB17BFBA9'
Get-BtbnFfmpeg -Dir "C:\Users\Public\ffmpeg-arm64" -ZipTag 'winarm64' -Sha 'CDC81352B7781DBAD87D8069AF7835FEC86C039F1ADC2B41BB27B3A295695A70'
# --- Vulkan-Headers (pf-ffvk's bindgen: libavutil/hwcontext_vulkan.h includes <vulkan/vulkan.h>,
# and Windows has no system copy). Headers only - the loader (vulkan-1.dll) is a GPU-driver