diff --git a/CLAUDE.md b/CLAUDE.md index a6cdd19..fa8457b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -884,7 +884,7 @@ integration tests. Matrix: macOS **arm64** (`macos-15`), macOS **x64** All three `download-deps-*` scripts apply these automatically, and they must stay **identical across platforms** — a patch applied on only some platforms makes the -same job produce different output per OS. Six patches: +same job produce different output per OS. Seven patches: 1. **mvtools API**: Renamed `_lambda`→`lambda`, `_global`→`global` parameters 2. **DFTTest API**: `sstring` parameter removed, replaced with `sigma=10.0` @@ -893,6 +893,8 @@ same job produce different output per OS. Six patches: over; fall back to CPU `EEDI3` (NNEDI3CL still uses the GPU) 5. **`Bob()` 16-bit resample** — see below 6. **ARM nnedi3 preference** — see "The ARM interpolator choice" below +7. **akarin Expr routing** — rebinds havsfunc's `core` to a proxy that sends + `.std.Expr` to `akarin.Expr`; see "`std.Expr` on ARM" below Each patch is a **literal string match** against havsfunc r31. If upstream ever changes one of those lines the patch silently does nothing, so behavioural tests @@ -958,9 +960,75 @@ path also needs `yasm`, which the runners don't have). > `ExprInterpreter::eval()`, a scalar switch-dispatch interpreter run **once per > pixel**: 69.5s vs 3.3s of CPU on the same job, **21x**. Most of VS core is > x86-SIMD-only the same way (genericfilters, mergefilters, averageframes, -> planestats). Fixing that needs a vectorized Expr on ARM and is tracked -> separately. Not a cause: mvtools is *faster* natively (it compiles its SSE2 -> paths through simde). +> planestats). Not a cause: mvtools is *faster* natively (it compiles its SSE2 +> paths through simde). **That Expr gap is now closed by akarin** — see below. + +### `std.Expr` on ARM goes through akarin's LLVM JIT + +VapourSynth's `compile_jit()` is x86-only, so on ARM every expression is walked +**once per pixel** by `ExprInterpreter::eval()`. Measured on an M1 under R78, +`Expr` costs **550–640 CPU-seconds** in a QTGMC Slow graph against the +interpolator's **30** — it is not one cost among several, it is the cost. +`akarin.Expr` is a real LLVM JIT that works on aarch64: **QTGMC Slow 11.5s → +2.8s, 4.1x** (3 runs each, 720x576, 120 output frames). + +Three things to keep straight: + +- **The routing is a shim, not 116 edits.** havsfunc has 116 `core.std.Expr` + call sites, so **patch 7** rebinds its module-level `core` to a proxy that + swaps *only* `.std.Expr` and forwards everything else. The proxy installs + **only when `core.akarin` exists**, so where it doesn't, `core` stays the real + core — no wrapper, no overhead, no behaviour change. Both templates get an + `_expr()` helper for their own two call sites each, mirroring `_nnedi3()`; + `test_93` fails the build if either calls `core.std.Expr` directly, and also + if the helper's fallback calls *itself* (a blanket search-and-replace made it + infinitely recursive once — the fallback must name `core.std.Expr`). +- **macOS x64 deliberately does not get it.** The only wheel is + `macosx_14_0_x86_64` and that bundle targets **12.0** (issue #39), so shipping + it would raise the Intel floor to macOS 14 — for a platform that already has + the JIT. It keeps `std.Expr` through the fallback. The namespace requirement in + `vapoursynth_integration_test.dart` is therefore **conditional**, like `nnedi3`. +- **It is not bit-identical, and the one difference is known.** Of the **46** + expressions havsfunc actually generates, 45 match exactly. The exception is the + `DeHalo_alpha`/`FineDehalo` edge-**mask** scale `x {thmi} - {i} / 255 *`, where + one input value lands on an exact `.5` tie: `std.Expr` rounds half-to-even, + akarin rounds down — one level, in a mask. So macOS x64 differs from every + other platform by that one level. That is far smaller than the ARM/x86 + difference already accepted for nnedi3 vs znedi3 (mean 0.045/255, worst pixel + 27/255). + +**Test the corpus, not a sample.** The expressions are mostly f-strings with +computed thresholds, so a static scan of havsfunc finds **11** of the 46. +`app/test/akarin_expr_parity_test.dart` (heavy) collects them *at runtime* across +every QTGMC preset plus daa/santiag/LSFmod/DeHalo_alpha/FineDehalo/SMDegrain/ +Deblock_QED/EdgeCleaner/YAHR, then compares both implementations over inputs +covering all 256 values. It asserts `corpus >= 40` so a broken collector fails +rather than passing vacuously, and bounds the difference at exactly what is +measured today (worst ≤ 1 level, ≤ 1 differing expression). + +The plugin comes from the `vapoursynth-akarin` **PyPI wheel** (a wheel is a zip; +never pip-install it into the embedded interpreter), pinned to a version and +resolved through the PyPI JSON API so the hashed file URL is never hardcoded. +Per-platform placement differs and matters: + +| | plugin | its private libs | +|---|---|---| +| macOS arm64 | `vapoursynth/plugins/` | `lib/`, repointed from `@loader_path/../../../vapoursynth_akarin.dylibs` and **re-signed** | +| Linux x64/arm64 | `vapoursynth/plugins/` | `lib/`, via `patchelf --set-rpath` | +| Windows x64 | `vapoursynth/vs-plugins/` | **beside the plugin** | + +On Unix the private libs go in `lib/` rather than `plugins/`, because `plugins/` +is autoloaded and a non-plugin `.so` there gets probed on every core init. `lib/` +is deliberately **not** on `DYLD_LIBRARY_PATH`, so the bundled libz cannot shadow +the system one for ffmpeg. Linux's zstd carries a **per-arch build hash** in its +filename (`libzstd-5df4f4df…` on x64, `-a1561916…` on arm64), so glob it — and +the ELF `NEEDED` entry uses that exact hashed name. + +akarin is **LGPL-3.0** and statically links **LLVM 22.1.2** (Apache-2.0 with LLVM +exception); both are in `licenses/NOTICES.txt`. It adds ~61 MB uncompressed per +platform, but only about **21 MB to each deps zip** — the earlier "the zips +roughly double" estimate was wrong, because it compared uncompressed size against +compressed zips. ### Linux builds on ubuntu-24.04, and that sets the glibc floor The Linux **deps** builds and the runners that test against them @@ -1473,3 +1541,4 @@ Create the app-specific password at appleid.apple.com → Sign-In and Security | 1.0.0 | 2025-01-15 | Initial release | | … | | (1.1.0–1.6.0 went unrecorded) | | 1.7.0 | 2026-08-01 | Fixes QTGMC Placebo/Very Slow brightening and near-black Draft on arm64, via `Scripts/patches/fmtconv-r31-arm-int-scaler.patch` (root cause: sign constants in fmtconv's non-SIMD integer scaler) plus havsfunc patch 5 as defence in depth; fmtconv r30 → **r31**, now pinned and sourced from GitLab on every platform. **Rebuilt 2026-08-02** to add the **zsmooth** plugin (MIT), providing `core.zsmooth.CCD` plus `Cnr4` and a set of RemoveGrain/TemporalMedian-family filters. Version pinned to 0.19.0 in all three download scripts — keep them in step so the same job can't produce different chroma per OS. Taken pre-built everywhere except macOS x64, which builds it with Zig to reach `minos 12.0` (see the macOS platform notes) | +| 1.8.0 | 2026-08-07 | VapourSynth **R73 → R78** on every platform, which moves Windows to a Python 3.12 wheel layout and makes `deps//vapoursynth/` the Python package itself on macOS/Linux (see the R78 sections). Adds the **akarin** plugin (LGPL-3.0, statically links LLVM 22.1.2) supplying an LLVM JIT for `std.Expr`, routed in via havsfunc **patch 7** and the templates' `_expr()` helper — worth **4.1x** on arm64 QTGMC Slow, since VapourSynth's own Expr JIT is x86-only. **Not** shipped on macos-x64, whose only wheel would raise the Intel floor to macOS 14 (issue #39). Fixes the **nnedi3** build on linux-arm64, which had never produced a binary (`-mfpu=neon` and `HWCAP_ARM_*` are both 32-bit-ARM-only), and drops the plugin from linux-x64's expected list to match the other x86 bundles. **BestSource removed** — nothing had called it since the pipe source replaced it. Linux now needs **glibc 2.39** (ubuntu-24.04), so Ubuntu 22.04 and Debian 12 can no longer run it | diff --git a/Scripts/deps-expected-plugins.json b/Scripts/deps-expected-plugins.json index 60e5248..03ec5f6 100644 --- a/Scripts/deps-expected-plugins.json +++ b/Scripts/deps-expected-plugins.json @@ -23,7 +23,9 @@ "libmvtools.dll", "libtemporalmedian.dll", "neo-f3kdb.dll", - "vsznedi3.dll" + "vsznedi3.dll", + "libakarin.dll", + "libzstd.dll" ], "macos-arm64": [ "libaddgrain.dylib", @@ -50,7 +52,8 @@ "libttempsmooth.dylib", "libvivtc.dylib", "libznedi3.dylib", - "libzsmooth.dylib" + "libzsmooth.dylib", + "libakarin.dylib" ], "macos-x64": [ "libaddgrain.dylib", @@ -100,7 +103,8 @@ "libttempsmooth.so", "libvivtc.so", "libznedi3.so", - "libzsmooth.so" + "libzsmooth.so", + "libakarin.so" ], "linux-arm64": [ "libaddgrain.so", @@ -126,6 +130,7 @@ "libttempsmooth.so", "libvivtc.so", "libznedi3.so", - "libzsmooth.so" + "libzsmooth.so", + "libakarin.so" ] } diff --git a/Scripts/download-deps-linux.sh b/Scripts/download-deps-linux.sh index 6cec765..d08f94d 100755 --- a/Scripts/download-deps-linux.sh +++ b/Scripts/download-deps-linux.sh @@ -977,6 +977,69 @@ else echo " DeScratch already exists, skipping" fi +# ============================================================================ +# akarin — LLVM JIT for std.Expr +# ============================================================================ +# VapourSynth's own Expr JIT is wrapped in #ifdef VS_TARGET_CPU_X86, so on ARM +# every expression is walked once per pixel by a scalar interpreter. akarin has +# a real LLVM JIT: measured 4.1x end to end on QTGMC Slow (arm64), and +# bit-identical to std.Expr on 45 of the 46 expressions havsfunc generates. +# +# Shipped on both Linux arches. x86 gains little (it already has a JIT) but the +# wheel exists, the routing shim is arch-neutral, and keeping the two arches +# identical avoids the same job producing different output per platform. +# manylinux_2_35 is satisfied by our glibc 2.39 floor (ubuntu-24.04). +AKARIN_VERSION="1.4.1" +echo "" +echo "=== Downloading akarin $AKARIN_VERSION (LLVM JIT for std.Expr) ===" +if [ "$FORCE" = true ] || [ ! -f "$PLUGINS_DIR/libakarin.so" ]; then + case "$ARCH" in + x86_64) AK_TAG="manylinux_2_35_x86_64" ;; + aarch64) AK_TAG="manylinux_2_35_aarch64" ;; + *) AK_TAG="" ;; + esac + if [ -z "$AK_TAG" ]; then + echo " No akarin wheel for $ARCH - skipping (std.Expr fallback applies)." + else + AK_URL=$("$PYTHON_BIN" - "$AKARIN_VERSION" "$AK_TAG" <<'PYEOF' +import json, sys, urllib.request +ver, tag = sys.argv[1], sys.argv[2] +d = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/vapoursynth-akarin/{ver}/json")) +print(next(f["url"] for f in d["urls"] if f["filename"].endswith(tag + ".whl"))) +PYEOF +) + rm -rf "$BUILD_DIR/akarin" && mkdir -p "$BUILD_DIR/akarin" "$LIB_DIR" + curl -L -o "$BUILD_DIR/akarin.whl" "$AK_URL" + # A wheel is a zip; take the plugin binary only, never pip install into + # the embedded interpreter. + unzip -q "$BUILD_DIR/akarin.whl" -d "$BUILD_DIR/akarin" + cp "$BUILD_DIR/akarin/vapoursynth/plugins/akarin/libakarin.so" "$PLUGINS_DIR/" + # The private zstd carries a build hash in its filename that differs per + # arch (libzstd-5df4f4df... on x64, -a1561916... on arm64), so glob it. + # It goes in lib/, not plugins/, because plugins/ is autoloaded and a + # non-plugin .so there would be probed on every core init. + cp "$BUILD_DIR/akarin"/vapoursynth_akarin.libs/*.so* "$LIB_DIR/" 2>/dev/null || true + chmod u+w "$PLUGINS_DIR/libakarin.so" + patchelf --set-rpath '$ORIGIN:$ORIGIN/../../lib' "$PLUGINS_DIR/libakarin.so" 2>/dev/null || true + + # The wheel's RPATH points at vapoursynth_akarin.libs, which does not + # exist in our layout; if a NEEDED entry is still unresolvable the + # plugin loads here and dies in the packaged bundle, so check now. + if command -v ldd >/dev/null 2>&1; then + if ldd "$PLUGINS_DIR/libakarin.so" 2>/dev/null | grep -q "not found"; then + echo " ERROR: libakarin.so has unresolved dependencies after relinking:" + ldd "$PLUGINS_DIR/libakarin.so" | grep "not found" | sed 's/^/ /' + exit 1 + fi + fi + rm -rf "$BUILD_DIR/akarin" "$BUILD_DIR/akarin.whl" + BUILT_PLUGINS+=("akarin") + echo " Installed akarin -> libakarin.so" + fi +else + echo " akarin already exists, skipping" +fi + # ============================================================================ # Download NNEDI3 weights # ============================================================================ @@ -1169,6 +1232,55 @@ def _nnedi3_impl(): ''') patches.append(f'ARM nnedi3 preference ({n_edi} sites)') +# Patch 7: route std.Expr through akarin's LLVM JIT. +# VapourSynth's Expr JIT is wrapped in #ifdef VS_TARGET_CPU_X86, so on ARM the +# whole bytecode program is walked once per pixel by ExprInterpreter::eval(). +# Expr dominates arm64 QTGMC by a wide margin -- measured 550-640 CPU-seconds +# against the interpolator's 30 -- so this is the single biggest arm64 win +# available: 4.1x end to end on QTGMC Slow (11.5s -> 2.8s, M1, 720x576). +# +# havsfunc has 116 core.std.Expr call sites. Rewriting each one is unmaintainable +# against a file we already patch six other ways, so rebind the module's `core` +# to a proxy that swaps only .std.Expr and forwards everything else untouched. +# +# Verified against the 46 distinct expressions havsfunc actually generates +# (collected at runtime across every QTGMC preset plus daa/santiag/LSFmod/ +# DeHalo_alpha/FineDehalo/SMDegrain/Deblock_QED/EdgeCleaner/YAHR): 45 are +# bit-identical. The one exception is the DeHalo_alpha/FineDehalo edge-MASK +# scale 'x {thmi} - {i} / 255 *', where a single input value lands on an exact +# .5 tie -- std.Expr rounds half-to-even, akarin rounds it down, so one level in +# a mask. macOS x64 has no akarin wheel compatible with our 12.0 floor and so +# keeps std.Expr; that platform therefore differs by that one level. It is far +# smaller than the ARM/x86 difference already accepted for nnedi3 vs znedi3 +# (mean 0.045/255, worst pixel 27/255). +# +# The shim installs only when akarin is present, so where it is absent `core` +# stays the real core: no wrapper, no overhead, no behaviour change. +if '_akarin_expr' not in content: + content = content.replace('import math\n', 'import math\n' + ''' + +# Route std.Expr through akarin's LLVM JIT where present (see download-deps-*). +_akarin_expr = getattr(getattr(core, 'akarin', None), 'Expr', None) +if _akarin_expr is not None: + class _ExprStd: + __slots__ = ('_std',) + def __init__(self, std): + self._std = std + def __getattr__(self, name): + return _akarin_expr if name == 'Expr' else getattr(self._std, name) + + class _ExprCore: + __slots__ = ('_core', '_std') + def __init__(self, c): + self._core = c + self._std = _ExprStd(c.std) + def __getattr__(self, name): + return self._std if name == 'std' else getattr(self._core, name) + + core = _ExprCore(core) +''') + patches.append('akarin Expr routing') + if patches: with open(havsfunc_path, 'w') as f: f.write(content) diff --git a/Scripts/download-deps-macos.sh b/Scripts/download-deps-macos.sh index 17aa695..48f6e0e 100755 --- a/Scripts/download-deps-macos.sh +++ b/Scripts/download-deps-macos.sh @@ -1409,6 +1409,71 @@ else fi fi +# ============================================================================ +# akarin — LLVM JIT for std.Expr (arm64 only) +# ============================================================================ +# VapourSynth's own Expr JIT is wrapped in #ifdef VS_TARGET_CPU_X86, so on ARM +# every expression is walked once per pixel by a scalar interpreter. akarin has +# a real LLVM JIT that works on aarch64: measured 4.1x end to end on QTGMC Slow +# (11.5s -> 2.8s, 720x576, M1) and bit-identical to std.Expr on 45 of the 46 +# expressions havsfunc actually generates. +# +# arm64 only, deliberately. The x64 wheel is macosx_14_0 while this bundle +# targets $MACOS_MIN_VERSION (issue #39), so shipping it would raise the Intel +# floor to macOS 14 — and x86 already has the JIT, so it loses nothing. The +# routing shim falls back to std.Expr wherever core.akarin is absent. +AKARIN_VERSION="1.4.1" +echo "" +echo "=== Downloading akarin $AKARIN_VERSION (LLVM JIT for std.Expr) ===" +if [ "$ARCH" = "x86_64" ]; then + echo " Skipped on x64: wheel is macosx_14_0, this bundle targets $MACOS_MIN_VERSION." +elif [ "$FORCE" = true ] || [ ! -f "$PLUGINS_DIR/libakarin.dylib" ]; then + AK_URL=$("$PYTHON_BIN" - "$AKARIN_VERSION" "macosx_14_0_arm64" <<'PYEOF' +import json, sys, urllib.request +ver, tag = sys.argv[1], sys.argv[2] +d = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/vapoursynth-akarin/{ver}/json")) +print(next(f["url"] for f in d["urls"] if f["filename"].endswith(tag + ".whl"))) +PYEOF +) + rm -rf "$BUILD_DIR/akarin" && mkdir -p "$BUILD_DIR/akarin" "$LIB_DIR" + curl -L -o "$BUILD_DIR/akarin.whl" "$AK_URL" + # A wheel is a zip; we want the plugin binary only, never a pip install into + # the embedded interpreter. + unzip -q "$BUILD_DIR/akarin.whl" -d "$BUILD_DIR/akarin" + cp "$BUILD_DIR/akarin/vapoursynth/plugins/akarin/libakarin.dylib" "$PLUGINS_DIR/" + cp "$BUILD_DIR/akarin"/vapoursynth_akarin.dylibs/*.dylib "$LIB_DIR/" + chmod u+w "$PLUGINS_DIR/libakarin.dylib" + + # The wheel links its private libs as @loader_path/../../../vapoursynth_akarin.dylibs, + # which is relative to the wheel's own layout and resolves to nothing here. + # Repoint to lib/, matching the nnedi3cl -> boost convention. lib/ is + # deliberately NOT on DYLD_LIBRARY_PATH, so the bundled libz cannot shadow + # the system one for ffmpeg or anything else. + install_name_tool -id "@loader_path/libakarin.dylib" "$PLUGINS_DIR/libakarin.dylib" + for dep in $(otool -L "$PLUGINS_DIR/libakarin.dylib" | awk '/vapoursynth_akarin\.dylibs/{print $1}'); do + install_name_tool -change "$dep" "@loader_path/../../lib/$(basename "$dep")" \ + "$PLUGINS_DIR/libakarin.dylib" + done + for f in "$BUILD_DIR/akarin"/vapoursynth_akarin.dylibs/*.dylib; do + b=$(basename "$f") + chmod u+w "$LIB_DIR/$b" + install_name_tool -id "@loader_path/$b" "$LIB_DIR/$b" 2>/dev/null || true + codesign -s - -f "$LIB_DIR/$b" 2>/dev/null || true + done + # Re-sign after install_name_tool or macOS SIGKILLs the loader (exit 137). + codesign -s - -f "$PLUGINS_DIR/libakarin.dylib" 2>/dev/null || true + + if otool -L "$PLUGINS_DIR/libakarin.dylib" | grep -q "vapoursynth_akarin.dylibs"; then + echo " ERROR: libakarin.dylib still references the wheel-relative dylib path." + echo " It would load here and fail in the packaged bundle." + exit 1 + fi + rm -rf "$BUILD_DIR/akarin" "$BUILD_DIR/akarin.whl" + echo " Installed akarin -> libakarin.dylib" +else + echo " akarin already exists, skipping" +fi + # ============================================================================ # Download NNEDI3 weights # ============================================================================ @@ -1608,6 +1673,55 @@ def _nnedi3_impl(): ''') patches.append(f'ARM nnedi3 preference ({n_edi} sites)') +# Patch 7: route std.Expr through akarin's LLVM JIT. +# VapourSynth's Expr JIT is wrapped in #ifdef VS_TARGET_CPU_X86, so on ARM the +# whole bytecode program is walked once per pixel by ExprInterpreter::eval(). +# Expr dominates arm64 QTGMC by a wide margin -- measured 550-640 CPU-seconds +# against the interpolator's 30 -- so this is the single biggest arm64 win +# available: 4.1x end to end on QTGMC Slow (11.5s -> 2.8s, M1, 720x576). +# +# havsfunc has 116 core.std.Expr call sites. Rewriting each one is unmaintainable +# against a file we already patch six other ways, so rebind the module's `core` +# to a proxy that swaps only .std.Expr and forwards everything else untouched. +# +# Verified against the 46 distinct expressions havsfunc actually generates +# (collected at runtime across every QTGMC preset plus daa/santiag/LSFmod/ +# DeHalo_alpha/FineDehalo/SMDegrain/Deblock_QED/EdgeCleaner/YAHR): 45 are +# bit-identical. The one exception is the DeHalo_alpha/FineDehalo edge-MASK +# scale 'x {thmi} - {i} / 255 *', where a single input value lands on an exact +# .5 tie -- std.Expr rounds half-to-even, akarin rounds it down, so one level in +# a mask. macOS x64 has no akarin wheel compatible with our 12.0 floor and so +# keeps std.Expr; that platform therefore differs by that one level. It is far +# smaller than the ARM/x86 difference already accepted for nnedi3 vs znedi3 +# (mean 0.045/255, worst pixel 27/255). +# +# The shim installs only when akarin is present, so where it is absent `core` +# stays the real core: no wrapper, no overhead, no behaviour change. +if '_akarin_expr' not in content: + content = content.replace('import math\n', 'import math\n' + ''' + +# Route std.Expr through akarin's LLVM JIT where present (see download-deps-*). +_akarin_expr = getattr(getattr(core, 'akarin', None), 'Expr', None) +if _akarin_expr is not None: + class _ExprStd: + __slots__ = ('_std',) + def __init__(self, std): + self._std = std + def __getattr__(self, name): + return _akarin_expr if name == 'Expr' else getattr(self._std, name) + + class _ExprCore: + __slots__ = ('_core', '_std') + def __init__(self, c): + self._core = c + self._std = _ExprStd(c.std) + def __getattr__(self, name): + return self._std if name == 'std' else getattr(self._core, name) + + core = _ExprCore(core) +''') + patches.append('akarin Expr routing') + if patches: with open(havsfunc_path, 'w') as f: f.write(content) diff --git a/Scripts/download-deps-windows.ps1 b/Scripts/download-deps-windows.ps1 index fd44412..2374dfd 100644 --- a/Scripts/download-deps-windows.ps1 +++ b/Scripts/download-deps-windows.ps1 @@ -509,6 +509,58 @@ if (-not (Test-Path $DvdReadPath)) { Write-Host " libdvdread already installed" -ForegroundColor Gray } +# ============================================================================= +# akarin - LLVM JIT for std.Expr +# ============================================================================= +# VapourSynth's own Expr JIT is x86-only (#ifdef VS_TARGET_CPU_X86), so ARM runs +# a scalar per-pixel interpreter. Windows x64 already has that JIT, so it gains +# far less than arm64 does -- but the routing shim is arch-neutral and shipping +# akarin everywhere it exists keeps the same job from producing different output +# per OS, which is the rule the havsfunc patches already follow. +# +# akarin is bit-identical to std.Expr on 45 of the 46 expressions havsfunc +# generates; the one exception rounds a single .5 tie down instead of to even. +$AkarinVersion = "1.4.1" +Write-Host "" +Write-Host "Downloading akarin $AkarinVersion (LLVM JIT for std.Expr)..." -ForegroundColor Yellow +if (-not (Test-Path "$PluginsDir\libakarin.dll")) { + try { + $AkMeta = Invoke-RestMethod -Uri "https://pypi.org/pypi/vapoursynth-akarin/$AkarinVersion/json" + $AkUrl = ($AkMeta.urls | Where-Object { $_.filename -like "*win_amd64.whl" } | + Select-Object -First 1).url + if (-not $AkUrl) { throw "no win_amd64 wheel for akarin $AkarinVersion" } + + $AkWhl = Join-Path $TempDir "akarin.whl" + $AkZip = Join-Path $TempDir "akarin-wheel.zip" + $AkOut = Join-Path $TempDir "akarin-extract" + Invoke-WebRequest -Uri $AkUrl -OutFile $AkWhl -UseBasicParsing + # A wheel is a zip, but Expand-Archive validates the *extension* and + # refuses .whl outright -- same trap as the VapourSynth wheel above. + Copy-Item $AkWhl $AkZip -Force + Remove-Item $AkOut -Recurse -Force -ErrorAction SilentlyContinue + Expand-Archive -Path $AkZip -DestinationPath $AkOut -Force + + # libzstd.dll sits beside the plugin, which is how the wheel ships it and + # how Windows resolves a plugin's own dependencies. VapourSynth will probe + # it during autoload and skip it (no VapourSynthPluginInit2); that is + # noise in the log, not a failure. + $AkSrc = Join-Path $AkOut "vapoursynth\plugins\akarin" + foreach ($dll in @("libakarin.dll", "libzstd.dll")) { + $p = Join-Path $AkSrc $dll + if (-not (Test-Path $p)) { throw "$dll missing from the akarin wheel" } + Copy-Item $p (Join-Path $PluginsDir $dll) -Force + Write-Host " Copied: $dll" -ForegroundColor Gray + } + Remove-Item $AkWhl, $AkZip -Force -ErrorAction SilentlyContinue + Remove-Item $AkOut -Recurse -Force -ErrorAction SilentlyContinue + Write-Host " akarin installed" -ForegroundColor Green + } catch { + Write-Host " Failed: $_" -ForegroundColor Red + } +} else { + Write-Host " akarin already installed" -ForegroundColor Gray +} + # ============================================================================= # 5. Python Packages (havsfunc, mvsfunc, adjust) # ============================================================================= @@ -721,6 +773,44 @@ def _nnedi3_impl(): $PatchesApplied += "ARM nnedi3 preference" } + # Patch 7: route std.Expr through akarin's LLVM JIT. + # VapourSynth's own Expr JIT is x86-only, so ARM walks the whole bytecode + # program once per pixel. Windows x64 already has that JIT and gains far + # less, but this is applied here so every platform generates identical + # output from an identical havsfunc - the same reasoning as patches 5 and 6. + # havsfunc has 116 core.std.Expr sites, so rebind the module's `core` to a + # proxy that swaps only .std.Expr and forwards everything else untouched. + # The shim installs only when akarin is present; where it is absent `core` + # stays the real core, with no wrapper and no behaviour change. + if ($Content -notmatch "_akarin_expr") { + Write-Host " Applying akarin Expr routing patch..." -ForegroundColor Gray + $PatchFunction = @" + +# Route std.Expr through akarin's LLVM JIT where present (see download-deps-*). +_akarin_expr = getattr(getattr(core, 'akarin', None), 'Expr', None) +if _akarin_expr is not None: + class _ExprStd: + __slots__ = ('_std',) + def __init__(self, std): + self._std = std + def __getattr__(self, name): + return _akarin_expr if name == 'Expr' else getattr(self._std, name) + + class _ExprCore: + __slots__ = ('_core', '_std') + def __init__(self, c): + self._core = c + self._std = _ExprStd(c.std) + def __getattr__(self, name): + return self._std if name == 'std' else getattr(self._core, name) + + core = _ExprCore(core) + +"@ + $Content = $Content -replace "(import math\r?\n)", "`$1$PatchFunction" + $PatchesApplied += "akarin Expr routing" + } + if ($PatchesApplied.Count -gt 0) { Set-Content $HavsfuncPath $Content -NoNewline Write-Host " havsfunc patched ($($PatchesApplied -join ', '))" -ForegroundColor Green diff --git a/app/assets/deps-version.json b/app/assets/deps-version.json index 638c627..b2a6975 100644 --- a/app/assets/deps-version.json +++ b/app/assets/deps-version.json @@ -1,6 +1,6 @@ { - "version": "1.8.0-rc1", - "releaseTag": "deps-v1.8.0-rc1", + "version": "1.8.0-rc2", + "releaseTag": "deps-v1.8.0-rc2", "releaseDate": "2026-08-07", "githubRepo": "StuartCameronCode/VapourBox" } diff --git a/app/test/akarin_expr_parity_test.dart b/app/test/akarin_expr_parity_test.dart new file mode 100644 index 0000000..5ca5725 --- /dev/null +++ b/app/test/akarin_expr_parity_test.dart @@ -0,0 +1,245 @@ +// akarin.Expr must be a drop-in replacement for std.Expr. +// +// VapourSynth's own Expr JIT is wrapped in `#ifdef VS_TARGET_CPU_X86`, so on ARM +// the whole bytecode program is walked once per pixel by ExprInterpreter::eval(). +// Measured on an M1, Expr costs 550-640 CPU-seconds in a QTGMC Slow graph against +// the interpolator's 30 — it dominates everything else, which is why havsfunc's +// patch 7 and the templates' `_expr()` helper route through akarin's LLVM JIT. +// +// That routing is only safe while the two agree pixel-for-pixel, and "it ran" is +// not evidence of that: a wrong Expr still produces a picture. So this test +// collects the expressions havsfunc *actually generates* at runtime — across +// every QTGMC preset and a spread of other entry points — and compares both +// implementations over inputs covering the full 0-255 range. +// +// Collecting at runtime rather than checking in a fixed list matters: the corpus +// is mostly f-strings with computed thresholds, so a static scan finds only 11 of +// the 46 real expressions. +// +// Skipped on macos-x64, which deliberately ships no akarin (its only wheel is +// macosx_14_0 and that bundle targets 12.0, issue #39). +@Tags(['heavy']) +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; + +import 'support/worker_harness.dart'; + +/// Collects every distinct Expr call havsfunc makes, then runs each one through +/// both implementations and reports the worst per-expression difference. +const String _parityScript = r''' +import json, sys +import vapoursynth as vs +core = vs.core + +if not hasattr(core, 'akarin'): + print("AKARIN_ABSENT", file=sys.stderr) + core.std.BlankClip(width=16, height=16, length=1).set_output() + raise SystemExit(0) + +import havsfunc as haf + +# --- 1. collect the real corpus ------------------------------------------- +seen = [] + + +class _Rec: + def __init__(self, real): + self._r = real + + def __getattr__(self, n): + if n != 'Expr': + return getattr(self._r, n) + + def rec(clips, expr, *a, **k): + cl = clips[0] if isinstance(clips, (list, tuple)) else clips + seen.append({ + 'expr': list(expr) if isinstance(expr, (list, tuple)) else [expr], + 'fmt': cl.format.name, + 'n': len(clips) if isinstance(clips, (list, tuple)) else 1, + }) + return self._r.Expr(clips, expr, *a, **k) + + return rec + + +class _RecCore: + def __init__(self, c): + self._c = c + + def __getattr__(self, n): + return _Rec(self._c.std) if n == 'std' else getattr(self._c, n) + + +# havsfunc may already be wrapped by patch 7; record against the real std either way. +_real = getattr(haf.core, '_core', haf.core) +haf.core = _RecCore(_real) + +src = core.std.SetFieldBased( + core.std.BlankClip(width=720, height=576, format=vs.YUV420P8, length=4, + color=[110, 120, 140]), 2) + +for preset in ['Placebo', 'Very Slow', 'Slower', 'Slow', 'Medium', 'Fast', + 'Faster', 'Very Fast', 'Super Fast', 'Ultra Fast', 'Draft']: + try: + haf.QTGMC(src, Preset=preset, TFF=True) + except Exception: + pass +for kw in [dict(SourceMatch=3), dict(NoiseProcess=1), dict(NoiseProcess=2), + dict(NoiseProcess=2, NoiseRestore=0.7), dict(Sharpness=1.0, SMode=2), + dict(Lossless=2)]: + try: + haf.QTGMC(src, Preset='Slow', TFF=True, **kw) + except Exception: + pass +for fn in ['daa', 'santiag', 'LSFmod', 'DeHalo_alpha', 'FineDehalo', 'SMDegrain', + 'Deblock_QED', 'EdgeCleaner', 'YAHR']: + try: + getattr(haf, fn)(src) + except Exception: + pass + +uniq = {} +for s in seen: + uniq[(tuple(s['expr']), s['fmt'], s['n'])] = s +corpus = list(uniq.values()) + +# --- 2. compare both implementations -------------------------------------- +FMT = {'YUV420P8': vs.YUV420P8, 'Gray8': vs.GRAY8} + + +def grid(fmt, seed): + # 16x16 tiles of 16x16 px, so every luma value 0..255 is present. + rows = [] + for r in range(16): + cells = [] + for c in range(16): + v = (r * 16 + c + seed * 37) % 256 + col = [v] if fmt == vs.GRAY8 else [v, (v * 5 + seed * 11) % 256, + (v * 3 + seed * 29) % 256] + cells.append(core.std.BlankClip(width=16, height=16, format=fmt, + length=1, color=col)) + rows.append(core.std.StackHorizontal(cells)) + return core.std.StackVertical(rows) + + +cache, worst, differing, errors = {}, 0.0, [], [] +for item in corpus: + f = FMT.get(item['fmt']) + if f is None: + continue + key = (item['fmt'], item['n']) + if key not in cache: + cache[key] = [grid(f, s) for s in range(item['n'])] + clips = cache[key] + arg = clips if len(clips) > 1 else clips[0] + try: + a = core.std.Expr(arg, item['expr']) + b = core.akarin.Expr(arg, item['expr']) + d = core.std.Expr([a, b], ['x y - abs'] * a.format.num_planes) + mx = 0.0 + for p in range(a.format.num_planes): + fr = core.std.PlaneStats(d, plane=p).get_frame(0) + mx = max(mx, float(fr.props['PlaneStatsMax'])) + worst = max(worst, mx) + if mx > 0: + differing.append({'max': mx, 'expr': item['expr']}) + except Exception as e: + errors.append({'err': f"{type(e).__name__}: {e}", 'expr': item['expr']}) + +print("PARITY_JSON:" + json.dumps({ + 'corpus': len(corpus), + 'worst': worst, + 'differing': differing, + 'errors': errors, +}), file=sys.stderr) + +core.std.BlankClip(width=16, height=16, length=1).set_output() +'''; + +void main() { + group('akarin.Expr parity with std.Expr', () { + late String vspipePath; + late String depsDir; + + setUpAll(() async { + await WorkerHarness.ensureReady(); + depsDir = WorkerHarness.depsDir; + vspipePath = path.join( + depsDir, + 'vapoursynth', + Platform.isWindows + ? path.join('Lib', 'site-packages', 'vapoursynth', 'vspipe.exe') + : 'vspipe', + ); + }); + + test('matches over every expression havsfunc generates', () async { + final scriptFile = File( + path.join(Directory.systemTemp.path, 'akarin_parity.vpy'), + ); + await scriptFile.writeAsString(_parityScript); + addTearDown(() async { + if (await scriptFile.exists()) await scriptFile.delete(); + }); + + final env = Platform.isWindows + ? { + 'PYTHONHOME': path.join(depsDir, 'vapoursynth'), + 'PYTHONPATH': + path.join(depsDir, 'vapoursynth', 'Lib', 'site-packages'), + 'VAPOURSYNTH_EXTRA_PLUGIN_PATH': + path.join(depsDir, 'vapoursynth', 'vs-plugins'), + } + : {'PYTHONPATH': path.join(depsDir, 'python-packages')}; + + final result = await Process.run( + vspipePath, + ['-o', '0', scriptFile.path, Platform.isWindows ? 'NUL' : '/dev/null'], + environment: env, + ); + final stderr = result.stderr.toString(); + + if (stderr.contains('AKARIN_ABSENT')) { + // macos-x64 ships no akarin by design; std.Expr is used there unchanged. + markTestSkipped('akarin not in this bundle (expected on macos-x64)'); + return; + } + + expect(result.exitCode, 0, reason: 'vspipe failed:\n$stderr'); + final line = LineSplitter.split(stderr) + .firstWhere((l) => l.startsWith('PARITY_JSON:'), orElse: () => ''); + expect(line, isNotEmpty, reason: 'no parity result produced:\n$stderr'); + + final r = + jsonDecode(line.substring('PARITY_JSON:'.length)) as Map; + final corpus = r['corpus'] as int; + final worst = (r['worst'] as num).toDouble(); + final differing = r['differing'] as List; + final errors = r['errors'] as List; + + // A regex over havsfunc finds only 11 expressions; the rest are f-strings + // built at call time. If this collapses, the test has stopped covering the + // corpus and would pass vacuously. + expect(corpus, greaterThanOrEqualTo(40), + reason: 'collected only $corpus expressions — the collector broke'); + + expect(errors, isEmpty, + reason: 'akarin rejected expressions std.Expr accepts: $errors'); + + // One known difference: the DeHalo_alpha/FineDehalo edge-MASK scale + // 'x {thmi} - {i} / 255 *' puts a single input value on an exact .5 tie, + // where std.Expr rounds half-to-even and akarin rounds down. That is one + // level in a mask. Anything larger is a real behavioural divergence, and + // anything *additional* means akarin changed its rounding somewhere else. + expect(worst, lessThanOrEqualTo(1.0), + reason: 'akarin differs from std.Expr by $worst levels: $differing'); + expect(differing.length, lessThanOrEqualTo(1), + reason: 'more expressions differ than the one known .5 tie: $differing'); + }, timeout: const Timeout(Duration(minutes: 10))); + }); +} diff --git a/app/test/vapoursynth_integration_test.dart b/app/test/vapoursynth_integration_test.dart index 3370c25..ee58984 100644 --- a/app/test/vapoursynth_integration_test.dart +++ b/app/test/vapoursynth_integration_test.dart @@ -134,6 +134,20 @@ required = ['std', 'resize', 'mv', 'znedi3', 'eedi3m', 'fmtc', import platform if platform.machine().lower() in ('arm64', 'aarch64'): required.append('nnedi3') + +# `akarin` supplies the LLVM JIT for std.Expr. VapourSynth's own Expr JIT is +# x86-only (#ifdef VS_TARGET_CPU_X86), so on ARM every expression is otherwise +# walked once per pixel — measured 550-640 CPU-seconds against the interpolator's +# 30 in a QTGMC Slow graph, i.e. the dominant arm64 cost. havsfunc's patch 7 and +# the templates' _expr() helper both fall back to std.Expr when it is absent, so +# a bundle missing it still renders correctly and just runs several times slower: +# the same silent class of regression as nnedi3 above. +# macos-x64 is the deliberate exception — the only wheel is macosx_14_0 and that +# bundle targets 12.0 (issue #39), so requiring it there would fail CI for a +# platform that intentionally does not ship it (and already has an x86 JIT). +import sys +if not (sys.platform == 'darwin' and platform.machine().lower() in ('x86_64', 'amd64')): + required.append('akarin') # nnedi3cl and knlm are deliberately NOT required: both are OpenCL and the app # degrades to a CPU path when the driver is absent. diff --git a/licenses/Apache-2.0-LLVM-Exception.txt b/licenses/Apache-2.0-LLVM-Exception.txt new file mode 100644 index 0000000..fa6ac54 --- /dev/null +++ b/licenses/Apache-2.0-LLVM-Exception.txt @@ -0,0 +1,279 @@ +============================================================================== +The LLVM Project is under the Apache License v2.0 with LLVM Exceptions: +============================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +---- LLVM Exceptions to the Apache 2.0 License ---- + +As an exception, if, as a result of your compiling your source code, portions +of this Software are embedded into an Object form of such source code, you +may redistribute such embedded portions in such Object form without complying +with the conditions of Sections 4(a), 4(b) and 4(d) of the License. + +In addition, if you combine or link compiled forms of this Software with +software that is licensed under the GPLv2 ("Combined Software") and if a +court of competent jurisdiction determines that the patent provision (Section +3), the indemnity provision (Section 9) or other Section of the License +conflicts with the conditions of the GPLv2, you may retroactively and +prospectively choose to deem waived or otherwise exclude such Section(s) of +the License, but only in their entirety and only with respect to the Combined +Software. + +============================================================================== +Software from third parties included in the LLVM Project: +============================================================================== +The LLVM Project contains third party software which is under different license +terms. All such code will be identified clearly using at least one of two +mechanisms: +1) It will be in a separate directory tree with its own `LICENSE.txt` or + `LICENSE` file at the top containing the specific license and restrictions + which apply to that software, or +2) It will contain specific license and restriction terms at the top of every + file. + +============================================================================== +Legacy LLVM License (https://llvm.org/docs/DeveloperPolicy.html#legacy): +============================================================================== +University of Illinois/NCSA +Open Source License + +Copyright (c) 2003-2019 University of Illinois at Urbana-Champaign. +All rights reserved. + +Developed by: + + LLVM Team + + University of Illinois at Urbana-Champaign + + http://llvm.org + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal with +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimers. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimers in the + documentation and/or other materials provided with the distribution. + + * Neither the names of the LLVM Team, University of Illinois at + Urbana-Champaign, nor the names of its contributors may be used to + endorse or promote products derived from this Software without specific + prior written permission. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE +SOFTWARE. + diff --git a/licenses/LGPL-3.0.txt b/licenses/LGPL-3.0.txt new file mode 100644 index 0000000..0a04128 --- /dev/null +++ b/licenses/LGPL-3.0.txt @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/licenses/NOTICES.txt b/licenses/NOTICES.txt index 8b4dadb..ad72920 100644 --- a/licenses/NOTICES.txt +++ b/licenses/NOTICES.txt @@ -132,6 +132,27 @@ Denoise pass. CCD itself originates as a VirtualDub filter by Sergey Stolyarevsky. +akarin (LGPL-3.0) +----------------- +Copyright (c) the akarin plugin authors +https://github.com/Jaded-Encoding-Thaumaturgy/akarin-vapoursynth-plugin +License: See LGPL-3.0.txt +Provides core.akarin.Expr, an LLVM-JIT implementation of std.Expr. VapourSynth's +own Expr JIT is x86-only, so on ARM every expression is otherwise interpreted +once per pixel; akarin supplies the JIT those platforms lack. Distributed as an +unmodified binary taken from the project's published wheel, and dynamically +linked -- the LGPL relinking right is satisfied by replacing that binary in +deps//vapoursynth/plugins/. Not shipped on macOS x64. + +LLVM (Apache-2.0 WITH LLVM-exception) +------------------------------------- +Copyright (c) the LLVM Project contributors +https://llvm.org +License: See Apache-2.0-LLVM-Exception.txt +LLVM 22.1.2 is statically linked into the akarin plugin above, which is where +its JIT comes from. Included here because that binary carries LLVM code. + + Flutter (BSD 3-Clause) ---------------------- Copyright (c) 2014-2024 The Flutter Authors diff --git a/worker/templates/pipeline_template.vpy b/worker/templates/pipeline_template.vpy index 2920c02..0c0ab4a 100644 --- a/worker/templates/pipeline_template.vpy +++ b/worker/templates/pipeline_template.vpy @@ -66,6 +66,23 @@ def _nnedi3(clip, **kwargs): return core.nnedi3.nnedi3(clip, **kwargs) return core.znedi3.nnedi3(clip, **kwargs) + +# VapourSynth's std.Expr JIT is wrapped in #ifdef VS_TARGET_CPU_X86, so on ARM +# every expression is walked once per pixel by a scalar interpreter -- by far the +# dominant cost in an arm64 filter graph. akarin has a real LLVM JIT that works +# on aarch64 and is bit-identical to std.Expr for every expression used here. +# Falls back to std.Expr wherever akarin is absent (notably macos-x64, whose +# only wheel would raise the Intel floor to macOS 14). havsfunc's own 116 call +# sites get the same treatment via its patch 7. Route every expression through +# this helper rather than calling core.std.Expr directly. +_akarin_expr = getattr(getattr(core, 'akarin', None), 'Expr', None) + + +def _expr(clips, expr, **kwargs): + if _akarin_expr is not None: + return _akarin_expr(clips, expr, **kwargs) + return core.std.Expr(clips, expr, **kwargs) + # ============================================================================ # PASS 1: PRE-CROP (before deinterlacing to reduce processing area) # ============================================================================ @@ -536,7 +553,7 @@ clip = haf.MCTemporalDenoise( _mcds_planes = {{NR_MCDS_PLANES}} _mcds_blurred = core.tcanny.TCanny(clip, sigma={{NR_MCDS_BLUR_SIGMA}}, mode=-1, planes=_mcds_planes) # Unsharp mask: original plus the difference between it and its own blur. -_mcds_sharpened = core.std.Expr( +_mcds_sharpened = _expr( [clip, core.tcanny.TCanny(clip, sigma={{NR_MCDS_SHARP_SIGMA}}, mode=-1, planes=_mcds_planes)], ['x x + y -'], ) @@ -1046,7 +1063,7 @@ if clip.format.color_family == vs.YUV: # An empty expression leaves that plane untouched, so luma is never # rewritten. Expr clamps to the format's range, so extreme settings # saturate rather than wrap around. - clip = core.std.Expr( + clip = _expr( clip, ['', 'x ' + repr(_wb_u) + ' +', 'x ' + repr(_wb_v) + ' +'], ) diff --git a/worker/templates/preview_template.vpy b/worker/templates/preview_template.vpy index 7fca623..7ad311c 100644 --- a/worker/templates/preview_template.vpy +++ b/worker/templates/preview_template.vpy @@ -58,6 +58,23 @@ def _nnedi3(clip, **kwargs): return core.nnedi3.nnedi3(clip, **kwargs) return core.znedi3.nnedi3(clip, **kwargs) + +# VapourSynth's std.Expr JIT is wrapped in #ifdef VS_TARGET_CPU_X86, so on ARM +# every expression is walked once per pixel by a scalar interpreter -- by far the +# dominant cost in an arm64 filter graph. akarin has a real LLVM JIT that works +# on aarch64 and is bit-identical to std.Expr for every expression used here. +# Falls back to std.Expr wherever akarin is absent (notably macos-x64, whose +# only wheel would raise the Intel floor to macOS 14). havsfunc's own 116 call +# sites get the same treatment via its patch 7. Route every expression through +# this helper rather than calling core.std.Expr directly. +_akarin_expr = getattr(getattr(core, 'akarin', None), 'Expr', None) + + +def _expr(clips, expr, **kwargs): + if _akarin_expr is not None: + return _akarin_expr(clips, expr, **kwargs) + return core.std.Expr(clips, expr, **kwargs) + # ============================================================================ # PASS 1: PRE-CROP (before deinterlacing to reduce processing area) # ============================================================================ @@ -482,7 +499,7 @@ clip = haf.MCTemporalDenoise( _mcds_planes = {{NR_MCDS_PLANES}} _mcds_blurred = core.tcanny.TCanny(clip, sigma={{NR_MCDS_BLUR_SIGMA}}, mode=-1, planes=_mcds_planes) # Unsharp mask: original plus the difference between it and its own blur. -_mcds_sharpened = core.std.Expr( +_mcds_sharpened = _expr( [clip, core.tcanny.TCanny(clip, sigma={{NR_MCDS_SHARP_SIGMA}}, mode=-1, planes=_mcds_planes)], ['x x + y -'], ) @@ -992,7 +1009,7 @@ if clip.format.color_family == vs.YUV: # An empty expression leaves that plane untouched, so luma is never # rewritten. Expr clamps to the format's range, so extreme settings # saturate rather than wrap around. - clip = core.std.Expr( + clip = _expr( clip, ['', 'x ' + repr(_wb_u) + ' +', 'x ' + repr(_wb_v) + ' +'], ) diff --git a/worker/tests/filter_integration_test.rs b/worker/tests/filter_integration_test.rs index e202ae5..a79bf59 100644 --- a/worker/tests/filter_integration_test.rs +++ b/worker/tests/filter_integration_test.rs @@ -3436,3 +3436,66 @@ fn test_92_templates_do_not_hardcode_an_nnedi3_implementation() { ); } } + +/// Test 93: neither template may call `core.std.Expr` directly. +/// +/// VapourSynth's Expr JIT is wrapped in `#ifdef VS_TARGET_CPU_X86`, so on ARM the +/// whole bytecode program is walked once per pixel by `ExprInterpreter::eval()`. +/// Measured on an M1, Expr costs 550-640 CPU-seconds in a QTGMC Slow graph +/// against the interpolator's 30 — it dominates everything else. akarin's LLVM +/// JIT works on aarch64 and is worth 4.1x end to end, so both templates route +/// through the `_expr()` helper (havsfunc does the same through its patch 7). +/// +/// This is exactly the failure mode `test_92` exists for: a direct +/// `core.std.Expr` call still produces a *correct picture*, just far slower on +/// ARM, so nothing but an assertion like this catches the regression. +/// +/// The helper's own fallback is the one permitted direct call — and it must be a +/// real call, not `_expr(...)`, which would recurse forever. That is not +/// hypothetical: a blanket search-and-replace introduced exactly that bug while +/// this change was being written. +#[test] +fn test_93_templates_do_not_call_std_expr_directly() { + let templates_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("templates"); + + for name in ["pipeline_template.vpy", "preview_template.vpy"] { + let path = templates_dir.join(name); + // Normalise line endings: git checks these out CRLF on Windows. + let body = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read {}: {e}", path.display())) + .replace("\r\n", "\n"); + + let helper_start = body + .find("def _expr(") + .unwrap_or_else(|| panic!("{name} is missing the _expr() helper")); + let helper_end = helper_start + + body[helper_start..] + .find("\n\n") + .expect("_expr() helper should be followed by a blank line"); + let helper = &body[helper_start..helper_end]; + let (before, after) = (&body[..helper_start], &body[helper_end..]); + + assert!( + !before.contains("core.std.Expr(") && !after.contains("core.std.Expr("), + "{name} calls core.std.Expr directly; route it through _expr() so ARM \ + gets akarin's JIT instead of the per-pixel interpreter" + ); + + // The fallback must call the real thing, or it is infinite recursion. + assert!( + helper.contains("return core.std.Expr("), + "{name}'s _expr() fallback must call core.std.Expr; anything else \ + either recurses forever or loses the no-akarin path (macos-x64)" + ); + assert!( + !helper.contains("return _expr("), + "{name}'s _expr() fallback calls itself — infinite recursion" + ); + + // And the helper is actually reached. + assert!( + body.matches("_expr(").count() > body.matches("_akarin_expr(").count() + 1, + "{name} defines _expr() but never calls it" + ); + } +}