From accea43da09200acb57f1e623d81341f01b2289b Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Tue, 3 Nov 2026 14:35:13 +1100 Subject: [PATCH 1/7] fix(deps): KNLMeansCL v1.3 on every platform (#144) Windows shipped Khanattila's v1.1.1 release (2018), which refuses every 9-15-bit clip except packed YUV444P10/RGB30, so the KNLMeans Noise Reduction method (and QTGMC's knlmeanscl denoiser) failed on any 10-bit 4:2:0/4:2:2 source. macOS built Khanattila's unpinned master, whose unsynchronised buffer fills on Apple's OpenCL gave unrepeatable results: 24 of 60 cases changed between runs on an M1 Pro, by 110-255 levels. Move every platform to Jaded Encoding Thaumaturgy's maintained fork, v1.3: the PyPI wheel on Windows, the v1.3 tag built from source on macOS and Linux. Both meson patchers learn v1.3's assigned run_command include probe. KNLMeans joins the high-bit-depth parity suite, with a 10-bit 4:2:0 group; the cases skip without an OpenCL device. Measured on macOS arm64 only; Windows still needs a run on real hardware. Report: docs/reports/issue-144-knlmeanscl/. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 17 +- README.md | 2 +- Scripts/download-deps-linux.sh | 63 ++++- Scripts/download-deps-macos.sh | 109 ++++++-- Scripts/download-deps-windows.ps1 | 22 +- app/lib/views/about_dialog.dart | 6 +- ...tegration_high_bit_depth_filters_test.dart | 117 +++++++- docs/ENGINEERING_NOTES.md | 33 +++ docs/reports/README.md | 1 + docs/reports/issue-144-knlmeanscl/index.html | 249 ++++++++++++++++++ .../scripts/knlm_compare.py | 70 +++++ .../scripts/knlm_depth_probe.py | 75 ++++++ .../scripts/nlm_reference.py | 62 +++++ .../issue-144-knlmeanscl/scripts/run.sh | 62 +++++ licenses/NOTICES.txt | 7 +- worker/templates/pipeline_template.vpy | 2 +- worker/templates/preview_template.vpy | 2 +- 17 files changed, 839 insertions(+), 60 deletions(-) create mode 100644 docs/reports/issue-144-knlmeanscl/index.html create mode 100644 docs/reports/issue-144-knlmeanscl/scripts/knlm_compare.py create mode 100644 docs/reports/issue-144-knlmeanscl/scripts/knlm_depth_probe.py create mode 100644 docs/reports/issue-144-knlmeanscl/scripts/nlm_reference.py create mode 100755 docs/reports/issue-144-knlmeanscl/scripts/run.sh diff --git a/CLAUDE.md b/CLAUDE.md index 8a9dca7..ebe4232 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1370,10 +1370,20 @@ the simple slots are full at four). `include_directories('include', run_command(py, '-c', 'import vapoursynth…'))`, and macOS x64 never ran `build_plugin`. Both failed silently because the plugin wasn't in their `deps-expected-plugins.json` lists. +- **KNLMeansCL is Jaded Encoding Thaumaturgy's fork, v1.3, on every + platform** (issue #144): tag `v1.3` from source on macOS/Linux (`KNLM_TAG`), + PyPI wheel 1.3 on Windows; keep them in step. Never Khanattila's v1.1.1 + release (refuses 9–15-bit except packed YUV444P10, so every 10-bit 4:2:0 + source failed on Windows) or master (races its buffer fills on Apple's + OpenCL: unrepeatable, sometimes garbage). No runner has OpenCL, so after + touching it run the KNLMeans cases of + `integration_high_bit_depth_filters_test.dart` on a machine with a GPU — + they skip otherwise. - **KNLMeans has its own device probe**, separate from `knlm_available`. - havsfunc calls KNLMeansCL with the default `device_type="auto"`, which fails - on Apple Silicon (`CL_INVALID_VALUE`) where `"gpu"` works. So `knlm` still - answers for QTGMC's call, and `DependencyLocator::knlm_device` (gpu, then + havsfunc calls KNLMeansCL with the default `device_type="auto"`, which + failed on Apple Silicon (`CL_INVALID_VALUE`) before v1.3, where `"gpu"` + worked; v1.3's `auto` works there, so QTGMC's `knlmeanscl` now runs on a Mac + instead of falling back to DFTTest. `knlm` still answers for QTGMC's call, and `DependencyLocator::knlm_device` (gpu, then auto; `knlm-device-probe.json`) is the device our own call passes. With no device the job is refused with `KNLM_NO_DEVICE_MESSAGE`, the same text the panel shows (`knlmDevice`/`knlmMessage` from `--probe-opencl`), through @@ -2283,3 +2293,4 @@ Full write-ups (root causes, measurements) for each entry are in | 1.12.0 | 2026-10-04 | **Issue #101**: akarin 1.4.1 → **1.5.0** on every platform. The 1.4.1 Linux wheel put JIT code on the `brk` heap, where SELinux denies `PROT_EXEC`, so every job segfaulted on Fedora/RHEL from deps 1.8.0 on. `download-deps-linux.sh` now fails if `libakarin.so` lacks the mmap allocator | | 1.13.0 | 2026-10 | One new plugin; every other binary is built from the same sources as 1.12.0. Adds **vszip** 22.1.0 (MIT) for `vszip.CLAHE`, Color Correction's local contrast (issue #90). Baseline build only, flat in the plugin directory on every platform (the wheels' AVX2/AVX-512 variants are left out); macOS x64 builds the sdist with Zig 0.16.0 for the 12.0 floor, with header padding so it can be signed. The v2 CPU tier is no longer a second full bundle: each x64 platform publishes its v3 bundle plus `…-x64-v2-delta.zip`, the files in `_tierFiles` (zsmooth; on macOS also MVTools), extracted over it (`"tierFormat": "delta"`). Every x64 machine downloads the same bundle; only an older CPU fetches the few MB more. This is also what lets the Linux AppImage carry its deps for both tiers without doubling in size | | 1.14.0 | 2026-10-25 | Adds **RIFE** 4.26 (`r9_mod_v33`, MIT) + its model in `vapoursynth/rife/`, and **VSNCNN** (vs-mlrt v15.16, GPL-3.0) + Real-ESRGAN / Real-CUGAN models in `vapoursynth/ncnn/`, on macos-arm64, windows-x64 and linux-x64, with **MoltenVK** 1.4.2 (Apache-2.0) in `vapoursynth/moltenvk/` on macOS. Not autoloaded; see "GPU Plugins". **BM3D** r12 (MIT) on every platform; it had been macOS arm64 only (see "BM3D and KNLMeans Noise Reduction"). **descale** r12 (MIT) on every platform, for the Descale pass. (AnimeJaNai's models, used by the same VSNCNN, are an app add-on, not in the bundle). Published as the tested `deps-v1.14.0-rc1` bundles with only the version changed; every other file is byte-identical | +| 1.15.0 | unreleased | **Issue #144**: KNLMeansCL moves to Jaded Encoding Thaumaturgy's fork **v1.3** on every platform (Windows: PyPI wheel, replacing Khanattila's 2018 v1.1.1 zip, which refused 10-bit 4:2:0; macOS/Linux: the `v1.3` tag, replacing unpinned master, which gave unrepeatable results on Apple's OpenCL). Both meson patchers learn v1.3's assigned `run_command` include probe | diff --git a/README.md b/README.md index 0e3903e..69da48e 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ VapourBox is a front end to other people's work. Thanks in particular to: - **dubhater** — VapourSynth ports of MVTools, nnedi3, AWarpSharp2, TemporalMedian, FluxSmooth, Bifrost and `adjust` - **HolyWu** — VapourSynth ports of DFTTest, TTempSmooth, TCanny, CTMF, DCTFilter, Deblock, AddGrain, CAS, EEDI3 and NNEDI3CL - **mawen1250** — Retinex, BM3D and mvsfunc -- **Edoardo Brunetti** — KNLMeansCL +- **Edoardo Brunetti** (maintained by Jaded Encoding Thaumaturgy) — KNLMeansCL - **Frechdachs** (maintained by Jaded Encoding Thaumaturgy) — descale - **vszip** by Julek — CLAHE, the local-contrast lift in Color Correction - **RIFE** by hzwer (with Megvii) — the frame interpolation network, run through HolyWu's VapourSynth plugin (built on nihui's rife-ncnn-vulkan and Tencent's ncnn), with **MoltenVK** for Vulkan on macOS diff --git a/Scripts/download-deps-linux.sh b/Scripts/download-deps-linux.sh index 87ec312..2ee017f 100755 --- a/Scripts/download-deps-linux.sh +++ b/Scripts/download-deps-linux.sh @@ -646,13 +646,48 @@ FAILED_PLUGINS=() # which fails with our bundled Python. Replace with hardcoded include path. patch_meson_vs_include() { [ ! -f meson.build ] && return - python3 -c " -import re -with open('meson.build', 'r') as f: + VS_INCLUDE_DIR="$VS_INCLUDE_DIR" python3 - meson.build <<'PYEOF' 2>/dev/null || true +import os, re, sys +path = sys.argv[1] +inc = os.environ["VS_INCLUDE_DIR"] +with open(path) as f: content = f.read() + +def call_end(s, i): + """Index just past the ')' closing the call whose '(' is at s[i], + skipping parentheses inside quoted strings.""" + depth, q = 0, None + while i < len(s): + c = s[i] + if q: + if c == "\\": + i += 1 + elif c == q: + q = None + elif c in "'\"": + q = c + elif c == "(": + depth += 1 + elif c == ")": + depth -= 1 + if depth == 0: + return i + 1 + i += 1 + return -1 + +# The probe assigned to a variable, used later as VAR.stdout().strip(), as +# KNLMeansCL v1.3 writes it: r = run_command(py, '-c', 'import vapoursynth ...'). +for m in list(re.finditer(r"^[ \t]*(\w+)\s*=\s*run_command\s*\(", content, flags=re.M))[::-1]: + end = call_end(content, m.end() - 1) + if end < 0 or "import vapoursynth" not in content[m.start():end]: + continue + var = m.group(1) + content = content[:m.start()] + content[end:] + content = re.sub(r"\b%s\.stdout\(\)\.strip\(\)" % re.escape(var), repr(inc), content) + content = re.sub( r'incdir\s*=\s*include_directories\s*\(\s*run_command\s*\(.*?\)\.stdout\(\)\.strip\(\)\s*,?\s*\)', - \"incdir = include_directories('$VS_INCLUDE_DIR')\", + "incdir = include_directories(%r)" % inc, content, flags=re.DOTALL ) @@ -660,13 +695,13 @@ content = re.sub( # include_directories('include', run_command(py, '-c', 'import vapoursynth ...')). # Only a vapoursynth include probe is replaced; other run_command calls stay. content = re.sub( - r\"run_command\(\s*[^,()]+,\s*'-c',\s*'import vapoursynth[^']*'[^)]*\)\.stdout\(\)\.strip\(\)\", - \"'$VS_INCLUDE_DIR'\", + r"run_command\(\s*[^,()]+,\s*'-c',\s*'import vapoursynth[^']*'[^)]*\)\.stdout\(\)\.strip\(\)", + repr(inc), content, ) -with open('meson.build', 'w') as f: +with open(path, 'w') as f: f.write(content) -" 2>/dev/null || true +PYEOF } build_plugin() { @@ -1068,11 +1103,17 @@ build_plugin "bm3d" \ "$PLUGIN_BUILD_ENV meson setup build --buildtype=release && ninja -C build" \ "$BM3D_TAG" -# KNLMeansCL (OpenCL denoiser) +# KNLMeansCL (OpenCL denoiser - Noise Reduction's KNLMeans method and QTGMC's +# "knlmeanscl" denoiser). Jaded-Encoding-Thaumaturgy's maintained fork, pinned: +# keep KNLM_TAG in step with download-deps-macos.sh and the wheel version in +# download-deps-windows.ps1 (v1.3 == wheel 1.3). Khanattila's master, built +# here before, had no tag to pin and races its buffer fills (issue #144). +KNLM_TAG="v1.3" build_plugin "knlmeanscl" \ - "https://github.com/Khanattila/KNLMeansCL.git" \ + "https://github.com/Jaded-Encoding-Thaumaturgy/vapoursynth-knlmeanscl.git" \ "libknlmeanscl.so" \ - "$PLUGIN_BUILD_ENV meson setup build --buildtype=release && ninja -C build" + "$PLUGIN_BUILD_ENV meson setup build --buildtype=release && ninja -C build" \ + "$KNLM_TAG" # TemporalMedian (core.tmedian.TemporalMedian - used by the SpotLess filter) build_plugin "tmedian" \ diff --git a/Scripts/download-deps-macos.sh b/Scripts/download-deps-macos.sh index 90b40a6..36e2536 100755 --- a/Scripts/download-deps-macos.sh +++ b/Scripts/download-deps-macos.sh @@ -359,6 +359,14 @@ VS_TAG="R78" # build_plugin). Keep in step with download-deps-linux.sh and the wheel version # in download-deps-windows.ps1 (r12 == wheel 12.0). BM3D_TAG="r12" +# KNLMeansCL is Jaded-Encoding-Thaumaturgy's maintained fork, built from source +# on both arches. Keep in step with download-deps-linux.sh and the wheel version +# in download-deps-windows.ps1 (v1.3 == wheel 1.3). Not Khanattila's master: +# that fills its OpenCL buffers without waiting on the fill, which Apple's +# OpenCL does not complete in time - KNLMeans then denoises with a stale +# max-weight buffer, and some frames come out as garbage (issue #144). +KNLM_REPO="https://github.com/Jaded-Encoding-Thaumaturgy/vapoursynth-knlmeanscl.git" +KNLM_TAG="v1.3" VS_BUILD_DIR="$BUILD_DIR/vapoursynth" VS_INSTALL_DIR="$BUILD_DIR/vapoursynth-install" @@ -813,6 +821,68 @@ cd "$BUILD_DIR" BUILT_PLUGINS=() FAILED_PLUGINS=() +# Some plugins' meson.build locates the VapourSynth headers via a run_command +# the clean build machine can't satisfy, in one of three forms: +# (a) `import vapoursynth as vs; print(vs.get_include())` in the host python +# (e.g. mvtools, bm3d, eedi3m) - the host python has no `vapoursynth` +# module (the runtime VS module is built against our embedded python). +# (b) `run_command('vapoursynth', 'get-include', ...)` - invokes the +# `vapoursynth` console script, which isn't on PATH here (e.g. CAS, which +# upstream switched to this form). (a) and (b) end in `.stdout().strip()`. +# (c) either probe assigned to a variable whose `.stdout().strip()` is used +# later (KNLMeansCL v1.3: `r = run_command(py, '-c', '...')`). +# Replace the probe with the from-source VS include dir (same trick used for +# vivtc below). Match the program form too, not just "import vapoursynth". +# A no-op when the file has no such probe or VS_INC_DIR is not known yet. +# Always returns 0, so it can sit in an && chain. +patch_meson_vs_include() { + local file="${1:-meson.build}" + [ -n "$VS_INC_DIR" ] && [ -f "$file" ] || return 0 + grep -qE "import vapoursynth|run_command\('vapoursynth'" "$file" || return 0 + VS_INC_DIR="$VS_INC_DIR" python3 - "$file" <<'PYEOF' +import os, re, sys +path = sys.argv[1] +inc = os.environ["VS_INC_DIR"] +s = open(path).read() + +def call_end(s, i): + """Index just past the ')' closing the call whose '(' is at s[i], + skipping parentheses inside quoted strings.""" + depth, q = 0, None + while i < len(s): + c = s[i] + if q: + if c == "\\": + i += 1 + elif c == q: + q = None + elif c in "'\"": + q = c + elif c == "(": + depth += 1 + elif c == ")": + depth -= 1 + if depth == 0: + return i + 1 + i += 1 + return -1 + +# (c) first: VAR = run_command(...vapoursynth...), then VAR.stdout().strip(). +for m in list(re.finditer(r"^[ \t]*(\w+)\s*=\s*run_command\s*\(", s, flags=re.M))[::-1]: + end = call_end(s, m.end() - 1) + if end < 0 or "vapoursynth" not in s[m.start():end]: + continue + var = m.group(1) + s = s[:m.start()] + s[end:] + s = re.sub(r"\b%s\.stdout\(\)\.strip\(\)" % re.escape(var), repr(inc), s) + +# (a)/(b): run_command(...).stdout().strip() inline. +s = re.sub(r"run_command\(.*?\)\.stdout\(\)\.strip\(\)", repr(inc), s, flags=re.S) +open(path, "w").write(s) +PYEOF + return 0 +} + build_plugin() { local name="$1" local repo="$2" @@ -837,27 +907,9 @@ build_plugin() { cd "$name" - # Some plugins' meson.build locates the VapourSynth headers via a run_command - # the clean build machine can't satisfy, in one of two forms: - # (a) `import vapoursynth as vs; print(vs.get_include())` in the host python - # (e.g. mvtools, bm3d, eedi3m) - the host python has no `vapoursynth` - # module (the runtime VS module is built against our embedded python). - # (b) `run_command('vapoursynth', 'get-include', ...)` - invokes the - # `vapoursynth` console script, which isn't on PATH here (e.g. CAS, which - # upstream switched to this form). Both end in `.stdout().strip()`. - # Replace either probe with the from-source VS include dir (same trick used - # for vivtc below). Match the program form too, not just "import vapoursynth". - if [ -n "$VS_INC_DIR" ] && [ -f meson.build ] \ - && grep -qE "import vapoursynth|run_command\('vapoursynth'" meson.build; then - VS_INC_DIR="$VS_INC_DIR" python3 - meson.build <<'PYEOF' -import os, re, sys -path = sys.argv[1] -inc = os.environ["VS_INC_DIR"] -s = open(path).read() -s = re.sub(r"run_command\(.*?\)\.stdout\(\)\.strip\(\)", repr(inc), s, flags=re.S) -open(path, "w").write(s) -PYEOF - fi + # Point any VapourSynth include probe in meson.build at our own headers + # (see patch_meson_vs_include above). + patch_meson_vs_include meson.build # Expose the from-source VapourSynth to pkg-config so plugins that use # `dependency('vapoursynth')` (removegrain, cas, ...) resolve against our @@ -1217,8 +1269,9 @@ PYEOF cd "$BUILD_DIR" fi - # KNLMeansCL (OpenCL denoiser - core.knlm.KNLMeansCL). Used by QTGMC when its - # Denoiser is set to "knlmeanscl" (havsfunc). Stefan-Olt doesn't ship an + # KNLMeansCL (OpenCL denoiser - core.knlm.KNLMeansCL), pinned by KNLM_TAG. + # Used by Noise Reduction's KNLMeans method and by QTGMC when its Denoiser + # is set to "knlmeanscl" (havsfunc). Stefan-Olt doesn't ship an # x86_64 build, so build from source like the arm64 path does. Its meson.build # needs BOTH the VapourSynth headers (via pkg-config) AND Boost # filesystem/system (via Homebrew, same as the nnedi3cl build), so set @@ -1228,7 +1281,8 @@ PYEOF if [ "$FORCE" = true ] || [ ! -f "$PLUGINS_DIR/libknlmeanscl.dylib" ]; then echo ""; echo "=== Building KNLMeansCL (x86_64) ===" rm -rf knlmeanscl - if git clone --depth 1 https://github.com/Khanattila/KNLMeansCL.git knlmeanscl \ + if git clone --depth 1 --branch "$KNLM_TAG" "$KNLM_REPO" knlmeanscl \ + && patch_meson_vs_include knlmeanscl/meson.build \ && PKG_CONFIG_PATH="$VS_PC_DIR:${PKG_CONFIG_PATH:-}" BOOST_ROOT="$SRCLIB" \ meson setup knlmeanscl/build knlmeanscl --buildtype=release \ && ninja -C knlmeanscl/build; then @@ -1463,11 +1517,12 @@ build_plugin "bm3d" \ "meson setup build --buildtype=release && ninja -C build" \ "$BM3D_TAG" -# KNLMeansCL (OpenCL denoiser) +# KNLMeansCL (OpenCL denoiser), pinned: see KNLM_TAG build_plugin "knlmeanscl" \ - "https://github.com/Khanattila/KNLMeansCL.git" \ + "$KNLM_REPO" \ "libknlmeanscl.dylib" \ - "meson setup build --buildtype=release && ninja -C build" + "meson setup build --buildtype=release && ninja -C build" \ + "$KNLM_TAG" # DeScratch (vertical scratch removal - core.descratch.DeScratch) # Built from source: not available pre-built from Stefan-Olt. The repo carries diff --git a/Scripts/download-deps-windows.ps1 b/Scripts/download-deps-windows.ps1 index a6567c6..3af20d1 100644 --- a/Scripts/download-deps-windows.ps1 +++ b/Scripts/download-deps-windows.ps1 @@ -417,16 +417,8 @@ $PluginsZip = @( Name = "fmtconv" Url = "https://ldesoras.fr/src/vs/fmtconv-r31.zip" Check = "fmtconv.dll" - }, - @{ - # core.knlm.KNLMeansCL - OpenCL denoiser used by QTGMC when its Denoiser - # is set to "knlmeanscl". The release zip ships a 32-bit DLL at the root - # AND x64\KNLMeansCL.dll; the $Win64 filter below picks the x64 one (and - # the PE-arch verifier rejects the 32-bit one if it ever slipped through). - Name = "knlmeanscl" - Url = "https://github.com/Khanattila/KNLMeansCL/releases/download/v1.1.1/KNLMeansCL-v1.1.1.zip" - Check = "KNLMeansCL.dll" } + # KNLMeansCL is NOT here: it is a PyPI wheel, see the wheel section below. # zsmooth is NOT here: which build ships depends on the CPU tier. See # section 4a below. ) @@ -774,7 +766,7 @@ if (-not (Test-Path "$PluginsDir\libakarin.dll")) { } # ============================================================================= -# Bwdif + DeDot + vszip + BM3D + descale - PyPI wheels +# Bwdif + DeDot + vszip + BM3D + descale + KNLMeansCL - PyPI wheels # ============================================================================= # None of these upstreams publishes a Windows release asset, so each comes from # its PyPI wheel, resolved through the JSON API the same way akarin is (never @@ -808,7 +800,15 @@ $WheelPlugins = @( @{ Name = "bm3d"; Package = "vapoursynth-bm3d"; Version = "12.0"; Dll = "bm3d.dll" }, # core.descale (the Descale pass). Its AVX2 kernel is chosen at run time, so # one DLL serves both tiers. Imports VCRUNTIME140, which Python ships. - @{ Name = "descale"; Package = "vapoursynth-descale"; Version = "12"; Dll = "libdescale.dll" } + @{ Name = "descale"; Package = "vapoursynth-descale"; Version = "12"; Dll = "libdescale.dll" }, + # core.knlm.KNLMeansCL (Noise Reduction's KNLMeans method, and QTGMC's + # "knlmeanscl" denoiser). Jaded-Encoding-Thaumaturgy's maintained fork: + # wheel 1.3 is git tag v1.3, as built from source by + # download-deps-{macos,linux}.sh. It replaces Khanattila's v1.1.1 release + # zip (2018), which refuses every 9-15-bit clip except packed YUV444P10 / + # RGB30 - so any 10-bit 4:2:0 source failed (issue #144). OpenCL is linked + # statically; the DLL imports only Windows system libraries. + @{ Name = "knlmeanscl"; Package = "vapoursynth-knlmeanscl"; Version = "1.3"; Dll = "KNLMeansCL.dll" } ) foreach ($W in $WheelPlugins) { diff --git a/app/lib/views/about_dialog.dart b/app/lib/views/about_dialog.dart index 28fef5f..7079627 100644 --- a/app/lib/views/about_dialog.dart +++ b/app/lib/views/about_dialog.dart @@ -396,8 +396,10 @@ class _AboutDialogState extends State { _ComponentTile( name: 'KNLMeansCL', license: 'GPL 3.0', - copyright: 'Edoardo Brunetti', - url: 'https://github.com/Khanattila/KNLMeansCL', + copyright: + 'Edoardo Brunetti; maintained by Jaded Encoding Thaumaturgy', + url: + 'https://github.com/Jaded-Encoding-Thaumaturgy/vapoursynth-knlmeanscl', ), _ComponentTile( name: 'DeScratch', diff --git a/app/test/integration_high_bit_depth_filters_test.dart b/app/test/integration_high_bit_depth_filters_test.dart index 4bf4681..cd3e31e 100644 --- a/app/test/integration_high_bit_depth_filters_test.dart +++ b/app/test/integration_high_bit_depth_filters_test.dart @@ -235,11 +235,20 @@ VideoJob _job({ /// trivially). class _Pass { const _Pass(this.name, this.pipeline, - {this.tolerance = 2.0, this.changesPicture = true}); + {this.tolerance = 2.0, + this.changesPicture = true, + this.needsKnlmDevice = false}); final String name; final ProcessingPipeline pipeline; + /// Whether the pass needs a working KNLMeansCL OpenCL device. Without one the + /// worker refuses the job by design, so the pass is skipped (with a printed + /// line) rather than failed — which means the hosted runners, which have no + /// OpenCL, never run it. Run this file on a machine with a GPU after touching + /// KNLMeansCL. + final bool needsKnlmDevice; + /// Whether this pass is expected to visibly change the frame. /// /// The parity check compares a pass against the same pass at another depth, so @@ -443,6 +452,24 @@ List<_Pass> _passes() => [ ), ), + // KNLMeansCL (issue #144). Windows shipped the 2018 v1.1.1 release, which + // refused every 9-15-bit clip except packed YUV444P10, so this pass failed + // outright on any 10-bit 4:2:0/4:2:2 source. h is raised from the default + // 1.2 so the pass visibly changes this fixture: with the default, weights + // fall under KNLMeansCL's epsilon floor on grain this heavy and the + // output is (correctly) almost the input. + _Pass( + 'noise_knlmeans', + _only( + noiseReduction: const NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.knlMeans, + knlmH: 3.0, + ), + ), + needsKnlmDevice: true, + ), + // Deflicker measures frame brightness against a windowed average, so its // correction is a ratio and should be depth-independent by construction. _Pass( @@ -742,6 +769,28 @@ Future _passthroughRgb10() async { await WorkerHarness.imageToRgb24(result.png!, label: 'ref10'); } +/// The worker's KNLMeansCL device probe, run once. Null when no OpenCL device +/// can run KNLMeansCL here. +Future? _knlmDeviceProbe; +Future _knlmDevice() => _knlmDeviceProbe ??= () async { + final probe = await Process.run( + WorkerHarness.workerPath, + ['--probe-opencl'], + environment: WorkerHarness.workerEnv, + workingDirectory: File(WorkerHarness.workerPath).parent.path, + ); + final match = RegExp(r'"knlmDevice":"([^"]+)"') + .firstMatch(probe.stdout.toString()); + return match?.group(1); + }(); + +/// True (after printing why) when [pass] cannot run on this machine. +Future _skipWithoutDevice(_Pass pass) async { + if (!pass.needsKnlmDevice || await _knlmDevice() != null) return false; + print(' SKIPPED ${pass.name}: no OpenCL device KNLMeansCL can use here'); + return true; +} + void main() { setUpAll(() async { await WorkerHarness.ensureReady(); @@ -754,6 +803,7 @@ void main() { group('preview runs on a 10-bit 4:2:2 source', () { for (final pass in _passes()) { test(pass.name, () async { + if (await _skipWithoutDevice(pass)) return; final fixture = await _fixture('yuv422p10le'); final result = await WorkerHarness.runPreview( _job( @@ -780,6 +830,7 @@ void main() { for (final pixFmt in ['yuv444p12le', 'yuv422p16le']) { for (final pass in _passes()) { test('${pass.name} on $pixFmt', () async { + if (await _skipWithoutDevice(pass)) return; final fixture = await _fixture(pixFmt); final result = await WorkerHarness.runPreview( _job(fixture: fixture, pixFmt: pixFmt, pipeline: pass.pipeline) @@ -802,6 +853,7 @@ void main() { group('8-bit vs 10-bit result parity', () { for (final pass in _passes()) { test(pass.name, () async { + if (await _skipWithoutDevice(pass)) return; final src8 = await _fixture('yuv422p'); final src10 = await _fixture('yuv422p10le'); @@ -853,6 +905,69 @@ void main() { } }); + // =========================================================================== + // 3b. KNLMeans on 10-bit 4:2:0, the format issue #144 was reported on, in + // both paths. The groups above use 4:2:2 and 4:4:4 fixtures. + // =========================================================================== + group('KNLMeans on a 10-bit 4:2:0 source (issue #144)', () { + final pass = _passes().firstWhere((p) => p.name == 'noise_knlmeans'); + + test('preview matches the 8-bit 4:2:0 result', () async { + if (await _skipWithoutDevice(pass)) return; + final src8 = await _fixture('yuv420p'); + final src10 = await _fixture('yuv420p10le'); + final eight = await WorkerHarness.runPreview( + _job(fixture: src8, pixFmt: 'yuv420p', pipeline: pass.pipeline).toJson(), + frame: _previewFrame, + label: 'knlm420_8', + ); + final ten = await WorkerHarness.runPreview( + _job(fixture: src10, pixFmt: 'yuv420p10le', pipeline: pass.pipeline) + .toJson(), + frame: _previewFrame, + label: 'knlm420_10', + ); + expect(eight.success, isTrue, reason: '8-bit 4:2:0 run failed: $eight'); + expect(ten.success, isTrue, reason: '10-bit 4:2:0 run failed: $ten'); + final diff = WorkerHarness.meanAbsDiff( + await WorkerHarness.imageToRgb24(eight.png!, label: 'k8'), + await WorkerHarness.imageToRgb24(ten.png!, label: 'k10')); + // The 4:2:0 fixtures differ by more than rounding before any filter runs + // (ffmpeg subsamples the chroma separately at each depth), so measure + // that baseline and allow the filter only its own rounding on top. + final plain = _only(); + final base8 = await WorkerHarness.runPreview( + _job(fixture: src8, pixFmt: 'yuv420p', pipeline: plain).toJson(), + frame: _previewFrame, + label: 'plain420_8', + ); + final base10 = await WorkerHarness.runPreview( + _job(fixture: src10, pixFmt: 'yuv420p10le', pipeline: plain).toJson(), + frame: _previewFrame, + label: 'plain420_10', + ); + final baseline = WorkerHarness.meanAbsDiff( + await WorkerHarness.imageToRgb24(base8.png!, label: 'b8'), + await WorkerHarness.imageToRgb24(base10.png!, label: 'b10')); + print(' knlmeans 4:2:0: mean abs diff ${diff.toStringAsFixed(3)} ' + '(fixtures alone: ${baseline.toStringAsFixed(3)})'); + expect(diff, lessThan(baseline + 1.0)); + }, timeout: const Timeout(Duration(minutes: 6))); + + test('full encode succeeds', () async { + if (await _skipWithoutDevice(pass)) return; + final src10 = await _fixture('yuv420p10le'); + final job = _job( + fixture: src10, + pixFmt: 'yuv420p10le', + pipeline: pass.pipeline, + outputName: 'knlm420_10'); + final result = + await WorkerHarness.runJob(job.toJson(), label: 'knlm420_10_encode'); + expect(result.success, isTrue, reason: 'encode failed: $result'); + }, timeout: const Timeout(Duration(minutes: 6))); + }); + // =========================================================================== // 4. The stacked pipeline, both paths. Preview and encode are separate scripts // and separate ffmpeg invocations, so each needs its own assertion. diff --git a/docs/ENGINEERING_NOTES.md b/docs/ENGINEERING_NOTES.md index f20d2b5..094ef61 100644 --- a/docs/ENGINEERING_NOTES.md +++ b/docs/ENGINEERING_NOTES.md @@ -2775,6 +2775,39 @@ Recommendation, if built: an `advancedOnly` Deinterlace method with its own small control set, tuned as above, vs-jetpack and mvutensils pinned together. x86 speed and the v2 CPU tier are unmeasured. +### KNLMeansCL: 10-bit sources failed on Windows, results unreliable on macOS (issue #144, 2026-11-03) + +Full report with scripts: +[docs/reports/issue-144-knlmeanscl/](reports/issue-144-knlmeanscl/index.html). + +- **Windows** shipped Khanattila's v1.1.1 release zip (2018). Its VapourSynth + filter accepts 10-bit only as packed YUV444P10/RGB30 with all channels + together, and 9/12/14-bit not at all, so our `channels="Y"` / `"UV"` calls + failed on every 10-bit 4:2:0/4:2:2 source ("only YUV444P10 and RGB30 are + supported!"). QTGMC's `knlmeanscl` goes through havsfunc's wrapper, which + makes the same calls. Never caught because no runner has OpenCL, and the + 10-bit check of 2026-10-25 ran on a Mac. +- **macOS** built Khanattila's unpinned master. Apple's `clEnqueueFillBuffer` + doesn't complete unless an event is requested, so the max-weight buffer + (meant to start at `CL_FLT_EPSILON`) holds stale data. On an M1 Pro, 24 of 60 + depth/layout/channel cases gave a different result on a repeat run (by + 110–255 levels), and a CPU reference showed it denoising at `h=1.2` where + the algorithm leaves the picture alone (3.28 levels off). +- **Fix:** Jaded Encoding Thaumaturgy's fork, v1.3, everywhere: tag `v1.3` from + source on macOS/Linux, PyPI wheel 1.3 on Windows. It carries pinterf's + 9–16-bit support and the fill fix. Same kernel as master. Repeatable in all + 60 cases, within 0.26 of the reference, and the source build is + bit-identical to the wheel. +- v1.3's `meson.build` assigns the VapourSynth include probe to a variable + (`r = run_command(...)`, then `r.stdout().strip()`); both scripts' meson + patchers now handle that form. +- **Behaviour changes:** a Mac's KNLMeans now denoises less at low `h` + (matching Windows/Linux, as designed), and its `auto` device works, so + QTGMC's `knlmeanscl` denoiser runs on Apple Silicon instead of falling back + to DFTTest. +- Not yet run on Windows, macOS x64 or Linux. The Windows result is the one + that matters for the issue. + ### Testing a deps change before publishing A PR that changes `deps/` has a chicken-and-egg problem: `ci-test.yml` and diff --git a/docs/reports/README.md b/docs/reports/README.md index 6ac174a..c846904 100644 --- a/docs/reports/README.md +++ b/docs/reports/README.md @@ -66,4 +66,5 @@ to it. | Date | Report | Outcome | |---|---|---| +| 2026-11-03 | [Issue #144: KNLMeansCL fails on 10-bit sources (Windows), unreliable on macOS](issue-144-knlmeanscl/index.html) | Swap every platform to the maintained fork v1.3 (tag on macOS/Linux, PyPI wheel on Windows); verified on macOS arm64, Windows pending | | 2026-11-03 | [Issue #117: QTempGaussMC (vs-jetpack) as an alternate deinterlacer](issue-117-qtempgaussmc/index.html) | Worth adding as an advanced-only method (not macOS x64); +0.5–0.8 dB over havsfunc QTGMC Slower at equal speed once tuned; not built yet | diff --git a/docs/reports/issue-144-knlmeanscl/index.html b/docs/reports/issue-144-knlmeanscl/index.html new file mode 100644 index 0000000..bff4e08 --- /dev/null +++ b/docs/reports/issue-144-knlmeanscl/index.html @@ -0,0 +1,249 @@ + + + + + +Issue #144 — KNLMeansCL fails on 10-bit sources (Windows), and is unreliable on macOS + + + +
+

Issue #144 — KNLMeansCL fails on 10-bit sources on Windows, and is unreliable on macOS

+

2026-11-03 · issue #144 · +measured on an M1 Pro (macOS arm64) against the deps 1.14.0 bundle · Windows result pending

+ +
+Outcome. Every platform moves to the same KNLMeansCL: Jaded Encoding +Thaumaturgy's maintained fork, v1.3 (GPL-3.0). It is built from that tag on macOS +and Linux, and taken from its PyPI wheel vapoursynth-knlmeanscl 1.3 on Windows. +That fixes two separate bugs: +
    +
  1. Windows shipped Khanattila's v1.1.1 release (2018), which refuses +every 9–15-bit clip except packed YUV444P10 / RGB30. The KNLMeans Noise +Reduction method failed outright on any 10-bit 4:2:0 or 4:2:2 source, as reported. +This is read from the source, not reproduced: no Windows machine has +run it yet.
  2. +
  3. macOS built Khanattila's unpinned master. On Apple's +OpenCL it gives a different result on a repeat run in 24 of 60 cases (by 110–255 +levels), and even when repeatable it does not compute the algorithm's result at low +h. v1.3 is repeatable in all 60 cases and matches a CPU reference.
  4. +
+
+ +

1. The reported failure (Windows)

+

From the user's job log (Windows 11, RTX 3060), with the username path removed:

+
Python exception: knlm.KNLMeansCL: only YUV444P10 and RGB30 are supported!
+  clip = core.knlm.KNLMeansCL(clip, d=1, a=2, s=4, h=1.20, channels="Y", device_type="gpu")
+

The device had already been selected ("gpu", from the worker's probe), so this +is a format refusal, not a missing-OpenCL case. The message comes from v1.1.1's +NLMVapoursynth.cpp, which the Windows script downloaded as a release zip:

+
} else if (d.vi->format->bitsPerSample == 10) {
+    if (d.clip_t & NLM_CLIP_REF_YUV || d.clip_t & NLM_CLIP_REF_RGB) {
+        ...CL_UNORM_INT_101010...          // packed 10-10-10 only
+    } else {
+        vsapi->setError(out, "knlm.KNLMeansCL: only YUV444P10 and RGB30 are supported!");
+}} else if (bitsPerSample == 16) { ... } else {
+        "knlm.KNLMeansCL: P8, P10, P16, Half and Single are supported!"   // 9/12/14-bit
+

NLM_CLIP_REF_YUV is set only for channels="YUV" on 4:4:4. Our +template calls channels="Y" then channels="UV" (4:2:0 can't take +"YUV"), so every 10-bit source fails, and 9/12/14-bit fail with the second +message. Only 8-bit, 16-bit and float work. pinterf's 2020 commits (ad0a020, +"Add 9-15 bit support for all modes") fixed this on master, but Khanattila never cut a +release with them, so the only Windows binaries were v1.1.1 and pinterf's own 2020 builds.

+

QTGMC's knlmeanscl denoiser is affected the same way: +havsfunc's KNLMeansCL wrapper makes the same channels='UV' call. The +user's QTGMC jobs presumably work because those sources are 8-bit, or because QTGMC's High +precision option runs it at 16-bit. That is an inference; it has not been confirmed with the user.

+ +

Why nothing caught it

+
    +
  • The 2026-10-25 depth-parity measurement for the new KNLMeans method ran on a Mac, which +had the master build.
  • +
  • integration_high_bit_depth_filters_test.dart did not include KNLMeans.
  • +
  • No hosted runner has an OpenCL device (lavapipe is Vulkan only), so KNLMeans has never +run on Windows in CI, at any depth. The worker's device probe uses an 8-bit clip, so it +passes there too.
  • +
+ +

2. The candidate: KNLMeansCL 1.3

+

Jaded-Encoding-Thaumaturgy/vapoursynth-knlmeanscl +continues Khanattila's master (merge base ca424fa, 2023-01-05). Its OpenCL kernel +(NLMKernel.cpp) is unchanged. Its 1.2 and 1.3 changes: VapourSynth API v4, wheels for +every platform, Apple CL_INVALID_VALUE handling in auto device selection, +and a fix for Apple's clEnqueueFillBuffer not completing when no event is +requested, which is the macOS bug in section 3. Its 9–16-bit code +(NLMVapoursynth.cpp:693) accepts every integer depth for every +channels mode.

+ + + + + +
PlatformBeforeAfter
Windows x64Khanattila v1.1.1 release zip (2018)PyPI wheel 1.3 (win_amd64), a single KNLMeansCL.dll, OpenCL linked statically; imports only Windows system DLLs
macOS arm64 / x64Khanattila master, unpinnedtag v1.3, built from source
Linux x64 / arm64Khanattila master, unpinnedtag v1.3, built from source
+

Source builds rather than wheels on macOS and Linux: the macOS x86_64 wheel is minos 15.0 +(the Intel floor is 12.0), and the Linux wheel bundles its own libOpenCL loader; +both platforms already build every other plugin from a pinned tag. v1.3's +meson.build locates the VapourSynth headers through +r = run_command(py, '-c', 'import vapoursynth …') with +r.stdout().strip() used later. Neither script's meson patcher recognised that +assigned form, so both now handle it. The patchers were checked against v1.3, BM3D r12, CAS and +mvtools; the three existing forms patch exactly as before.

+ +

3. Measurements (macOS arm64)

+

All from scripts/run.sh. Synthetic clips (gradient, coloured blocks, grain +from grain.Add) at 320×240; frame 5 of 11; d=1 a=2 s=4. +Differences are mean absolute, in 8-bit levels.

+ +

3a. Does it run, and is it repeatable?

+

The depth matrix is 6 depths (8, 9, 10, 12, 14, 16) × 4:2:0 / 4:4:4 × channels +Y / UV (and YUV on 4:4:4) × device gpu / auto = 60 cases. Each build +ran it twice, each time in a fresh process.

+ + + + + +
BuildCases that runIdentical on repeatRepeat-run differences
deps 1.14.0 macOS (master)603624 cases differ by 110–255 levels
v1.3 PyPI wheel6060—
v1.3 built from source (script recipe)6060bit-identical to the wheel in all 60
+

Which cases go wrong on master changes from run to run. In one pass, 18 +of 60 were garbage (more than 20 levels from v1.3) and the remaining 42 still differed by +about 2.3–3.1 levels (see 3b). Under v1.1.1 on Windows, the 9–15-bit Y and UV cases are +exactly the ones that would error.

+ +

3b. Is it computing the right thing?

+

To decide which build is right where they disagree, nlm_reference.py +implements the kernel's maths on the CPU for 150 sampled pixels. The weight is +exp(−P·255² / (h²(2s+1)²)), and the centre pixel's weight is +wref × max(largest weight, FLT_EPSILON) (the max-weight buffer starts at +CL_FLT_EPSILON). The input is a flat-ish picture with grain of variance 40 +(RMS 6.3 levels).

+ + + + + + +
hinput vs referencemaster vs referencev1.3 vs reference
1.20.0003.2800.000
2.03.1740.5590.254
3.04.1660.2530.253
5.04.6680.2590.259
+

Lower is better. About 0.25 is the floor from GPU float rounding and 8-bit +output. At h=1.2 every weight here is about e−55, far below the epsilon floor, so the +correct output is the input. Master denoises anyway because its max-weight buffer was never +filled. The master figures are from a run where it produced no garbage; in other runs, h=2.0 +and h=5.0 came out at about 125 levels.

+ +

What this means for users. The KNLMeans default (h=1.2, +KNLMeansCL's own) removes little from heavy grain. It denoises noise of a couple of levels +RMS, but at 6 levels every weight is below the floor. Windows and Linux have always behaved +like that. macOS users saw stronger denoising, and the occasional garbage frame, because of the +race. After this change macOS matches the other platforms, so a macOS user's existing KNLMeans +settings will denoise less. The defaults are unchanged; whether 1.2 is the right default for +the sources people use KNLMeans on is a separate question.

+ +

3c. In the app (macOS arm64, local bundle with v1.3 swapped in)

+ + + + + + + +
TestResult
High-bit-depth suite, new noise_knlmeans pass (h=3.0): preview on 4:2:2 10-bit, 4:4:4 12-bit, 4:2:2 16-bitpass
8- vs 10-bit parity, 4:2:20.301 (tolerance 2.0)
New: 10-bit 4:2:0 (the reported format), preview parity and full encode1.394, below the fixtures' own 1.717 difference; encode passes
Existing: KNLMeans end-to-end encode; QTGMC with knlmeansclpass
--probe-opencl{"knlm":true,"knlmDevice":"gpu"}. knlm was false before.
+

QTGMC behaviour change on Apple Silicon. knlm is the probe of +havsfunc's call, which uses device_type="auto". It used to fail on Apple Silicon +(CL_INVALID_VALUE), so QTGMC's knlmeanscl denoiser silently fell +back to DFTTest there. v1.3's device selection handles that, so on a Mac QTGMC now runs the +denoiser the user chose.

+ +

4. What wasn't tested

+
    +
  • Windows, the reported platform. The diagnosis comes from v1.1.1's source and +the user's log. The fix rests on the v1.3 wheel containing the same source that was measured +on macOS. Before release, it needs one run on a Windows machine with a GPU: a 10-bit 4:2:0 +source through KNLMeans with v1.1.1 (expect the error), then with the v1.3 DLL (expect it to +run), and the KNLMeans tests in integration_high_bit_depth_filters_test.dart.
  • +
  • macOS x64 and Linux: the scripts are changed, but nothing has been built or run there. +build-deps-* CI is the build test. Neither platform can run KNLMeans in CI (no +OpenCL).
  • +
  • Only one kind of input: synthetic grain on synthetic pictures. That is enough to tell right +from wrong against the reference, but says nothing about the best h for real +footage.
  • +
  • NVIDIA, AMD and Intel OpenCL drivers: Apple's is the only one measured. The fill-buffer race is +described as Apple-specific (see GROMACS issue 4852, linked in v1.3's source).
  • +
+ +

5. Decisions

+
    +
  • Swap the plugin rather than work around the depth in the template (for +example by converting 9–15-bit to 16-bit around the call). The workaround would have fixed only +the Windows error. It would have left QTGMC's havsfunc call broken on the same sources and +macOS still producing garbage.
  • +
  • One version everywhere (v1.3), as the scripts already do for BM3D (tag r12 = +wheel 12.0), so a job produces the same picture on every OS.
  • +
  • Leave the default h alone: the default isn't what broke, and +changing it would change every platform's output.
  • +
+ +

6. Corrections made during the work

+
+
    +
  • The first reply to the user suggested switching Color Format to 8-bit as a workaround. +That would not have worked: the output colour conversion is the last step in the script, +after Noise Reduction. It was withdrawn before the reply was posted.
  • +
  • The first probe loaded both builds into one process and got nonsense. I put that down to the two +copies' OpenCL state colliding. Running each build in its own process showed the garbage was +master's alone. The scripts still use one build per process.
  • +
  • The first two versions of the CPU reference were wrong: they left out the +CL_FLT_EPSILON floor and missed that luma's 3·d² distance cancels +NLM_LEGACY = 3. Those versions made master look right at h=1.2. Only the +corrected version, the one in scripts/, is used in 3b.
  • +
+
+ +

7. Reproducing

+
# compare the bundle's own knlm with the v1.3 PyPI wheel (fetched and hash-checked)
+KNLM_PROBE_DIR=/path/to/scratch docs/reports/issue-144-knlmeanscl/scripts/run.sh
+
+# compare any two builds, e.g. the deps 1.14.0 libknlmeanscl.dylib against a v1.3 build
+KNLM_PROBE_DIR=/path/to/scratch docs/reports/issue-144-knlmeanscl/scripts/run.sh OLD.dylib NEW.dylib
+

The deps 1.14.0 macOS arm64 libknlmeanscl.dylib (sha256 +147879958ba65b73a5fbee2af5814a489d9a89291373041d22701a13dc001687) is in the +published deps-v1.14.0 bundle. The app-level numbers in 3c come from +flutter test test/integration_high_bit_depth_filters_test.dart --plain-name knlm +(and --plain-name "issue #144").

+
+ + diff --git a/docs/reports/issue-144-knlmeanscl/scripts/knlm_compare.py b/docs/reports/issue-144-knlmeanscl/scripts/knlm_compare.py new file mode 100644 index 0000000..cdcb541 --- /dev/null +++ b/docs/reports/issue-144-knlmeanscl/scripts/knlm_compare.py @@ -0,0 +1,70 @@ +"""Compare knlm_depth_probe.py dumps: plain Python 3, no VapourSynth needed. + + knlm_compare.py A_DIR [B_DIR] + +For every case prints, per directory, whether it ran and how much it changed +the processed planes (denoise), and with two directories how far A and B are +apart. All figures are mean absolute differences in 8-bit levels (0-255). +""" +import array +import os +import sys + +W, H = 320, 240 + + +def planes(path, depth, layout): + """Read a dump into a list of three flat arrays (one per plane).""" + a = array.array("B" if depth == 8 else "H") + with open(path, "rb") as fh: + a.frombytes(fh.read()) + sub = 2 if layout == "420" else 1 + sizes = [W * H, (W // sub) * (H // sub), (W // sub) * (H // sub)] + out, i = [], 0 + for s in sizes: + out.append(a[i:i + s]) + i += s + return out + + +def mad(pa, pb, idx, depth): + scale = 255.0 / ((1 << depth) - 1) + total = n = 0 + for p in idx: + for x, y in zip(pa[p], pb[p]): + total += abs(x - y) + n += len(pa[p]) + return total / n * scale + + +def load_status(d): + st = {} + for line in open(os.path.join(d, "status.txt")): + case, rest = line.rstrip("\n").split(" ", 1) + st[case] = rest + return st + + +dirs = sys.argv[1:] +stats = [load_status(d) for d in dirs] +hdr = "case | " + " | ".join(f"{os.path.basename(d.rstrip('/'))}: denoise" for d in dirs) +print(hdr + (" | A vs B" if len(dirs) == 2 else "")) +for case in stats[0]: + depth, layout, channels, device = case.split("_") + depth = int(depth) + idx = {"Y": [0], "UV": [1, 2], "YUV": [0, 1, 2]}[channels] + cells, outs = [], [] + for d, st in zip(dirs, stats): + r = st.get(case, "missing") + if r != "ok": + cells.append(r[:72]) + outs.append(None) + continue + src = planes(os.path.join(d, f"src_{depth}_{layout}.raw"), depth, layout) + out = planes(os.path.join(d, case + ".raw"), depth, layout) + outs.append(out) + cells.append(f"{mad(src, out, idx, depth):7.3f}") + row = f"{case:<20} | " + " | ".join(cells) + if len(dirs) == 2 and all(o is not None for o in outs): + row += f" | {mad(outs[0], outs[1], idx, depth):.4f}" + print(row, flush=True) diff --git a/docs/reports/issue-144-knlmeanscl/scripts/knlm_depth_probe.py b/docs/reports/issue-144-knlmeanscl/scripts/knlm_depth_probe.py new file mode 100644 index 0000000..1464c2e --- /dev/null +++ b/docs/reports/issue-144-knlmeanscl/scripts/knlm_depth_probe.py @@ -0,0 +1,75 @@ +"""Run ONE KNLMeansCL build across bit depths, layouts and channel modes, +and dump every output frame for knlm_compare.py. + +One build per process, so a fault in one cannot be blamed on the other. + +Run with the deps bundle's Python (see run.sh): + + knlm_depth_probe.py --out DIR # the bundle's own knlm + knlm_depth_probe.py --out DIR --alt PLUGIN # another build, as knlmalt + +Writes DIR/.raw (frame FRAME, planes concatenated) for the source and +each case that runs, and DIR/status.txt: one line per case, "ok" or the error. +""" +import argparse +import os +import vapoursynth as vs + +core = vs.core + +ap = argparse.ArgumentParser() +ap.add_argument("--out", required=True) +ap.add_argument("--alt", help="KNLMeansCL build to use instead, loaded as knlmalt") +ap.add_argument("--devices", default="gpu,auto") +args = ap.parse_args() + +ns = "knlm" +if args.alt: + core.std.LoadPlugin(args.alt, forcens="knlmalt", forceid="probe.knlm.alt") + ns = "knlmalt" +os.makedirs(args.out, exist_ok=True) + +W, H, FRAMES, FRAME = 320, 240, 11, 5 +DEPTHS = [8, 9, 10, 12, 14, 16] +LAYOUTS = {"420": (1, 1), "444": (0, 0)} + + +def source(fmt): + """A deterministic noisy picture: gradient + coloured blocks + grain, made + at 16-bit and truncated so every depth sees the same content.""" + base = core.std.BlankClip(width=W, height=H, format=vs.YUV444P16, + length=FRAMES, color=[32768, 32768, 32768]) + grad = core.akarin.Expr(base, ["X 128 * Y 64 * + 8192 +", + "X 32 / floor 2 % 16384 * 24576 +", + "Y 24 / floor 2 % 16384 * 24576 +"]) + noisy = core.grain.Add(grad, var=40.0, uvar=20.0, seed=7) + return core.resize.Point(noisy, format=fmt, dither_type="none") + + +def dump(clip, name): + f = clip.get_frame(FRAME) + with open(os.path.join(args.out, name + ".raw"), "wb") as fh: + for p in range(f.format.num_planes): + fh.write(bytes(f[p])) + + +status = [] +for depth in DEPTHS: + for lname, (sw, sh) in LAYOUTS.items(): + fmt = core.query_video_format(vs.YUV, vs.INTEGER, depth, sw, sh) + src = source(fmt.id) + dump(src, f"src_{depth}_{lname}") + for device in args.devices.split(","): + for channels in ["Y", "UV"] + (["YUV"] if lname == "444" else []): + case = f"{depth}_{lname}_{channels}_{device}" + try: + out = getattr(core, ns).KNLMeansCL( + src, d=1, a=2, s=4, h=1.2, channels=channels, device_type=device) + dump(out, case) + status.append(f"{case} ok") + except vs.Error as e: + status.append(f"{case} ERROR {str(e).splitlines()[0]}") + print(status[-1], flush=True) + +with open(os.path.join(args.out, "status.txt"), "w") as fh: + fh.write("\n".join(status) + "\n") diff --git a/docs/reports/issue-144-knlmeanscl/scripts/nlm_reference.py b/docs/reports/issue-144-knlmeanscl/scripts/nlm_reference.py new file mode 100644 index 0000000..769b89c --- /dev/null +++ b/docs/reports/issue-144-knlmeanscl/scripts/nlm_reference.py @@ -0,0 +1,62 @@ +"""CPU reference for KNLMeansCL's maths (channels=Y, wmode=0, wref=1), +compared with a GPU build on 150 sampled pixels of frame 5. + + nlm_reference.py [PLUGIN] # PLUGIN loaded as knlmalt; else the bundle's knlm + +Weight of offset (dx,dy,dt): exp(-P * 255^2 / (h^2 (2s+1)^2)), P = sum of +squared differences over the (2s+1)^2 patch, pixels in [0,1] (nlmDistance's +3*d^2 for luma cancels NLM_LEGACY = 3). The centre pixel gets +wref * max(largest weight, FLT_EPSILON): the max-weight buffer U5 starts at +CL_FLT_EPSILON. Output = weighted mean. Prints, per h, the mean |GPU - reference| +and |input - reference| in 8-bit levels. The GPU's own float rounding and +8-bit output quantisation leave about 0.25 even when it is right. +""" +import math, random, sys, vapoursynth as vs +core = vs.core +ns = "knlm" +if len(sys.argv) > 1: + core.std.LoadPlugin(sys.argv[1], forcens="knlmalt", forceid="probe.knlm.alt"); ns = "knlmalt" +A, S_, Dt, W, H = 2, 4, 1, 160, 120 +FLT_EPSILON = 1.1920929e-07 +clean = core.std.BlankClip(width=W, height=H, format=vs.YUV420P8, length=11, color=[128,128,128]) +clean = core.akarin.Expr(clean, ["X 40 / floor 2 % 64 * 96 +", "", ""]) +noisy = core.grain.Add(clean, var=40.0, uvar=0.0, seed=7) + +def luma(clip, n): + f = clip.get_frame(n); p = f[0] + return [[p[y, x] / 255.0 for x in range(W)] for y in range(H)] # 2-D index works + +frames = {t: luma(noisy, 5 + t) for t in (-1, 0, 1)} +random.seed(1) +pts = [(random.randrange(A + S_ + 1, W - A - S_ - 1), random.randrange(A + S_ + 1, H - A - S_ - 1)) for _ in range(150)] + +def ref(h, x, y): + # luma distance is 3*d^2 (nlmDistance), over NLM_LEGACY=3: the 3s cancel + k = 255.0 ** 2 / (h * h * (2 * S_ + 1) ** 2) + c = frames[0] + ws, acc = [], 0.0 + for dt in (-1, 0, 1): + f = frames[dt] + for dy in range(-A, A + 1): + for dx in range(-A, A + 1): + if dt == 0 and dx == 0 and dy == 0: + continue + P = 0.0 + for j in range(-S_, S_ + 1): + rc, rf = c[y + j], f[y + dy + j] + for i in range(-S_, S_ + 1): + d = rc[x + i] - rf[x + dx + i] + P += d * d + w = math.exp(-P * k) + ws.append(w); acc += w * f[y + dy][x + dx] + m = max(max(ws), FLT_EPSILON) # the U5 buffer starts at CL_FLT_EPSILON + return (acc + m * c[y][x]) / (sum(ws) + m) + +for h in (1.2, 2.0, 3.0, 5.0): + out = getattr(core, ns).KNLMeansCL(noisy, d=Dt, a=A, s=S_, h=h, channels="Y", device_type="gpu") + g = luma(out, 5) + e_gpu = e_in = 0.0 + for x, y in pts: + r = ref(h, x, y) + e_gpu += abs(g[y][x] - r); e_in += abs(frames[0][y][x] - r) + print(f"h={h}: |gpu-ref|={e_gpu/len(pts)*255:.3f} |input-ref|={e_in/len(pts)*255:.3f}", flush=True) diff --git a/docs/reports/issue-144-knlmeanscl/scripts/run.sh b/docs/reports/issue-144-knlmeanscl/scripts/run.sh new file mode 100755 index 0000000..62ec56f --- /dev/null +++ b/docs/reports/issue-144-knlmeanscl/scripts/run.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Reproduce every number in the issue #144 report (macOS arm64; any platform +# with an OpenCL GPU and a deps bundle should work with DEPS adjusted). +# +# KNLM_PROBE_DIR=/some/scratch ./run.sh [PLUGIN ...] +# +# With no PLUGIN arguments it compares the bundle's own knlm with the +# KNLMeansCL 1.3 PyPI wheel, downloaded into $KNLM_PROBE_DIR and checked by +# hash. Give plugin paths to compare those instead, e.g. the deps-1.14.0 +# libknlmeanscl.dylib (Khanattila master) against a v1.3 build. +# +# Each plugin runs in its own process: knlm_depth_probe.py dumps frames there, +# knlm_compare.py diffs them, nlm_reference.py checks the maths. +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$HERE/../../../.." && pwd)" +: "${KNLM_PROBE_DIR:?set KNLM_PROBE_DIR to a scratch directory}" +mkdir -p "$KNLM_PROBE_DIR" + +case "$(uname -s)-$(uname -m)" in + Darwin-arm64) DEPS="${DEPS:-$REPO/deps/macos-arm64}" + WHEEL_TAG=macosx_15_0_arm64 + WHEEL_SHA=78a044c24f9a185159990f3127040d3db2d354dce2235f7362a811211c8fa96b ;; + *) echo "set DEPS and pass plugin paths explicitly on this platform" >&2 + DEPS="${DEPS:?}"; WHEEL_TAG=""; WHEEL_SHA="" ;; +esac + +if [ $# -eq 0 ]; then + [ -n "$WHEEL_TAG" ] || { echo "no wheel known for this platform" >&2; exit 1; } + W="$KNLM_PROBE_DIR/wheel"; mkdir -p "$W" + url=$(curl -fsS https://pypi.org/pypi/vapoursynth-knlmeanscl/1.3/json | python3 -c \ + "import sys,json;print([f['url'] for f in json.load(sys.stdin)['urls'] if '$WHEEL_TAG' in f['filename']][0])") + curl -fsSL -o "$W/k.whl" "$url" + echo "$WHEEL_SHA $W/k.whl" | shasum -a 256 -c - + (cd "$W" && unzip -o -q k.whl) + set -- "" "$(find "$W/vapoursynth/plugins" -name '*KNLMeansCL*' | head -1)" +fi + +export PYTHONHOME="$DEPS/python" PYTHONPATH="$DEPS:$DEPS/python-packages" +export DYLD_LIBRARY_PATH="$DEPS/vapoursynth:$DEPS/python/lib" XDG_CONFIG_HOME="$DEPS/config" +PY="$DEPS/python/bin/python3" + +dirs=() +i=0 +for plugin in "$@"; do + name="build$i"; [ -z "$plugin" ] && name="bundle" + for run in a b; do + out="$KNLM_PROBE_DIR/$name-$run"; rm -rf "$out" + "$PY" "$HERE/knlm_depth_probe.py" --out "$out" ${plugin:+--alt "$plugin"} >/dev/null + done + dirs+=("$KNLM_PROBE_DIR/$name-a") + echo "== $name (${plugin:-bundle knlm}): CPU reference" + "$PY" "$HERE/nlm_reference.py" ${plugin:+"$plugin"} + echo "== $name: repeat run, distinct differences (0.0000 = repeatable)" + env -u PYTHONHOME -u PYTHONPATH python3 "$HERE/knlm_compare.py" \ + "$KNLM_PROBE_DIR/$name-a" "$KNLM_PROBE_DIR/$name-b" | tail -n +2 | awk -F'|' '{print $NF}' | sort | uniq -c + i=$((i + 1)) +done +if [ ${#dirs[@]} -eq 2 ]; then + echo "== depth matrix, first vs second" + env -u PYTHONHOME -u PYTHONPATH python3 "$HERE/knlm_compare.py" "${dirs[0]}" "${dirs[1]}" +fi diff --git a/licenses/NOTICES.txt b/licenses/NOTICES.txt index b7cac9d..fb41275 100644 --- a/licenses/NOTICES.txt +++ b/licenses/NOTICES.txt @@ -342,10 +342,13 @@ descale — MIT KNLMeansCL — GPL-3.0 Copyright (C) 2015-2020 Edoardo Brunetti - https://github.com/Khanattila/KNLMeansCL + maintained by Jaded Encoding Thaumaturgy + https://github.com/Jaded-Encoding-Thaumaturgy/vapoursynth-knlmeanscl + (originally https://github.com/Khanattila/KNLMeansCL) Licence text: GPL-3.0.txt OpenCL non-local-means denoising; degrades gracefully where no OpenCL device - is available. + is available. Version 1.3: built from source on macOS and Linux, taken + unmodified from the project's PyPI wheel on Windows. DeScratch — GPL-2.0 Copyright (C) 2003-2016 Alexander G. Balakhnin aka Fizick diff --git a/worker/templates/pipeline_template.vpy b/worker/templates/pipeline_template.vpy index 12628e6..412ff1f 100644 --- a/worker/templates/pipeline_template.vpy +++ b/worker/templates/pipeline_template.vpy @@ -966,7 +966,7 @@ clip = core.resize.Point(_bm3d_work, format=_bm3d_fmt.id, dither_type="error_dif # KNLMeansCL: non-local means on the GPU (OpenCL). d = frames each side, # a = search radius, s = similarity radius, h = strength. Runs on the device the # worker's probe found working ({{NR_KNLM_DEVICE}}): the plugin's own default, -# "auto", fails on Apple Silicon. 4:2:0 cannot take channels="YUV", so luma and +# "auto", failed on Apple Silicon before v1.3. 4:2:0 cannot take channels="YUV", so luma and # chroma are separate calls. clip = core.knlm.KNLMeansCL(clip, d={{NR_KNLM_D}}, a={{NR_KNLM_A}}, s={{NR_KNLM_S}}, h={{NR_KNLM_H}}, channels="Y", device_type="{{NR_KNLM_DEVICE}}") if {{NR_KNLM_CHROMA}} and clip.format.color_family == vs.YUV: diff --git a/worker/templates/preview_template.vpy b/worker/templates/preview_template.vpy index d4df1b2..9e54700 100644 --- a/worker/templates/preview_template.vpy +++ b/worker/templates/preview_template.vpy @@ -909,7 +909,7 @@ clip = core.resize.Point(_bm3d_work, format=_bm3d_fmt.id, dither_type="error_dif # KNLMeansCL: non-local means on the GPU (OpenCL). d = frames each side, # a = search radius, s = similarity radius, h = strength. Runs on the device the # worker's probe found working ({{NR_KNLM_DEVICE}}): the plugin's own default, -# "auto", fails on Apple Silicon. 4:2:0 cannot take channels="YUV", so luma and +# "auto", failed on Apple Silicon before v1.3. 4:2:0 cannot take channels="YUV", so luma and # chroma are separate calls. clip = core.knlm.KNLMeansCL(clip, d={{NR_KNLM_D}}, a={{NR_KNLM_A}}, s={{NR_KNLM_S}}, h={{NR_KNLM_H}}, channels="Y", device_type="{{NR_KNLM_DEVICE}}") if {{NR_KNLM_CHROMA}} and clip.format.color_family == vs.YUV: From cd01170fac62f9f5a12ad4e70f39c958ddf76c02 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Tue, 3 Nov 2026 15:05:44 +1100 Subject: [PATCH 2/7] chore(deps): point the PR at the 1.15.0-rc1 release candidate CI derives the asset version from the release tag, so the rc's zips are VapourBox-deps-1.15.0-rc1-*.zip. Repoint at deps-v1.15.0 before merge. Co-Authored-By: Claude Opus 5.5 --- app/assets/deps-version.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/assets/deps-version.json b/app/assets/deps-version.json index 71f33b6..9e75c1e 100644 --- a/app/assets/deps-version.json +++ b/app/assets/deps-version.json @@ -1,7 +1,7 @@ { - "version": "1.14.0", - "releaseTag": "deps-v1.14.0", - "releaseDate": "2026-10-25", + "version": "1.15.0-rc1", + "releaseTag": "deps-v1.15.0-rc1", + "releaseDate": "2026-11-03", "tierFormat": "delta", "githubRepo": "StuartCameronCode/VapourBox" } From 50412cc6a082b06c892aa69f95de01e59e96f410 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Tue, 3 Nov 2026 17:19:48 +1100 Subject: [PATCH 3/7] docs(#144): record the Windows verification of KNLMeansCL v1.3 On Windows x64 (Radeon 780M, OpenCL probe gpu), the deps 1.14.0 DLL (v1.1.1) reproduces the reported error on every 10-bit case, plus 12-bit, and QTGMC's knlmeanscl denoiser fails the same way. The v1.3 DLL installed by download-deps-windows.ps1 matches the wheel and the rc1 bundle, and passes all of them. Co-Authored-By: Claude Opus 5.5 --- docs/ENGINEERING_NOTES.md | 14 ++++- docs/reports/README.md | 2 +- docs/reports/issue-144-knlmeanscl/index.html | 54 ++++++++++++++++---- 3 files changed, 58 insertions(+), 12 deletions(-) diff --git a/docs/ENGINEERING_NOTES.md b/docs/ENGINEERING_NOTES.md index 094ef61..04f1f86 100644 --- a/docs/ENGINEERING_NOTES.md +++ b/docs/ENGINEERING_NOTES.md @@ -2805,8 +2805,18 @@ Full report with scripts: (matching Windows/Linux, as designed), and its `auto` device works, so QTGMC's `knlmeanscl` denoiser runs on Apple Silicon instead of falling back to DFTTest. -- Not yet run on Windows, macOS x64 or Linux. The Windows result is the one - that matters for the issue. +- **Windows x64, verified 2026-11-03** (Radeon 780M, driver 32.0.32015.7029, + OpenCL probe `gpu`, so nothing skipped). With the 1.14.0 DLL (v1.1.1, sha256 + `bb60e8fa…7a42`), every 10-bit KNLMeans case failed with the predicted + message, 12-bit failed with "P8, P10, P16, Half and Single", and 16-bit + passed. QTGMC's `knlmeanscl` failed too on a 10-bit 4:2:0 source, inside + havsfunc. With v1.3 (sha256 `c7092121…a343`; the same DLL from the script, + the wheel and the rc1 bundle), all of these pass. Parity was 0.638 at 4:2:2 + (Mac 0.301), and 1.620 at 4:2:0 against a fixtures-only baseline of 1.885 + (Mac 1.394 / 1.717). One thing to know when reading these runs: the 10-bit + encode failure shows up in ffmpeg as "Header too large", not the KNLMeans + message. Only the preview path surfaces the Python error. +- Not yet run on macOS x64, Linux, or an NVIDIA GPU. ### Testing a deps change before publishing diff --git a/docs/reports/README.md b/docs/reports/README.md index c846904..1227d3a 100644 --- a/docs/reports/README.md +++ b/docs/reports/README.md @@ -66,5 +66,5 @@ to it. | Date | Report | Outcome | |---|---|---| -| 2026-11-03 | [Issue #144: KNLMeansCL fails on 10-bit sources (Windows), unreliable on macOS](issue-144-knlmeanscl/index.html) | Swap every platform to the maintained fork v1.3 (tag on macOS/Linux, PyPI wheel on Windows); verified on macOS arm64, Windows pending | +| 2026-11-03 | [Issue #144: KNLMeansCL fails on 10-bit sources (Windows), unreliable on macOS](issue-144-knlmeanscl/index.html) | Swap every platform to the maintained fork v1.3 (tag on macOS/Linux, PyPI wheel on Windows); verified on macOS arm64; on Windows x64 (AMD GPU) the bug reproduces with v1.1.1 and v1.3 fixes it | | 2026-11-03 | [Issue #117: QTempGaussMC (vs-jetpack) as an alternate deinterlacer](issue-117-qtempgaussmc/index.html) | Worth adding as an advanced-only method (not macOS x64); +0.5–0.8 dB over havsfunc QTGMC Slower at equal speed once tuned; not built yet | diff --git a/docs/reports/issue-144-knlmeanscl/index.html b/docs/reports/issue-144-knlmeanscl/index.html index bff4e08..44d2b47 100644 --- a/docs/reports/issue-144-knlmeanscl/index.html +++ b/docs/reports/issue-144-knlmeanscl/index.html @@ -46,7 +46,7 @@

Issue #144 — KNLMeansCL fails on 10-bit sources on Windows, and is unreliable on macOS

2026-11-03 · issue #144 · -measured on an M1 Pro (macOS arm64) against the deps 1.14.0 bundle · Windows result pending

+measured on an M1 Pro (macOS arm64) against the deps 1.14.0 bundle · reproduced and fixed on Windows x64 (AMD Radeon 780M), see 3d

Outcome. Every platform moves to the same KNLMeansCL: Jaded Encoding @@ -57,8 +57,8 @@

Issue #144 — KNLMeansCL fails on 10-bit sources on Windows, and is unrelia
  • Windows shipped Khanattila's v1.1.1 release (2018), which refuses every 9–15-bit clip except packed YUV444P10 / RGB30. The KNLMeans Noise Reduction method failed outright on any 10-bit 4:2:0 or 4:2:2 source, as reported. -This is read from the source, not reproduced: no Windows machine has -run it yet.
  • +This was first read from the source. It has since been reproduced on +Windows with the 1.14.0 DLL and shown fixed with v1.3 (section 3d).
  • macOS built Khanattila's unpinned master. On Apple's OpenCL it gives a different result on a repeat run in 24 of 60 cases (by 110–255 levels), and even when repeatable it does not compute the algorithm's result at low @@ -188,20 +188,56 @@

    3c. In the app (macOS arm64, local bundle with v1.3 swapped in)

    back to DFTTest there. v1.3's device selection handles that, so on a Mac QTGMC now runs the denoiser the user chose.

    +

    3d. Windows x64 (deps 1.14.0 → v1.3)

    +

    Windows 11 Pro (build 26200), AMD Radeon 780M integrated graphics, driver +32.0.32015.7029, CPU tier v3. --probe-opencl reported +{"knlm":true,"knlmDevice":"gpu"} with both DLLs, so no KNLMeans test was skipped +(no SKIPPED line in any run).

    + + + + +
    DLLSHA-256
    deps 1.14.0, Khanattila v1.1.1bb60e8fa6cb531daa8d705a338c1188e202e38087f6c6c437fd2d4ee58477a42
    v1.3, from wheel 6ba28e0c…2b278b (hash checked)c7092121f4fde51f93e48fbbc9277e22599caf3a41027d0df5cfd9cc100ba343
    +

    These three copies of v1.3 all have the second hash: the DLL installed by +download-deps-windows.ps1, the one extracted from the PyPI wheel by hand, and the +one in the published deps-v1.15.0-rc1 bundle.

    + + + + + + + + + + + +
    Testv1.1.1 (deps 1.14.0)v1.3
    noise_knlmeans preview, 4:2:2 10-bitfails: only YUV444P10 and RGB30 are supported!pass
    noise_knlmeans preview, 4:4:4 12-bitfails: P8, P10, P16, Half and Single are supported!pass
    noise_knlmeans preview, 4:2:2 16-bitpasspass
    8- vs 10-bit parity, 4:2:2fails (the 10-bit run hits the same error)0.638 (tolerance 2.0)
    10-bit 4:2:0 preview parityfails: only YUV444P10 and RGB30…1.620, below the fixtures' own 1.885
    10-bit 4:2:0 full encodefails (vspipe dies at evaluation; ffmpeg reports only Header too large)pass
    QTGMC denoiser: knlmeanscl on the 10-bit 4:2:0 fixture, preview and encode (a one-off test, not committed)fails in havsfunc's KNLMeansCL(noiseWindow, d=NoiseTR, h=Sigma): only YUV444P10 and RGB30…pass
    Existing tests: KNLMeans end-to-end encode; QTGMC with knlmeanscl (8-bit fixture); vapoursynth_integration_test, attribution_test—pass
    The same issue #144 tests against the published deps-v1.15.0-rc1 bundle, downloaded by the harness—pass (1.620 / 1.885)
    +

    The diagnosis in section 1 holds exactly, including 16-bit passing under +v1.1.1. The parity numbers are higher than on the Mac (0.638 vs 0.301; 1.620 vs 1.394), but the +4:2:0 fixtures alone also differ more here (1.885 vs 1.717) before any filter runs. Both stay +well inside their tolerances. I did not check whether the rest of the gap comes from the +fixtures or from AMD's OpenCL rounding. download-deps-windows.ps1 ran cleanly +and installed the DLL from the wheel. It also re-downloaded FFmpeg and the other plugin archives +instead of skipping them as already installed, which is harmless.

    +

    Not run on Windows: the real app on a real 10-bit file, the repeatability matrix from 3a, +the CPU-reference comparison from 3b, and an NVIDIA GPU like the reporter's RTX 3060.

    +

    4. What wasn't tested

      -
    • Windows, the reported platform. The diagnosis comes from v1.1.1's source and -the user's log. The fix rests on the v1.3 wheel containing the same source that was measured -on macOS. Before release, it needs one run on a Windows machine with a GPU: a 10-bit 4:2:0 -source through KNLMeans with v1.1.1 (expect the error), then with the v1.3 DLL (expect it to -run), and the KNLMeans tests in integration_high_bit_depth_filters_test.dart.
    • +
    • Windows: tested since, see 3d. This was untested when the report was first +written. It has since run on Windows x64 with an AMD GPU: v1.1.1 reproduces the error and v1.3 +fixes it, both in the Noise Reduction pass and in QTGMC's denoiser. Still untested on Windows: +an NVIDIA GPU (the reporter's), and the real app on a real 10-bit file rather than the test +harness.
    • macOS x64 and Linux: the scripts are changed, but nothing has been built or run there. build-deps-* CI is the build test. Neither platform can run KNLMeans in CI (no OpenCL).
    • Only one kind of input: synthetic grain on synthetic pictures. That is enough to tell right from wrong against the reference, but says nothing about the best h for real footage.
    • -
    • NVIDIA, AMD and Intel OpenCL drivers: Apple's is the only one measured. The fill-buffer race is +
    • NVIDIA and Intel OpenCL drivers. AMD's ran the app-level tests in 3d, but not the +repeatability matrix or the CPU reference, which have run only on Apple's. The fill-buffer race is described as Apple-specific (see GROMACS issue 4852, linked in v1.3's source).
    From 74cc316c1fdd4db268aaf4dd753afbd7d16aa197 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Tue, 3 Nov 2026 20:36:16 +1100 Subject: [PATCH 4/7] test(harness): pass --sub-frame from runPreview integration_frame_mapping_test (#142) calls runPreview(subFrame: k), but the harness change never landed, so the file failed to compile and every nightly since has reported it as a load failure. Co-Authored-By: Claude Opus 5.5 --- app/test/support/worker_harness.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/test/support/worker_harness.dart b/app/test/support/worker_harness.dart index f462547..138f911 100644 --- a/app/test/support/worker_harness.dart +++ b/app/test/support/worker_harness.dart @@ -542,6 +542,7 @@ class WorkerHarness { static Future runPreview( Map jobJson, { required int frame, + int subFrame = 0, String label = 'preview', Duration timeout = const Duration(minutes: 5), Map extraEnv = const {}, @@ -552,7 +553,10 @@ class WorkerHarness { try { final process = await Process.start( workerPath, - ['--config', configFile.path, '--preview', '--frame', '$frame'], + [ + '--config', configFile.path, '--preview', '--frame', '$frame', + '--sub-frame', '$subFrame', + ], environment: {...workerEnv, ...extraEnv}, workingDirectory: File(workerPath).parent.path, ); From 2b3f0c4266a67fcf1e2dcac56882b221217c08a5 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Tue, 3 Nov 2026 21:21:01 +1100 Subject: [PATCH 5/7] fix(frame-rate): set FlowFPS's output length in the script MVTools v24 (Windows, macOS x64) makes FlowFPS emit floor((n-1)*r)+1 frames; master (macOS arm64, Linux) emits floor(n*r), which is what FrameMap::Retime counts. A FlowFPS job on Windows or an Intel Mac was one frame short (or long, below 1x), so the progress total and preview index were off at the tail. Found by the custom-rate test's first CI run: 178 frames for 179 on both macOS x64 jobs. Both templates now compute floor(n*r) with Fraction and trim or pad (with the last frame, which is what master returns past the source) after the call. test_183 asserts it. The frame-mapping suite passes against the arm64 bundle (master) and the macos-x64 bundle under Rosetta (v24). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 ++++ docs/ENGINEERING_NOTES.md | 32 +++++++++++++++++++++++++ worker/templates/pipeline_template.vpy | 21 ++++++++++++---- worker/templates/preview_template.vpy | 21 ++++++++++++---- worker/tests/filter_integration_test.rs | 25 +++++++++++++++++++ 5 files changed, 96 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ebe4232..38410a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1180,6 +1180,11 @@ rate is `FrameRateParameters.customFps` (a plain number, default 48). NTSC source. Anything else is taken to three decimals. Out-of-range input is clamped to 1–240 rather than refused: the panel's number box doesn't enforce the schema's bounds. The Dart enum carries no numbers. +- **FlowFPS's length is set by the script, not MVTools.** macOS arm64 and + Linux build MVTools `master` (`floor(n * ratio)` frames), Windows and macOS + x64 ship v24 (`floor((n - 1) * ratio) + 1`). Both templates trim or extend + to `floor(n * ratio)` after the call (`test_183`); a frame-count assertion + that passes on one platform proves nothing about the others. - **Don't check an NTSC output rate on an MKV.** Matroska's 1/1000 timebase can't hold 60000/1001, so ffprobe reports an estimate (`19001/317`) for any NTSC rate. `integration_frame_mapping_test.dart` encodes its custom-rate diff --git a/docs/ENGINEERING_NOTES.md b/docs/ENGINEERING_NOTES.md index 04f1f86..4e65b8b 100644 --- a/docs/ENGINEERING_NOTES.md +++ b/docs/ENGINEERING_NOTES.md @@ -2818,6 +2818,38 @@ Full report with scripts: message. Only the preview path surfaces the Python error. - Not yet run on macOS x64, Linux, or an NVIDIA GPU. +### FlowFPS's frame count depends on the MVTools version (issue #129, 2026-11-03) + +The custom-rate test in `integration_frame_mapping_test.dart` (25 fps to +59.94, 75 frames, expecting `floor(75 * 2400/1001)` = 179) produced 178 on +both macOS x64 nightly jobs and 179 everywhere it was run before. The file had +not compiled since #142 (its harness change was never committed), so this was +its first CI run. + +- **Cause.** The platforms do not ship the same MVTools. macOS arm64 and Linux + build dubhater's unpinned `master`; Windows (the v24 release zip) and macOS + x64 (Stefan-Olt's v24 build for v3, v24 from source for v2) ship v24. + Between them `MVFlowFPS` changed its length from + `1 + (n - 1) * fb / fa` (v24) to `n * fb / fa` (master). The template's + "FlowFPS emits floor(n * ratio)" was measured on master only. So a FlowFPS + job on Windows or an Intel Mac wrote one frame fewer than `FrameMap::Retime` + counts when the ratio is above 1 (one more below 1), and the progress total + and preview index were off by one at the tail. +- **Reproduced locally** by running an x86_64 worker against the rc1 + macos-x64 bundle under Rosetta (an arm64 worker ignores + `VAPOURBOX_DEPS_DIR` for its tools and quietly uses the installed arm64 + deps, so it reproduces nothing). vspipe: "Output 178 frames". +- **Fix: the script sets the length.** Both templates compute + `floor(n * ratio)` with `Fraction` before the call and trim or extend + after it. The frame v24 leaves off lies past the last source frame, where + master returns that source frame, so it is padded with the last frame. + `test_183` asserts the order in both scripts; the frame-mapping suite passes + on both arm64 (master) and x64 under Rosetta (v24). +- **Not done: one MVTools everywhere.** Pinning arm64/Linux to v24 would lose + master's ARM speed work (sse2neon removals); Windows has no build newer + than v24. Any other FlowFPS/BlockFPS/FlowInter behaviour that differs + between the two versions is still per-platform. + ### Testing a deps change before publishing A PR that changes `deps/` has a chicken-and-egg problem: `ci-test.yml` and diff --git a/worker/templates/pipeline_template.vpy b/worker/templates/pipeline_template.vpy index 412ff1f..86e57ee 100644 --- a/worker/templates/pipeline_template.vpy +++ b/worker/templates/pipeline_template.vpy @@ -2268,10 +2268,13 @@ clip = haf.GrainFactory3( # it resamples the timeline, so anything after it would be working on invented # frames rather than photographed ones. # -# FlowFPS rather than BlockFPS deliberately. Measured over 35 combinations, -# FlowFPS emits floor(n_in * ratio), which is exactly what FrameMap::Retime -# computes; BlockFPS emits floor((n_in-1) * ratio) + 1 and is off by one in 14 -# of them, which would desynchronise the progress total and the preview index. +# FlowFPS rather than BlockFPS deliberately: BlockFPS emits +# floor((n_in-1) * ratio) + 1, off by one in 14 of 35 measured combinations +# from floor(n_in * ratio), which is what FrameMap::Retime computes and the +# progress total and preview index rely on. FlowFPS's own count depends on the +# MVTools version: master emits floor(n_in * ratio), but the v24 release +# (Windows, macOS x64) emits floor((n_in-1) * ratio) + 1 like BlockFPS, so the +# block below sets the length itself rather than trusting either. # # mvtools rejects 4:1:1 at Super ("input clip must be GRAY, 420, 422, 440, or # 444") and 4:1:1 is NTSC DV — precisely the tape an NTSC->PAL conversion @@ -2286,10 +2289,20 @@ if clip.format.subsampling_w == 2: _fps_super = core.mv.Super(clip, pel=2) _fps_bw = core.mv.Analyse(_fps_super, isb=True, blksize={{FPS_BLOCK_SIZE}}, overlap={{FPS_OVERLAP}}) _fps_fw = core.mv.Analyse(_fps_super, isb=False, blksize={{FPS_BLOCK_SIZE}}, overlap={{FPS_OVERLAP}}) +from fractions import Fraction as _FpsFraction +_fps_ratio = _FpsFraction({{FPS_TARGET_NUM}}, {{FPS_TARGET_DEN}}) / _FpsFraction(clip.fps_num, clip.fps_den) +_fps_len = len(clip) * _fps_ratio.numerator // _fps_ratio.denominator clip = core.mv.FlowFPS( clip, _fps_super, _fps_bw, _fps_fw, num={{FPS_TARGET_NUM}}, den={{FPS_TARGET_DEN}}, ) +# Exactly floor(n_in * ratio) frames on every MVTools version. v24 is at most +# one short (ratio > 1) or one long (ratio < 1). The missing frame lies past +# the last source frame, where master returns that source frame, so repeat it. +if len(clip) > _fps_len: + clip = clip[:_fps_len] +elif len(clip) < _fps_len: + clip = clip + clip[-1] * (_fps_len - len(clip)) {{/FRAME_RATE_FLOWFPS}} {{#FRAME_RATE_DUPLICATE}} # No invented pixels — whole frames are repeated or dropped. Visible judder, but diff --git a/worker/templates/preview_template.vpy b/worker/templates/preview_template.vpy index 9e54700..51c5531 100644 --- a/worker/templates/preview_template.vpy +++ b/worker/templates/preview_template.vpy @@ -2200,10 +2200,13 @@ clip = haf.GrainFactory3( # it resamples the timeline, so anything after it would be working on invented # frames rather than photographed ones. # -# FlowFPS rather than BlockFPS deliberately. Measured over 35 combinations, -# FlowFPS emits floor(n_in * ratio), which is exactly what FrameMap::Retime -# computes; BlockFPS emits floor((n_in-1) * ratio) + 1 and is off by one in 14 -# of them, which would desynchronise the progress total and the preview index. +# FlowFPS rather than BlockFPS deliberately: BlockFPS emits +# floor((n_in-1) * ratio) + 1, off by one in 14 of 35 measured combinations +# from floor(n_in * ratio), which is what FrameMap::Retime computes and the +# progress total and preview index rely on. FlowFPS's own count depends on the +# MVTools version: master emits floor(n_in * ratio), but the v24 release +# (Windows, macOS x64) emits floor((n_in-1) * ratio) + 1 like BlockFPS, so the +# block below sets the length itself rather than trusting either. # # mvtools rejects 4:1:1 at Super ("input clip must be GRAY, 420, 422, 440, or # 444") and 4:1:1 is NTSC DV — precisely the tape an NTSC->PAL conversion @@ -2226,10 +2229,20 @@ if _fps_pad: _fps_super = core.mv.Super(clip, pel=2) _fps_bw = core.mv.Analyse(_fps_super, isb=True, blksize={{FPS_BLOCK_SIZE}}, overlap={{FPS_OVERLAP}}) _fps_fw = core.mv.Analyse(_fps_super, isb=False, blksize={{FPS_BLOCK_SIZE}}, overlap={{FPS_OVERLAP}}) +from fractions import Fraction as _FpsFraction +_fps_ratio = _FpsFraction({{FPS_TARGET_NUM}}, {{FPS_TARGET_DEN}}) / _FpsFraction(clip.fps_num, clip.fps_den) +_fps_len = len(clip) * _fps_ratio.numerator // _fps_ratio.denominator clip = core.mv.FlowFPS( clip, _fps_super, _fps_bw, _fps_fw, num={{FPS_TARGET_NUM}}, den={{FPS_TARGET_DEN}}, ) +# Exactly floor(n_in * ratio) frames on every MVTools version. v24 is at most +# one short (ratio > 1) or one long (ratio < 1). The missing frame lies past +# the last source frame, where master returns that source frame, so repeat it. +if len(clip) > _fps_len: + clip = clip[:_fps_len] +elif len(clip) < _fps_len: + clip = clip + clip[-1] * (_fps_len - len(clip)) {{/FRAME_RATE_FLOWFPS}} {{#FRAME_RATE_DUPLICATE}} # No invented pixels — whole frames are repeated or dropped. Visible judder, but diff --git a/worker/tests/filter_integration_test.rs b/worker/tests/filter_integration_test.rs index 8433112..9decc04 100644 --- a/worker/tests/filter_integration_test.rs +++ b/worker/tests/filter_integration_test.rs @@ -6793,3 +6793,28 @@ fn test_182_preview_retime_padding() { assert!(pad < retime, "{method:?}: padding after the retime"); } } + +/// FlowFPS's own output length depends on the MVTools version (master: +/// floor(n * ratio); the v24 release on Windows and macOS x64: +/// floor((n - 1) * ratio) + 1), so both scripts set it to FrameMap::Retime's +/// count themselves, after the call. +#[test] +fn test_183_flowfps_length_is_set_by_the_script() { + create_output_dir(); + let generator = ScriptGenerator::new().unwrap().with_rife(Ok(rife_files())); + let mut job = rife_job("test_183_flowfps_length"); + let fr = &mut job.processing_pipeline.as_mut().unwrap().frame_rate; + fr.method = FrameRateMethod::FlowFps; + let (encode, preview) = both_scripts_with(&generator, &job).unwrap(); + for (name, script) in [("encode", &encode), ("preview", &preview)] { + let call = script.find("core.mv.FlowFPS(").expect("FlowFPS"); + let len = script.find("_fps_len = len(clip) * _fps_ratio.numerator // _fps_ratio.denominator") + .unwrap_or_else(|| panic!("{name}: no length computed")); + let trim = script.find(" clip = clip[:_fps_len]") + .unwrap_or_else(|| panic!("{name}: no trim")); + let pad = script.find(" clip = clip + clip[-1] * (_fps_len - len(clip))") + .unwrap_or_else(|| panic!("{name}: no pad")); + assert!(len < call, "{name}: length must be taken from FlowFPS's input"); + assert!(call < trim && call < pad, "{name}: length set before FlowFPS"); + } +} From 5d29604692f5d2bc5944fe497fd40127dc87190a Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Tue, 3 Nov 2026 21:25:17 +1100 Subject: [PATCH 6/7] test(frame-mapping): give the RIFE encode the AI-upscale budget On the GPU-less Linux runners RIFE runs on lavapipe alongside the other heavy files, and the group's 75-frame setup encode hit the harness's default 5 minutes in four of main's last seven nightlies (it passed in under two on other jobs). It now gets 20 minutes and the matching stall limit, as the AI-upscale encodes do. Co-Authored-By: Claude Opus 5.5 --- app/test/integration_frame_mapping_test.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/test/integration_frame_mapping_test.dart b/app/test/integration_frame_mapping_test.dart index d4a0bb3..05ccbb2 100644 --- a/app/test/integration_frame_mapping_test.dart +++ b/app/test/integration_frame_mapping_test.dart @@ -607,7 +607,14 @@ void main() { available = probe.stdout.toString().contains('"available":true'); print(' RIFE probe: ${probe.stdout.toString().trim()}'); if (!available) return; - final r = await WorkerHarness.runJob(rifeJob().toJson(), label: 'rife_2x'); + // On the GPU-less runners RIFE runs on lavapipe (CPU Vulkan), alongside + // the other heavy files. This 75-frame encode passed in under two + // minutes on one Linux job and hit the default 5 in another, so it gets + // the AI-upscale encodes' budget. + final r = await WorkerHarness.runJob(rifeJob().toJson(), + label: 'rife_2x', + timeout: const Duration(minutes: 20), + extraEnv: const {'VAPOURBOX_STALL_TIMEOUT_SECS': '1200'}); expect(r.success, isTrue, reason: r.error); encoded = r.outputPath!; }); From e176c6becea854d78ee2b16a9a2c1c19a297156a Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Tue, 3 Nov 2026 23:16:51 +1100 Subject: [PATCH 7/7] chore: app 1.6.0, deps 1.15.0 deps-v1.15.0 is the tested deps-v1.15.0-rc1 bundles with only the version changed (file names, each zip's version.json, the deltas' baseSha256, the sidecars); every other entry has the same contents, size and mode. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- app/assets/deps-version.json | 4 ++-- app/macos/Runner/Info.plist | 4 ++-- app/pubspec.yaml | 2 +- app/windows/runner/Runner.rc | 4 ++-- worker/Cargo.toml | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 38410a4..307ce12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2298,4 +2298,4 @@ Full write-ups (root causes, measurements) for each entry are in | 1.12.0 | 2026-10-04 | **Issue #101**: akarin 1.4.1 → **1.5.0** on every platform. The 1.4.1 Linux wheel put JIT code on the `brk` heap, where SELinux denies `PROT_EXEC`, so every job segfaulted on Fedora/RHEL from deps 1.8.0 on. `download-deps-linux.sh` now fails if `libakarin.so` lacks the mmap allocator | | 1.13.0 | 2026-10 | One new plugin; every other binary is built from the same sources as 1.12.0. Adds **vszip** 22.1.0 (MIT) for `vszip.CLAHE`, Color Correction's local contrast (issue #90). Baseline build only, flat in the plugin directory on every platform (the wheels' AVX2/AVX-512 variants are left out); macOS x64 builds the sdist with Zig 0.16.0 for the 12.0 floor, with header padding so it can be signed. The v2 CPU tier is no longer a second full bundle: each x64 platform publishes its v3 bundle plus `…-x64-v2-delta.zip`, the files in `_tierFiles` (zsmooth; on macOS also MVTools), extracted over it (`"tierFormat": "delta"`). Every x64 machine downloads the same bundle; only an older CPU fetches the few MB more. This is also what lets the Linux AppImage carry its deps for both tiers without doubling in size | | 1.14.0 | 2026-10-25 | Adds **RIFE** 4.26 (`r9_mod_v33`, MIT) + its model in `vapoursynth/rife/`, and **VSNCNN** (vs-mlrt v15.16, GPL-3.0) + Real-ESRGAN / Real-CUGAN models in `vapoursynth/ncnn/`, on macos-arm64, windows-x64 and linux-x64, with **MoltenVK** 1.4.2 (Apache-2.0) in `vapoursynth/moltenvk/` on macOS. Not autoloaded; see "GPU Plugins". **BM3D** r12 (MIT) on every platform; it had been macOS arm64 only (see "BM3D and KNLMeans Noise Reduction"). **descale** r12 (MIT) on every platform, for the Descale pass. (AnimeJaNai's models, used by the same VSNCNN, are an app add-on, not in the bundle). Published as the tested `deps-v1.14.0-rc1` bundles with only the version changed; every other file is byte-identical | -| 1.15.0 | unreleased | **Issue #144**: KNLMeansCL moves to Jaded Encoding Thaumaturgy's fork **v1.3** on every platform (Windows: PyPI wheel, replacing Khanattila's 2018 v1.1.1 zip, which refused 10-bit 4:2:0; macOS/Linux: the `v1.3` tag, replacing unpinned master, which gave unrepeatable results on Apple's OpenCL). Both meson patchers learn v1.3's assigned `run_command` include probe | +| 1.15.0 | 2026-11-03 | **Issue #144**: KNLMeansCL moves to Jaded Encoding Thaumaturgy's fork **v1.3** on every platform (Windows: PyPI wheel, replacing Khanattila's 2018 v1.1.1 zip, which refused 10-bit 4:2:0; macOS/Linux: the `v1.3` tag, replacing unpinned master, which gave unrepeatable results on Apple's OpenCL). Both meson patchers learn v1.3's assigned `run_command` include probe. Published as the tested `deps-v1.15.0-rc1` bundles with only the version changed | diff --git a/app/assets/deps-version.json b/app/assets/deps-version.json index 9e75c1e..6537b71 100644 --- a/app/assets/deps-version.json +++ b/app/assets/deps-version.json @@ -1,6 +1,6 @@ { - "version": "1.15.0-rc1", - "releaseTag": "deps-v1.15.0-rc1", + "version": "1.15.0", + "releaseTag": "deps-v1.15.0", "releaseDate": "2026-11-03", "tierFormat": "delta", "githubRepo": "StuartCameronCode/VapourBox" diff --git a/app/macos/Runner/Info.plist b/app/macos/Runner/Info.plist index 0a56c98..b19806b 100644 --- a/app/macos/Runner/Info.plist +++ b/app/macos/Runner/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.5.1 + 1.6.0 CFBundleVersion - 1.5.1 + 1.6.0