From 5bb6dd5f30864de2afa128c446a05ff97f410b4b Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 8 Nov 2026 17:03:12 +1100 Subject: [PATCH 01/61] chore: ignore the local add-on staging directory Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index c37ace7f..da368673 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ tmpclaude-* licenseplan.md *.mkv !Tests/TestResources/soft_telecine_test.mkv + +# Locally converted add-on model files awaiting upload (see docs/ADDONS.md) +addon-staging/ From 131b9b31c640e4101fa3756df6c61117885f809d Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 8 Nov 2026 17:32:16 +1100 Subject: [PATCH 02/61] fix(debug): copy every template module into the debug bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run-debug-macos.sh and run-debug-linux.sh copied only pipe_source.py and spotless.py beside the debug worker. pipe_source_dir() picks the first templates directory holding pipe_source.py, so every other vendored module (deflicker, mclean, temporaldegrain2, …, and now ai_runtime) was missing in a debug run and only worked from worker/templates/. Copy *.py, as the packaging scripts already do. Co-Authored-By: Claude Opus 5.5 --- Scripts/run-debug-linux.sh | 5 +++-- Scripts/run-debug-macos.sh | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Scripts/run-debug-linux.sh b/Scripts/run-debug-linux.sh index d01b5c18..03dcee90 100755 --- a/Scripts/run-debug-linux.sh +++ b/Scripts/run-debug-linux.sh @@ -83,8 +83,9 @@ cp "$WORKER_DIR/target/debug/vapourbox-worker" "$BUNDLE_DIR/" # Copy templates mkdir -p "$BUNDLE_DIR/templates" cp "$WORKER_DIR/templates/"*.vpy "$BUNDLE_DIR/templates/" -cp "$WORKER_DIR/templates/pipe_source.py" "$BUNDLE_DIR/templates/" -cp "$WORKER_DIR/templates/spotless.py" "$BUNDLE_DIR/templates/" +# Every helper module (pipe_source, ai_runtime, the vendored filters), as the +# packaging scripts do: one left out works from worker/templates/ and fails here. +cp "$WORKER_DIR/templates/"*.py "$BUNDLE_DIR/templates/" echo "==> Launching VapourBox (debug)..." "$BUNDLE_DIR/vapourbox" & diff --git a/Scripts/run-debug-macos.sh b/Scripts/run-debug-macos.sh index 1d90394a..3c989830 100755 --- a/Scripts/run-debug-macos.sh +++ b/Scripts/run-debug-macos.sh @@ -98,8 +98,9 @@ cp "$WORKER_DIR/target/debug/vapourbox-worker" "$DEBUG_APP/Contents/MacOS/" # Copy templates (includes pipe_source.py used by VapourSynth scripts) mkdir -p "$DEBUG_APP/Contents/MacOS/templates" cp "$WORKER_DIR/templates/"*.vpy "$DEBUG_APP/Contents/MacOS/templates/" -cp "$WORKER_DIR/templates/pipe_source.py" "$DEBUG_APP/Contents/MacOS/templates/" -cp "$WORKER_DIR/templates/spotless.py" "$DEBUG_APP/Contents/MacOS/templates/" +# Every helper module (pipe_source, ai_runtime, the vendored filters), as the +# packaging scripts do: one left out works from worker/templates/ and fails here. +cp "$WORKER_DIR/templates/"*.py "$DEBUG_APP/Contents/MacOS/templates/" # Always strip quarantine from deps. macOS SIGKILLs quarantined binaries # (ffmpeg/ffprobe/vspipe) on exec, which surfaces as opaque "Failed to run From 5369eb68d0d4de5ac5126cbebcfe80f9c3adae2d Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 8 Nov 2026 17:32:16 +1100 Subject: [PATCH 03/61] feat(addons): manifest-driven add-ons, and the AI runtime add-on Every AI model is to ship as an optional add-on, never in the deps bundle. This is the shared groundwork. Manifest add-ons: one JSON per add-on in app/assets/addons/ (format in docs/ADDONS.md). Kind "files" is a model pack (plain files, or archive members each checked by its own hash); kind "python" is wheels unpacked into addons//site-packages/. ManifestAddonInstaller downloads and verifies every file, stages in addons/.new, writes a .vapourbox-addon.json marker (id, version, platform) and renames into place; a manifest version bump makes the old install count as missing. Platforms come from the manifest's table, with minOs checked against the running macOS. AddonService finds every manifest through the asset manifest; AddonInfo gains additionalCredits for add-ons made of several separately licensed components. VAPOURBOX_ADDON_MIRROR= takes files from a local directory (still size- and hash-checked) so add-ons can be tested before upload. The worker compiles the same manifests in (build.rs -> addons.rs): install state, addon_dir, require() with a message naming the add-on and Settings -> Add-ons, and --list-addons. ProcessingPipeline::required_addons() is where a pass declares what it needs; ScriptGenerator refuses the job unless it is installed and emits the {{#AI_RUNTIME}} block (both templates) when the AI runtime is needed. WorkerHarness.ensureAddon() installs through the app's own installer. ai-runtime: ONNX Runtime + NumPy 2.5.4 for the deps Python 3.12, pinned by URL and SHA-256 from PyPI: onnxruntime 1.31.1 (macOS arm64, Linux x64/arm64), onnxruntime-directml 1.24.4 (Windows), 1.23.2 on Intel Macs (macOS 13+, the last Intel wheel). Nothing else is needed at run time (checked). Passes use templates/ai_runtime.py: setup() puts site-packages on sys.path itself (the Windows embeddable Python ignores PYTHONPATH), session() picks TensorRT / CUDA / DirectML / Core ML / CPU with a CPU fallback (VAPOURBOX_AI_FORCE_CPU), and frame <-> NCHW helpers with map_frames(). --probe-ai-runtime runs a committed 172-byte self-test model through that path and reports the provider; positive results are cached per boot. Credited in NOTICES, the About dialog and the README (ONNX Runtime MIT, NumPy BSD-3-Clause, DirectML under Microsoft's licence on Windows). Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +- Scripts/models/make_ai_runtime_selftest.py | 69 +++ Tests/TestResources/ai_runtime_selftest.onnx | Bin 0 -> 172 bytes app/lib/models/ai_runtime_status.dart | 82 +++ app/lib/services/addon_service.dart | 41 ++ app/lib/services/manifest_addon.dart | 528 +++++++++++++++++++ app/lib/services/worker_manager.dart | 34 ++ app/lib/views/about_dialog.dart | 18 + app/lib/views/addon_dialogs.dart | 13 + app/pubspec.yaml | 1 + app/test/addon_service_test.dart | 27 +- app/test/ai_runtime_test.dart | 110 ++++ app/test/attribution_test.dart | 1 + app/test/integration_ai_runtime_test.dart | 65 +++ app/test/manifest_addon_test.dart | 322 +++++++++++ app/test/support/worker_harness.dart | 106 ++++ licenses/NOTICES.txt | 37 +- worker/build.rs | 40 ++ worker/src/addons.rs | 400 ++++++++++++++ worker/src/ai_runtime.rs | 383 ++++++++++++++ worker/src/dependency_locator.rs | 79 +++ worker/src/gpu_plugin.rs | 17 +- worker/src/lib.rs | 2 + worker/src/main.rs | 44 ++ worker/src/models/processing_pipeline.rs | 13 + worker/src/pipeline_executor.rs | 1 + worker/src/script_generator.rs | 82 ++- worker/templates/ai_runtime.py | 265 ++++++++++ worker/templates/pipeline_template.vpy | 9 + worker/templates/preview_template.vpy | 9 + 30 files changed, 2790 insertions(+), 13 deletions(-) create mode 100644 Scripts/models/make_ai_runtime_selftest.py create mode 100644 Tests/TestResources/ai_runtime_selftest.onnx create mode 100644 app/lib/models/ai_runtime_status.dart create mode 100644 app/lib/services/manifest_addon.dart create mode 100644 app/test/ai_runtime_test.dart create mode 100644 app/test/integration_ai_runtime_test.dart create mode 100644 app/test/manifest_addon_test.dart create mode 100644 worker/build.rs create mode 100644 worker/src/addons.rs create mode 100644 worker/src/ai_runtime.rs create mode 100644 worker/templates/ai_runtime.py diff --git a/README.md b/README.md index 69da48e2..e08e9584 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Where an AppImage can't be mounted, use the `.tar.gz` of the same build: extract **GPU features.** RIFE frame interpolation and AI upscaling run on the graphics card through Vulkan: on any Apple Silicon Mac, and on Windows and Linux x64 PCs with a current graphics driver (RIFE on Windows also needs the Microsoft Visual C++ Redistributable). They aren't available on Intel Macs or Linux on ARM yet. GPU deinterlacing and KNLMeans denoising use OpenCL instead; without it, deinterlacing falls back to the CPU. Whenever you choose a GPU feature your machine can't run, VapourBox says what's missing and how to fix it. -**Add-ons.** A few features use third-party downloads that aren't part of VapourBox: the Whisper engine and language models for subtitles, and the AnimeJaNai models for AI upscaling. VapourBox offers to download one the first time you need it, stating its source, size and licence, and **Settings → Add-ons** installs or removes them at any time. AnimeJaNai is free for **non-commercial use only** (CC BY-NC-SA 4.0); for paid work, use Real-ESRGAN or Real-CUGAN instead. +**Add-ons.** A few features use third-party downloads that aren't part of VapourBox: the Whisper engine and language models for subtitles, the AnimeJaNai models for AI upscaling, and the AI runtime (Microsoft's ONNX Runtime) that AI model add-ons run on. VapourBox offers to download one the first time you need it, stating its source, size and licence, and **Settings → Add-ons** installs or removes them at any time. AnimeJaNai is free for **non-commercial use only** (CC BY-NC-SA 4.0); for paid work, use Real-ESRGAN or Real-CUGAN instead. **Older processors.** Intel and AMD CPUs from before about 2013 (no AVX2) are supported on every platform. VapourBox checks the processor on first launch and installs a matching set of components, which runs some filters more slowly but never differently. @@ -241,7 +241,7 @@ The shutdown is never forced, so an app with unsaved work can stop it. VapourBox ## Building from source -See [docs/BUILDING.md](docs/BUILDING.md) for build instructions, project structure and the development workflow. +See [docs/BUILDING.md](docs/BUILDING.md) for build instructions, project structure and the development workflow, and [docs/ADDONS.md](docs/ADDONS.md) for how optional downloads (AI models and the AI runtime) are described, installed and tested. The investigations behind feature decisions (measurements, screenshots and the reasoning, including ideas that weren't built) are written up in [docs/reports/](docs/reports/README.md). @@ -276,6 +276,7 @@ VapourBox is a front end to other people's work. Thanks in particular to: - **Real-ESRGAN** by Xintao Wang and **Real-CUGAN** by bilibili — the AI upscaling networks, run through AmusementClub's **vs-mlrt** (VSNCNN) - **AnimeJaNai** by the-database — anime upscaling models, an optional download (CC BY-NC-SA 4.0, non-commercial use only) - **whisper.cpp** by the ggml authors and OpenAI's **Whisper** models — speech recognition for subtitles, an optional download +- **ONNX Runtime** by Microsoft Corporation, with **NumPy** by the NumPy Developers and, on Windows, Microsoft's **DirectML** — the AI runtime add-on that runs the optional AI models, an optional download - **libdvdread** by VideoLAN — DVD reading - **FFmpeg** — encoding and decoding - **Hybrid** by Selur — the inspiration for this project diff --git a/Scripts/models/make_ai_runtime_selftest.py b/Scripts/models/make_ai_runtime_selftest.py new file mode 100644 index 00000000..50c75c1a --- /dev/null +++ b/Scripts/models/make_ai_runtime_selftest.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Build the AI runtime's self-test model: y = x * 2 + 1 on a float32 NCHW +tensor with three channels and any height and width. + +`vapourbox-worker --probe-ai-runtime` runs it through ONNX Runtime from the +ai-runtime add-on to prove that the add-on imports, that a session can be +created on the chosen execution provider, and that it computes the right +answer. It is deliberately tiny (two nodes, no weights) so it can live inside +`worker/templates/ai_runtime.py` as base64 and ship wherever the templates do. + +Written with an explicit IR version and opset rather than the onnx package's +defaults, so the oldest ONNX Runtime the add-on pins (1.23.2, the last with an +Intel macOS wheel) can load it. + +Usage (in the conversion venv, see docs/ADDONS.md): + + python Scripts/models/make_ai_runtime_selftest.py Tests/TestResources/ai_runtime_selftest.onnx + +then paste the printed base64 into `_SELFTEST_MODEL_B64` in +`worker/templates/ai_runtime.py`. `ai_runtime_selftest_test.dart` asserts the +two agree. +""" +import base64 +import sys + +import onnx +from onnx import TensorProto, helper + +IR_VERSION = 8 +OPSET = 13 + + +def build() -> onnx.ModelProto: + x = helper.make_tensor_value_info("x", TensorProto.FLOAT, [1, 3, "h", "w"]) + y = helper.make_tensor_value_info("y", TensorProto.FLOAT, [1, 3, "h", "w"]) + two = helper.make_tensor("two", TensorProto.FLOAT, [], [2.0]) + one = helper.make_tensor("one", TensorProto.FLOAT, [], [1.0]) + graph = helper.make_graph( + [ + helper.make_node("Mul", ["x", "two"], ["x2"]), + helper.make_node("Add", ["x2", "one"], ["y"]), + ], + "vapourbox_selftest", + [x], + [y], + initializer=[two, one], + ) + model = helper.make_model( + graph, + producer_name="vapourbox", + opset_imports=[helper.make_opsetid("", OPSET)], + ) + model.ir_version = IR_VERSION + onnx.checker.check_model(model) + return model + + +def main() -> None: + if len(sys.argv) != 2: + sys.exit(__doc__) + data = build().SerializeToString() + with open(sys.argv[1], "wb") as f: + f.write(data) + print(f"{len(data)} bytes") + print(base64.b64encode(data).decode()) + + +if __name__ == "__main__": + main() diff --git a/Tests/TestResources/ai_runtime_selftest.onnx b/Tests/TestResources/ai_runtime_selftest.onnx new file mode 100644 index 0000000000000000000000000000000000000000..b386ce1333e39d130e15fbad6d05df8776b437ee GIT binary patch literal 172 zcmd9GpPfVcZ6LCy>r4S)jQ>5?o>&j6y providers; + + /// The provider the test model ran on. + final String? provider; + + /// Why the preferred GPU provider was not used, if it fell back to the CPU. + final String? fallback; + + const AiRuntimeStatus({ + required this.available, + this.reason, + this.message, + this.version, + this.providers = const [], + this.provider, + this.fallback, + }); + + /// The status when the probe itself could not be run or read. + const AiRuntimeStatus.unknown() + : available = false, + reason = 'probeFailed', + message = "VapourBox couldn't check whether the AI runtime add-on runs on " + 'this computer. Restart VapourBox to try again.', + version = null, + providers = const [], + provider = null, + fallback = null; + + factory AiRuntimeStatus.fromJson(Map json) { + final available = json['available'] == true; + final message = json['message']; + if (!available && message is! String) return const AiRuntimeStatus.unknown(); + return AiRuntimeStatus( + available: available, + reason: json['reason'] as String?, + message: available ? null : message as String, + version: json['version'] as String?, + providers: (json['providers'] as List? ?? const []).cast(), + provider: json['provider'] as String?, + fallback: json['fallback'] as String?, + ); + } + + /// Whether the test model ran on a GPU (or Neural Engine) provider rather + /// than the CPU. + bool get onGpu => available && provider != null && provider != 'CPUExecutionProvider'; + + /// The provider in words, for the UI ("Core ML", "DirectML", "CPU"). + static String providerLabel(String provider) => switch (provider) { + 'CoreMLExecutionProvider' => 'Core ML', + 'DmlExecutionProvider' => 'DirectML', + 'CUDAExecutionProvider' => 'CUDA', + 'TensorrtExecutionProvider' => 'TensorRT', + 'CPUExecutionProvider' => 'CPU', + _ => provider.replaceAll('ExecutionProvider', ''), + }; +} diff --git a/app/lib/services/addon_service.dart b/app/lib/services/addon_service.dart index f6c01fa5..ae662750 100644 --- a/app/lib/services/addon_service.dart +++ b/app/lib/services/addon_service.dart @@ -10,8 +10,29 @@ import 'package:path/path.dart' as path; import 'addon_manager.dart'; import 'dependency_manager.dart'; +import 'manifest_addon.dart'; import 'whisper_addon_manager.dart'; +/// A further third-party component inside an add-on, credited on its own: +/// its own NOTICES entry, About tile and README credit, like the add-on's +/// main one. The AI runtime is ONNX Runtime plus NumPy (and on Windows +/// Microsoft's DirectML), each under its own licence. +class AddonCredit { + final String noticeName; + final String licence; + final String? licenceUrl; + final String credit; + final String sourceUrl; + + const AddonCredit({ + required this.noticeName, + required this.licence, + this.licenceUrl, + required this.credit, + required this.sourceUrl, + }); +} + /// One optional third-party download: something VapourBox can use but does not /// ship, because of its size or its licence. Shown on Settings → Add-ons and /// in the prompt a feature raises when it needs one. @@ -55,6 +76,10 @@ class AddonInfo { /// Why it cannot be used on this platform, or null if it can. final String? unavailableReason; + /// Further components it includes on this platform, each credited + /// separately (see [AddonCredit]); the main one is the fields above. + final List additionalCredits; + const AddonInfo({ required this.id, required this.name, @@ -69,7 +94,20 @@ class AddonInfo { required this.downloadBytes, this.requires = const [], this.unavailableReason, + this.additionalCredits = const [], }); + + /// Every component to credit: the main one, then [additionalCredits]. + List get credits => [ + AddonCredit( + noticeName: noticeName, + licence: licence, + licenceUrl: licenceUrl, + credit: credit, + sourceUrl: sourceUrl, + ), + ...additionalCredits, + ]; } /// How one add-on is installed, recognised and removed under the add-ons @@ -139,6 +177,9 @@ class AddonService extends ChangeNotifier { as Map, platform, ), + // Every add-on described by a manifest in assets/addons/. + for (final manifest in await AddonManifest.loadBundled()) + ManifestAddonInstaller(manifest, platform), ]); } await refresh(); diff --git a/app/lib/services/manifest_addon.dart b/app/lib/services/manifest_addon.dart new file mode 100644 index 00000000..4a52ee66 --- /dev/null +++ b/app/lib/services/manifest_addon.dart @@ -0,0 +1,528 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive_io.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/services.dart'; +import 'package:path/path.dart' as path; + +import 'addon_manager.dart'; +import 'addon_service.dart'; + +/// Add-ons described entirely by a manifest, one JSON file per add-on in +/// `assets/addons/` (format: docs/ADDONS.md). The worker compiles in the same +/// files (`worker/src/addons.rs`), so the app and the worker agree on what +/// each add-on is, where it installs and what "installed" means. +/// +/// Two kinds: +/// * `files` — a model pack: each file is downloaded into `addons//`, +/// or, for an archive entry, only its listed members are kept, each checked +/// against its own hash. +/// * `python` — Python packages for VapourBox's own Python: each file is a +/// wheel, unpacked into `addons//site-packages/`. +/// +/// Everything is pinned by SHA-256; an install is built in `addons/.new` +/// and renamed into place, and ends by writing [markerName], so neither the +/// app nor the worker ever sees a partial install. A manifest whose `version` +/// changes makes the installed copy count as missing, so the feature asks for +/// it again and the install replaces it. + +/// The platform ids the add-ons are described for (the deps bundle's). +const addonPlatformIds = [ + 'macos-arm64', + 'macos-x64', + 'windows-x64', + 'linux-x64', + 'linux-arm64', +]; + +/// `VAPOURBOX_ADDON_MIRROR=DIR`: take each file from `DIR/` + the last path +/// segment of its url instead of downloading it, still checked against its +/// size and hash — for testing an add-on whose files are not uploaded yet. +const addonMirrorEnv = 'VAPOURBOX_ADDON_MIRROR'; + +/// One file a manifest add-on installs. +class AddonManifestFile { + /// Its name in `addons//` (a `files` add-on), or the wheel's file name. + final String name; + final String url; + final int size; + final String sha256; + + /// The platforms it is for; null means every platform the add-on supports. + final List? platforms; + + /// For an archive: the members to keep (nothing else is extracted). Null + /// for a file kept as downloaded, or a wheel. + final List? extract; + + const AddonManifestFile({ + required this.name, + required this.url, + required this.size, + required this.sha256, + this.platforms, + this.extract, + }); + + factory AddonManifestFile.fromJson(Map json) => AddonManifestFile( + name: json['name'] as String, + url: json['url'] as String, + size: json['size'] as int, + sha256: json['sha256'] as String, + platforms: (json['platforms'] as List?)?.cast(), + extract: (json['extract'] as List?) + ?.map((m) => AddonArchiveMember.fromJson((m as Map).cast())) + .toList(), + ); + + bool isFor(String platformId) => platforms == null || platforms!.contains(platformId); +} + +/// A member of an archive entry: its path inside the archive, the name it is +/// installed under, and its own hash. +class AddonArchiveMember { + final String path; + final String name; + final String sha256; + + const AddonArchiveMember({required this.path, required this.name, required this.sha256}); + + factory AddonArchiveMember.fromJson(Map json) => AddonArchiveMember( + path: json['path'] as String, + name: json['name'] as String? ?? (json['path'] as String).split('/').last, + sha256: json['sha256'] as String, + ); +} + +/// One add-on's manifest (`assets/addons/.json`). +class AddonManifest { + final String id; + final String version; + final String name; + final String feature; + final String description; + final String licence; + final String? licenceUrl; + final String? usageNote; + final String credit; + final String noticeName; + final String homepage; + final List requires; + + /// `files` or `python`. + final String kind; + + /// Platform id → its requirements (`minOs`, macOS only), for the platforms + /// it supports. `"platforms": "all"` in the file is every [addonPlatformIds]. + final Map> platforms; + final List files; + + /// Further components credited separately, each with optional `platforms`. + final List> components; + + const AddonManifest({ + required this.id, + required this.version, + required this.name, + required this.feature, + required this.description, + required this.licence, + this.licenceUrl, + this.usageNote, + required this.credit, + required this.noticeName, + required this.homepage, + this.requires = const [], + required this.kind, + required this.platforms, + required this.files, + this.components = const [], + }); + + static const kinds = {'files', 'python'}; + + /// Parse and validate: a malformed manifest fails here, in the test suite, + /// rather than as a half-working add-on. + factory AddonManifest.fromJson(Map json) { + final rawPlatforms = json['platforms']; + final platforms = rawPlatforms == 'all' + ? {for (final p in addonPlatformIds) p: {}} + : { + for (final MapEntry(:key, :value) in (rawPlatforms as Map).entries) + key as String: (value as Map?)?.cast() ?? {}, + }; + final m = AddonManifest( + id: json['id'] as String, + version: json['version'] as String, + name: json['name'] as String, + feature: json['feature'] as String, + description: json['description'] as String, + licence: json['licence'] as String, + licenceUrl: json['licenceUrl'] as String?, + usageNote: json['usageNote'] as String?, + credit: json['credit'] as String, + noticeName: json['noticeName'] as String, + homepage: json['homepage'] as String, + requires: (json['requires'] as List? ?? []).cast(), + kind: json['kind'] as String, + platforms: platforms, + files: (json['files'] as List) + .map((f) => AddonManifestFile.fromJson((f as Map).cast())) + .toList(), + components: [ + for (final c in json['components'] as List? ?? []) (c as Map).cast(), + ], + ); + m._validate(); + return m; + } + + void _validate() { + void fail(String why) => throw FormatException('Add-on manifest "$id": $why'); + if (!RegExp(r'^[a-z0-9][a-z0-9-]*$').hasMatch(id)) fail('id must be lower-case-with-dashes'); + if (!kinds.contains(kind)) fail('kind must be one of $kinds'); + for (final p in platforms.keys) { + if (!addonPlatformIds.contains(p)) fail('unknown platform $p'); + } + final hash = RegExp(r'^[0-9a-f]{64}$'); + final names = {}; + for (final f in files) { + if (!hash.hasMatch(f.sha256)) fail('${f.name} needs a lower-case hex sha256'); + if (f.size <= 0) fail('${f.name} needs its size'); + if (!f.url.startsWith('https://')) fail('${f.name} must download over https'); + if (!_safeName(f.name)) fail('${f.name} is not a plain file name'); + if (!names.add(f.name)) fail('${f.name} is listed twice'); + for (final p in f.platforms ?? const []) { + if (!platforms.containsKey(p)) fail('${f.name} names $p, which the add-on does not support'); + } + if (kind == 'python' && (!f.name.endsWith('.whl') || f.extract != null)) { + fail('a python add-on lists only wheels (${f.name})'); + } + for (final member in f.extract ?? const []) { + if (!hash.hasMatch(member.sha256)) fail('${member.path} needs a sha256'); + if (!_safeName(member.name)) fail('${member.name} is not a plain file name'); + } + } + for (final p in platforms.keys) { + if (!files.any((f) => f.isFor(p))) fail('no files for $p'); + } + } + + static bool _safeName(String name) => + name.isNotEmpty && !name.contains('/') && !name.contains('\\') && name != '.' && name != '..'; + + /// The files this platform installs. + List filesFor(String platformId) => + files.where((f) => f.isFor(platformId)).toList(); + + /// Every manifest shipped in `assets/addons/`, found through the asset + /// manifest so a new file needs no index to be picked up. + static Future> loadBundled({AssetBundle? bundle}) async { + final b = bundle ?? rootBundle; + final assets = (await AssetManifest.loadFromAssetBundle(b)) + .listAssets() + .where((a) => a.startsWith('assets/addons/') && a.endsWith('.json')) + .toList() + ..sort(); + return [ + for (final a in assets) + AddonManifest.fromJson(jsonDecode(await b.loadString(a)) as Map), + ]; + } + + /// Every manifest in a directory on disk (tests, and the harness). + static List loadDirectory(Directory dir) { + final files = dir.listSync().whereType().where((f) => f.path.endsWith('.json')).toList() + ..sort((a, b) => a.path.compareTo(b.path)); + return [ + for (final f in files) + AddonManifest.fromJson(jsonDecode(f.readAsStringSync()) as Map), + ]; + } +} + +/// Installs, recognises and removes one manifest add-on (see the library +/// comment). Every file is verified before anything is unpacked. +class ManifestAddonInstaller implements AddonInstaller { + final AddonManifest manifest; + final String platformId; + + /// The running macOS version ("15.1"), for `minOs`; read from the system + /// unless given (tests). + final String? _osVersion; + + /// Where to take files from instead of the network; defaults to + /// [addonMirrorEnv]. + final String? _mirror; + + ManifestAddonInstaller(this.manifest, this.platformId, {String? osVersion, String? mirror}) + : _osVersion = osVersion, + _mirror = mirror; + + /// Written last into an install, as its commit marker: the add-on's id, + /// version and platform. The worker reads the same file. + static const markerName = '.vapourbox-addon.json'; + + String? get mirror { + if (_mirror != null) return _mirror; + final env = Platform.environment[addonMirrorEnv]; + return env == null || env.isEmpty ? null : env; + } + + List get _files => manifest.filesFor(platformId); + + @override + AddonInfo get info => AddonInfo( + id: manifest.id, + name: manifest.name, + feature: manifest.feature, + description: manifest.description, + licence: manifest.licence, + licenceUrl: manifest.licenceUrl, + usageNote: manifest.usageNote, + credit: manifest.credit, + noticeName: manifest.noticeName, + sourceUrl: manifest.homepage, + downloadBytes: _files.fold(0, (sum, f) => sum + f.size), + requires: manifest.requires, + unavailableReason: unavailableReason, + additionalCredits: [ + for (final c in manifest.components) + if ((c['platforms'] as List?)?.contains(platformId) ?? true) + AddonCredit( + noticeName: c['noticeName'] as String, + licence: c['licence'] as String, + licenceUrl: c['licenceUrl'] as String?, + credit: c['credit'] as String, + sourceUrl: c['homepage'] as String, + ), + ], + ); + + static const _platformNames = { + 'macos-arm64': 'Apple Silicon Macs', + 'macos-x64': 'Intel Macs', + 'windows-x64': 'Windows', + 'linux-x64': 'Linux (x64)', + 'linux-arm64': 'Linux on ARM', + }; + + /// Why it cannot be installed here, or null: the platform is not in the + /// manifest's table, or (macOS) the system is older than its `minOs`. + String? get unavailableReason { + final requirements = manifest.platforms[platformId]; + if (requirements == null) { + return '${manifest.name} isn\'t available for ' + '${_platformNames[platformId] ?? 'this platform'}.'; + } + final minOs = requirements['minOs'] as String?; + if (minOs != null && platformId.startsWith('macos')) { + final running = _osVersion ?? macOsVersion(); + if (running != null && compareOsVersions(running, minOs) < 0) { + return '${manifest.name} needs macOS $minOs or later; this Mac has $running.'; + } + } + return null; + } + + /// The macOS version this is running on, or null elsewhere / unreadable. + static String? macOsVersion() { + if (!Platform.isMacOS) return null; + return RegExp(r'\d+(\.\d+)*').firstMatch(Platform.operatingSystemVersion)?.group(0); + } + + /// Compare dotted versions numerically ("13.0" < "13.10"). + static int compareOsVersions(String a, String b) { + final pa = a.split('.').map((s) => int.tryParse(s) ?? 0).toList(); + final pb = b.split('.').map((s) => int.tryParse(s) ?? 0).toList(); + for (var i = 0; i < pa.length || i < pb.length; i++) { + final x = i < pa.length ? pa[i] : 0, y = i < pb.length ? pb[i] : 0; + if (x != y) return x.compareTo(y); + } + return 0; + } + + Directory target(Directory addonsDir) => Directory(path.join(addonsDir.path, manifest.id)); + + /// The files a complete install of this platform's files holds, relative to + /// the add-on's directory (a python add-on: its site-packages directory). + List get expectedPaths => manifest.kind == 'python' + ? ['site-packages'] + : [ + for (final f in _files) + if (f.extract == null) f.name else ...f.extract!.map((m) => m.name), + ]; + + @override + bool isInstalled(Directory addonsDir) { + final dir = target(addonsDir); + final marker = File(path.join(dir.path, markerName)); + if (!marker.existsSync()) return false; + try { + final json = jsonDecode(marker.readAsStringSync()) as Map; + if (json['id'] != manifest.id || + json['version'] != manifest.version || + json['platform'] != platformId) { + return false; + } + } catch (_) { + return false; + } + return expectedPaths.every((p) => FileSystemEntity.typeSync(path.join(dir.path, p)) != + FileSystemEntityType.notFound); + } + + @override + Future install(Directory addonsDir, AddonManager downloader) async { + final reason = unavailableReason; + if (reason != null) throw StateError(reason); + final dest = target(addonsDir); + final staging = Directory('${dest.path}.new'); + final old = Directory('${dest.path}.old'); + // Downloads are kept beside the add-ons, not in temp, so a cancelled one + // resumes (AddonManager keeps the .partial), and are removed once installed. + final downloads = + Directory(path.join(addonsDir.path, '.downloads', '${manifest.id}-${manifest.version}')); + try { + await downloads.create(recursive: true); + final fetched = {}; + for (final f in _files) { + final file = File(path.join(downloads.path, f.name)); + await _fetch(f, file, downloader); + if (downloader.isCancelled) return; + fetched[f] = file; + } + + if (await staging.exists()) await staging.delete(recursive: true); + await staging.create(recursive: true); + for (final MapEntry(key: f, value: file) in fetched.entries) { + if (manifest.kind == 'python') { + await extractWheel(file, Directory(path.join(staging.path, 'site-packages'))); + } else if (f.extract != null) { + await extractMembers(file, f.extract!, staging); + } else { + await file.copy(path.join(staging.path, f.name)); + } + } + if (Platform.isMacOS) { + // Downloads made by the app are not quarantined today, but a mirror + // copy may be; a quarantined dylib is killed on load. + await Process.run('xattr', ['-cr', staging.path]); + } + await File(path.join(staging.path, markerName)).writeAsString(jsonEncode({ + 'id': manifest.id, + 'version': manifest.version, + 'platform': platformId, + })); + + if (await old.exists()) await old.delete(recursive: true); + if (await dest.exists()) await dest.rename(old.path); + await staging.rename(dest.path); + if (await old.exists()) await old.delete(recursive: true); + await downloads.delete(recursive: true); + } finally { + if (await staging.exists()) await staging.delete(recursive: true); + } + } + + /// Get [f] into [file], from the mirror if one is set, else the network; + /// either way it must match its size and hash. + Future _fetch(AddonManifestFile f, File file, AddonManager downloader) async { + if (await file.exists() && await _matches(file, f)) return; + final mirrorDir = mirror; + if (mirrorDir != null) { + final source = File(path.join(mirrorDir, Uri.parse(f.url).pathSegments.last)); + if (!await source.exists()) { + throw StateError('${f.name} is not in the add-on mirror ($addonMirrorEnv=$mirrorDir): ' + 'expected ${source.path}'); + } + await source.copy(file.path); + } else { + await downloader.downloadFile(f.url, file, expectedSize: f.size, expectedSha256: f.sha256); + if (downloader.isCancelled) return; + } + if (!await _matches(file, f)) { + final actual = await sha256OfFile(file); + final length = await file.length(); + await file.delete(); + throw StateError('${f.name} does not match the pinned size and hash ' + '(got $length bytes, sha256 $actual; expected ${f.size} bytes, sha256 ${f.sha256})'); + } + } + + static Future _matches(File file, AddonManifestFile f) async => + await file.length() == f.size && await sha256OfFile(file) == f.sha256; + + static Future sha256OfFile(File file) async => + (await sha256.bind(file.openRead()).first).toString(); + + /// A wheel's contents into [siteDir], as pip lays them out: `*.data/purelib` + /// and `*.data/platlib` go to the top, other `.data` directories (scripts, + /// headers) are left out. + static Future extractWheel(File wheel, Directory siteDir) async { + final input = InputFileStream(wheel.path); + try { + final archive = ZipDecoder().decodeBuffer(input); + for (final entry in archive.files) { + if (!entry.isFile) continue; + var rel = entry.name.replaceAll('\\', '/'); + final data = RegExp(r'^[^/]+\.data/([^/]+)/(.*)$').firstMatch(rel); + if (data != null) { + if (data.group(1) != 'purelib' && data.group(1) != 'platlib') continue; + rel = data.group(2)!; + } + final out = File(_safeJoin(siteDir.path, rel)); + await out.parent.create(recursive: true); + final stream = OutputFileStream(out.path); + entry.writeContent(stream); + await stream.close(); + } + } finally { + await input.close(); + } + } + + /// The listed [members] of an archive into [into], each checked against its + /// own hash; nothing else in the archive is written, and nothing at all if + /// one is missing or wrong. + static Future extractMembers( + File zip, List members, Directory into) async { + final input = InputFileStream(zip.path); + try { + final archive = ZipDecoder().decodeBuffer(input); + final found = >{}; + for (final m in members) { + final entry = archive.files.where((f) => f.isFile && f.name.replaceAll('\\', '/') == m.path); + if (entry.isEmpty) throw StateError('${m.path} is missing from ${path.basename(zip.path)}'); + final bytes = entry.first.content as List; + final actual = sha256.convert(bytes).toString(); + if (actual != m.sha256) { + throw StateError('${m.path} does not match its expected hash ($actual)'); + } + found[m] = bytes; + } + await into.create(recursive: true); + for (final MapEntry(key: m, value: bytes) in found.entries) { + await File(path.join(into.path, m.name)).writeAsBytes(bytes); + } + } finally { + await input.close(); + } + } + + /// [rel] under [root], refusing a path that would land outside it. + static String _safeJoin(String root, String rel) { + final joined = path.normalize(path.join(root, rel)); + if (path.isAbsolute(rel) || !path.isWithin(root, joined)) { + throw StateError('Archive entry escapes its directory: $rel'); + } + return joined; + } + + @override + Future remove(Directory addonsDir) async { + final dir = target(addonsDir); + if (await dir.exists()) await dir.delete(recursive: true); + } +} diff --git a/app/lib/services/worker_manager.dart b/app/lib/services/worker_manager.dart index ecde1c15..57dc742a 100644 --- a/app/lib/services/worker_manager.dart +++ b/app/lib/services/worker_manager.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; +import '../models/ai_runtime_status.dart'; import '../models/progress_info.dart'; import '../models/gpu_plugin_status.dart'; import '../models/native_res_report.dart'; @@ -267,6 +268,39 @@ class WorkerManager implements JobRunner { } } + /// Ask the worker whether the AI runtime add-on runs here, and on which + /// execution provider (`--probe-ai-runtime`). The first call after a boot + /// (or after installing the add-on) imports ONNX Runtime and runs a test + /// model, so it takes a few seconds; a positive answer is cached by the + /// worker. Never throws: an unreadable answer is [AiRuntimeStatus.unknown]. + static Future probeAiRuntime() async { + final toolLocator = ToolLocator.instance; + final workerPath = toolLocator.workerPath; + if (workerPath == null) return const AiRuntimeStatus.unknown(); + try { + final result = await Process.run( + workerPath, + ['--probe-ai-runtime'], + environment: toolLocator.workerEnvironment, + workingDirectory: File(workerPath).parent.path, + ); + if (result.exitCode != 0) return const AiRuntimeStatus.unknown(); + for (final line in const LineSplitter().convert(result.stdout.toString())) { + try { + final decoded = jsonDecode(line.trim()); + if (decoded is Map && decoded['available'] is bool) { + return AiRuntimeStatus.fromJson(decoded); + } + } catch (_) { + // Not the JSON line; keep scanning. + } + } + return const AiRuntimeStatus.unknown(); + } catch (_) { + return const AiRuntimeStatus.unknown(); + } + } + /// Find the resolution and kernel [job]'s video was upscaled from /// (`--detect-native`). Decodes a few frames and tries every height and the /// common kernels, so it takes tens of seconds on HD. Never throws: a failure diff --git a/app/lib/views/about_dialog.dart b/app/lib/views/about_dialog.dart index 70796274..d5c78955 100644 --- a/app/lib/views/about_dialog.dart +++ b/app/lib/views/about_dialog.dart @@ -493,6 +493,24 @@ class _AboutDialogState extends State { copyright: 'OpenAI', url: 'https://github.com/openai/whisper', ), + _ComponentTile( + name: 'ONNX Runtime (optional download, AI runtime add-on)', + license: 'MIT', + copyright: 'Microsoft Corporation', + url: 'https://github.com/microsoft/onnxruntime', + ), + _ComponentTile( + name: 'NumPy (optional download, AI runtime add-on)', + license: 'BSD-3-Clause', + copyright: 'NumPy Developers', + url: 'https://github.com/numpy/numpy', + ), + _ComponentTile( + name: 'DirectML (optional download, AI runtime add-on, Windows)', + license: 'Microsoft Software License Terms', + copyright: 'Microsoft Corporation', + url: 'https://github.com/microsoft/DirectML', + ), _ComponentTile( name: 'libdvdread', license: 'GPL 2.0+', diff --git a/app/lib/views/addon_dialogs.dart b/app/lib/views/addon_dialogs.dart index 7d6ae214..aafbc3e3 100644 --- a/app/lib/views/addon_dialogs.dart +++ b/app/lib/views/addon_dialogs.dart @@ -69,6 +69,19 @@ class AddonTerms extends StatelessWidget { _Link(text: 'Source', url: info.sourceUrl, style: small), ], ), + // Components under their own licence (the AI runtime's NumPy, and + // on Windows DirectML), each with its own credit and links. + for (final c in info.additionalCredits) + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 4, + children: [ + Text('Includes ${c.noticeName}: ${c.credit} · ', style: small), + _Link(text: c.licence, url: c.licenceUrl, style: small), + Text('·', style: small), + _Link(text: 'Source', url: c.sourceUrl, style: small), + ], + ), ], ); } diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 908522ca..dfae6f3a 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -58,5 +58,6 @@ flutter: - assets/deps-version.json - assets/whisper-addon.json - assets/animejanai-addon.json + - assets/addons/ - assets/filters/ - assets/filters/core/ diff --git a/app/test/addon_service_test.dart b/app/test/addon_service_test.dart index a62b2517..8ad43e0a 100644 --- a/app/test/addon_service_test.dart +++ b/app/test/addon_service_test.dart @@ -12,6 +12,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:vapourbox/services/addon_manager.dart'; import 'package:vapourbox/services/addon_service.dart'; +import 'package:vapourbox/services/manifest_addon.dart'; import 'package:vapourbox/services/whisper_addon_manager.dart'; /// An add-on that is a single marker file. @@ -265,10 +266,23 @@ void main() { jsonDecode(_repoFile('app/assets/animejanai-addon.json')) as Map, 'macos-arm64') .info, + // Every manifest add-on, on every platform (a component can be + // platform-specific, like the AI runtime's DirectML on Windows). + for (final manifest in AddonManifest.loadDirectory(Directory(p.join( + Directory.current.path.endsWith('app') ? Directory.current.parent.path : Directory.current.path, + 'app', 'assets', 'addons')))) + for (final platform in addonPlatformIds) + ManifestAddonInstaller(manifest, platform, osVersion: '99.0').info, ]; - final byNotice = {for (final i in infos) '${i.noticeName}|${i.licence}|${i.credit}': i}.values; + // Each component to credit, with whether it is the add-on's main one (the + // one whose licence a usage note is about). + final byNotice = { + for (final i in infos) + for (final (index, c) in i.credits.indexed) + '${c.noticeName}|${c.licence}|${c.credit}': (c, index == 0 ? i : null), + }.values; - for (final info in byNotice) { + for (final (info, main) in byNotice) { test(info.noticeName, () { final heading = RegExp('^${RegExp.escape(info.noticeName)} — (.*)\$', multiLine: true) .firstMatch(notices); @@ -289,8 +303,13 @@ void main() { final acks = readme.substring(readme.indexOf('## Acknowledgments')); expect(acks.toLowerCase(), contains(who), reason: 'README Acknowledgments omit $who'); - expect(info.usageNote != null, info.licence.contains('NC'), - reason: 'a non-commercial licence must be shown as a usage note, and only that'); + if (main != null) { + expect(main.usageNote != null, main.licence.contains('NC'), + reason: 'a non-commercial licence must be shown as a usage note, and only that'); + } else { + expect(info.licence.contains('NC'), isFalse, + reason: 'a non-commercial component needs a usage note: make it its own add-on'); + } }); } }); diff --git a/app/test/ai_runtime_test.dart b/app/test/ai_runtime_test.dart new file mode 100644 index 00000000..e29ceeef --- /dev/null +++ b/app/test/ai_runtime_test.dart @@ -0,0 +1,110 @@ +// The AI runtime add-on (assets/addons/ai-runtime.json) and the worker's +// --probe-ai-runtime answer, without the network: the wheels pinned per +// platform are the ones the bundled Python can load, the self-test model the +// probe runs is the committed one, and the probe's JSON reads as the worker +// means it. integration_ai_runtime_test.dart (heavy) installs and runs it. +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:vapourbox/models/ai_runtime_status.dart'; +import 'package:vapourbox/services/manifest_addon.dart'; + +String get _root => + Directory.current.path.endsWith('app') ? Directory.current.parent.path : Directory.current.path; + +AddonManifest _manifest() => AddonManifest.fromJson( + jsonDecode(File(p.join(_root, 'app', 'assets', 'addons', 'ai-runtime.json')).readAsStringSync()) + as Map); + +void main() { + group('ai-runtime manifest', () { + final m = _manifest(); + + test('a python add-on on every platform: one onnxruntime and one NumPy wheel each', () { + expect(m.kind, 'python'); + expect(m.requires, isEmpty); + expect(m.platforms.keys.toSet(), addonPlatformIds.toSet()); + for (final platform in addonPlatformIds) { + final files = m.filesFor(platform).map((f) => f.name).toList(); + expect(files.where((f) => f.startsWith('onnxruntime')).length, 1, reason: platform); + expect(files.where((f) => f.startsWith('numpy-')).length, 1, reason: platform); + expect(files.length, 2, reason: '$platform: only what import onnxruntime needs'); + for (final f in m.filesFor(platform)) { + // The deps bundle's Python is 3.12 everywhere. + expect(f.name, contains('-cp312-cp312-'), reason: f.name); + expect(f.url, startsWith('https://files.pythonhosted.org/'), reason: f.name); + expect(Uri.parse(f.url).pathSegments.last, f.name, reason: 'the mirror finds files by url name'); + } + } + }); + + test('each platform gets its own build: DirectML on Windows, the last Intel wheel on Intel Macs', () { + String ort(String platform) => m.filesFor(platform).firstWhere((f) => f.name.startsWith('onnxruntime')).name; + expect(ort('windows-x64'), startsWith('onnxruntime_directml-')); + expect(ort('windows-x64'), endsWith('win_amd64.whl')); + expect(ort('macos-arm64'), contains('macosx_14_0_arm64')); + expect(ort('macos-x64'), contains('macosx_13_0_x86_64')); + expect(ort('linux-x64'), contains('manylinux_2_28_x86_64')); + expect(ort('linux-arm64'), contains('manylinux_2_28_aarch64')); + // The NumPy wheels' own macOS floors must not exceed the add-on's. + expect(m.filesFor('macos-x64').firstWhere((f) => f.name.startsWith('numpy')).name, + contains('macosx_10_13_x86_64')); + }); + + test('an Intel Mac before macOS 13 is told it cannot have it', () { + expect(m.platforms['macos-x64']!['minOs'], '13.0'); + expect(ManifestAddonInstaller(m, 'macos-x64', osVersion: '12.7').unavailableReason, + contains('macOS 13.0')); + expect(ManifestAddonInstaller(m, 'macos-x64', osVersion: '14.2').unavailableReason, isNull); + expect(ManifestAddonInstaller(m, 'macos-arm64', osVersion: '15.0').unavailableReason, isNull); + expect(ManifestAddonInstaller(m, 'windows-x64').unavailableReason, isNull); + }); + + test('credits NumPy everywhere and DirectML on Windows only', () { + List credited(String platform) => + ManifestAddonInstaller(m, platform, osVersion: '15.0').info.credits.map((c) => c.noticeName).toList(); + expect(credited('windows-x64'), ['ONNX Runtime', 'NumPy', 'DirectML']); + expect(credited('macos-arm64'), ['ONNX Runtime', 'NumPy']); + expect(credited('linux-x64'), ['ONNX Runtime', 'NumPy']); + }); + }); + + test('the self-test model in ai_runtime.py is the committed one', () { + final helper = File(p.join(_root, 'worker', 'templates', 'ai_runtime.py')).readAsStringSync(); + final literal = RegExp(r'_SELFTEST_MODEL_B64 = \(\n((?:\s*"[^"]*"\n)+)\)').firstMatch(helper)!.group(1)!; + final b64 = RegExp(r'"([^"]*)"').allMatches(literal).map((x) => x.group(1)).join(); + final committed = + File(p.join(_root, 'Tests', 'TestResources', 'ai_runtime_selftest.onnx')).readAsBytesSync(); + expect(base64Decode(b64), committed); + expect(committed.length, lessThan(1024), reason: 'keep it tiny'); + }); + + group('AiRuntimeStatus', () { + test('reads a working probe', () { + final s = AiRuntimeStatus.fromJson({ + 'available': true, + 'version': '1.31.1', + 'providers': ['CoreMLExecutionProvider', 'CPUExecutionProvider'], + 'provider': 'CoreMLExecutionProvider', + }); + expect(s.available, isTrue); + expect(s.message, isNull); + expect(s.onGpu, isTrue); + expect(AiRuntimeStatus.providerLabel(s.provider!), 'Core ML'); + expect(AiRuntimeStatus.providerLabel('DmlExecutionProvider'), 'DirectML'); + }); + + test('a failure carries the worker\'s message; an unreadable one asks for a restart', () { + final s = AiRuntimeStatus.fromJson( + {'available': false, 'reason': 'notInstalled', 'message': 'Install it in Settings → Add-ons.'}); + expect(s.available, isFalse); + expect(s.message, contains('Settings → Add-ons')); + expect(s.onGpu, isFalse); + final unknown = AiRuntimeStatus.fromJson({'available': false}); + expect(unknown.reason, 'probeFailed'); + expect(unknown.message, contains('Restart')); + }); + }); +} diff --git a/app/test/attribution_test.dart b/app/test/attribution_test.dart index 2ebce35b..22ff90c3 100644 --- a/app/test/attribution_test.dart +++ b/app/test/attribution_test.dart @@ -340,6 +340,7 @@ Set _licenceFamilies(String text) { 'bsd': 'bsd', 'apache': 'apache', 'isc': 'isc', 'boost': 'bsl', 'bsl': 'bsl', 'psf': 'psf', 'python software': 'psf', 'zlib': 'zlib', 'unlicense': 'unlicense', 'no licence': 'none stated', 'no license': 'none stated', 'no copyright': 'no copyright', + 'microsoft software license': 'microsoft proprietary', }; final out = {for (final MapEntry(key: w, value: f) in words.entries) if (t.contains(w)) f}; for (final m in RegExp(r'(? _probe({Map env = const {}}) async { + final result = await Process.run(WorkerHarness.workerPath, ['--probe-ai-runtime'], + environment: {...WorkerHarness.workerEnv, ...env}); + expect(result.exitCode, 0, reason: '${result.stderr}'); + final line = const LineSplitter() + .convert(result.stdout as String) + .lastWhere((l) => l.trim().startsWith('{')); + // ignore: avoid_print + print('probe: $line'); + return AiRuntimeStatus.fromJson(jsonDecode(line) as Map); +} + +void main() { + setUpAll(() async { + await WorkerHarness.ensureReady(); + }); + + test('installs from its pinned wheels and runs the self-test model', () async { + final dir = await WorkerHarness.ensureAddon('ai-runtime'); + expect(File(p.join(dir.path, ManifestAddonInstaller.markerName)).existsSync(), isTrue); + expect(Directory(p.join(dir.path, 'site-packages', 'onnxruntime')).existsSync(), isTrue); + expect(Directory(p.join(dir.path, 'site-packages', 'numpy')).existsSync(), isTrue); + + final status = await _probe(); + expect(status.available, isTrue, reason: status.message); + expect(status.version, isNotNull); + expect(status.providers, contains('CPUExecutionProvider')); + final expected = switch (WorkerHarness.platform) { + 'macos-arm64' || 'macos-x64' => {'CoreMLExecutionProvider', 'CPUExecutionProvider'}, + 'windows-x64' => {'DmlExecutionProvider', 'CPUExecutionProvider'}, + _ => {'CPUExecutionProvider'}, + }; + expect(expected, contains(status.provider)); + if (WorkerHarness.platform.startsWith('macos')) { + expect(status.providers, contains('CoreMLExecutionProvider')); + } + }); + + test('VAPOURBOX_AI_FORCE_CPU runs it on the CPU', () async { + await WorkerHarness.ensureAddon('ai-runtime'); + final status = await _probe(env: {'VAPOURBOX_AI_FORCE_CPU': '1'}); + expect(status.available, isTrue, reason: status.message); + expect(status.provider, 'CPUExecutionProvider'); + }); +} diff --git a/app/test/manifest_addon_test.dart b/app/test/manifest_addon_test.dart new file mode 100644 index 00000000..9fa7aa88 --- /dev/null +++ b/app/test/manifest_addon_test.dart @@ -0,0 +1,322 @@ +// Manifest add-ons (assets/addons/*.json, ManifestAddonInstaller): checked +// without the network, through the mirror (VAPOURBOX_ADDON_MIRROR's code +// path) and a fake downloader. Every file is hash-checked before anything is +// unpacked, an install is all or nothing, a manifest version bump reinstalls, +// and the app and the worker see the same set of manifests. +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:vapourbox/services/addon_manager.dart'; +import 'package:vapourbox/services/addon_service.dart'; +import 'package:vapourbox/services/manifest_addon.dart'; + +String get _root => + Directory.current.path.endsWith('app') ? Directory.current.parent.path : Directory.current.path; + +/// Serves files from a map of url → bytes, checking the hash as AddonManager does. +class _Serve extends AddonManager { + final Map> bodies; + final List fetched = []; + _Serve(this.bodies); + @override + Future downloadFile(String url, File destination, + {int? expectedSize, String? expectedSha256, Map? extraHeaders}) async { + fetched.add(url); + final body = bodies[url]!; + if (expectedSha256 != null && sha256.convert(body).toString() != expectedSha256) { + throw StateError('SHA256 mismatch'); + } + await destination.writeAsBytes(body); + } +} + +List _zip(Map> files) { + final archive = Archive(); + for (final MapEntry(key: name, value: bytes) in files.entries) { + archive.addFile(ArchiveFile(name, bytes.length, bytes)); + } + return ZipEncoder().encode(archive)!; +} + +String _sha(List b) => sha256.convert(b).toString(); + +Map _entry(String name, List bytes, {List? platforms, Object? extract}) => { + 'name': name, + 'url': 'https://example.invalid/dl/$name', + 'size': bytes.length, + 'sha256': _sha(bytes), + if (platforms != null) 'platforms': platforms, + if (extract != null) 'extract': extract, + }; + +Map _manifest({ + String id = 'test-addon', + String version = '1.0.0', + String kind = 'files', + Object platforms = 'all', + required List> files, + List requires = const [], +}) => + { + 'id': id, + 'version': version, + 'name': 'Test add-on', + 'feature': 'Testing', + 'description': 'A test.', + 'licence': 'MIT', + 'credit': 'Tests', + 'noticeName': 'Test', + 'homepage': 'https://example.invalid', + 'requires': requires, + 'kind': kind, + 'platforms': platforms, + 'files': files, + }; + +void main() { + late Directory tmp; + late Directory addons; + setUp(() { + tmp = Directory.systemTemp.createTempSync('vb_manifest_addon_'); + addons = Directory(p.join(tmp.path, 'addons'))..createSync(); + }); + tearDown(() => tmp.deleteSync(recursive: true)); + + final model = utf8.encode('model weights'); + final other = utf8.encode('other model'); + + group('files add-on', () { + test('installs each file, hash-checked, behind a marker; removes cleanly', () async { + final m = AddonManifest.fromJson(_manifest(files: [_entry('a.onnx', model)])); + final server = _Serve({'https://example.invalid/dl/a.onnx': model}); + final service = AddonService.forTest( + addonsDir: addons, + installers: [ManifestAddonInstaller(m, 'linux-x64')], + downloader: server); + await service.load(); + expect(service.isInstalled('test-addon'), isFalse); + expect(service.info('test-addon')!.downloadBytes, model.length); + expect(await service.install('test-addon'), isTrue); + final dir = Directory(p.join(addons.path, 'test-addon')); + expect(File(p.join(dir.path, 'a.onnx')).readAsBytesSync(), model); + final marker = jsonDecode( + File(p.join(dir.path, ManifestAddonInstaller.markerName)).readAsStringSync()); + expect(marker, {'id': 'test-addon', 'version': '1.0.0', 'platform': 'linux-x64'}); + expect(service.isInstalled('test-addon'), isTrue); + expect(addons.listSync().map((e) => p.basename(e.path)).toSet(), {'test-addon', '.downloads'}); + expect(Directory(p.join(addons.path, '.downloads')).listSync(), isEmpty, + reason: 'downloads are deleted once installed'); + await service.remove('test-addon'); + expect(dir.existsSync(), isFalse); + }); + + test('only this platform\'s files are fetched', () async { + final m = AddonManifest.fromJson(_manifest( + platforms: {'linux-x64': {}, 'windows-x64': {}}, + files: [ + _entry('linux.onnx', model, platforms: ['linux-x64']), + _entry('windows.onnx', other, platforms: ['windows-x64']), + ])); + final server = _Serve({ + 'https://example.invalid/dl/linux.onnx': model, + 'https://example.invalid/dl/windows.onnx': other, + }); + final installer = ManifestAddonInstaller(m, 'windows-x64'); + expect(installer.info.downloadBytes, other.length); + await installer.install(addons, server); + expect(server.fetched, ['https://example.invalid/dl/windows.onnx']); + expect(installer.isInstalled(addons), isTrue); + expect(ManifestAddonInstaller(m, 'linux-x64').isInstalled(addons), isFalse, + reason: 'another platform\'s install is not this one\'s'); + }); + + test('an archive keeps only its listed members, each hash-checked', () async { + final zip = _zip({'pack/a.onnx': model, 'pack/b.pth': utf8.encode('checkpoint')}); + final m = AddonManifest.fromJson(_manifest(files: [ + _entry('pack.zip', zip, extract: [ + {'path': 'pack/a.onnx', 'sha256': _sha(model)}, + ]), + ])); + final installer = ManifestAddonInstaller(m, 'macos-arm64', osVersion: '15.0'); + await installer.install(addons, _Serve({'https://example.invalid/dl/pack.zip': zip})); + final dir = Directory(p.join(addons.path, 'test-addon')); + expect(dir.listSync().map((e) => p.basename(e.path)).toSet(), + {'a.onnx', ManifestAddonInstaller.markerName}); + expect(installer.isInstalled(addons), isTrue); + }); + + test('a wrong member hash installs nothing', () async { + final zip = _zip({'pack/a.onnx': utf8.encode('tampered')}); + final m = AddonManifest.fromJson(_manifest(files: [ + _entry('pack.zip', zip, extract: [ + {'path': 'pack/a.onnx', 'sha256': _sha(model)}, + ]), + ])); + final installer = ManifestAddonInstaller(m, 'linux-x64'); + await expectLater(installer.install(addons, _Serve({'https://example.invalid/dl/pack.zip': zip})), + throwsA(predicate((e) => '$e'.contains('does not match its expected hash')))); + expect(Directory(p.join(addons.path, 'test-addon')).existsSync(), isFalse); + expect(Directory(p.join(addons.path, 'test-addon.new')).existsSync(), isFalse); + expect(installer.isInstalled(addons), isFalse); + }); + + test('a version bump counts as missing, and reinstalling replaces the old install', () async { + final v1 = AddonManifest.fromJson(_manifest(files: [_entry('a.onnx', model)])); + final v2 = AddonManifest.fromJson(_manifest(version: '2.0.0', files: [_entry('b.onnx', other)])); + final server = _Serve({ + 'https://example.invalid/dl/a.onnx': model, + 'https://example.invalid/dl/b.onnx': other, + }); + await ManifestAddonInstaller(v1, 'linux-x64').install(addons, server); + final upgraded = ManifestAddonInstaller(v2, 'linux-x64'); + expect(upgraded.isInstalled(addons), isFalse); + await upgraded.install(addons, server); + expect(upgraded.isInstalled(addons), isTrue); + final dir = Directory(p.join(addons.path, 'test-addon')); + expect(File(p.join(dir.path, 'a.onnx')).existsSync(), isFalse, reason: 'replaced, not merged'); + expect(Directory(p.join(addons.path, 'test-addon.old')).existsSync(), isFalse); + }); + + test('the mirror replaces the download, and is still verified', () async { + final mirror = Directory(p.join(tmp.path, 'mirror'))..createSync(); + File(p.join(mirror.path, 'a.onnx')).writeAsBytesSync(model); + final m = AddonManifest.fromJson(_manifest(files: [_entry('a.onnx', model)])); + final server = _Serve({}); + final installer = ManifestAddonInstaller(m, 'linux-x64', mirror: mirror.path); + await installer.install(addons, server); + expect(server.fetched, isEmpty); + expect(installer.isInstalled(addons), isTrue); + + await installer.remove(addons); + File(p.join(mirror.path, 'a.onnx')).writeAsBytesSync(other); + await expectLater(installer.install(addons, server), + throwsA(predicate((e) => '$e'.contains('does not match the pinned size and hash')))); + expect(installer.isInstalled(addons), isFalse); + + File(p.join(mirror.path, 'a.onnx')).deleteSync(); + await expectLater(installer.install(addons, server), + throwsA(predicate((e) => '$e'.contains(addonMirrorEnv)))); + }); + }); + + group('python add-on', () { + test('unpacks each wheel into site-packages, as pip lays it out', () async { + final wheel = _zip({ + 'pkg/__init__.py': utf8.encode('x = 1'), + 'pkg-1.0.dist-info/METADATA': utf8.encode('Name: pkg'), + 'pkg-1.0.data/platlib/pkg_native.so': utf8.encode('elf'), + 'pkg-1.0.data/scripts/tool': utf8.encode('#!'), + }); + final m = AddonManifest.fromJson( + _manifest(kind: 'python', files: [_entry('pkg-1.0-cp312-cp312-any.whl', wheel)])); + final installer = ManifestAddonInstaller(m, 'linux-arm64'); + await installer.install( + addons, _Serve({'https://example.invalid/dl/pkg-1.0-cp312-cp312-any.whl': wheel})); + final site = Directory(p.join(addons.path, 'test-addon', 'site-packages')); + expect(File(p.join(site.path, 'pkg', '__init__.py')).readAsStringSync(), 'x = 1'); + expect(File(p.join(site.path, 'pkg_native.so')).existsSync(), isTrue); + expect(File(p.join(site.path, 'tool')).existsSync(), isFalse); + expect(installer.isInstalled(addons), isTrue); + }); + + test('a wheel entry escaping its directory is refused', () async { + final wheel = _zip({'../evil.py': utf8.encode('boom')}); + final m = AddonManifest.fromJson(_manifest(kind: 'python', files: [_entry('evil.whl', wheel)])); + await expectLater( + ManifestAddonInstaller(m, 'linux-x64') + .install(addons, _Serve({'https://example.invalid/dl/evil.whl': wheel})), + throwsStateError); + expect(File(p.join(addons.path, 'evil.py')).existsSync(), isFalse); + expect(Directory(p.join(addons.path, 'test-addon')).existsSync(), isFalse); + }); + }); + + group('availability', () { + final m = AddonManifest.fromJson(_manifest( + platforms: {'macos-x64': {'minOs': '13.0'}, 'linux-x64': {}}, + files: [_entry('a.onnx', model)])); + + test('a platform outside the table is unavailable, and refused', () async { + final installer = ManifestAddonInstaller(m, 'windows-x64'); + expect(installer.unavailableReason, contains('isn\'t available for Windows')); + final service = AddonService.forTest(addonsDir: addons, installers: [installer]); + await service.load(); + await expectLater(service.install('test-addon'), throwsStateError); + }); + + test('minOs is checked against the running macOS', () { + expect(ManifestAddonInstaller(m, 'macos-x64', osVersion: '12.7.6').unavailableReason, + contains('needs macOS 13.0 or later')); + expect(ManifestAddonInstaller(m, 'macos-x64', osVersion: '13.0').unavailableReason, isNull); + expect(ManifestAddonInstaller(m, 'macos-x64', osVersion: '15.1').unavailableReason, isNull); + expect(ManifestAddonInstaller.compareOsVersions('13.10', '13.9'), greaterThan(0)); + }); + }); + + group('validation', () { + Map good() => _manifest(files: [_entry('a.onnx', model)]); + void bad(void Function(Map) edit, String why) { + final json = good(); + edit(json); + expect(() => AddonManifest.fromJson(json), throwsFormatException, reason: why); + } + + test('malformed manifests are refused', () { + expect(AddonManifest.fromJson(good()).id, 'test-addon'); + bad((j) => j['kind'] = 'binary', 'unknown kind'); + bad((j) => j['id'] = 'Test Addon', 'id format'); + bad((j) => (j['files'] as List).first['sha256'] = 'abc', 'short hash'); + bad((j) => (j['files'] as List).first['url'] = 'http://example.invalid/a', 'plain http'); + bad((j) => (j['files'] as List).first['name'] = '../a.onnx', 'path in a name'); + bad((j) => j['platforms'] = {'windows-arm64': {}}, 'unknown platform'); + bad((j) { + j['platforms'] = {'linux-x64': {}, 'macos-arm64': {}}; + (j['files'] as List).first['platforms'] = ['linux-x64']; + }, 'a platform with no files'); + bad((j) => j['kind'] = 'python', 'a python add-on lists wheels only'); + }); + }); + + group('shipped manifests', () { + final dir = Directory(p.join(_root, 'app', 'assets', 'addons')); + final manifests = AddonManifest.loadDirectory(dir); + + test('all parse, one per id, named after it, requirements known', () { + expect(manifests, isNotEmpty); + final ids = manifests.map((m) => m.id).toSet(); + expect(ids.length, manifests.length); + for (final m in manifests) { + expect(File(p.join(dir.path, '${m.id}.json')).existsSync(), isTrue, reason: m.id); + for (final r in m.requires) { + expect(ids, contains(r), reason: '${m.id} requires unknown $r'); + } + // Not the ids the hand-written installers use. + expect({'whisper', 'animejanai'}, isNot(contains(m.id))); + expect(m.id.startsWith('whisper-model-'), isFalse); + } + }); + + test('pubspec ships the directory, so AddonService finds every one', () { + final pubspec = File(p.join(_root, 'app', 'pubspec.yaml')).readAsStringSync(); + expect(pubspec, contains('- assets/addons/')); + }); + + test('the worker compiled in the same manifests, at the same versions', () async { + final ext = Platform.isWindows ? '.exe' : ''; + final worker = [ + p.join(_root, 'worker', 'target', 'debug', 'vapourbox-worker$ext'), + p.join(_root, 'worker', 'target', 'release', 'vapourbox-worker$ext'), + ].firstWhere((w) => File(w).existsSync(), + orElse: () => fail('Build the worker first: (cd worker && cargo build)')); + final result = await Process.run(worker, ['--list-addons']); + expect(result.exitCode, 0, reason: '${result.stderr}'); + final listed = jsonDecode((result.stdout as String).trim()) as Map; + expect(listed, {for (final m in manifests) m.id: m.version}, + reason: 'a stale worker binary? rebuild it (cd worker && cargo build)'); + }); + }); +} diff --git a/app/test/support/worker_harness.dart b/app/test/support/worker_harness.dart index 138f9110..61364128 100644 --- a/app/test/support/worker_harness.dart +++ b/app/test/support/worker_harness.dart @@ -30,7 +30,9 @@ import 'package:path/path.dart' as p; // importing this file does NOT initialize the app's rhttp HTTP client, which is // why the harness does its own dart:io download below (rhttp's native bindings // don't load under the headless `flutter test` VM). +import 'package:vapourbox/services/addon_manager.dart'; import 'package:vapourbox/services/addon_service.dart'; +import 'package:vapourbox/services/manifest_addon.dart'; import 'package:vapourbox/services/dependency_manager.dart' show DepsVersionInfo, DependencyManager; @@ -139,6 +141,60 @@ class WorkerHarness { return target; } + /// The add-ons directory the debug worker reads: `$VAPOURBOX_ADDONS_DIR`, + /// else the nearest `addons/` above the repo root (as the worker searches + /// upward from its binary — a git worktree inside the main checkout shares + /// the checkout's), else `/addons`, created. + static Directory get addonsDirectory { + final found = _addonsDir; + if (found != null) return Directory(found); + var dir = Directory(repoRoot).parent; + for (var i = 0; i < 8; i++) { + final candidate = Directory(p.join(dir.path, 'addons')); + if (candidate.existsSync()) return candidate; + if (dir.parent.path == dir.path) break; + dir = dir.parent; + } + return Directory(p.join(repoRoot, 'addons'))..createSync(recursive: true); + } + + /// Install the manifest add-on [id] (`app/assets/addons/.json`) and what + /// it requires where the worker finds it, with the app's own + /// [ManifestAddonInstaller] — so a test of an add-on also tests that its + /// pinned URLs and hashes still hold. Honours `VAPOURBOX_ADDON_MIRROR` + /// (files taken from a local directory instead of downloaded, still + /// verified), which is how an add-on is tested before its files are + /// uploaded. No-op for what is already installed at this version. + static Future ensureAddon(String id) async { + final manifests = { + for (final m in AddonManifest.loadDirectory( + Directory(p.join(repoRoot, 'app', 'assets', 'addons')))) + m.id: m, + }; + final addons = addonsDirectory; + final order = []; + void visit(String x) { + final m = manifests[x]; + if (m == null) throw ArgumentError('No add-on manifest for "$x"'); + m.requires.forEach(visit); + if (!order.contains(x)) order.add(x); + } + + visit(id); + for (final x in order) { + final installer = ManifestAddonInstaller(manifests[x]!, platform); + if (installer.isInstalled(addons)) continue; + final mirror = installer.mirror; + stderr.writeln('[harness] installing add-on $x into ${addons.path}' + '${mirror != null ? ' from mirror $mirror' : ''}'); + await installer.install(addons, _IoDownloader()); + if (!installer.isInstalled(addons)) { + throw StateError('Add-on $x did not install completely'); + } + } + return Directory(p.join(addons.path, id)); + } + /// Whether the whisper add-on (whisper-cli) is available. Subtitle tests that /// run the full pipeline must skip when this is false (local dev w/o add-on). static bool get whisperAvailable { @@ -1053,3 +1109,53 @@ class PreviewResult { @override String toString() => success ? 'SUCCESS (${png!.length} bytes)' : 'FAILED: $error\n$errorTail'; } + +/// [AddonManager] over dart:io, for [WorkerHarness.ensureAddon]: the app's +/// downloader uses rhttp, whose native library does not load under the +/// headless `flutter test` VM. Streams to the file (models run to hundreds of +/// MB) and checks size and hash as the app's does. +class _IoDownloader extends AddonManager { + @override + Future downloadFile(String url, File destination, + {int? expectedSize, String? expectedSha256, Map? extraHeaders}) async { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 30); + final partial = File('${destination.path}.partial'); + try { + var current = Uri.parse(url); + for (var redirects = 0;; redirects++) { + if (redirects > 6) throw StateError('too many redirects for $url'); + final request = await client.getUrl(current); + request.followRedirects = false; + extraHeaders?.forEach(request.headers.set); + final response = await request.close(); + if (response.isRedirect) { + final loc = response.headers.value(HttpHeaders.locationHeader); + await response.drain(); + if (loc == null) throw StateError('redirect without Location ($current)'); + current = current.resolve(loc); + continue; + } + if (response.statusCode != 200) { + await response.drain(); + throw HttpException('HTTP ${response.statusCode} for $current'); + } + await response.pipe(partial.openWrite()); + break; + } + if (expectedSize != null && await partial.length() != expectedSize) { + throw StateError('$url: got ${await partial.length()} bytes, expected $expectedSize'); + } + if (expectedSha256 != null) { + final digest = (await sha256.bind(partial.openRead()).first).toString(); + if (digest != expectedSha256) { + throw StateError('$url: SHA256 mismatch, expected $expectedSha256, got $digest'); + } + } + if (await destination.exists()) await destination.delete(); + await partial.rename(destination.path); + } finally { + client.close(force: true); + if (await partial.exists()) await partial.delete(); + } + } +} diff --git a/licenses/NOTICES.txt b/licenses/NOTICES.txt index fb412757..8b239e64 100644 --- a/licenses/NOTICES.txt +++ b/licenses/NOTICES.txt @@ -50,6 +50,35 @@ Flutter — BSD 3-Clause https://github.com/flutter/flutter The UI toolkit the application is written in. +ONNX Runtime — MIT + Copyright (c) Microsoft Corporation + https://github.com/microsoft/onnxruntime + Runs AI models for the features that need it. NOT distributed with + VapourBox: part of the optional AI runtime add-on, which the app downloads + as the project's own unmodified Python wheels from PyPI (onnxruntime; on + Windows onnxruntime-directml) only when the user asks (Settings → Add-ons). + ONNX Runtime's ThirdPartyNotices.txt, inside each wheel, lists the + open-source components it includes. + +NumPy — BSD-3-Clause + Copyright (c) 2005-2025, NumPy Developers. + https://github.com/numpy/numpy + The array library ONNX Runtime's Python interface uses. NOT distributed + with VapourBox: part of the optional AI runtime add-on, downloaded as the + project's own unmodified wheels from PyPI. Some platforms' wheels bundle + further libraries (OpenBLAS, LAPACK, the GCC runtime libraries), each under + the licence given in the LICENSE.txt inside the wheel. + +DirectML — Microsoft Software License Terms (DirectML redistributable) + © Microsoft Corporation. All rights reserved. + https://github.com/microsoft/DirectML + Licence: https://www.nuget.org/packages/Microsoft.AI.DirectML/1.15.4/License + The DirectX Machine Learning library (DirectML.dll) that lets ONNX Runtime + use any DirectX 12 graphics card. Windows only, and NOT distributed with + VapourBox: it comes inside the onnxruntime-directml wheel that the optional + AI runtime add-on downloads from PyPI when the user asks. Proprietary; + Microsoft permits its redistribution in applications for Windows. + ------------------------------------------------------------------------------- 2. VapourSynth Python modules @@ -534,8 +563,8 @@ MIT License ----------- (Applies to zsmooth, vszip and the zigimg library it includes, CAS, DCTFilter, BM3D, descale, the RIFE plugin, rife-ncnn-vulkan, the RIFE model and -ECCV2022-RIFE, the Real-CUGAN models, whisper.cpp, the Whisper models, pip and -the Clense filter +ECCV2022-RIFE, the Real-CUGAN models, whisper.cpp, the Whisper models, ONNX +Runtime, pip and the Clense filter in vs-removegrain, each under the copyright notice given in its entry above.) @@ -596,8 +625,8 @@ DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE, Version 2 BSD 3-Clause License -------------------- -(Applies to Flutter, Zstandard, ncnn, the core of glslang, Protocol Buffers -and the Real-ESRGAN model, under the copyright notices given above.) +(Applies to Flutter, Zstandard, ncnn, the core of glslang, Protocol Buffers, +the Real-ESRGAN model and NumPy, under the copyright notices given above.) Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/worker/build.rs b/worker/build.rs new file mode 100644 index 00000000..520e1130 --- /dev/null +++ b/worker/build.rs @@ -0,0 +1,40 @@ +//! Compiles the add-on manifests (`app/assets/addons/*.json`) into the worker, +//! so the app and the worker read the same files: one source of truth for +//! what each add-on is and what an install of it holds (see `src/addons.rs`). + +use std::env; +use std::fs; +use std::path::PathBuf; + +fn main() { + let crate_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let dir = crate_dir.join("..").join("app").join("assets").join("addons"); + // A directory: Cargo rescans it, so adding a manifest rebuilds too. + println!("cargo:rerun-if-changed={}", dir.display()); + + // Git keeps no empty directory, so none at all means no manifests. + let mut manifests: Vec = fs::read_dir(&dir) + .into_iter() + .flatten() + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|x| x == "json")) + .collect(); + manifests.sort(); + + let mut out = String::from( + "/// Every add-on manifest, as (file name, contents). Generated by build.rs.\n\ + pub(crate) const MANIFESTS: &[(&str, &str)] = &[\n", + ); + for path in &manifests { + println!("cargo:rerun-if-changed={}", path.display()); + let name = path.file_name().unwrap().to_string_lossy().to_string(); + let abs = fs::canonicalize(path).unwrap(); + out.push_str(&format!( + " ({name:?}, include_str!({:?})),\n", + abs.to_string_lossy() + )); + } + out.push_str("];\n"); + let dest = PathBuf::from(env::var("OUT_DIR").unwrap()).join("addon_manifests.rs"); + fs::write(dest, out).unwrap(); +} diff --git a/worker/src/addons.rs b/worker/src/addons.rs new file mode 100644 index 00000000..2f25ad59 --- /dev/null +++ b/worker/src/addons.rs @@ -0,0 +1,400 @@ +//! Manifest-described add-ons: optional third-party downloads (AI models, the +//! AI runtime) that VapourBox can use but never ships in the deps bundle. +//! +//! Each is described by one JSON file in `app/assets/addons/`, which the app +//! installs from (`ManifestAddonInstaller`, `app/lib/services/manifest_addon.dart`) +//! and which `build.rs` compiles in here, so both sides read the same +//! description (docs/ADDONS.md has the format). The app downloads, verifies +//! and unpacks an add-on into `//`, writing [`MARKER`] last; this +//! module only answers whether that happened for this platform and version, +//! and refuses a job that needs an add-on that isn't there, with the same kind +//! of message as `animejanai::MISSING_MESSAGE`. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use serde::Deserialize; + +include!(concat!(env!("OUT_DIR"), "/addon_manifests.rs")); + +/// The commit marker an install ends with: `{id, version, platform}`. +pub const MARKER: &str = ".vapourbox-addon.json"; + +/// The AI runtime: ONNX Runtime and NumPy for the job's Python. +pub const AI_RUNTIME: &str = "ai-runtime"; + +/// Where in the add-ons UI to install them — named in every refusal. +const WHERE: &str = "Settings → Add-ons"; + +/// One file an add-on installs (the fields the worker needs). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ManifestFile { + pub name: String, + #[serde(default)] + pub platforms: Option>, + #[serde(default)] + pub extract: Option>, +} + +impl ManifestFile { + fn is_for(&self, platform: &str) -> bool { + self.platforms.as_ref().map_or(true, |p| p.iter().any(|x| x == platform)) + } +} + +/// A member kept from an archive entry. +#[derive(Debug, Clone, Deserialize)] +pub struct ArchiveMember { + pub path: String, + #[serde(default)] + pub name: Option, +} + +impl ArchiveMember { + fn installed_name(&self) -> &str { + self.name.as_deref().unwrap_or_else(|| self.path.rsplit('/').next().unwrap_or(&self.path)) + } +} + +/// `"all"` or a table of platform id → requirements. +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +enum Platforms { + All(String), + Table(BTreeMap), +} + +/// An add-on manifest (the fields the worker needs). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Manifest { + pub id: String, + pub version: String, + pub name: String, + pub feature: String, + pub kind: String, + #[serde(default)] + pub requires: Vec, + platforms: Platforms, + pub files: Vec, +} + +/// Every platform id add-ons are described for. +pub const PLATFORMS: &[&str] = &["macos-arm64", "macos-x64", "windows-x64", "linux-x64", "linux-arm64"]; + +impl Manifest { + pub fn supports(&self, platform: &str) -> bool { + match &self.platforms { + Platforms::All(s) => s == "all" && PLATFORMS.contains(&platform), + Platforms::Table(t) => t.contains_key(platform), + } + } + + /// The paths, relative to the add-on's directory, that a complete install + /// on `platform` holds: a python add-on's `site-packages`, or a model + /// pack's files (an archive's kept members by their installed names). + pub fn expected_paths(&self, platform: &str) -> Vec { + if self.kind == "python" { + return vec!["site-packages".to_string()]; + } + let mut out = Vec::new(); + for f in self.files.iter().filter(|f| f.is_for(platform)) { + match &f.extract { + None => out.push(f.name.clone()), + Some(members) => out.extend(members.iter().map(|m| m.installed_name().to_string())), + } + } + out + } +} + +/// Every compiled-in manifest, sorted by id. +pub fn manifests() -> &'static [Manifest] { + static ALL: OnceLock> = OnceLock::new(); + ALL.get_or_init(|| { + let mut v: Vec = MANIFESTS + .iter() + .map(|(file, json)| { + serde_json::from_str(json) + .unwrap_or_else(|e| panic!("add-on manifest {file} does not parse: {e}")) + }) + .collect(); + v.sort_by(|a, b| a.id.cmp(&b.id)); + v + }) +} + +pub fn manifest(id: &str) -> Option<&'static Manifest> { + manifests().iter().find(|m| m.id == id) +} + +/// The add-on's directory under the add-ons directory. +pub fn addon_dir(addons_root: &Path, id: &str) -> PathBuf { + addons_root.join(id) +} + +/// A python add-on's packages directory, which the script puts on `sys.path`. +pub fn site_packages(addons_root: &Path, id: &str) -> PathBuf { + addon_dir(addons_root, id).join("site-packages") +} + +/// What is installed of one add-on. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AddonState { + Installed, + /// Nothing, or an incomplete install. + Missing, + /// An install of another version (the manifest was updated since). + Outdated { installed: String }, + /// No build of it for this platform. + Unsupported, + /// No manifest by that id: a bug, not a user problem. + Unknown, +} + +/// Whether `id` is installed for `platform` in `addons_root`. +pub fn state(addons_root: &Path, id: &str, platform: &str) -> AddonState { + let Some(m) = manifest(id) else { return AddonState::Unknown }; + if !m.supports(platform) { + return AddonState::Unsupported; + } + let dir = addon_dir(addons_root, id); + let marker: Option = std::fs::read_to_string(dir.join(MARKER)) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()); + let Some(marker) = marker else { return AddonState::Missing }; + if marker.get("id").and_then(|v| v.as_str()) != Some(id) + || marker.get("platform").and_then(|v| v.as_str()) != Some(platform) + { + return AddonState::Missing; + } + let version = marker.get("version").and_then(|v| v.as_str()).unwrap_or(""); + if version != m.version { + return AddonState::Outdated { installed: version.to_string() }; + } + if m.expected_paths(platform).iter().all(|p| dir.join(p).exists()) { + AddonState::Installed + } else { + AddonState::Missing + } +} + +pub fn is_installed(addons_root: &Path, id: &str, platform: &str) -> bool { + state(addons_root, id, platform) == AddonState::Installed +} + +/// The add-ons one feature of a job needs: what `ProcessingPipeline::required_addons` +/// returns, and what `ScriptGenerator` checks before writing a script. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AddonNeed { + /// The feature as the user knows it, starting the refusal message. + pub feature: &'static str, + /// The add-on ids it uses (their own requirements are added automatically). + pub ids: Vec<&'static str>, +} + +/// `ids` and everything they require, requirements first, each once. +pub fn with_requirements(ids: &[&str]) -> Vec { + fn visit(id: &str, out: &mut Vec) { + if out.iter().any(|x| x == id) { + return; + } + if let Some(m) = manifest(id) { + for r in &m.requires { + visit(r, out); + } + } + out.push(id.to_string()); + } + let mut out = Vec::new(); + for id in ids { + visit(id, &mut out); + } + out +} + +/// Whether a job needing `ids` needs the AI runtime (directly or through a +/// model add-on that requires it) — what decides the script's `AI_RUNTIME` +/// block. +pub fn needs_ai_runtime(ids: &[&str]) -> bool { + with_requirements(ids).iter().any(|i| i == AI_RUNTIME) +} + +/// Refuse, with a message the user can act on, unless every add-on in `ids` +/// (and everything they require) is installed. `feature` is what the user +/// chose ("Face restoration"), at the start of the message. +pub fn require(addons_root: &Path, ids: &[&str], feature: &str, platform: &str) -> anyhow::Result<()> { + match missing_message(addons_root, ids, feature, platform) { + None => Ok(()), + Some(message) => anyhow::bail!("{message}"), + } +} + +/// The refusal [`require`] gives, or `None` when everything is installed. +pub fn missing_message(addons_root: &Path, ids: &[&str], feature: &str, platform: &str) -> Option { + let mut missing = Vec::new(); + let mut outdated = Vec::new(); + for id in with_requirements(ids) { + let name = manifest(&id).map(|m| m.name.clone()).unwrap_or_else(|| id.clone()); + match state(addons_root, &id, platform) { + AddonState::Installed => {} + AddonState::Unknown => { + return Some(format!("{feature} needs an add-on this version of VapourBox doesn't know (\"{id}\").")) + } + AddonState::Unsupported => { + return Some(format!( + "{feature} needs the {name} add-on, which isn't available on this platform." + )) + } + AddonState::Missing => missing.push(name), + AddonState::Outdated { installed } => outdated.push((name, installed)), + } + } + if missing.is_empty() && outdated.is_empty() { + return None; + } + let mut parts = Vec::new(); + match missing.len() { + 0 => {} + 1 => parts.push(format!( + "{feature} needs the {} add-on, which is not installed. It is a separate download: \ + install it in {WHERE}, or choose {feature} again to be offered it.", + missing[0] + )), + _ => parts.push(format!( + "{feature} needs these add-ons, which are not installed: {}. They are separate \ + downloads: install them in {WHERE}, or choose {feature} again to be offered them.", + missing.join(", ") + )), + } + for (name, installed) in outdated { + parts.push(format!( + "The installed {name} add-on (version {installed}) is out of date for this version \ + of VapourBox. Install it again in {WHERE}." + )); + } + Some(parts.join(" ")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("vb-addons-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + fn install(root: &Path, id: &str, version: &str, platform: &str) { + let m = manifest(id).unwrap(); + let dir = addon_dir(root, id); + std::fs::create_dir_all(&dir).unwrap(); + for p in m.expected_paths(platform) { + if m.kind == "python" { + std::fs::create_dir_all(dir.join(p)).unwrap(); + } else { + std::fs::write(dir.join(p), b"x").unwrap(); + } + } + std::fs::write( + dir.join(MARKER), + serde_json::json!({"id": id, "version": version, "platform": platform}).to_string(), + ) + .unwrap(); + } + + /// The compiled-in set is exactly the directory's: build.rs found them all. + #[test] + fn compiled_manifests_match_the_app_directory() { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../app/assets/addons"); + let mut on_disk: Vec = std::fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .filter(|n| n.ends_with(".json")) + .collect(); + on_disk.sort(); + let compiled: Vec = MANIFESTS.iter().map(|(n, _)| n.to_string()).collect(); + assert_eq!(compiled, on_disk); + for m in manifests() { + // One file per add-on, named after it. + assert!(on_disk.contains(&format!("{}.json", m.id)), "{}", m.id); + assert!(matches!(m.kind.as_str(), "files" | "python"), "{}", m.id); + for r in &m.requires { + assert!(manifest(r).is_some(), "{} requires unknown {r}", m.id); + } + for p in PLATFORMS { + if m.supports(p) { + assert!(!m.expected_paths(p).is_empty(), "{} has nothing for {p}", m.id); + } + } + } + assert!(manifest(AI_RUNTIME).is_some()); + } + + #[test] + fn ai_runtime_is_python_on_every_platform() { + let m = manifest(AI_RUNTIME).unwrap(); + assert_eq!(m.kind, "python"); + for p in PLATFORMS { + assert!(m.supports(p), "{p}"); + assert_eq!(m.expected_paths(p), vec!["site-packages".to_string()]); + assert_eq!(m.files.iter().filter(|f| f.is_for(p)).count(), 2, "onnxruntime + numpy on {p}"); + } + } + + #[test] + fn installed_needs_the_marker_for_this_version_and_platform() { + let root = tmp("state"); + let v = manifest(AI_RUNTIME).unwrap().version.clone(); + assert_eq!(state(&root, AI_RUNTIME, "linux-x64"), AddonState::Missing); + // Files without the marker: an interrupted install. + std::fs::create_dir_all(site_packages(&root, AI_RUNTIME)).unwrap(); + assert_eq!(state(&root, AI_RUNTIME, "linux-x64"), AddonState::Missing); + install(&root, AI_RUNTIME, &v, "linux-x64"); + assert!(is_installed(&root, AI_RUNTIME, "linux-x64")); + // Another platform's install (a copied add-ons directory) is not this one's. + assert_eq!(state(&root, AI_RUNTIME, "linux-arm64"), AddonState::Missing); + install(&root, AI_RUNTIME, "0.0.1", "linux-x64"); + assert_eq!( + state(&root, AI_RUNTIME, "linux-x64"), + AddonState::Outdated { installed: "0.0.1".into() } + ); + assert_eq!(state(&root, "no-such-addon", "linux-x64"), AddonState::Unknown); + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn require_names_the_addon_and_where_to_get_it() { + let root = tmp("require"); + let err = require(&root, &[AI_RUNTIME], "Face restoration", "macos-arm64").unwrap_err().to_string(); + assert!(err.starts_with("Face restoration needs the AI runtime (ONNX Runtime) add-on"), "{err}"); + assert!(err.contains("Settings → Add-ons"), "{err}"); + let v = manifest(AI_RUNTIME).unwrap().version.clone(); + install(&root, AI_RUNTIME, &v, "macos-arm64"); + require(&root, &[AI_RUNTIME], "Face restoration", "macos-arm64").unwrap(); + require(&root, &[], "Nothing", "macos-arm64").unwrap(); + install(&root, AI_RUNTIME, "0.0.1", "macos-arm64"); + let err = require(&root, &[AI_RUNTIME], "Face restoration", "macos-arm64").unwrap_err().to_string(); + assert!(err.contains("out of date") && err.contains("0.0.1"), "{err}"); + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn requirements_come_first() { + assert_eq!(with_requirements(&[AI_RUNTIME, AI_RUNTIME]), vec![AI_RUNTIME.to_string()]); + assert!(needs_ai_runtime(&[AI_RUNTIME])); + assert!(!needs_ai_runtime(&[])); + for m in manifests() { + let order = with_requirements(&[m.id.as_str()]); + assert_eq!(order.last(), Some(&m.id)); + for r in &m.requires { + assert!(order.iter().position(|x| x == r) < order.iter().position(|x| x == &m.id)); + } + } + } +} diff --git a/worker/src/ai_runtime.rs b/worker/src/ai_runtime.rs new file mode 100644 index 00000000..c8b20995 --- /dev/null +++ b/worker/src/ai_runtime.rs @@ -0,0 +1,383 @@ +//! The AI runtime add-on (ONNX Runtime + NumPy, `app/assets/addons/ai-runtime.json`): +//! whether it runs here, and on which execution provider. +//! +//! Like the GPU plugins (`gpu_plugin.rs`), this is found out by running it +//! once in a throwaway vspipe rather than by a job: the probe script imports +//! ONNX Runtime from the add-on through `templates/ai_runtime.py` — exactly +//! the path a job's `{{#AI_RUNTIME}}` block takes — creates a session for the +//! committed self-test model on the preferred provider, runs it directly and +//! through a VapourSynth frame, and reports the result as JSON on stderr. The +//! outcome becomes an [`AiRuntimeStatus`] with a message the app shows +//! verbatim (`--probe-ai-runtime`). Only a positive result is cached, for the +//! boot, as for the GPU plugins. + +use std::collections::HashMap; +use std::path::Path; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::addons::{self, AddonState, AI_RUNTIME}; +use crate::gpu_plugin::{py_str, run_vspipe_probe, ProbeExit}; + +/// Session creation can compile the model for the GPU (Core ML, DirectML); +/// a driver that hangs must not hang the app. +const PROBE_TIMEOUT: Duration = Duration::from_secs(90); + +/// Stage marker the probe prints to stderr, so a silent crash says where. +const MARKER: &str = "AI_RUNTIME_PROBE:"; +/// Prefix of the line carrying the self-test's JSON result. +const RESULT: &str = "AI_RUNTIME_RESULT:"; + +/// Why the AI runtime cannot run here. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AiRuntimeReason { + /// The add-on has no build for this platform (or this macOS is too old — + /// the app decides that one before offering it). + UnsupportedPlatform, + /// The add-on is not installed. + NotInstalled, + /// An install of an older manifest version. + Outdated, + /// A system library onnxruntime needs is missing (Windows: the VC++ runtime). + MissingLibrary, + /// `import onnxruntime` failed for another reason. + ImportFailed, + /// The self-test model could not be run, or computed the wrong answer. + SessionFailed, + /// An instruction this CPU lacks. + UnsupportedCpu, + /// The probe process crashed. + Crashed, + /// The probe could not be run or did not finish. + ProbeFailed, +} + +/// Whether the AI runtime runs here, and how. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiRuntimeStatus { + pub available: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, + /// onnxruntime's version. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + /// Every execution provider the installed onnxruntime offers. + #[serde(default)] + pub providers: Vec, + /// The provider the self-test session ran on (its first registered one). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// Why the preferred GPU provider was not used, when it fell back to the CPU. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fallback: Option, +} + +impl AiRuntimeStatus { + pub fn unavailable(reason: AiRuntimeReason, detail: impl Into>) -> Self { + Self { + available: false, + reason: Some(reason), + detail: detail.into(), + version: None, + providers: Vec::new(), + provider: None, + fallback: None, + } + } + + /// What to tell the user, remedy included; `None` when available. The app + /// shows this verbatim. + pub fn message(&self, platform: &str) -> Option { + use AiRuntimeReason as R; + let reason = self.reason?; + let name = addons::manifest(AI_RUNTIME).map(|m| m.name.as_str()).unwrap_or("The AI runtime"); + let text = match reason { + R::UnsupportedPlatform => format!("The {name} add-on isn't available on this platform."), + R::NotInstalled => format!( + "The {name} add-on is not installed. It is a separate download: install it in \ + Settings → Add-ons." + ), + R::Outdated => format!( + "The installed {name} add-on is out of date for this version of VapourBox. \ + Install it again in Settings → Add-ons." + ), + R::MissingLibrary => { + if platform.starts_with("windows") { + "ONNX Runtime needs the Microsoft Visual C++ Redistributable (x64), which \ + isn't installed. Install the latest version from Microsoft (search for \ + \"Visual C++ Redistributable latest supported downloads\"), then restart \ + VapourBox." + .to_string() + } else { + "ONNX Runtime needs a system library that isn't installed; the details below \ + name it. Install it with your package manager, then restart VapourBox." + .to_string() + } + } + R::ImportFailed => format!( + "The {name} add-on is installed but would not load. Remove it and install it \ + again in Settings → Add-ons; if this persists, please report it with the \ + details below." + ), + R::SessionFailed => format!( + "The {name} add-on loaded but could not run its test model. Updating your \ + graphics driver may help; if this persists, please report it with the details \ + below." + ), + R::UnsupportedCpu => "ONNX Runtime needs a newer processor than this computer has \ + (it uses instructions this CPU lacks)." + .to_string(), + R::Crashed => format!( + "The {name} add-on crashed while testing itself. Updating your graphics driver \ + usually fixes this; restart VapourBox afterwards." + ), + R::ProbeFailed => format!( + "VapourBox couldn't check whether the {name} add-on runs on this computer. \ + Restart VapourBox to try again." + ), + }; + Some(match &self.detail { + Some(d) if !d.is_empty() => format!("{text}\n\nDetails: {d}"), + _ => text, + }) + } + + /// The JSON `--probe-ai-runtime` prints, message included. + pub fn to_json(&self, platform: &str) -> serde_json::Value { + let mut v = serde_json::to_value(self).unwrap_or_default(); + if let (Some(msg), Some(obj)) = (self.message(platform), v.as_object_mut()) { + obj.insert("message".into(), serde_json::Value::String(msg)); + } + v + } +} + +/// What the add-on files alone decide. `None`: installed — run the probe. +pub fn precheck(addons_root: &Path, platform: &str) -> Option { + use AiRuntimeReason as R; + match addons::state(addons_root, AI_RUNTIME, platform) { + AddonState::Installed => None, + AddonState::Unsupported | AddonState::Unknown => { + Some(AiRuntimeStatus::unavailable(R::UnsupportedPlatform, None)) + } + AddonState::Missing => Some(AiRuntimeStatus::unavailable(R::NotInstalled, None)), + AddonState::Outdated { installed } => Some(AiRuntimeStatus::unavailable( + R::Outdated, + format!("installed version {installed}"), + )), + } +} + +/// The probe script: the same import path a job takes, then the self-tests. +pub fn probe_script(templates_dir: &Path, addon_dir: &Path, force_cpu: bool) -> String { + format!( + "import sys, json\n\ + def stage(s):\n print('{MARKER}' + s, file=sys.stderr, flush=True)\n\ + stage('start')\n\ + sys.path.insert(0, {templates})\n\ + import ai_runtime\n\ + stage('setup')\n\ + ai_runtime.setup({addon})\n\ + stage('import')\n\ + ai_runtime.ort()\n\ + stage('session')\n\ + result = ai_runtime.self_test(force_cpu={force})\n\ + stage('frames')\n\ + result['frame'] = ai_runtime.frame_test(force_cpu={force})\n\ + print('{RESULT}' + json.dumps(result), file=sys.stderr, flush=True)\n\ + import vapoursynth as vs\n\ + vs.core.std.BlankClip(width=16, height=16, length=1).set_output()\n", + templates = py_str(templates_dir), + addon = py_str(addon_dir), + force = if force_cpu { "True" } else { "False" }, + ) +} + +/// Turn a finished probe into a status. Pure, so every branch is testable. +pub fn classify(exit: &ProbeExit, stderr: &str) -> AiRuntimeStatus { + use AiRuntimeReason as R; + let result = stderr + .lines() + .filter_map(|l| l.trim().strip_prefix(RESULT)) + .last() + .and_then(|j| serde_json::from_str::(j).ok()); + if exit.success { + if let Some(r) = result { + let s = |k: &str| r.get(k).and_then(|v| v.as_str()).map(str::to_string); + return AiRuntimeStatus { + available: true, + reason: None, + detail: None, + version: s("version"), + providers: r + .get("providers") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()) + .unwrap_or_default(), + provider: s("provider"), + fallback: s("fallback"), + }; + } + return AiRuntimeStatus::unavailable(R::ProbeFailed, "the self-test reported nothing".to_string()); + } + if exit.timed_out { + return AiRuntimeStatus::unavailable( + R::ProbeFailed, + format!("the test did not finish within {}s", PROBE_TIMEOUT.as_secs()), + ); + } + if exit.status.starts_with("signal 4 ") || exit.status.contains("illegal instruction") { + return AiRuntimeStatus::unavailable(R::UnsupportedCpu, exit.status.clone()); + } + let last = stderr.lines().filter_map(|l| l.trim().strip_prefix(MARKER)).last(); + let error = last_error_line(stderr); + let or_status = |e: Option| e.or_else(|| Some(format!("vspipe exited with {}", exit.status))); + match last { + Some("setup") if stderr.contains("is not installed") => { + AiRuntimeStatus::unavailable(R::NotInstalled, error) + } + Some("import") | Some("setup") + if stderr.contains("DLL load failed") || stderr.contains("cannot open shared object") => + { + AiRuntimeStatus::unavailable(R::MissingLibrary, or_status(error)) + } + Some("import") | Some("setup") if error.is_some() => AiRuntimeStatus::unavailable(R::ImportFailed, error), + Some("session") | Some("frames") if error.is_some() => { + AiRuntimeStatus::unavailable(R::SessionFailed, error) + } + Some("import") | Some("session") | Some("frames") => { + AiRuntimeStatus::unavailable(R::Crashed, or_status(error)) + } + _ => AiRuntimeStatus::unavailable(R::ProbeFailed, or_status(error)), + } +} + +/// The last line of a Python error, which names the actual failure. +fn last_error_line(stderr: &str) -> Option { + stderr + .lines() + .map(str::trim) + .filter(|l| !l.is_empty() && !l.starts_with(MARKER) && !l.starts_with(RESULT)) + .filter(|l| l.contains("Error") || l.contains("error") || l.contains("Exception")) + .last() + .map(|l| l.chars().take(400).collect()) +} + +/// Run the probe through vspipe and classify it. +pub fn run_probe( + vspipe: &Path, + env: &HashMap, + templates_dir: &Path, + addon_dir: &Path, + force_cpu: bool, +) -> AiRuntimeStatus { + let tmp = std::env::temp_dir().join(format!("vapourbox_ai_runtime_probe_{}.vpy", std::process::id())); + if let Err(e) = std::fs::write(&tmp, probe_script(templates_dir, addon_dir, force_cpu)) { + return AiRuntimeStatus::unavailable( + AiRuntimeReason::ProbeFailed, + format!("could not write the probe script: {e}"), + ); + } + let outcome = run_vspipe_probe(vspipe, &tmp, env, &["-s", "0", "-e", "0"], PROBE_TIMEOUT); + let _ = std::fs::remove_file(&tmp); + match outcome { + Ok((exit, stderr)) => classify(&exit, &stderr), + Err(e) => AiRuntimeStatus::unavailable(AiRuntimeReason::ProbeFailed, format!("could not run vspipe: {e}")), + } +} + +/// `VAPOURBOX_AI_FORCE_CPU=1`: run on the CPU only (also read by ai_runtime.py). +pub fn force_cpu_requested() -> bool { + std::env::var("VAPOURBOX_AI_FORCE_CPU") + .map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes")) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use AiRuntimeReason as R; + + fn exit(success: bool, status: &str) -> ProbeExit { + ProbeExit { success, timed_out: false, status: status.into() } + } + + #[test] + fn a_successful_probe_reports_its_provider() { + let stderr = "AI_RUNTIME_PROBE:start\nAI_RUNTIME_PROBE:session\nAI_RUNTIME_PROBE:frames\n\ + AI_RUNTIME_RESULT:{\"version\": \"1.31.1\", \"providers\": [\"CoreMLExecutionProvider\", \ + \"CPUExecutionProvider\"], \"provider\": \"CoreMLExecutionProvider\", \"fallback\": null}\n"; + let s = classify(&exit(true, "0"), stderr); + assert!(s.available); + assert_eq!(s.provider.as_deref(), Some("CoreMLExecutionProvider")); + assert_eq!(s.version.as_deref(), Some("1.31.1")); + assert_eq!(s.providers.len(), 2); + assert_eq!(s.fallback, None); + assert_eq!(s.message("macos-arm64"), None); + assert!(!classify(&exit(true, "0"), "AI_RUNTIME_PROBE:start\n").available); + } + + #[test] + fn failures_are_classified_by_stage() { + let cases = [ + ("AI_RUNTIME_PROBE:setup\nRuntimeError: The AI runtime add-on is not installed (no x)", R::NotInstalled), + ("AI_RUNTIME_PROBE:import\nImportError: DLL load failed while importing onnxruntime_pybind11_state", R::MissingLibrary), + ("AI_RUNTIME_PROBE:import\nModuleNotFoundError: No module named 'numpy'", R::ImportFailed), + ("AI_RUNTIME_PROBE:session\nonnxruntime.capi.onnxruntime_pybind11_state.Fail: [ONNXRuntimeError]", R::SessionFailed), + ("AI_RUNTIME_PROBE:frames\nRuntimeError: wrong pixels", R::SessionFailed), + ("AI_RUNTIME_PROBE:session\n", R::Crashed), + ("", R::ProbeFailed), + ]; + for (stderr, want) in cases { + assert_eq!(classify(&exit(false, "exit code 1"), stderr).reason, Some(want), "{stderr}"); + } + assert_eq!( + classify(&exit(false, "signal 4 (SIGILL)"), "AI_RUNTIME_PROBE:import\n").reason, + Some(R::UnsupportedCpu) + ); + let t = ProbeExit { success: false, timed_out: true, status: "timed out".into() }; + assert_eq!(classify(&t, "").reason, Some(R::ProbeFailed)); + } + + #[test] + fn every_reason_has_a_message() { + for r in [ + R::UnsupportedPlatform, R::NotInstalled, R::Outdated, R::MissingLibrary, R::ImportFailed, + R::SessionFailed, R::UnsupportedCpu, R::Crashed, R::ProbeFailed, + ] { + for p in addons::PLATFORMS { + let m = AiRuntimeStatus::unavailable(r, Some("d".into())).message(p).unwrap(); + assert!(m.ends_with("Details: d"), "{m}"); + } + } + let m = AiRuntimeStatus::unavailable(R::NotInstalled, None).message("linux-x64").unwrap(); + assert!(m.contains("Settings → Add-ons"), "{m}"); + let j = AiRuntimeStatus::unavailable(R::NotInstalled, None).to_json("linux-x64"); + assert_eq!(j["available"], false); + assert_eq!(j["reason"], "notInstalled"); + assert!(j["message"].as_str().unwrap().contains("not installed")); + } + + #[test] + fn precheck_needs_an_install() { + let root = std::env::temp_dir().join(format!("vb-ai-precheck-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + assert_eq!(precheck(&root, "linux-x64").unwrap().reason, Some(R::NotInstalled)); + assert_eq!(precheck(&root, "windows-arm64").unwrap().reason, Some(R::UnsupportedPlatform)); + } + + #[test] + fn the_probe_script_uses_the_template_helper() { + let s = probe_script(Path::new("/t"), Path::new("/a/ai-runtime"), true); + assert!(s.contains("sys.path.insert(0, '/t')")); + assert!(s.contains("ai_runtime.setup('/a/ai-runtime')")); + assert!(s.contains("self_test(force_cpu=True)")); + assert!(s.contains("frame_test(force_cpu=True)")); + assert!(s.contains(".set_output()")); + } +} diff --git a/worker/src/dependency_locator.rs b/worker/src/dependency_locator.rs index b511fe28..9427c021 100644 --- a/worker/src/dependency_locator.rs +++ b/worker/src/dependency_locator.rs @@ -862,6 +862,85 @@ impl DependencyLocator { PathBuf::from("addons") } + /// A manifest add-on's directory (`addons//`, see `addons.rs`). + pub fn addon_dir(&self, id: &str) -> PathBuf { + crate::addons::addon_dir(&self.addons_dir(), id) + } + + /// Refuse unless every add-on in `ids` (and what they require) is + /// installed for this platform, naming them and Settings → Add-ons. + /// `feature` starts the message ("Face restoration needs …"). + pub fn require_addons(&self, ids: &[&str], feature: &str) -> Result<()> { + crate::addons::require(&self.addons_dir(), ids, feature, self.platform_suffix()) + } + + /// Whether the AI runtime add-on runs here, and on which provider (see + /// `ai_runtime.rs`). As for the GPU plugins, only a positive result is + /// cached for the boot — keyed by the add-on's version and the force-CPU + /// setting too, so reinstalling or upgrading it re-probes — because a + /// failure is what the user is about to fix. + pub fn ai_runtime_status(&self) -> crate::ai_runtime::AiRuntimeStatus { + use crate::ai_runtime::{self, AiRuntimeReason, AiRuntimeStatus}; + let addons = self.addons_dir(); + if let Some(status) = ai_runtime::precheck(&addons, self.platform_suffix()) { + return status; + } + let force_cpu = ai_runtime::force_cpu_requested(); + let version = crate::addons::manifest(crate::addons::AI_RUNTIME) + .map(|m| m.version.clone()) + .unwrap_or_default(); + let boot = Self::boot_token(); + let cache_file = self.platform_dir().join("ai-runtime-probe.json"); + if !boot.is_empty() { + if let Ok(txt) = std::fs::read_to_string(&cache_file) { + if let Ok(json) = serde_json::from_str::(&txt) { + if json.get("boot").and_then(|v| v.as_str()) == Some(boot.as_str()) + && json.get("addonVersion").and_then(|v| v.as_str()) == Some(version.as_str()) + && json.get("forceCpu").and_then(|v| v.as_bool()) == Some(force_cpu) + { + if let Some(status) = json + .get("status") + .and_then(|s| serde_json::from_value::(s.clone()).ok()) + .filter(|s| s.available) + { + return status; + } + } + } + } + } + let vspipe = match self.vspipe_path() { + Ok(p) => p, + Err(e) => return AiRuntimeStatus::unavailable(AiRuntimeReason::ProbeFailed, e.to_string()), + }; + let templates = match crate::script_generator::ScriptGenerator::pipe_source_dir() { + Ok(p) => p, + Err(e) => return AiRuntimeStatus::unavailable(AiRuntimeReason::ProbeFailed, e.to_string()), + }; + let status = ai_runtime::run_probe( + &vspipe, + &self.build_environment(), + &templates, + &self.addon_dir(crate::addons::AI_RUNTIME), + force_cpu, + ); + if status.available { + let _ = std::fs::write( + &cache_file, + serde_json::json!({ + "boot": boot, + "addonVersion": version, + "forceCpu": force_cpu, + "status": status, + }) + .to_string(), + ); + } else { + let _ = std::fs::remove_file(&cache_file); + } + status + } + /// AnimeJaNai's installed model directory under the add-ons, or the /// message explaining that the add-on is missing — what /// `ScriptGenerator::with_animejanai` takes. diff --git a/worker/src/gpu_plugin.rs b/worker/src/gpu_plugin.rs index d7a90e45..bb0d90b2 100644 --- a/worker/src/gpu_plugin.rs +++ b/worker/src/gpu_plugin.rs @@ -537,8 +537,21 @@ fn run_with_timeout( ) -> std::io::Result<(ProbeExit, String)> { // Frame 1: for RIFE's 2x output the first interpolated frame (frame 0 is a // copy and would never touch the GPU); for upscaling, any frame runs it. + run_vspipe_probe(vspipe, script, env, &["-s", "1", "-e", "1"], PROBE_TIMEOUT) +} + +/// Run a probe script through vspipe with `args` (the frame range), output +/// discarded, killing it after `timeout`; returns how it ended and its stderr. +/// Shared with the AI runtime's probe (`ai_runtime.rs`). +pub(crate) fn run_vspipe_probe( + vspipe: &Path, + script: &Path, + env: &HashMap, + args: &[&str], + timeout: Duration, +) -> std::io::Result<(ProbeExit, String)> { let mut child = Command::new(vspipe) - .args(["-s", "1", "-e", "1"]) + .args(args) .arg(script) .arg("-") .envs(env) @@ -559,7 +572,7 @@ fn run_with_timeout( if let Some(status) = child.try_wait()? { break Some(status); } - if start.elapsed() > PROBE_TIMEOUT { + if start.elapsed() > timeout { let _ = child.kill(); let _ = child.wait(); break None; diff --git a/worker/src/lib.rs b/worker/src/lib.rs index 98d8108d..e5326ee3 100644 --- a/worker/src/lib.rs +++ b/worker/src/lib.rs @@ -14,6 +14,8 @@ pub mod pixel_format; pub mod source_decode; pub mod source_timing; pub mod progress_reporter; +pub mod addons; +pub mod ai_runtime; pub mod animejanai; pub mod gpu_plugin; pub mod hdr_metadata; diff --git a/worker/src/main.rs b/worker/src/main.rs index 6a83968d..6d5250ec 100644 --- a/worker/src/main.rs +++ b/worker/src/main.rs @@ -28,6 +28,8 @@ mod pixel_format; mod source_decode; mod source_timing; mod progress_reporter; +mod addons; +mod ai_runtime; mod animejanai; mod gpu_plugin; mod hdr_metadata; @@ -120,6 +122,18 @@ struct Args { /// from (for the Descale pass); prints a JSON report. #[arg(long)] detect_native: bool, + + /// Probe whether the AI runtime add-on (ONNX Runtime) runs here: imports + /// it the way a job does, runs a tiny test model, and prints JSON + /// (`{"available", "reason", "detail", "message", "version", "providers", + /// "provider", "fallback"}`). `VAPOURBOX_AI_FORCE_CPU=1` tests the CPU path. + #[arg(long)] + probe_ai_runtime: bool, + + /// Print the add-on manifests compiled into this worker, as JSON + /// (`{"": "", …}`), and exit. + #[arg(long)] + list_addons: bool, } fn main() -> ExitCode { @@ -181,6 +195,19 @@ fn main() -> ExitCode { return run_detect_native(&args); } + if args.probe_ai_runtime { + return run_probe_ai_runtime(); + } + + if args.list_addons { + let list: serde_json::Map = addons::manifests() + .iter() + .map(|m| (m.id.clone(), serde_json::Value::String(m.version.clone()))) + .collect(); + println!("{}", serde_json::Value::Object(list)); + return ExitCode::SUCCESS; + } + // Preview mode outputs raw PNG to stdout - no JSON messages if args.preview { return run_preview_mode(&args); @@ -296,6 +323,19 @@ fn run_detect_native(args: &Args) -> ExitCode { /// result as JSON to stdout. Deps that cannot be located are reported as "not /// installed" rather than as a failure of the probe: the remedy is the same, /// let the app download them. +fn run_probe_ai_runtime() -> ExitCode { + let json = match dependency_locator::DependencyLocator::new() { + Ok(deps) => deps.ai_runtime_status().to_json(deps.platform_suffix()), + Err(e) => ai_runtime::AiRuntimeStatus::unavailable( + ai_runtime::AiRuntimeReason::ProbeFailed, + e.to_string(), + ) + .to_json(""), + }; + println!("{json}"); + ExitCode::SUCCESS +} + fn run_probe_gpu_plugin(plugin: gpu_plugin::GpuPlugin) -> ExitCode { let json = match dependency_locator::DependencyLocator::new() { Ok(deps) => deps.gpu_plugin_status(plugin).to_json(plugin, deps.platform_suffix()), @@ -719,6 +759,10 @@ fn run_worker( _ => Err(animejanai::MISSING_MESSAGE.to_string()), }) .with_timeline(timeline_window); + let script_generator = match deps.as_ref() { + Some(d) => script_generator.with_addons(d.addons_dir(), d.platform_suffix()), + None => script_generator, + }; let script_path = script_generator .generate(&job) .with_context(|| "Failed to generate VapourSynth script")?; diff --git a/worker/src/models/processing_pipeline.rs b/worker/src/models/processing_pipeline.rs index b10dd237..827f957a 100644 --- a/worker/src/models/processing_pipeline.rs +++ b/worker/src/models/processing_pipeline.rs @@ -525,6 +525,19 @@ impl ProcessingPipeline { self.crop_resize.uses_animejanai() } + /// The manifest add-ons (`app/assets/addons/`, see `addons.rs`) each + /// enabled feature of this pipeline needs. `ScriptGenerator` refuses the + /// job, naming them and Settings → Add-ons, unless all are installed, and + /// emits the `{{#AI_RUNTIME}}` block when any of them needs the AI runtime. + /// + /// No pass uses one yet. A pass that runs a model from an add-on adds an + /// entry here when enabled, e.g. + /// `AddonNeed { feature: "Face restoration", ids: vec!["gfpgan"] }` + /// (a model add-on whose manifest `requires` `ai-runtime`). + pub fn required_addons(&self) -> Vec { + Vec::new() + } + /// Whether this pipeline denoises with KNLMeans, which needs a working /// OpenCL device before its script can be generated. pub fn uses_knlm(&self) -> bool { diff --git a/worker/src/pipeline_executor.rs b/worker/src/pipeline_executor.rs index 82e9cf2d..5f40beca 100644 --- a/worker/src/pipeline_executor.rs +++ b/worker/src/pipeline_executor.rs @@ -1339,6 +1339,7 @@ impl PipelineExecutor { Err("KNLMeans is not used by this job.".into()) }) .with_animejanai(self.deps.animejanai_runtime()) + .with_addons(self.deps.addons_dir(), self.deps.platform_suffix()) .with_timeline(window.clone()); let (fps_num, fps_den) = script_generator.frame_rate_to_rational(frame_rate); diff --git a/worker/src/script_generator.rs b/worker/src/script_generator.rs index c8276e74..242f82cb 100644 --- a/worker/src/script_generator.rs +++ b/worker/src/script_generator.rs @@ -83,6 +83,10 @@ pub struct ScriptGenerator { /// addition to `upscale`. Defaults to "not checked"; callers set it from /// `DependencyLocator::animejanai_runtime()`. animejanai: std::result::Result, + /// The add-ons directory and this platform's id, for the manifest add-ons + /// a job's passes need (`ProcessingPipeline::required_addons`, see + /// `addons.rs`). `None` ("not checked") refuses any job that needs one. + addons: Option<(PathBuf, String)>, /// For a variable-frame-rate source, where the decoded window's stored /// frames fall on the grid (see `source_timing`). `None` for a /// constant-rate source, whose script is unchanged. @@ -125,10 +129,19 @@ impl ScriptGenerator { upscale: Err("AI upscaling was not checked for this job.".to_string()), knlm: Err("KNLMeansCL was not checked for this job.".to_string()), animejanai: Err("AnimeJaNai was not checked for this job.".to_string()), + addons: None, timeline: None, }) } + /// Set the add-ons directory and platform id the job's add-ons are + /// checked against and loaded from (`DependencyLocator::addons_dir`, + /// `platform_suffix`). + pub fn with_addons(mut self, addons_dir: PathBuf, platform: &str) -> Self { + self.addons = Some((addons_dir, platform.to_string())); + self + } + /// Set whether OpenCL is available (probe result from `DependencyLocator`). /// When false, the GPU NNEDI3CL path is suppressed and QTGMC uses CPU NNEDI3. pub fn with_opencl_available(mut self, available: bool) -> Self { @@ -212,9 +225,28 @@ impl ScriptGenerator { anyhow::bail!("{message}"); } } + for need in pipeline.required_addons() { + match &self.addons { + Some((dir, platform)) => crate::addons::require(dir, &need.ids, need.feature, platform)?, + None => anyhow::bail!("{} needs add-ons, which were not checked for this job.", need.feature), + } + } Ok(()) } + /// Emit or drop the `{{#AI_RUNTIME}}` block: emitted, with the add-on's + /// directory, when a pass needs an add-on that requires the AI runtime. + fn substitute_addons(&self, script: String, pipeline: &ProcessingPipeline) -> String { + let ids: Vec<&str> = pipeline.required_addons().iter().flat_map(|n| n.ids.clone()).collect(); + let dir = match &self.addons { + Some((root, _)) if crate::addons::needs_ai_runtime(&ids) => { + Some(crate::addons::addon_dir(root, crate::addons::AI_RUNTIME)) + } + _ => None, + }; + ai_runtime_block(script, dir.as_deref()) + } + /// Generate a .vpy script file for the given job. pub fn generate(&self, job: &VideoJob) -> Result { let pipeline = job.effective_pipeline(); @@ -466,7 +498,7 @@ impl ScriptGenerator { /// Substitute pipeline parameters on an already-prepared script. fn substitute_parameters_on(&self, script: &str, job: &VideoJob, pipeline: &ProcessingPipeline) -> String { - let mut script = script.to_string(); + let mut script = self.substitute_addons(script.to_string(), pipeline); let params = &pipeline.deinterlace; // ==================================================================== @@ -2500,6 +2532,20 @@ const DEINT_METHOD_BLOCKS: [(DeinterlaceMethod, &str); 4] = [ (DeinterlaceMethod::Bwdif, "DEINT_BWDIF"), ]; +/// The `{{#AI_RUNTIME}}` block with `{{AI_RUNTIME_DIR}}` set to the AI runtime +/// add-on's directory, or removed when `dir` is `None`. The block imports +/// `ai_runtime.py` (beside `pipe_source.py`) and calls its `setup()`, after +/// which any later block can use `ai_runtime.session(...)` and friends. +pub(crate) fn ai_runtime_block(script: String, dir: Option<&Path>) -> String { + match dir { + Some(dir) => script + .replace("{{#AI_RUNTIME}}", "") + .replace("{{/AI_RUNTIME}}", "") + .replace("{{AI_RUNTIME_DIR}}", &py_str(dir)), + None => remove_block("{{#AI_RUNTIME}}", "{{/AI_RUNTIME}}", script), + } +} + /// Remove a block from start tag to end tag (including the line). fn remove_block(start_tag: &str, end_tag: &str, mut script: String) -> String { while let Some(start_pos) = script.find(start_tag) { @@ -2523,6 +2569,40 @@ fn remove_block(start_tag: &str, end_tag: &str, mut script: String) -> String { mod tests { use super::*; + /// Both templates carry the AI runtime block once, before any pass, and + /// it imports the helper and sets it up from the substituted directory. + #[test] + fn ai_runtime_block_in_both_templates() { + for name in ["pipeline_template.vpy", "preview_template.vpy"] { + let t = ScriptGenerator::load_template_by_name(name).unwrap(); + assert_eq!(t.matches("{{#AI_RUNTIME}}").count(), 1, "{name}"); + let block_at = t.find("{{#AI_RUNTIME}}").unwrap(); + assert!(block_at > t.find("from pipe_source import").unwrap(), "{name}: after the pipe source"); + assert!(block_at < t.find("{{#DEINTERLACE}}").unwrap(), "{name}: before the passes"); + + let on = ai_runtime_block(t.clone(), Some(Path::new("/x/addons/ai-runtime"))); + assert!(on.contains("import ai_runtime\n"), "{name}"); + assert!(on.contains("ai_runtime.setup('/x/addons/ai-runtime')"), "{name}"); + assert!(!on.contains("AI_RUNTIME}}"), "{name}"); + + let off = ai_runtime_block(t, None); + assert!(!off.contains("import ai_runtime"), "{name}"); + assert!(!off.contains("AI_RUNTIME"), "{name}"); + } + } + + /// No pass needs an add-on yet, so a generated script never has the + /// block, whatever the generator was told about add-ons. + #[test] + fn a_job_without_addon_passes_has_no_ai_runtime_block() { + let gen = ScriptGenerator::new().unwrap().with_addons(PathBuf::from("/nonexistent"), "macos-arm64"); + let pipeline = ProcessingPipeline::default(); + assert!(pipeline.required_addons().is_empty()); + gen.ensure_methods_ready(&pipeline).unwrap(); + let script = gen.substitute_addons(gen.template.clone(), &pipeline); + assert!(!script.contains("ai_runtime")); + } + /// A method missing from DEINT_METHOD_BLOCKS keeps every other method's /// block in its script (issue #108). The match has no catch-all, so a new /// variant fails to compile here until it is listed. diff --git a/worker/templates/ai_runtime.py b/worker/templates/ai_runtime.py new file mode 100644 index 00000000..497e3858 --- /dev/null +++ b/worker/templates/ai_runtime.py @@ -0,0 +1,265 @@ +""" +ONNX Runtime for VapourSynth scripts, from the ai-runtime add-on. + +The ai-runtime add-on (app/assets/addons/ai-runtime.json) unpacks ONNX Runtime +and NumPy wheels into /ai-runtime/site-packages/. Passes that run a +model the VSNCNN plugin can't (per-crop inference, models with more than one +input, anything needing NumPy around the network) use them through this +module, which ships beside pipe_source.py in the templates directory. + + import ai_runtime + ai_runtime.setup(r"/ai-runtime") # the {{#AI_RUNTIME}} block + sess = ai_runtime.session(r"/my-model/model.onnx") + clip = ai_runtime.map_frames(clip, lambda x: sess.run(None, {"input": x})[0], + width=clip.width * 2, height=clip.height * 2) + +setup() puts the add-on's packages on sys.path itself rather than relying on +PYTHONPATH, because Windows' embeddable Python ignores PYTHONPATH (its ._pth +file fixes sys.path), and registers onnxruntime's DLL directory there. + +Execution providers are chosen in this order, from those the installed wheel +offers: TensorRT, CUDA (neither is in today's wheels; a future NVIDIA add-on +can add them by passing its own directory to setup() first), DirectML +(Windows), Core ML (macOS), then the CPU. VAPOURBOX_AI_FORCE_CPU=1, or +force_cpu=True, uses the CPU only. A session that fails to create on a GPU +provider is retried on the CPU, and the reason is kept in last_fallback. + +`vapourbox-worker --probe-ai-runtime` calls self_test() through vspipe. +""" + +import base64 +import os +import sys +import threading + +ADDON_ID = "ai-runtime" +FORCE_CPU_ENV = "VAPOURBOX_AI_FORCE_CPU" + +# Most-preferred first. Only those the installed onnxruntime reports as +# available are used; the CPU is always last. +GPU_PROVIDERS = [ + "TensorrtExecutionProvider", + "CUDAExecutionProvider", + "DmlExecutionProvider", + "CoreMLExecutionProvider", +] +CPU_PROVIDER = "CPUExecutionProvider" + +# Options per provider. Core ML's MLProgram format supports more operators +# than the older NeuralNetwork format and runs on the GPU and Neural Engine. +PROVIDER_OPTIONS = { + "CoreMLExecutionProvider": {"ModelFormat": "MLProgram", "MLComputeUnits": "ALL"}, +} + +# y = x * 2 + 1 over a float32 (1, 3, h, w) tensor, IR 8 / opset 13. Built by +# Scripts/models/make_ai_runtime_selftest.py; the same bytes are committed as +# Tests/TestResources/ai_runtime_selftest.onnx (a test asserts they agree). +_SELFTEST_MODEL_B64 = ( + "CAgSCXZhcG91cmJveDqWAQoRCgF4CgN0d28SAngyIgNNdWwKEQoCeDIKA29uZRIBeSIDQWRkEhJ2YXBvdXJib3hfc2VsZnRlc3Qq" + "DRABIgQAAABAQgN0d28qDRABIgQAAIA/QgNvbmVaHQoBeBIYChYIARISCgIIAQoCCAMKAxIBaAoDEgF3Yh0KAXkSGAoWCAESEgoC" + "CAEKAggDCgMSAWgKAxIBd0IECgAQDQ==" +) + +_lock = threading.Lock() +_sessions = {} +_dll_dirs = [] +_ort = None + +# Why the last session() call fell back to the CPU, or None. +last_fallback = None + + +def setup(*addon_dirs): + """Make the add-on's packages importable. Pass the ai-runtime add-on's + directory (and, first, any add-on that should take precedence). Safe to + call more than once.""" + for addon_dir in reversed(addon_dirs): + site = os.path.join(addon_dir, "site-packages") + if not os.path.isdir(site): + raise RuntimeError( + "The AI runtime add-on is not installed (no %s). Install it in " + "Settings -> Add-ons." % site + ) + if site not in sys.path: + sys.path.insert(0, site) + capi = os.path.join(site, "onnxruntime", "capi") + if os.name == "nt" and os.path.isdir(capi) and hasattr(os, "add_dll_directory"): + # onnxruntime_pybind11_state.pyd links onnxruntime.dll and + # DirectML.dll beside it. Keep the handle: closing it removes the + # directory from the search path again. + _dll_dirs.append(os.add_dll_directory(capi)) + + +def ort(): + """The onnxruntime module (after setup()).""" + global _ort + if _ort is None: + import onnxruntime # noqa: deferred until setup() has run + + _ort = onnxruntime + return _ort + + +def force_cpu_requested(): + return os.environ.get(FORCE_CPU_ENV, "").strip().lower() in ("1", "true", "yes") + + +def available_providers(): + return list(ort().get_available_providers()) + + +def preferred_providers(force_cpu=False): + """The providers a session should try, most preferred first.""" + if force_cpu or force_cpu_requested(): + return [CPU_PROVIDER] + available = set(available_providers()) + return [p for p in GPU_PROVIDERS if p in available] + [CPU_PROVIDER] + + +def _session_options(providers): + rt = ort() + opts = rt.SessionOptions() + opts.log_severity_level = 3 # errors only: Core ML warns per unsupported node + if "DmlExecutionProvider" in providers: + # DirectML requires both (onnxruntime's DirectML documentation). + opts.enable_mem_pattern = False + opts.execution_mode = rt.ExecutionMode.ORT_SEQUENTIAL + return opts + + +def _create(model, providers): + rt = ort() + with_options = [(p, PROVIDER_OPTIONS[p]) if p in PROVIDER_OPTIONS else p for p in providers] + try: + return rt.InferenceSession(model, sess_options=_session_options(providers), providers=with_options) + except Exception: + if with_options == providers: + raise + # An older runtime that doesn't know an option: the provider's defaults. + return rt.InferenceSession(model, sess_options=_session_options(providers), providers=providers) + + +def session(model, force_cpu=False, providers=None): + """An InferenceSession for `model` (a path, or the model's bytes), on the + preferred providers unless `providers` is given. Cached per model and + provider list, so a pass calling this from a frame callback creates it + once. Falls back to the CPU if a GPU provider can't create it.""" + global last_fallback + chosen = list(providers) if providers else preferred_providers(force_cpu) + key = (model if isinstance(model, str) else hash(bytes(model)), tuple(chosen)) + with _lock: + cached = _sessions.get(key) + if cached is not None: + return cached + try: + sess = _create(model, chosen) + last_fallback = None + except Exception as e: + if chosen == [CPU_PROVIDER]: + raise + last_fallback = "%s: %s" % (chosen[0], e) + sess = _create(model, [CPU_PROVIDER]) + _sessions[key] = sess + return sess + + +def session_provider(sess): + """The provider a session prefers (its first registered one).""" + return sess.get_providers()[0] + + +def frame_to_nchw(frame): + """A planar RGBS VideoFrame as a float32 (1, 3, height, width) array.""" + import numpy as np + + return np.stack([np.asarray(frame[p]) for p in range(3)])[None].astype(np.float32, copy=False) + + +def nchw_to_frame(array, frame): + """Write a (1, 3, height, width) array into a writable copy of `frame` + (planar RGBS, of the array's size) and return the copy.""" + import numpy as np + + out = frame.copy() + data = np.asarray(array, dtype=np.float32) + if data.ndim == 4: + data = data[0] + for p in range(3): + np.copyto(np.asarray(out[p]), data[p]) + return out + + +def map_frames(clip, fn, width=None, height=None): + """Apply `fn` (a float32 (1, 3, h, w) array -> a (1, 3, height, width) + array) to every frame of a planar RGBS clip. The output is `width` x + `height` (default: the input's) and keeps each source frame's properties.""" + import vapoursynth as vs + + core = vs.core + if clip.format is None or clip.format.id != vs.RGBS: + raise ValueError("ai_runtime.map_frames needs a constant-format RGBS clip") + width = width or clip.width + height = height or clip.height + template = clip + if (width, height) != (clip.width, clip.height): + template = core.std.BlankClip(clip, width=width, height=height, keep=True) + + def _apply(n, f): + src, dst = f[0], f[1] + out = nchw_to_frame(fn(frame_to_nchw(src)), dst) + for k in list(out.props.keys()): + del out.props[k] + out.props.update(src.props) + return out + + return core.std.ModifyFrame(template, [clip, template], _apply) + + +def selftest_model(): + """The self-test model's bytes.""" + return base64.b64decode(_SELFTEST_MODEL_B64) + + +def frame_test(force_cpu=False): + """Run the self-test model over a VapourSynth frame through map_frames + (the path a pass takes: RGBS frame -> NumPy -> session -> frame) and check + the pixels. Returns the three output plane values.""" + import numpy as np + import vapoursynth as vs + + sess = session(selftest_model(), force_cpu=force_cpu) + name = sess.get_inputs()[0].name + clip = vs.core.std.BlankClip(width=24, height=16, format=vs.RGBS, color=[0.25, 0.5, 0.75], length=1) + clip = map_frames(clip, lambda x: sess.run(None, {name: x})[0], width=24, height=16) + frame = clip.get_frame(0) + got = [float(np.asarray(frame[p]).mean()) for p in range(3)] + want = [1.5, 2.0, 2.5] + if max(abs(a - b) for a, b in zip(got, want)) > 1e-2: + raise RuntimeError("the self-test model gave the wrong pixels through VapourSynth: %r" % got) + return got + + +def self_test(force_cpu=False): + """Create a session for the self-test model on the preferred provider, + run it, and check the answer. Returns a dict for the probe: + {version, providers (available), requested, provider, fallback, maxError}. + Raises if the session can't be created or the answer is wrong.""" + import numpy as np + + rt = ort() + requested = preferred_providers(force_cpu) + sess = session(selftest_model(), force_cpu=force_cpu) + x = np.linspace(0.0, 1.0, 3 * 16 * 16, dtype=np.float32).reshape(1, 3, 16, 16) + y = sess.run(None, {sess.get_inputs()[0].name: x})[0] + err = float(np.abs(y - (x * 2.0 + 1.0)).max()) + if not err < 1e-2: + raise RuntimeError("the self-test model computed the wrong answer (max error %g)" % err) + return { + "version": rt.__version__, + "providers": available_providers(), + "requested": requested, + "provider": session_provider(sess), + "sessionProviders": list(sess.get_providers()), + "fallback": last_fallback, + "maxError": err, + } diff --git a/worker/templates/pipeline_template.vpy b/worker/templates/pipeline_template.vpy index df6f311c..bb2e7015 100644 --- a/worker/templates/pipeline_template.vpy +++ b/worker/templates/pipeline_template.vpy @@ -24,6 +24,15 @@ import os sys.path.insert(0, r"{{PIPE_SOURCE_DIR}}") from pipe_source import create_pipe_clip +{{#AI_RUNTIME}} +# ONNX Runtime from the ai-runtime add-on (Settings -> Add-ons), for passes +# that run a model through ai_runtime.session() / ai_runtime.map_frames(). +# ai_runtime.py sits beside pipe_source.py and puts the add-on's packages on +# sys.path itself: Windows' embeddable Python ignores PYTHONPATH. +import ai_runtime +ai_runtime.setup({{AI_RUNTIME_DIR}}) +{{/AI_RUNTIME}} + clip = create_pipe_clip( width={{INPUT_WIDTH}}, height={{INPUT_HEIGHT}}, diff --git a/worker/templates/preview_template.vpy b/worker/templates/preview_template.vpy index 69bef234..1b97bb8d 100644 --- a/worker/templates/preview_template.vpy +++ b/worker/templates/preview_template.vpy @@ -22,6 +22,15 @@ core.max_cache_size = {{MAX_CACHE_SIZE}} sys.path.insert(0, r"{{PIPE_SOURCE_DIR}}") from pipe_source import create_pipe_clip +{{#AI_RUNTIME}} +# ONNX Runtime from the ai-runtime add-on (Settings -> Add-ons), for passes +# that run a model through ai_runtime.session() / ai_runtime.map_frames(). +# ai_runtime.py sits beside pipe_source.py and puts the add-on's packages on +# sys.path itself: Windows' embeddable Python ignores PYTHONPATH. +import ai_runtime +ai_runtime.setup({{AI_RUNTIME_DIR}}) +{{/AI_RUNTIME}} + clip = create_pipe_clip( width={{INPUT_WIDTH}}, height={{INPUT_HEIGHT}}, num_frames={{TOTAL_FRAMES}}, fps_num={{FPS_NUM}}, From 98bd7bd8e12c6b0fc0b06fd9484d80e37ab57e88 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 8 Nov 2026 17:32:27 +1100 Subject: [PATCH 04/61] docs(addons): the manifest format, the AI runtime, adding an add-on docs/ADDONS.md: the manifest fields, what an install looks like, the VAPOURBOX_ADDON_MIRROR test mirror, the addon-models-v1 hosting convention and the addon-staging/ conversion venv, the worker and template API (addons.rs, required_addons, the AI_RUNTIME block, ai_runtime.py), the --probe-ai-runtime probe, and the checklist for adding an add-on. CLAUDE.md's Add-ons section, key files and structure, BUILDING.md's structure, and an ENGINEERING_NOTES entry with the wheel facts the ai-runtime manifest rests on. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 44 ++++++- docs/ADDONS.md | 256 ++++++++++++++++++++++++++++++++++++++ docs/BUILDING.md | 2 + docs/ENGINEERING_NOTES.md | 33 +++++ 4 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 docs/ADDONS.md diff --git a/CLAUDE.md b/CLAUDE.md index 03721cb9..092e2396 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,6 +71,7 @@ VapourBox/ │ │ └── widgets/ # Reusable UI widgets │ ├── assets/filters/ # Built-in filter schemas (JSON) │ │ └── core/ # Core filters (deinterlace, denoise, etc.) +│ ├── assets/addons/ # Add-on manifests, one JSON per add-on (docs/ADDONS.md) │ ├── macos/ # macOS platform config │ ├── windows/ # Windows platform config │ └── linux/ # Linux platform config @@ -95,9 +96,10 @@ VapourBox/ │ └── linux-arm64/ │ ├── licenses/ # GPL, LGPL, NOTICES -├── docs/ # BUILDING, FILTER_SCHEMA, ENGINEERING_NOTES, … +├── docs/ # BUILDING, FILTER_SCHEMA, ADDONS, ENGINEERING_NOTES, … │ └── reports/ # Investigation reports (evidence behind decisions) ├── Scripts/ # Build, package, and release scripts +│ └── models/ # Model conversion scripts for self-hosted add-on files └── .github/workflows/ # CI build workflows ``` @@ -117,6 +119,9 @@ VapourBox/ | `worker/src/source_timing.rs` | A variable-frame-rate source's stored frames placed on the constant-rate grid (see "Variable Frame Rate Sources") | | `worker/src/gpu_plugin.rs` | The ncnn-on-Vulkan plugins (RIFE, AI upscaling): file layout, the `--probe-rife` / `--probe-upscale` probe, and the user-facing reason/remedy when they can't run (see "GPU Plugins") | | `worker/src/animejanai.rs` | AnimeJaNai's add-on models: where they live, which one a job runs, the refusal when they're missing (see "Add-ons") | +| `worker/src/addons.rs` | Manifest add-ons (compiled in from `app/assets/addons/` by `worker/build.rs`): install state, `require`, `addon_dir` (see "Add-ons", docs/ADDONS.md) | +| `worker/src/ai_runtime.rs` | The AI runtime add-on's `--probe-ai-runtime`: runs the self-test model through `templates/ai_runtime.py`, classifies failures | +| `worker/templates/ai_runtime.py` | ONNX Runtime helpers for passes: `setup`, `session` (provider choice), `map_frames`, NCHW frame conversion | | `worker/src/comment_metadata.rs` | The source's comment tag, prefixed with the version and passed to ffmpeg as an FFMETADATA file (issue #138) | | `worker/src/hdr_metadata.rs` | HDR10 mastering display / MaxCLL read from the source for x265, and the Dolby Vision profile (see "HDR Sources and Dolby Vision") | | `worker/src/native_res.rs` | `--detect-native`: the resolution and kernel a source was upscaled from, for the Descale pass (see "Descale") | @@ -136,6 +141,8 @@ VapourBox/ | `app/lib/services/overwrite_behavior_service.dart` | Default action for existing output files (see "Existing Output Files") | | `app/lib/models/native_res_report.dart` | What `--detect-native` found, as the Descale panel's Detect shows and applies it | | `app/lib/services/addon_service.dart` | Every optional third-party download (Whisper, AnimeJaNai): catalogue, install with requirements, remove with dependants (see "Add-ons") | +| `app/lib/services/manifest_addon.dart` | `AddonManifest` + `ManifestAddonInstaller`: the generic installer for `app/assets/addons/*.json` (docs/ADDONS.md) | +| `app/lib/models/ai_runtime_status.dart` | The worker's `--probe-ai-runtime` result | | `app/lib/models/hdr_display.dart` | The display tone-map for the app's own source decodes (twin of `ColorMetadata::display_tonemap_filter`) and Dolby Vision detection | | `app/lib/models/gpu_plugin_status.dart` | The worker's GPU-plugin probe results, and the panels' notice decision | | `app/assets/filters/core/*.json` | Built-in filter schema definitions | @@ -1357,6 +1364,41 @@ by `AddonService.addonsDir`), only when the user asks. Rules: - **Tested against the real download**: `WorkerHarness.ensureAnimeJanai()` installs from the pinned URL with the app's own extraction, so CI fails if the link or hashes stop holding. +- **Every AI model is an add-on, never in the deps bundle**, and every new + add-on is a **manifest add-on**: one `app/assets/addons/.json` + (format, checklist and hosting: **[docs/ADDONS.md](docs/ADDONS.md)**), + installed by the generic `ManifestAddonInstaller` + (`app/lib/services/manifest_addon.dart`) and compiled into the worker by + `worker/build.rs` (`worker/src/addons.rs`), so both sides read the same + file. Kinds: `files` (model pack, archive members hash-checked one by one) + and `python` (wheels unpacked into `addons//site-packages/`). An install + is staged in `.new` and ends with the `.vapourbox-addon.json` marker + (id, version, platform); a manifest `version` bump makes the old install + count as missing. Whisper and AnimeJaNai keep their hand-written installers. +- **A pass declares its add-ons in `ProcessingPipeline::required_addons()`** + (`AddonNeed { feature, ids }`); `ScriptGenerator::ensure_methods_ready` + refuses the job via `addons::require` (naming them and Settings → Add-ons), + and emits the `{{#AI_RUNTIME}}` block (both templates) when one needs + `ai-runtime`. +- **The AI runtime (`ai-runtime`, python kind)** is ONNX Runtime + NumPy for + the deps Python 3.12: onnxruntime 1.31.1 (macOS arm64, Linux), + onnxruntime-directml 1.24.4 (Windows), 1.23.2 on Intel Macs (`minOs` 13.0 — + the last Intel wheel). Passes use `templates/ai_runtime.py` (`setup`, + `session`, `map_frames`, NCHW helpers): it puts `site-packages` on + `sys.path` itself because **Windows' embeddable Python ignores + PYTHONPATH**. `--probe-ai-runtime` runs a committed self-test model the way + a job would (`ai_runtime.rs`, positive results cached per boot); + `VAPOURBOX_AI_FORCE_CPU=1` forces the CPU provider. +- **`VAPOURBOX_ADDON_MIRROR=`** makes the installer (app and + `WorkerHarness.ensureAddon`) take files from a local directory, still size- + and hash-checked — how an add-on is tested before its files are uploaded. + Files we convert ourselves are staged in `addon-staging/` (gitignored, with + the conversion venv in `addon-staging/convert-venv`) and hosted on the + `addon-models-v1` release, which only the owner uploads to. +- **Tested for real**: `WorkerHarness.ensureAddon(id)` installs an add-on and + its requirements with the app's own installer; `integration_ai_runtime_test.dart` + (heavy) installs `ai-runtime` and runs the probe. `manifest_addon_test.dart` + asserts the worker (`--list-addons`) compiled in the same manifests. ### BM3D and KNLMeans Noise Reduction diff --git a/docs/ADDONS.md b/docs/ADDONS.md new file mode 100644 index 00000000..d58c1bf7 --- /dev/null +++ b/docs/ADDONS.md @@ -0,0 +1,256 @@ +# Add-ons + +Anything VapourBox can use but does not ship in the app or the deps bundle is +an **add-on**: an optional download the user asks for, from Settings → Add-ons +or from the prompt a feature raises when it needs one (`AddonPrompt.ensure`). +**Every AI model is an add-on, never part of the deps bundle** — a model's +size or licence should never be a cost to users who don't use it, and the deps +bundle must stay freely redistributable. + +There are three kinds of installer behind `AddonService`: + +| Add-on | Installer | Described by | +|---|---|---| +| Whisper engine + models | `WhisperAddonManager` (hand-written) | `app/assets/whisper-addon.json` | +| AnimeJaNai models | `AnimeJanaiInstaller` (hand-written) | `app/assets/animejanai-addon.json` | +| **Everything new** | `ManifestAddonInstaller` (generic) | `app/assets/addons/.json` | + +New add-ons are **manifest add-ons**. This file documents them. + +## The manifest + +One JSON file per add-on, `app/assets/addons/.json`, named after its id. +The app finds every file in that directory through the asset manifest +(`assets/addons/` is registered in `pubspec.yaml`), and the worker compiles the +same files in (`worker/build.rs` → `worker/src/addons.rs`), so there is one +source of truth for what an add-on is and what an install of it holds. + +```json +{ + "id": "face-restore-gfpgan", + "version": "1.0.0", + "name": "GFPGAN face restoration model", + "feature": "Face restoration", + "description": "One or two sentences for Settings → Add-ons.", + "licence": "Apache-2.0", + "licenceUrl": "https://github.com/TencentARC/GFPGAN/blob/master/LICENSE", + "usageNote": "Free for non-commercial use only.", + "credit": "Tencent ARC Lab", + "noticeName": "GFPGAN model", + "homepage": "https://github.com/TencentARC/GFPGAN", + "components": [ + { + "noticeName": "Some bundled part", + "licence": "MIT", + "licenceUrl": "https://…", + "credit": "Its authors", + "homepage": "https://…", + "platforms": ["windows-x64"] + } + ], + "requires": ["ai-runtime"], + "kind": "files", + "platforms": "all", + "files": [ + { + "name": "GFPGANv1.4.onnx", + "url": "https://github.com/StuartCameronCode/VapourBox/releases/download/addon-models-v1/GFPGANv1.4.onnx", + "size": 348632874, + "sha256": "…64 lower-case hex…" + }, + { + "name": "upstream-pack.zip", + "url": "https://example.org/releases/pack.zip", + "size": 24674026, + "sha256": "…", + "platforms": ["macos-arm64", "windows-x64", "linux-x64"], + "extract": [ + { "path": "pack/model.onnx", "name": "model.onnx", "sha256": "…" } + ] + } + ] +} +``` + +| Field | | +|---|---| +| `id` | lower-case-with-dashes; the directory it installs to (`addons//`) and the id features ask for. Must not collide with the hand-written ids (`whisper`, `whisper-model-*`, `animejanai`). | +| `version` | the add-on's own version. **Bump it whenever its files change**: an install of another version counts as missing (the app offers it again and the install replaces it; the worker refuses with "out of date"). | +| `name`, `feature`, `description` | what Settings → Add-ons shows; `feature` groups the list. | +| `licence`, `licenceUrl`, `credit`, `noticeName`, `homepage` | the main component's credit (see "Credits" below). | +| `usageNote` | required exactly when the licence is non-commercial (`NC`), and shown bold wherever the add-on is offered. | +| `components` | further components credited separately, each optionally limited to `platforms`. | +| `requires` | add-on ids installed first, and removed with this one's dependants. A model run through ONNX Runtime requires `ai-runtime`. | +| `kind` | `files` — a model pack: each file is saved as `addons//`, or for an entry with `extract`, only the listed members are kept (each checked against its own hash; nothing else in the archive is written). `python` — every file is a wheel, unpacked into `addons//site-packages/` as pip lays it out (`*.data/purelib`/`platlib` at the top, other `.data` dropped). | +| `python` | for a python add-on, the wheel tag it targets (`cp312`: the deps bundle's Python on every platform). | +| `platforms` | `"all"`, or a table of the deps platform ids (`macos-arm64`, `macos-x64`, `windows-x64`, `linux-x64`, `linux-arm64`) it supports, each with optional `minOs` (macOS only, checked at run time against the running system). Anything else gets an `unavailableReason` and cannot be installed. | +| `files[]` | `name` (a plain file name), `url` (https), `size` (bytes), `sha256`; optional `platforms` (default: every supported platform) and `extract` (members: `path` in the archive, `name` to install as, default its basename, and `sha256`). Every supported platform must have at least one file. | + +`AddonManifest.fromJson` validates all of this, so a malformed manifest fails +`manifest_addon_test.dart` rather than half-working. + +### What an install looks like + +``` +addons/ + / + + .vapourbox-addon.json {"id", "version", "platform"} — written last + .downloads/-/ kept across a cancel so the download resumes; + deleted once installed +``` + +An install downloads and verifies **every** file first, builds the add-on in +`addons/.new`, writes the marker, then renames the old install aside and +the new one into place. A failure or cancel at any point leaves the previous +state; the app and the worker both treat a directory without a matching marker +(id, version, *platform*) as not installed. + +### Testing before the files exist: the mirror + +`VAPOURBOX_ADDON_MIRROR=` makes the installer take each file from +`/` instead of downloading it — still +checked against the manifest's size and hash. Use it to test an add-on whose +files are converted locally and not uploaded yet. It applies to the app and to +`WorkerHarness.ensureAddon`. + +## Hosting files we convert ourselves + +Where upstream publishes only a PyTorch checkpoint (`.pth`), we convert it to +ONNX and host the result on this repository's releases: + +``` +https://github.com/StuartCameronCode/VapourBox/releases/download/addon-models-v1/ +``` + +That release is created by the project owner, not by a change. Until it +exists, put converted files in `addon-staging/` at the repo root (gitignored) +and test through the mirror: + +```bash +VAPOURBOX_ADDON_MIRROR=$PWD/addon-staging \ +VAPOURBOX_DEPS_DIR=$PWD/deps/macos-arm64 \ + (cd app && flutter test test/integration__test.dart) +``` + +### The conversion environment + +A shared Python venv for converting checkpoints lives at +`addon-staging/convert-venv/` (gitignored; Python 3.14 with CPU `torch`, +`torchvision`, `onnx`, `onnxruntime`, `onnxscript`, `numpy`). Recreate it with: + +```bash +python3.14 -m venv addon-staging/convert-venv +addon-staging/convert-venv/bin/pip install torch torchvision onnx onnxruntime onnxscript numpy +``` + +Add a model's own packages to it as needed. Every conversion is a script +committed under `Scripts/models/` (see `make_ai_runtime_selftest.py` for the +shape): it must reproduce the hosted file from the upstream checkpoint, pin the +checkpoint by URL and hash, and state the opset and IR version (keep them +loadable by the oldest ONNX Runtime the `ai-runtime` add-on pins — 1.23.2 on +Intel Macs). + +## The worker side + +`worker/src/addons.rs`: + +- `addon_dir(addons_root, id)`, `site_packages(addons_root, id)` — where it is. +- `state(addons_root, id, platform)` → `Installed | Missing | Outdated | Unsupported | Unknown`, + and `is_installed(...)`: the marker for this id, version and platform, plus + every expected path (a model pack's files; a python add-on's `site-packages`). +- `require(addons_root, &[ids], feature, platform) -> anyhow::Result<()>` — + refuses with a message naming the add-on(s) and Settings → Add-ons + (`DependencyLocator::require_addons(&[ids], feature)` fills in the root and + platform). + +A pass declares what it needs in `ProcessingPipeline::required_addons()` +(`AddonNeed { feature, ids }`). `ScriptGenerator::ensure_methods_ready` refuses +the job unless all are installed, exactly as for AnimeJaNai, and emits the +`{{#AI_RUNTIME}}` block when any of them (or what they require) is +`ai-runtime`. + +## The AI runtime add-on (`ai-runtime`) + +ONNX Runtime and NumPy, as wheels for the deps bundle's Python 3.12, for +passes that run a model the VSNCNN plugin can't (per-crop inference, several +inputs, NumPy around the network): + +| Platform | onnxruntime | Execution providers | +|---|---|---| +| macOS arm64 | 1.31.1 | Core ML, CPU | +| macOS x64 | 1.23.2 (the last Intel wheel; needs macOS 13) | Core ML, CPU | +| Windows x64 | onnxruntime-directml 1.24.4 | DirectML (any DX12 GPU), CPU | +| Linux x64 / arm64 | 1.31.1 | CPU | + +plus NumPy 2.5.4. `import onnxruntime` and an `InferenceSession` need nothing +else (checked: no sympy, protobuf, flatbuffers or coloredlogs at run time, +though the wheels declare them). + +### Using it from a pass + +Both templates carry, near the top: + +```python +{{#AI_RUNTIME}} +import ai_runtime +ai_runtime.setup({{AI_RUNTIME_DIR}}) +{{/AI_RUNTIME}} +``` + +`ScriptGenerator` keeps it (with `{{AI_RUNTIME_DIR}}` the add-on's directory as +a Python string literal) when the job needs the runtime, and removes it +otherwise. A pass's own block then uses `templates/ai_runtime.py`: + +| Call | | +|---|---| +| `setup(*addon_dirs)` | puts each add-on's `site-packages` on `sys.path` (first argument wins) and, on Windows, registers onnxruntime's DLL directory. Done by the block; never rely on `PYTHONPATH` — Windows' embeddable Python ignores it. | +| `session(model, force_cpu=False, providers=None)` | a cached `InferenceSession` for a path or the model's bytes, on TensorRT → CUDA → DirectML → Core ML → CPU (those available), retried on the CPU if a GPU provider fails (`ai_runtime.last_fallback` says why). | +| `preferred_providers(force_cpu=False)`, `available_providers()`, `session_provider(sess)` | the provider choice. `VAPOURBOX_AI_FORCE_CPU=1` forces the CPU everywhere. | +| `frame_to_nchw(frame)` / `nchw_to_frame(array, frame)` | planar RGBS `VideoFrame` ↔ float32 `(1, 3, h, w)`. | +| `map_frames(clip, fn, width=None, height=None)` | apply `fn(nchw) -> nchw` to every frame of an RGBS clip, at an output size, keeping frame properties. | + +TensorRT/CUDA are hooks for a future NVIDIA add-on (its directory passed to +`setup()` before `ai-runtime`'s); today's wheels don't offer them. + +### The probe + +`vapourbox-worker --probe-ai-runtime` imports the runtime the way a job does, +runs the committed self-test model (`Tests/TestResources/ai_runtime_selftest.onnx`, +embedded in `ai_runtime.py`, built by `Scripts/models/make_ai_runtime_selftest.py`) +directly and through `map_frames`, and prints JSON: `available`, `reason`, +`detail`, `message` (user-facing, with a remedy), `version`, `providers`, +`provider`, `fallback`. Only a positive result is cached, per boot, add-on +version and force-CPU setting (`/ai-runtime-probe.json`). The app reads +it with `WorkerManager.probeAiRuntime()` → `AiRuntimeStatus`. + +## Adding an add-on: checklist + +1. **Conversion** (if upstream ships no ONNX): a script in `Scripts/models/`, + run in the conversion venv, writing into `addon-staging/`. +2. **Manifest**: `app/assets/addons/.json`, every file pinned by size and + SHA-256, `requires: ["ai-runtime"]` for an ONNX Runtime model. Self-hosted + files use the `addon-models-v1` URL above. +3. **Credits** — `addon_service_test.dart` ("credits agree everywhere") fails + until all exist for the main component and each `components` entry: + a `licenses/NOTICES.txt` entry headed ` — ` whose body + names the credit and says it is an add-on; an About tile in + `app/lib/views/about_dialog.dart` named ` (optional download…)` + with the same licence; the credit in the README's Acknowledgments. Take + holders and licences from upstream's LICENSE, never from a handle (see + CLAUDE.md, "Attribution"). A non-commercial licence needs `usageNote`. +4. **Worker**: the pass adds an `AddonNeed` in `ProcessingPipeline::required_addons()` + and loads its files from `addons::addon_dir(...)`; its template block uses + `ai_runtime` (both templates). +5. **App**: the feature calls `AddonPrompt.ensure(context, [''], reason: …)` + when it is chosen. +6. **Tests**: a heavy integration test that calls + `WorkerHarness.ensureAddon('')` (installs it and its requirements with + the app's own installer, so a dead URL or changed hash fails CI) and runs + the feature. Before upload, run it with `VAPOURBOX_ADDON_MIRROR=addon-staging`. +7. **Upload** (the owner): attach the files in `addon-staging/` to the + `addon-models-v1` release (`gh release upload addon-models-v1 `; + the release is created once with `--latest=false`, like every deps and + whisper release, so it never takes Latest from the app). Never replace a + file a released manifest already pins — a changed file gets a new name and + the manifest a new `version`. Then run the test again without the mirror. diff --git a/docs/BUILDING.md b/docs/BUILDING.md index b2fb3533..239f58b3 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -46,6 +46,7 @@ VapourBox/ │ │ ├── services/ # WorkerManager, PresetService, FilterLoader │ │ └── widgets/ # Reusable UI widgets │ ├── assets/filters/ # Built-in filter schemas (JSON) + manifest.json +│ ├── assets/addons/ # Add-on manifests, one JSON per add-on (docs/ADDONS.md) │ ├── assets/deps-version.json# Which deps release the app expects │ ├── test/ # Unit, shell-out and integration tests │ ├── macos/ # macOS platform config @@ -74,6 +75,7 @@ VapourBox/ ├── packaging/ # macOS + Windows packaging assets (entitlements etc.) ├── licenses/ # License files (GPL, LGPL, NOTICES) ├── Scripts/ # Build, packaging, and release scripts +│ └── models/ # Model conversion scripts for self-hosted add-on files ├── Tests/TestResources/ # Committed test fixtures (small_clip.mp4, etc.) ├── docs/ # Additional documentation │ └── reports/ # Investigation reports: screenshots, measurements, decisions diff --git a/docs/ENGINEERING_NOTES.md b/docs/ENGINEERING_NOTES.md index a5eeb270..e64a1907 100644 --- a/docs/ENGINEERING_NOTES.md +++ b/docs/ENGINEERING_NOTES.md @@ -2882,6 +2882,39 @@ MPEG-2 at 1000/1500/3000 kbps with `+ildct+ilme`: - The film fixture's reference carries `small_clip.mp4`'s own (upscaled) H.264 blocks; PSNR rewards deblocking's smoothing, and VMAF dips slightly. +### AI models become add-ons; the AI runtime is ONNX Runtime wheels (2026-11-08) + +Policy (owner's decision): every AI model ships as an optional add-on, never +in the deps bundle, through one generic manifest format (docs/ADDONS.md). The +first manifest add-on is `ai-runtime`, ONNX Runtime + NumPy for passes that +need per-crop inference or NumPy around a network, which VSNCNN can't do. +The facts the manifest rests on, checked against PyPI and the wheels on this +date (groundwork, not a quality comparison, so no report): + +- **What `import onnxruntime` needs is NumPy, nothing else.** The wheels + declare flatbuffers, packaging, protobuf (and on 1.23.2 / DirectML + coloredlogs and sympy), but importing the package and running an + `InferenceSession` in the deps Python with only onnxruntime + numpy on + `sys.path` loaded no other third-party module; a scan of the 1.23.2 and + DirectML wheels' runtime modules found no top-level import of them + either (they are used by `tools/`, `transformers/`, `quantization/`). +- **Intel Macs stop at onnxruntime 1.23.2**, whose wheel is + `macosx_13_0_x86_64` — above the x64 app floor (12.0), so the add-on carries + `minOs: 13.0` there and an older Mac is told so. NumPy 2.5.4's Intel wheel + is `macosx_10_13`, and its arm64 one `macosx_14_0` (Accelerate), both + within their app floors. +- **Windows uses onnxruntime-directml 1.24.4**, so any DirectX 12 GPU works. + Its `DirectML.dll` (1.15.4) is under Microsoft's proprietary DirectML + licence (read from the Microsoft.AI.DirectML 1.15.4 NuGet package — the + wheel ships no licence text for it), credited as its own component. +- **Windows' embeddable Python ignores PYTHONPATH** (its `._pth` fixes + `sys.path`), so `ai_runtime.setup()` inserts the add-on's `site-packages` + itself, and registers `onnxruntime/capi` with `os.add_dll_directory`. + Untested on Windows from macOS: the nightly heavy run is the first check. +- On an M-series Mac the self-test session reports `CoreMLExecutionProvider` + first; whether Core ML or the CPU fallback executed the two-node model is + not distinguishable from `get_providers()`. + ### Testing a deps change before publishing A PR that changes `deps/` has a chicken-and-egg problem: `ci-test.yml` and From 44a3f0a9fb90b3a9475ea2abfe183587cb710361 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 8 Nov 2026 17:34:11 +1100 Subject: [PATCH 05/61] fix(addons): commit the ai-runtime manifest The root .gitignore's 'addons/' (the downloaded add-ons directory) also matched app/assets/addons/, so the manifest was never committed and the worker's build.rs found no manifests outside the worktree that wrote it. Anchor the rule to the repository root. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 2 +- app/assets/addons/ai-runtime.json | 111 ++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 app/assets/addons/ai-runtime.json diff --git a/.gitignore b/.gitignore index da368673..f37d339e 100644 --- a/.gitignore +++ b/.gitignore @@ -117,7 +117,7 @@ BundledDependencies/Scripts/*.py !BundledDependencies/Scripts/.gitkeep # Add-ons (whisper binary + models, downloaded at runtime) -addons/ +/addons/ # Claude Code .claude/ diff --git a/app/assets/addons/ai-runtime.json b/app/assets/addons/ai-runtime.json new file mode 100644 index 00000000..3c7210c4 --- /dev/null +++ b/app/assets/addons/ai-runtime.json @@ -0,0 +1,111 @@ +{ + "id": "ai-runtime", + "version": "1.0.0", + "name": "AI runtime (ONNX Runtime)", + "feature": "AI models", + "description": "Runs AI models for the features that need it, on the graphics card where it can (DirectML on Windows, Core ML on macOS) and otherwise on the CPU: Microsoft's ONNX Runtime and NumPy, installed for VapourBox's own Python. The AI model add-ons need it.", + "licence": "MIT", + "licenceUrl": "https://github.com/microsoft/onnxruntime/blob/main/LICENSE", + "credit": "Microsoft Corporation", + "noticeName": "ONNX Runtime", + "homepage": "https://onnxruntime.ai", + "components": [ + { + "noticeName": "NumPy", + "licence": "BSD-3-Clause", + "licenceUrl": "https://github.com/numpy/numpy/blob/main/LICENSE.txt", + "credit": "NumPy Developers", + "homepage": "https://numpy.org" + }, + { + "noticeName": "DirectML", + "licence": "Microsoft Software License Terms", + "licenceUrl": "https://www.nuget.org/packages/Microsoft.AI.DirectML/1.15.4/License", + "credit": "Microsoft Corporation", + "homepage": "https://github.com/microsoft/DirectML", + "platforms": ["windows-x64"] + } + ], + "requires": [], + "kind": "python", + "python": "cp312", + "platforms": { + "macos-arm64": { "minOs": "14.0" }, + "macos-x64": { "minOs": "13.0" }, + "windows-x64": {}, + "linux-x64": {}, + "linux-arm64": {} + }, + "files": [ + { + "name": "onnxruntime-1.31.1-cp312-cp312-macosx_14_0_arm64.whl", + "url": "https://files.pythonhosted.org/packages/0e/f7/cecc0efeeea83bff2f7789ae1b80f27fa0f0210d6d9d66d1f0031a6b5e47/onnxruntime-1.31.1-cp312-cp312-macosx_14_0_arm64.whl", + "size": 20882125, + "sha256": "96dd2564b10ea7153ad9c207e7cd8e5917cb3ba521e0e0a62ac03486fdc8d56c", + "platforms": ["macos-arm64"] + }, + { + "name": "numpy-2.5.4-cp312-cp312-macosx_14_0_arm64.whl", + "url": "https://files.pythonhosted.org/packages/31/2f/5ea3570fcb8ccd0882bea99436a513b2c85dad8f774a2057849130a8fb99/numpy-2.5.4-cp312-cp312-macosx_14_0_arm64.whl", + "size": 5451717, + "sha256": "9cb18a327b49c5c337f972b03682f6a49855525faaf3c0d3e9c96cd0fd8880a8", + "platforms": ["macos-arm64"] + }, + { + "name": "onnxruntime-1.23.2-cp312-cp312-macosx_13_0_x86_64.whl", + "url": "https://files.pythonhosted.org/packages/91/9d/a81aafd899b900101988ead7fb14974c8a58695338ab6a0f3d6b0100f30b/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_x86_64.whl", + "size": 19157705, + "sha256": "218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b", + "platforms": ["macos-x64"] + }, + { + "name": "numpy-2.5.4-cp312-cp312-macosx_10_13_x86_64.whl", + "url": "https://files.pythonhosted.org/packages/d0/97/ba2074e92b7befea137e77ea8471e768bbd87c339b7e8c9f5a931949f977/numpy-2.5.4-cp312-cp312-macosx_10_13_x86_64.whl", + "size": 17001609, + "sha256": "c6342f54c67093cae5c0227eb0eb772fdb79f2a2c37a6eb278b9909ee06aa356", + "platforms": ["macos-x64"] + }, + { + "name": "onnxruntime_directml-1.24.4-cp312-cp312-win_amd64.whl", + "url": "https://files.pythonhosted.org/packages/88/ea/33814eb0ec96775eda4c1d30b0d86e91d7d2cd0d84c66d3915aef0e06fa3/onnxruntime_directml-1.24.4-cp312-cp312-win_amd64.whl", + "size": 25111930, + "sha256": "f2ecb68b7b7b259d2ef3112ae760149f9b5a1e7c0fbb73d539da6250a648a614", + "platforms": ["windows-x64"] + }, + { + "name": "numpy-2.5.4-cp312-cp312-win_amd64.whl", + "url": "https://files.pythonhosted.org/packages/12/33/fefe62073dc8acfd0f2b9ed7c003af2f50aa61555e113e6db02b8f79f145/numpy-2.5.4-cp312-cp312-win_amd64.whl", + "size": 12573091, + "sha256": "a7b1b6353e36a7e50de2973a38d705c88ee93adcf120673cee7f45a4a3fa223a", + "platforms": ["windows-x64"] + }, + { + "name": "onnxruntime-1.31.1-cp312-cp312-manylinux_2_28_x86_64.whl", + "url": "https://files.pythonhosted.org/packages/eb/27/c6a823bac55b2977bea2027c3ec9393a2c1002cb9a09eb40354563e02730/onnxruntime-1.31.1-cp312-cp312-manylinux_2_28_x86_64.whl", + "size": 23760988, + "sha256": "1f3dea47f2f0e5117c2404cea7d21fd253ae7836a3bf167a501cdfbb2600d536", + "platforms": ["linux-x64"] + }, + { + "name": "numpy-2.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", + "url": "https://files.pythonhosted.org/packages/43/a3/c799c62e19c337e6d3770b08e475887fb30ce8477d3c09efca6b2f0228a6/numpy-2.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", + "size": 16727283, + "sha256": "fbde6962867ee75b48b0ee29b2b9372ec5d617799dbaf38e82dc0596f2f7738a", + "platforms": ["linux-x64"] + }, + { + "name": "onnxruntime-1.31.1-cp312-cp312-manylinux_2_28_aarch64.whl", + "url": "https://files.pythonhosted.org/packages/ba/e4/1ed1a7a5f4702d899091fa2b976d377995250c307326ec8dc9ae6378aa13/onnxruntime-1.31.1-cp312-cp312-manylinux_2_28_aarch64.whl", + "size": 21420803, + "sha256": "0b9a492176886992e0e875f06bc0367e333f5fff11711a2d642e18b02f3009cc", + "platforms": ["linux-arm64"] + }, + { + "name": "numpy-2.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", + "url": "https://files.pythonhosted.org/packages/dc/96/8319e2457ae4333c62c815c7006b869a4f60985c1e01024c2f8c6c040fe5/numpy-2.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", + "size": 15695312, + "sha256": "fe4d21ab149f15e4e6043dfb0de87e6e5f34ac176cde83060e9802981fca2ac2", + "platforms": ["linux-arm64"] + } + ] +} From 723c87143842c80287d49d345eca4a160e30642c Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 8 Nov 2026 18:16:44 +1100 Subject: [PATCH 06/61] feat(models): convert Real-ESRGAN general and x4plus/x2plus to ONNX Scripts/models/convert_realesrgan.py defines SRVGGNetCompact and RRDBNet (no basicsr needed), loads xinntao's checkpoints pinned by URL and SHA-256, and exports ONNX in the shape VSNCNN already runs (input/output, NCHW RGB float, dynamic batch/height/width, opset 13, IR 7). x2plus's pixel_unshuffle becomes the equivalent one-hot stride-2 convolution, which ncnn can import (checked exactly equal). Parity against PyTorch is printed; two runs are byte-identical. Co-Authored-By: Claude Opus 5.5 --- Scripts/models/convert_realesrgan.py | 288 +++++++++++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 Scripts/models/convert_realesrgan.py diff --git a/Scripts/models/convert_realesrgan.py b/Scripts/models/convert_realesrgan.py new file mode 100644 index 00000000..9d8c8e4c --- /dev/null +++ b/Scripts/models/convert_realesrgan.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +"""Convert xinntao's Real-ESRGAN PyTorch checkpoints to ONNX for VSNCNN. + +Upstream (https://github.com/xinntao/Real-ESRGAN, BSD-3-Clause) publishes +these models only as `.pth` checkpoints. The Crop & Resize AI upscaler runs +networks through vs-mlrt's VSNCNN (`ncnn.Model`), which reads ONNX, so we +convert them and host the result on the `addon-models-v1` release (see +docs/ADDONS.md, "Hosting files we convert ourselves"). + +Two architectures, each defined here so no basicsr/realesrgan install is +needed (both transcribed from basicsr 1.4.2 / realesrgan 0.3.0, the code the +checkpoints were trained with): + +- SRVGGNetCompact (`realesr-general-x4v3`, `realesr-general-wdn-x4v3`): the + same architecture as vs-mlrt's `RealESRGANv2-animevideo-*` ONNX the deps + bundle ships, with 32 body convs instead of 16. +- RRDBNet (`RealESRGAN_x4plus`, `RealESRGAN_x2plus`): 23 RRDB blocks. + +The ONNX match the conventions of the models VSNCNN already runs: one input +`input` and one output `output`, float32 NCHW RGB in 0..1, batch, height and +width dynamic, opset 13, IR version 7 (vs-mlrt's own conversions). Two +deliberate choices keep them inside what ncnn's ONNX importer handles: + +- x2plus starts with `pixel_unshuffle(2)`, which the TorchScript exporter + writes as a 6-D Reshape/Transpose pair ncnn cannot import. It is replaced by + the equivalent stride-2 convolution with one-hot weights (exact: every output + is one input sample times 1.0). +- The legacy (TorchScript) exporter is used, not dynamo: it writes plain + Conv/LeakyRelu/PRelu/Resize/Add graphs at opset 13. + +Every checkpoint is pinned by URL and SHA-256; a different download fails. +With torch 2.15.0 and onnx 1.23.2 (the conversion venv of 2026-11) the output +is byte-for-byte reproducible: two runs give the hashes pinned in +`app/assets/addons/upscale-general.json` / `upscale-esrgan-plus.json`. +After export the script checks PyTorch against ONNX Runtime on a seeded random +image and a smooth synthetic one, and prints the maximum and mean absolute +difference (in 0..1 units) — the parity numbers in +docs/reports/issue-ai-upscale-addons/. + +Usage (in the conversion venv, see docs/ADDONS.md): + + python -I Scripts/models/convert_realesrgan.py [model ...] + +`` holds the downloaded `.pth` files (or they are fetched +into it); each model is written as `/upscale-.onnx`. +""" +import hashlib +import os +import sys +import urllib.request + +import numpy as np +import onnx +import onnxruntime as ort +import torch +from torch import nn +from torch.nn import functional as F + +OPSET = 13 +IR_VERSION = 7 + +REL = "https://github.com/xinntao/Real-ESRGAN/releases/download" + +# name -> (checkpoint url, sha256, architecture, kwargs) +MODELS = { + "realesr-general-x4v3": ( + f"{REL}/v0.2.5.0/realesr-general-x4v3.pth", + "8dc7edb9ac80ccdc30c3a5dca6616509367f05fbc184ad95b731f05bece96292", + "srvgg", dict(num_feat=64, num_conv=32, upscale=4), + ), + "realesr-general-wdn-x4v3": ( + f"{REL}/v0.2.5.0/realesr-general-wdn-x4v3.pth", + "1641f8c4464b9f097c9fdda5589273713f67cf59f3d909e0bd688f0cee269dca", + "srvgg", dict(num_feat=64, num_conv=32, upscale=4), + ), + "RealESRGAN_x4plus": ( + f"{REL}/v0.1.0/RealESRGAN_x4plus.pth", + "4fa0d38905f75ac06eb49a7951b426670021be3018265fd191d2125df9d682f1", + "rrdb", dict(scale=4), + ), + "RealESRGAN_x2plus": ( + f"{REL}/v0.2.1/RealESRGAN_x2plus.pth", + "49fafd45f8fd7aa8d31ab2a22d14d91b536c34494a5cfe31eb5d89c2fa266abb", + "rrdb", dict(scale=2), + ), +} + + +# --- SRVGGNetCompact (realesrgan/archs/srvgg_arch.py) ----------------------- + +class SRVGGNetCompact(nn.Module): + def __init__(self, num_in_ch=3, num_out_ch=3, num_feat=64, num_conv=16, upscale=4): + super().__init__() + self.upscale = upscale + self.body = nn.ModuleList() + self.body.append(nn.Conv2d(num_in_ch, num_feat, 3, 1, 1)) + self.body.append(nn.PReLU(num_parameters=num_feat)) + for _ in range(num_conv): + self.body.append(nn.Conv2d(num_feat, num_feat, 3, 1, 1)) + self.body.append(nn.PReLU(num_parameters=num_feat)) + self.body.append(nn.Conv2d(num_feat, num_out_ch * upscale * upscale, 3, 1, 1)) + self.upsampler = nn.PixelShuffle(upscale) + + def forward(self, x): + out = x + for layer in self.body: + out = layer(out) + out = self.upsampler(out) + return out + F.interpolate(x, scale_factor=self.upscale, mode="nearest") + + +# --- RRDBNet (basicsr/archs/rrdbnet_arch.py) -------------------------------- + +class ResidualDenseBlock(nn.Module): + def __init__(self, num_feat=64, num_grow_ch=32): + super().__init__() + self.conv1 = nn.Conv2d(num_feat, num_grow_ch, 3, 1, 1) + self.conv2 = nn.Conv2d(num_feat + num_grow_ch, num_grow_ch, 3, 1, 1) + self.conv3 = nn.Conv2d(num_feat + 2 * num_grow_ch, num_grow_ch, 3, 1, 1) + self.conv4 = nn.Conv2d(num_feat + 3 * num_grow_ch, num_grow_ch, 3, 1, 1) + self.conv5 = nn.Conv2d(num_feat + 4 * num_grow_ch, num_feat, 3, 1, 1) + self.lrelu = nn.LeakyReLU(negative_slope=0.2, inplace=True) + + def forward(self, x): + x1 = self.lrelu(self.conv1(x)) + x2 = self.lrelu(self.conv2(torch.cat((x, x1), 1))) + x3 = self.lrelu(self.conv3(torch.cat((x, x1, x2), 1))) + x4 = self.lrelu(self.conv4(torch.cat((x, x1, x2, x3), 1))) + x5 = self.conv5(torch.cat((x, x1, x2, x3, x4), 1)) + return x5 * 0.2 + x + + +class RRDB(nn.Module): + def __init__(self, num_feat, num_grow_ch=32): + super().__init__() + self.rdb1 = ResidualDenseBlock(num_feat, num_grow_ch) + self.rdb2 = ResidualDenseBlock(num_feat, num_grow_ch) + self.rdb3 = ResidualDenseBlock(num_feat, num_grow_ch) + + def forward(self, x): + out = self.rdb3(self.rdb2(self.rdb1(x))) + return out * 0.2 + x + + +class PixelUnshuffleConv(nn.Module): + """`F.pixel_unshuffle(x, s)` as a stride-s convolution with one-hot + weights: output channel c*s*s + i*s + j takes input channel c at offset + (i, j) of each s x s cell — pixel_unshuffle's channel order.""" + + def __init__(self, channels, s): + super().__init__() + w = torch.zeros(channels * s * s, channels, s, s) + for c in range(channels): + for i in range(s): + for j in range(s): + w[c * s * s + i * s + j, c, i, j] = 1.0 + self.register_buffer("weight", w) + self.s = s + + def forward(self, x): + return F.conv2d(x, self.weight, stride=self.s) + + +class RRDBNet(nn.Module): + def __init__(self, num_in_ch=3, num_out_ch=3, scale=4, num_feat=64, num_block=23, num_grow_ch=32): + super().__init__() + self.scale = scale + if scale == 2: + num_in_ch = num_in_ch * 4 + self.unshuffle = PixelUnshuffleConv(3, 2) + elif scale == 1: + num_in_ch = num_in_ch * 16 + self.unshuffle = PixelUnshuffleConv(3, 4) + else: + self.unshuffle = None + self.conv_first = nn.Conv2d(num_in_ch, num_feat, 3, 1, 1) + self.body = nn.Sequential(*[RRDB(num_feat, num_grow_ch) for _ in range(num_block)]) + self.conv_body = nn.Conv2d(num_feat, num_feat, 3, 1, 1) + self.conv_up1 = nn.Conv2d(num_feat, num_feat, 3, 1, 1) + self.conv_up2 = nn.Conv2d(num_feat, num_feat, 3, 1, 1) + self.conv_hr = nn.Conv2d(num_feat, num_feat, 3, 1, 1) + self.conv_last = nn.Conv2d(num_feat, num_out_ch, 3, 1, 1) + self.lrelu = nn.LeakyReLU(negative_slope=0.2, inplace=True) + + def forward(self, x): + feat = self.unshuffle(x) if self.unshuffle is not None else x + feat = self.conv_first(feat) + feat = feat + self.conv_body(self.body(feat)) + feat = self.lrelu(self.conv_up1(F.interpolate(feat, scale_factor=2, mode="nearest"))) + feat = self.lrelu(self.conv_up2(F.interpolate(feat, scale_factor=2, mode="nearest"))) + return self.conv_last(self.lrelu(self.conv_hr(feat))) + + +# --- conversion ------------------------------------------------------------- + +def sha256(path): + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +def fetch(url, digest, ckpt_dir): + path = os.path.join(ckpt_dir, url.rsplit("/", 1)[1]) + if not os.path.exists(path): + print(f"downloading {url}") + urllib.request.urlretrieve(url, path) + got = sha256(path) + if got != digest: + sys.exit(f"{path}: sha256 {got}, expected {digest}") + return path + + +def build(arch, kwargs, ckpt): + net = SRVGGNetCompact(**kwargs) if arch == "srvgg" else RRDBNet(**kwargs) + state = torch.load(ckpt, map_location="cpu", weights_only=True) + state = state.get("params_ema", state.get("params", state)) + missing, unexpected = net.load_state_dict(state, strict=False) + # The only parameter not in the checkpoint is our unshuffle conv's buffer. + if unexpected or any(not k.startswith("unshuffle.") for k in missing): + sys.exit(f"{ckpt}: missing {missing}, unexpected {unexpected}") + if getattr(net, "unshuffle", None) is not None: + # The replacement must be exactly upstream's pixel_unshuffle. + x = torch.rand(1, 3, 12, 16) + if not torch.equal(net.unshuffle(x), F.pixel_unshuffle(x, net.unshuffle.s)): + sys.exit("PixelUnshuffleConv differs from F.pixel_unshuffle") + return net.eval() + + +def test_images(h=96, w=128): + rng = np.random.default_rng(1) + noise = rng.random((1, 3, h, w), dtype=np.float32) + yy, xx = np.mgrid[0:h, 0:w].astype(np.float32) + smooth = np.stack([ + 0.5 + 0.4 * np.sin(xx / 9.0), + 0.5 + 0.4 * np.cos(yy / 7.0), + (xx + yy) / (h + w), + ])[None].astype(np.float32) + return {"random": noise, "smooth": smooth} + + +def convert(name, ckpt_dir, out_dir): + url, digest, arch, kwargs = MODELS[name] + net = build(arch, kwargs, fetch(url, digest, ckpt_dir)) + out = os.path.join(out_dir, f"upscale-{name}.onnx") + dummy = torch.rand(1, 3, 64, 64) + torch.onnx.export( + net, (dummy,), out, dynamo=False, opset_version=OPSET, + input_names=["input"], output_names=["output"], + dynamic_axes={"input": {0: "batch", 2: "height", 3: "width"}, + "output": {0: "batch", 2: "height", 3: "width"}}, + do_constant_folding=True, + ) + model = onnx.load(out) + model.ir_version = IR_VERSION + model.producer_name = "vapourbox" + onnx.checker.check_model(model) + onnx.save(model, out) + + sess = ort.InferenceSession(out, providers=["CPUExecutionProvider"]) + ops = sorted({n.op_type for n in model.graph.node}) + print(f"{name}: {os.path.getsize(out)} bytes, sha256 {sha256(out)}, ops {ops}") + for label, img in test_images().items(): + with torch.no_grad(): + ref = net(torch.from_numpy(img)).numpy() + got = sess.run(None, {"input": img})[0] + diff = np.abs(ref - got) + print(f" parity {label}: shape {got.shape}, max {diff.max():.3e}, mean {diff.mean():.3e}" + f" (8-bit levels: max {diff.max() * 255:.4f})") + return out + + +def main(): + if len(sys.argv) < 3: + sys.exit(__doc__) + ckpt_dir, out_dir = sys.argv[1], sys.argv[2] + names = sys.argv[3:] or list(MODELS) + for n in names: + if n not in MODELS: + sys.exit(f"unknown model {n}; known: {', '.join(MODELS)}") + torch.manual_seed(0) + for n in names: + convert(n, ckpt_dir, out_dir) + + +if __name__ == "__main__": + main() From c513f7ad3cb334753f79cb1d7e4c0851e356b5d6 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 8 Nov 2026 18:16:44 +1100 Subject: [PATCH 07/61] feat(upscale): add-on AI upscalers and an AI strength control in the worker Three new Crop & Resize methods, run on VSNCNN like the bundled anime models, with their models in two manifest add-ons: - upscale-general: Real-ESRGAN General (realesr-general-x4v3 and its denoise variant, converted, to be hosted on addon-models-v1) and jcj83429's 2xLiveActionV1_SPAN (Apache-2.0, upstream's ONNX pinned at commit 5d8cdd2e). - upscale-esrgan-plus: Real-ESRGAN x4plus / x2plus (converted). Factor rules: General is 4x only, so 2x resamples its result down (Spline36, in float RGB, before the conversion back); SPAN is 2x only, so 4x runs it twice; x2plus/x4plus by factor. x4plus runs in 384-pixel tiles: whole-frame at 720x576 lost the Vulkan device on an M1 Pro and 512 tiles thrashed. ProcessingPipeline::required_addons() declares the add-on, so a job without it is refused naming it and Settings -> Add-ons; only macOS arm64, Windows x64 and Linux x64 (where VSNCNN ships) are offered. aiStrength (0-1, default 1) blends any AI upscale with a Spline36 upscale of the same frame in one std.Merge at the clip's format; at 1.0 nothing is emitted and the script is unchanged. Tests 185-187. Co-Authored-By: Claude Opus 5.5 --- app/assets/addons/upscale-esrgan-plus.json | 33 +++ app/assets/addons/upscale-general.json | 48 +++++ worker/src/lib.rs | 1 + worker/src/main.rs | 1 + worker/src/models/crop_resize_parameters.rs | 215 +++++++++++++++++++- worker/src/models/processing_pipeline.rs | 11 +- worker/src/script_generator.rs | 33 ++- worker/src/upscale_addons.rs | 88 ++++++++ worker/templates/pipeline_template.vpy | 30 ++- worker/templates/preview_template.vpy | 30 ++- worker/tests/filter_integration_test.rs | 171 ++++++++++++++++ 11 files changed, 637 insertions(+), 24 deletions(-) create mode 100644 app/assets/addons/upscale-esrgan-plus.json create mode 100644 app/assets/addons/upscale-general.json create mode 100644 worker/src/upscale_addons.rs diff --git a/app/assets/addons/upscale-esrgan-plus.json b/app/assets/addons/upscale-esrgan-plus.json new file mode 100644 index 00000000..7a0fb6c8 --- /dev/null +++ b/app/assets/addons/upscale-esrgan-plus.json @@ -0,0 +1,33 @@ +{ + "id": "upscale-esrgan-plus", + "version": "1.0.0", + "name": "Real-ESRGAN x4plus / x2plus models", + "feature": "AI upscaling", + "description": "Real-ESRGAN's original large models (x4plus and x2plus) for Crop & Resize: the most detailed Real-ESRGAN upscale of real-world footage, run on the graphics card, and very slow — about a second per DVD frame at 2x and ten at 4x on an M1 Pro.", + "licence": "BSD-3-Clause", + "licenceUrl": "https://github.com/xinntao/Real-ESRGAN/blob/master/LICENSE", + "credit": "Xintao Wang", + "noticeName": "Real-ESRGAN x4plus / x2plus models", + "homepage": "https://github.com/xinntao/Real-ESRGAN", + "requires": [], + "kind": "files", + "platforms": { + "macos-arm64": {}, + "windows-x64": {}, + "linux-x64": {} + }, + "files": [ + { + "name": "upscale-RealESRGAN_x4plus.onnx", + "url": "https://github.com/StuartCameronCode/VapourBox/releases/download/addon-models-v1/upscale-RealESRGAN_x4plus.onnx", + "size": 67051646, + "sha256": "86df1f74dbb7ee7521f2e342fb610eb7c147e161cbd377faafa706d4880716ca" + }, + { + "name": "upscale-RealESRGAN_x2plus.onnx", + "url": "https://github.com/StuartCameronCode/VapourBox/releases/download/addon-models-v1/upscale-RealESRGAN_x2plus.onnx", + "size": 67073184, + "sha256": "0fb8264f02ead334932e50fd5a23d8ca4884fcbaa747943acf5ae6278a112d49" + } + ] +} diff --git a/app/assets/addons/upscale-general.json b/app/assets/addons/upscale-general.json new file mode 100644 index 00000000..5283914a --- /dev/null +++ b/app/assets/addons/upscale-general.json @@ -0,0 +1,48 @@ +{ + "id": "upscale-general", + "version": "1.0.0", + "name": "General & live-action upscaling models", + "feature": "AI upscaling", + "description": "Two upscaling models for real-world footage rather than anime, run on the graphics card by Crop & Resize: Real-ESRGAN's general model (4x, with a noise-removing variant) and jcj83429's 2xLiveActionV1 SPAN model (2x), which repairs compression, blur and halos while keeping detail.", + "licence": "BSD-3-Clause", + "licenceUrl": "https://github.com/xinntao/Real-ESRGAN/blob/master/LICENSE", + "credit": "Xintao Wang", + "noticeName": "Real-ESRGAN general model", + "homepage": "https://github.com/xinntao/Real-ESRGAN", + "components": [ + { + "noticeName": "2xLiveActionV1_SPAN model", + "licence": "Apache-2.0", + "licenceUrl": "https://github.com/jcj83429/upscaling/blob/5d8cdd2e17750b64be39ccab3a8763d91128fe15/2xLiveActionV1_SPAN/LICENSE", + "credit": "jcj83429", + "homepage": "https://github.com/jcj83429/upscaling/tree/main/2xLiveActionV1_SPAN" + } + ], + "requires": [], + "kind": "files", + "platforms": { + "macos-arm64": {}, + "windows-x64": {}, + "linux-x64": {} + }, + "files": [ + { + "name": "upscale-realesr-general-x4v3.onnx", + "url": "https://github.com/StuartCameronCode/VapourBox/releases/download/addon-models-v1/upscale-realesr-general-x4v3.onnx", + "size": 4866424, + "sha256": "f3fe05b8a48076ec0fe58143cf39f34fbc93b42197f897e329613cf8521d79d8" + }, + { + "name": "upscale-realesr-general-wdn-x4v3.onnx", + "url": "https://github.com/StuartCameronCode/VapourBox/releases/download/addon-models-v1/upscale-realesr-general-wdn-x4v3.onnx", + "size": 4866424, + "sha256": "d0b7c5d84193670b94db88585fe2120cd48816c12f3cedfd8c803167db6b451a" + }, + { + "name": "2xLiveActionV1_SPAN_490000.onnx", + "url": "https://raw.githubusercontent.com/jcj83429/upscaling/5d8cdd2e17750b64be39ccab3a8763d91128fe15/2xLiveActionV1_SPAN/2xLiveActionV1_SPAN_490000.onnx", + "size": 1654748, + "sha256": "bfa72f3c6347076aed140d0836cee30c27ea434c047beeaf9466469483836ecc" + } + ] +} diff --git a/worker/src/lib.rs b/worker/src/lib.rs index e5326ee3..f063ceb6 100644 --- a/worker/src/lib.rs +++ b/worker/src/lib.rs @@ -17,6 +17,7 @@ pub mod progress_reporter; pub mod addons; pub mod ai_runtime; pub mod animejanai; +pub mod upscale_addons; pub mod gpu_plugin; pub mod hdr_metadata; pub mod native_res; diff --git a/worker/src/main.rs b/worker/src/main.rs index 6d5250ec..6e5f5839 100644 --- a/worker/src/main.rs +++ b/worker/src/main.rs @@ -31,6 +31,7 @@ mod progress_reporter; mod addons; mod ai_runtime; mod animejanai; +mod upscale_addons; mod gpu_plugin; mod hdr_metadata; mod native_res; diff --git a/worker/src/models/crop_resize_parameters.rs b/worker/src/models/crop_resize_parameters.rs index 129551c6..4481ae45 100644 --- a/worker/src/models/crop_resize_parameters.rs +++ b/worker/src/models/crop_resize_parameters.rs @@ -75,6 +75,16 @@ pub enum UpscaleMethod { /// Its models are an optional download, not in the deps bundle — see /// `animejanai.rs`. AnimeJanai, + /// Real-ESRGAN's general model for real-world footage (`realesr-general-x4v3`, + /// BSD-3), 4x native; a 2x upscale runs it at 4x and resamples down. Its + /// model is an add-on — see `upscale_addons.rs`. + RealEsrganGeneral, + /// jcj83429's 2xLiveActionV1_SPAN (Apache-2.0), 2x native, for live + /// action; 4x runs it twice. Add-on, as above. + LiveAction, + /// Real-ESRGAN x4plus / x2plus (BSD-3), the large RRDB networks, chosen by + /// factor. Add-on, as above. + RealEsrganPlus, } impl UpscaleMethod { @@ -83,9 +93,28 @@ impl UpscaleMethod { pub fn is_ai(self) -> bool { matches!( self, - UpscaleMethod::RealEsrgan | UpscaleMethod::RealCugan | UpscaleMethod::AnimeJanai + UpscaleMethod::RealEsrgan + | UpscaleMethod::RealCugan + | UpscaleMethod::AnimeJanai + | UpscaleMethod::RealEsrganGeneral + | UpscaleMethod::LiveAction + | UpscaleMethod::RealEsrganPlus ) } + + /// The manifest add-on holding this method's models, and the feature name + /// the worker's refusal starts with when it is missing. `None` for the + /// methods whose models are in the deps bundle (and for AnimeJaNai, whose + /// hand-written add-on is checked separately — see `animejanai.rs`). + pub fn addon(self) -> Option<(&'static str, &'static str)> { + use crate::upscale_addons::{ESRGAN_PLUS_ADDON, GENERAL_ADDON}; + match self { + UpscaleMethod::RealEsrganGeneral => Some((GENERAL_ADDON, "Real-ESRGAN General upscaling")), + UpscaleMethod::LiveAction => Some((GENERAL_ADDON, "Live-action upscaling")), + UpscaleMethod::RealEsrganPlus => Some((ESRGAN_PLUS_ADDON, "Real-ESRGAN x4plus upscaling")), + _ => None, + } + } } /// One network an AI upscale runs. @@ -98,6 +127,21 @@ pub enum AiModel { /// Decided in the script, per doubling, because only it knows the height /// after cropping (and after a first doubling). AnimeJanai { sd: &'static str, hd: &'static str }, + /// A model from a manifest add-on: `addons//` (see + /// `upscale_addons.rs`). + Addon { addon: &'static str, file: &'static str }, +} + +impl AiModel { + /// The network's own scale factor. Every bundled model and AnimeJaNai's + /// double; add-on models say (`upscale_addons::MODELS`). + pub fn scale(&self) -> u32 { + match self { + AiModel::Bundled(name) if name.ends_with("xsx4.onnx") => 4, + AiModel::Bundled(_) | AiModel::AnimeJanai { .. } => 2, + AiModel::Addon { file, .. } => crate::upscale_addons::scale_of(file), + } + } } /// What to do with the pixel aspect ratio (SAR) on output. @@ -291,6 +335,18 @@ pub struct CropResizeParameters { #[serde(default)] pub animejanai_sharp: bool, + /// Real-ESRGAN General's denoise variant (`realesr-general-wdn-x4v3`), + /// which also removes noise as it upscales. + #[serde(default)] + pub general_denoise: bool, + + /// How much of an AI upscale to keep, 0.0-1.0: the result is blended with + /// a Spline36 upscale of the same frame (`std.Merge`) at the clip's own + /// format. 1.0 (the default) is the network's output alone, and leaves the + /// script exactly as it was before this setting existed. + #[serde(default = "default_ai_strength")] + pub ai_strength: f64, + // --- nnedi3 controls for the edge-directed upscale paths --- // With EEDI3 selected these shape the nnedi3 sclip that guides it. @@ -341,6 +397,7 @@ fn default_true() -> bool { true } fn default_upscale_factor() -> i32 { 2 } fn default_cugan_noise() -> String { "conservative".to_string() } fn default_animejanai_model() -> String { "compact".to_string() } +fn default_ai_strength() -> f64 { 1.0 } impl Default for CropResizeParameters { fn default() -> Self { @@ -375,6 +432,8 @@ impl Default for CropResizeParameters { cugan_noise: default_cugan_noise(), animejanai_model: default_animejanai_model(), animejanai_sharp: false, + general_denoise: false, + ai_strength: default_ai_strength(), upscale_nsize: None, upscale_neurons: None, upscale_qual: None, @@ -441,8 +500,15 @@ impl CropResizeParameters { self.uses_ai_upscale() && self.upscale_method == UpscaleMethod::AnimeJanai } + /// The manifest add-on this pass's AI upscale needs, with the feature name + /// for the refusal (see `UpscaleMethod::addon`). + pub fn upscale_addon(&self) -> Option<(&'static str, &'static str)> { + if self.uses_ai_upscale() { self.upscale_method.addon() } else { None } + } + /// The models an AI upscale runs, in order, as paths under - /// `vapoursynth/ncnn/models/`. Factors follow the edge-directed path's + /// `vapoursynth/ncnn/models/` (or an add-on's directory for + /// `AiModel::Addon`). Factors follow the edge-directed path's /// rule (3x behaves as 2x). Real-ESRGAN has a 4x model; Real-CUGAN's /// bundled models are 2x only, so 4x runs it twice, denoising on the first /// pass only — a second denoise pass would smooth the result twice. @@ -476,10 +542,78 @@ impl CropResizeParameters { vec![first] }) } + UpscaleMethod::RealEsrganGeneral => { + use crate::upscale_addons::{GENERAL_ADDON, GENERAL_WDN_X4, GENERAL_X4}; + let file = if self.general_denoise { GENERAL_WDN_X4 } else { GENERAL_X4 }; + // 4x only: a 2x upscale resamples its result down (see + // `ai_resample_to`). + vec![AiModel::Addon { addon: GENERAL_ADDON, file }] + } + UpscaleMethod::LiveAction => { + use crate::upscale_addons::{GENERAL_ADDON, LIVE_ACTION_X2}; + let m = AiModel::Addon { addon: GENERAL_ADDON, file: LIVE_ACTION_X2 }; + vec![m; if four { 2 } else { 1 }] + } + UpscaleMethod::RealEsrganPlus => { + use crate::upscale_addons::{ESRGAN_PLUS_ADDON, PLUS_X2, PLUS_X4}; + vec![AiModel::Addon { addon: ESRGAN_PLUS_ADDON, file: if four { PLUS_X4 } else { PLUS_X2 } }] + } _ => Vec::new(), } } + /// The factor an AI upscale delivers: 4 for 4x, else 2 (3x behaves as 2x, + /// as on the edge-directed path). + pub fn ai_upscale_factor(&self) -> u32 { + if self.upscale_factor >= 4 { 4 } else { 2 } + } + + /// When the networks enlarge further than asked — Real-ESRGAN General is + /// 4x only, so a 2x job runs it at 4x — the factor to resample their + /// result down to (Spline36, in the networks' float RGB, before the + /// conversion back). `None` when the networks land on the factor exactly, + /// which leaves the script as it was. Upstream does the same for its own + /// `--outscale` (4x, then a Lanczos resize); see the report in + /// docs/reports/issue-ai-upscale-addons/ for the kernel comparison. + pub fn ai_resample_to(&self) -> Option { + let models = self.ai_upscale_models(); + if models.is_empty() { + return None; + } + let total: u32 = models.iter().map(AiModel::scale).product(); + (total != self.ai_upscale_factor()).then(|| self.ai_upscale_factor()) + } + + /// The AI strength as a blend weight for the network's output, or `None` + /// at full strength (1.0, the default), where no blend is emitted and the + /// script is unchanged. Clamped to 0..1; a non-finite value (a hand-edited + /// preset) is full strength. + pub fn ai_blend_weight(&self) -> Option { + let s = self.ai_strength; + if !s.is_finite() || s >= 1.0 { + None + } else { + Some(s.max(0.0)) + } + } + + /// When a frame runs in tiles, and their size: `(largest whole frame in + /// pixels, tile size)`. Above SD every model tiles at 512 — whole-frame + /// Real-CUGAN at 1080p ran out of GPU memory on a 16 GB Mac. Real-ESRGAN + /// x4plus (23 RRDB blocks, then two doublings at full width) is far larger: + /// whole-frame at 720x576 lost the Vulkan device (`VK_ERROR_DEVICE_LOST`, + /// then a segfault) and 512-pixel tiles thrashed at 29 s a frame, so it + /// runs in 384-pixel tiles (6.8 s a frame there; 256 took 10 s). x2plus + /// works at a quarter of the area until its last layers and runs a DVD + /// frame whole, so it keeps the common rule. + pub fn ai_tiling(&self) -> (&'static str, u32) { + if self.upscale_method == UpscaleMethod::RealEsrganPlus && self.ai_upscale_factor() == 4 { + ("384 * 384", 384) + } else { + ("720 * 576", 512) + } + } + /// Whether the pixel grid itself has to be resampled to square up the /// pixels. True even with no target size — "make this anamorphic source /// square" is a resize in its own right. @@ -836,4 +970,81 @@ mod tests { let d = CropResizeParameters::default(); assert_eq!((d.animejanai_model.as_str(), d.animejanai_sharp), ("compact", false)); } + + #[test] + fn test_addon_upscalers_follow_their_factor_rules() { + use crate::upscale_addons::*; + let p = |method, factor, denoise| CropResizeParameters { + enabled: true, + use_integer_upscale: true, + upscale_method: method, + upscale_factor: factor, + general_denoise: denoise, + ..Default::default() + }; + let a = |addon, file| AiModel::Addon { addon, file }; + // General is 4x only: 2x (and 3x) run it at 4x and resample down. + for f in [2, 3] { + let g = p(UpscaleMethod::RealEsrganGeneral, f, false); + assert_eq!(g.ai_upscale_models(), [a(GENERAL_ADDON, GENERAL_X4)]); + assert_eq!(g.ai_resample_to(), Some(2)); + } + let g4 = p(UpscaleMethod::RealEsrganGeneral, 4, true); + assert_eq!(g4.ai_upscale_models(), [a(GENERAL_ADDON, GENERAL_WDN_X4)]); + assert_eq!(g4.ai_resample_to(), None); + // SPAN is 2x only: 4x runs it twice. + assert_eq!(p(UpscaleMethod::LiveAction, 2, false).ai_upscale_models(), [a(GENERAL_ADDON, LIVE_ACTION_X2)]); + assert_eq!(p(UpscaleMethod::LiveAction, 4, false).ai_upscale_models(), [a(GENERAL_ADDON, LIVE_ACTION_X2); 2]); + assert_eq!(p(UpscaleMethod::LiveAction, 4, false).ai_resample_to(), None); + // x2plus / x4plus by factor, the 4x one in its own tiles. + let plus2 = p(UpscaleMethod::RealEsrganPlus, 2, false); + let plus4 = p(UpscaleMethod::RealEsrganPlus, 4, false); + assert_eq!(plus2.ai_upscale_models(), [a(ESRGAN_PLUS_ADDON, PLUS_X2)]); + assert_eq!(plus4.ai_upscale_models(), [a(ESRGAN_PLUS_ADDON, PLUS_X4)]); + assert_eq!(plus2.ai_tiling(), ("720 * 576", 512)); + assert_eq!(plus4.ai_tiling(), ("384 * 384", 384)); + // The bundled models never resample, and keep the common tiles. + for m in [UpscaleMethod::RealEsrgan, UpscaleMethod::RealCugan, UpscaleMethod::AnimeJanai] { + for f in [2, 4] { + assert_eq!(p(m, f, false).ai_resample_to(), None, "{m:?} {f}"); + assert_eq!(p(m, f, false).ai_tiling(), ("720 * 576", 512)); + } + } + // The add-on each needs, only when it is what the pass runs. + assert_eq!(plus4.upscale_addon().map(|a| a.0), Some(ESRGAN_PLUS_ADDON)); + assert_eq!(p(UpscaleMethod::LiveAction, 2, false).upscale_addon().map(|a| a.0), Some(GENERAL_ADDON)); + assert_eq!(p(UpscaleMethod::RealEsrgan, 2, false).upscale_addon(), None); + assert_eq!(p(UpscaleMethod::AnimeJanai, 2, false).upscale_addon(), None); + let off = CropResizeParameters { use_integer_upscale: false, ..plus4.clone() }; + assert_eq!(off.upscale_addon(), None); + for m in [UpscaleMethod::RealEsrganGeneral, UpscaleMethod::LiveAction, UpscaleMethod::RealEsrganPlus] { + assert!(m.is_ai(), "{m:?}"); + } + } + + #[test] + fn test_ai_strength_defaults_to_full_and_clamps() { + let d = CropResizeParameters::default(); + assert_eq!(d.ai_strength, 1.0); + assert_eq!(d.ai_blend_weight(), None); + let with = |s| CropResizeParameters { ai_strength: s, ..Default::default() }.ai_blend_weight(); + assert_eq!(with(0.5), Some(0.5)); + assert_eq!(with(-0.2), Some(0.0)); + assert_eq!(with(1.5), None); + assert_eq!(with(f64::NAN), None); + // Absent from old JSON = full strength; the camel-case wire names. + let p: CropResizeParameters = serde_json::from_str(r#"{"enabled":true}"#).unwrap(); + assert_eq!((p.ai_strength, p.general_denoise), (1.0, false)); + let json = serde_json::to_string(&CropResizeParameters { + upscale_method: UpscaleMethod::RealEsrganGeneral, + ..Default::default() + }) + .unwrap(); + assert!(json.contains("\"aiStrength\":1.0"), "{json}"); + assert!(json.contains("\"generalDenoise\":false"), "{json}"); + assert!(json.contains("\"upscaleMethod\":\"realEsrganGeneral\""), "{json}"); + for (v, s) in [(UpscaleMethod::LiveAction, "liveAction"), (UpscaleMethod::RealEsrganPlus, "realEsrganPlus")] { + assert_eq!(serde_json::to_string(&v).unwrap(), format!("\"{s}\"")); + } + } } diff --git a/worker/src/models/processing_pipeline.rs b/worker/src/models/processing_pipeline.rs index 827f957a..5b80c7cf 100644 --- a/worker/src/models/processing_pipeline.rs +++ b/worker/src/models/processing_pipeline.rs @@ -530,12 +530,17 @@ impl ProcessingPipeline { /// job, naming them and Settings → Add-ons, unless all are installed, and /// emits the `{{#AI_RUNTIME}}` block when any of them needs the AI runtime. /// - /// No pass uses one yet. A pass that runs a model from an add-on adds an - /// entry here when enabled, e.g. + /// A pass that runs a model from an add-on adds an entry here when + /// enabled, e.g. /// `AddonNeed { feature: "Face restoration", ids: vec!["gfpgan"] }` /// (a model add-on whose manifest `requires` `ai-runtime`). pub fn required_addons(&self) -> Vec { - Vec::new() + let mut needs = Vec::new(); + // Crop & Resize's add-on upscalers (`upscale_addons.rs`). + if let Some((id, feature)) = self.crop_resize.upscale_addon() { + needs.push(crate::addons::AddonNeed { feature, ids: vec![id] }); + } + needs } /// Whether this pipeline denoises with KNLMeans, which needs a working diff --git a/worker/src/script_generator.rs b/worker/src/script_generator.rs index 242f82cb..f3af06b7 100644 --- a/worker/src/script_generator.rs +++ b/worker/src/script_generator.rs @@ -2177,6 +2177,13 @@ impl ScriptGenerator { let dir = self.animejanai.as_deref().unwrap_or(Path::new("")); format!("({}, {})", py_str(&dir.join(sd)), py_str(&dir.join(hd))) } + // ensure_methods_ready has refused the job + // unless the add-on is installed here. + AiModel::Addon { addon, file } => { + let root = self.addons.as_ref().map(|(r, _)| r.as_path()); + let dir = crate::addons::addon_dir(root.unwrap_or(Path::new("")), addon); + py_str(&dir.join(file)) + } }) .collect::>() .join(", "), @@ -2190,6 +2197,27 @@ impl ScriptGenerator { "{{AI_SD_MAX_HEIGHT}}", &crate::animejanai::SD_MAX_HEIGHT.to_string(), ); + let (tile_above, tile) = resize.ai_tiling(); + script = script.replace("{{AI_TILE_ABOVE}}", tile_above); + script = script.replace("{{AI_TILE}}", &tile.to_string()); + match resize.ai_resample_to() { + Some(factor) => { + script = script.replace("{{#AI_RESAMPLE}}", ""); + script = script.replace("{{/AI_RESAMPLE}}", ""); + script = script.replace("{{AI_FACTOR}}", &factor.to_string()); + } + None => script = remove_block("{{#AI_RESAMPLE}}", "{{/AI_RESAMPLE}}", script), + } + // Full strength emits nothing: the script is exactly the + // one from before the setting existed. + match resize.ai_blend_weight() { + Some(weight) => { + script = script.replace("{{#AI_STRENGTH}}", ""); + script = script.replace("{{/AI_STRENGTH}}", ""); + script = script.replace("{{AI_STRENGTH}}", &format!("{weight:.3}")); + } + None => script = remove_block("{{#AI_STRENGTH}}", "{{/AI_STRENGTH}}", script), + } let color = job.color_metadata(); script = script.replace("{{AI_MATRIX}}", color.zimg_matrix(job.input_height)); script = script.replace( @@ -2229,7 +2257,10 @@ impl ScriptGenerator { UpscaleMethod::Spline36 | UpscaleMethod::RealEsrgan | UpscaleMethod::RealCugan - | UpscaleMethod::AnimeJanai => { + | UpscaleMethod::AnimeJanai + | UpscaleMethod::RealEsrganGeneral + | UpscaleMethod::LiveAction + | UpscaleMethod::RealEsrganPlus => { script = remove_block("{{#UPSCALE_NNEDI3}}", "{{/UPSCALE_NNEDI3}}", script); script = remove_block("{{#UPSCALE_EEDI3}}", "{{/UPSCALE_EEDI3}}", script); } diff --git a/worker/src/upscale_addons.rs b/worker/src/upscale_addons.rs new file mode 100644 index 00000000..856f1b90 --- /dev/null +++ b/worker/src/upscale_addons.rs @@ -0,0 +1,88 @@ +//! The Crop & Resize AI upscalers whose models are manifest add-ons +//! (`app/assets/addons/upscale-*.json`, see `addons.rs` and docs/ADDONS.md): +//! Real-ESRGAN's general-purpose model, a live-action SPAN model, and +//! Real-ESRGAN's large x4plus/x2plus models. +//! +//! They run through the same VSNCNN plugin and `{{#UPSCALE_AI}}` block as the +//! bundled anime models, so everything in `gpu_plugin.rs` (the Vulkan probe, +//! never autoloading) applies — none of them needs the ONNX Runtime add-on. +//! This module only says which add-on holds which file, what each network's +//! own factor is, and how large a tile it can run in. +//! +//! Every model is an add-on, never part of the deps bundle (docs/ADDONS.md). +//! Real-ESRGAN publishes `.pth` checkpoints only, so those are converted by +//! `Scripts/models/convert_realesrgan.py` and hosted on this project's +//! `addon-models-v1` release; the SPAN model is upstream's own ONNX. + +/// Real-ESRGAN General (`realesr-general-x4v3` and its denoise variant) and +/// 2xLiveActionV1_SPAN: both small, both for real-world footage. +pub const GENERAL_ADDON: &str = "upscale-general"; + +/// Real-ESRGAN x4plus / x2plus: the original 23-block RRDB networks — the +/// most detailed of the Real-ESRGAN family, and about 40x slower. +pub const ESRGAN_PLUS_ADDON: &str = "upscale-esrgan-plus"; + +/// `realesr-general-x4v3` (SRVGGNetCompact, 4x). +pub const GENERAL_X4: &str = "upscale-realesr-general-x4v3.onnx"; +/// `realesr-general-wdn-x4v3`: the same network trained to remove noise too +/// ("wdn": with denoise). +pub const GENERAL_WDN_X4: &str = "upscale-realesr-general-wdn-x4v3.onnx"; +/// jcj83429's 2xLiveActionV1_SPAN (SPAN, 2x). +pub const LIVE_ACTION_X2: &str = "2xLiveActionV1_SPAN_490000.onnx"; +/// `RealESRGAN_x4plus` (RRDBNet, 4x). +pub const PLUS_X4: &str = "upscale-RealESRGAN_x4plus.onnx"; +/// `RealESRGAN_x2plus` (RRDBNet, 2x). +pub const PLUS_X2: &str = "upscale-RealESRGAN_x2plus.onnx"; + +/// Every model file, with the add-on that installs it and the network's own +/// scale factor. The manifests list the same files (asserted below). +pub const MODELS: &[(&str, &str, u32)] = &[ + (GENERAL_ADDON, GENERAL_X4, 4), + (GENERAL_ADDON, GENERAL_WDN_X4, 4), + (GENERAL_ADDON, LIVE_ACTION_X2, 2), + (ESRGAN_PLUS_ADDON, PLUS_X4, 4), + (ESRGAN_PLUS_ADDON, PLUS_X2, 2), +]; + +/// A model file's own scale factor (2 for an unknown name, which no job can +/// reach: every name comes from [`MODELS`]). +pub fn scale_of(file: &str) -> u32 { + MODELS.iter().find(|(_, f, _)| *f == file).map(|(_, _, s)| *s).unwrap_or(2) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn models_match_the_manifests() { + for addon in [GENERAL_ADDON, ESRGAN_PLUS_ADDON] { + let m = crate::addons::manifest(addon).unwrap_or_else(|| panic!("no manifest {addon}")); + let mut listed: Vec = m + .expected_paths("macos-arm64") + .into_iter() + .filter(|p| p.ends_with(".onnx")) + .collect(); + listed.sort(); + let mut ours: Vec = + MODELS.iter().filter(|(a, _, _)| *a == addon).map(|(_, f, _)| f.to_string()).collect(); + ours.sort(); + assert_eq!(listed, ours, "{addon}"); + // VSNCNN ships on exactly these platforms, so the models are + // offered on exactly these. + for p in crate::addons::PLATFORMS { + assert_eq!(m.supports(p), crate::gpu_plugin::platform_ships_upscale(p), "{addon} {p}"); + } + // They run on VSNCNN, not ONNX Runtime. + assert!(!crate::addons::needs_ai_runtime(&[addon]), "{addon}"); + } + } + + #[test] + fn scales() { + assert_eq!(scale_of(GENERAL_X4), 4); + assert_eq!(scale_of(LIVE_ACTION_X2), 2); + assert_eq!(scale_of(PLUS_X4), 4); + assert_eq!(scale_of(PLUS_X2), 2); + } +} diff --git a/worker/templates/pipeline_template.vpy b/worker/templates/pipeline_template.vpy index bb2e7015..2add8d4b 100644 --- a/worker/templates/pipeline_template.vpy +++ b/worker/templates/pipeline_template.vpy @@ -2058,14 +2058,16 @@ clip = _upscale_fix_shift(clip, _upscale_shift) clip = core.resize.Spline36(clip, width=clip.width * {{UPSCALE_FACTOR}}, height=clip.height * {{UPSCALE_FACTOR}}) {{/UPSCALE_SPLINE36}} {{#UPSCALE_AI}} -# AI upscaling: Real-ESRGAN / Real-CUGAN through vs-mlrt's VSNCNN (ncnn on -# Vulkan), loaded explicitly from vapoursynth/ncnn/ (never autoloaded) after -# MoltenVK on macOS, exactly as RIFE is. The networks take float RGB, so the -# source's matrix and range convert there and back, and the result is dithered -# to the source format. Above SD the frame runs in overlapping 512-pixel tiles: -# whole-frame Real-CUGAN at 1080p ran out of GPU memory on a 16 GB Mac. An odd -# size is padded to even by repeating the edge (CUGAN works in 2-pixel steps) -# and cropped after. fp16 differs from fp32 by at most 0.3/255 and is ~3x faster. +# AI upscaling: Real-ESRGAN / Real-CUGAN (and the add-on models) through +# vs-mlrt's VSNCNN (ncnn on Vulkan), loaded explicitly from vapoursynth/ncnn/ +# (never autoloaded) after MoltenVK on macOS, exactly as RIFE is. The networks +# take float RGB, so the source's matrix and range convert there and back, and +# the result is dithered to the source format. Above a size the frame runs in +# overlapping tiles: whole-frame Real-CUGAN at 1080p ran out of GPU memory on a +# 16 GB Mac (512-pixel tiles above SD), and the large Real-ESRGAN x4plus lost +# the device at SD (256-pixel tiles always). An odd size is padded to even by +# repeating the edge (CUGAN works in 2-pixel steps) and cropped after. fp16 +# differs from fp32 by at most 0.3/255 and is ~3x faster. import ctypes as _ai_ctypes _ai_preload = {{AI_PRELOAD}} if _ai_preload: @@ -2093,17 +2095,27 @@ for _ai_model in [{{AI_MODELS}}]: # AnimeJaNai: its SD model for a picture at most {{AI_SD_MAX_HEIGHT}} tall, else # the HD one (trained on 1080p anime), chosen per doubling. _ai_model = _ai_model[0] if _ai_rgb.height <= {{AI_SD_MAX_HEIGHT}} else _ai_model[1] - _ai_tiles = {} if _ai_rgb.width * _ai_rgb.height <= 720 * 576 else dict(tilesize=[512, 512], overlap=[16, 16]) + _ai_tiles = {} if _ai_rgb.width * _ai_rgb.height <= {{AI_TILE_ABOVE}} else dict(tilesize=[{{AI_TILE}}, {{AI_TILE}}], overlap=[16, 16]) _ai_rgb = core.ncnn.Model(_ai_rgb, network_path=_ai_model, fp16=True, num_streams=2, **_ai_tiles) _ai_scale = _ai_rgb.width // _ai_in_w if _ai_pad_w or _ai_pad_h: _ai_rgb = core.std.Crop(_ai_rgb, right=_ai_pad_w * _ai_scale, bottom=_ai_pad_h * _ai_scale) +{{#AI_RESAMPLE}} +# The network enlarges further than asked (Real-ESRGAN General is 4x only), so +# resample to {{AI_FACTOR}}x here, in float RGB, before the conversion back. +_ai_rgb = core.resize.Spline36(_ai_rgb, width=_ai_rgb.width * {{AI_FACTOR}} // _ai_scale, height=_ai_rgb.height * {{AI_FACTOR}} // _ai_scale) +{{/AI_RESAMPLE}} if _ai_fmt.color_family == vs.YUV: clip = core.resize.Spline36(_ai_rgb, format=_ai_fmt.id, matrix_s="{{AI_MATRIX}}", range_s="{{AI_RANGE}}", dither_type="error_diffusion") elif _ai_fmt.color_family == vs.GRAY: clip = core.resize.Point(core.std.ShufflePlanes(_ai_rgb, 1, vs.GRAY), format=_ai_fmt.id, range_s="{{AI_RANGE}}", dither_type="error_diffusion") else: clip = core.resize.Point(_ai_rgb, format=_ai_fmt.id, dither_type="error_diffusion") +{{#AI_STRENGTH}} +# AI strength: keep {{AI_STRENGTH}} of the network's picture and blend in a Spline36 +# upscale of the same frame for the rest, at the clip's own format. +clip = core.std.Merge(core.resize.Spline36(_ai_src, width=clip.width, height=clip.height), clip, weight={{AI_STRENGTH}}) +{{/AI_STRENGTH}} # The network drops frame properties; field order, matrix and the rest are the source's. clip = core.std.CopyFrameProps(clip, _ai_src) {{/UPSCALE_AI}} diff --git a/worker/templates/preview_template.vpy b/worker/templates/preview_template.vpy index 1b97bb8d..bfe4643d 100644 --- a/worker/templates/preview_template.vpy +++ b/worker/templates/preview_template.vpy @@ -1990,14 +1990,16 @@ clip = _upscale_fix_shift(clip, _upscale_shift) clip = core.resize.Spline36(clip, width=clip.width * {{UPSCALE_FACTOR}}, height=clip.height * {{UPSCALE_FACTOR}}) {{/UPSCALE_SPLINE36}} {{#UPSCALE_AI}} -# AI upscaling: Real-ESRGAN / Real-CUGAN through vs-mlrt's VSNCNN (ncnn on -# Vulkan), loaded explicitly from vapoursynth/ncnn/ (never autoloaded) after -# MoltenVK on macOS, exactly as RIFE is. The networks take float RGB, so the -# source's matrix and range convert there and back, and the result is dithered -# to the source format. Above SD the frame runs in overlapping 512-pixel tiles: -# whole-frame Real-CUGAN at 1080p ran out of GPU memory on a 16 GB Mac. An odd -# size is padded to even by repeating the edge (CUGAN works in 2-pixel steps) -# and cropped after. fp16 differs from fp32 by at most 0.3/255 and is ~3x faster. +# AI upscaling: Real-ESRGAN / Real-CUGAN (and the add-on models) through +# vs-mlrt's VSNCNN (ncnn on Vulkan), loaded explicitly from vapoursynth/ncnn/ +# (never autoloaded) after MoltenVK on macOS, exactly as RIFE is. The networks +# take float RGB, so the source's matrix and range convert there and back, and +# the result is dithered to the source format. Above a size the frame runs in +# overlapping tiles: whole-frame Real-CUGAN at 1080p ran out of GPU memory on a +# 16 GB Mac (512-pixel tiles above SD), and the large Real-ESRGAN x4plus lost +# the device at SD (256-pixel tiles always). An odd size is padded to even by +# repeating the edge (CUGAN works in 2-pixel steps) and cropped after. fp16 +# differs from fp32 by at most 0.3/255 and is ~3x faster. import ctypes as _ai_ctypes _ai_preload = {{AI_PRELOAD}} if _ai_preload: @@ -2025,17 +2027,27 @@ for _ai_model in [{{AI_MODELS}}]: # AnimeJaNai: its SD model for a picture at most {{AI_SD_MAX_HEIGHT}} tall, else # the HD one (trained on 1080p anime), chosen per doubling. _ai_model = _ai_model[0] if _ai_rgb.height <= {{AI_SD_MAX_HEIGHT}} else _ai_model[1] - _ai_tiles = {} if _ai_rgb.width * _ai_rgb.height <= 720 * 576 else dict(tilesize=[512, 512], overlap=[16, 16]) + _ai_tiles = {} if _ai_rgb.width * _ai_rgb.height <= {{AI_TILE_ABOVE}} else dict(tilesize=[{{AI_TILE}}, {{AI_TILE}}], overlap=[16, 16]) _ai_rgb = core.ncnn.Model(_ai_rgb, network_path=_ai_model, fp16=True, num_streams=2, **_ai_tiles) _ai_scale = _ai_rgb.width // _ai_in_w if _ai_pad_w or _ai_pad_h: _ai_rgb = core.std.Crop(_ai_rgb, right=_ai_pad_w * _ai_scale, bottom=_ai_pad_h * _ai_scale) +{{#AI_RESAMPLE}} +# The network enlarges further than asked (Real-ESRGAN General is 4x only), so +# resample to {{AI_FACTOR}}x here, in float RGB, before the conversion back. +_ai_rgb = core.resize.Spline36(_ai_rgb, width=_ai_rgb.width * {{AI_FACTOR}} // _ai_scale, height=_ai_rgb.height * {{AI_FACTOR}} // _ai_scale) +{{/AI_RESAMPLE}} if _ai_fmt.color_family == vs.YUV: clip = core.resize.Spline36(_ai_rgb, format=_ai_fmt.id, matrix_s="{{AI_MATRIX}}", range_s="{{AI_RANGE}}", dither_type="error_diffusion") elif _ai_fmt.color_family == vs.GRAY: clip = core.resize.Point(core.std.ShufflePlanes(_ai_rgb, 1, vs.GRAY), format=_ai_fmt.id, range_s="{{AI_RANGE}}", dither_type="error_diffusion") else: clip = core.resize.Point(_ai_rgb, format=_ai_fmt.id, dither_type="error_diffusion") +{{#AI_STRENGTH}} +# AI strength: keep {{AI_STRENGTH}} of the network's picture and blend in a Spline36 +# upscale of the same frame for the rest, at the clip's own format. +clip = core.std.Merge(core.resize.Spline36(_ai_src, width=clip.width, height=clip.height), clip, weight={{AI_STRENGTH}}) +{{/AI_STRENGTH}} # The network drops frame properties; field order, matrix and the rest are the source's. clip = core.std.CopyFrameProps(clip, _ai_src) {{/UPSCALE_AI}} diff --git a/worker/tests/filter_integration_test.rs b/worker/tests/filter_integration_test.rs index 89c94cb9..6ee1f267 100644 --- a/worker/tests/filter_integration_test.rs +++ b/worker/tests/filter_integration_test.rs @@ -6879,3 +6879,174 @@ fn test_184_flowfps_length_is_set_by_the_script() { assert!(call < trim && call < pad, "{name}: length set before FlowFPS"); } } + +/// An add-ons directory with `ids` installed for macOS arm64, as the app's +/// installer leaves it: every expected file plus the marker, written last. +fn fake_addons(tag: &str, ids: &[&str]) -> PathBuf { + let root = std::env::temp_dir().join(format!("vb-upscale-addons-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + for id in ids { + let m = vapourbox_worker::addons::manifest(id).expect("manifest"); + let dir = vapourbox_worker::addons::addon_dir(&root, id); + std::fs::create_dir_all(&dir).unwrap(); + for p in m.expected_paths("macos-arm64") { + std::fs::write(dir.join(p), b"x").unwrap(); + } + std::fs::write( + dir.join(vapourbox_worker::addons::MARKER), + format!(r#"{{"id":"{id}","version":"{}","platform":"macos-arm64"}}"#, m.version), + ) + .unwrap(); + } + root +} + +/// The add-on upscalers (Real-ESRGAN General, live-action SPAN, Real-ESRGAN +/// x4plus/x2plus): the same VSNCNN block, each model from its add-on's +/// directory, with the factor rules — General is 4x only, so 2x resamples its +/// result down; SPAN is 2x only, so 4x runs it twice; x4plus/x2plus by factor, +/// in 384-pixel tiles at 4x — in both scripts. +#[test] +fn test_185_upscale_addon_models_and_factors() { + use vapourbox_worker::upscale_addons::*; + create_output_dir(); + let root = fake_addons("185", &[GENERAL_ADDON, ESRGAN_PLUS_ADDON]); + let generator = ScriptGenerator::new() + .unwrap() + .with_upscale(Ok(upscale_files())) + .with_addons(root.clone(), "macos-arm64"); + let py = |addon: &str, f: &str| { + vapourbox_worker::gpu_plugin::py_str(&vapourbox_worker::addons::addon_dir(&root, addon).join(f)) + }; + let cases: Vec<(UpscaleMethod, i32, bool, Vec, Option)> = vec![ + (UpscaleMethod::RealEsrganGeneral, 2, false, vec![py(GENERAL_ADDON, GENERAL_X4)], Some(2)), + (UpscaleMethod::RealEsrganGeneral, 4, false, vec![py(GENERAL_ADDON, GENERAL_X4)], None), + (UpscaleMethod::RealEsrganGeneral, 2, true, vec![py(GENERAL_ADDON, GENERAL_WDN_X4)], Some(2)), + (UpscaleMethod::LiveAction, 2, false, vec![py(GENERAL_ADDON, LIVE_ACTION_X2)], None), + (UpscaleMethod::LiveAction, 4, false, vec![py(GENERAL_ADDON, LIVE_ACTION_X2); 2], None), + (UpscaleMethod::RealEsrganPlus, 2, false, vec![py(ESRGAN_PLUS_ADDON, PLUS_X2)], None), + (UpscaleMethod::RealEsrganPlus, 4, false, vec![py(ESRGAN_PLUS_ADDON, PLUS_X4)], None), + ]; + for (method, factor, denoise, models, resample) in cases { + let label = format!("{method:?} {factor}x denoise={denoise}"); + let mut job = ai_upscale_job("test_185", method, factor, ""); + job.processing_pipeline.as_mut().unwrap().crop_resize.general_denoise = denoise; + let (encode, preview) = both_scripts_with(&generator, &job).unwrap(); + for (name, script) in [("encode", &encode), ("preview", &preview)] { + assert!(script.contains(&format!("for _ai_model in [{}]:", models.join(", "))), + "{label} {name}: wrong models"); + assert!(script.contains("core.ncnn.Model(_ai_rgb, network_path=_ai_model"), "{label} {name}"); + match resample { + Some(f) => assert!(script.contains(&format!( + "_ai_rgb = core.resize.Spline36(_ai_rgb, width=_ai_rgb.width * {f} // _ai_scale, \ + height=_ai_rgb.height * {f} // _ai_scale)")), "{label} {name}: no resample"), + None => assert!(!script.contains("// _ai_scale"), "{label} {name}: stray resample"), + } + let tiles = if method == UpscaleMethod::RealEsrganPlus && factor == 4 { + "<= 384 * 384 else dict(tilesize=[384, 384]" + } else { + "<= 720 * 576 else dict(tilesize=[512, 512]" + }; + assert!(script.contains(tiles), "{label} {name}: tiles"); + assert!(!script.contains("AI_RESAMPLE") && !script.contains("AI_STRENGTH") && !script.contains("{{AI_"), "{label} {name}: unsubstituted placeholder"); + assert!(!script.contains("core.std.Merge(core.resize.Spline36(_ai_src"), "{label} {name}"); + } + } + let _ = std::fs::remove_dir_all(&root); +} + +/// Without its add-on each add-on upscaler is refused, naming the add-on and +/// Settings → Add-ons; with the other add-on installed it is still refused, +/// and the bundled models need none. +#[test] +fn test_186_upscale_addons_are_refused_when_missing() { + use vapourbox_worker::upscale_addons::*; + create_output_dir(); + let root = fake_addons("186", &[ESRGAN_PLUS_ADDON]); + let generator = ScriptGenerator::new() + .unwrap() + .with_upscale(Ok(upscale_files())) + .with_addons(root.clone(), "macos-arm64"); + for (method, feature) in [ + (UpscaleMethod::RealEsrganGeneral, "Real-ESRGAN General upscaling"), + (UpscaleMethod::LiveAction, "Live-action upscaling"), + ] { + let job = ai_upscale_job("test_186", method, 2, ""); + let err = both_scripts_with(&generator, &job).unwrap_err().to_string(); + assert!(err.starts_with(feature), "{err}"); + assert!(err.contains("General & live-action upscaling models"), "{err}"); + assert!(err.contains("Settings → Add-ons"), "{err}"); + } + both_scripts_with(&generator, &ai_upscale_job("test_186_plus", UpscaleMethod::RealEsrganPlus, 4, "")) + .expect("x4plus is installed"); + let missing = fake_addons("186b", &[]); + let bare = ScriptGenerator::new() + .unwrap() + .with_upscale(Ok(upscale_files())) + .with_addons(missing.clone(), "macos-arm64"); + let err = both_scripts_with(&bare, &ai_upscale_job("test_186_plus", UpscaleMethod::RealEsrganPlus, 2, "")) + .unwrap_err() + .to_string(); + assert!(err.contains("Real-ESRGAN x4plus / x2plus models"), "{err}"); + both_scripts_with(&bare, &ai_upscale_job("test_186_esrgan", UpscaleMethod::RealEsrgan, 2, "")) + .expect("bundled Real-ESRGAN needs no add-on"); + // On a platform VSNCNN doesn't ship on, the add-on is unavailable too. + let mac_x64 = ScriptGenerator::new() + .unwrap() + .with_upscale(Ok(upscale_files())) + .with_addons(root.clone(), "macos-x64"); + let err = both_scripts_with(&mac_x64, &ai_upscale_job("test_186_x64", UpscaleMethod::LiveAction, 2, "")) + .unwrap_err() + .to_string(); + assert!(err.contains("isn't available on this platform"), "{err}"); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&missing); +} + +/// AI strength blends the network's result with a Spline36 upscale of the same +/// frame in one std.Merge at the clip's format, for every AI method, in both +/// scripts. At full strength (the default) nothing is emitted: the script is +/// byte-for-byte the one a job without the setting gets. +#[test] +fn test_187_ai_strength_blends_with_spline36() { + use vapourbox_worker::upscale_addons::*; + create_output_dir(); + let root = fake_addons("187", &[GENERAL_ADDON]); + let generator = ScriptGenerator::new() + .unwrap() + .with_upscale(Ok(upscale_files())) + .with_addons(root.clone(), "macos-arm64"); + let merge = "clip = core.std.Merge(core.resize.Spline36(_ai_src, width=clip.width, \ + height=clip.height), clip, weight=0.650)"; + for method in [UpscaleMethod::RealEsrgan, UpscaleMethod::RealCugan, UpscaleMethod::LiveAction, + UpscaleMethod::RealEsrganGeneral] { + let mut job = ai_upscale_job("test_187", method, 2, "conservative"); + job.processing_pipeline.as_mut().unwrap().crop_resize.ai_strength = 0.65; + let (encode, preview) = both_scripts_with(&generator, &job).unwrap(); + for (name, script) in [("encode", &encode), ("preview", &preview)] { + assert!(script.contains(merge), "{method:?} {name}: no blend"); + let at = script.find(merge).unwrap(); + assert!(at > script.find("for _ai_model in").unwrap(), "{method:?} {name}: blend before the network"); + assert!(at < script.find("clip = core.std.CopyFrameProps(clip, _ai_src)").unwrap(), + "{method:?} {name}: blend after the props copy"); + } + } + // Full strength: byte-identical to a job without the field at all. + let full = ai_upscale_job("test_187_full", UpscaleMethod::RealEsrgan, 2, ""); + assert_eq!(full.processing_pipeline.as_ref().unwrap().crop_resize.ai_strength, 1.0); + let mut json = serde_json::to_value(&full).unwrap(); + json["processingPipeline"]["cropResize"].as_object_mut().unwrap().remove("aiStrength"); + json["id"] = serde_json::Value::String(Uuid::new_v4().to_string()); + let legacy: VideoJob = serde_json::from_value(json).unwrap(); + let a = script_text_with(&generator, &full); + let b = script_text_with(&generator, &legacy); + assert_eq!(a.replace(&full.id.to_string(), "ID"), b.replace(&legacy.id.to_string(), "ID")); + assert!(!a.contains("core.std.Merge(core.resize.Spline36(_ai_src")); + assert!(!a.contains("AI_STRENGTH")); + // Out-of-range values clamp; 0 is all Spline36. + let mut zero = ai_upscale_job("test_187_zero", UpscaleMethod::RealEsrgan, 2, ""); + zero.processing_pipeline.as_mut().unwrap().crop_resize.ai_strength = -1.0; + assert!(script_text_with(&generator, &zero).contains("clip, weight=0.000)")); + let _ = std::fs::remove_dir_all(&root); +} From 6f9fa9ac1e53695b1a627be102f6e4dad187a5ae Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 8 Nov 2026 18:18:19 +1100 Subject: [PATCH 08/61] deps(1.16.0, unreleased): vszip 24.0.0 and SangNom r45 on every platform - vszip 22.1.0 -> 24.0.0 (first release with CPU NLMeans; WNNM since 22.0.0). macOS x64 builds the sdist with Zig 0.17.0 (-Doptimize=fast; the headerpad patch now matches the 8-space use_llvm inside if (build_vs)). Linux wheel pinned by hash. - SangNom 45.0 (PyPI wheels, pinned by hash) for Anti-aliasing's SangNom method. SSE2 only on x64: one file serves both CPU tiers. - deps-expected-plugins.json lists SangNom on every platform and tier; vapoursynth_integration_test requires sangnom and vszip.NLMeans. - Credits: SangNom (MIT source, GPL-2.0 binaries, as upstream states) and vs-jetpack (MIT, for the ported functions) in NOTICES, About and README. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- Scripts/deps-expected-plugins.json | 24 +++-- Scripts/download-deps-linux.sh | 81 ++++++++++++++-- Scripts/download-deps-macos.sh | 106 +++++++++++++++++---- Scripts/download-deps-windows.ps1 | 15 ++- app/lib/views/about_dialog.dart | 14 +++ app/test/attribution_test.dart | 1 + app/test/vapoursynth_integration_test.dart | 10 +- licenses/NOTICES.txt | 27 +++++- 9 files changed, 237 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index e08e9584..59dfca6b 100644 --- a/README.md +++ b/README.md @@ -271,7 +271,9 @@ VapourBox is a front end to other people's work. Thanks in particular to: - **mawen1250** — Retinex, BM3D and mvsfunc - **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 +- **vszip** by Julek — CLAHE, the local-contrast lift in Color Correction, and NLMeans, the CPU non-local-means denoiser +- **SangNom** by james1201 and Victor Efimov — the SangNom anti-aliasing method +- **vs-jetpack** by the Jaded Encoding Thaumaturgy project — FixInterlacedFades, vine_dehalo and mpeg2stinx, ported into VapourBox's filter scripts - **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 - **Real-ESRGAN** by Xintao Wang and **Real-CUGAN** by bilibili — the AI upscaling networks, run through AmusementClub's **vs-mlrt** (VSNCNN) - **AnimeJaNai** by the-database — anime upscaling models, an optional download (CC BY-NC-SA 4.0, non-commercial use only) diff --git a/Scripts/deps-expected-plugins.json b/Scripts/deps-expected-plugins.json index 2763a287..84c092fa 100644 --- a/Scripts/deps-expected-plugins.json +++ b/Scripts/deps-expected-plugins.json @@ -108,7 +108,8 @@ "vsznedi3.dll", "zsmooth.dll", "bm3d.dll", - "libdescale.dll" + "libdescale.dll", + "sangnom.dll" ], "windows-x64-v2": [ "AddGrain.dll", @@ -146,7 +147,8 @@ "vsznedi3.dll", "zsmooth.dll", "bm3d.dll", - "libdescale.dll" + "libdescale.dll", + "sangnom.dll" ], "macos-arm64": [ "libaddgrain.dylib", @@ -184,7 +186,8 @@ "libvszip.dylib", "libznedi3.dylib", "libzsmooth.dylib", - "libdescale.dylib" + "libdescale.dylib", + "libsangnom.dylib" ], "macos-x64": [ "libaddgrain.dylib", @@ -220,7 +223,8 @@ "libznedi3.dylib", "libzsmooth.dylib", "libbm3d.dylib", - "libdescale.dylib" + "libdescale.dylib", + "libsangnom.dylib" ], "macos-x64-v2": [ "libaddgrain.dylib", @@ -256,7 +260,8 @@ "libznedi3.dylib", "libzsmooth.dylib", "libbm3d.dylib", - "libdescale.dylib" + "libdescale.dylib", + "libsangnom.dylib" ], "linux-x64": [ "libaddgrain.so", @@ -293,7 +298,8 @@ "libznedi3.so", "libzsmooth.so", "libbm3d.so", - "libdescale.so" + "libdescale.so", + "libsangnom.so" ], "linux-x64-v2": [ "libaddgrain.so", @@ -330,7 +336,8 @@ "libznedi3.so", "libzsmooth.so", "libbm3d.so", - "libdescale.so" + "libdescale.so", + "libsangnom.so" ], "linux-arm64": [ "libaddgrain.so", @@ -368,6 +375,7 @@ "libznedi3.so", "libzsmooth.so", "libbm3d.so", - "libdescale.so" + "libdescale.so", + "libsangnom.so" ] } diff --git a/Scripts/download-deps-linux.sh b/Scripts/download-deps-linux.sh index 2ee017f9..8d473d98 100755 --- a/Scripts/download-deps-linux.sh +++ b/Scripts/download-deps-linux.sh @@ -1567,10 +1567,12 @@ else fi # ============================================================================ -# vszip — CLAHE (Colour Correction's local contrast, issue #90) +# vszip — CLAHE, NLMeans (CPU) and the vine dehalo method # ============================================================================ -# Only core.vszip.CLAHE is used. Keep VSZIP_VERSION in step across -# download-deps-{macos,linux}.sh and download-deps-windows.ps1. +# core.vszip.CLAHE (Colour Correction's local contrast, issue #90) and, from +# 24.0.0 (the first release with it), core.vszip.NLMeans: Noise Reduction's +# CPU non-local means and the Dehalo pass's vine method. Keep VSZIP_VERSION in +# step across download-deps-{macos,linux}.sh and download-deps-windows.ps1. # # The x86_64 wheel carries three builds — libvszip.so (baseline x86-64), # libvszip.avx2.so and libvszip.zn4.so (AVX-512) — that VapourSynth picks @@ -1581,14 +1583,18 @@ fi # by path and fault); and the AVX-512 build is never chosen on a machine no # runner can test (the CTMF lesson). CLAHE is cheap next to anything else in a # job, so the AVX2 speedup is not worth that machinery. -# manylinux_2_17 is far under our glibc 2.39 floor. -VSZIP_VERSION="22.1.0" +# manylinux_2_17 is far under our glibc 2.39 floor. 24.0.0's baseline build no +# longer calls libm's fmaf per lane, and contains no AVX at all (checked with +# objdump), so it still needs no _tierFiles entry. Pinned by hash. +VSZIP_VERSION="24.0.0" echo "" -echo "=== Downloading vszip $VSZIP_VERSION (CLAHE) ===" +echo "=== Downloading vszip $VSZIP_VERSION (CLAHE, NLMeans) ===" if [ "$FORCE" = true ] || [ ! -f "$PLUGINS_DIR/libvszip.so" ]; then case "$ARCH" in - x86_64) VSZIP_TAG="manylinux_2_17_x86_64" ;; - aarch64) VSZIP_TAG="manylinux_2_17_aarch64" ;; + x86_64) VSZIP_TAG="manylinux_2_17_x86_64" + VSZIP_SHA256="abf1be51f3b4733500eca04f7179236f8383e09b298ac4ca2c8e8959a93a92da" ;; + aarch64) VSZIP_TAG="manylinux_2_17_aarch64" + VSZIP_SHA256="8f2a87cd1cbb0a6b5392b6f43c9dd22bb531c7d2021c4c2b66fe6856c8dc7f92" ;; *) VSZIP_TAG="" ;; esac VSZIP_URL="" @@ -1605,6 +1611,7 @@ PYEOF mkdir -p "$BUILD_DIR/vszip" if [ -n "$VSZIP_URL" ] \ && curl -fL -o "$BUILD_DIR/vszip.whl" "$VSZIP_URL" \ + && echo "$VSZIP_SHA256 $BUILD_DIR/vszip.whl" | sha256sum -c - \ && unzip -q "$BUILD_DIR/vszip.whl" -d "$BUILD_DIR/vszip"; then # The baseline build only, by exact name — see above. cp "$BUILD_DIR/vszip/vapoursynth/plugins/vszip/libvszip.so" "$PLUGINS_DIR/" 2>/dev/null || true @@ -1622,6 +1629,64 @@ else echo " vszip already exists, skipping" fi +# ============================================================================ +# SangNom — Anti-aliasing's SangNom method (core.sangnom.SangNom) +# ============================================================================ +# The PyPI wheels (r45), pinned by hash. Source is MIT; upstream states its +# binaries are GPL v2 (see licenses/NOTICES.txt). x86_64 has SSE2 code only +# (no AVX anywhere, checked with objdump), so one file serves both CPU tiers. +# manylinux_2_28 (glibc 2.28) is under our 2.39 floor; it needs the system +# libstdc++, as every C++ plugin here does. Keep SANGNOM_VERSION in step with +# download-deps-{macos.sh,windows.ps1}. +SANGNOM_VERSION="45.0" +case "$ARCH" in + x86_64) SANGNOM_TAG="manylinux_2_28_x86_64" + SANGNOM_SHA256="dd71e28fa7d0383101b6ab5e84307be80984f69a43838238686de1480793525d" ;; + aarch64) SANGNOM_TAG="manylinux_2_28_aarch64" + SANGNOM_SHA256="4fd6e39bc32aec755853f974936324081223d6b7e082cc38e717c9b8dad0cf6a" ;; + *) SANGNOM_TAG="" ;; +esac +SANGNOM_OUT="$PLUGINS_DIR/libsangnom.so" +echo "" +echo "=== Installing SangNom $SANGNOM_VERSION ===" +if [ "$FORCE" = false ] && [ -f "$SANGNOM_OUT" ]; then + echo " SangNom already exists, skipping" +else + rm -f "$SANGNOM_OUT" + # Run on its own, not as `if ( set -e ... ); then`: bash ignores set -e in + # anything that is an if/&&/|| condition, subshells included. + set +e + ( + set -e + [ -n "$SANGNOM_TAG" ] + cd "$BUILD_DIR" + rm -rf sangnom-whl sangnom.whl + url=$("$PYTHON_BIN" - "$SANGNOM_VERSION" "$SANGNOM_TAG" <<'PYEOF2' +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-sangnom/{ver}/json")) +print(next(f["url"] for f in d["urls"] if f["filename"].endswith(tag + ".whl"))) +PYEOF2 +) + curl -fsSL -o sangnom.whl "$url" + echo "$SANGNOM_SHA256 sangnom.whl" | sha256sum -c - + mkdir sangnom-whl && unzip -q sangnom.whl -d sangnom-whl + cp sangnom-whl/vapoursynth/plugins/sangnom.so "$SANGNOM_OUT" + chmod 755 "$SANGNOM_OUT" + rm -rf sangnom-whl sangnom.whl + ) + _block_status=$? + set -e + if [ "$_block_status" -eq 0 ]; then + echo " Installed SangNom -> libsangnom.so" + BUILT_PLUGINS+=("sangnom") + else + rm -f "$SANGNOM_OUT" + echo " Failed to install SangNom" + FAILED_PLUGINS+=("sangnom") + fi +fi + # ============================================================================ # descale — the Descale pass (core.descale.De*) # ============================================================================ diff --git a/Scripts/download-deps-macos.sh b/Scripts/download-deps-macos.sh index 36e2536c..0354d269 100755 --- a/Scripts/download-deps-macos.sh +++ b/Scripts/download-deps-macos.sh @@ -2074,19 +2074,20 @@ else fi # ============================================================================ -# vszip — CLAHE (Colour Correction's local contrast, issue #90) +# vszip — CLAHE, NLMeans (CPU) and the vine dehalo method # ============================================================================ -# Only core.vszip.CLAHE is used. Keep VSZIP_VERSION in step across -# download-deps-{macos,linux}.sh and download-deps-windows.ps1. +# core.vszip.CLAHE (Colour Correction's local contrast, issue #90) and, from +# 24.0.0 (the first release with it), core.vszip.NLMeans: Noise Reduction's +# CPU non-local means and the Dehalo pass's vine method. Keep VSZIP_VERSION in +# step across download-deps-{macos,linux}.sh and download-deps-windows.ps1. # -# arm64 takes the PyPI wheel's dylib: minos 13, under this arch's 15.0 target. -# It has zero bytes between its load commands and __text, so install_name_tool -# cannot fit a longer id (the repoint loop below fails quietly and leaves -# @rpath, which is harmless for a dlopen'd plugin). Re-signing is safe: it is -# already linker-signed, and codesign replaces an existing signature in place -# rather than adding a load command. +# arm64 takes the PyPI wheel's dylib: minos 15.0 from 24.0.0 (13 before), which +# is exactly this arch's 15.0 target. 24.0.0's has 4 KB free after its load +# commands (22.1.0's had none); re-signing is safe either way: it is already +# linker-signed, and codesign replaces an existing signature in place rather +# than adding a load command. # -# x64 builds the same release's sdist, for two reasons. The wheel is minos 13.0 +# x64 builds the same release's sdist, for two reasons. The wheel is minos 15.0 # against this bundle's 12.0 floor (issue #39). And Zig's x86_64 macOS output # carries NO code signature and leaves 8 bytes after its load commands, so # `codesign` — which must add a 16-byte LC_CODE_SIGNATURE — silently writes the @@ -2095,15 +2096,18 @@ fi # gives the header room; the guard below fails the build if it ever doesn't. # Built with upstream's default x86_64-macos CPU (same as its wheel), which has # no AVX — so one build serves both CPU tiers and needs no _tierFiles entry. -VSZIP_VERSION="22.1.0" -VSZIP_SDIST_SHA256="93c1aaf5867ad43e1f21b77418fa89b0b1ed2191d0c4bfb03a65c958826ed8c9" -# The sdist's pyproject pins ziglang==0.16.0; its build.zig does not compile -# under 0.17 (Optimize enum rename). -VSZIP_ZIG_VERSION="0.16.0" -VSZIP_ZIG_SHA256="0387557ed1877bc6a2e1802c8391953baddba76081876301c522f52977b52ba7" +VSZIP_VERSION="24.0.0" +VSZIP_SDIST_SHA256="b2d6157228d0e786a19ac9055814e6141841ffc2c7b42ac1858b0600fc4a69cb" +# The sdist's pyproject pins ziglang==0.17.0 (from vszip 23.0.0; its +# build.zig.zon refuses anything older). 0.17 renamed the optimize modes, hence +# -Doptimize=fast below rather than ReleaseFast. Cross-built from arm64 with +# the same recipe on 2026-11-08: minos 12.0, 4 KB header room, no AVX, no +# static initializers (check-load-time-simd.py). +VSZIP_ZIG_VERSION="0.17.0" +VSZIP_ZIG_SHA256="4f9a1c5269aa17ebda5e6d3c2b89d6cbf36f7d2b22a0306e9ab98f25f95529c6" VSZIP_OUT="$PLUGINS_DIR/libvszip.dylib" echo "" -echo "=== Installing vszip $VSZIP_VERSION (CLAHE) ===" +echo "=== Installing vszip $VSZIP_VERSION (CLAHE, NLMeans) ===" if [ "$FORCE" = false ] && [ -f "$VSZIP_OUT" ]; then echo " vszip already exists, skipping" else @@ -2145,10 +2149,12 @@ else mkdir -p vszip-src tar -xzf vszip.tar.gz -C vszip-src --strip-components=1 cd vszip-src - grep -q '^ lib.use_llvm = true;$' build.zig || { + # The VapourSynth library's `lib.use_llvm` sits inside `if (build_vs)` + # from 23.0.0 (8 spaces); the AviSynth+ one (12 spaces) is never built. + grep -q '^ lib.use_llvm = true;$' build.zig || { echo " ERROR: vszip build.zig changed; re-check the headerpad patch" >&2; exit 1; } - sed -i.bak 's/^ lib.use_llvm = true;$/ lib.use_llvm = true;\ - lib.headerpad_max_install_names = true;/' build.zig + sed -i.bak 's/^ lib.use_llvm = true;$/ lib.use_llvm = true;\ + lib.headerpad_max_install_names = true;/' build.zig grep -q 'lib.headerpad_max_install_names = true;' build.zig # Also fetches vszip's own Zig dependencies (pinned by hash in # build.zig.zon), so this step needs network access. @@ -2157,7 +2163,7 @@ else *.*) vszip_min="$MACOS_MIN_VERSION" ;; *) vszip_min="${MACOS_MIN_VERSION}.0" ;; esac - ../vszip-zig/zig build -Doptimize=ReleaseFast -Dtarget="x86_64-macos.${vszip_min}" + ../vszip-zig/zig build -Doptimize=fast -Dtarget="x86_64-macos.${vszip_min}" cp zig-out/lib/libvszip.dylib "$VSZIP_OUT" ) || true rm -rf "$BUILD_DIR/vszip-zig" "$BUILD_DIR/vszip-zig.tar.xz" \ @@ -2196,6 +2202,64 @@ else fi fi # end vszip install +# ============================================================================ +# SangNom — Anti-aliasing's SangNom method (core.sangnom.SangNom) +# ============================================================================ +# The PyPI wheels (r45), pinned by hash: arm64 is minos 11.0 and x86_64 10.15, +# both under their bundle's floor. Source is MIT; upstream states its binaries +# are GPL v2 (see licenses/NOTICES.txt). The x86_64 dylib has SSE2 code only +# (no AVX anywhere, checked with objdump; no static initializers), so one file +# serves both CPU tiers. It links only libc++ and libSystem. Keep +# SANGNOM_VERSION in step with download-deps-{linux.sh,windows.ps1}. +SANGNOM_VERSION="45.0" +if [ "$ARCH" = "arm64" ]; then + SANGNOM_TAG="macosx_11_0_arm64" + SANGNOM_SHA256="e558c2ffd7d5f6657878e97504c536cdb5e199c558537767b229bd37f8764810" +else + SANGNOM_TAG="macosx_10_15_x86_64" + SANGNOM_SHA256="cbcb274aa86732d01d4f39ab4058c32d580901163ac372c0219cdb3fa468840d" +fi +SANGNOM_OUT="$PLUGINS_DIR/libsangnom.dylib" +echo "" +echo "=== Installing SangNom $SANGNOM_VERSION ===" +if [ "$FORCE" = false ] && [ -f "$SANGNOM_OUT" ]; then + echo " SangNom already exists, skipping" +else + rm -f "$SANGNOM_OUT" + # Run on its own, not as `if ( set -e ... ); then` (see descale below). + set +e + ( + set -e + cd "$BUILD_DIR" + rm -rf sangnom-whl sangnom.whl + url=$("$PYTHON_BIN" - "$SANGNOM_VERSION" "$SANGNOM_TAG" <<'PYEOF2' +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-sangnom/{ver}/json")) +print(next(f["url"] for f in d["urls"] if f["filename"].endswith(tag + ".whl"))) +PYEOF2 +) + curl -fsSL -o sangnom.whl "$url" + echo "$SANGNOM_SHA256 sangnom.whl" | shasum -a 256 -c - + mkdir sangnom-whl && unzip -q sangnom.whl -d sangnom-whl + cp sangnom-whl/vapoursynth/plugins/sangnom.dylib "$SANGNOM_OUT" + chmod u+w "$SANGNOM_OUT" + install_name_tool -id "@loader_path/libsangnom.dylib" "$SANGNOM_OUT" + codesign -s - -f "$SANGNOM_OUT" + rm -rf sangnom-whl sangnom.whl + ) + _block_status=$? + set -e + if [ "$_block_status" -eq 0 ]; then + echo " Installed SangNom -> libsangnom.dylib" + BUILT_PLUGINS+=("sangnom") + else + rm -f "$SANGNOM_OUT" + echo " Failed to install SangNom" + FAILED_PLUGINS+=("sangnom") + fi +fi + # ============================================================================ # descale — the Descale pass (core.descale.De*) # ============================================================================ diff --git a/Scripts/download-deps-windows.ps1 b/Scripts/download-deps-windows.ps1 index 3af20d17..8c6aff8b 100644 --- a/Scripts/download-deps-windows.ps1 +++ b/Scripts/download-deps-windows.ps1 @@ -766,7 +766,7 @@ if (-not (Test-Path "$PluginsDir\libakarin.dll")) { } # ============================================================================= -# Bwdif + DeDot + vszip + BM3D + descale + KNLMeansCL - PyPI wheels +# Bwdif + DeDot + vszip + BM3D + descale + KNLMeansCL + SangNom - 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 @@ -777,7 +777,9 @@ if (-not (Test-Path "$PluginsDir\libakarin.dll")) { # only the system runtime, so there is nothing to place beside it. Bwdif and # DeDot hold exactly vapoursynth\plugins\.dll. # -# vszip (core.vszip.CLAHE, Colour Correction's local contrast, issue #90) holds +# vszip (core.vszip.CLAHE, Colour Correction's local contrast, issue #90; and +# from 24.0.0, the first release with it, core.vszip.NLMeans for Noise +# Reduction's CPU non-local means and the Dehalo pass's vine method) holds # three builds under vapoursynth\plugins\vszip\ - vszip.dll (baseline x86-64), # vszip.avx2.dll and vszip.zn4.dll (AVX-512) - that VapourSynth picks between # through the wheel's manifest.vs. Only the baseline is taken, flat in @@ -793,7 +795,7 @@ if (-not (Test-Path "$PluginsDir\libakarin.dll")) { $WheelPlugins = @( @{ Name = "bwdif"; Package = "vapoursynth-bwdif"; Version = "5.1"; Dll = "bwdif.dll" }, @{ Name = "dedot"; Package = "vapoursynth-dedot"; Version = "3.0"; Dll = "dedot.dll" }, - @{ Name = "vszip"; Package = "vapoursynth-vszip"; Version = "22.1.0"; Dll = "vszip.dll"; Src = "vszip\vszip.dll" }, + @{ Name = "vszip"; Package = "vapoursynth-vszip"; Version = "24.0.0"; Dll = "vszip.dll"; Src = "vszip\vszip.dll" }, # core.bm3d (Noise Reduction's BM3D method). Wheel 12.0 is git tag r12, as # built from source by download-deps-{macos,linux}.sh. fftw is linked in # statically: the DLL imports only the UCRT. @@ -808,7 +810,12 @@ $WheelPlugins = @( # 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" } + @{ Name = "knlmeanscl"; Package = "vapoursynth-knlmeanscl"; Version = "1.3"; Dll = "KNLMeansCL.dll" }, + # core.sangnom.SangNom (Anti-aliasing's SangNom method). Wheel 45.0 is git + # tag r45, the same release the macOS/Linux wheels give. SSE2 only (no AVX + # in the DLL), so one file serves both tiers; imports only the UCRT. Source + # is MIT, upstream states its binaries are GPL v2 (licenses\NOTICES.txt). + @{ Name = "sangnom"; Package = "vapoursynth-sangnom"; Version = "45.0"; Dll = "sangnom.dll" } ) foreach ($W in $WheelPlugins) { diff --git a/app/lib/views/about_dialog.dart b/app/lib/views/about_dialog.dart index d5c78955..f688b73c 100644 --- a/app/lib/views/about_dialog.dart +++ b/app/lib/views/about_dialog.dart @@ -182,6 +182,14 @@ class _AboutDialogState extends State { copyright: 'mawen1250', url: 'https://github.com/HomeOfVapourSynthEvolution/mvsfunc', ), + _ComponentTile( + name: 'vs-jetpack (ported code)', + license: 'MIT', + copyright: 'Jaded-Encoding-Thaumaturgy; ' + 'FixInterlacedFades, vine_dehalo and mpeg2stinx ' + 'ported into VapourBox\'s templates', + url: 'https://github.com/Jaded-Encoding-Thaumaturgy/vs-jetpack', + ), _ComponentTile( name: 'adjust', license: 'WTFPL', @@ -266,6 +274,12 @@ class _AboutDialogState extends State { 'vapoursynth-zig (LGPL 2.1)', url: 'https://github.com/dnjulek/vapoursynth-zip', ), + _ComponentTile( + name: 'SangNom', + license: 'MIT source; binaries GPL 2.0', + copyright: 'james1201; Victor Efimov', + url: 'https://github.com/dubhatervapoursynth/vapoursynth-sangnom', + ), _ComponentTile( name: 'RIFE (VapourSynth-RIFE-ncnn-Vulkan)', license: 'MIT', diff --git a/app/test/attribution_test.dart b/app/test/attribution_test.dart index 22ff90c3..349138f5 100644 --- a/app/test/attribution_test.dart +++ b/app/test/attribution_test.dart @@ -77,6 +77,7 @@ const _pluginToNotice = { 'removegrain': 'RemoveGrain', 'removegrainvs': 'RemoveGrain', 'retinex': 'Retinex', + 'sangnom': 'SangNom — MIT', 'tcanny': 'TCanny', 'temporalmedian': 'TemporalMedian', 'tmedian': 'TemporalMedian', diff --git a/app/test/vapoursynth_integration_test.dart b/app/test/vapoursynth_integration_test.dart index 2776e64a..130b822b 100644 --- a/app/test/vapoursynth_integration_test.dart +++ b/app/test/vapoursynth_integration_test.dart @@ -129,7 +129,12 @@ required = ['std', 'resize', 'mv', 'znedi3', 'eedi3m', 'fmtc', 'descratch', 'vivtc', 'ttmpsm', 'tmedian', 'fft3dfilter', 'flux', 'bifrost', 'retinex', 'bwdif', 'fb', 'removedirt', 'dedot', 'lghost', 'zsmooth', - 'vszip', 'bm3d', 'descale'] + 'vszip', 'bm3d', 'descale', 'sangnom'] + +# A namespace is not a version: vszip 22.1.0 (deps <= 1.15.0) has CLAHE but no +# NLMeans, which Noise Reduction's NLMeans method and the vine dehalo need. A +# stale bundle would pass the namespace check and fail those jobs. +required_functions = [('vszip', 'NLMeans'), ('vszip', 'CLAHE')] # On ARM, `nnedi3` is load-bearing and `znedi3` is only the fallback: znedi3's # SIMD is x86-only, so the ARM bundles build it scalar and both the templates' @@ -163,6 +168,9 @@ missing = [] for plugin in required: if not hasattr(core, plugin): missing.append(plugin) +for ns, fn in required_functions: + if hasattr(core, ns) and not hasattr(getattr(core, ns), fn): + missing.append(f'{ns}.{fn}') if missing: raise Exception(f"Missing plugins: {missing}") diff --git a/licenses/NOTICES.txt b/licenses/NOTICES.txt index 8b239e64..ad693eea 100644 --- a/licenses/NOTICES.txt +++ b/licenses/NOTICES.txt @@ -103,6 +103,16 @@ mvsfunc — no licence stated upstream would prefer it not be redistributed, please open an issue and it will be removed. +vs-jetpack (ported code) — MIT + Copyright (c) 2020 - 2025 Jaded-Encoding-Thaumaturgy + https://github.com/Jaded-Encoding-Thaumaturgy/vs-jetpack + Not bundled as a package: three of its functions are ported, as VapourBox's + own template code, from release v3.0.0 — FixInterlacedFades (vsdeinterlace; + upstream describes it as an improved version of the Fix-Telecined-Fades + plugin), vine_dehalo (vsdehalo) and mpeg2stinx (vsdenoise). Used by the + Deinterlace pass's IVTC fade fix, the Dehalo pass's vine method and the + Deblock pass's mpeg2stinx method. + adjust — WTFPL Copyright (c) dubhater https://github.com/dubhater/vapoursynth-adjust @@ -204,7 +214,9 @@ vszip — MIT Copyright (c) 2024 Julek https://github.com/dnjulek/vapoursynth-zip Provides core.vszip.CLAHE (contrast-limited adaptive histogram - equalization), used by Color Correction's local contrast option. + equalization), used by Color Correction's local contrast option, and + core.vszip.NLMeans (non-local means on the CPU), used by Noise Reduction's + NLMeans method and the Dehalo pass's vine method. The binary statically includes two Zig libraries: zigimg — MIT, Copyright (c) 2019-2021 zigimg developers https://github.com/zigimg/zigimg @@ -213,6 +225,16 @@ vszip — MIT https://github.com/dnjulek/vapoursynth-zig Licence text: LGPL-2.1.txt +SangNom — MIT (source); binaries GPL-2.0 + Copyright (c) 2016 james1201 + Copyright (c) 2013 Victor Efimov + https://github.com/dubhatervapoursynth/vapoursynth-sangnom + The source header and LICENSE grant MIT; the same header states "Binaries + are GPL v2", and the PyPI wheels VapourBox ships declare GPL-2.0, so the + binaries are redistributed under GPL-2.0. Licence text: GPL-2.0.txt + Provides core.sangnom.SangNom, used by the Anti-aliasing pass's SangNom + method. Taken unmodified from the project's PyPI wheels (r45). + RIFE (VapourSynth-RIFE-ncnn-Vulkan) — MIT Copyright (c) 2021-2022 HolyWu https://github.com/styler00dollar/VapourSynth-RIFE-ncnn-Vulkan @@ -561,7 +583,8 @@ Appendix — full text of the short permissive licences MIT License ----------- -(Applies to zsmooth, vszip and the zigimg library it includes, CAS, +(Applies to zsmooth, vszip and the zigimg library it includes, SangNom's +source, the code ported from vs-jetpack, CAS, DCTFilter, BM3D, descale, the RIFE plugin, rife-ncnn-vulkan, the RIFE model and ECCV2022-RIFE, the Real-CUGAN models, whisper.cpp, the Whisper models, ONNX Runtime, pip and the Clense filter From b710ff0b70974531a2ca3d5d0d4e767d9724d9fe Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 8 Nov 2026 18:19:52 +1100 Subject: [PATCH 09/61] feat(audio): audio restoration rendered before the encode A new audio_restoration module renders the output's soundtrack for one source stream before the video encode: channel fix, sync offset (either sign), drift correction from the encoded picture's FrameMap length, declip/declick/rumble/hum notches/afftdn (its 25 ms delay trimmed), DeepFilterNet (add-on), two-pass loudnorm and a latency-compensated limiter, all chosen in one place with one -af per ffmpeg run. The encoder maps the rendered track first and keeps the other audio streams; subtitles transcribe the rendered track. --audio-plan prints the plan. Co-Authored-By: Claude Opus 5.5 --- app/assets/addons/deepfilternet.json | 63 + worker/src/addons.rs | 31 + worker/src/audio_restoration.rs | 1492 +++++++++++++++++ worker/src/lib.rs | 1 + worker/src/main.rs | 237 ++- .../models/audio_restoration_parameters.rs | 235 +++ worker/src/models/mod.rs | 2 + worker/src/models/processing_pipeline.rs | 37 +- worker/src/pipeline_executor.rs | 86 +- worker/src/progress_reporter.rs | 17 + worker/tests/filter_integration_test.rs | 42 + 11 files changed, 2127 insertions(+), 116 deletions(-) create mode 100644 app/assets/addons/deepfilternet.json create mode 100644 worker/src/audio_restoration.rs create mode 100644 worker/src/models/audio_restoration_parameters.rs diff --git a/app/assets/addons/deepfilternet.json b/app/assets/addons/deepfilternet.json new file mode 100644 index 00000000..381a3ea3 --- /dev/null +++ b/app/assets/addons/deepfilternet.json @@ -0,0 +1,63 @@ +{ + "id": "deepfilternet", + "version": "1.0.0", + "name": "DeepFilterNet speech enhancement", + "feature": "Audio restoration", + "description": "Turns down the noise behind speech — hiss, hum, traffic, a noisy room — with a small neural network that runs on the CPU. Audio Restoration's Speech enhancement uses it. One self-contained program (DeepFilterNet 3, deep-filter 0.5.6), downloaded from the DeepFilterNet project's own release.", + "licence": "MIT OR Apache-2.0", + "licenceUrl": "https://github.com/Rikorose/DeepFilterNet/blob/v0.5.6/LICENSE-MIT", + "credit": "Hendrik Schröter", + "noticeName": "DeepFilterNet", + "homepage": "https://github.com/Rikorose/DeepFilterNet", + "requires": [], + "kind": "files", + "platforms": { + "macos-arm64": { "minOs": "13.0" }, + "macos-x64": { "minOs": "13.0" }, + "windows-x64": {}, + "linux-x64": {}, + "linux-arm64": {} + }, + "files": [ + { + "name": "deep-filter-0.5.6-aarch64-apple-darwin", + "url": "https://github.com/Rikorose/DeepFilterNet/releases/download/v0.5.6/deep-filter-0.5.6-aarch64-apple-darwin", + "size": 27877081, + "sha256": "4601e7f4e4c03e59a4c5b5000216ef3add3e808799cfccd95e14e83ea4611081", + "platforms": ["macos-arm64"], + "executable": true + }, + { + "name": "deep-filter-0.5.6-x86_64-apple-darwin", + "url": "https://github.com/Rikorose/DeepFilterNet/releases/download/v0.5.6/deep-filter-0.5.6-x86_64-apple-darwin", + "size": 29933512, + "sha256": "d3be84003acb7c23e738ad7f70a158ec779a8d233a82e7fa3e717d112eb5b50f", + "platforms": ["macos-x64"], + "executable": true + }, + { + "name": "deep-filter-0.5.6-x86_64-pc-windows-msvc.exe", + "url": "https://github.com/Rikorose/DeepFilterNet/releases/download/v0.5.6/deep-filter-0.5.6-x86_64-pc-windows-msvc.exe", + "size": 26912256, + "sha256": "75e11fa16445f560cb6b021521ddb89e89270d13b83089705d98776f58fd7915", + "platforms": ["windows-x64"], + "executable": true + }, + { + "name": "deep-filter-0.5.6-x86_64-unknown-linux-musl", + "url": "https://github.com/Rikorose/DeepFilterNet/releases/download/v0.5.6/deep-filter-0.5.6-x86_64-unknown-linux-musl", + "size": 36417296, + "sha256": "70775e251eee44c0f2451a1e833326cf8bcbbe304d3e7cd12851e6fce72ef7da", + "platforms": ["linux-x64"], + "executable": true + }, + { + "name": "deep-filter-0.5.6-aarch64-unknown-linux-gnu", + "url": "https://github.com/Rikorose/DeepFilterNet/releases/download/v0.5.6/deep-filter-0.5.6-aarch64-unknown-linux-gnu", + "size": 39238496, + "sha256": "14e02a1c0028f3ca0bdf83b62b3336e56ba0556894ef295a95e8573f06557166", + "platforms": ["linux-arm64"], + "executable": true + } + ] +} diff --git a/worker/src/addons.rs b/worker/src/addons.rs index 2f25ad59..720079f0 100644 --- a/worker/src/addons.rs +++ b/worker/src/addons.rs @@ -24,6 +24,10 @@ pub const MARKER: &str = ".vapourbox-addon.json"; /// The AI runtime: ONNX Runtime and NumPy for the job's Python. pub const AI_RUNTIME: &str = "ai-runtime"; +/// DeepFilterNet's `deep-filter` program, for audio restoration's speech +/// enhancement (`audio_restoration.rs`). +pub const DEEPFILTERNET: &str = "deepfilternet"; + /// Where in the add-ons UI to install them — named in every refusal. const WHERE: &str = "Settings → Add-ons"; @@ -36,6 +40,9 @@ pub struct ManifestFile { pub platforms: Option>, #[serde(default)] pub extract: Option>, + /// A program the worker runs: the app marks it executable on install. + #[serde(default)] + pub executable: bool, } impl ManifestFile { @@ -110,6 +117,14 @@ impl Manifest { } } +impl Manifest { + /// The program this add-on installs for `platform` (the file marked + /// `executable`), by its installed name. + pub fn executable_for(&self, platform: &str) -> Option<&str> { + self.files.iter().find(|f| f.executable && f.is_for(platform)).map(|f| f.name.as_str()) + } +} + /// Every compiled-in manifest, sorted by id. pub fn manifests() -> &'static [Manifest] { static ALL: OnceLock> = OnceLock::new(); @@ -384,6 +399,22 @@ mod tests { std::fs::remove_dir_all(&root).unwrap(); } + /// DeepFilterNet is one program per platform, each the platform's own + /// upstream build, marked executable so the app sets the bit on install. + #[test] + fn deepfilternet_has_one_program_per_platform() { + let m = manifest(DEEPFILTERNET).unwrap(); + assert_eq!(m.kind, "files"); + for p in PLATFORMS { + assert!(m.supports(p), "{p}"); + let exe = m.executable_for(p).unwrap_or_else(|| panic!("no program for {p}")); + assert_eq!(m.expected_paths(p), vec![exe.to_string()]); + assert_eq!(exe.ends_with(".exe"), *p == "windows-x64", "{exe}"); + } + // Nothing else is marked executable. + assert!(manifest(AI_RUNTIME).unwrap().files.iter().all(|f| !f.executable)); + } + #[test] fn requirements_come_first() { assert_eq!(with_requirements(&[AI_RUNTIME, AI_RUNTIME]), vec![AI_RUNTIME.to_string()]); diff --git a/worker/src/audio_restoration.rs b/worker/src/audio_restoration.rs new file mode 100644 index 00000000..3c78a230 --- /dev/null +++ b/worker/src/audio_restoration.rs @@ -0,0 +1,1492 @@ +//! Audio restoration: renders the output's audio track before the encode. +//! +//! VapourBox's picture goes through VapourSynth; its sound never did — the +//! encoder copied or re-encoded the source's audio. This pass changes that for +//! one audio stream, and does it **before** the video encode, into a WAV the +//! encoder then maps in place of the source's stream: +//! +//! ```text +//! source ──ffmpeg (restore chain)──► a.wav ──[deep-filter]──► b.wav ──ffmpeg (loudness, limiter)──► restored.wav +//! │ +//! transcribe restored.wav (Whisper) ◄──────────────────────────────────────────────┤ +//! encode: video from vspipe + restored.wav (+ the source's other audio streams) ◄─┘ +//! ``` +//! +//! Rendering first, rather than adding an `-af` to the encode, is what makes +//! the rest possible: DeepFilterNet is a separate program; two-pass loudness +//! normalisation needs the whole track measured before any of it is written; +//! a negative sync offset needs audio from *before* the trim point; and the +//! subtitles transcribe the rendered file, which is the output's soundtrack +//! sample for sample, so their cues line up whatever offset, drift or trim was +//! applied. It also leaves the audio independent of how the video encode is +//! run (a segmented encode can map the same file). +//! +//! **One decision site.** Every filter is chosen by [`AudioPlan::new`], in one +//! fixed order, and each ffmpeg run gets exactly one `-af`, joined from its +//! share of that list — the same rule as the encoder's `-vf` chain, for the +//! same reason (ffmpeg honours only the last `-af`). +//! +//! The order, and why: +//! 1. `aresample=first_pts=0` — pads the start when the audio stream begins +//! after the video does (common in captures). Mapping the source's stream +//! straight into the output kept that gap through its timestamps; a WAV has +//! none, so it must be made of samples. +//! 2. channel fix (`pan`) — before anything measures or filters the channels. +//! 3. `adeclip` — first of the repairs: it reconstructs peaks from the shape +//! of the clipped waveform, which any filter ahead of it would change. +//! 4. `adeclick` — impulses, before the filters below smear them. +//! 5. rumble `highpass`, 6. hum notches (`bandreject` at f, 2f, 3f). +//! 7. `afftdn`, then `atrim` of its delay: it puts out its input 25 ms late +//! at every sample rate and strength (measured: 1200 samples at 48 kHz, +//! 1102 at 44.1, 800 at 32). +//! 8. timing — `atempo` (drift) and a silent lead (`adelay`) where a positive +//! offset reaches before the start; a negative one just reads later. +//! 9. DeepFilterNet (a separate program, `-D` compensates its 30 ms delay). +//! 10. `loudnorm` (two passes, linear), `aresample` back from its 192 kHz. +//! 11. `alimiter` with `latency=1` (otherwise it delays by its attack time) +//! and `level=0` (otherwise it normalises back up to 0 dBFS). +//! 12. `apad` + `atrim` to exactly the picture's length. + +use std::io::{BufRead, BufReader, Read}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use anyhow::{bail, Context, Result}; + +use crate::dependency_locator::DependencyLocator; +use crate::models::{ + AudioCodec, AudioMode, AudioRestorationParameters, ChannelFix, ContainerFormat, + EncodingSettings, HumFrequency, LogLevel, ProgressInfo, VideoJob, +}; +use crate::progress_reporter::ProgressReporter; + +/// The feature name the DeepFilterNet add-on refusal starts with. +pub const DEEP_FILTER_FEATURE: &str = "Speech enhancement (DeepFilterNet)"; + +/// DeepFilterNet works at 48 kHz and writes 48 kHz whatever it is given. +pub const DEEP_FILTER_RATE: u32 = 48_000; + +/// `afftdn`'s output delay, as a fraction of a second (measured, see above). +pub const AFFTDN_DELAY_SECONDS: f64 = 0.025; + +/// Drift smaller than this is left alone: under two frames, and within the +/// accuracy of a container's stated durations. +pub const DRIFT_MIN_SECONDS: f64 = 0.05; + +/// A tempo this close to 1 is not applied: `atempo=1.0` still resamples. +pub const TEMPO_EPSILON: f64 = 1e-6; + +/// Loudness normalisation's true-peak ceiling and loudness range: the +/// widely used streaming values. Only the integrated target is a setting. +pub const LOUDNORM_TRUE_PEAK: f64 = -1.5; +pub const LOUDNORM_RANGE: f64 = 11.0; + +/// How much audio the automatic channel and hum checks look at, and at what +/// rate (hum and its harmonics are all under 200 Hz). +pub const ANALYSIS_SECONDS: f64 = 60.0; +pub const ANALYSIS_RATE: u32 = 8000; + +/// Extra source audio read past the end, so the filters that trim their own +/// delay off the front still fill the window. +const READ_MARGIN_SECONDS: f64 = 1.0; + +/// Notch width (Q) for the hum filters: about 6 Hz wide at 50 Hz. +const HUM_Q: f64 = 8.0; + +// --------------------------------------------------------------------------- +// What is known about the source and the output +// --------------------------------------------------------------------------- + +/// The source's audio, as probed. +#[derive(Debug, Clone, PartialEq)] +pub struct SourceAudio { + /// How many audio streams the source has. + pub streams: u32, + /// The restored stream's channel count and sample rate. + pub channels: u32, + pub sample_rate: u32, + /// The restored stream's duration in seconds, when stated or measured. + pub duration: Option, +} + +/// The span of the output on the source's timeline: where the picture starts +/// (the trim's in-point) and how long the encoded picture is. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct OutputWindow { + pub start: f64, + /// `None` when the length is unknown: the audio then runs to its end. + pub duration: Option, +} + +impl OutputWindow { + /// The window a job's picture covers: from its in-point, as long as the + /// encoded video (its output frame count over its output rate, from the + /// pipeline's `FrameMap`s — not the source's count, which double-rate + /// deinterlacing, IVTC and frame-rate conversion all change). + pub fn for_job(job: &VideoJob) -> Self { + let fps = job.input_frame_rate.unwrap_or(29.97); + let start = job.start_frame.filter(|s| *s > 0).map_or(0.0, |s| s as f64 / fps); + let duration = job + .total_frames + .filter(|n| *n > 0) + .and_then(|n| job.effective_pipeline().output_duration_secs(n as u64, fps)); + Self { start, duration } + } +} + +/// Which channel of a stereo pair is dead. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeadChannel { + Left, + Right, +} + +/// What the automatic checks found, on a sample of the window. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Analysis { + pub dead_channel: Option, + /// 50 or 60 when a mains hum stands out of the spectrum. + pub hum: Option, +} + +/// A channel is dead when it is this far below the other… +pub const DEAD_CHANNEL_GAP_DB: f64 = 30.0; +/// …and the other carries something. +pub const LIVE_CHANNEL_MIN_DB: f64 = -60.0; + +/// Which channel to replace, from each channel's RMS level in dBFS. +pub fn dead_channel(left_db: f64, right_db: f64) -> Option { + let (loud, quiet) = if left_db >= right_db { (left_db, right_db) } else { (right_db, left_db) }; + if loud < LIVE_CHANNEL_MIN_DB || loud - quiet < DEAD_CHANNEL_GAP_DB { + return None; + } + Some(if left_db < right_db { DeadChannel::Left } else { DeadChannel::Right }) +} + +/// RMS level in dBFS of each channel of interleaved samples. +pub fn channel_levels_db(interleaved: &[f32], channels: usize) -> Vec { + let channels = channels.max(1); + let frames = interleaved.len() / channels; + (0..channels) + .map(|c| { + let sum: f64 = (0..frames).map(|i| (interleaved[i * channels + c] as f64).powi(2)).sum(); + let rms = (sum / frames.max(1) as f64).sqrt(); + 20.0 * (rms + 1e-12).log10() + }) + .collect() +} + +/// Power at `freq` by the Goertzel algorithm, averaged over 2-second blocks +/// (0.5 Hz resolution: mains frequency wanders by a tenth of a hertz, which a +/// single minute-long transform would resolve right past). +pub fn band_power(x: &[f32], rate: u32, freq: f64) -> f64 { + let block = (rate as usize) * 2; + if x.len() < block { + return goertzel(x, rate, freq); + } + let blocks: Vec = x.chunks_exact(block).map(|b| goertzel(b, rate, freq)).collect(); + blocks.iter().sum::() / blocks.len() as f64 +} + +fn goertzel(x: &[f32], rate: u32, freq: f64) -> f64 { + let n = x.len().max(1) as f64; + let w = 2.0 * std::f64::consts::PI * freq / rate as f64; + let coeff = 2.0 * w.cos(); + let (mut s1, mut s2) = (0.0f64, 0.0f64); + for (i, &v) in x.iter().enumerate() { + // Hann window, so a strong neighbour does not leak into the bin. + let win = 0.5 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / (n - 1.0).max(1.0)).cos(); + let s = v as f64 * win + coeff * s1 - s2; + s2 = s1; + s1 = s; + } + (s1 * s1 + s2 * s2 - coeff * s1 * s2) / (n * n) +} + +/// How far a hum line must stand above its neighbours, in dB. +pub const HUM_PROMINENCE_DB: f64 = 12.0; + +/// How far (dB) the strongest of `f`, 2f and 3f stands above the spectrum +/// either side of it. +pub fn hum_prominence_db(mono: &[f32], rate: u32, mains: f64) -> f64 { + (1..=3) + .map(|k| { + let f = mains * k as f64; + let peak = band_power(mono, rate, f); + // Neighbours 4–7 Hz away: outside the notch, inside the band, and + // clear of the other mains frequency's harmonics. + let mut refs: Vec = + [-7.0, -5.5, -4.0, 4.0, 5.5, 7.0].iter().map(|d| band_power(mono, rate, f + d)).collect(); + refs.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let floor = (refs[2] + refs[3]) / 2.0; + 10.0 * ((peak + 1e-20) / (floor + 1e-20)).log10() + }) + .fold(f64::MIN, f64::max) +} + +/// 50, 60 or nothing: whichever mains frequency's lines stand out more, if +/// either stands out by [`HUM_PROMINENCE_DB`]. +pub fn detect_hum(mono: &[f32], rate: u32) -> Option { + let p50 = hum_prominence_db(mono, rate, 50.0); + let p60 = hum_prominence_db(mono, rate, 60.0); + let (best, p) = if p50 >= p60 { (50, p50) } else { (60, p60) }; + (p >= HUM_PROMINENCE_DB).then_some(best) +} + +/// What drift correction will do. +#[derive(Debug, Clone, PartialEq)] +pub enum DriftDecision { + /// Stretch by this tempo factor (`atempo`), audio length / picture length. + Apply(f64), + /// Leave it, with the reason for the job log. + Skip(String), +} + +/// Whether to correct drift between an audio track of `audio_secs` and a +/// picture of `video_secs`, refusing anything above `max_percent`. +pub fn drift_decision(audio_secs: f64, video_secs: f64, max_percent: f64) -> DriftDecision { + if !(audio_secs > 0.0 && video_secs > 0.0) { + return DriftDecision::Skip("the audio or picture length could not be measured".into()); + } + let drift = audio_secs - video_secs; + let factor = audio_secs / video_secs; + let percent = (factor - 1.0).abs() * 100.0; + if drift.abs() <= DRIFT_MIN_SECONDS { + return DriftDecision::Skip(format!( + "the audio is {:.3} s against the picture's {:.3} s — within {} s, nothing to correct", + audio_secs, video_secs, DRIFT_MIN_SECONDS + )); + } + if percent > max_percent { + return DriftDecision::Skip(format!( + "the audio ({:.3} s) and picture ({:.3} s) differ by {:.2}%, more than the {:.2}% \ + limit — that is not clock drift (a missing stretch of audio, or the wrong track?), \ + so it is left uncorrected", + audio_secs, video_secs, percent, max_percent + )); + } + if (factor - 1.0).abs() < TEMPO_EPSILON { + return DriftDecision::Skip("the tempo change would be below 1e-6".into()); + } + DriftDecision::Apply(factor) +} + +// --------------------------------------------------------------------------- +// The plan: every decision, as pure data +// --------------------------------------------------------------------------- + +/// Loudness as measured by `loudnorm`'s first pass. +#[derive(Debug, Clone, PartialEq)] +pub struct LoudnessMeasurement { + pub input_i: f64, + pub input_tp: f64, + pub input_lra: f64, + pub input_thresh: f64, + pub target_offset: f64, +} + +/// Every decision about one job's audio, made up front. +#[derive(Debug, Clone, PartialEq)] +pub struct AudioPlan { + /// The source audio stream (among audio streams) restored. + pub stream: u32, + pub source_rate: u32, + /// Where to start reading the source (seconds, `-ss` before `-i`). + pub read_start: f64, + /// How much to read (`-t`), when the window's length is known. + pub read_duration: Option, + /// The first ffmpeg run's filters, in order. + pub restore_filters: Vec, + /// DeepFilterNet's attenuation limit, when it runs. + pub deep_filter: Option, + /// The loudness target, when normalising. + pub loudness_target: Option, + /// The filters after DeepFilterNet / loudness (limiter, length). + pub finish_filters: Vec, + /// The rendered track's sample rate and exact length in samples. + pub out_rate: u32, + pub out_samples: Option, + /// What was decided and why, for the job log. + pub notes: Vec, +} + +impl AudioPlan { + /// Decide everything. `drift` is the tempo factor to apply, already + /// vetted by [`drift_decision`]. + pub fn new( + p: &AudioRestorationParameters, + source: &SourceAudio, + window: OutputWindow, + analysis: &Analysis, + drift: Option, + ) -> Self { + let rate = source.sample_rate.max(1); + let mut notes = Vec::new(); + let mut f: Vec = Vec::new(); + + // 1. A stream that starts late is padded from the window's start. + f.push("aresample=first_pts=0".to_string()); + + // 2. Channels. + let fix = match p.channel_fix { + ChannelFix::Auto => match (source.channels, analysis.dead_channel) { + (2, Some(DeadChannel::Right)) => { + notes.push("Channel fix: the right channel is silent; using the left for both.".into()); + ChannelFix::LeftOnly + } + (2, Some(DeadChannel::Left)) => { + notes.push("Channel fix: the left channel is silent; using the right for both.".into()); + ChannelFix::RightOnly + } + (2, None) => { + notes.push("Channel fix: both channels carry sound; left as they are.".into()); + ChannelFix::Off + } + _ => ChannelFix::Off, + }, + other => other, + }; + if fix != ChannelFix::Off && source.channels != 2 { + notes.push(format!( + "Channel fix applies to stereo audio; this stream has {} channel(s), so it is skipped.", + source.channels + )); + } else if let Some(pan) = pan_filter(fix) { + f.push(pan.to_string()); + } + + // 3–7. Repairs. + if p.declip { + f.push("adeclip".to_string()); + } + if p.declick { + f.push(format!("adeclick=t={}", num(p.declick_threshold.clamp(1.0, 100.0)))); + } + if p.rumble_filter { + f.push(format!("highpass=f={}:poles=2", p.rumble_cutoff_hz.clamp(10, 300))); + } + if p.hum_removal { + let mains = match p.hum_frequency { + HumFrequency::Hz50 => Some(50), + HumFrequency::Hz60 => Some(60), + HumFrequency::Auto => { + match analysis.hum { + Some(hz) => notes.push(format!("Hum removal: found {hz} Hz mains hum.")), + None => notes.push( + "Hum removal: no 50 or 60 Hz hum stands out, so nothing is notched.".into(), + ), + } + analysis.hum + } + }; + if let Some(hz) = mains { + for k in 1..=3 { + f.push(format!("bandreject=f={}:width_type=q:w={}", hz * k, num(HUM_Q))); + } + } + } + if p.denoise { + f.push(format!("afftdn=nr={}:nf=-50:tn=1", num(p.denoise_strength.clamp(0.01, 97.0)))); + let delay = (rate as f64 * AFFTDN_DELAY_SECONDS).round() as u64; + f.push(format!("atrim=start_sample={delay}")); + f.push("asetpts=PTS-STARTPTS".to_string()); + } + + // 8. Timing. Source time for output time t is (start + t - offset) × tempo. + let tempo = drift.filter(|t| (t - 1.0).abs() >= TEMPO_EPSILON); + let factor = tempo.unwrap_or(1.0); + let offset = p.offset_ms as f64 / 1000.0; + let source_start = (window.start - offset) * factor; + let (read_start, lead) = if source_start >= 0.0 { + (source_start, 0.0) + } else { + // A delay reaching before the source's start: silence first. + (0.0, -source_start / factor) + }; + if let Some(t) = tempo { + f.push(format!("atempo={}", num_precise(t))); + notes.push(format!("Drift correction: tempo × {} .", num_precise(t))); + } + let lead_samples = (lead * rate as f64).round() as u64; + if lead_samples > 0 { + f.push(format!("adelay=delays={lead_samples}S:all=1")); + } + if p.offset_ms != 0 { + notes.push(format!( + "Sync offset: sound {} by {} ms.", + if p.offset_ms > 0 { "delayed" } else { "advanced" }, + p.offset_ms.abs() + )); + } + let read_duration = window.duration.map(|d| { + (d - lead).max(0.0) * factor + READ_MARGIN_SECONDS + if p.denoise { AFFTDN_DELAY_SECONDS } else { 0.0 } + }); + + // 9–12. + let deep_filter = p.deep_filter.then(|| p.deep_filter_attenuation.clamp(1, 100)); + let loudness_target = p.loudness_normalize.then(|| p.loudness_target.clamp(-70.0, -5.0)); + let out_rate = if deep_filter.is_some() { DEEP_FILTER_RATE } else { rate }; + let out_samples = window.duration.map(|d| (d * out_rate as f64).round() as u64); + let mut finish = Vec::new(); + if p.limiter { + let limit = 10f64.powf(p.limiter_ceiling_db.clamp(-24.0, 0.0) / 20.0).clamp(0.0625, 1.0); + finish.push(format!("alimiter=limit={}:level=0:latency=1", num(limit))); + } + if let Some(n) = out_samples { + finish.extend(length_filters(n)); + } + // With nothing after the first run, it writes the final track itself. + if deep_filter.is_none() && loudness_target.is_none() { + f.append(&mut finish); + } else if let Some(d) = window.duration { + // The input to DeepFilterNet / loudness already has its length. + f.extend(length_filters((d * rate as f64).round() as u64)); + } + + Self { + stream: p.stream_index, + source_rate: rate, + read_start, + read_duration, + restore_filters: f, + deep_filter, + loudness_target, + finish_filters: finish, + out_rate, + out_samples, + notes, + } + } + + /// Whether the first ffmpeg run writes the finished track. + pub fn single_pass(&self) -> bool { + self.deep_filter.is_none() && self.loudness_target.is_none() + } + + /// The first run: the source's chosen stream through the restore chain. + pub fn restore_args(&self, source: &str, output: &Path) -> Vec { + let mut args = quiet_args(); + if self.read_start > 0.0 { + args.extend(["-ss".into(), format!("{:.6}", self.read_start)]); + } + if let Some(t) = self.read_duration { + args.extend(["-t".into(), format!("{:.6}", t)]); + } + args.extend(["-i".into(), source.to_string()]); + args.extend(["-map".into(), format!("0:a:{}", self.stream)]); + args.extend(["-af".into(), self.restore_filters.join(",")]); + args.extend(wav_output_args(output)); + args + } + + /// DeepFilterNet on the first run's output, into `out_dir` (it keeps the + /// input's file name). + pub fn deep_filter_args(&self, input: &Path, out_dir: &Path) -> Option> { + let atten = self.deep_filter?; + Some(vec![ + // Compensate its STFT + lookahead delay (30 ms, measured). The + // track comes out that much short at the end; `finish` pads it. + "-D".into(), + "-a".into(), + atten.to_string(), + "-o".into(), + out_dir.to_string_lossy().to_string(), + input.to_string_lossy().to_string(), + ]) + } + + /// `loudnorm`'s measuring pass over `input`; its JSON is on stderr. + pub fn measure_args(&self, input: &Path) -> Option> { + let target = self.loudness_target?; + let mut args = quiet_args(); + args.extend(["-i".into(), input.to_string_lossy().to_string()]); + args.extend([ + "-af".into(), + format!( + "loudnorm=I={}:TP={}:LRA={}:print_format=json", + num(target), + num(LOUDNORM_TRUE_PEAK), + num(LOUDNORM_RANGE) + ), + ]); + args.extend(["-f".into(), "null".into(), "-".into()]); + Some(args) + } + + /// The last run, after DeepFilterNet or for loudness: loudness (with the + /// first pass's measurement), back to the track's rate, limiter, length. + pub fn finish_filter_chain(&self, measured: Option<&LoudnessMeasurement>) -> String { + let mut f = Vec::new(); + if let (Some(target), Some(m)) = (self.loudness_target, measured) { + f.push(format!( + "loudnorm=I={}:TP={}:LRA={}:measured_I={}:measured_TP={}:measured_LRA={}:\ + measured_thresh={}:offset={}:linear=true:print_format=summary", + num(target), + num(LOUDNORM_TRUE_PEAK), + num(LOUDNORM_RANGE), + num(m.input_i), + num(m.input_tp), + num(m.input_lra), + num(m.input_thresh), + num(m.target_offset) + )); + } + // loudnorm resamples to 192 kHz; DeepFilterNet's output is already 48. + f.push(format!("aresample={}", self.out_rate)); + f.extend(self.finish_filters.iter().cloned()); + f.join(",") + } + + pub fn finish_args(&self, input: &Path, output: &Path, measured: Option<&LoudnessMeasurement>) -> Vec { + let mut args = quiet_args(); + args.extend(["-i".into(), input.to_string_lossy().to_string()]); + args.extend(["-af".into(), self.finish_filter_chain(measured)]); + args.extend(wav_output_args(output)); + args + } +} + +fn quiet_args() -> Vec { + ["-hide_banner", "-nostdin", "-nostats", "-y"].iter().map(|s| s.to_string()).collect() +} + +/// Intermediate and final tracks are 32-bit float: `adeclip` rebuilds peaks +/// above full scale, and loudness gain comes later, so nothing may clip in +/// between. RF64 past 4 GB (about 3.5 hours of float stereo at 48 kHz). +fn wav_output_args(output: &Path) -> Vec { + [ + "-vn", "-sn", "-dn", "-map_metadata", "-1", "-c:a", "pcm_f32le", "-rf64", "auto", "-f", "wav", + ] + .iter() + .map(|s| s.to_string()) + .chain(std::iter::once(output.to_string_lossy().to_string())) + .collect() +} + +/// Exactly `n` samples: silence added if short, the rest cut if long. +fn length_filters(n: u64) -> [String; 2] { + [format!("apad=whole_len={n}"), format!("atrim=end_sample={n}")] +} + +fn pan_filter(fix: ChannelFix) -> Option<&'static str> { + match fix { + ChannelFix::LeftOnly => Some("pan=stereo|c0=c0|c1=c0"), + ChannelFix::RightOnly => Some("pan=stereo|c0=c1|c1=c1"), + ChannelFix::Mix => Some("pan=stereo|c0=0.5*c0+0.5*c1|c1=0.5*c0+0.5*c1"), + ChannelFix::Off | ChannelFix::Auto => None, + } +} + +/// A number for a filter argument: no trailing zeros, no exponent. +fn num(v: f64) -> String { + let s = format!("{:.4}", v); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.to_string() } +} + +/// A tempo factor: drift of 0.01% needs more than four places. +fn num_precise(v: f64) -> String { + let s = format!("{:.9}", v); + s.trim_end_matches('0').trim_end_matches('.').to_string() +} + +/// Parse `loudnorm`'s `print_format=json` block from ffmpeg's stderr. +pub fn parse_loudnorm_json(stderr: &str) -> Option { + let start = stderr.rfind("\"input_i\"").and_then(|i| stderr[..i].rfind('{'))?; + let end = start + stderr[start..].find('}')?; + let v: serde_json::Value = serde_json::from_str(&stderr[start..=end]).ok()?; + let get = |k: &str| -> Option { + let s = v.get(k)?.as_str()?.trim(); + s.parse::().ok().filter(|x| x.is_finite()) + }; + Some(LoudnessMeasurement { + input_i: get("input_i")?, + input_tp: get("input_tp")?, + input_lra: get("input_lra")?, + input_thresh: get("input_thresh")?, + target_offset: get("target_offset")?, + }) +} + +// --------------------------------------------------------------------------- +// The encoder's side: mapping the rendered track +// --------------------------------------------------------------------------- + +/// The rendered track as the encoder needs it. +#[derive(Debug, Clone, PartialEq)] +pub struct RestoredTrack { + pub path: PathBuf, + /// Which source audio stream it replaces, and how many there are. + pub stream: u32, + pub source_streams: u32, +} + +/// The encoder's input for the rendered track (comes after its other inputs). +pub fn encode_input_args(track: Option<&RestoredTrack>) -> Vec { + match track { + Some(t) => vec!["-i".into(), t.path.to_string_lossy().to_string()], + None => Vec::new(), + } +} + +/// The encoder's audio `-map`s. Without a rendered track, every source audio +/// stream (`1:a?`) as always. With one, the rendered track first — it is +/// the one a player picks by default — then the source's other audio streams +/// untouched, in their order, with the restored stream's tags (language, +/// title) carried over to the rendered one. +pub fn encode_map_args(settings: &EncodingSettings, track: Option<&RestoredTrack>, track_input: usize) -> Vec { + if settings.audio_mode == AudioMode::None { + return Vec::new(); + } + let Some(t) = track else { + return vec!["-map".into(), "1:a?".into()]; + }; + let mut args = vec!["-map".into(), format!("{track_input}:a:0")]; + for k in (0..t.source_streams).filter(|k| *k != t.stream) { + args.extend(["-map".into(), format!("1:a:{k}")]); + } + args.extend(["-map_metadata:s:a:0".into(), format!("1:s:a:{}", t.stream)]); + args +} + +/// The encoder's audio codec arguments. Passthrough copies — except the +/// rendered track, which is new audio and must be encoded: FLAC in MKV, AAC +/// at the chosen quality in MP4/MOV, PCM in AVI. +pub fn encode_codec_args(settings: &EncodingSettings, track: Option<&RestoredTrack>) -> Vec { + let mut args = Vec::new(); + match settings.audio_mode { + AudioMode::Passthrough => { + args.extend(["-c:a".into(), "copy".into()]); + if track.is_some() { + let codec = restored_passthrough_codec(settings.container); + args.extend(["-c:a:0".into(), codec.ffmpeg_name().into()]); + if !codec.is_lossless() { + args.extend(["-b:a:0".into(), format!("{}k", settings.audio_quality.bitrate())]); + } + } + } + AudioMode::Convert => { + args.extend(["-c:a".into(), settings.audio_codec.ffmpeg_name().into()]); + if !settings.audio_codec.is_lossless() { + args.extend(["-b:a".into(), format!("{}k", settings.audio_quality.bitrate())]); + } + } + AudioMode::None => args.push("-an".into()), + } + args +} + +/// What a restored track is encoded as when the audio setting is Passthrough. +pub fn restored_passthrough_codec(container: ContainerFormat) -> AudioCodec { + match container { + ContainerFormat::Mkv => AudioCodec::Flac, + ContainerFormat::Mp4 | ContainerFormat::Mov => AudioCodec::Aac, + ContainerFormat::Avi => AudioCodec::Pcm, + } +} + +// --------------------------------------------------------------------------- +// Running it +// --------------------------------------------------------------------------- + +/// A finished render: the track, and the scratch directory it lives in, +/// removed when this is dropped (after the encode and the subtitle mux). +pub struct RenderedAudio { + pub track: RestoredTrack, + work: PathBuf, +} + +impl Drop for RenderedAudio { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.work); + } +} + +/// The DeepFilterNet add-on's program for `platform` (its upstream file +/// name, which differs per platform; see its manifest). +pub fn deep_filter_binary(addons_root: &Path, platform: &str) -> Result { + let name = crate::addons::manifest(crate::addons::DEEPFILTERNET) + .and_then(|m| m.executable_for(platform)) + .with_context(|| format!("DeepFilterNet has no program for {platform}"))?; + Ok(crate::addons::addon_dir(addons_root, crate::addons::DEEPFILTERNET).join(name)) +} + +/// Probes the source, plans and renders. Owns nothing but its helpers. +pub struct AudioRestorer { + reporter: ProgressReporter, + deps: DependencyLocator, +} + +impl AudioRestorer { + pub fn new(reporter: ProgressReporter, deps: DependencyLocator) -> Self { + Self { reporter, deps } + } + + /// Plan the job's audio without rendering it (`--audio-plan`), or `None` + /// when the pass does nothing for this job (with the reason logged). + pub fn plan(&self, job: &VideoJob) -> Result> { + let p = job.effective_pipeline().audio; + if !p.has_effect() { + return Ok(None); + } + if p.uses_deep_filter() { + // Refused before anything runs, with the add-on refusal every + // feature gives (the app offers the download when it is chosen). + self.deps.require_addons(&[crate::addons::DEEPFILTERNET], DEEP_FILTER_FEATURE)?; + } + if job.encoding_settings.audio_mode == AudioMode::None { + self.log(LogLevel::Warning, "Audio restoration is on, but the output has no audio (Output → Audio is off); skipping it."); + return Ok(None); + } + let Some(mut source) = self.probe_source(&job.input_path, p.stream_index)? else { + self.log(LogLevel::Warning, "Audio restoration: the source has no audio stream; skipping it."); + return Ok(None); + }; + let mut p = p; + if p.stream_index >= source.streams { + self.log( + LogLevel::Warning, + &format!( + "Audio restoration: the source has {} audio stream(s), so stream {} does not exist; restoring the first.", + source.streams, p.stream_index + 1 + ), + ); + p.stream_index = 0; + source = self.probe_source(&job.input_path, 0)?.context("audio stream vanished")?; + } + let window = OutputWindow::for_job(job); + + let needs_analysis = p.channel_fix == ChannelFix::Auto + || (p.hum_removal && p.hum_frequency == HumFrequency::Auto); + let analysis = if needs_analysis { + self.analyse(&job.input_path, &p, &source, window).unwrap_or_else(|e| { + self.log(LogLevel::Warning, &format!("Audio analysis failed ({e}); automatic choices are off.")); + Analysis::default() + }) + } else { + Analysis::default() + }; + + let drift = if p.drift_correction { + match self.drift_factor(job, &source, p.drift_max_percent) { + DriftDecision::Apply(f) => Some(f), + DriftDecision::Skip(why) => { + self.log(LogLevel::Info, &format!("Drift correction: {why}.")); + None + } + } + } else { + None + }; + let plan = AudioPlan::new(&p, &source, window, &analysis, drift); + Ok(Some((plan, source))) + } + + /// Render the job's restored audio track, or `None` when the pass does + /// nothing for this job. + pub fn render bool>(&self, job: &VideoJob, on_cancel: F) -> Result> { + let Some((plan, source)) = self.plan(job)? else { return Ok(None) }; + for note in &plan.notes { + self.log(LogLevel::Info, note); + } + let ffmpeg = self.deps.ffmpeg_path()?; + let work = std::env::temp_dir().join(format!("vb_audio_{}", job.id)); + let _ = std::fs::remove_dir_all(&work); + std::fs::create_dir_all(&work).context("Failed to create the audio work directory")?; + // From here the directory is removed on any early return. + let rendered = RenderedAudio { + track: RestoredTrack { path: work.join("restored.wav"), stream: plan.stream, source_streams: source.streams }, + work: work.clone(), + }; + let total = plan.out_samples.map(|n| n as f64 / plan.out_rate as f64).or(source.duration); + + self.log(LogLevel::Info, "Restoring audio..."); + let first = if plan.single_pass() { rendered.track.path.clone() } else { work.join("restore.wav") }; + self.run_ffmpeg(&ffmpeg, &plan.restore_args(&job.input_path, &first), total, &on_cancel) + .context("Audio restoration failed")?; + + let mut current = first; + if let Some(args) = plan.deep_filter_args(¤t, &work.join("dfn")) { + self.log(LogLevel::Info, "Running DeepFilterNet speech enhancement..."); + let bin = deep_filter_binary(&self.deps.addons_dir(), self.deps.platform_suffix())?; + self.run_quiet(&bin, &args, &on_cancel).context("DeepFilterNet failed")?; + current = work.join("dfn").join(current.file_name().unwrap()); + if !current.exists() { + bail!("DeepFilterNet wrote no output at {}", current.display()); + } + } + if !plan.single_pass() { + let measured = match plan.measure_args(¤t) { + Some(args) => { + self.log(LogLevel::Info, "Measuring loudness..."); + let stderr = self.run_ffmpeg(&ffmpeg, &args, total, &on_cancel)?; + let m = parse_loudnorm_json(&stderr); + if m.is_none() { + self.log(LogLevel::Warning, "Loudness could not be measured (silent audio?); not normalising it."); + } + m + } + None => None, + }; + if let Some(m) = &measured { + self.log(LogLevel::Info, &format!( + "Loudness: measured {:.1} LUFS, true peak {:.1} dBTP; normalising to {} LUFS.", + m.input_i, m.input_tp, num(plan.loudness_target.unwrap_or_default()) + )); + } + let args = plan.finish_args(¤t, &rendered.track.path, measured.as_ref()); + self.run_ffmpeg(&ffmpeg, &args, total, &on_cancel).context("Audio finishing failed")?; + } + if on_cancel() { + bail!("Job cancelled"); + } + Ok(Some(rendered)) + } + + fn log(&self, level: LogLevel, msg: &str) { + self.reporter.send_log(level, msg); + } + + /// The number of audio streams, and the chosen one's details. + fn probe_source(&self, input: &str, stream: u32) -> Result> { + let ffprobe = self.deps.ffprobe_path()?; + let out = Command::new(&ffprobe) + .args(["-v", "error", "-select_streams", "a", "-show_entries", + "stream=channels,sample_rate,duration:stream_tags=DURATION", "-of", "json", input]) + .envs(self.deps.build_environment()) + .output() + .context("Failed to run ffprobe on the audio")?; + Ok(parse_source_probe(&String::from_utf8_lossy(&out.stdout), stream)) + } + + /// Decode a minute from the middle of the window at 8 kHz and measure it. + fn analyse(&self, input: &str, p: &AudioRestorationParameters, source: &SourceAudio, window: OutputWindow) -> Result { + let ffmpeg = self.deps.ffmpeg_path()?; + let start = window.start + window.duration.map_or(0.0, |d| (d / 2.0 - ANALYSIS_SECONDS / 2.0).max(0.0)); + let out = Command::new(&ffmpeg) + .args(["-v", "error", "-nostdin", "-ss", &format!("{start:.3}"), "-t", &format!("{ANALYSIS_SECONDS}"), + "-i", input, "-map", &format!("0:a:{}", p.stream_index), + "-ar", &ANALYSIS_RATE.to_string(), "-f", "f32le", "-"]) + .envs(self.deps.build_environment()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .context("Failed to decode audio for analysis")?; + let samples: Vec = out.stdout.chunks_exact(4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect(); + let ch = source.channels.max(1) as usize; + let mut a = Analysis::default(); + if ch == 2 { + let levels = channel_levels_db(&samples, 2); + a.dead_channel = dead_channel(levels[0], levels[1]); + self.log(LogLevel::Debug, &format!("Audio analysis: channel levels L {:.1} dB, R {:.1} dB", levels[0], levels[1])); + } + let mono: Vec = samples.chunks_exact(ch).map(|f| f.iter().sum::() / ch as f32).collect(); + a.hum = detect_hum(&mono, ANALYSIS_RATE); + self.log(LogLevel::Debug, &format!( + "Audio analysis: hum prominence 50 Hz {:.1} dB, 60 Hz {:.1} dB", + hum_prominence_db(&mono, ANALYSIS_RATE, 50.0), hum_prominence_db(&mono, ANALYSIS_RATE, 60.0) + )); + Ok(a) + } + + /// The whole source's audio length over the whole encoded picture's. + fn drift_factor(&self, job: &VideoJob, source: &SourceAudio, max_percent: f64) -> DriftDecision { + let fps = job.input_frame_rate.unwrap_or(29.97); + let trimmed = job.start_frame.is_some() || job.end_frame.is_some(); + // Drift is a property of the recording, so it is measured over all of + // it: a trimmed window's audio is cut to the window by construction. + let frames = if trimmed { self.deps.probe_frame_count(&job.input_path) } else { job.total_frames }; + let video = frames + .filter(|n| *n > 0) + .and_then(|n| job.effective_pipeline().output_duration_secs(n as u64, fps)); + let audio = source.duration.or_else(|| self.measure_audio_duration(&job.input_path, job.effective_pipeline().audio.stream_index)); + match (audio, video) { + (Some(a), Some(v)) => drift_decision(a, v, max_percent), + _ => DriftDecision::Skip("the audio or picture length could not be measured".into()), + } + } + + /// Decode the stream to nothing and read how long it was. + fn measure_audio_duration(&self, input: &str, stream: u32) -> Option { + let ffmpeg = self.deps.ffmpeg_path().ok()?; + let out = Command::new(&ffmpeg) + .args(["-nostdin", "-v", "error", "-i", input, "-map", &format!("0:a:{stream}"), + "-progress", "pipe:1", "-f", "null", "-"]) + .envs(self.deps.build_environment()) + .output() + .ok()?; + String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|l| l.strip_prefix("out_time_us=")) + .filter_map(|v| v.trim().parse::().ok()) + .last() + .map(|us| us as f64 / 1e6) + .filter(|s| *s > 0.0) + } + + /// Run ffmpeg, reporting progress against `total` seconds and honouring + /// cancel. Returns its stderr tail (the loudness measurement is there). + fn run_ffmpeg bool>(&self, ffmpeg: &Path, args: &[String], total: Option, on_cancel: &F) -> Result { + self.log(LogLevel::Debug, &format!("audio ffmpeg: {:?}", args)); + let mut full = vec!["-progress".to_string(), "pipe:1".to_string()]; + full.extend(args.iter().cloned()); + let mut child = Command::new(ffmpeg) + .args(&full) + .envs(self.deps.build_environment()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("Failed to start ffmpeg for audio")?; + let out_us = Arc::new(AtomicI64::new(0)); + let stdout = child.stdout.take().context("ffmpeg stdout")?; + let out_us2 = out_us.clone(); + let progress = thread::spawn(move || { + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + if let Some(v) = line.strip_prefix("out_time_us=") { + if let Ok(us) = v.trim().parse::() { + out_us2.store(us, Ordering::Relaxed); + } + } + } + }); + let tail = Arc::new(Mutex::new(String::new())); + let mut stderr = child.stderr.take().context("ffmpeg stderr")?; + let tail2 = tail.clone(); + let errs = thread::spawn(move || { + let mut s = String::new(); + let _ = stderr.read_to_string(&mut s); + let mut keep = s.len().saturating_sub(16_000); + while !s.is_char_boundary(keep) { + keep += 1; + } + *tail2.lock().unwrap() = s[keep..].to_string(); + }); + let status = loop { + if on_cancel() { + let _ = child.kill(); + let _ = child.wait(); + bail!("Job cancelled"); + } + if let Some(status) = child.try_wait()? { + break status; + } + let pct = total + .filter(|t| *t > 0.0) + .map_or(0, |t| ((out_us.load(Ordering::Relaxed) as f64 / 1e6 / t) * 100.0).clamp(0.0, 100.0) as i32); + self.reporter.send_progress_phase(&ProgressInfo::new(pct, if total.is_some() { 100 } else { 0 }, 0.0, 0.0), "audio"); + thread::sleep(Duration::from_millis(250)); + }; + let _ = progress.join(); + let _ = errs.join(); + let stderr = tail.lock().unwrap().clone(); + if !status.success() { + let last: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect(); + let last = last[last.len().saturating_sub(8)..].join("\n"); + bail!("ffmpeg exited with {status}:\n{last}"); + } + Ok(stderr) + } + + /// Run a helper program (DeepFilterNet) with indeterminate progress. + fn run_quiet bool>(&self, bin: &Path, args: &[String], on_cancel: &F) -> Result<()> { + self.log(LogLevel::Debug, &format!("{}: {:?}", bin.display(), args)); + let mut child = Command::new(bin) + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| format!("Failed to start {}", bin.display()))?; + let mut stderr = child.stderr.take().context("stderr")?; + let errs = thread::spawn(move || { + let mut s = String::new(); + let _ = stderr.read_to_string(&mut s); + s + }); + let status = loop { + if on_cancel() { + let _ = child.kill(); + let _ = child.wait(); + bail!("Job cancelled"); + } + if let Some(status) = child.try_wait()? { + break status; + } + self.reporter.send_progress_phase(&ProgressInfo::new(0, 0, 0.0, 0.0), "audio"); + thread::sleep(Duration::from_millis(250)); + }; + let stderr = errs.join().unwrap_or_default(); + if !status.success() { + let last: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect(); + bail!("exited with {status}:\n{}", last[last.len().saturating_sub(8)..].join("\n")); + } + Ok(()) + } +} + +/// Parse ffprobe's `-select_streams a` JSON: the stream count and the chosen +/// stream's details (`None` without any audio stream, or without `stream`). +pub fn parse_source_probe(json: &str, stream: u32) -> Option { + let v: serde_json::Value = serde_json::from_str(json).ok()?; + let streams = v.get("streams")?.as_array()?; + let s = streams.get(stream as usize)?; + let int = |k: &str| -> Option { + let x = s.get(k)?; + x.as_u64().map(|n| n as u32).or_else(|| x.as_str()?.parse().ok()) + }; + let duration = s + .get("duration") + .and_then(|d| d.as_str()) + .and_then(|d| d.parse::().ok()) + .or_else(|| s.get("tags")?.get("DURATION")?.as_str().and_then(parse_hms)) + .filter(|d| *d > 0.0); + Some(SourceAudio { + streams: streams.len() as u32, + channels: int("channels").unwrap_or(2), + sample_rate: int("sample_rate").unwrap_or(48_000), + duration, + }) +} + +/// "01:02:03.500000000" → 3723.5 (Matroska's DURATION tag). +fn parse_hms(s: &str) -> Option { + let parts: Vec<&str> = s.trim().split(':').collect(); + if parts.len() != 3 { + return None; + } + let h: f64 = parts[0].parse().ok()?; + let m: f64 = parts[1].parse().ok()?; + let sec: f64 = parts[2].parse().ok()?; + Some(h * 3600.0 + m * 60.0 + sec) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{AudioQuality, ProcessingPipeline, QTGMCParameters, DeinterlaceMethod, FrameRateParameters, FrameRateTarget}; + + fn stereo48() -> SourceAudio { + SourceAudio { streams: 1, channels: 2, sample_rate: 48_000, duration: Some(60.0) } + } + + fn window(start: f64, d: f64) -> OutputWindow { + OutputWindow { start, duration: Some(d) } + } + + fn on() -> AudioRestorationParameters { + AudioRestorationParameters { enabled: true, ..Default::default() } + } + + fn everything() -> AudioRestorationParameters { + AudioRestorationParameters { + enabled: true, + channel_fix: ChannelFix::Mix, + offset_ms: 120, + drift_correction: true, + declip: true, + declick: true, + hum_removal: true, + hum_frequency: HumFrequency::Hz50, + rumble_filter: true, + denoise: true, + deep_filter: true, + loudness_normalize: true, + limiter: true, + ..Default::default() + } + } + + fn names(filters: &[String]) -> Vec { + filters.iter().map(|f| f.split('=').next().unwrap().to_string()).collect() + } + + fn af_count(args: &[String]) -> usize { + args.iter().filter(|a| *a == "-af").count() + } + + /// The order is the module's documented order whatever is switched on, and + /// every run carries exactly one -af (ffmpeg honours only the last). + #[test] + fn every_combination_keeps_the_order_and_one_af_per_run() { + let order = [ + "aresample", "pan", "adeclip", "adeclick", "highpass", "bandreject", "afftdn", "atrim", + "asetpts", "atempo", "adelay", "alimiter", "apad", + ]; + let rank = |n: &str| order.iter().position(|o| *o == n).unwrap_or_else(|| panic!("unexpected filter {n}")); + // 9 toggles → 512 combinations, each with and without a negative offset. + for mask in 0u32..512 { + for offset in [0, 250, -250] { + let p = AudioRestorationParameters { + enabled: true, + channel_fix: if mask & 1 != 0 { ChannelFix::LeftOnly } else { ChannelFix::Off }, + declip: mask & 2 != 0, + declick: mask & 4 != 0, + rumble_filter: mask & 8 != 0, + hum_removal: mask & 16 != 0, + hum_frequency: HumFrequency::Hz60, + denoise: mask & 32 != 0, + deep_filter: mask & 64 != 0, + loudness_normalize: mask & 128 != 0, + limiter: mask & 256 != 0, + offset_ms: offset, + ..Default::default() + }; + let drift = if mask % 3 == 0 { Some(1.002) } else { None }; + let plan = AudioPlan::new(&p, &stereo48(), window(1.0, 30.0), &Analysis::default(), drift); + let ranks: Vec = names(&plan.restore_filters) + .iter() + .enumerate() + // The trailing atrim of the length fix is the last filter. + .filter(|(i, n)| !(n.as_str() == "atrim" && *i == plan.restore_filters.len() - 1)) + .map(|(_, n)| rank(n)) + .collect(); + assert!(ranks.windows(2).all(|w| w[0] <= w[1]), "mask {mask}: {:?}", plan.restore_filters); + let restore = plan.restore_args("in.mkv", Path::new("a.wav")); + assert_eq!(af_count(&restore), 1, "{restore:?}"); + if !plan.single_pass() { + let finish = plan.finish_args(Path::new("a.wav"), Path::new("b.wav"), None); + assert_eq!(af_count(&finish), 1, "{finish:?}"); + // The limiter only ever runs once, last before the length fix. + assert!(!plan.restore_filters.iter().any(|f| f.starts_with("alimiter"))); + } + // The length is always fixed at the end of the run that writes the track. + let last_chain = if plan.single_pass() { + plan.restore_filters.join(",") + } else { + plan.finish_filter_chain(None) + }; + assert!(last_chain.ends_with(&format!("apad=whole_len={n},atrim=end_sample={n}", n = 30 * plan.out_rate)), "{last_chain}"); + } + } + } + + #[test] + fn full_chain_reads_in_the_documented_order() { + let plan = AudioPlan::new(&everything(), &stereo48(), window(0.0, 10.0), &Analysis::default(), Some(1.001)); + assert_eq!( + plan.restore_filters, + vec![ + "aresample=first_pts=0", + "pan=stereo|c0=0.5*c0+0.5*c1|c1=0.5*c0+0.5*c1", + "adeclip", + "adeclick=t=2", + "highpass=f=40:poles=2", + "bandreject=f=50:width_type=q:w=8", + "bandreject=f=100:width_type=q:w=8", + "bandreject=f=150:width_type=q:w=8", + "afftdn=nr=12:nf=-50:tn=1", + "atrim=start_sample=1200", + "asetpts=PTS-STARTPTS", + "atempo=1.001", + // 120 ms delay from a start of 0 reaches before the source: lead silence. + "adelay=delays=5760S:all=1", + "apad=whole_len=480000", + "atrim=end_sample=480000", + ] + ); + assert_eq!(plan.deep_filter, Some(100)); + assert_eq!(plan.out_rate, 48_000); + let m = LoudnessMeasurement { input_i: -27.3, input_tp: -6.0, input_lra: 5.5, input_thresh: -37.5, target_offset: 0.2 }; + assert_eq!( + plan.finish_filter_chain(Some(&m)), + "loudnorm=I=-16:TP=-1.5:LRA=11:measured_I=-27.3:measured_TP=-6:measured_LRA=5.5:\ + measured_thresh=-37.5:offset=0.2:linear=true:print_format=summary,aresample=48000,\ + alimiter=limit=0.8913:level=0:latency=1,apad=whole_len=480000,atrim=end_sample=480000" + ); + } + + /// afftdn's delay is trimmed in samples at the source's own rate. + #[test] + fn afftdn_delay_is_trimmed_at_the_source_rate() { + for (rate, samples) in [(48_000, 1200), (44_100, 1103), (32_000, 800)] { + let src = SourceAudio { sample_rate: rate, ..stereo48() }; + let p = AudioRestorationParameters { denoise: true, ..on() }; + let plan = AudioPlan::new(&p, &src, window(0.0, 5.0), &Analysis::default(), None); + assert!(plan.restore_filters.contains(&format!("atrim=start_sample={samples}")), "{:?}", plan.restore_filters); + } + } + + /// A positive offset delays the sound: from a later in-point it reads + /// earlier source audio; from the start it needs silence in front. + /// A negative one reads later audio, never silence (adelay alone could + /// only ever delay). + #[test] + fn offsets_move_the_read_point_or_add_lead_silence() { + let at = |offset_ms: i32, start: f64| { + let p = AudioRestorationParameters { offset_ms, ..on() }; + AudioPlan::new(&p, &stereo48(), window(start, 10.0), &Analysis::default(), None) + }; + let late = at(300, 5.0); + assert!((late.read_start - 4.7).abs() < 1e-9); + assert!(!late.restore_filters.iter().any(|f| f.starts_with("adelay"))); + + let from_zero = at(300, 0.0); + assert_eq!(from_zero.read_start, 0.0); + assert!(from_zero.restore_filters.contains(&"adelay=delays=14400S:all=1".to_string())); + // Read only what fills the window after the lead (plus the margin). + assert!((from_zero.read_duration.unwrap() - (9.7 + READ_MARGIN_SECONDS)).abs() < 1e-9); + + let early = at(-300, 0.0); + assert!((early.read_start - 0.3).abs() < 1e-9); + assert!(!early.restore_filters.iter().any(|f| f.starts_with("adelay"))); + let args = early.restore_args("in.mkv", Path::new("o.wav")); + let ss = args.iter().position(|a| a == "-ss").unwrap(); + let i = args.iter().position(|a| a == "-i").unwrap(); + assert!(ss < i, "the seek is an input option"); + assert_eq!(args[ss + 1], "0.300000"); + } + + /// Drift and offset compose: source time = (start + t − offset) × tempo. + #[test] + fn drift_scales_the_read_point_and_length() { + let p = AudioRestorationParameters { offset_ms: -100, ..on() }; + let plan = AudioPlan::new(&p, &stereo48(), window(10.0, 20.0), &Analysis::default(), Some(1.004)); + assert!((plan.read_start - 10.1 * 1.004).abs() < 1e-9); + assert!((plan.read_duration.unwrap() - (20.0 * 1.004 + READ_MARGIN_SECONDS)).abs() < 1e-9); + assert!(plan.restore_filters.contains(&"atempo=1.004".to_string())); + } + + #[test] + fn atempo_is_skipped_within_1e6_of_one() { + for t in [1.0, 1.0 + 5e-7, 1.0 - 9e-7] { + let plan = AudioPlan::new(&on(), &stereo48(), window(0.0, 1.0), &Analysis::default(), Some(t)); + assert!(!plan.restore_filters.iter().any(|f| f.starts_with("atempo")), "{t}"); + } + let plan = AudioPlan::new(&on(), &stereo48(), window(0.0, 1.0), &Analysis::default(), Some(1.0 + 2e-6)); + assert!(plan.restore_filters.contains(&"atempo=1.000002".to_string()), "{:?}", plan.restore_filters); + } + + #[test] + fn drift_needs_more_than_50ms_and_at_most_the_cap() { + // 1 h picture. + let v = 3600.0; + assert!(matches!(drift_decision(v + 0.04, v, 0.5), DriftDecision::Skip(_))); + assert_eq!(drift_decision(v + 3.6, v, 0.5), DriftDecision::Apply((v + 3.6) / v)); + assert_eq!(drift_decision(v - 3.6, v, 0.5), DriftDecision::Apply((v - 3.6) / v)); + // 0.6% is over a 0.5% cap but under a 1% one. + assert!(matches!(drift_decision(v * 1.006, v, 0.5), DriftDecision::Skip(why) if why.contains("0.60%"))); + assert!(matches!(drift_decision(v * 1.006, v, 1.0), DriftDecision::Apply(_))); + assert!(matches!(drift_decision(0.0, v, 0.5), DriftDecision::Skip(_))); + } + + #[test] + fn channel_fix_auto_uses_the_analysis() { + let p = AudioRestorationParameters { channel_fix: ChannelFix::Auto, ..on() }; + let with = |dead| AudioPlan::new(&p, &stereo48(), window(0.0, 1.0), &Analysis { dead_channel: dead, hum: None }, None); + assert!(with(Some(DeadChannel::Right)).restore_filters.contains(&"pan=stereo|c0=c0|c1=c0".to_string())); + assert!(with(Some(DeadChannel::Left)).restore_filters.contains(&"pan=stereo|c0=c1|c1=c1".to_string())); + assert!(!with(None).restore_filters.iter().any(|f| f.starts_with("pan"))); + // Never on anything but stereo. + let mono = SourceAudio { channels: 1, ..stereo48() }; + let p = AudioRestorationParameters { channel_fix: ChannelFix::LeftOnly, ..on() }; + let plan = AudioPlan::new(&p, &mono, window(0.0, 1.0), &Analysis::default(), None); + assert!(!plan.restore_filters.iter().any(|f| f.starts_with("pan"))); + assert!(plan.notes.iter().any(|n| n.contains("1 channel"))); + } + + #[test] + fn dead_channel_needs_a_30db_gap_and_a_live_side() { + assert_eq!(dead_channel(-20.0, -90.0), Some(DeadChannel::Right)); + assert_eq!(dead_channel(-95.0, -25.0), Some(DeadChannel::Left)); + assert_eq!(dead_channel(-20.0, -45.0), None); // quiet, not dead + assert_eq!(dead_channel(-80.0, -120.0), None); // all silence + } + + #[test] + fn hum_auto_notches_only_what_was_found() { + let p = AudioRestorationParameters { hum_removal: true, ..on() }; + let plan = |hum| AudioPlan::new(&p, &stereo48(), window(0.0, 1.0), &Analysis { dead_channel: None, hum }, None); + let f60 = plan(Some(60)).restore_filters; + assert!(f60.iter().any(|f| f == "bandreject=f=180:width_type=q:w=8")); + assert!(!f60.iter().any(|f| f.contains("f=50:"))); + let none = plan(None); + assert!(!none.restore_filters.iter().any(|f| f.starts_with("bandreject"))); + assert!(none.notes.iter().any(|n| n.contains("nothing is notched"))); + } + + fn synth(rate: u32, secs: f64, mut f: impl FnMut(f64) -> f64) -> Vec { + (0..(rate as f64 * secs) as usize).map(|i| f(i as f64 / rate as f64) as f32).collect() + } + + /// A deterministic noise source for the synthetic tests. + fn noise(seed: &mut u64) -> f64 { + *seed ^= *seed << 13; + *seed ^= *seed >> 7; + *seed ^= *seed << 17; + (*seed as f64 / u64::MAX as f64) * 2.0 - 1.0 + } + + #[test] + fn hum_detection_tells_50_from_60_and_ignores_plain_noise() { + use std::f64::consts::PI; + let mut s = 0x1234_5678_9abc_def0u64; + let mut s2 = s; + let mut s3 = s; + // Hum at 50.1 Hz (mains wanders), harmonics, under noise and a 440 Hz tone. + let hum50 = synth(8000, 20.0, |t| { + 0.02 * (2.0 * PI * 50.1 * t).sin() + 0.01 * (2.0 * PI * 100.2 * t).sin() + + 0.2 * (2.0 * PI * 440.0 * t).sin() + 0.05 * noise(&mut s) + }); + assert_eq!(detect_hum(&hum50, 8000), Some(50)); + // 60 Hz mains where the third harmonic dominates (rectifier buzz). + let hum60 = synth(8000, 20.0, |t| { + 0.003 * (2.0 * PI * 60.0 * t).sin() + 0.02 * (2.0 * PI * 180.0 * t).sin() + 0.05 * noise(&mut s2) + }); + assert_eq!(detect_hum(&hum60, 8000), Some(60)); + let clean = synth(8000, 20.0, |t| 0.2 * (2.0 * PI * 440.0 * t).sin() + 0.05 * noise(&mut s3)); + assert_eq!(detect_hum(&clean, 8000), None); + } + + #[test] + fn channel_levels_measure_each_channel() { + let interleaved: Vec = (0..16000).flat_map(|i| [if i % 2 == 0 { 0.5 } else { -0.5 }, 0.0]).collect(); + let l = channel_levels_db(&interleaved, 2); + assert!((l[0] - 20.0 * 0.5f64.log10()).abs() < 0.01); + assert!(l[1] < -200.0); + } + + #[test] + fn loudnorm_json_is_read_from_stderr() { + let stderr = "[Parsed_loudnorm_0 @ 0x1]\n{\n\t\"input_i\" : \"-27.61\",\n\t\"input_tp\" : \"-4.47\",\n\ + \t\"input_lra\" : \"18.06\",\n\t\"input_thresh\" : \"-39.20\",\n\t\"output_i\" : \"-16.58\",\n\ + \t\"output_tp\" : \"-1.50\",\n\t\"output_lra\" : \"14.78\",\n\t\"output_thresh\" : \"-27.71\",\n\ + \t\"normalization_type\" : \"dynamic\",\n\t\"target_offset\" : \"0.58\"\n}\n"; + let m = parse_loudnorm_json(stderr).unwrap(); + assert_eq!(m.input_i, -27.61); + assert_eq!(m.target_offset, 0.58); + // Silence measures -inf: nothing to normalise. + assert!(parse_loudnorm_json(&stderr.replace("-27.61", "-inf")).is_none()); + assert!(parse_loudnorm_json("no json").is_none()); + } + + #[test] + fn source_probe_reads_streams_and_durations() { + let json = r#"{"streams":[{"channels":2,"sample_rate":"48000","duration":"12.5"}, + {"channels":6,"sample_rate":"44100","tags":{"DURATION":"01:00:01.250000000"}}]}"#; + let a = parse_source_probe(json, 0).unwrap(); + assert_eq!((a.streams, a.channels, a.sample_rate, a.duration), (2, 2, 48_000, Some(12.5))); + let b = parse_source_probe(json, 1).unwrap(); + assert_eq!((b.channels, b.sample_rate, b.duration), (6, 44_100, Some(3601.25))); + assert!(parse_source_probe(json, 2).is_none()); + assert!(parse_source_probe(r#"{"streams":[]}"#, 0).is_none()); + } + + /// The picture's length is its output frames over its output rate, so + /// double-rate deinterlacing and frame-rate conversion leave it as long + /// as the source window, and a trim shortens it. + #[test] + fn the_window_is_the_encoded_pictures_length() { + let mut job: VideoJob = serde_json::from_value(serde_json::json!({ + "id": "00000000-0000-0000-0000-000000000001", + "inputPath": "in.mkv", "outputPath": "out.mkv", + "qtgmcParameters": {}, "encodingSettings": {}, + "totalFrames": 250, "inputFrameRate": 25.0 + })) + .unwrap(); + let mut pipeline = ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: true, fps_divisor: Some(1), ..Default::default() }, + ..Default::default() + }; + job.processing_pipeline = Some(pipeline.clone()); + let w = OutputWindow::for_job(&job); + assert_eq!(w.start, 0.0); + assert!((w.duration.unwrap() - 10.0).abs() < 1e-9, "{w:?}"); + + // IVTC from 29.97: 300 frames → 240 at 23.976. + pipeline.deinterlace = QTGMCParameters { enabled: true, method: DeinterlaceMethod::Ivtc, ..Default::default() }; + job.processing_pipeline = Some(pipeline.clone()); + job.input_frame_rate = Some(30000.0 / 1001.0); + job.total_frames = Some(300); + assert!((OutputWindow::for_job(&job).duration.unwrap() - 300.0 * 1001.0 / 30000.0).abs() < 1e-9); + + // 25 → 50 fps, trimmed to 100 frames from frame 50: 200 frames at 50 = 4 s. + pipeline.deinterlace.enabled = false; + pipeline.frame_rate = FrameRateParameters { enabled: true, target: FrameRateTarget::Pal50, ..Default::default() }; + job.processing_pipeline = Some(pipeline.clone()); + job.input_frame_rate = Some(25.0); + job.start_frame = Some(50); + job.end_frame = Some(149); + job.total_frames = Some(100); + let w = OutputWindow::for_job(&job); + assert!((w.start - 2.0).abs() < 1e-9); + assert!((w.duration.unwrap() - 4.0).abs() < 1e-9, "{w:?}"); + + // 25 → 59.94 is not a whole ratio: floor(100 × 2400/1001) = 239 frames + // at 60000/1001 — the encoded length, a frame short of the source's. + pipeline.frame_rate.target = FrameRateTarget::Ntsc5994; + job.processing_pipeline = Some(pipeline); + let d = OutputWindow::for_job(&job).duration.unwrap(); + assert!((d - 239.0 * 1001.0 / 60000.0).abs() < 1e-9, "{d}"); + } + + fn settings(mode: AudioMode, container: ContainerFormat) -> EncodingSettings { + EncodingSettings { audio_mode: mode, container, audio_quality: AudioQuality::High, ..Default::default() } + } + + #[test] + fn encoder_maps_the_restored_track_first_and_keeps_the_others() { + let s = settings(AudioMode::Passthrough, ContainerFormat::Mkv); + assert_eq!(encode_map_args(&s, None, 3), vec!["-map", "1:a?"]); + let t = RestoredTrack { path: "r.wav".into(), stream: 1, source_streams: 3 }; + assert_eq!( + encode_map_args(&s, Some(&t), 3), + vec!["-map", "3:a:0", "-map", "1:a:0", "-map", "1:a:2", "-map_metadata:s:a:0", "1:s:a:1"] + ); + assert!(encode_map_args(&settings(AudioMode::None, ContainerFormat::Mkv), Some(&t), 3).is_empty()); + assert_eq!(encode_input_args(Some(&t)), vec!["-i", "r.wav"]); + assert!(encode_input_args(None).is_empty()); + } + + #[test] + fn passthrough_copies_the_others_and_encodes_the_restored_track() { + let t = RestoredTrack { path: "r.wav".into(), stream: 0, source_streams: 2 }; + assert_eq!( + encode_codec_args(&settings(AudioMode::Passthrough, ContainerFormat::Mkv), Some(&t)), + vec!["-c:a", "copy", "-c:a:0", "flac"] + ); + assert_eq!( + encode_codec_args(&settings(AudioMode::Passthrough, ContainerFormat::Mp4), Some(&t)), + vec!["-c:a", "copy", "-c:a:0", "aac", "-b:a:0", "192k"] + ); + assert_eq!( + encode_codec_args(&settings(AudioMode::Passthrough, ContainerFormat::Avi), Some(&t)), + vec!["-c:a", "copy", "-c:a:0", "pcm_s16le"] + ); + // Without a track, exactly what the encoder always did. + assert_eq!(encode_codec_args(&settings(AudioMode::Passthrough, ContainerFormat::Mp4), None), vec!["-c:a", "copy"]); + let mut convert = settings(AudioMode::Convert, ContainerFormat::Mp4); + convert.audio_codec = AudioCodec::Opus; + assert_eq!(encode_codec_args(&convert, Some(&t)), vec!["-c:a", "libopus", "-b:a", "192k"]); + assert_eq!(encode_codec_args(&settings(AudioMode::None, ContainerFormat::Mp4), None), vec!["-an"]); + } + + #[test] + fn deep_filter_and_loudness_args() { + let p = AudioRestorationParameters { deep_filter: true, deep_filter_attenuation: 24, loudness_normalize: true, loudness_target: -23.0, ..on() }; + let plan = AudioPlan::new(&p, &SourceAudio { sample_rate: 44_100, ..stereo48() }, window(0.0, 2.0), &Analysis::default(), None); + assert_eq!( + plan.deep_filter_args(Path::new("w/a.wav"), Path::new("w/dfn")).unwrap(), + vec!["-D", "-a", "24", "-o", "w/dfn", "w/a.wav"] + ); + // The DeepFilterNet input keeps the source rate; the output is 48 kHz. + assert!(plan.restore_filters.ends_with(&["apad=whole_len=88200".to_string(), "atrim=end_sample=88200".to_string()])); + assert_eq!(plan.out_rate, 48_000); + let m = plan.measure_args(Path::new("b.wav")).unwrap(); + assert!(m.contains(&"loudnorm=I=-23:TP=-1.5:LRA=11:print_format=json".to_string())); + assert_eq!(&m[m.len() - 3..], ["-f", "null", "-"]); + // Without a measurement (silence), no loudnorm at all — never one-pass. + assert!(!plan.finish_filter_chain(None).contains("loudnorm")); + } + + #[test] + fn numbers_are_plain() { + assert_eq!(num(12.0), "12"); + assert_eq!(num(-1.5), "-1.5"); + assert_eq!(num(0.891251), "0.8913"); + assert_eq!(num(-0.0), "0"); + assert_eq!(num_precise(1.000999), "1.000999"); + } +} diff --git a/worker/src/lib.rs b/worker/src/lib.rs index e5326ee3..f429c99b 100644 --- a/worker/src/lib.rs +++ b/worker/src/lib.rs @@ -15,6 +15,7 @@ pub mod source_decode; pub mod source_timing; pub mod progress_reporter; pub mod addons; +pub mod audio_restoration; pub mod ai_runtime; pub mod animejanai; pub mod gpu_plugin; diff --git a/worker/src/main.rs b/worker/src/main.rs index 6d5250ec..1c3d10e2 100644 --- a/worker/src/main.rs +++ b/worker/src/main.rs @@ -29,6 +29,7 @@ mod source_decode; mod source_timing; mod progress_reporter; mod addons; +mod audio_restoration; mod ai_runtime; mod animejanai; mod gpu_plugin; @@ -130,6 +131,12 @@ struct Args { #[arg(long)] probe_ai_runtime: bool, + /// Print the audio restoration plan for the job in `--config` (the probes + /// it makes, then every ffmpeg / DeepFilterNet argument list it would run) + /// as JSON, and exit without rendering anything. + #[arg(long)] + audio_plan: bool, + /// Print the add-on manifests compiled into this worker, as JSON /// (`{"": "", …}`), and exit. #[arg(long)] @@ -199,6 +206,10 @@ fn main() -> ExitCode { return run_probe_ai_runtime(); } + if args.audio_plan { + return run_audio_plan(&args); + } + if args.list_addons { let list: serde_json::Map = addons::manifests() .iter() @@ -294,6 +305,49 @@ fn run_probe_opencl() -> ExitCode { } +/// Print the audio restoration plan for the job in `--config` as JSON +/// (`{"active": false}` when the pass does nothing for it). Log messages go to +/// stderr, so stdout is the one JSON line. +fn run_audio_plan(args: &Args) -> ExitCode { + let result = (|| -> anyhow::Result { + let config = args + .config + .as_ref() + .ok_or_else(|| anyhow::anyhow!("--config is required with --audio-plan"))?; + let mut job: models::VideoJob = serde_json::from_str(&std::fs::read_to_string(config)?)?; + let deps = dependency_locator::DependencyLocator::new()?; + fill_total_frames(&mut job, Some(&deps), |_| {}); + let reporter = ProgressReporter::new_to_stderr(); + let Some((plan, source)) = audio_restoration::AudioRestorer::new(reporter, deps).plan(&job)? else { + return Ok(serde_json::json!({ "active": false })); + }; + let p = |s: &str| std::path::PathBuf::from(s); + let first = if plan.single_pass() { "restored.wav" } else { "restore.wav" }; + Ok(serde_json::json!({ + "active": true, + "sourceStreams": source.streams, + "stream": plan.stream, + "outRate": plan.out_rate, + "outSamples": plan.out_samples, + "restore": plan.restore_args(&job.input_path, &p(first)), + "deepFilter": plan.deep_filter_args(&p(first), &p("dfn")), + "measure": plan.measure_args(&p(first)), + "finish": (!plan.single_pass()).then(|| plan.finish_args(&p(first), &p("restored.wav"), None)), + "notes": plan.notes, + })) + })(); + match result { + Ok(v) => { + println!("{v}"); + ExitCode::SUCCESS + } + Err(e) => { + println!("{}", serde_json::json!({ "error": format!("{e:#}") })); + ExitCode::FAILURE + } + } +} + /// Detect a source's native resolution and kernel and print the report as one /// JSON line (`{"error": ...}` when it could not be analysed). fn run_detect_native(args: &Args) -> ExitCode { @@ -537,6 +591,33 @@ fn run_preview_mode(args: &Args) -> ExitCode { } } +/// Resolve the frame count if the caller didn't supply one. The Flutter app +/// probes and sets total_frames; direct callers (tests, CLI) may omit it, and +/// the script's pipe_source needs an exact length (it builds a fixed-size +/// clip). Without this the worker defaults to 1 frame and the whole output is +/// a single frame. total_frames must be the *post-trim* count the decoder +/// will actually pipe (trimming is decoder-side: -ss + -frames:v), matching +/// the decoder's own range logic in pipeline_executor. +fn fill_total_frames( + job: &mut VideoJob, + deps: Option<&dependency_locator::DependencyLocator>, + log: impl Fn(&str), +) { + if job.total_frames.is_none() { + if let Some(full) = deps.and_then(|d| d.probe_frame_count(&job.input_path)) { + let effective = match (job.start_frame, job.end_frame) { + (Some(s), Some(e)) => (e - s + 1).max(0), + (None, Some(e)) => e + 1, + (Some(s), None) => (full - s).max(0), + (None, None) => full, + }; + log(&format!("Probed input frame count: {} (effective after trim: {})", full, effective)); + job.total_frames = Some(effective); + } + } + +} + /// Fill a missing `input_width`/`input_height` from ffprobe. A job that carries /// both is never touched; if either is missing and the probe succeeds, both /// come from the probe; a failed probe leaves the old fallback in place. @@ -619,80 +700,9 @@ fn run_worker( // Resolve deps once for the pre-generation probes below. let deps = dependency_locator::DependencyLocator::new().ok(); - // ------------------------------------------------------------------ - // Pre-encode transcription - // ------------------------------------------------------------------ - // Whisper used to run only after the encode, which made burn-in - // impossible: the encoder needs the subtitle file while it is running, and - // a transcript that does not exist yet cannot be drawn into the picture. - // - // Transcribing the SOURCE instead means honouring the trim. The encoder - // seeks the audio input to the trim point, so the output's audio starts - // there — a transcript of the whole source would put every cue early by - // exactly the trimmed-off head. Nothing else retimes audio: IVTC and - // frame-rate conversion change the video timeline only. - let mut whisper_srt: Option = None; - if let Some(ref sub_settings) = job.subtitle_settings { - if sub_settings.enabled && job.burn_in_subtitle_path.is_none() { - let fps = job.input_frame_rate.unwrap_or(29.97); - let window = match (job.start_frame, job.end_frame) { - (Some(start), Some(end)) if end >= start => Some(( - start as f64 / fps, - Some((end - start + 1) as f64 / fps), - )), - (Some(start), None) => Some((start as f64 / fps, None)), - (None, Some(end)) => Some((0.0, Some((end + 1) as f64 / fps))), - _ => None, - }; - let srt_target = std::path::Path::new(&job.output_path).with_extension("srt"); - match dependency_locator::DependencyLocator::new().and_then(|d| { - SubtitleGenerator::new(reporter.clone(), d).transcribe( - &job.input_path, - sub_settings, - window, - &srt_target, - || cancelled.load(Ordering::SeqCst), - ) - }) { - Ok(Some(path)) => { - // Burn-in reads this during the encode; the mux pass below - // reads it afterwards. - if sub_settings.output.burns_in() { - job.burn_in_subtitle_path = Some(path.to_string_lossy().to_string()); - } - whisper_srt = Some(path); - } - Ok(None) => {} - Err(e) => reporter.send_log( - models::LogLevel::Warning, - &format!("Subtitle generation failed: {e}"), - ), - } - } - } - - // Resolve the frame count if the caller didn't supply one. The Flutter app - // probes and sets total_frames; direct callers (tests, CLI) may omit it, and - // the script's pipe_source needs an exact length (it builds a fixed-size - // clip). Without this the worker defaults to 1 frame and the whole output is - // a single frame. total_frames must be the *post-trim* count the decoder - // will actually pipe (trimming is decoder-side: -ss + -frames:v), matching - // the decoder's own range logic in pipeline_executor. - if job.total_frames.is_none() { - if let Some(full) = deps.as_ref().and_then(|d| d.probe_frame_count(&job.input_path)) { - let effective = match (job.start_frame, job.end_frame) { - (Some(s), Some(e)) => (e - s + 1).max(0), - (None, Some(e)) => e + 1, - (Some(s), None) => (full - s).max(0), - (None, None) => full, - }; - reporter.send_log( - models::LogLevel::Debug, - &format!("Probed input frame count: {} (effective after trim: {})", full, effective), - ); - job.total_frames = Some(effective); - } - } + fill_total_frames(&mut job, deps.as_ref(), |msg| { + reporter.send_log(models::LogLevel::Debug, msg) + }); // Same for the frame size: the app always sends ffprobe's width/height, but // a direct caller may not, and the old 720x480 fallback is now a decode @@ -772,6 +782,81 @@ fn run_worker( &format!("Script written to: {:?}", script_path), ); + // ------------------------------------------------------------------ + // Audio restoration + // ------------------------------------------------------------------ + // Rendered before the transcription and the encode, both of which then + // use the rendered track (see audio_restoration.rs). After the script, so + // a job the script generator refuses fails before any audio work. Held + // to the end of the job: its scratch directory goes when it is dropped. + let restored_audio = if job.effective_pipeline().audio.has_effect() { + let d = dependency_locator::DependencyLocator::new()?; + audio_restoration::AudioRestorer::new(reporter.clone(), d) + .render(&job, || cancelled.load(Ordering::SeqCst))? + } else { + None + }; + + // ------------------------------------------------------------------ + // Pre-encode transcription + // ------------------------------------------------------------------ + // Whisper used to run only after the encode, which made burn-in + // impossible: the encoder needs the subtitle file while it is running, and + // a transcript that does not exist yet cannot be drawn into the picture. + // + // Transcribing before the encode means transcribing exactly the audio the + // output will carry. With audio restoration that is the rendered track: + // it is the output's soundtrack sample for sample — trimmed, offset, + // drift-corrected and cleaned — so cues line up whatever was done to it, + // and Whisper hears the cleaned sound. Without it, it is the source's + // audio over the trim window: the encoder seeks the audio input to the + // trim point, so a transcript of the whole source would put every cue + // early by exactly the trimmed-off head. Nothing else retimes audio: IVTC + // and frame-rate conversion change the video timeline only. + let mut whisper_srt: Option = None; + if let Some(ref sub_settings) = job.subtitle_settings { + if sub_settings.enabled && job.burn_in_subtitle_path.is_none() { + let fps = job.input_frame_rate.unwrap_or(29.97); + let source_window = match (job.start_frame, job.end_frame) { + (Some(start), Some(end)) if end >= start => Some(( + start as f64 / fps, + Some((end - start + 1) as f64 / fps), + )), + (Some(start), None) => Some((start as f64 / fps, None)), + (None, Some(end)) => Some((0.0, Some((end + 1) as f64 / fps))), + _ => None, + }; + let (transcribe_from, window) = match &restored_audio { + Some(r) => (r.track.path.to_string_lossy().to_string(), None), + None => (job.input_path.clone(), source_window), + }; + let srt_target = std::path::Path::new(&job.output_path).with_extension("srt"); + match dependency_locator::DependencyLocator::new().and_then(|d| { + SubtitleGenerator::new(reporter.clone(), d).transcribe( + &transcribe_from, + sub_settings, + window, + &srt_target, + || cancelled.load(Ordering::SeqCst), + ) + }) { + Ok(Some(path)) => { + // Burn-in reads this during the encode; the mux pass below + // reads it afterwards. + if sub_settings.output.burns_in() { + job.burn_in_subtitle_path = Some(path.to_string_lossy().to_string()); + } + whisper_srt = Some(path); + } + Ok(None) => {} + Err(e) => reporter.send_log( + models::LogLevel::Warning, + &format!("Subtitle generation failed: {e}"), + ), + } + } + } + // Execute pipeline. // // Retry on the rare VapourSynth plugin-autoload skip (a needed plugin @@ -784,7 +869,9 @@ fn run_worker( const MAX_PIPELINE_ATTEMPTS: u32 = 3; let mut attempt = 1; loop { - let mut executor = PipelineExecutor::new(reporter.clone())?.with_timeline(timeline.clone()); + let mut executor = PipelineExecutor::new(reporter.clone())? + .with_timeline(timeline.clone()) + .with_restored_audio(restored_audio.as_ref().map(|r| r.track.clone())); match executor.execute(&script_path, &job, || cancelled.load(Ordering::SeqCst)) { Ok(()) => break, Err(e) diff --git a/worker/src/models/audio_restoration_parameters.rs b/worker/src/models/audio_restoration_parameters.rs new file mode 100644 index 00000000..d164218d --- /dev/null +++ b/worker/src/models/audio_restoration_parameters.rs @@ -0,0 +1,235 @@ +//! Audio restoration — the one pass that works on the soundtrack. +//! +//! Everything here is applied by ffmpeg (and, for speech, the DeepFilterNet +//! add-on) in `audio_restoration.rs`, which renders the output's audio track +//! before the video encode; nothing reaches the VapourSynth script. See that +//! module for the order of the steps and why each sits where it does. + +use serde::{Deserialize, Serialize}; + +/// What to do about the two channels of a stereo source. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum ChannelFix { + /// Leave the channels alone. + #[default] + Off, + /// Measure both channels; if one is silent, copy the other into it. + /// Otherwise leave them alone. + Auto, + /// Both outputs from the left channel. + LeftOnly, + /// Both outputs from the right channel. + RightOnly, + /// Both outputs from the average of the two. + Mix, +} + +/// Which mains frequency to notch out. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum HumFrequency { + /// Measure the audio for a 50 or 60 Hz hum and notch whichever is there; + /// nothing when neither is. + #[default] + #[serde(rename = "auto")] + Auto, + #[serde(rename = "50")] + Hz50, + #[serde(rename = "60")] + Hz60, +} + +/// Parameters for the audio restoration pass. Off by default, and every step +/// inside it is off by default too: switching the pass on changes nothing until +/// a step is chosen. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AudioRestorationParameters { + #[serde(default)] + pub enabled: bool, + + /// Which of the source's audio streams to restore, counted among its audio + /// streams from 0. The others are kept as they are. + #[serde(default)] + pub stream_index: u32, + + #[serde(default)] + pub channel_fix: ChannelFix, + + /// Move the sound against the picture, in milliseconds. Positive delays + /// the sound (it plays later), negative advances it. + #[serde(default)] + pub offset_ms: i32, + + /// Stretch or squeeze the sound to the picture's length when the two were + /// recorded on clocks that disagreed. + #[serde(default)] + pub drift_correction: bool, + + /// The largest drift corrected, as a percentage of the length. A bigger + /// mismatch is a different problem (the wrong track, a missing stretch of + /// audio), which a stretch would only disguise. + #[serde(default = "default_drift_max_percent")] + pub drift_max_percent: f64, + + /// Rebuild waveform peaks the recording flattened (`adeclip`). + #[serde(default)] + pub declip: bool, + + /// Remove clicks and crackle (`adeclick`). + #[serde(default)] + pub declick: bool, + + /// `adeclick`'s threshold: lower removes more (and risks transients). + #[serde(default = "default_declick_threshold")] + pub declick_threshold: f64, + + /// Notch out mains hum and its first two harmonics. + #[serde(default)] + pub hum_removal: bool, + + #[serde(default)] + pub hum_frequency: HumFrequency, + + /// High-pass out rumble (turntable, handling and wind noise). + #[serde(default)] + pub rumble_filter: bool, + + #[serde(default = "default_rumble_cutoff_hz")] + pub rumble_cutoff_hz: u32, + + /// Broadband noise reduction (`afftdn`): tape hiss, fan noise. + #[serde(default)] + pub denoise: bool, + + /// How much noise to remove, in dB. + #[serde(default = "default_denoise_strength")] + pub denoise_strength: f64, + + /// Speech enhancement with DeepFilterNet (an add-on). + #[serde(default)] + pub deep_filter: bool, + + /// DeepFilterNet's attenuation limit in dB: how far it may turn noise + /// down. 100 is no limit; lower keeps a little of the room. + #[serde(default = "default_deep_filter_attenuation")] + pub deep_filter_attenuation: u32, + + /// Normalise loudness to a target (EBU R128, two passes). + #[serde(default)] + pub loudness_normalize: bool, + + /// Integrated loudness target in LUFS. + #[serde(default = "default_loudness_target")] + pub loudness_target: f64, + + /// Keep peaks under a ceiling (`alimiter`). + #[serde(default)] + pub limiter: bool, + + /// The ceiling in dBFS. + #[serde(default = "default_limiter_ceiling_db")] + pub limiter_ceiling_db: f64, +} + +fn default_drift_max_percent() -> f64 { 0.5 } +fn default_declick_threshold() -> f64 { 2.0 } +fn default_rumble_cutoff_hz() -> u32 { 40 } +fn default_denoise_strength() -> f64 { 12.0 } +fn default_deep_filter_attenuation() -> u32 { 100 } +fn default_loudness_target() -> f64 { -16.0 } +fn default_limiter_ceiling_db() -> f64 { -1.0 } + +impl Default for AudioRestorationParameters { + fn default() -> Self { + Self { + enabled: false, + stream_index: 0, + channel_fix: ChannelFix::Off, + offset_ms: 0, + drift_correction: false, + drift_max_percent: default_drift_max_percent(), + declip: false, + declick: false, + declick_threshold: default_declick_threshold(), + hum_removal: false, + hum_frequency: HumFrequency::Auto, + rumble_filter: false, + rumble_cutoff_hz: default_rumble_cutoff_hz(), + denoise: false, + denoise_strength: default_denoise_strength(), + deep_filter: false, + deep_filter_attenuation: default_deep_filter_attenuation(), + loudness_normalize: false, + loudness_target: default_loudness_target(), + limiter: false, + limiter_ceiling_db: default_limiter_ceiling_db(), + } + } +} + +impl AudioRestorationParameters { + /// Whether the pass changes anything: on, with at least one step chosen. + /// A pass switched on with nothing in it leaves the audio exactly as + /// before — copied or converted by the encoder, not rendered. + pub fn has_effect(&self) -> bool { + self.enabled + && (self.channel_fix != ChannelFix::Off + || self.offset_ms != 0 + || self.drift_correction + || self.declip + || self.declick + || self.hum_removal + || self.rumble_filter + || self.denoise + || self.deep_filter + || self.loudness_normalize + || self.limiter) + } + + /// Whether the job needs the DeepFilterNet add-on. + pub fn uses_deep_filter(&self) -> bool { + self.enabled && self.deep_filter + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_names_match_the_app() { + let json = r#"{"enabled":true,"streamIndex":1,"channelFix":"leftOnly","offsetMs":-120, + "driftCorrection":true,"driftMaxPercent":0.3,"declip":true,"declick":true, + "declickThreshold":3,"humRemoval":true,"humFrequency":"60","rumbleFilter":true, + "rumbleCutoffHz":60,"denoise":true,"denoiseStrength":18,"deepFilter":true, + "deepFilterAttenuation":30,"loudnessNormalize":true,"loudnessTarget":-23, + "limiter":true,"limiterCeilingDb":-2}"#; + let p: AudioRestorationParameters = serde_json::from_str(json).unwrap(); + assert_eq!(p.stream_index, 1); + assert_eq!(p.channel_fix, ChannelFix::LeftOnly); + assert_eq!(p.offset_ms, -120); + assert_eq!(p.hum_frequency, HumFrequency::Hz60); + assert_eq!(p.deep_filter_attenuation, 30); + assert_eq!(p.loudness_target, -23.0); + let back = serde_json::to_value(&p).unwrap(); + assert_eq!(back["channelFix"], "leftOnly"); + assert_eq!(back["humFrequency"], "60"); + for fix in ["off", "auto", "leftOnly", "rightOnly", "mix"] { + let v: ChannelFix = serde_json::from_str(&format!("\"{fix}\"")).unwrap(); + assert_eq!(serde_json::to_value(v).unwrap(), fix); + } + } + + #[test] + fn an_empty_object_is_the_default_and_does_nothing() { + let p: AudioRestorationParameters = serde_json::from_str("{}").unwrap(); + assert_eq!(p, AudioRestorationParameters::default()); + assert!(!p.has_effect()); + // On, with nothing chosen, still does nothing. + let on = AudioRestorationParameters { enabled: true, ..Default::default() }; + assert!(!on.has_effect()); + assert!(AudioRestorationParameters { enabled: true, offset_ms: 10, ..Default::default() }.has_effect()); + assert!(!AudioRestorationParameters { enabled: false, denoise: true, ..Default::default() }.has_effect()); + } +} diff --git a/worker/src/models/mod.rs b/worker/src/models/mod.rs index 3300ac4d..fdc27008 100644 --- a/worker/src/models/mod.rs +++ b/worker/src/models/mod.rs @@ -25,6 +25,7 @@ mod descale_parameters; mod spotless_parameters; mod stabilize_parameters; mod sharpen_parameters; +mod audio_restoration_parameters; mod processing_pipeline; mod dvd_info; @@ -52,5 +53,6 @@ pub use descale_parameters::*; pub use stabilize_parameters::*; pub use spotless_parameters::*; pub use sharpen_parameters::*; +pub use audio_restoration_parameters::*; pub use processing_pipeline::*; pub use dvd_info::*; diff --git a/worker/src/models/processing_pipeline.rs b/worker/src/models/processing_pipeline.rs index 827f957a..4134b33b 100644 --- a/worker/src/models/processing_pipeline.rs +++ b/worker/src/models/processing_pipeline.rs @@ -8,7 +8,7 @@ use super::{ AntiAliasParameters, GeometryParameters, GrainParameters, StabilizeParameters, ChromaDenoiseParameters, ChromaFixParameters, ColorCorrectionParameters, CropResizeParameters, DebandParameters, DeblockParameters, DehaloParameters, DeinterlaceMethod, DeScratchParameters, - SpotLessParameters, SharpenParameters, NoiseReductionParameters, QTGMCParameters, FrameRateParameters, FrameRateMethod, NoiseReductionMethod, DeflickerParameters, EdgeRepairParameters, GhostRemovalParameters, DescaleParameters,}; + SpotLessParameters, SharpenParameters, NoiseReductionParameters, QTGMCParameters, FrameRateParameters, FrameRateMethod, NoiseReductionMethod, DeflickerParameters, EdgeRepairParameters, GhostRemovalParameters, DescaleParameters, AudioRestorationParameters,}; /// Minimum temporal context (frames on each side of the target) a windowed /// preview decodes, regardless of which filters are enabled. Motion-compensated @@ -313,6 +313,12 @@ pub struct ProcessingPipeline { /// Crop and resize pass parameters. #[serde(default)] pub crop_resize: CropResizeParameters, + + /// Audio restoration. Not a VapourSynth pass — it is rendered by ffmpeg + /// before the encode (`audio_restoration.rs`) — so it is not in + /// `PassType` or `enabled_passes`, which describe the picture. + #[serde(default)] + pub audio: AudioRestorationParameters, } impl Default for ProcessingPipeline { @@ -339,6 +345,7 @@ impl Default for ProcessingPipeline { color_correction: ColorCorrectionParameters::default(), chroma_fixes: ChromaFixParameters::default(), crop_resize: CropResizeParameters::default(), + audio: AudioRestorationParameters::default(), } } } @@ -369,6 +376,7 @@ impl ProcessingPipeline { color_correction: ColorCorrectionParameters { enabled: false, ..Default::default() }, chroma_fixes: ChromaFixParameters { enabled: false, ..Default::default() }, crop_resize: CropResizeParameters { enabled: false, ..Default::default() }, + audio: AudioRestorationParameters::default(), } } @@ -534,6 +542,10 @@ impl ProcessingPipeline { /// entry here when enabled, e.g. /// `AddonNeed { feature: "Face restoration", ids: vec!["gfpgan"] }` /// (a model add-on whose manifest `requires` `ai-runtime`). + /// + /// Picture passes only. Audio restoration's DeepFilterNet add-on is + /// checked by `audio_restoration` when the track is rendered: a preview + /// never touches the audio, so it must not be refused for it. pub fn required_addons(&self) -> Vec { Vec::new() } @@ -669,6 +681,29 @@ impl ProcessingPipeline { .fold(source_frames, |n, m| m.output_count(n)) } + /// How long the encoded picture is, in seconds, for `source_frames` at + /// `source_fps`: the output frame count over the output rate, both + /// composed from every pass's `FrameMap`. Double-rate deinterlacing and + /// IVTC change the count and the rate together, so the length stays the + /// source's; a frame-rate conversion's count is floored, so it can be up + /// to one output frame short. Audio restoration fits the sound to this. + pub fn output_duration_secs(&self, source_frames: u64, source_fps: f64) -> Option { + if !(source_fps > 0.0) { + return None; + } + let (mut num, mut den) = (1u64, 1u64); + for m in self.frame_maps() { + let (a, b) = m.rate_factor(); + num *= a; + den *= b; + let g = gcd(num, den); + num /= g; + den /= g; + } + let out_rate = source_fps * num as f64 / den as f64; + Some(self.output_count(source_frames) as f64 / out_rate) + } + /// Map a final output frame index back to the source frame span that /// produces it, composing every enabled pass's inverse in reverse order. pub fn invert(&self, output_frame: u64) -> SourceSpan { diff --git a/worker/src/pipeline_executor.rs b/worker/src/pipeline_executor.rs index 5f40beca..d9da9c0b 100644 --- a/worker/src/pipeline_executor.rs +++ b/worker/src/pipeline_executor.rs @@ -162,11 +162,12 @@ fn preview_window(target: i32, radius: i32) -> (i32, i32, i32) { (first, local, num_frames) } +use crate::audio_restoration::{self, RestoredTrack}; use crate::comment_metadata::{self, CommentFile}; use crate::gpu_plugin::GpuPlugin; use crate::hdr_metadata::HdrMetadata; use crate::dependency_locator::DependencyLocator; -use crate::models::{AspectDeclaration, AudioMode, ContainerFormat, DeinterlaceMethod, EncoderFamily, LogLevel, ProgressInfo, SubtitleOutput, VideoCodec, VideoJob}; +use crate::models::{AspectDeclaration, ContainerFormat, DeinterlaceMethod, EncoderFamily, LogLevel, ProgressInfo, SubtitleOutput, VideoCodec, VideoJob}; use crate::pixel_format; use crate::source_decode; use crate::progress_reporter::ProgressReporter; @@ -184,6 +185,9 @@ pub struct PipelineExecutor { /// which the encode and preview decoders take their window. `None` for a /// constant-rate source. timeline: Option, + /// The track audio restoration rendered for this job, mapped in place of + /// the source's stream (see `audio_restoration`). `None` otherwise. + restored_audio: Option, } /// Watches for the pipeline making no progress at all. @@ -247,6 +251,7 @@ impl PipelineExecutor { vspipe_process: None, ffmpeg_process: None, timeline: None, + restored_audio: None, }) } @@ -256,6 +261,12 @@ impl PipelineExecutor { self } + /// Use a restored audio track (see `self.restored_audio`). + pub fn with_restored_audio(mut self, track: Option) -> Self { + self.restored_audio = track; + self + } + /// Execute the deinterlacing pipeline. pub fn execute(&mut self, script_path: &Path, job: &VideoJob, on_cancel: F) -> Result<()> where @@ -872,14 +883,18 @@ impl PipelineExecutor { // Input 2: the output's comment, as an FFMETADATA file (issue #138) args.extend(comment_file.input_args()); + // Input 3: the restored audio track, when audio restoration ran. + args.extend(audio_restoration::encode_input_args(self.restored_audio.as_ref())); + // Progress output to a temp file (avoids Windows pipe buffering delay) args.extend(["-progress".to_string(), progress_file.to_string_lossy().to_string()]); // Map streams: video from input 0 (processed), audio and subtitles from input 1 (original) args.extend(["-map".to_string(), "0:v".to_string()]); // Video from Y4M pipe - if settings.audio_mode != AudioMode::None { - args.extend(["-map".to_string(), "1:a?".to_string()]); // Audio from original (? = optional) - } + // Audio from the original (every stream, `1:a?`), or — when audio + // restoration rendered a track — that track (input 3) first, then the + // source's other audio streams. One decision site: audio_restoration. + args.extend(audio_restoration::encode_map_args(settings, self.restored_audio.as_ref(), 3)); // Preserve existing subtitle streams from source (? = optional, no error if none) let subtitle_embed_pending = job.subtitle_settings.as_ref() @@ -1012,22 +1027,9 @@ impl PipelineExecutor { // and is then a genuine conversion from the source's real matrix. args.extend(job.color_metadata().to_ffmpeg_output_args()); - // Audio handling - match settings.audio_mode { - AudioMode::Passthrough => { - args.extend(["-c:a".to_string(), "copy".to_string()]); - } - AudioMode::Convert => { - args.extend(["-c:a".to_string(), settings.audio_codec.ffmpeg_name().to_string()]); - // Only add bitrate for lossy codecs - if !settings.audio_codec.is_lossless() { - args.extend(["-b:a".to_string(), format!("{}k", settings.audio_quality.bitrate())]); - } - } - AudioMode::None => { - args.push("-an".to_string()); - } - } + // Audio handling: copy / convert / none, and a restored track is + // always encoded (see audio_restoration::encode_codec_args). + args.extend(audio_restoration::encode_codec_args(settings, self.restored_audio.as_ref())); // Timestamp normalization for modes that change frame rate. // Fixes audio sync offset caused by mismatched initial PTS between the @@ -1559,7 +1561,7 @@ impl Drop for PipelineExecutor { #[cfg(test)] mod tests { use super::*; - use crate::models::{AudioCodec, AudioQuality, ChromaSubsampling, EncodingSettings, ProResQuantMat, QTGMCParameters, VideoCodec}; + use crate::models::{AudioCodec, AudioMode, AudioQuality, ChromaSubsampling, EncodingSettings, ProResQuantMat, QTGMCParameters, VideoCodec}; use uuid::Uuid; /// The two codes that have actually cost debugging time must be named in @@ -1857,6 +1859,26 @@ mod tests { ); } + /// The real builder takes every audio decision from audio_restoration, + /// with the restored track as input 3 (after the source and the metadata + /// file); the copy below cannot show that, having neither. + #[test] + fn test_real_ffmpeg_builder_delegates_audio_to_audio_restoration() { + let src = include_str!("pipeline_executor.rs").replace("\r\n", "\n"); + let start = src.find("fn build_ffmpeg_args(\n").expect("real builder"); + let body = &src[start..]; + let body = &body[..body.find("\n }\n").expect("end of builder")]; + let file = body.find("comment_file.input_args()").expect("metadata file input"); + let track = body + .find("audio_restoration::encode_input_args(self.restored_audio.as_ref())") + .expect("restored track input"); + assert!(file < track, "the restored track must be input 3, after the metadata file"); + assert!(body.contains("audio_restoration::encode_map_args(settings, self.restored_audio.as_ref(), 3)")); + assert!(body.contains("audio_restoration::encode_codec_args(settings, self.restored_audio.as_ref())")); + assert!(!body.contains("\"1:a?\""), "audio mapping belongs to audio_restoration"); + assert_eq!(body.matches("\"-af\"").count(), 0, "the encode never filters audio itself"); + } + /// Helper to build FFmpeg args without requiring a full PipelineExecutor. /// This mirrors the logic in PipelineExecutor::build_ffmpeg_args for testing. fn build_ffmpeg_args_for_test(job: &VideoJob) -> Vec { @@ -1877,9 +1899,7 @@ mod tests { // Map streams: video from input 0 (processed), audio from input 1 (original) if not disabled args.extend(["-map".to_string(), "0:v".to_string()]); - if settings.audio_mode != AudioMode::None { - args.extend(["-map".to_string(), "1:a?".to_string()]); - } + args.extend(audio_restoration::encode_map_args(settings, None, 2)); // Video codec args.extend(["-c:v".to_string(), settings.codec.ffmpeg_codec().to_string()]); @@ -1913,22 +1933,8 @@ mod tests { // SAR block was missed that way and is still absent below. args.extend(job.color_metadata().to_ffmpeg_output_args()); - // Audio handling - match settings.audio_mode { - AudioMode::Passthrough => { - args.extend(["-c:a".to_string(), "copy".to_string()]); - } - AudioMode::Convert => { - args.extend(["-c:a".to_string(), settings.audio_codec.ffmpeg_name().to_string()]); - // Only add bitrate for lossy codecs - if !settings.audio_codec.is_lossless() { - args.extend(["-b:a".to_string(), format!("{}k", settings.audio_quality.bitrate())]); - } - } - AudioMode::None => { - args.push("-an".to_string()); - } - } + // Audio handling (mirrors build_ffmpeg_args; no restored track here). + args.extend(audio_restoration::encode_codec_args(settings, None)); // Custom arguments if !settings.custom_ffmpeg_args.is_empty() { diff --git a/worker/src/progress_reporter.rs b/worker/src/progress_reporter.rs index b550ff29..1ac78939 100644 --- a/worker/src/progress_reporter.rs +++ b/worker/src/progress_reporter.rs @@ -13,6 +13,8 @@ pub struct ProgressReporter { struct ProgressReporterInner { output_lock: Mutex<()>, + /// Write to stderr instead: for a mode whose stdout is one JSON answer. + to_stderr: bool, } impl ProgressReporter { @@ -21,6 +23,18 @@ impl ProgressReporter { Self { inner: std::sync::Arc::new(ProgressReporterInner { output_lock: Mutex::new(()), + to_stderr: false, + }), + } + } + + /// A reporter that writes its messages to stderr, leaving stdout to a + /// mode that prints one JSON answer there (`--audio-plan`). + pub fn new_to_stderr() -> Self { + Self { + inner: std::sync::Arc::new(ProgressReporterInner { + output_lock: Mutex::new(()), + to_stderr: true, }), } } @@ -60,6 +74,9 @@ impl ProgressReporter { let _lock = self.inner.output_lock.lock().unwrap(); match serde_json::to_string(message) { + Ok(json) if self.inner.to_stderr => { + eprintln!("{}", json); + } Ok(json) => { let stdout = io::stdout(); let mut handle = stdout.lock(); diff --git a/worker/tests/filter_integration_test.rs b/worker/tests/filter_integration_test.rs index 89c94cb9..dace4fa6 100644 --- a/worker/tests/filter_integration_test.rs +++ b/worker/tests/filter_integration_test.rs @@ -6879,3 +6879,45 @@ fn test_184_flowfps_length_is_set_by_the_script() { assert!(call < trim && call < pad, "{name}: length set before FlowFPS"); } } + +/// Audio restoration is rendered by ffmpeg before the encode, never by the +/// VapourSynth script: every step switched on — DeepFilterNet included, which +/// is not installed here — leaves both scripts byte-for-byte as they are with +/// the pass off. (A preview must never be refused for an audio add-on.) +#[test] +fn test_185_audio_restoration_never_touches_the_scripts() { + create_output_dir(); + let generator = ScriptGenerator::new() + .unwrap() + .with_addons(std::env::temp_dir().join("vb-no-addons-here"), "macos-arm64"); + let mut job = create_base_job("test_185_audio_restoration"); + job.processing_pipeline = Some(ProcessingPipeline::default()); + let (encode_off, preview_off) = both_scripts_with(&generator, &job).unwrap(); + + job.processing_pipeline = Some(ProcessingPipeline { + audio: AudioRestorationParameters { + enabled: true, + channel_fix: ChannelFix::Auto, + offset_ms: -250, + drift_correction: true, + declip: true, + declick: true, + hum_removal: true, + rumble_filter: true, + denoise: true, + deep_filter: true, + loudness_normalize: true, + limiter: true, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + let (encode_on, preview_on) = both_scripts_with(&generator, &job).unwrap(); + assert_eq!(encode_on, encode_off, "the encode script must not change"); + assert_eq!(preview_on, preview_off, "the preview script must not change"); + for script in [&encode_on, &preview_on] { + for word in ["afftdn", "loudnorm", "deep-filter", "atempo"] { + assert!(!script.contains(word), "{word} reached the script"); + } + } +} From c49a5f541d0e41786206546d29f18e259e609de9 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 8 Nov 2026 18:20:00 +1100 Subject: [PATCH 10/61] feat(app): Audio Restoration pass and the DeepFilterNet add-on The pass sits with Subtitles in an 'Audio & Subtitles' stage (it runs first: the transcript is of the restored sound), schema-driven like the others. Manifest add-on files can now be marked executable: the installer sets the bit and, on macOS, signs an unsigned program. DeepFilterNet is credited in NOTICES, About and the README. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- .../filters/core/audio_restoration.json | 343 ++++++++++++++++++ app/assets/filters/manifest.json | 3 +- .../models/audio_restoration_parameters.dart | 218 +++++++++++ app/lib/models/parameter_converter.dart | 82 +++++ app/lib/models/pass_relevance.dart | 2 + app/lib/models/processing_pipeline.dart | 26 +- app/lib/services/manifest_addon.dart | 32 ++ app/lib/viewmodels/main_viewmodel.dart | 20 +- app/lib/views/about_dialog.dart | 6 + app/lib/views/pass_list/pass_list_item.dart | 2 + app/lib/views/pass_list/pass_list_panel.dart | 10 +- .../pass_settings/pass_settings_inline.dart | 14 + app/test/pass_list_stages_test.dart | 1 + app/test/pass_relevance_test.dart | 1 + app/test/preset_visibility_test.dart | 1 + licenses/NOTICES.txt | 12 + 17 files changed, 768 insertions(+), 9 deletions(-) create mode 100644 app/assets/filters/core/audio_restoration.json create mode 100644 app/lib/models/audio_restoration_parameters.dart diff --git a/README.md b/README.md index e08e9584..db5518a1 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,8 @@ Most sources need only a few of these, and the presets pick them for you. Filter | 19 | **Sharpen** | Soft pictures, sharpened after the resize so the result isn't softened again | | 20 | **Film Grain** | Grain added back after denoising, so the picture doesn't look plastic | | 21 | **Frame Rate** | PAL ↔ NTSC and other rate changes, or any custom rate, by motion interpolation, RIFE, or frame repetition | -| 22 | **Subtitles** | Speech-to-text with Whisper AI | +| 22 | **Audio Restoration** | A dead channel, hum, rumble, clicks, clipping, hiss, sound out of sync or drifting, uneven loudness; speech enhancement with DeepFilterNet | +| 23 | **Subtitles** | Speech-to-text with Whisper AI, from the restored sound | The order is designed so each step works on the right picture: damage and noise are removed first, the picture is framed and resized next, and only then sharpened and given grain. The frame rate is converted last, so every other filter works on real frames. Borders are added after everything else, so nothing touches them. @@ -277,6 +278,7 @@ VapourBox is a front end to other people's work. Thanks in particular to: - **AnimeJaNai** by the-database — anime upscaling models, an optional download (CC BY-NC-SA 4.0, non-commercial use only) - **whisper.cpp** by the ggml authors and OpenAI's **Whisper** models — speech recognition for subtitles, an optional download - **ONNX Runtime** by Microsoft Corporation, with **NumPy** by the NumPy Developers and, on Windows, Microsoft's **DirectML** — the AI runtime add-on that runs the optional AI models, an optional download +- **DeepFilterNet** by Hendrik Schröter — speech enhancement for Audio Restoration, an optional download (MIT or Apache-2.0) - **libdvdread** by VideoLAN — DVD reading - **FFmpeg** — encoding and decoding - **Hybrid** by Selur — the inspiration for this project diff --git a/app/assets/filters/core/audio_restoration.json b/app/assets/filters/core/audio_restoration.json new file mode 100644 index 00000000..9390c5df --- /dev/null +++ b/app/assets/filters/core/audio_restoration.json @@ -0,0 +1,343 @@ +{ + "$schema": "https://vapourbox.app/schemas/filter-v1.json", + "id": "audio_restoration", + "version": "1.0.0", + "sinceAppVersion": "1.7.0", + "name": "Audio Restoration", + "description": "Repair, clean and re-sync the soundtrack. Applied during the encode — the preview shows the picture only.", + "longDescription": "Fixes the faults old recordings bring with their sound: a capture with one dead channel, mains hum, rumble, clicks, clipped peaks, tape hiss, speech buried in noise, sound that is out of step with the picture or drifts out of step over a long tape, and loudness that is far too quiet or uneven. Switch on only the steps the recording needs; with none ticked the sound is left exactly as it was.\n\nThe sound is processed once, before the video is encoded, to exactly the length of the picture (after any trim, deinterlacing or frame-rate change). Subtitles are transcribed from the restored sound, so they follow any sync offset or drift correction. The preview is picture only — listen to a short trimmed export to judge the result.\n\nOnly one audio stream is restored (the first, unless chosen otherwise); any others are kept as they are. When Output → Audio is set to copy, the restored stream is encoded anyway, because it is new audio: FLAC in MKV, AAC in MP4 and MOV, PCM in AVI.", + "category": "enhancement", + "icon": "graphic_eq", + "order": 95, + "methods": [ + { + "id": "restore", + "name": "Restore", + "description": "FFmpeg's audio filters, and DeepFilterNet for speech", + "function": "custom", + "parameters": [ + "channelFix", + "offsetMs", + "driftCorrection", + "driftMaxPercent", + "declip", + "declick", + "declickThreshold", + "rumbleFilter", + "rumbleCutoffHz", + "humRemoval", + "humFrequency", + "denoise", + "denoiseStrength", + "deepFilter", + "deepFilterAttenuation", + "loudnessNormalize", + "loudnessTarget", + "limiter", + "limiterCeilingDb", + "streamIndex" + ] + } + ], + "implementation": [ + { + "function": "pan", + "role": "channel fix: copy the live channel over a dead one, or mix the two", + "activeWhen": { "channelFix": ["auto", "leftOnly", "rightOnly", "mix"] } + }, + { "function": "adeclip", "role": "rebuild clipped peaks", "activeWhen": { "declip": true } }, + { "function": "adeclick", "role": "remove clicks and crackle", "activeWhen": { "declick": true } }, + { "function": "highpass", "role": "remove rumble", "activeWhen": { "rumbleFilter": true } }, + { + "function": "bandreject", + "role": "notch out mains hum and its first two harmonics", + "activeWhen": { "humRemoval": true } + }, + { "function": "afftdn", "role": "broadband noise reduction", "activeWhen": { "denoise": true } }, + { "function": "atempo", "role": "drift correction", "activeWhen": { "driftCorrection": true } }, + { "function": "-ss / adelay", "role": "where the sound starts: the trim and the sync offset" }, + { + "function": "deep-filter (DeepFilterNet)", + "role": "speech enhancement", + "activeWhen": { "deepFilter": true } + }, + { + "function": "loudnorm", + "role": "loudness normalisation, measured first, then applied as one gain", + "activeWhen": { "loudnessNormalize": true } + }, + { "function": "alimiter", "role": "peak limiter", "activeWhen": { "limiter": true } }, + { "function": "apad / atrim", "role": "the sound cut or padded to the picture's length" } + ], + "parameters": { + "enabled": { + "type": "boolean", + "default": false, + "ui": { "hidden": true } + }, + "method": { + "type": "enum", + "default": "restore", + "options": ["restore"], + "ui": { "hidden": true } + }, + "channelFix": { + "type": "enum", + "default": "off", + "options": ["off", "auto", "leftOnly", "rightOnly", "mix"], + "ui": { + "label": "Channels", + "description": "For a stereo capture where one channel is silent or broken. Automatic measures both and copies the live one over a silent one; otherwise it leaves them alone.", + "widget": "dropdown", + "optionLabels": { + "off": "Leave as is", + "auto": "Automatic (fix a silent channel)", + "leftOnly": "Left channel only", + "rightOnly": "Right channel only", + "mix": "Mix both channels" + } + } + }, + "offsetMs": { + "type": "integer", + "default": 0, + "min": -10000, + "max": 10000, + "step": 10, + "ui": { + "label": "Sync offset (ms)", + "description": "Moves the sound against the picture. Positive delays the sound (use it when lips move before you hear them); negative plays it earlier.", + "widget": "number" + } + }, + "driftCorrection": { + "type": "boolean", + "default": false, + "ui": { + "label": "Correct drift", + "description": "For sound that starts in sync and slowly drifts out over a long recording: stretches or squeezes it to the picture's length. Only applied when the two differ by more than 0.05 s and by no more than the limit below.", + "widget": "checkbox" + } + }, + "driftMaxPercent": { + "type": "number", + "default": 0.5, + "min": 0.05, + "max": 2.0, + "step": 0.05, + "ui": { + "label": "Largest drift corrected (%)", + "description": "A bigger difference is not clock drift — usually a missing stretch of audio or the wrong track — and is left alone rather than disguised.", + "widget": "slider", + "precision": 2, + "visibleWhen": { "driftCorrection": true } + } + }, + "declip": { + "type": "boolean", + "default": false, + "ui": { + "label": "Repair clipping", + "description": "Rebuilds peaks that were flattened by recording too loud.", + "widget": "checkbox" + } + }, + "declick": { + "type": "boolean", + "default": false, + "ui": { + "label": "Remove clicks", + "description": "Clicks, crackle and dropouts, as from vinyl or a damaged tape.", + "widget": "checkbox" + } + }, + "declickThreshold": { + "type": "number", + "default": 2.0, + "min": 1.0, + "max": 10.0, + "step": 0.5, + "ui": { + "label": "Click threshold", + "description": "Lower removes more, but can soften sharp sounds like drum hits.", + "widget": "slider", + "precision": 1, + "visibleWhen": { "declick": true } + } + }, + "rumbleFilter": { + "type": "boolean", + "default": false, + "ui": { + "label": "Remove rumble", + "description": "Low-frequency rumble from handling, wind or a turntable motor.", + "widget": "checkbox" + } + }, + "rumbleCutoffHz": { + "type": "integer", + "default": 40, + "min": 20, + "max": 120, + "step": 5, + "ui": { + "label": "Rumble cutoff (Hz)", + "description": "Everything below this is turned down.", + "widget": "slider", + "visibleWhen": { "rumbleFilter": true } + } + }, + "humRemoval": { + "type": "boolean", + "default": false, + "ui": { + "label": "Remove mains hum", + "description": "The steady 50 or 60 Hz hum of a ground loop, with its first two harmonics.", + "widget": "checkbox" + } + }, + "humFrequency": { + "type": "enum", + "default": "auto", + "options": ["auto", "50", "60"], + "ui": { + "label": "Hum frequency", + "description": "Automatic measures the sound and removes whichever hum is there — nothing, if neither is. 50 Hz is the mains in Europe, Africa, most of Asia and Australia; 60 Hz in the Americas.", + "widget": "dropdown", + "optionLabels": { "auto": "Automatic", "50": "50 Hz", "60": "60 Hz" }, + "visibleWhen": { "humRemoval": true } + } + }, + "denoise": { + "type": "boolean", + "default": false, + "ui": { + "label": "Reduce hiss and noise", + "description": "Broadband noise such as tape hiss or fan noise.", + "widget": "checkbox" + } + }, + "denoiseStrength": { + "type": "number", + "default": 12.0, + "min": 3.0, + "max": 40.0, + "step": 1.0, + "ui": { + "label": "Noise reduction (dB)", + "description": "How far the noise is turned down. High values make speech sound watery.", + "widget": "slider", + "precision": 0, + "visibleWhen": { "denoise": true } + } + }, + "deepFilter": { + "type": "boolean", + "default": false, + "ui": { + "label": "Speech enhancement (DeepFilterNet)", + "description": "A neural network that separates speech from the noise behind it — very effective on dialogue, not meant for music. A separate download (about 30 MB).", + "widget": "checkbox" + } + }, + "deepFilterAttenuation": { + "type": "integer", + "default": 100, + "min": 6, + "max": 100, + "step": 1, + "ui": { + "label": "Noise removed (dB)", + "description": "How far DeepFilterNet may turn the noise down. 100 removes all it can; 15–30 keeps a little of the room, which often sounds more natural.", + "widget": "slider", + "visibleWhen": { "deepFilter": true } + } + }, + "loudnessNormalize": { + "type": "boolean", + "default": false, + "ui": { + "label": "Normalise loudness", + "description": "Measures the whole soundtrack and sets one gain so it plays at a standard loudness (EBU R128), peaks kept under −1.5 dBTP.", + "widget": "checkbox" + } + }, + "loudnessTarget": { + "type": "number", + "default": -16.0, + "min": -31.0, + "max": -10.0, + "step": 1.0, + "ui": { + "label": "Loudness target (LUFS)", + "description": "−16 suits online video and phones; −23 is the broadcast standard (EBU R128); −24 for US broadcast (ATSC A/85).", + "widget": "slider", + "precision": 0, + "visibleWhen": { "loudnessNormalize": true } + } + }, + "limiter": { + "type": "boolean", + "default": false, + "ui": { + "label": "Limit peaks", + "description": "Catches any peak above the ceiling, after everything else.", + "widget": "checkbox" + } + }, + "limiterCeilingDb": { + "type": "number", + "default": -1.0, + "min": -12.0, + "max": 0.0, + "step": 0.5, + "ui": { + "label": "Ceiling (dBFS)", + "widget": "slider", + "precision": 1, + "visibleWhen": { "limiter": true } + } + }, + "streamIndex": { + "type": "integer", + "default": 0, + "min": 0, + "max": 15, + "step": 1, + "ui": { + "label": "Audio stream to restore", + "description": "0 is the first audio stream. The others are kept as they are, after the restored one.", + "widget": "number" + } + } + }, + "ui": { + "sections": [ + { + "title": "Channels and Sync", + "parameters": ["channelFix", "offsetMs", "driftCorrection", "driftMaxPercent"], + "expanded": true + }, + { + "title": "Repair", + "parameters": ["declip", "declick", "declickThreshold", "rumbleFilter", "rumbleCutoffHz", "humRemoval", "humFrequency"], + "expanded": true + }, + { + "title": "Noise", + "parameters": ["denoise", "denoiseStrength", "deepFilter", "deepFilterAttenuation"], + "expanded": true + }, + { + "title": "Loudness", + "parameters": ["loudnessNormalize", "loudnessTarget", "limiter", "limiterCeilingDb"], + "expanded": true + }, + { + "title": "Source Stream", + "parameters": ["streamIndex"], + "advancedOnly": true + } + ] + } +} diff --git a/app/assets/filters/manifest.json b/app/assets/filters/manifest.json index 00ac3319..b7031c2e 100644 --- a/app/assets/filters/manifest.json +++ b/app/assets/filters/manifest.json @@ -20,5 +20,6 @@ "deflicker.json", "edge_repair.json", "ghost_removal.json", - "descale.json" + "descale.json", + "audio_restoration.json" ] diff --git a/app/lib/models/audio_restoration_parameters.dart b/app/lib/models/audio_restoration_parameters.dart new file mode 100644 index 00000000..5f917753 --- /dev/null +++ b/app/lib/models/audio_restoration_parameters.dart @@ -0,0 +1,218 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'audio_restoration_parameters.g.dart'; + +/// The add-on speech enhancement runs (`assets/addons/deepfilternet.json`). +const deepFilterNetAddonId = 'deepfilternet'; + +/// Whether an edit to [filterId]'s values switches speech enhancement on — +/// the moment the DeepFilterNet add-on is offered. +bool deepFilterChosen( + String filterId, + Map oldValues, + Map newValues, +) => + filterId == 'audio_restoration' && + newValues['deepFilter'] == true && + oldValues['deepFilter'] != true; + +/// What to do about the two channels of a stereo source. +enum ChannelFix { + @JsonValue('off') + off('Leave as is'), + @JsonValue('auto') + auto('Automatic (fix a silent channel)'), + @JsonValue('leftOnly') + leftOnly('Left channel only'), + @JsonValue('rightOnly') + rightOnly('Right channel only'), + @JsonValue('mix') + mix('Mix both channels'); + + const ChannelFix(this.displayName); + final String displayName; +} + +/// Which mains frequency to notch out. +enum HumFrequency { + @JsonValue('auto') + auto('Automatic'), + @JsonValue('50') + hz50('50 Hz'), + @JsonValue('60') + hz60('60 Hz'); + + const HumFrequency(this.displayName); + final String displayName; +} + +/// Audio restoration: the one pass that works on the soundtrack. +/// +/// Rendered by the worker before the encode (`worker/src/audio_restoration.rs`); +/// nothing here reaches the VapourSynth script, and the preview is picture +/// only. Every step is off by default, so switching the pass on changes +/// nothing until a step is chosen. +@JsonSerializable() +class AudioRestorationParameters { + final bool enabled; + + /// Which of the source's audio streams to restore (0 = the first). + final int streamIndex; + + final ChannelFix channelFix; + + /// Positive delays the sound, negative advances it (milliseconds). + final int offsetMs; + + final bool driftCorrection; + + /// The largest drift corrected, as a percentage of the length. + final double driftMaxPercent; + + final bool declip; + final bool declick; + + /// adeclick threshold: lower removes more. + final double declickThreshold; + + final bool humRemoval; + final HumFrequency humFrequency; + + final bool rumbleFilter; + final int rumbleCutoffHz; + + final bool denoise; + + /// afftdn noise reduction in dB. + final double denoiseStrength; + + /// DeepFilterNet speech enhancement (an add-on). + final bool deepFilter; + + /// DeepFilterNet's attenuation limit in dB (100 = no limit). + final int deepFilterAttenuation; + + final bool loudnessNormalize; + + /// Integrated loudness target, LUFS. + final double loudnessTarget; + + final bool limiter; + + /// Limiter ceiling, dBFS. + final double limiterCeilingDb; + + const AudioRestorationParameters({ + this.enabled = false, + this.streamIndex = 0, + this.channelFix = ChannelFix.off, + this.offsetMs = 0, + this.driftCorrection = false, + this.driftMaxPercent = 0.5, + this.declip = false, + this.declick = false, + this.declickThreshold = 2.0, + this.humRemoval = false, + this.humFrequency = HumFrequency.auto, + this.rumbleFilter = false, + this.rumbleCutoffHz = 40, + this.denoise = false, + this.denoiseStrength = 12.0, + this.deepFilter = false, + this.deepFilterAttenuation = 100, + this.loudnessNormalize = false, + this.loudnessTarget = -16.0, + this.limiter = false, + this.limiterCeilingDb = -1.0, + }); + + AudioRestorationParameters copyWith({ + bool? enabled, + int? streamIndex, + ChannelFix? channelFix, + int? offsetMs, + bool? driftCorrection, + double? driftMaxPercent, + bool? declip, + bool? declick, + double? declickThreshold, + bool? humRemoval, + HumFrequency? humFrequency, + bool? rumbleFilter, + int? rumbleCutoffHz, + bool? denoise, + double? denoiseStrength, + bool? deepFilter, + int? deepFilterAttenuation, + bool? loudnessNormalize, + double? loudnessTarget, + bool? limiter, + double? limiterCeilingDb, + }) => + AudioRestorationParameters( + enabled: enabled ?? this.enabled, + streamIndex: streamIndex ?? this.streamIndex, + channelFix: channelFix ?? this.channelFix, + offsetMs: offsetMs ?? this.offsetMs, + driftCorrection: driftCorrection ?? this.driftCorrection, + driftMaxPercent: driftMaxPercent ?? this.driftMaxPercent, + declip: declip ?? this.declip, + declick: declick ?? this.declick, + declickThreshold: declickThreshold ?? this.declickThreshold, + humRemoval: humRemoval ?? this.humRemoval, + humFrequency: humFrequency ?? this.humFrequency, + rumbleFilter: rumbleFilter ?? this.rumbleFilter, + rumbleCutoffHz: rumbleCutoffHz ?? this.rumbleCutoffHz, + denoise: denoise ?? this.denoise, + denoiseStrength: denoiseStrength ?? this.denoiseStrength, + deepFilter: deepFilter ?? this.deepFilter, + deepFilterAttenuation: + deepFilterAttenuation ?? this.deepFilterAttenuation, + loudnessNormalize: loudnessNormalize ?? this.loudnessNormalize, + loudnessTarget: loudnessTarget ?? this.loudnessTarget, + limiter: limiter ?? this.limiter, + limiterCeilingDb: limiterCeilingDb ?? this.limiterCeilingDb, + ); + + /// On, with at least one step chosen (twin of the worker's `has_effect`). + bool get hasEffect => + enabled && + (channelFix != ChannelFix.off || + offsetMs != 0 || + driftCorrection || + declip || + declick || + humRemoval || + rumbleFilter || + denoise || + deepFilter || + loudnessNormalize || + limiter); + + /// The steps switched on, in the order they run, for the pass row. + List get activeSteps => [ + if (channelFix != ChannelFix.off) 'Channels', + if (declip) 'Declip', + if (declick) 'Declick', + if (rumbleFilter) 'Rumble', + if (humRemoval) 'Hum', + if (denoise) 'Denoise', + if (offsetMs != 0) 'Sync ${offsetMs > 0 ? '+' : ''}$offsetMs ms', + if (driftCorrection) 'Drift', + if (deepFilter) 'DeepFilterNet', + if (loudnessNormalize) 'Loudness', + if (limiter) 'Limiter', + ]; + + String get summary { + if (!enabled) return 'Off'; + final steps = activeSteps; + if (steps.isEmpty) return 'Nothing selected'; + if (steps.length <= 3) return steps.join(', '); + return '${steps.take(2).join(', ')} +${steps.length - 2}'; + } + + factory AudioRestorationParameters.fromJson(Map json) => + _$AudioRestorationParametersFromJson(json); + Map toJson() => _$AudioRestorationParametersToJson(this); +} diff --git a/app/lib/models/parameter_converter.dart b/app/lib/models/parameter_converter.dart index 936375d1..bc6a4714 100644 --- a/app/lib/models/parameter_converter.dart +++ b/app/lib/models/parameter_converter.dart @@ -1,6 +1,7 @@ import 'chroma_denoise_parameters.dart'; import 'anti_alias_parameters.dart'; import 'geometry_parameters.dart'; +import 'audio_restoration_parameters.dart'; import 'deflicker_parameters.dart'; import 'descale_parameters.dart'; import 'edge_repair_parameters.dart'; @@ -972,6 +973,83 @@ class ParameterConverter { ); } + /// The schema's option values for the audio restoration enums (the same + /// strings the worker reads). + static const channelFixValues = { + ChannelFix.off: 'off', + ChannelFix.auto: 'auto', + ChannelFix.leftOnly: 'leftOnly', + ChannelFix.rightOnly: 'rightOnly', + ChannelFix.mix: 'mix', + }; + static const humFrequencyValues = { + HumFrequency.auto: 'auto', + HumFrequency.hz50: '50', + HumFrequency.hz60: '60', + }; + + /// Convert audio restoration parameters to dynamic format. + static DynamicParameters fromAudioRestoration(AudioRestorationParameters params) { + return DynamicParameters( + filterId: 'audio_restoration', + enabled: params.enabled, + values: { + 'streamIndex': params.streamIndex, + 'channelFix': channelFixValues[params.channelFix], + 'offsetMs': params.offsetMs, + 'driftCorrection': params.driftCorrection, + 'driftMaxPercent': params.driftMaxPercent, + 'declip': params.declip, + 'declick': params.declick, + 'declickThreshold': params.declickThreshold, + 'humRemoval': params.humRemoval, + 'humFrequency': humFrequencyValues[params.humFrequency], + 'rumbleFilter': params.rumbleFilter, + 'rumbleCutoffHz': params.rumbleCutoffHz, + 'denoise': params.denoise, + 'denoiseStrength': params.denoiseStrength, + 'deepFilter': params.deepFilter, + 'deepFilterAttenuation': params.deepFilterAttenuation, + 'loudnessNormalize': params.loudnessNormalize, + 'loudnessTarget': params.loudnessTarget, + 'limiter': params.limiter, + 'limiterCeilingDb': params.limiterCeilingDb, + }, + ); + } + + /// Convert dynamic parameters to audio restoration parameters. + static AudioRestorationParameters toAudioRestoration(DynamicParameters params) { + final v = params.values; + const d = AudioRestorationParameters(); + T pick(Map table, Object? value, T fallback) => table.entries + .firstWhere((e) => e.value == value, orElse: () => MapEntry(fallback, '')) + .key; + return AudioRestorationParameters( + enabled: params.enabled, + streamIndex: _asInt(v['streamIndex']) ?? d.streamIndex, + channelFix: pick(channelFixValues, v['channelFix'], d.channelFix), + offsetMs: _asInt(v['offsetMs']) ?? d.offsetMs, + driftCorrection: v['driftCorrection'] as bool? ?? d.driftCorrection, + driftMaxPercent: (v['driftMaxPercent'] as num?)?.toDouble() ?? d.driftMaxPercent, + declip: v['declip'] as bool? ?? d.declip, + declick: v['declick'] as bool? ?? d.declick, + declickThreshold: (v['declickThreshold'] as num?)?.toDouble() ?? d.declickThreshold, + humRemoval: v['humRemoval'] as bool? ?? d.humRemoval, + humFrequency: pick(humFrequencyValues, v['humFrequency'], d.humFrequency), + rumbleFilter: v['rumbleFilter'] as bool? ?? d.rumbleFilter, + rumbleCutoffHz: _asInt(v['rumbleCutoffHz']) ?? d.rumbleCutoffHz, + denoise: v['denoise'] as bool? ?? d.denoise, + denoiseStrength: (v['denoiseStrength'] as num?)?.toDouble() ?? d.denoiseStrength, + deepFilter: v['deepFilter'] as bool? ?? d.deepFilter, + deepFilterAttenuation: _asInt(v['deepFilterAttenuation']) ?? d.deepFilterAttenuation, + loudnessNormalize: v['loudnessNormalize'] as bool? ?? d.loudnessNormalize, + loudnessTarget: (v['loudnessTarget'] as num?)?.toDouble() ?? d.loudnessTarget, + limiter: v['limiter'] as bool? ?? d.limiter, + limiterCeilingDb: (v['limiterCeilingDb'] as num?)?.toDouble() ?? d.limiterCeilingDb, + ); + } + /// Convert a full processing pipeline to a dynamic pipeline. static DynamicPipeline fromPipeline(ProcessingPipeline pipeline) { return DynamicPipeline( @@ -997,6 +1075,7 @@ class ParameterConverter { 'color_correction': fromColorCorrection(pipeline.colorCorrection), 'chroma_fixes': fromChromaFixes(pipeline.chromaFixes), 'crop_resize': fromCropResize(pipeline.cropResize), + 'audio_restoration': fromAudioRestoration(pipeline.audio), 'subtitles': fromSubtitles(pipeline.subtitles), }, ); @@ -1656,6 +1735,9 @@ class ParameterConverter { cropResize: dynamic.get('crop_resize') != null ? toCropResize(dynamic.get('crop_resize')!) : const CropResizeParameters(), + audio: dynamic.get('audio_restoration') != null + ? toAudioRestoration(dynamic.get('audio_restoration')!) + : const AudioRestorationParameters(), subtitles: dynamic.get('subtitles') != null ? toSubtitles(dynamic.get('subtitles')!) : const SubtitleParameters(), diff --git a/app/lib/models/pass_relevance.dart b/app/lib/models/pass_relevance.dart index 33e4b380..8d436b40 100644 --- a/app/lib/models/pass_relevance.dart +++ b/app/lib/models/pass_relevance.dart @@ -135,6 +135,8 @@ PassRelevanceResult relevanceFor(PassType pass, VideoInfo? info) { case PassType.sharpen: case PassType.colorCorrection: case PassType.subtitles: + // A dead channel, hum or drift is not in the metadata either. + case PassType.audioRestoration: // Anti-aliasing and stabilisation are tempting to tie to detection — // jaggies follow deinterlacing, shake follows handheld capture — but // neither is actually visible in the metadata, only plausible from it. diff --git a/app/lib/models/processing_pipeline.dart b/app/lib/models/processing_pipeline.dart index 3ea43f04..e00d7667 100644 --- a/app/lib/models/processing_pipeline.dart +++ b/app/lib/models/processing_pipeline.dart @@ -1,6 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'anti_alias_parameters.dart'; +import 'audio_restoration_parameters.dart'; import 'geometry_parameters.dart'; import 'deflicker_parameters.dart'; import 'descale_parameters.dart'; @@ -50,6 +51,7 @@ enum PassType { colorCorrection, chromaFixes, cropResize, + audioRestoration, subtitles, } @@ -99,6 +101,8 @@ extension PassTypeExtension on PassType { return 'Chroma Fixes'; case PassType.cropResize: return 'Crop / Resize'; + case PassType.audioRestoration: + return 'Audio Restoration'; case PassType.subtitles: return 'Subtitles'; } @@ -148,6 +152,8 @@ extension PassTypeExtension on PassType { return 'Fix chroma bleeding and crawl artifacts'; case PassType.cropResize: return 'Crop borders and resize output'; + case PassType.audioRestoration: + return 'Repair, clean and re-sync the soundtrack'; case PassType.subtitles: return 'Generate subtitles using Whisper AI'; } @@ -203,6 +209,10 @@ class ProcessingPipeline { /// Crop and resize pass parameters. final CropResizeParameters cropResize; + /// Audio restoration (rendered by ffmpeg before the encode; not a + /// VapourSynth pass, so not in [enabledPasses]). + final AudioRestorationParameters audio; + /// Subtitle generation pass parameters (Whisper AI). final SubtitleParameters subtitles; @@ -228,6 +238,7 @@ class ProcessingPipeline { this.colorCorrection = const ColorCorrectionParameters(), this.chromaFixes = const ChromaFixParameters(), this.cropResize = const CropResizeParameters(), + this.audio = const AudioRestorationParameters(), this.subtitles = const SubtitleParameters(), }); @@ -256,6 +267,7 @@ class ProcessingPipeline { colorCorrection: const ColorCorrectionParameters(enabled: false), chromaFixes: const ChromaFixParameters(enabled: false), cropResize: const CropResizeParameters(enabled: false), + audio: const AudioRestorationParameters(enabled: false), subtitles: const SubtitleParameters(enabled: false), ); } @@ -362,7 +374,7 @@ class ProcessingPipeline { return passes; } - /// Get count of enabled passes (includes subtitles). + /// Get count of enabled passes (includes audio restoration and subtitles). int get enabledPassCount { var count = 0; if (deinterlace.enabled) count++; @@ -386,11 +398,13 @@ class ProcessingPipeline { if (colorCorrection.enabled) count++; if (chromaFixes.enabled) count++; if (cropResize.enabled) count++; + if (audio.enabled) count++; if (subtitles.enabled) count++; return count; } - /// Get count of enabled video processing passes (excludes subtitles). + /// Get count of enabled video processing passes (excludes audio + /// restoration and subtitles). int get videoPassCount { var count = 0; if (deinterlace.enabled) count++; @@ -462,6 +476,8 @@ class ProcessingPipeline { return chromaFixes.enabled; case PassType.cropResize: return cropResize.enabled; + case PassType.audioRestoration: + return audio.enabled; case PassType.subtitles: return subtitles.enabled; } @@ -514,6 +530,8 @@ class ProcessingPipeline { return chromaFixes.summary; case PassType.cropResize: return cropResize.summary; + case PassType.audioRestoration: + return audio.summary; case PassType.subtitles: return subtitles.summary; } @@ -541,6 +559,7 @@ class ProcessingPipeline { ColorCorrectionParameters? colorCorrection, ChromaFixParameters? chromaFixes, CropResizeParameters? cropResize, + AudioRestorationParameters? audio, SubtitleParameters? subtitles, }) { return ProcessingPipeline( @@ -565,6 +584,7 @@ class ProcessingPipeline { colorCorrection: colorCorrection ?? this.colorCorrection, chromaFixes: chromaFixes ?? this.chromaFixes, cropResize: cropResize ?? this.cropResize, + audio: audio ?? this.audio, subtitles: subtitles ?? this.subtitles, ); } @@ -650,6 +670,8 @@ class ProcessingPipeline { return copyWith( cropResize: cropResize.copyWith(enabled: enabled), ); + case PassType.audioRestoration: + return copyWith(audio: audio.copyWith(enabled: enabled)); case PassType.subtitles: return copyWith( subtitles: subtitles.copyWith(enabled: enabled), diff --git a/app/lib/services/manifest_addon.dart b/app/lib/services/manifest_addon.dart index 4a52ee66..034c83da 100644 --- a/app/lib/services/manifest_addon.dart +++ b/app/lib/services/manifest_addon.dart @@ -56,6 +56,10 @@ class AddonManifestFile { /// for a file kept as downloaded, or a wheel. final List? extract; + /// A program the worker runs (DeepFilterNet's `deep-filter`): installed + /// with its executable bit set and, on macOS, signed if it is not. + final bool executable; + const AddonManifestFile({ required this.name, required this.url, @@ -63,6 +67,7 @@ class AddonManifestFile { required this.sha256, this.platforms, this.extract, + this.executable = false, }); factory AddonManifestFile.fromJson(Map json) => AddonManifestFile( @@ -74,6 +79,7 @@ class AddonManifestFile { extract: (json['extract'] as List?) ?.map((m) => AddonArchiveMember.fromJson((m as Map).cast())) .toList(), + executable: json['executable'] as bool? ?? false, ); bool isFor(String platformId) => platforms == null || platforms!.contains(platformId); @@ -199,6 +205,9 @@ class AddonManifest { if (kind == 'python' && (!f.name.endsWith('.whl') || f.extract != null)) { fail('a python add-on lists only wheels (${f.name})'); } + if (f.executable && (kind != 'files' || f.extract != null)) { + fail('only a plain file of a files add-on can be executable (${f.name})'); + } for (final member in f.extract ?? const []) { if (!hash.hasMatch(member.sha256)) fail('${member.path} needs a sha256'); if (!_safeName(member.name)) fail('${member.name} is not a plain file name'); @@ -410,6 +419,9 @@ class ManifestAddonInstaller implements AddonInstaller { // copy may be; a quarantined dylib is killed on load. await Process.run('xattr', ['-cr', staging.path]); } + for (final f in fetched.keys.where((f) => f.executable)) { + await prepareExecutable(File(path.join(staging.path, f.name))); + } await File(path.join(staging.path, markerName)).writeAsString(jsonEncode({ 'id': manifest.id, 'version': manifest.version, @@ -426,6 +438,26 @@ class ManifestAddonInstaller implements AddonInstaller { } } + /// Make a downloaded program runnable: the executable bit (a download + /// has none), and on macOS a signature. Apple Silicon refuses to run an + /// unsigned arm64 binary at all, and upstream's Intel build is unsigned; + /// one that verifies (DeepFilterNet's arm64 build is linker-signed) is left + /// as it is. Quarantine was cleared with the rest of the install above. + static Future prepareExecutable(File file) async { + if (Platform.isWindows) return; + final chmod = await Process.run('chmod', ['755', file.path]); + if (chmod.exitCode != 0) { + throw StateError('Could not make ${path.basename(file.path)} executable: ${chmod.stderr}'); + } + if (!Platform.isMacOS) return; + final verify = await Process.run('codesign', ['--verify', file.path]); + if (verify.exitCode == 0) return; + final sign = await Process.run('codesign', ['--sign', '-', '--force', file.path]); + if (sign.exitCode != 0) { + throw StateError('Could not sign ${path.basename(file.path)}: ${sign.stderr}'); + } + } + /// Get [f] into [file], from the mirror if one is set, else the network; /// either way it must match its size and hash. Future _fetch(AddonManifestFile f, File file, AddonManager downloader) async { diff --git a/app/lib/viewmodels/main_viewmodel.dart b/app/lib/viewmodels/main_viewmodel.dart index 43dbac94..7ebda7a7 100644 --- a/app/lib/viewmodels/main_viewmodel.dart +++ b/app/lib/viewmodels/main_viewmodel.dart @@ -344,7 +344,8 @@ class MainViewModel extends ChangeNotifier { _regenerateOutputPath(); } notifyListeners(); - _requestPreviewUpdate(); + // The preview is picture only: nothing in audio restoration changes it. + if (filterId != 'audio_restoration') _requestPreviewUpdate(); } DynamicParameters _convertToParams(String filterId) { @@ -391,6 +392,8 @@ class MainViewModel extends ChangeNotifier { return ParameterConverter.fromChromaFixes(_processingPipeline.chromaFixes); case 'crop_resize': return ParameterConverter.fromCropResize(_processingPipeline.cropResize); + case 'audio_restoration': + return ParameterConverter.fromAudioRestoration(_processingPipeline.audio); case 'subtitles': return ParameterConverter.fromSubtitles(_processingPipeline.subtitles); default: @@ -517,6 +520,11 @@ class MainViewModel extends ChangeNotifier { cropResize: ParameterConverter.toCropResize(params), ); break; + case 'audio_restoration': + _processingPipeline = _processingPipeline.copyWith( + audio: ParameterConverter.toAudioRestoration(params), + ); + break; case 'subtitles': _processingPipeline = _processingPipeline.copyWith( subtitles: ParameterConverter.toSubtitles(params), @@ -1380,6 +1388,8 @@ class MainViewModel extends ChangeNotifier { return 'chroma_fixes'; case PassType.cropResize: return 'crop_resize'; + case PassType.audioRestoration: + return 'audio_restoration'; case PassType.subtitles: return 'subtitles'; } @@ -1482,8 +1492,11 @@ class MainViewModel extends ChangeNotifier { } // Subtitle-only mode: no video processing passes enabled, only subtitles + // Audio restoration needs the full job (it renders the output's + // soundtrack), so it rules out subtitle-only mode. final isSubtitleOnly = _processingPipeline.subtitles.enabled && - _processingPipeline.enabledPasses.isEmpty; + _processingPipeline.enabledPasses.isEmpty && + !_processingPipeline.audio.hasEffect; _lastJobSubtitleOnly = isSubtitleOnly && _processingPipeline.subtitles.output == SubtitleOutput.srtFile; @@ -1855,7 +1868,8 @@ class MainViewModel extends ChangeNotifier { // Subtitle-only with SRT output: use .srt extension final isSubtitleOnly = _processingPipeline.subtitles.enabled && - _processingPipeline.videoPassCount == 0; + _processingPipeline.videoPassCount == 0 && + !_processingPipeline.audio.hasEffect; if (isSubtitleOnly && _processingPipeline.subtitles.output == SubtitleOutput.srtFile) { return path.join(outputDir, '$outputFilename.srt'); } diff --git a/app/lib/views/about_dialog.dart b/app/lib/views/about_dialog.dart index d5c78955..4318877a 100644 --- a/app/lib/views/about_dialog.dart +++ b/app/lib/views/about_dialog.dart @@ -511,6 +511,12 @@ class _AboutDialogState extends State { copyright: 'Microsoft Corporation', url: 'https://github.com/microsoft/DirectML', ), + _ComponentTile( + name: 'DeepFilterNet (optional download, speech enhancement add-on)', + license: 'MIT OR Apache-2.0', + copyright: 'Hendrik Schröter', + url: 'https://github.com/Rikorose/DeepFilterNet', + ), _ComponentTile( name: 'libdvdread', license: 'GPL 2.0+', diff --git a/app/lib/views/pass_list/pass_list_item.dart b/app/lib/views/pass_list/pass_list_item.dart index 7c1f4412..ef4a61e5 100644 --- a/app/lib/views/pass_list/pass_list_item.dart +++ b/app/lib/views/pass_list/pass_list_item.dart @@ -263,6 +263,8 @@ class PassListItem extends StatelessWidget { return Icons.tune; case PassType.cropResize: return Icons.crop; + case PassType.audioRestoration: + return Icons.graphic_eq; case PassType.subtitles: return Icons.subtitles; } diff --git a/app/lib/views/pass_list/pass_list_panel.dart b/app/lib/views/pass_list/pass_list_panel.dart index d1aaf80b..af210727 100644 --- a/app/lib/views/pass_list/pass_list_panel.dart +++ b/app/lib/views/pass_list/pass_list_panel.dart @@ -71,9 +71,12 @@ class PassListPanel extends StatelessWidget { title: 'Finishing', passes: [PassType.sharpen, PassType.grain, PassType.frameRate], ), + // Neither is a picture pass: the soundtrack is rendered, then (when + // subtitles are on) transcribed, both before the encode. Restoration + // comes first because the transcript is of the restored sound. ( - title: 'Post-Processing', - passes: [PassType.subtitles], + title: 'Audio & Subtitles', + passes: [PassType.audioRestoration, PassType.subtitles], ), ]; @@ -176,6 +179,9 @@ class PassListPanel extends StatelessWidget { case PassType.cropResize: return item(passType, 'Crop / Resize', pipeline.cropResize.summary, pipeline.cropResize.enabled); + case PassType.audioRestoration: + return item(passType, 'Audio Restoration', pipeline.audio.summary, + pipeline.audio.enabled); case PassType.subtitles: return item( passType, diff --git a/app/lib/views/pass_settings/pass_settings_inline.dart b/app/lib/views/pass_settings/pass_settings_inline.dart index d9d963ca..089f4325 100644 --- a/app/lib/views/pass_settings/pass_settings_inline.dart +++ b/app/lib/views/pass_settings/pass_settings_inline.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../models/dynamic_parameters.dart'; +import '../../models/audio_restoration_parameters.dart'; import '../../models/filter_registry.dart'; import '../../models/filter_schema.dart'; import '../../models/pass_advice.dart'; @@ -74,6 +75,8 @@ class PassSettingsInline extends StatelessWidget { return 'chroma_fixes'; case PassType.cropResize: return 'crop_resize'; + case PassType.audioRestoration: + return 'audio_restoration'; case PassType.subtitles: return 'subtitles'; } @@ -375,6 +378,17 @@ class PassSettingsInline extends StatelessWidget { ); if (!ready) return; } + // DeepFilterNet is an add-on too: choosing speech enhancement offers it. + if (deepFilterChosen(filterId, oldParams.values, newParams.values) && + newParams.enabled) { + if (!context.mounted) return; + final ready = await AddonPrompt.ensure( + context, + [deepFilterNetAddonId], + reason: 'Speech enhancement needs DeepFilterNet.', + ); + if (!ready) return; + } viewModel.updateDynamicParams(filterId, newParams); } diff --git a/app/test/pass_list_stages_test.dart b/app/test/pass_list_stages_test.dart index 29d00367..c23eae66 100644 --- a/app/test/pass_list_stages_test.dart +++ b/app/test/pass_list_stages_test.dart @@ -57,6 +57,7 @@ void main() { PassType.sharpen, PassType.grain, PassType.frameRate, + PassType.audioRestoration, PassType.subtitles, ]); }); diff --git a/app/test/pass_relevance_test.dart b/app/test/pass_relevance_test.dart index 74ee32fa..1740083f 100644 --- a/app/test/pass_relevance_test.dart +++ b/app/test/pass_relevance_test.dart @@ -208,6 +208,7 @@ void main() { PassType.sharpen, PassType.colorCorrection, PassType.subtitles, + PassType.audioRestoration, ]; for (final scan in ScanType.values) { diff --git a/app/test/preset_visibility_test.dart b/app/test/preset_visibility_test.dart index ec0aca5f..1db9a95b 100644 --- a/app/test/preset_visibility_test.dart +++ b/app/test/preset_visibility_test.dart @@ -66,6 +66,7 @@ const _filterIds = { PassType.cropResize: 'crop_resize', PassType.grain: 'grain', PassType.subtitles: 'subtitles', + PassType.audioRestoration: 'audio_restoration', PassType.edgeRepair: 'edge_repair', PassType.deflicker: 'deflicker', PassType.ghostRemoval: 'ghost_removal', diff --git a/licenses/NOTICES.txt b/licenses/NOTICES.txt index 8b239e64..1f2f58d5 100644 --- a/licenses/NOTICES.txt +++ b/licenses/NOTICES.txt @@ -79,6 +79,18 @@ DirectML — Microsoft Software License Terms (DirectML redistributable) AI runtime add-on downloads from PyPI when the user asks. Proprietary; Microsoft permits its redistribution in applications for Windows. +DeepFilterNet — MIT OR Apache-2.0 (at your option) + Copyright (c) 2021 Hendrik Schröter + https://github.com/Rikorose/DeepFilterNet + Speech enhancement for Audio Restoration: the deep-filter 0.5.6 program, + with the DeepFilterNet 3 model built in. The repository's code and model + are dual-licensed under the MIT licence (LICENSE-MIT) or the Apache License + 2.0 (LICENSE-APACHE). NOT distributed with VapourBox: an optional add-on, + downloaded unmodified from the project's own v0.5.6 GitHub release only when + the user asks (Settings → Add-ons). The program statically includes the + Rust libraries it is built from (among them Sonos' tract), each under the + licence stated in its own crate. + ------------------------------------------------------------------------------- 2. VapourSynth Python modules From 66211a39e61ca257ea9ddb7790d25faff9eef0a7 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 8 Nov 2026 18:29:18 +1100 Subject: [PATCH 11/61] feat(upscale): offer the add-on upscalers and AI strength in Crop & Resize Three new Upscale Method options (Real-ESRGAN General, Live Action SPAN, Real-ESRGAN x4plus; NEW since 1.7.0), a General Denoise switch and an AI Strength slider for every AI method. Choosing an add-on method prompts for its add-on through AddonPrompt.ensure (moving between two methods of one add-on asks once), and the panel shows the add-on's terms with a Download button while it is missing: the AnimeJaNai notice is now the general UpscaleAddonNotice. upscaleMethodAddons mirrors the worker's UpscaleMethod::addon. The SPAN description says it cannot handle VHS damage, as its author does. Co-Authored-By: Claude Opus 5.5 --- app/assets/filters/core/crop_resize.json | 87 +++++++++++++++++-- app/lib/models/crop_resize_parameters.dart | 26 ++++++ app/lib/models/gpu_plugin_status.dart | 29 ++++++- app/lib/models/parameter_converter.dart | 4 + .../pass_settings/pass_settings_inline.dart | 85 ++++++++++++------ app/test/addons_ui_widget_test.dart | 18 ++++ app/test/gpu_plugin_status_test.dart | 58 +++++++++++++ 7 files changed, 273 insertions(+), 34 deletions(-) diff --git a/app/assets/filters/core/crop_resize.json b/app/assets/filters/core/crop_resize.json index 8a75b57d..da36f9bf 100644 --- a/app/assets/filters/core/crop_resize.json +++ b/app/assets/filters/core/crop_resize.json @@ -4,7 +4,7 @@ "version": "1.3.0", "name": "Crop & Resize", "description": "Crop borders, resize or upscale, and add bars to a target size", - "longDescription": "Trims unwanted borders, changes the output resolution, and adds bars out to a target frame size. Cropping happens first, then resizing, then the bars.\n\nUse crop to cut the head-switching noise along the bottom of VHS captures and the black overscan edges of broadcast material — otherwise the encoder spends bitrate on them. Use resize for a target resolution, or Upscale for a much better 2x/4x enlargement than a plain kernel: NNEDI3 or EEDI3 on any machine, or an AI upscaler trained on anime (Real-ESRGAN, Real-CUGAN, AnimeJaNai) on the graphics card. Keep crop values even so they stay aligned with chroma subsampling.\n\nUse Add Borders to bring a cropped picture back to an exact frame size for authoring (720x576 for PAL DVD, 720x480 for NTSC) without rescaling it: the picture is centred and the rest filled with bars.", + "longDescription": "Trims unwanted borders, changes the output resolution, and adds bars out to a target frame size. Cropping happens first, then resizing, then the bars.\n\nUse crop to cut the head-switching noise along the bottom of VHS captures and the black overscan edges of broadcast material — otherwise the encoder spends bitrate on them. Use resize for a target resolution, or Upscale for a much better 2x/4x enlargement than a plain kernel: NNEDI3 or EEDI3 on any machine, or an AI upscaler on the graphics card: trained on anime (Real-ESRGAN, Real-CUGAN, AnimeJaNai) or on real-world footage (Real-ESRGAN General, Live Action SPAN, Real-ESRGAN x4plus, each a separate download). Keep crop values even so they stay aligned with chroma subsampling.\n\nUse Add Borders to bring a cropped picture back to an exact frame size for authoring (720x576 for PAL DVD, 720x480 for NTSC) without rescaling it: the picture is centred and the rest filled with bars.", "category": "transform", "icon": "crop", "order": 9, @@ -249,7 +249,10 @@ "spline36", "realEsrgan", "realCugan", - "animeJanai" + "animeJanai", + "realEsrganGeneral", + "liveAction", + "realEsrganPlus" ], "optional": true, "ui": { @@ -260,9 +263,12 @@ "spline36": "Spline36", "realEsrgan": "Real-ESRGAN", "realCugan": "Real-CUGAN", - "animeJanai": "AnimeJaNai (download, non-commercial)" + "animeJanai": "AnimeJaNai (download, non-commercial)", + "realEsrganGeneral": "Real-ESRGAN General (download)", + "liveAction": "Live Action SPAN (download)", + "realEsrganPlus": "Real-ESRGAN x4plus (download, slow)" }, - "description": "How the picture is enlarged. NNEDI3 is the usual choice, an edge-directed doubler that runs anywhere; EEDI3 is slower and follows near-horizontal edges better (it uses NNEDI3 to guide its search); Spline36 is plain resampling with no edge direction. Real-ESRGAN and Real-CUGAN are neural upscalers trained on anime: they redraw clean line art rather than interpolating it, and run on the graphics card (Vulkan). Real-ESRGAN is sharper and faster; Real-CUGAN is gentler and can remove noise as it goes. AnimeJaNai, trained on HD anime, keeps colour almost exactly and comes in three speeds; it is a separate download, free for non-commercial use only. Use them after deinterlacing: on a combed picture they sharpen the combing too", + "description": "How the picture is enlarged. NNEDI3 is the usual choice, an edge-directed doubler that runs anywhere; EEDI3 is slower and follows near-horizontal edges better (it uses NNEDI3 to guide its search); Spline36 is plain resampling with no edge direction. Real-ESRGAN and Real-CUGAN are neural upscalers trained on anime: they redraw clean line art rather than interpolating it, and run on the graphics card (Vulkan). Real-ESRGAN is sharper and faster; Real-CUGAN is gentler and can remove noise as it goes. AnimeJaNai, trained on HD anime, keeps colour almost exactly and comes in three speeds; it is a separate download, free for non-commercial use only. For live action and other real-world footage there are three more, each a separate download: Real-ESRGAN General, fast and sharp, which redraws texture and can remove noise too; Live Action SPAN, trained on film and video to repair compression, blur and halos while keeping detail and colour (it cannot handle VHS damage — use it on DVD, broadcast or digital sources); and Real-ESRGAN x4plus, the most detailed and very slow. Use them after deinterlacing: on a combed picture they sharpen the combing too", "widget": "dropdown", "visibleWhen": { "useIntegerUpscale": true @@ -271,7 +277,10 @@ "optionSinceAppVersion": { "realEsrgan": "1.5.0", "realCugan": "1.5.0", - "animeJanai": "1.5.0" + "animeJanai": "1.5.0", + "realEsrganGeneral": "1.7.0", + "liveAction": "1.7.0", + "realEsrganPlus": "1.7.0" } }, "upscaleFactor": { @@ -352,6 +361,46 @@ } } }, + "generalDenoise": { + "type": "boolean", + "default": false, + "sinceAppVersion": "1.7.0", + "ui": { + "label": "Real-ESRGAN General Denoise", + "description": "Use the variant trained to remove noise as it upscales (realesr-general-wdn-x4v3). Leave off for clean sources: it also smooths fine texture", + "visibleWhen": { + "useIntegerUpscale": true, + "upscaleMethod": [ + "realEsrganGeneral" + ] + } + } + }, + "aiStrength": { + "type": "number", + "default": 1.0, + "min": 0.0, + "max": 1.0, + "step": 0.05, + "sinceAppVersion": "1.7.0", + "ui": { + "label": "AI Strength", + "description": "How much of the AI upscale to keep. Below 1 the result is blended with a plain Spline36 upscale of the same frame, which tones down a model that redraws too much or shifts flat colours (Real-ESRGAN tints flat areas by a few levels). 1 is the model alone", + "widget": "slider", + "precision": 2, + "visibleWhen": { + "useIntegerUpscale": true, + "upscaleMethod": [ + "realEsrgan", + "realCugan", + "animeJanai", + "realEsrganGeneral", + "liveAction", + "realEsrganPlus" + ] + } + } + }, "bicubicB": { "type": "number", "default": 0.0, @@ -904,7 +953,9 @@ "upscaleFactor", "cuganNoise", "animejanaiModel", - "animejanaiSharp" + "animejanaiSharp", + "generalDenoise", + "aiStrength" ], "expanded": false }, @@ -1015,6 +1066,30 @@ "upscaleMethod": "animeJanai" } }, + { + "function": "core.ncnn.Model", + "role": "Real-ESRGAN General (add-on), 4x, on the GPU (vs-mlrt's VSNCNN); core.resize.Spline36 to 2x after it", + "activeWhen": { + "useIntegerUpscale": true, + "upscaleMethod": "realEsrganGeneral" + } + }, + { + "function": "core.ncnn.Model", + "role": "2xLiveActionV1 SPAN (add-on), 2x, twice for 4x, on the GPU (vs-mlrt's VSNCNN)", + "activeWhen": { + "useIntegerUpscale": true, + "upscaleMethod": "liveAction" + } + }, + { + "function": "core.ncnn.Model", + "role": "Real-ESRGAN x2plus / x4plus (add-on), by factor, on the GPU (vs-mlrt's VSNCNN)", + "activeWhen": { + "useIntegerUpscale": true, + "upscaleMethod": "realEsrganPlus" + } + }, { "function": "core.std.AddBorders", "role": "Pad to Fill's letterbox/pillarbox bars, in the border colour", diff --git a/app/lib/models/crop_resize_parameters.dart b/app/lib/models/crop_resize_parameters.dart index e4cfb250..66e41925 100644 --- a/app/lib/models/crop_resize_parameters.dart +++ b/app/lib/models/crop_resize_parameters.dart @@ -42,6 +42,19 @@ enum UpscaleMethod { /// non-commercial use only, downloaded when it is chosen. @JsonValue('animeJanai') animeJanai, + /// Real-ESRGAN's general model, for real-world footage, on the GPU (needs + /// Vulkan). 4x only: a 2x upscale resamples its result down. Its model is + /// the `upscale-general` add-on. + @JsonValue('realEsrganGeneral') + realEsrganGeneral, + /// jcj83429's 2xLiveActionV1 SPAN model, for live action, on the GPU. 2x + /// only: 4x runs it twice. Also in the `upscale-general` add-on. + @JsonValue('liveAction') + liveAction, + /// Real-ESRGAN x4plus / x2plus, the large, slow models, on the GPU. The + /// `upscale-esrgan-plus` add-on. + @JsonValue('realEsrganPlus') + realEsrganPlus, } /// What to do with the pixel aspect ratio (SAR) on output. @@ -196,6 +209,13 @@ class CropResizeParameters { /// AnimeJaNai's Sharp variant of the HD model. final bool animejanaiSharp; + /// Real-ESRGAN General's denoise variant (realesr-general-wdn-x4v3). + final bool generalDenoise; + + /// How much of an AI upscale to keep, 0.0-1.0: the rest is a Spline36 + /// upscale of the same frame. 1.0 (the default) is the network alone. + final double aiStrength; + // --- nnedi3 controls for the edge-directed upscale paths --- // With EEDI3 selected these shape the nnedi3 sclip that guides it. @@ -267,6 +287,8 @@ class CropResizeParameters { this.cuganNoise = 'conservative', this.animejanaiModel = 'compact', this.animejanaiSharp = false, + this.generalDenoise = false, + this.aiStrength = 1.0, this.upscaleNsize, this.upscaleNeurons, this.upscaleQual, @@ -365,6 +387,8 @@ class CropResizeParameters { String? cuganNoise, String? animejanaiModel, bool? animejanaiSharp, + bool? generalDenoise, + double? aiStrength, int? upscaleNsize, int? upscaleNeurons, int? upscaleQual, @@ -407,6 +431,8 @@ class CropResizeParameters { cuganNoise: cuganNoise ?? this.cuganNoise, animejanaiModel: animejanaiModel ?? this.animejanaiModel, animejanaiSharp: animejanaiSharp ?? this.animejanaiSharp, + generalDenoise: generalDenoise ?? this.generalDenoise, + aiStrength: aiStrength ?? this.aiStrength, upscaleNsize: upscaleNsize ?? this.upscaleNsize, upscaleNeurons: upscaleNeurons ?? this.upscaleNeurons, upscaleQual: upscaleQual ?? this.upscaleQual, diff --git a/app/lib/models/gpu_plugin_status.dart b/app/lib/models/gpu_plugin_status.dart index 0e7e8a90..05cab999 100644 --- a/app/lib/models/gpu_plugin_status.dart +++ b/app/lib/models/gpu_plugin_status.dart @@ -88,6 +88,32 @@ bool animeJanaiSelectedBy(String filterId, Map values) => values['useIntegerUpscale'] == true && values['upscaleMethod'] == 'animeJanai'; +/// Crop & Resize's AI upscale methods, all run by VSNCNN on the GPU. +const aiUpscaleMethods = { + 'realEsrgan', + 'realCugan', + 'animeJanai', + 'realEsrganGeneral', + 'liveAction', + 'realEsrganPlus', +}; + +/// The add-on holding each AI upscale method's models, for the methods whose +/// models are not in the deps bundle — the worker's `UpscaleMethod::addon` +/// (plus AnimeJaNai's hand-written add-on). +const upscaleMethodAddons = { + 'animeJanai': 'animejanai', + 'realEsrganGeneral': 'upscale-general', + 'liveAction': 'upscale-general', + 'realEsrganPlus': 'upscale-esrgan-plus', +}; + +/// The add-on a panel's current values need for their AI upscale, if any. +String? upscaleAddonSelectedBy(String filterId, Map values) => + filterId == 'crop_resize' && values['useIntegerUpscale'] == true + ? upscaleMethodAddons[values['upscaleMethod']] + : null; + /// Which GPU feature, if any, a panel's current values select. GpuPlugin? gpuPluginSelectedBy(String filterId, Map values) { switch (filterId) { @@ -95,8 +121,7 @@ GpuPlugin? gpuPluginSelectedBy(String filterId, Map values) { return values['method'] == 'rife' ? GpuPlugin.rife : null; case 'crop_resize': final method = values['upscaleMethod']; - return values['useIntegerUpscale'] == true && - (method == 'realEsrgan' || method == 'realCugan' || method == 'animeJanai') + return values['useIntegerUpscale'] == true && aiUpscaleMethods.contains(method) ? GpuPlugin.upscale : null; } diff --git a/app/lib/models/parameter_converter.dart b/app/lib/models/parameter_converter.dart index 936375d1..a4eed8a6 100644 --- a/app/lib/models/parameter_converter.dart +++ b/app/lib/models/parameter_converter.dart @@ -920,6 +920,8 @@ class ParameterConverter { 'cuganNoise': params.cuganNoise, 'animejanaiModel': params.animejanaiModel, 'animejanaiSharp': params.animejanaiSharp, + 'generalDenoise': params.generalDenoise, + 'aiStrength': params.aiStrength, }; // Kernel tuning and the nnedi3/EEDI3 controls start unticked, so the // plugin's own default applies until the user opts in. Fallbacks are those @@ -1551,6 +1553,8 @@ class ParameterConverter { cuganNoise: v['cuganNoise'] as String? ?? 'conservative', animejanaiModel: v['animejanaiModel'] as String? ?? 'compact', animejanaiSharp: v['animejanaiSharp'] as bool? ?? false, + generalDenoise: v['generalDenoise'] as bool? ?? false, + aiStrength: (v['aiStrength'] as num?)?.toDouble() ?? 1.0, // Absent (checkbox off) stays null so the script omits it entirely. bicubicB: (v['bicubicB'] as num?)?.toDouble(), bicubicC: (v['bicubicC'] as num?)?.toDouble(), diff --git a/app/lib/views/pass_settings/pass_settings_inline.dart b/app/lib/views/pass_settings/pass_settings_inline.dart index d9d963ca..54458c0c 100644 --- a/app/lib/views/pass_settings/pass_settings_inline.dart +++ b/app/lib/views/pass_settings/pass_settings_inline.dart @@ -103,7 +103,9 @@ class PassSettingsInline extends StatelessWidget { _buildGpuPluginNotice(context, viewModel, filterId, params), _buildKnlmNotice(viewModel, filterId, params), if (animeJanaiSelectedBy(filterId, params.values)) - const AnimeJanaiNotice(), + const AnimeJanaiNotice() + else if (upscaleAddonSelectedBy(filterId, params.values) case final id?) + UpscaleAddonNotice(addonId: id, reason: upscaleAddonReason(id)), if (filterId == 'descale') _buildDescaleDetect(context, viewModel), _buildBitDepthWarning(context, viewModel, schema, params), DynamicFilterPanelCompact( @@ -364,28 +366,23 @@ class PassSettingsInline extends StatelessWidget { if (!ready) return; } - // AnimeJaNai's models are an add-on: choosing it downloads them, after - // saying what they are and that they are for non-commercial use only. - if (_choosesAnimeJanai(filterId, oldParams, newParams)) { + // AnimeJaNai's and the other add-on upscalers' models are add-ons: + // choosing one downloads its models, after saying what they are, their + // licence and size (AnimeJaNai's: non-commercial use only). Moving + // between two methods of one add-on asks once. + final addon = upscaleAddonSelectedBy(filterId, newParams.values); + if (addon != null && addon != upscaleAddonSelectedBy(filterId, oldParams.values)) { if (!context.mounted) return; final ready = await AddonPrompt.ensure( context, - [animeJanaiAddonId], - reason: 'AnimeJaNai upscaling needs its models.', + [addon], + reason: upscaleAddonReason(addon), ); if (!ready) return; } viewModel.updateDynamicParams(filterId, newParams); } - bool _choosesAnimeJanai( - String filterId, - DynamicParameters oldParams, - DynamicParameters newParams, - ) => - animeJanaiSelectedBy(filterId, newParams.values) && - !animeJanaiSelectedBy(filterId, oldParams.values); - Widget _buildFallbackPanel(BuildContext context, PassType passType) { return Padding( padding: const EdgeInsets.symmetric(vertical: 16), @@ -543,19 +540,50 @@ class _FilterDescriptionState extends State<_FilterDescription> { } } +/// Why an AI upscale method asks for its add-on, in [AddonPrompt.ensure]. +String upscaleAddonReason(String addonId) => switch (addonId) { + animeJanaiAddonId => 'AnimeJaNai upscaling needs its models.', + 'upscale-esrgan-plus' => 'Real-ESRGAN x4plus upscaling needs its models.', + _ => 'Real-ESRGAN General and Live Action SPAN upscaling need their models.', + }; + /// Under Crop & Resize while AnimeJaNai is chosen: its terms, always — the /// non-commercial restriction is the user's to keep in mind on every job — /// and, if its models are not installed (deleted since, or chosen by a /// preset), a button to download them. -class AnimeJanaiNotice extends StatefulWidget { +class AnimeJanaiNotice extends UpscaleAddonNotice { + const AnimeJanaiNotice({super.key, super.service}) + : super( + addonId: animeJanaiAddonId, + reason: 'AnimeJaNai upscaling needs its models.', + label: 'AnimeJaNai', + ); +} + +/// Under Crop & Resize while an add-on upscaler is chosen: the add-on's terms +/// and, if its models are not installed (deleted since, or chosen by a +/// preset), a button to download them. +class UpscaleAddonNotice extends StatefulWidget { + final String addonId; + final String reason; + + /// Names the models in the status line ("AnimeJaNai models installed."); + /// without it the add-on's own name is used. + final String? label; final AddonService? service; - const AnimeJanaiNotice({super.key, this.service}); + const UpscaleAddonNotice({ + super.key, + required this.addonId, + required this.reason, + this.label, + this.service, + }); @override - State createState() => _AnimeJanaiNoticeState(); + State createState() => _UpscaleAddonNoticeState(); } -class _AnimeJanaiNoticeState extends State { +class _UpscaleAddonNoticeState extends State { AddonService get _service => widget.service ?? AddonService.instance; @override @@ -569,9 +597,11 @@ class _AnimeJanaiNoticeState extends State { return ListenableBuilder( listenable: _service, builder: (context, _) { - final info = _service.info(animeJanaiAddonId); + final id = widget.addonId; + final info = _service.info(id); if (info == null) return const SizedBox.shrink(); - final installed = _service.isInstalled(animeJanaiAddonId); + final installed = _service.isInstalled(id); + final label = widget.label; return Padding( padding: const EdgeInsets.only(bottom: 12), child: Row( @@ -581,9 +611,13 @@ class _AnimeJanaiNoticeState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - installed - ? 'AnimeJaNai models installed.' - : 'AnimeJaNai\'s models are not installed.', + label != null + ? (installed + ? '$label models installed.' + : '$label\'s models are not installed.') + : (installed + ? '${info.name}: installed.' + : '${info.name}: not installed.'), style: Theme.of(context).textTheme.bodySmall, ), AddonTerms(info: info), @@ -594,9 +628,8 @@ class _AnimeJanaiNoticeState extends State { OutlinedButton.icon( onPressed: _service.installingId != null ? null - : () => AddonPrompt.ensure(context, [animeJanaiAddonId], - reason: 'AnimeJaNai upscaling needs its models.', - service: _service), + : () => AddonPrompt.ensure(context, [id], + reason: widget.reason, service: _service), icon: const Icon(Icons.download, size: 16), label: Text('Download (${formatAddonSize(info.downloadBytes)})'), ), diff --git a/app/test/addons_ui_widget_test.dart b/app/test/addons_ui_widget_test.dart index df82df1b..e60ca487 100644 --- a/app/test/addons_ui_widget_test.dart +++ b/app/test/addons_ui_widget_test.dart @@ -122,6 +122,24 @@ void main() { expect(find.text('AnimeJaNai models installed.'), findsOneWidget); }); + testWidgets('an add-on upscaler\'s notice names its add-on and offers it while missing', + (tester) async { + final own = AddonService.forTest(addonsDir: tmp, installers: [ + _Marker(_info('upscale-general', 'AI upscaling', bytes: 11400000)), + ]); + await own.load(); + await pump(tester, UpscaleAddonNotice( + addonId: 'upscale-general', reason: upscaleAddonReason('upscale-general'), service: own)); + expect(find.text('Name of upscale-general: not installed.'), findsOneWidget); + expect(find.textContaining('Download (11 MB)'), findsOneWidget); + expect(find.text('MIT'), findsOneWidget, reason: 'the licence is shown with it'); + + await tester.runAsync(() => own.install('upscale-general')); + await tester.pumpAndSettle(); + expect(find.text('Name of upscale-general: installed.'), findsOneWidget); + expect(find.textContaining('Download'), findsNothing); + }); + // The Settings tabs share the dialog's width equally (at most 600 px). A // NEW pill set beside "Add-ons" overflowed its tab by 14 px; overlaid, it // takes no width. Flutter reports a RenderFlex overflow as a test failure. diff --git a/app/test/gpu_plugin_status_test.dart b/app/test/gpu_plugin_status_test.dart index 1be061a2..f121fec9 100644 --- a/app/test/gpu_plugin_status_test.dart +++ b/app/test/gpu_plugin_status_test.dart @@ -2,13 +2,17 @@ // `--probe-upscale`, and the panels' decision about what to say. Pure unit // tests; the probes themselves run against the real worker in // integration_gpu_plugins_test.dart. +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; import 'package:vapourbox/models/crop_resize_parameters.dart'; import 'package:vapourbox/models/dynamic_parameters.dart'; import 'package:vapourbox/models/frame_rate_parameters.dart'; import 'package:vapourbox/models/gpu_plugin_status.dart'; import 'package:vapourbox/models/parameter_converter.dart'; +import 'package:vapourbox/services/addon_service.dart'; void main() { group('GpuPluginStatus.fromJson', () { @@ -123,6 +127,60 @@ void main() { isFalse); }); + test('each add-on upscaler names its add-on, only when chosen', () { + Map v(String m, {bool on = true}) => + {'useIntegerUpscale': on, 'upscaleMethod': m}; + expect(upscaleAddonSelectedBy('crop_resize', v('realEsrganGeneral')), 'upscale-general'); + expect(upscaleAddonSelectedBy('crop_resize', v('liveAction')), 'upscale-general'); + expect(upscaleAddonSelectedBy('crop_resize', v('realEsrganPlus')), 'upscale-esrgan-plus'); + expect(upscaleAddonSelectedBy('crop_resize', v('animeJanai')), animeJanaiAddonId); + for (final m in ['realEsrgan', 'realCugan', 'nnedi3Rpow2', 'spline36']) { + expect(upscaleAddonSelectedBy('crop_resize', v(m)), isNull, reason: m); + } + expect(upscaleAddonSelectedBy('crop_resize', v('liveAction', on: false)), isNull); + expect(upscaleAddonSelectedBy('frame_rate', v('liveAction')), isNull); + // Every manifest id named is a manifest the app ships. + final root = Directory.current.path.endsWith('app') + ? Directory.current.path + : p.join(Directory.current.path, 'app'); + for (final id in upscaleMethodAddons.values.where((id) => id != animeJanaiAddonId)) { + expect(File(p.join(root, 'assets', 'addons', '$id.json')).existsSync(), isTrue, reason: id); + } + // Every enum value is either an AI method or one of the three that run + // anywhere; the add-on methods are all AI methods. + for (final m in UpscaleMethod.values) { + expect(aiUpscaleMethods.contains(m.name), + !{'nnedi3Rpow2', 'eedi3Rpow2', 'spline36'}.contains(m.name), reason: m.name); + } + expect(aiUpscaleMethods.containsAll(upscaleMethodAddons.keys), isTrue); + }); + + test('the converter round-trips the General denoise switch and AI strength', () { + for (final (denoise, strength) in [(false, 1.0), (true, 0.55)]) { + final params = CropResizeParameters( + enabled: true, + useIntegerUpscale: true, + upscaleMethod: UpscaleMethod.realEsrganGeneral, + generalDenoise: denoise, + aiStrength: strength); + final back = ParameterConverter.toCropResize(ParameterConverter.fromCropResize(params)); + expect(back.upscaleMethod, UpscaleMethod.realEsrganGeneral); + expect(back.generalDenoise, denoise); + expect(back.aiStrength, strength); + final json = params.toJson(); + expect(json['aiStrength'], strength); + expect(json['upscaleMethod'], 'realEsrganGeneral'); + } + // Absent from an older preset: full strength, no denoise. + final old = CropResizeParameters.fromJson({'enabled': true}); + expect((old.aiStrength, old.generalDenoise), (1.0, false)); + for (final m in [UpscaleMethod.liveAction, UpscaleMethod.realEsrganPlus]) { + final back = ParameterConverter.toCropResize(ParameterConverter.fromCropResize( + CropResizeParameters(enabled: true, useIntegerUpscale: true, upscaleMethod: m))); + expect(back.upscaleMethod, m); + } + }); + test('the converter round-trips AnimeJaNai\'s model and Sharp settings', () { for (final model in ['compact', 'ultraCompact', 'superUltraCompact']) { for (final sharp in [false, true]) { From bdab36762bc0f35e6c6fc3b76871dd324a2246ca Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 8 Nov 2026 18:29:18 +1100 Subject: [PATCH 12/61] docs(credits): credit the Real-ESRGAN and 2xLiveActionV1_SPAN add-on models NOTICES, About tiles and README Acknowledgments for the Real-ESRGAN general model and x4plus/x2plus models (BSD-3-Clause, Copyright (c) 2021 Xintao Wang, from upstream's LICENSE) and 2xLiveActionV1_SPAN (Apache-2.0, Copyright 2025 jcj83429, its NOTICE reproduced), each saying it is an add-on. README also describes the live-action upscalers and AI Strength. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++- app/lib/views/about_dialog.dart | 18 ++++++++++++++ licenses/NOTICES.txt | 44 ++++++++++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e08e9584..06f6ddfe 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ VapourBox runs [VapourSynth](https://www.vapoursynth.com/), QTGMC and FFmpeg for - **Fixes how the picture is stored.** Detects interlaced and telecined sources, then deinterlaces with QTGMC or recovers the original film frames with inverse telecine (or by stripping soft-telecine flags). - **Cleans up old footage.** Optional filters for tape noise, smeared colour, dust and scratches, flicker, ghosting, compression blocking, banding, camera shake and dark, flat pictures. They're off until you turn them on, and the presets turn on what a given kind of source needs. - **Restores anime.** **Descale** undoes the upscale most HD anime went through in production, **BM3D** cleans flat colour without smearing lines, and **AI upscaling** redraws line art at 2× or 4×: Real-ESRGAN (sharpest, fastest), Real-CUGAN (gentler, with denoising) or AnimeJaNai (trained on HD anime). +- **Upscales live action** with AI models trained on real-world footage, each an optional download: Real-ESRGAN General (fast, with a noise-removing variant), Live Action SPAN (repairs compression, blur and halos on DVD and broadcast sources while keeping colour), or Real-ESRGAN x4plus (most detailed, slow). An **AI Strength** control blends any AI upscale back towards a plain one. - **Converts frame rates** — the broadcast standards or any rate you type in (24 → 48 or 60, say) — with motion interpolation or the **RIFE** AI interpolator. - **Writes subtitles** from speech with Whisper AI, as an `.srt` file, an embedded track, or burnt into the picture. @@ -82,7 +83,7 @@ Where an AppImage can't be mounted, use the `.tar.gz` of the same build: extract **GPU features.** RIFE frame interpolation and AI upscaling run on the graphics card through Vulkan: on any Apple Silicon Mac, and on Windows and Linux x64 PCs with a current graphics driver (RIFE on Windows also needs the Microsoft Visual C++ Redistributable). They aren't available on Intel Macs or Linux on ARM yet. GPU deinterlacing and KNLMeans denoising use OpenCL instead; without it, deinterlacing falls back to the CPU. Whenever you choose a GPU feature your machine can't run, VapourBox says what's missing and how to fix it. -**Add-ons.** A few features use third-party downloads that aren't part of VapourBox: the Whisper engine and language models for subtitles, the AnimeJaNai models for AI upscaling, and the AI runtime (Microsoft's ONNX Runtime) that AI model add-ons run on. VapourBox offers to download one the first time you need it, stating its source, size and licence, and **Settings → Add-ons** installs or removes them at any time. AnimeJaNai is free for **non-commercial use only** (CC BY-NC-SA 4.0); for paid work, use Real-ESRGAN or Real-CUGAN instead. +**Add-ons.** A few features use third-party downloads that aren't part of VapourBox: the Whisper engine and language models for subtitles, the AnimeJaNai and live-action models for AI upscaling, and the AI runtime (Microsoft's ONNX Runtime) that AI model add-ons run on. VapourBox offers to download one the first time you need it, stating its source, size and licence, and **Settings → Add-ons** installs or removes them at any time. AnimeJaNai is free for **non-commercial use only** (CC BY-NC-SA 4.0); for paid work, use Real-ESRGAN or Real-CUGAN instead. **Older processors.** Intel and AMD CPUs from before about 2013 (no AVX2) are supported on every platform. VapourBox checks the processor on first launch and installs a matching set of components, which runs some filters more slowly but never differently. @@ -275,6 +276,7 @@ VapourBox is a front end to other people's work. Thanks in particular to: - **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 - **Real-ESRGAN** by Xintao Wang and **Real-CUGAN** by bilibili — the AI upscaling networks, run through AmusementClub's **vs-mlrt** (VSNCNN) - **AnimeJaNai** by the-database — anime upscaling models, an optional download (CC BY-NC-SA 4.0, non-commercial use only) +- **Real-ESRGAN** general and x4plus/x2plus models by Xintao Wang, and **2xLiveActionV1_SPAN** by jcj83429 — upscaling models for real-world footage, optional downloads (BSD-3-Clause; Apache-2.0) - **whisper.cpp** by the ggml authors and OpenAI's **Whisper** models — speech recognition for subtitles, an optional download - **ONNX Runtime** by Microsoft Corporation, with **NumPy** by the NumPy Developers and, on Windows, Microsoft's **DirectML** — the AI runtime add-on that runs the optional AI models, an optional download - **libdvdread** by VideoLAN — DVD reading diff --git a/app/lib/views/about_dialog.dart b/app/lib/views/about_dialog.dart index d5c78955..6b228426 100644 --- a/app/lib/views/about_dialog.dart +++ b/app/lib/views/about_dialog.dart @@ -307,6 +307,24 @@ class _AboutDialogState extends State { copyright: 'the-database', url: 'https://github.com/the-database/mpv-upscale-2x_animejanai', ), + _ComponentTile( + name: 'Real-ESRGAN general model (optional download, upscaling add-on)', + license: 'BSD-3-Clause', + copyright: 'Xintao Wang', + url: 'https://github.com/xinntao/Real-ESRGAN', + ), + _ComponentTile( + name: '2xLiveActionV1_SPAN model (optional download, upscaling add-on)', + license: 'Apache-2.0', + copyright: 'Copyright 2025 jcj83429', + url: 'https://github.com/jcj83429/upscaling/tree/main/2xLiveActionV1_SPAN', + ), + _ComponentTile( + name: 'Real-ESRGAN x4plus / x2plus models (optional download, upscaling add-on)', + license: 'BSD-3-Clause', + copyright: 'Xintao Wang', + url: 'https://github.com/xinntao/Real-ESRGAN', + ), _ComponentTile( name: 'Real-CUGAN models', license: 'MIT', diff --git a/licenses/NOTICES.txt b/licenses/NOTICES.txt index 8b239e64..13151da8 100644 --- a/licenses/NOTICES.txt +++ b/licenses/NOTICES.txt @@ -294,6 +294,48 @@ AnimeJaNai models — CC BY-NC-SA 4.0 (non-commercial) upscaler). Free for non-commercial use only; anyone using them must keep to that. +Real-ESRGAN general model — BSD-3-Clause + Copyright (c) 2021, Xintao Wang + https://github.com/xinntao/Real-ESRGAN + The trained network weights of realesr-general-x4v3 and + realesr-general-wdn-x4v3, used by the Real-ESRGAN General upscaler. NOT + distributed with VapourBox: part of the optional "General & live-action + upscaling" add-on, which the app downloads only when the user asks (Settings + → Add-ons, or on choosing the upscaler). Upstream publishes PyTorch + checkpoints only; the add-on carries this project's ONNX conversions + (Scripts/models/convert_realesrgan.py), redistributed under the BSD 3-Clause + licence whose text is given below. + +2xLiveActionV1_SPAN model — Apache-2.0 + Copyright 2025 jcj83429 + https://github.com/jcj83429/upscaling/tree/main/2xLiveActionV1_SPAN + Licence text: Apache-2.0.txt + The 2x SPAN model for live-action video and film, used by the Live Action + SPAN upscaler, as upstream's own unmodified ONNX + (2xLiveActionV1_SPAN_490000.onnx). NOT distributed with VapourBox: part of + the optional "General & live-action upscaling" add-on, which the app + downloads from upstream only when the user asks. Relicensed by its author to + the Apache License 2.0 in upstream commit 5d8cdd2e; its NOTICE file reads: + Copyright 2025 jcj83429 + 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. + +Real-ESRGAN x4plus / x2plus models — BSD-3-Clause + Copyright (c) 2021, Xintao Wang + https://github.com/xinntao/Real-ESRGAN + The trained network weights of RealESRGAN_x4plus and RealESRGAN_x2plus, used + by the Real-ESRGAN x4plus upscaler. NOT distributed with VapourBox: the + optional "Real-ESRGAN x4plus / x2plus" add-on, which the app downloads only + when the user asks. Upstream publishes PyTorch checkpoints only; the add-on + carries this project's ONNX conversions, redistributed under the BSD + 3-Clause licence whose text is given below. + Real-CUGAN models — MIT Copyright (c) 2022 bilibili https://github.com/bilibili/ailab/tree/main/Real-CUGAN @@ -626,7 +668,7 @@ DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE, Version 2 BSD 3-Clause License -------------------- (Applies to Flutter, Zstandard, ncnn, the core of glslang, Protocol Buffers, -the Real-ESRGAN model and NumPy, under the copyright notices given above.) +the Real-ESRGAN models and NumPy, under the copyright notices given above.) Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: From 3eee28fd004c463d4491be5eea087f1a93b15cb7 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 8 Nov 2026 18:30:52 +1100 Subject: [PATCH 13/61] fix(audio): resample for drift, measured afftdn floor, tags on every stream; tests The first heavy run found atempo moving every click ~20 ms early, afftdn taking 0.7 dB off -39 dB hiss at its default floor (13.6 dB with the measured one; its noise tracking undid it), and per-stream metadata mapping dropping the other streams' tags. Drift is now an exact resample (aresample up, integer asetrate, aresample back), afftdn gets the source's measured noise floor, and every audio stream's tags are mapped. Adds the push-gate --audio-plan test, the heavy encode tests (dead channel, +/- offset, trim, drift, hum, afftdn, loudness, late audio start, several streams, DeepFilterNet), unit tests, and an 'audio' progress phase. Co-Authored-By: Claude Opus 5.5 --- .../models/audio_restoration_parameters.dart | 51 ++-- app/lib/models/progress_info.dart | 3 + app/lib/views/progress_panel.dart | 18 ++ app/test/audio_restoration_test.dart | 134 ++++++++ app/test/integration_audio_plan_test.dart | 158 ++++++++++ .../integration_audio_restoration_test.dart | 289 ++++++++++++++++++ app/test/manifest_addon_test.dart | 51 ++++ app/test/parameter_copy_with_test.dart | 6 +- app/test/support/audio_fixtures.dart | 193 ++++++++++++ worker/src/audio_restoration.rs | 196 +++++++++--- 10 files changed, 1039 insertions(+), 60 deletions(-) create mode 100644 app/test/audio_restoration_test.dart create mode 100644 app/test/integration_audio_plan_test.dart create mode 100644 app/test/integration_audio_restoration_test.dart create mode 100644 app/test/support/audio_fixtures.dart diff --git a/app/lib/models/audio_restoration_parameters.dart b/app/lib/models/audio_restoration_parameters.dart index 5f917753..a7bd7db9 100644 --- a/app/lib/models/audio_restoration_parameters.dart +++ b/app/lib/models/audio_restoration_parameters.dart @@ -148,31 +148,32 @@ class AudioRestorationParameters { double? loudnessTarget, bool? limiter, double? limiterCeilingDb, - }) => - AudioRestorationParameters( - enabled: enabled ?? this.enabled, - streamIndex: streamIndex ?? this.streamIndex, - channelFix: channelFix ?? this.channelFix, - offsetMs: offsetMs ?? this.offsetMs, - driftCorrection: driftCorrection ?? this.driftCorrection, - driftMaxPercent: driftMaxPercent ?? this.driftMaxPercent, - declip: declip ?? this.declip, - declick: declick ?? this.declick, - declickThreshold: declickThreshold ?? this.declickThreshold, - humRemoval: humRemoval ?? this.humRemoval, - humFrequency: humFrequency ?? this.humFrequency, - rumbleFilter: rumbleFilter ?? this.rumbleFilter, - rumbleCutoffHz: rumbleCutoffHz ?? this.rumbleCutoffHz, - denoise: denoise ?? this.denoise, - denoiseStrength: denoiseStrength ?? this.denoiseStrength, - deepFilter: deepFilter ?? this.deepFilter, - deepFilterAttenuation: - deepFilterAttenuation ?? this.deepFilterAttenuation, - loudnessNormalize: loudnessNormalize ?? this.loudnessNormalize, - loudnessTarget: loudnessTarget ?? this.loudnessTarget, - limiter: limiter ?? this.limiter, - limiterCeilingDb: limiterCeilingDb ?? this.limiterCeilingDb, - ); + }) { + return AudioRestorationParameters( + enabled: enabled ?? this.enabled, + streamIndex: streamIndex ?? this.streamIndex, + channelFix: channelFix ?? this.channelFix, + offsetMs: offsetMs ?? this.offsetMs, + driftCorrection: driftCorrection ?? this.driftCorrection, + driftMaxPercent: driftMaxPercent ?? this.driftMaxPercent, + declip: declip ?? this.declip, + declick: declick ?? this.declick, + declickThreshold: declickThreshold ?? this.declickThreshold, + humRemoval: humRemoval ?? this.humRemoval, + humFrequency: humFrequency ?? this.humFrequency, + rumbleFilter: rumbleFilter ?? this.rumbleFilter, + rumbleCutoffHz: rumbleCutoffHz ?? this.rumbleCutoffHz, + denoise: denoise ?? this.denoise, + denoiseStrength: denoiseStrength ?? this.denoiseStrength, + deepFilter: deepFilter ?? this.deepFilter, + deepFilterAttenuation: + deepFilterAttenuation ?? this.deepFilterAttenuation, + loudnessNormalize: loudnessNormalize ?? this.loudnessNormalize, + loudnessTarget: loudnessTarget ?? this.loudnessTarget, + limiter: limiter ?? this.limiter, + limiterCeilingDb: limiterCeilingDb ?? this.limiterCeilingDb, + ); + } /// On, with at least one step chosen (twin of the worker's `has_effect`). bool get hasEffect => diff --git a/app/lib/models/progress_info.dart b/app/lib/models/progress_info.dart index 9a7b7f64..6c7cf8fc 100644 --- a/app/lib/models/progress_info.dart +++ b/app/lib/models/progress_info.dart @@ -44,6 +44,9 @@ class ProgressInfo { /// True during subtitle embedding phase. bool get isEmbeddingPhase => phase == 'embedding'; + /// True while audio restoration renders the soundtrack (before the encode). + bool get isAudioPhase => phase == 'audio'; + /// Formatted ETA string (e.g., "1h 23m 45s"). String get etaFormatted { if (eta <= 0 || !eta.isFinite) return '--'; diff --git a/app/lib/views/progress_panel.dart b/app/lib/views/progress_panel.dart index 020d3d5d..3104ec2a 100644 --- a/app/lib/views/progress_panel.dart +++ b/app/lib/views/progress_panel.dart @@ -242,6 +242,7 @@ class ProgressPanel extends StatelessWidget { if (progress.isExtractingPhase) return 'Extracting DVD Title'; if (progress.isSubtitlePhase) return 'Generating Subtitles'; if (progress.isEmbeddingPhase) return 'Embedding Subtitles'; + if (progress.isAudioPhase) return 'Restoring Audio'; } return 'Processing'; case ProcessingState.cancelling: @@ -284,6 +285,23 @@ class ProgressPanel extends StatelessWidget { ); } + // Audio restoration: a percentage, or just the label while DeepFilterNet + // (which reports nothing) runs. + if (progress.isAudioPhase) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildDetailChip( + context, + icon: Icons.graphic_eq, + value: progress.isIndeterminate + ? 'Restoring audio...' + : 'Restoring audio... ${progress.percentComplete}%', + ), + ], + ); + } + // Embedding phase: show text only (indeterminate) if (progress.isEmbeddingPhase) { return Row( diff --git a/app/test/audio_restoration_test.dart b/app/test/audio_restoration_test.dart new file mode 100644 index 00000000..f6a38b79 --- /dev/null +++ b/app/test/audio_restoration_test.dart @@ -0,0 +1,134 @@ +// Audio restoration's model, converter and wiring — the parts that run without +// a worker. The generated arguments are checked against the worker itself in +// integration_audio_plan_test.dart, and the audio in +// integration_audio_restoration_test.dart (heavy). + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:vapourbox/models/audio_restoration_parameters.dart'; +import 'package:vapourbox/models/filter_schema.dart'; +import 'package:vapourbox/models/parameter_converter.dart'; +import 'package:vapourbox/models/processing_pipeline.dart'; +import 'package:vapourbox/models/progress_info.dart'; + +const _everything = AudioRestorationParameters( + enabled: true, + streamIndex: 1, + channelFix: ChannelFix.rightOnly, + offsetMs: -120, + driftCorrection: true, + driftMaxPercent: 0.3, + declip: true, + declick: true, + declickThreshold: 3.5, + humRemoval: true, + humFrequency: HumFrequency.hz60, + rumbleFilter: true, + rumbleCutoffHz: 60, + denoise: true, + denoiseStrength: 18, + deepFilter: true, + deepFilterAttenuation: 30, + loudnessNormalize: true, + loudnessTarget: -23, + limiter: true, + limiterCeilingDb: -2, +); + +void main() { + test('the JSON is what the worker reads', () { + // The same names and values as the worker's json_names_match_the_app. + final json = _everything.toJson(); + expect(json['channelFix'], 'rightOnly'); + expect(json['humFrequency'], '60'); + expect(json['offsetMs'], -120); + expect(json['deepFilterAttenuation'], 30); + expect(json.keys.toSet(), { + 'enabled', 'streamIndex', 'channelFix', 'offsetMs', 'driftCorrection', + 'driftMaxPercent', 'declip', 'declick', 'declickThreshold', 'humRemoval', + 'humFrequency', 'rumbleFilter', 'rumbleCutoffHz', 'denoise', + 'denoiseStrength', 'deepFilter', 'deepFilterAttenuation', + 'loudnessNormalize', 'loudnessTarget', 'limiter', 'limiterCeilingDb', + }); + for (final fix in ChannelFix.values) { + final back = AudioRestorationParameters.fromJson( + const AudioRestorationParameters(channelFix: ChannelFix.off) + .copyWith(channelFix: fix) + .toJson()); + expect(back.channelFix, fix); + } + // The pipeline carries it as "audio", which the worker's pipeline reads. + final pipeline = const ProcessingPipeline(audio: _everything).toJson(); + expect((pipeline['audio'] as Map)['offsetMs'], -120); + expect(ProcessingPipeline.fromJson(jsonDecode(jsonEncode(pipeline))).audio.toJson(), + _everything.toJson()); + }); + + test('the converter round-trips every field', () { + final dynamic = ParameterConverter.fromAudioRestoration(_everything); + expect(dynamic.filterId, 'audio_restoration'); + expect(ParameterConverter.toAudioRestoration(dynamic).toJson(), _everything.toJson()); + // And through a whole pipeline. + final back = ParameterConverter.toPipeline( + ParameterConverter.fromPipeline(const ProcessingPipeline(audio: _everything))); + expect(back.audio.toJson(), _everything.toJson()); + }); + + test('every converter value is a schema parameter with a valid value', () { + final schema = FilterSchema.fromJson(jsonDecode( + File('assets/filters/core/audio_restoration.json').readAsStringSync()) + as Map); + final values = ParameterConverter.fromAudioRestoration(_everything).values; + for (final entry in values.entries) { + final def = schema.parameters[entry.key]; + expect(def, isNotNull, reason: '${entry.key} is not in the schema'); + if (def!.options != null) { + expect(def.options, contains(entry.value), reason: entry.key); + } + } + // Every visible schema parameter is something the converter sets. + for (final id in schema.parameters.keys) { + if (id == 'enabled' || id == 'method') continue; + expect(values.containsKey(id), isTrue, reason: '$id is never converted'); + } + }); + + test('a pass switched on with nothing in it does nothing', () { + const on = AudioRestorationParameters(enabled: true); + expect(on.hasEffect, isFalse); + expect(on.summary, 'Nothing selected'); + expect(const AudioRestorationParameters().summary, 'Off'); + expect(on.copyWith(offsetMs: 40).hasEffect, isTrue); + expect(on.copyWith(offsetMs: 40).summary, 'Sync +40 ms'); + expect(_everything.summary, startsWith('Channels, Declip +')); + }); + + test('it is not a picture pass', () { + const p = ProcessingPipeline(audio: _everything); + expect(p.enabledPasses, isNot(contains(PassType.audioRestoration))); + expect(p.isPassEnabled(PassType.audioRestoration), isTrue); + expect(p.enabledPassCount - p.videoPassCount, 1); + // Toggling keeps every setting. + final off = p.togglePass(PassType.audioRestoration, false); + expect(off.audio.enabled, isFalse); + expect(off.togglePass(PassType.audioRestoration, true).audio.toJson(), + _everything.toJson()); + }); + + test('DeepFilterNet is offered when speech enhancement is switched on', () { + expect(deepFilterChosen('audio_restoration', {'deepFilter': false}, {'deepFilter': true}), isTrue); + expect(deepFilterChosen('audio_restoration', {'deepFilter': true}, {'deepFilter': true}), isFalse); + expect(deepFilterChosen('audio_restoration', {}, {'denoise': true}), isFalse); + expect(deepFilterChosen('noise_reduction', {}, {'deepFilter': true}), isFalse); + expect(deepFilterNetAddonId, 'deepfilternet'); + expect(File('assets/addons/$deepFilterNetAddonId.json').existsSync(), isTrue); + }); + + test('the audio phase is its own progress phase', () { + const p = ProgressInfo(frame: 40, totalFrames: 100, fps: 0, eta: 0, phase: 'audio'); + expect(p.isAudioPhase, isTrue); + expect(p.isEncodingPhase, isFalse); + }); +} diff --git a/app/test/integration_audio_plan_test.dart b/app/test/integration_audio_plan_test.dart new file mode 100644 index 00000000..e1d1ce57 --- /dev/null +++ b/app/test/integration_audio_plan_test.dart @@ -0,0 +1,158 @@ +// Integration test: audio restoration's arguments, from the worker itself +// (`--audio-plan`: the probes run on a real source, nothing is rendered). +// Script-level, like integration_filter_parameters_test — runs in the +// per-push gate. The audio itself is measured in +// integration_audio_restoration_test.dart (heavy). +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:vapourbox/services/manifest_addon.dart'; +import 'dart:io'; + +import 'support/audio_fixtures.dart'; +import 'support/worker_harness.dart'; + +/// The `-af` argument of an ffmpeg argument list, asserting there is one. +String af(List args) { + final idx = [for (var i = 0; i < args.length; i++) if (args[i] == '-af') i]; + expect(idx, hasLength(1), reason: 'one -af per ffmpeg run: $args'); + return args[idx.single + 1] as String; +} + +void main() { + late String deadRightHum; + late String twoStreams; + + setUpAll(() async { + await WorkerHarness.ensureReady(); + // Left: a 1 kHz tone over 50 Hz hum. Right: nothing. + deadRightHum = await AudioFixtures.source('plan_dead_right_hum', + filterComplex: 'sine=f=1000:r=48000:d=6[t];sine=f=50:r=48000:d=6[h];' + "[t][h]amix=inputs=2:weights='1 0.3':normalize=0," + 'pan=stereo|c0=c0|c1=0*c0[a]'); + // Two audio streams: mono 48 kHz, then stereo 44.1 kHz. + twoStreams = await AudioFixtures.source('plan_two_streams', + audioStreams: 2, + filterComplex: 'sine=f=440:r=48000:d=6[a0];' + 'sine=f=660:r=44100:d=6,pan=stereo|c0=c0|c1=c0[a1]'); + }); + + test('a pass with nothing chosen plans nothing', () async { + final plan = await AudioFixtures.plan(AudioFixtures.job(deadRightHum, {})); + expect(plan['active'], isFalse); + }); + + test('automatic channel fix and hum removal follow the source', () async { + final plan = await AudioFixtures.plan(AudioFixtures.job(deadRightHum, { + 'channelFix': 'auto', + 'humRemoval': true, + 'humFrequency': 'auto', + 'declick': true, + 'declickThreshold': 4, + 'rumbleFilter': true, + 'rumbleCutoffHz': 60, + 'declip': true, + 'denoise': true, + 'denoiseStrength': 20, + 'limiter': true, + 'limiterCeilingDb': -3, + })); + expect(plan['active'], isTrue); + // afftdn's floor is measured from the source (here the tone itself: it + // has no pauses, so the clamp's -30 dB). + final chain = af(plan['restore'] as List) + .replaceAll(RegExp(r'afftdn=nr=20:nf=-\d+'), 'afftdn=nr=20:nf='); + expect(af(plan['restore'] as List), contains('afftdn=nr=20:nf=-30')); + expect(chain.split(','), [ + 'aresample=first_pts=0', + 'pan=stereo|c0=c0|c1=c0', + 'adeclip', + 'adeclick=t=4', + 'highpass=f=60:poles=2', + 'bandreject=f=50:width_type=q:w=8', + 'bandreject=f=100:width_type=q:w=8', + 'bandreject=f=150:width_type=q:w=8', + 'afftdn=nr=20:nf=', + 'atrim=start_sample=1200', + 'asetpts=PTS-STARTPTS', + 'alimiter=limit=0.7079:level=0:latency=1', + // 6 s at 48 kHz: the picture's length. + 'apad=whole_len=288000', + 'atrim=end_sample=288000', + ]); + expect(plan['finish'], isNull, reason: 'one ffmpeg run writes the track'); + expect(plan['notes'], contains(contains('right channel is silent'))); + expect(plan['notes'], contains(contains('50 Hz'))); + expect(plan['notes'], contains(contains('noise floor'))); + }); + + test('a negative offset reads later, a positive one adds silence', () async { + final early = await AudioFixtures.plan(AudioFixtures.job(deadRightHum, {'offsetMs': -400})); + final restore = (early['restore'] as List).cast(); + expect(restore.indexOf('-ss'), lessThan(restore.indexOf('-i'))); + expect(restore[restore.indexOf('-ss') + 1], '0.400000'); + expect(af(restore), isNot(contains('adelay'))); + + final late = await AudioFixtures.plan(AudioFixtures.job(deadRightHum, {'offsetMs': 400})); + final restoreLate = (late['restore'] as List).cast(); + expect(restoreLate, isNot(contains('-ss'))); + expect(af(restoreLate), contains('adelay=delays=19200S:all=1')); + + // From a trim's in-point at 2 s, a 400 ms delay reads from 1.6 s instead. + final trimmed = await AudioFixtures.plan(AudioFixtures.job(deadRightHum, {'offsetMs': 400}, + extra: {'startFrame': 50, 'endFrame': 124, 'totalFrames': 75})); + final restoreTrimmed = (trimmed['restore'] as List).cast(); + expect(restoreTrimmed[restoreTrimmed.indexOf('-ss') + 1], '1.600000'); + expect(af(restoreTrimmed), endsWith('apad=whole_len=144000,atrim=end_sample=144000')); + }); + + test('the length follows the encoded picture, not the source frame count', () async { + // Double-rate deinterlacing doubles the frames and the rate: same length. + final plan = await AudioFixtures.plan(AudioFixtures.job(deadRightHum, {'limiter': true}, + pipeline: { + 'deinterlace': {'enabled': true, 'method': 'bwdif', 'fpsDivisor': 1}, + }, + extra: {'totalFrames': 150})); + expect(plan['outSamples'], 288000); + }); + + test('loudness and DeepFilterNet split the work into runs', () async { + final plan = await AudioFixtures.plan(AudioFixtures.job(twoStreams, { + 'streamIndex': 1, + 'denoise': true, + 'loudnessNormalize': true, + 'loudnessTarget': -23, + 'limiter': true, + })); + expect(plan['sourceStreams'], 2); + expect(plan['stream'], 1); + final restore = (plan['restore'] as List).cast(); + expect(restore[restore.indexOf('-map') + 1], '0:a:1'); + // 44.1 kHz: afftdn's 25 ms is 1103 samples, and the track keeps the rate. + expect(af(restore), contains('atrim=start_sample=1103')); + expect(af(restore), endsWith('apad=whole_len=264600,atrim=end_sample=264600')); + expect(af(plan['measure'] as List), 'loudnorm=I=-23:TP=-1.5:LRA=11:print_format=json'); + expect(af(plan['finish'] as List), + 'aresample=44100,alimiter=limit=0.8913:level=0:latency=1,' + 'apad=whole_len=264600,atrim=end_sample=264600'); + expect(plan['outRate'], 44100); + }); + + test('DeepFilterNet needs its add-on, and runs at 48 kHz with -D', () async { + final job = AudioFixtures.job(twoStreams, {'streamIndex': 1, 'deepFilter': true, 'deepFilterAttenuation': 24}); + final plan = await AudioFixtures.plan(job); + final manifest = AddonManifest.loadDirectory( + Directory(p.join(WorkerHarness.repoRoot, 'app', 'assets', 'addons'))) + .firstWhere((m) => m.id == 'deepfilternet'); + final installed = + ManifestAddonInstaller(manifest, WorkerHarness.platform).isInstalled(WorkerHarness.addonsDirectory); + if (!installed) { + expect(plan['error'], allOf(contains('DeepFilterNet'), contains('Settings → Add-ons'))); + return; + } + expect(plan['deepFilter'], ['-D', '-a', '24', '-o', 'dfn', 'restore.wav']); + expect(plan['outRate'], 48000); + expect(af(plan['finish'] as List), 'aresample=48000,apad=whole_len=288000,atrim=end_sample=288000'); + }); +} diff --git a/app/test/integration_audio_restoration_test.dart b/app/test/integration_audio_restoration_test.dart new file mode 100644 index 00000000..e41d36b0 --- /dev/null +++ b/app/test/integration_audio_restoration_test.dart @@ -0,0 +1,289 @@ +// Integration test: audio restoration, measured on the encoded output. +// +// Every fixture is made with ffmpeg's lavfi generators, so what the audio +// should be afterwards is known exactly: a click track's onsets say where the +// sound is in time (offset, drift, trim, filter delays), a tone's level says +// what survived, and a gap's level says how much noise is left. +// +// Heavy: full encodes. Run with +// flutter test --tags heavy test/integration_audio_restoration_test.dart +@Tags(['heavy']) +library; + +import 'dart:io'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'support/audio_fixtures.dart'; +import 'support/worker_harness.dart'; + +const _rate = 48000; + +/// Clicks (3 ms bursts of 3 kHz) at irregular times: two interleaved +/// periodic tracks, so no shift lines one click up with another. +const _clickExpr = "aevalsrc='0.8*sin(2*PI*3000*t)*(lt(mod(t\\,0.7)\\,0.003)+lt(mod(t+0.23\\,1.1)\\,0.003))'"; + +/// Encode [job] and return the output path, failing with the worker's log. +Future _encode(Map job) async { + final result = await WorkerHarness.runJob(job, label: job['outputPath'] as String); + expect(result.success, isTrue, + reason: '${result.error}\n${result.logs.where((l) => !l.contains('"debug"')).join('\n')}'); + return result.outputPath!; +} + +/// Asserts the output's clicks are the source's moved by [map]: every click +/// expected inside the track (away from its ends) is found within +/// [tolerance] seconds, and every click found there was expected. +void _expectOnsets(List source, List output, double Function(double) map, + double length, {double tolerance = 0.001}) { + const margin = 0.1; + bool inside(double t) => t > margin && t < length - margin; + final expected = source.map(map).toList(); + double nearest(List list, double t) => + list.reduce((a, b) => (a - t).abs() < (b - t).abs() ? a : b); + final checked = expected.where(inside).toList(); + expect(checked.length, greaterThan(4), reason: 'too few clicks to judge'); + for (final t in checked) { + final got = nearest(output, t); + expect((got - t).abs(), lessThan(tolerance), + reason: 'a click expected at ${t.toStringAsFixed(4)} s is nearest ${got.toStringAsFixed(4)} s'); + } + for (final t in output.where(inside)) { + expect((nearest(expected, t) - t).abs(), lessThan(tolerance), + reason: 'a click at ${t.toStringAsFixed(4)} s was not expected'); + } +} + +/// RMS level in 1 ms frames: an envelope to cross-correlate. +Float32List _envelope(Float32List x) { + const frame = _rate ~/ 1000; + return Float32List.fromList([ + for (var i = 0; i + frame <= x.length; i += frame) + math.sqrt([for (var j = i; j < i + frame; j++) x[j] * x[j]].reduce((a, b) => a + b) / frame), + ]); +} + +/// Integrated loudness (LUFS) by ffmpeg's EBU R128 meter. +Future _loudness(String path) async { + final r = await Process.run(WorkerHarness.ffmpegPath, + ['-hide_banner', '-nostats', '-i', path, '-map', '0:a:0', '-af', 'ebur128', '-f', 'null', '-'], + environment: WorkerHarness.ffmpegEnv); + final m = RegExp(r'I:\s+(-?[\d.]+) LUFS').allMatches(r.stderr as String).last; + return double.parse(m.group(1)!); +} + +void main() { + late String clicks; + + setUpAll(() async { + await WorkerHarness.ensureReady(); + clicks = await AudioFixtures.source('clicks', + seconds: 8, filterComplex: '$_clickExpr:s=$_rate:d=8,pan=stereo|c0=c0|c1=c0[a]'); + }); + + test('a dead channel is replaced: both channels come out equal', () async { + final src = await AudioFixtures.source('dead_right', + filterComplex: 'sine=f=1000:r=$_rate:d=6,pan=stereo|c0=0.5*c0|c1=0*c0[a]'); + final out = await _encode(AudioFixtures.job(src, {'channelFix': 'auto'}, label: 'dead_right')); + final ch = await AudioFixtures.decode(out); + expect(ch, hasLength(2)); + final left = AudioFixtures.rmsDb(ch[0]), right = AudioFixtures.rmsDb(ch[1]); + expect(left, closeTo(20 * math.log(0.5 / math.sqrt2) / math.ln10 - 18.06, 0.5), + reason: 'the live channel keeps its level'); + expect(right, closeTo(left, 0.01), reason: 'the dead channel now carries the same'); + expect(await AudioFixtures.audioSeconds(out), closeTo(6.0, 0.001)); + }); + + group('sync offset', () { + for (final ms in [250, -250]) { + test('$ms ms moves every click by $ms ms', () async { + final out = await _encode(AudioFixtures.job(clicks, {'offsetMs': ms}, label: 'offset_$ms')); + final source = AudioFixtures.onsets((await AudioFixtures.decode(clicks, mono: true)).first, _rate); + final output = AudioFixtures.onsets((await AudioFixtures.decode(out, mono: true)).first, _rate); + _expectOnsets(source, output, (t) => t + ms / 1000, 8.0); + expect(await AudioFixtures.audioSeconds(out), closeTo(8.0, 0.001), + reason: 'the track is cut or padded to the picture'); + }); + } + + test('with a trim, the offset is from the in-point', () async { + // Frames 25–174: 1.0 s to 7.0 s. + final out = await _encode(AudioFixtures.job(clicks, {'offsetMs': 300}, + label: 'offset_trim', extra: {'startFrame': 25, 'endFrame': 174, 'totalFrames': 150})); + final source = AudioFixtures.onsets((await AudioFixtures.decode(clicks, mono: true)).first, _rate); + final output = AudioFixtures.onsets((await AudioFixtures.decode(out, mono: true)).first, _rate); + _expectOnsets(source, output, (t) => t - 1.0 + 0.3, 6.0); + expect(await AudioFixtures.audioSeconds(out), closeTo(6.0, 0.001)); + }); + }); + + test('drift correction scales the track to the picture', () async { + // 20 s of picture, 20.08 s of audio (0.4% fast clock): clicks at + // audio-time t belong at picture-time t / 1.004. + final trimmedVideo = '${AudioFixtures.dir.path}/drift.mkv'; + if (!File(trimmedVideo).existsSync()) { + await AudioFixtures.ffmpeg([ + '-f', 'lavfi', '-i', 'testsrc2=s=160x120:r=25:d=20', + '-f', 'lavfi', '-i', '$_clickExpr:s=$_rate:d=20.08', + '-map', '0:v', '-map', '1:a', '-c:v', 'libx264', '-preset', 'ultrafast', + '-pix_fmt', 'yuv420p', '-c:a', 'flac', trimmedVideo, + ]); + } + final out = await _encode(AudioFixtures.job(trimmedVideo, {'driftCorrection': true}, + label: 'drift', extra: {'totalFrames': 500})); + final source = AudioFixtures.onsets((await AudioFixtures.decode(trimmedVideo, mono: true)).first, _rate); + final output = AudioFixtures.onsets((await AudioFixtures.decode(out, mono: true)).first, _rate); + final factor = (await AudioFixtures.audioSeconds(trimmedVideo)) / 20.0; + expect(factor, closeTo(1.004, 0.0005)); + _expectOnsets(source, output, (t) => t / factor, 20.0, tolerance: 0.002); + expect(await AudioFixtures.audioSeconds(out), closeTo(20.0, 0.001)); + }); + + test('hum removal notches 50 Hz and its harmonics, and keeps a 1 kHz tone', () async { + final src = await AudioFixtures.source('hum50', + filterComplex: 'sine=f=1000:r=$_rate:d=6[t];sine=f=50:r=$_rate:d=6[h1];' + 'sine=f=100:r=$_rate:d=6[h2];sine=f=150:r=$_rate:d=6[h3];' + "[t][h1][h2][h3]amix=inputs=4:weights='1 0.8 0.4 0.3':normalize=0," + 'pan=stereo|c0=c0|c1=c0[a]'); + final out = await _encode(AudioFixtures.job(src, {'humRemoval': true, 'humFrequency': 'auto'}, label: 'hum')); + // Two seconds in the middle: the notches' own settling is over. + Float32List mid(Float32List x) => Float32List.sublistView(x, 2 * _rate, 4 * _rate); + final before = mid((await AudioFixtures.decode(src, mono: true)).first); + final after = mid((await AudioFixtures.decode(out, mono: true)).first); + for (final f in [50.0, 100.0, 150.0]) { + final drop = AudioFixtures.bandDb(before, _rate, f) - AudioFixtures.bandDb(after, _rate, f); + expect(drop, greaterThan(30), reason: '$f Hz down by only ${drop.toStringAsFixed(1)} dB'); + } + final tone = AudioFixtures.bandDb(after, _rate, 1000) - AudioFixtures.bandDb(before, _rate, 1000); + expect(tone.abs(), lessThan(0.5), reason: 'the 1 kHz tone changed by $tone dB'); + }); + + test('afftdn lowers the noise floor without moving the clicks', () async { + final src = await AudioFixtures.source('clicks_noise', + seconds: 8, + filterComplex: '$_clickExpr:s=$_rate:d=8[c];anoisesrc=c=white:a=0.02:r=$_rate:d=8:seed=7[n];' + '[c][n]amix=inputs=2:normalize=0,pan=stereo|c0=c0|c1=c0[a]'); + final out = await _encode(AudioFixtures.job(src, {'denoise': true, 'denoiseStrength': 20}, label: 'afftdn')); + final before = (await AudioFixtures.decode(src, mono: true)).first; + final after = (await AudioFixtures.decode(out, mono: true)).first; + final source = AudioFixtures.onsets(before, _rate); + _expectOnsets(source, AudioFixtures.onsets(after, _rate), (t) => t, 8.0); + // Noise in a click-free stretch (the clicks are 3 ms; 0.71–0.86 s and + // 4.0–4.12 s are clear of both tracks), after afftdn has adapted. + double floor(Float32List x) => + AudioFixtures.rmsDb(x, from: (4.0 * _rate).round(), to: (4.12 * _rate).round()); + final drop = floor(before) - floor(after); + expect(drop, greaterThan(6), reason: 'noise floor down by only ${drop.toStringAsFixed(1)} dB'); + }); + + test('loudness normalisation hits its target within 1 LU', () async { + final src = await AudioFixtures.source('quiet_speechlike', + seconds: 10, + // A 200 Hz buzz, syllable-modulated, well under the target. + filterComplex: "aevalsrc='0.05*sin(2*PI*200*t)*(0.6+0.4*sin(2*PI*4*t))*lt(mod(t\\,1.5)\\,1.0)'" + ':s=$_rate:d=10,pan=stereo|c0=c0|c1=c0[a]'); + expect(await _loudness(src), lessThan(-30)); + for (final target in [-23.0, -16.0]) { + final out = await _encode(AudioFixtures.job(src, + {'loudnessNormalize': true, 'loudnessTarget': target, 'limiter': true}, + label: 'loudness_${target.abs().round()}')); + expect(await _loudness(out), closeTo(target, 1.0)); + expect(await AudioFixtures.audioSeconds(out), closeTo(10.0, 0.001)); + } + }); + + test('a late-starting audio stream keeps its place', () async { + // The audio starts 0.5 s after the picture; the encode used to keep that + // through the container's timestamps, the rendered track keeps it in samples. + final late = '${AudioFixtures.dir.path}/late_audio.mkv'; + if (!File(late).existsSync()) { + await AudioFixtures.ffmpeg(['-i', clicks, '-itsoffset', '0.5', '-i', clicks, + '-map', '0:v', '-map', '1:a', '-c', 'copy', '-t', '8', late]); + } + final out = await _encode(AudioFixtures.job(late, {'limiter': true}, label: 'late_audio')); + final source = AudioFixtures.onsets((await AudioFixtures.decode(clicks, mono: true)).first, _rate); + final output = AudioFixtures.onsets((await AudioFixtures.decode(out, mono: true)).first, _rate); + _expectOnsets(source, output, (t) => t + 0.5, 8.0); + }); + + group('several audio streams', () { + late String twoStreams; + setUpAll(() async { + final base = await AudioFixtures.source('two_streams_base', + audioStreams: 2, + filterComplex: 'sine=f=440:r=$_rate:d=6[a0];' + 'sine=f=660:r=44100:d=6,pan=stereo|c0=c0|c1=0*c0[a1]'); + twoStreams = '${AudioFixtures.dir.path}/two_streams.mkv'; + if (!File(twoStreams).existsSync()) { + await AudioFixtures.ffmpeg(['-i', base, '-map', '0', '-c', 'copy', + '-metadata:s:a:0', 'language=eng', '-metadata:s:a:1', 'language=jpn', twoStreams]); + } + }); + + test('the chosen stream is restored and first; the other is copied untouched', () async { + final out = await _encode(AudioFixtures.job(twoStreams, + {'streamIndex': 1, 'channelFix': 'auto'}, label: 'two_streams')); + final streams = (await WorkerHarness.ffprobeJson(['-v', 'error', '-of', 'json', + '-select_streams', 'a', '-show_entries', 'stream=codec_name,channels,sample_rate:stream_tags=language', out]))['streams'] as List; + expect(streams, hasLength(2)); + expect(streams[0]['codec_name'], 'flac', reason: 'restored, encoded (passthrough in MKV)'); + expect(streams[0]['sample_rate'], '44100'); + expect((streams[0]['tags'] as Map)['language'], 'jpn', reason: 'its own tags come with it'); + expect((streams[1]['tags'] as Map)['language'], 'eng'); + final restored = await AudioFixtures.decode(out, rate: 44100); + expect(AudioFixtures.rmsDb(restored[1]), closeTo(AudioFixtures.rmsDb(restored[0]), 0.01), + reason: 'the dead right channel was fixed'); + final copied = (await AudioFixtures.decode(out, stream: 1)).first; + final original = (await AudioFixtures.decode(twoStreams, stream: 0)).first; + expect(copied.length, original.length); + var maxDiff = 0.0; + for (var i = 0; i < copied.length; i++) { + maxDiff = math.max(maxDiff, (copied[i] - original[i]).abs()); + } + expect(maxDiff, 0, reason: 'the other stream is copied bit for bit'); + }); + + test('in MP4 with audio copied, the restored stream is AAC', () async { + final out = await _encode(AudioFixtures.job(twoStreams, {'limiter': true}, + container: 'mp4', label: 'two_streams_mp4', + encoding: {'audioMode': 'passthrough'})); + final first = await WorkerHarness.firstStream(out, selector: 'a:0', entries: ['codec_name']); + expect(first!['codec_name'], 'aac'); + }); + }); + + test('DeepFilterNet lowers stationary noise and keeps the timing', () async { + await WorkerHarness.ensureAddon('deepfilternet'); + // A voiced, syllable-modulated harmonic complex in bursts, over pink noise. + final src = await AudioFixtures.source('speechlike_noise', + seconds: 8, + filterComplex: "aevalsrc='(0.12*sin(2*PI*140*t)+0.08*sin(2*PI*280*t)+0.05*sin(2*PI*420*t)" + "+0.03*sin(2*PI*840*t))*(0.5+0.5*sin(2*PI*5*t))*lt(mod(t\\,1.6)\\,0.9)':s=$_rate:d=8[v];" + 'anoisesrc=c=pink:a=0.03:r=$_rate:d=8:seed=3[n];' + '[v][n]amix=inputs=2:normalize=0,pan=stereo|c0=c0|c1=c0[a]'); + final out = await _encode(AudioFixtures.job(src, {'deepFilter': true}, label: 'deepfilternet')); + final before = (await AudioFixtures.decode(src, mono: true)).first; + final after = (await AudioFixtures.decode(out, mono: true)).first; + expect(after.length, before.length, reason: 'the track keeps the picture\'s length'); + // Noise alone: the gaps (0.95–1.55 s of every 1.6 s cycle). + double gaps(Float32List x) { + var sum = 0.0; + var n = 0; + for (var cycle = 1; cycle < 4; cycle++) { + final from = ((cycle * 1.6 + 0.95) * _rate).round(), to = ((cycle * 1.6 + 1.55) * _rate).round(); + for (var i = from; i < to; i++) { + sum += x[i] * x[i]; + } + n += to - from; + } + return 10 * math.log(sum / n + 1e-20) / math.ln10; + } + final drop = gaps(before) - gaps(after); + expect(drop, greaterThan(10), reason: 'noise down by only ${drop.toStringAsFixed(1)} dB'); + // Timing: the bursts' envelopes line up to the millisecond. + final lag = AudioFixtures.lag(_envelope(before), _envelope(after), 60); + expect(lag.abs(), lessThanOrEqualTo(1), reason: 'DeepFilterNet moved the sound by $lag ms'); + }, timeout: const Timeout(Duration(minutes: 10))); +} diff --git a/app/test/manifest_addon_test.dart b/app/test/manifest_addon_test.dart index 9fa7aa88..9341c54c 100644 --- a/app/test/manifest_addon_test.dart +++ b/app/test/manifest_addon_test.dart @@ -257,6 +257,57 @@ void main() { }); }); + group('executable files', () { + test('a program is installed runnable (and signed on macOS)', () async { + // A real program, so there is something to run and sign. On macOS a + // copy of a system binary with its signature stripped: an unsigned + // program, as upstream DeepFilterNet's Intel build is, which Apple + // Silicon refuses to run until it is signed. (A copy that keeps Apple's + // own signature is killed outside /bin, so it would prove nothing.) + final source = File(Platform.isWindows ? r'C:\Windows\System32\whoami.exe' : '/bin/echo'); + final unsigned = File(p.join(tmp.path, 'unsigned'))..writeAsBytesSync(source.readAsBytesSync()); + if (Platform.isMacOS) { + final strip = await Process.run('codesign', ['--remove-signature', unsigned.path]); + expect(strip.exitCode, 0, reason: '${strip.stderr}'); + } + final bytes = unsigned.readAsBytesSync(); + final m = AddonManifest.fromJson(_manifest(files: [ + {..._entry('tool', bytes), 'executable': true}, + ])); + expect(m.files.single.executable, isTrue); + final installer = ManifestAddonInstaller(m, 'linux-x64'); + await installer.install(addons, _Serve({'https://example.invalid/dl/tool': bytes})); + final tool = File(p.join(addons.path, 'test-addon', 'tool')); + if (!Platform.isWindows) { + expect(tool.statSync().mode & 0x49, 0x49, reason: 'executable by user, group and other'); + } + if (Platform.isMacOS) { + final verify = await Process.run('codesign', ['--verify', tool.path]); + expect(verify.exitCode, 0, reason: 'an installed program verifies: ${verify.stderr}'); + } + final run = await Process.run(tool.path, Platform.isWindows ? [] : ['ok']); + expect(run.exitCode, 0, reason: 'the installed program runs: ${run.stderr}'); + }); + + test('only a plain file of a files add-on may be executable', () { + expect( + () => AddonManifest.fromJson(_manifest(kind: 'python', files: [ + {..._entry('pkg-1.0-cp312-cp312-any.whl', model), 'executable': true}, + ])), + throwsFormatException); + expect( + () => AddonManifest.fromJson(_manifest(files: [ + { + ..._entry('pack.zip', model, extract: [ + {'path': 'a', 'sha256': _sha(model)}, + ]), + 'executable': true, + }, + ])), + throwsFormatException); + }); + }); + group('validation', () { Map good() => _manifest(files: [_entry('a.onnx', model)]); void bad(void Function(Map) edit, String why) { diff --git a/app/test/parameter_copy_with_test.dart b/app/test/parameter_copy_with_test.dart index 4d6539dc..15a133c3 100644 --- a/app/test/parameter_copy_with_test.dart +++ b/app/test/parameter_copy_with_test.dart @@ -26,6 +26,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:vapourbox/models/anti_alias_parameters.dart'; +import 'package:vapourbox/models/audio_restoration_parameters.dart'; import 'package:vapourbox/models/chroma_denoise_parameters.dart'; import 'package:vapourbox/models/chroma_fix_parameters.dart'; import 'package:vapourbox/models/color_correction_parameters.dart'; @@ -72,6 +73,9 @@ void main() { final models = <_Model>[ _Model('AntiAliasParameters', () => const AntiAliasParameters().toJson(), (j) => AntiAliasParameters.fromJson(j).copyWith().toJson()), + _Model('AudioRestorationParameters', + () => const AudioRestorationParameters().toJson(), + (j) => AudioRestorationParameters.fromJson(j).copyWith().toJson()), _Model('ChromaDenoiseParameters', () => const ChromaDenoiseParameters().toJson(), (j) => ChromaDenoiseParameters.fromJson(j).copyWith().toJson()), @@ -140,7 +144,7 @@ void main() { test('every parameter model is covered', () { // The count is the guard: a new model with a forgetful copyWith would // otherwise be caught by nothing at all. - expect(models.length, 21, + expect(models.length, 22, reason: 'a parameter model was added or removed — add it to this ' 'list and update the count'); }); diff --git a/app/test/support/audio_fixtures.dart b/app/test/support/audio_fixtures.dart new file mode 100644 index 00000000..54c1e34b --- /dev/null +++ b/app/test/support/audio_fixtures.dart @@ -0,0 +1,193 @@ +// Audio fixtures and measurements for the audio restoration tests: sources +// made with ffmpeg's lavfi generators (nothing committed), decoded back to +// float samples, and measured here in Dart — level per channel, energy in a +// narrow band, and the lag between two signals by cross-correlation. + +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:path/path.dart' as p; +import 'package:uuid/uuid.dart'; + +import 'worker_harness.dart'; + +class AudioFixtures { + static Directory get dir => + Directory(p.join(WorkerHarness.outputDir, 'audio_fixtures'))..createSync(recursive: true); + + /// Run the bundle's ffmpeg; throws with its stderr on failure. + static Future ffmpeg(List args) async { + final r = await Process.run( + WorkerHarness.ffmpegPath, ['-hide_banner', '-nostdin', '-y', '-v', 'error', ...args], + environment: WorkerHarness.ffmpegEnv); + if (r.exitCode != 0) throw StateError('ffmpeg ${args.join(' ')}\n${r.stderr}'); + } + + /// A source with a small picture (25 fps) and the audio [filterComplex] + /// labels `[a]` (or several `[a0]`, `[a1]`… when [audioStreams] > 1). + /// Made once per name. + static Future source( + String name, { + required String filterComplex, + double seconds = 6, + int audioStreams = 1, + String audioCodec = 'flac', + }) async { + final out = p.join(dir.path, '$name.mkv'); + if (File(out).existsSync()) return out; + await ffmpeg([ + '-f', 'lavfi', '-i', 'testsrc2=s=160x120:r=25:d=$seconds', + '-filter_complex', filterComplex, + '-map', '0:v', + if (audioStreams == 1) ...['-map', '[a]'] else + for (var i = 0; i < audioStreams; i++) ...['-map', '[a$i]'], + '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-preset', 'ultrafast', + '-c:a', audioCodec, + '-t', '$seconds', + out, + ]); + return out; + } + + /// A job for [input] with [audio] as its audio restoration settings and + /// nothing done to the picture. + static Map job( + String input, + Map audio, { + String container = 'mkv', + String label = 'audio', + Map encoding = const {}, + Map extra = const {}, + Map pipeline = const {}, + }) { + final out = p.join(WorkerHarness.outputDir, 'audio_restoration', '$label.$container'); + Directory(p.dirname(out)).createSync(recursive: true); + return { + 'id': const Uuid().v4(), + 'inputPath': input, + 'outputPath': out, + 'qtgmcParameters': {'enabled': false}, + 'processingPipeline': { + 'deinterlace': {'enabled': false}, + ...pipeline, + 'audio': {'enabled': true, ...audio}, + }, + 'encodingSettings': { + 'container': container, + 'codec': 'libx264', + 'encoderPreset': 'ultrafast', + ...encoding, + }, + 'inputFrameRate': 25.0, + 'inputWidth': 160, + 'inputHeight': 120, + 'inputPixelFormat': 'yuv420p', + ...extra, + }; + } + + /// `vapourbox-worker --audio-plan` for [jobJson]: the decoded JSON. + static Future> plan(Map jobJson) async { + final cfg = File(p.join(Directory.systemTemp.path, 'vb_plan_${jobJson['id']}.json')) + ..writeAsStringSync(jsonEncode(jobJson)); + try { + final r = await Process.run(WorkerHarness.workerPath, ['--audio-plan', '--config', cfg.path], + environment: WorkerHarness.workerEnv); + final line = (r.stdout as String).trim().split('\n').last; + return jsonDecode(line) as Map; + } finally { + cfg.deleteSync(); + } + } + + /// Decode audio stream [stream] of [path] to float samples at [rate], + /// one list per channel (or downmixed to one with [mono]). + static Future> decode(String path, + {int stream = 0, int rate = 48000, bool mono = false}) async { + final probe = await WorkerHarness.ffprobeJson( + ['-v', 'error', '-of', 'json', '-select_streams', 'a:$stream', '-show_entries', 'stream=channels', path]); + final channels = mono ? 1 : ((probe['streams'] as List).first['channels'] as int); + final r = await Process.run( + WorkerHarness.ffmpegPath, + ['-v', 'error', '-nostdin', '-i', path, '-map', '0:a:$stream', '-ac', '$channels', + '-ar', '$rate', '-f', 'f32le', '-'], + stdoutEncoding: null, + environment: WorkerHarness.ffmpegEnv, + ); + if (r.exitCode != 0) throw StateError('decode $path: ${r.stderr}'); + final bytes = Uint8List.fromList(r.stdout as List); + final all = bytes.buffer.asFloat32List(0, bytes.lengthInBytes ~/ 4); + final frames = all.length ~/ channels; + return [ + for (var c = 0; c < channels; c++) + Float32List.fromList([for (var i = 0; i < frames; i++) all[i * channels + c]]), + ]; + } + + static double rmsDb(Float32List x, {int from = 0, int? to}) { + final end = to ?? x.length; + var sum = 0.0; + for (var i = from; i < end; i++) { + sum += x[i] * x[i]; + } + final rms = math.sqrt(sum / math.max(1, end - from)); + return 20 * math.log(rms + 1e-12) / math.ln10; + } + + /// Energy (dB) at [freq], by the Goertzel algorithm with a Hann window, + /// over [x] sampled at [rate]. + static double bandDb(Float32List x, int rate, double freq) { + final n = x.length; + final w = 2 * math.pi * freq / rate; + final coeff = 2 * math.cos(w); + var s1 = 0.0, s2 = 0.0; + for (var i = 0; i < n; i++) { + final win = 0.5 - 0.5 * math.cos(2 * math.pi * i / (n - 1)); + final s = x[i] * win + coeff * s1 - s2; + s2 = s1; + s1 = s; + } + final power = (s1 * s1 + s2 * s2 - coeff * s1 * s2) / (n * n); + return 10 * math.log(power + 1e-30) / math.ln10; + } + + /// Positions (seconds) of the onsets of the clicks in [x]: the first sample + /// above [threshold] after at least [gap] seconds below it. + static List onsets(Float32List x, int rate, {double threshold = 0.3, double gap = 0.1}) { + final out = []; + var last = -1e9; + for (var i = 0; i < x.length; i++) { + if (x[i].abs() > threshold) { + final t = i / rate; + if (t - last > gap) out.add(t); + last = t; + } + } + return out; + } + + /// Lag of [sig] against [ref] in samples (+ = [sig] late), by direct + /// cross-correlation within ±[maxLag]. + static int lag(Float32List ref, Float32List sig, int maxLag) { + final n = math.min(ref.length, sig.length); + var best = 0; + var bestScore = -double.infinity; + for (var l = -maxLag; l <= maxLag; l++) { + var score = 0.0; + for (var i = math.max(0, -l); i < n && i + l < n; i += 4) { + score += ref[i] * sig[i + l]; + } + if (score > bestScore) { + bestScore = score; + best = l; + } + } + return best; + } + + /// The output's duration in seconds, by its audio sample count. + static Future audioSeconds(String path, {int stream = 0}) async => + (await decode(path, stream: stream, mono: true)).first.length / 48000; +} diff --git a/worker/src/audio_restoration.rs b/worker/src/audio_restoration.rs index 3c78a230..c674bb3a 100644 --- a/worker/src/audio_restoration.rs +++ b/worker/src/audio_restoration.rs @@ -38,9 +38,15 @@ //! 5. rumble `highpass`, 6. hum notches (`bandreject` at f, 2f, 3f). //! 7. `afftdn`, then `atrim` of its delay: it puts out its input 25 ms late //! at every sample rate and strength (measured: 1200 samples at 48 kHz, -//! 1102 at 44.1, 800 at 32). -//! 8. timing — `atempo` (drift) and a silent lead (`adelay`) where a positive -//! offset reaches before the start; a negative one just reads later. +//! 1102 at 44.1, 800 at 32). Its noise floor (`nf`) is the source's, +//! measured: at its −50 dB default it took 0.7 dB off −39 dB hiss, with +//! the measured floor 13.6 dB; and its noise tracking (`tn`) undid that. +//! 8. timing — drift is corrected by resampling (`aresample` up, `asetrate` +//! to relabel by the exact ratio, `aresample` back), not `atempo`: atempo +//! moved every click ~20 ms early, and drift *is* a sample-clock error, +//! so resampling restores the original pitch as well as the length. Then +//! a silent lead (`adelay`) where a positive offset reaches before the +//! start; a negative one just reads later. //! 9. DeepFilterNet (a separate program, `-D` compensates its 30 ms delay). //! 10. `loudnorm` (two passes, linear), `aresample` back from its 192 kHz. //! 11. `alimiter` with `latency=1` (otherwise it delays by its attack time) @@ -77,7 +83,8 @@ pub const AFFTDN_DELAY_SECONDS: f64 = 0.025; /// accuracy of a container's stated durations. pub const DRIFT_MIN_SECONDS: f64 = 0.05; -/// A tempo this close to 1 is not applied: `atempo=1.0` still resamples. +/// A drift factor this close to 1 is not applied: the resample would still +/// run, for nothing. pub const TEMPO_EPSILON: f64 = 1e-6; /// Loudness normalisation's true-peak ceiling and loudness range: the @@ -85,10 +92,17 @@ pub const TEMPO_EPSILON: f64 = 1e-6; pub const LOUDNORM_TRUE_PEAK: f64 = -1.5; pub const LOUDNORM_RANGE: f64 = 11.0; -/// How much audio the automatic channel and hum checks look at, and at what -/// rate (hum and its harmonics are all under 200 Hz). +/// How much audio the automatic checks (channels, hum, noise floor) look at, +/// and the highest rate they decode it at (the noise floor needs the whole +/// band: white hiss is 8 dB quieter below 4 kHz than across 24). pub const ANALYSIS_SECONDS: f64 = 60.0; -pub const ANALYSIS_RATE: u32 = 8000; +pub const ANALYSIS_MAX_RATE: u32 = 48_000; + +/// `afftdn`'s noise floor is the quietest tenth of 50 ms windows, clamped to +/// what it accepts (-80) and to -30: a "floor" louder than that is the +/// content itself (music with no pauses), which afftdn would eat. +pub const NOISE_FLOOR_PERCENTILE: f64 = 0.10; +pub const NOISE_FLOOR_RANGE: (f64, f64) = (-80.0, -30.0); /// Extra source audio read past the end, so the filters that trim their own /// delay off the front still fill the window. @@ -146,13 +160,17 @@ pub enum DeadChannel { } /// What the automatic checks found, on a sample of the window. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Default)] pub struct Analysis { pub dead_channel: Option, /// 50 or 60 when a mains hum stands out of the spectrum. pub hum: Option, + /// The noise floor in dBFS (for `afftdn`), when measured. + pub noise_floor_db: Option, } + + /// A channel is dead when it is this far below the other… pub const DEAD_CHANNEL_GAP_DB: f64 = 30.0; /// …and the other carries something. @@ -180,6 +198,49 @@ pub fn channel_levels_db(interleaved: &[f32], channels: usize) -> Vec { .collect() } +/// The noise floor of `mono` in dBFS: the [`NOISE_FLOOR_PERCENTILE`] quietest +/// 50 ms window's RMS level, clamped to [`NOISE_FLOOR_RANGE`] (digital +/// silence reads as the bottom of the range). +pub fn noise_floor_db(mono: &[f32], rate: u32) -> f64 { + let win = (rate as usize / 20).max(1); + let mut levels: Vec = mono + .chunks_exact(win) + .map(|w| { + let rms = (w.iter().map(|v| (*v as f64).powi(2)).sum::() / win as f64).sqrt(); + 20.0 * (rms + 1e-12).log10() + }) + .collect(); + if levels.is_empty() { + return NOISE_FLOOR_RANGE.0; + } + levels.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let idx = ((levels.len() - 1) as f64 * NOISE_FLOOR_PERCENTILE).round() as usize; + levels[idx].clamp(NOISE_FLOOR_RANGE.0, NOISE_FLOOR_RANGE.1) +} + +/// The integer rates that resample by `factor` most exactly: upsample to +/// `r1`, relabel as `s` (`asetrate` takes only an integer), resample back. +/// `r1` is searched between 4× and 8× the rate, so the content is never +/// low-passed and the ratio `s / r1` is within ~1e-11 of `factor` — an +/// integer `asetrate` straight from 48 kHz would be off by up to 1e-5, 37 ms +/// over an hour. +pub fn resample_ratio(rate: u32, factor: f64) -> (u32, u32) { + let mut best = (rate, (rate as f64 * factor).round() as u32); + let mut best_err = f64::MAX; + for r1 in rate * 4..=rate * 8 { + let s = (r1 as f64 * factor).round(); + let err = (s / r1 as f64 - factor).abs(); + if err < best_err { + best_err = err; + best = (r1, s as u32); + if err < 1e-13 { + break; + } + } + } + best +} + /// Power at `freq` by the Goertzel algorithm, averaged over 2-second blocks /// (0.5 Hz resolution: mains frequency wanders by a tenth of a hertz, which a /// single minute-long transform would resolve right past). @@ -240,7 +301,7 @@ pub fn detect_hum(mono: &[f32], rate: u32) -> Option { /// What drift correction will do. #[derive(Debug, Clone, PartialEq)] pub enum DriftDecision { - /// Stretch by this tempo factor (`atempo`), audio length / picture length. + /// Resample by this factor, audio length / picture length. Apply(f64), /// Leave it, with the reason for the job log. Skip(String), @@ -270,7 +331,7 @@ pub fn drift_decision(audio_secs: f64, video_secs: f64, max_percent: f64) -> Dri )); } if (factor - 1.0).abs() < TEMPO_EPSILON { - return DriftDecision::Skip("the tempo change would be below 1e-6".into()); + return DriftDecision::Skip("the change would be below 1e-6".into()); } DriftDecision::Apply(factor) } @@ -315,7 +376,7 @@ pub struct AudioPlan { } impl AudioPlan { - /// Decide everything. `drift` is the tempo factor to apply, already + /// Decide everything. `drift` is the factor to apply, already /// vetted by [`drift_decision`]. pub fn new( p: &AudioRestorationParameters, @@ -390,13 +451,15 @@ impl AudioPlan { } } if p.denoise { - f.push(format!("afftdn=nr={}:nf=-50:tn=1", num(p.denoise_strength.clamp(0.01, 97.0)))); + let floor = analysis.noise_floor_db.unwrap_or(-50.0).round(); + f.push(format!("afftdn=nr={}:nf={}", num(p.denoise_strength.clamp(0.01, 97.0)), num(floor))); + notes.push(format!("Noise reduction: noise floor {} dB.", num(floor))); let delay = (rate as f64 * AFFTDN_DELAY_SECONDS).round() as u64; f.push(format!("atrim=start_sample={delay}")); f.push("asetpts=PTS-STARTPTS".to_string()); } - // 8. Timing. Source time for output time t is (start + t - offset) × tempo. + // 8. Timing. Source time for output time t is (start + t - offset) × factor. let tempo = drift.filter(|t| (t - 1.0).abs() >= TEMPO_EPSILON); let factor = tempo.unwrap_or(1.0); let offset = p.offset_ms as f64 / 1000.0; @@ -408,8 +471,15 @@ impl AudioPlan { (0.0, -source_start / factor) }; if let Some(t) = tempo { - f.push(format!("atempo={}", num_precise(t))); - notes.push(format!("Drift correction: tempo × {} .", num_precise(t))); + let (r1, s) = resample_ratio(rate, t); + f.push(format!("aresample={r1}")); + f.push(format!("asetrate={s}")); + f.push(format!("aresample={rate}")); + notes.push(format!( + "Drift correction: the sound is {:.3}% {} than the picture; resampled to fit.", + (t - 1.0).abs() * 100.0, + if t > 1.0 { "longer" } else { "shorter" } + )); } let lead_samples = (lead * rate as f64).round() as u64; if lead_samples > 0 { @@ -646,10 +716,16 @@ pub fn encode_map_args(settings: &EncodingSettings, track: Option<&RestoredTrack return vec!["-map".into(), "1:a?".into()]; }; let mut args = vec!["-map".into(), format!("{track_input}:a:0")]; - for k in (0..t.source_streams).filter(|k| *k != t.stream) { + let others: Vec = (0..t.source_streams).filter(|k| *k != t.stream).collect(); + for k in &others { args.extend(["-map".into(), format!("1:a:{k}")]); } + // Tags per output stream. Naming one stream's switches off ffmpeg's own + // copying for the rest, so every audio stream is named. args.extend(["-map_metadata:s:a:0".into(), format!("1:s:a:{}", t.stream)]); + for (j, k) in others.iter().enumerate() { + args.extend([format!("-map_metadata:s:a:{}", j + 1), format!("1:s:a:{k}")]); + } args } @@ -761,7 +837,8 @@ impl AudioRestorer { let window = OutputWindow::for_job(job); let needs_analysis = p.channel_fix == ChannelFix::Auto - || (p.hum_removal && p.hum_frequency == HumFrequency::Auto); + || (p.hum_removal && p.hum_frequency == HumFrequency::Auto) + || p.denoise; let analysis = if needs_analysis { self.analyse(&job.input_path, &p, &source, window).unwrap_or_else(|e| { self.log(LogLevel::Warning, &format!("Audio analysis failed ({e}); automatic choices are off.")); @@ -867,10 +944,11 @@ impl AudioRestorer { fn analyse(&self, input: &str, p: &AudioRestorationParameters, source: &SourceAudio, window: OutputWindow) -> Result { let ffmpeg = self.deps.ffmpeg_path()?; let start = window.start + window.duration.map_or(0.0, |d| (d / 2.0 - ANALYSIS_SECONDS / 2.0).max(0.0)); + let rate = source.sample_rate.clamp(8000, ANALYSIS_MAX_RATE); let out = Command::new(&ffmpeg) .args(["-v", "error", "-nostdin", "-ss", &format!("{start:.3}"), "-t", &format!("{ANALYSIS_SECONDS}"), "-i", input, "-map", &format!("0:a:{}", p.stream_index), - "-ar", &ANALYSIS_RATE.to_string(), "-f", "f32le", "-"]) + "-ar", &rate.to_string(), "-f", "f32le", "-"]) .envs(self.deps.build_environment()) .stdout(Stdio::piped()) .stderr(Stdio::null()) @@ -885,11 +963,16 @@ impl AudioRestorer { self.log(LogLevel::Debug, &format!("Audio analysis: channel levels L {:.1} dB, R {:.1} dB", levels[0], levels[1])); } let mono: Vec = samples.chunks_exact(ch).map(|f| f.iter().sum::() / ch as f32).collect(); - a.hum = detect_hum(&mono, ANALYSIS_RATE); - self.log(LogLevel::Debug, &format!( - "Audio analysis: hum prominence 50 Hz {:.1} dB, 60 Hz {:.1} dB", - hum_prominence_db(&mono, ANALYSIS_RATE, 50.0), hum_prominence_db(&mono, ANALYSIS_RATE, 60.0) - )); + if p.hum_removal && p.hum_frequency == HumFrequency::Auto { + a.hum = detect_hum(&mono, rate); + self.log(LogLevel::Debug, &format!( + "Audio analysis: hum prominence 50 Hz {:.1} dB, 60 Hz {:.1} dB", + hum_prominence_db(&mono, rate, 50.0), hum_prominence_db(&mono, rate, 60.0) + )); + } + if p.denoise { + a.noise_floor_db = Some(noise_floor_db(&mono, rate)); + } Ok(a) } @@ -1113,7 +1196,7 @@ mod tests { fn every_combination_keeps_the_order_and_one_af_per_run() { let order = [ "aresample", "pan", "adeclip", "adeclick", "highpass", "bandreject", "afftdn", "atrim", - "asetpts", "atempo", "adelay", "alimiter", "apad", + "asetpts", "asetrate", "adelay", "alimiter", "apad", ]; let rank = |n: &str| order.iter().position(|o| *o == n).unwrap_or_else(|| panic!("unexpected filter {n}")); // 9 toggles → 512 combinations, each with and without a negative offset. @@ -1139,8 +1222,11 @@ mod tests { let ranks: Vec = names(&plan.restore_filters) .iter() .enumerate() - // The trailing atrim of the length fix is the last filter. + // The trailing atrim of the length fix is the last filter; + // the first aresample is step 1, the drift resample's two + // aresamples belong with its asetrate. .filter(|(i, n)| !(n.as_str() == "atrim" && *i == plan.restore_filters.len() - 1)) + .filter(|(i, n)| !(n.as_str() == "aresample" && *i > 0)) .map(|(_, n)| rank(n)) .collect(); assert!(ranks.windows(2).all(|w| w[0] <= w[1]), "mask {mask}: {:?}", plan.restore_filters); @@ -1177,10 +1263,12 @@ mod tests { "bandreject=f=50:width_type=q:w=8", "bandreject=f=100:width_type=q:w=8", "bandreject=f=150:width_type=q:w=8", - "afftdn=nr=12:nf=-50:tn=1", + "afftdn=nr=12:nf=-50", "atrim=start_sample=1200", "asetpts=PTS-STARTPTS", - "atempo=1.001", + "aresample=192000", + "asetrate=192192", + "aresample=48000", // 120 ms delay from a start of 0 reaches before the source: lead silence. "adelay=delays=5760S:all=1", "apad=whole_len=480000", @@ -1246,17 +1334,53 @@ mod tests { let plan = AudioPlan::new(&p, &stereo48(), window(10.0, 20.0), &Analysis::default(), Some(1.004)); assert!((plan.read_start - 10.1 * 1.004).abs() < 1e-9); assert!((plan.read_duration.unwrap() - (20.0 * 1.004 + READ_MARGIN_SECONDS)).abs() < 1e-9); - assert!(plan.restore_filters.contains(&"atempo=1.004".to_string())); + let (r1, s) = resample_ratio(48_000, 1.004); + assert!(plan.restore_filters.contains(&format!("asetrate={s}"))); + assert!(plan.restore_filters.contains(&format!("aresample={r1}"))); } #[test] - fn atempo_is_skipped_within_1e6_of_one() { + fn drift_resample_is_skipped_within_1e6_of_one() { for t in [1.0, 1.0 + 5e-7, 1.0 - 9e-7] { let plan = AudioPlan::new(&on(), &stereo48(), window(0.0, 1.0), &Analysis::default(), Some(t)); - assert!(!plan.restore_filters.iter().any(|f| f.starts_with("atempo")), "{t}"); + assert!(!plan.restore_filters.iter().any(|f| f.starts_with("asetrate")), "{t}"); + assert_eq!(plan.restore_filters.iter().filter(|f| f.starts_with("aresample")).count(), 1); } let plan = AudioPlan::new(&on(), &stereo48(), window(0.0, 1.0), &Analysis::default(), Some(1.0 + 2e-6)); - assert!(plan.restore_filters.contains(&"atempo=1.000002".to_string()), "{:?}", plan.restore_filters); + assert!(plan.restore_filters.iter().any(|f| f.starts_with("asetrate")), "{:?}", plan.restore_filters); + } + + /// The relabelled rate is an integer, so the ratio is found where it can + /// be exact: within 1e-10 of the factor for any drift, never below 4×. + #[test] + fn resample_ratio_is_exact_enough_for_an_hour() { + for rate in [44_100u32, 48_000] { + for f in [1.004, 0.996, 1.000123456, 0.99987654321, 1.005] { + let (r1, s) = resample_ratio(rate, f); + assert!(r1 >= rate * 4 && r1 <= rate * 8, "{r1}"); + let err = (s as f64 / r1 as f64 - f).abs(); + // Under 0.4 ms over an hour. + assert!(err * 3600.0 < 0.0004, "{rate} {f}: {r1}->{s} err {err:e}"); + } + } + assert_eq!(resample_ratio(48_000, 1.001), (192_000, 192_192)); + } + + #[test] + fn noise_floor_is_the_quiet_tenth() { + let mut seed = 99u64; + // 9 s of -40 dB noise with 1 s bursts of a loud tone every 2 s. + let x = synth(48_000, 9.0, |t| { + let n = 0.01 * 3f64.sqrt() * noise(&mut seed); + if (t as u64) % 2 == 0 && t.fract() < 0.5 { n + 0.5 * (1000.0 * t).sin() } else { n } + }); + let floor = noise_floor_db(&x, 48_000); + assert!((floor - (-40.0)).abs() < 1.0, "{floor}"); + assert_eq!(noise_floor_db(&vec![0.0; 48_000], 48_000), NOISE_FLOOR_RANGE.0); + let p = AudioRestorationParameters { denoise: true, ..on() }; + let a = Analysis { noise_floor_db: Some(-41.4), ..Default::default() }; + let plan = AudioPlan::new(&p, &stereo48(), window(0.0, 1.0), &a, None); + assert!(plan.restore_filters.contains(&"afftdn=nr=12:nf=-41".to_string()), "{:?}", plan.restore_filters); } #[test] @@ -1275,7 +1399,7 @@ mod tests { #[test] fn channel_fix_auto_uses_the_analysis() { let p = AudioRestorationParameters { channel_fix: ChannelFix::Auto, ..on() }; - let with = |dead| AudioPlan::new(&p, &stereo48(), window(0.0, 1.0), &Analysis { dead_channel: dead, hum: None }, None); + let with = |dead| AudioPlan::new(&p, &stereo48(), window(0.0, 1.0), &Analysis { dead_channel: dead, ..Default::default() }, None); assert!(with(Some(DeadChannel::Right)).restore_filters.contains(&"pan=stereo|c0=c0|c1=c0".to_string())); assert!(with(Some(DeadChannel::Left)).restore_filters.contains(&"pan=stereo|c0=c1|c1=c1".to_string())); assert!(!with(None).restore_filters.iter().any(|f| f.starts_with("pan"))); @@ -1298,7 +1422,7 @@ mod tests { #[test] fn hum_auto_notches_only_what_was_found() { let p = AudioRestorationParameters { hum_removal: true, ..on() }; - let plan = |hum| AudioPlan::new(&p, &stereo48(), window(0.0, 1.0), &Analysis { dead_channel: None, hum }, None); + let plan = |hum| AudioPlan::new(&p, &stereo48(), window(0.0, 1.0), &Analysis { hum, ..Default::default() }, None); let f60 = plan(Some(60)).restore_filters; assert!(f60.iter().any(|f| f == "bandreject=f=180:width_type=q:w=8")); assert!(!f60.iter().any(|f| f.contains("f=50:"))); @@ -1433,7 +1557,11 @@ mod tests { let t = RestoredTrack { path: "r.wav".into(), stream: 1, source_streams: 3 }; assert_eq!( encode_map_args(&s, Some(&t), 3), - vec!["-map", "3:a:0", "-map", "1:a:0", "-map", "1:a:2", "-map_metadata:s:a:0", "1:s:a:1"] + vec![ + "-map", "3:a:0", "-map", "1:a:0", "-map", "1:a:2", + "-map_metadata:s:a:0", "1:s:a:1", "-map_metadata:s:a:1", "1:s:a:0", + "-map_metadata:s:a:2", "1:s:a:2", + ] ); assert!(encode_map_args(&settings(AudioMode::None, ContainerFormat::Mkv), Some(&t), 3).is_empty()); assert_eq!(encode_input_args(Some(&t)), vec!["-i", "r.wav"]); From 335ef7565504fbcfdd0e6a8afc69faf5df002058 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 8 Nov 2026 18:35:14 +1100 Subject: [PATCH 14/61] fix(upscale): never give VSNCNN a tile larger than the frame; test the add-on models A tile larger than the frame in either direction segfaults VSNCNN. The template tiled whenever the area passed its threshold, so a wide, short picture (800x200 with x4plus's 384 tiles, or any frame above 720x576 in area but under 512 lines) crashed. Tiles are now min(tile, width/height). Tests: script generation for AI strength and the upscale-general models in the push gate (GPU rule; installs the add-on with the app's installer), and in the heavy suite every add-on model at 2x and 4x by pixels against Spline36 (size, count, preview = encode, flat colour within measured bounds, bar centre within 0.75 px), AI strength 0.5 landing half-way, and x4plus on an 800x200 source. Co-Authored-By: Claude Opus 5.5 --- app/test/integration_ai_upscale_test.dart | 111 ++++++++++++++++-- .../integration_filter_parameters_test.dart | 67 +++++++++++ worker/templates/pipeline_template.vpy | 5 +- worker/templates/preview_template.vpy | 5 +- worker/tests/filter_integration_test.rs | 4 +- 5 files changed, 179 insertions(+), 13 deletions(-) diff --git a/app/test/integration_ai_upscale_test.dart b/app/test/integration_ai_upscale_test.dart index 74ee4d10..d159c857 100644 --- a/app/test/integration_ai_upscale_test.dart +++ b/app/test/integration_ai_upscale_test.dart @@ -1,6 +1,9 @@ -// Real-ESRGAN / Real-CUGAN / AnimeJaNai upscaling, encoded end to end and -// checked by pixels. AnimeJaNai's models are an add-on, which the harness -// installs from the upstream download the app pins. +// Real-ESRGAN / Real-CUGAN / AnimeJaNai upscaling, and the add-on models +// (Real-ESRGAN General, Live Action SPAN, Real-ESRGAN x4plus/x2plus), encoded +// end to end and checked by pixels. AnimeJaNai's models are an add-on, which +// the harness installs from the upstream download the app pins; the others +// install through WorkerHarness.ensureAddon (VAPOURBOX_ADDON_MIRROR before +// their files are uploaded). // // Wherever AI upscaling ships, CI gives the runner a Vulkan device (a software // one, lavapipe, on the GPU-less Linux and Windows runners) and sets @@ -29,7 +32,13 @@ // own wrapper doing nothing more than this template — the model, not the // pipeline) and CUGAN 4x drifts ~4, so those get bounds just above that. // AnimeJaNai keeps flat colour within half a level at 2x and 4x (measured -// 0.3 and 0.5), so it gets the strictest bound; +// 0.3 and 0.5), so it gets the strictest bound. The add-on models +// (1.7.0, measured on an M1 Pro): Real-ESRGAN General drifts up to 2.9 +// levels (the same at 2x, where its 4x result is resampled down), Live +// Action SPAN 0.6 at 2x and 1.3 at 4x, Real-ESRGAN x2plus 4.2 and x4plus +// 12.2 — each bounded just above; +// * AI strength 0.5 lands half-way between the network and Spline36 (the +// tint halves: 11.2 -> 5.8 levels for Real-ESRGAN 4x); // * the preview of a frame is the encode's frame (the same script runs); // * a 10-bit source stays 10-bit and matches the 8-bit result; // * AnimeJaNai picks its SD model for an SD picture (where the HD size @@ -49,6 +58,7 @@ import 'package:vapourbox/models/encoding_settings.dart'; import 'package:vapourbox/models/processing_pipeline.dart'; import 'package:vapourbox/models/qtgmc_parameters.dart'; import 'package:vapourbox/models/video_job.dart'; +import 'package:vapourbox/services/image_dimensions.dart'; import 'support/gpu_expectations.dart'; import 'support/worker_harness.dart'; @@ -63,7 +73,9 @@ VideoJob _job(String name, UpscaleMethod method, int factor, {String noise = 'conservative', String? input, String pixFmt = 'yuv420p', - String animejanaiModel = 'compact'}) => + String animejanaiModel = 'compact', + bool generalDenoise = false, + double aiStrength = 1.0}) => VideoJob( id: const Uuid().v4(), inputPath: input ?? _input, @@ -77,6 +89,8 @@ VideoJob _job(String name, UpscaleMethod method, int factor, upscaleFactor: factor, cuganNoise: noise, animejanaiModel: animejanaiModel, + generalDenoise: generalDenoise, + aiStrength: aiStrength, ), ), encodingSettings: const EncodingSettings( @@ -161,12 +175,30 @@ void main() { (UpscaleMethod.realCugan, 4, 'conservative'), (UpscaleMethod.animeJanai, 2, ''), (UpscaleMethod.animeJanai, 4, ''), + // The add-on models (1.7.0): General is 4x native (2x resamples down), + // SPAN 2x native (4x runs it twice), x2plus/x4plus by factor. + (UpscaleMethod.realEsrganGeneral, 2, ''), + (UpscaleMethod.realEsrganGeneral, 4, ''), + (UpscaleMethod.liveAction, 2, ''), + (UpscaleMethod.liveAction, 4, ''), + (UpscaleMethod.realEsrganPlus, 2, ''), + (UpscaleMethod.realEsrganPlus, 4, ''), ]; - /// The rule, plus AnimeJaNai's add-on where the method needs it. + /// The rule, plus the add-on where the method needs one: AnimeJaNai's from + /// upstream, the others through the app's manifest installer (which honours + /// VAPOURBOX_ADDON_MIRROR before the files are uploaded). Future ready(UpscaleMethod method) async { if (!await requireGpuPlugin('--probe-upscale')) return false; - if (method == UpscaleMethod.animeJanai) await WorkerHarness.ensureAnimeJanai(); + switch (method) { + case UpscaleMethod.animeJanai: + await WorkerHarness.ensureAnimeJanai(); + case UpscaleMethod.realEsrganGeneral || UpscaleMethod.liveAction: + await WorkerHarness.ensureAddon('upscale-general'); + case UpscaleMethod.realEsrganPlus: + await WorkerHarness.ensureAddon('upscale-esrgan-plus'); + default: + } return true; } @@ -242,8 +274,8 @@ void main() { expect(r.exitCode, 0, reason: '${r.stderr}'); }); - VideoJob patternJob(String name, UpscaleMethod method, int factor) { - final j = _job(name, method, factor, input: pattern); + VideoJob patternJob(String name, UpscaleMethod method, int factor, {double aiStrength = 1.0}) { + final j = _job(name, method, factor, input: pattern, aiStrength: aiStrength); return VideoJob.fromJson({ ...j.toJson(), 'inputFrameRate': 25.0, @@ -303,6 +335,9 @@ void main() { (UpscaleMethod.realEsrgan, 2, 6.0), (UpscaleMethod.realEsrgan, 4, 14.0), (UpscaleMethod.realCugan, 2, 2.0), (UpscaleMethod.realCugan, 4, 6.0), (UpscaleMethod.animeJanai, 2, 1.0), (UpscaleMethod.animeJanai, 4, 1.0), + (UpscaleMethod.realEsrganGeneral, 2, 4.0), (UpscaleMethod.realEsrganGeneral, 4, 4.0), + (UpscaleMethod.liveAction, 2, 1.5), (UpscaleMethod.liveAction, 4, 2.0), + (UpscaleMethod.realEsrganPlus, 2, 6.0), (UpscaleMethod.realEsrganPlus, 4, 14.0), ]) { test('${method.name} ${factor}x keeps colour and position', () async { if (!await ready(method)) return; @@ -342,8 +377,66 @@ void main() { expect(((rt - l) - (rr - rl)).abs(), lessThan(4.0), reason: 'the bar changed size'); }, timeout: const Timeout(Duration(minutes: 60))); } + + // AI strength (1.7.0) blends the network's picture with a Spline36 + // upscale: at 0.5 every pixel is half-way between the two (to rounding + // and dither), so Real-ESRGAN 4x's measured flat-colour tint halves. + test('AI strength 0.5 is half-way between the network and Spline36', () async { + if (!await ready(UpscaleMethod.realEsrgan)) return; + Future frame(String name, UpscaleMethod m, {double strength = 1.0}) async { + final job = patternJob(name, m, 4, aiStrength: strength); + return WorkerHarness.frameRgb24(await _encode(job, name), 1, label: '${name}_1'); + } + + final full = await frame('strength_esrgan_full', UpscaleMethod.realEsrgan); + final half = await frame('strength_esrgan_half', UpscaleMethod.realEsrgan, strength: 0.5); + final spline = await frame('strength_spline36', UpscaleMethod.spline36); + final mid = Uint8List(full.length); + for (var i = 0; i < full.length; i++) { + mid[i] = ((full[i] + spline[i]) / 2).round(); + } + final vsMid = WorkerHarness.meanAbsDiff(half, mid); + final w = _w * 4, inset = 48, size = patch * 4 - 2 * inset; + var worstFull = 0.0, worstHalf = 0.0; + for (final MapEntry(value: (x, y)) in patches.entries) { + final s = meanRgb(spline, w, x * 4 + inset, y * 4 + inset, size); + final f = meanRgb(full, w, x * 4 + inset, y * 4 + inset, size); + final h = meanRgb(half, w, x * 4 + inset, y * 4 + inset, size); + for (var c = 0; c < 3; c++) { + worstFull = (f[c] - s[c]).abs() > worstFull ? (f[c] - s[c]).abs() : worstFull; + worstHalf = (h[c] - s[c]).abs() > worstHalf ? (h[c] - s[c]).abs() : worstHalf; + } + } + // ignore: avoid_print + print(' strength 0.5 vs the mid-point: meanAbsDiff ${vsMid.toStringAsFixed(2)}; ' + 'worst patch drift full ${worstFull.toStringAsFixed(2)}, half ${worstHalf.toStringAsFixed(2)}'); + expect(vsMid, lessThan(1.0), reason: 'strength 0.5 is not the average of the two'); + expect(WorkerHarness.meanAbsDiff(half, full), greaterThan(0.5), reason: 'the blend did nothing'); + expect((worstHalf - worstFull / 2).abs(), lessThan(1.0), reason: 'the tint did not halve'); + }, timeout: const Timeout(Duration(minutes: 60))); }); + // A tile larger than the frame in either direction segfaults VSNCNN. An + // 800x200 picture passes x4plus's area test for tiling (above 384x384) while + // being shorter than a 384-pixel tile, so the tile must shrink to fit. + test('a wide, short frame runs in tiles no larger than itself', () async { + if (!await ready(UpscaleMethod.realEsrganPlus)) return; + final wide = p.join(_outDir, 'source_800x200.mkv'); + final made = await Process.run( + WorkerHarness.ffmpegPath, + ['-y', '-v', 'error', '-i', _input, '-frames:v', '110', '-vf', 'scale=800:200', + '-c:v', 'ffv1', wide], + environment: WorkerHarness.ffmpegEnv, + ); + expect(made.exitCode, 0, reason: '${made.stderr}'); + final j = _job('x4plus_wide', UpscaleMethod.realEsrganPlus, 4, input: wide); + final job = VideoJob.fromJson({...j.toJson(), 'inputWidth': 800, 'inputHeight': 200}); + final pv = await _preview(job.toJson(), _start + 2, 'x4plus_wide'); + expect(pv.success, isTrue, reason: pv.errorTail); + final size = encodedImageSize(pv.png!); + expect((size?.width, size?.height), (3200.0, 800.0)); + }, timeout: const Timeout(Duration(minutes: 60))); + test('a 10-bit source stays 10-bit and matches the 8-bit result', () async { if (!await requireGpuPlugin('--probe-upscale')) return; // Both fixtures from the same conversion, so frame N is the same picture diff --git a/app/test/integration_filter_parameters_test.dart b/app/test/integration_filter_parameters_test.dart index d961387d..e79ecdcd 100644 --- a/app/test/integration_filter_parameters_test.dart +++ b/app/test/integration_filter_parameters_test.dart @@ -35,6 +35,7 @@ import 'package:vapourbox/models/spotless_parameters.dart'; import 'package:vapourbox/models/stabilize_parameters.dart'; import 'package:vapourbox/models/video_job.dart'; +import 'support/gpu_expectations.dart'; import 'support/vfr_fixture.dart'; import 'support/worker_harness.dart'; @@ -1705,5 +1706,71 @@ void main() { expect(script, isNot(contains('{{#DEINT'))); print(' PASS'); }, timeout: const Timeout(Duration(minutes: 2))); + + // --- AI upscale strength (1.7.0) --- + // Below 1 the network's result is blended with a Spline36 upscale of the + // same frame in one std.Merge; at 1 nothing is added. Needs VSNCNN to run + // here (the worker refuses an AI job it cannot run), so it follows the + // GPU rule: skipped without Vulkan unless VAPOURBOX_EXPECT_VULKAN=1. + test('crop_resize: AI strength blends a Spline36 upscale into the AI result', () async { + if (!await requireGpuPlugin('--probe-upscale')) return; + CropResizeParameters crop(double strength) => ParameterConverter.toCropResize( + ParameterConverter.fromCropResize(CropResizeParameters( + enabled: true, + useIntegerUpscale: true, + upscaleMethod: UpscaleMethod.realEsrgan, + aiStrength: strength))); + final blended = await generateScriptViaWorker( + buildJob(testName: 'ai_strength', cropResize: crop(0.7))); + expect(blended, contains('core.ncnn.Model(_ai_rgb, network_path=_ai_model')); + expect(blended, contains('clip = core.std.Merge(core.resize.Spline36(_ai_src, ' + 'width=clip.width, height=clip.height), clip, weight=0.700)')); + final full = await generateScriptViaWorker( + buildJob(testName: 'ai_strength_full', cropResize: crop(1.0))); + expect(full, isNot(contains('core.std.Merge(core.resize.Spline36(_ai_src'))); + expect(full, isNot(contains('AI_STRENGTH'))); + }, timeout: const Timeout(Duration(minutes: 2))); + + // --- Add-on AI upscalers (1.7.0) --- + // Real-ESRGAN General (4x only: resampled to 2x after the network, the + // denoise variant on request) and Live Action SPAN (2x only: twice for + // 4x), each from the upscale-general add-on's directory. Installs the + // add-on (11 MB) with the app's own installer, so a dead link or changed + // hash fails here. + test('crop_resize: add-on upscalers load their models with their factor rules', () async { + if (!await requireGpuPlugin('--probe-upscale')) return; + await WorkerHarness.ensureAddon('upscale-general'); + // The models the script runs, by file name, each checked to come from + // the add-on's own directory. + List models(String script) { + final line = RegExp(r'for _ai_model in \[(.*)\]:').firstMatch(script)!.group(1)!; + final names = []; + for (final m in RegExp(r"'([^']*)'").allMatches(line)) { + final path = m.group(1)!.replaceAll(r'\\', '/'); + expect(path, contains('upscale-general/'), reason: path); + names.add(path.split('/').last); + } + return names; + } + + Future script(UpscaleMethod m, int factor, {bool denoise = false}) => + generateScriptViaWorker(buildJob( + testName: 'addon_upscale_${m.name}_$factor', + cropResize: CropResizeParameters( + enabled: true, + useIntegerUpscale: true, + upscaleMethod: m, + upscaleFactor: factor, + generalDenoise: denoise))); + final general2 = await script(UpscaleMethod.realEsrganGeneral, 2); + expect(models(general2), ['upscale-realesr-general-x4v3.onnx']); + expect(general2, contains('_ai_rgb = core.resize.Spline36(_ai_rgb, width=_ai_rgb.width * 2 // _ai_scale')); + final general4 = await script(UpscaleMethod.realEsrganGeneral, 4, denoise: true); + expect(models(general4), ['upscale-realesr-general-wdn-x4v3.onnx']); + expect(general4, isNot(contains('// _ai_scale'))); + final span4 = await script(UpscaleMethod.liveAction, 4); + expect(models(span4), List.filled(2, '2xLiveActionV1_SPAN_490000.onnx')); + expect(span4, isNot(contains('// _ai_scale'))); + }, timeout: const Timeout(Duration(minutes: 5))); }); } diff --git a/worker/templates/pipeline_template.vpy b/worker/templates/pipeline_template.vpy index 2add8d4b..58ab2270 100644 --- a/worker/templates/pipeline_template.vpy +++ b/worker/templates/pipeline_template.vpy @@ -2095,7 +2095,10 @@ for _ai_model in [{{AI_MODELS}}]: # AnimeJaNai: its SD model for a picture at most {{AI_SD_MAX_HEIGHT}} tall, else # the HD one (trained on 1080p anime), chosen per doubling. _ai_model = _ai_model[0] if _ai_rgb.height <= {{AI_SD_MAX_HEIGHT}} else _ai_model[1] - _ai_tiles = {} if _ai_rgb.width * _ai_rgb.height <= {{AI_TILE_ABOVE}} else dict(tilesize=[{{AI_TILE}}, {{AI_TILE}}], overlap=[16, 16]) + # A tile larger than the frame in either direction segfaults VSNCNN (a + # wide, short frame can pass the area test), so it never exceeds the frame. + _ai_tiles = {} if _ai_rgb.width * _ai_rgb.height <= {{AI_TILE_ABOVE}} else dict( + tilesize=[min({{AI_TILE}}, _ai_rgb.width), min({{AI_TILE}}, _ai_rgb.height)], overlap=[16, 16]) _ai_rgb = core.ncnn.Model(_ai_rgb, network_path=_ai_model, fp16=True, num_streams=2, **_ai_tiles) _ai_scale = _ai_rgb.width // _ai_in_w if _ai_pad_w or _ai_pad_h: diff --git a/worker/templates/preview_template.vpy b/worker/templates/preview_template.vpy index bfe4643d..b47907c4 100644 --- a/worker/templates/preview_template.vpy +++ b/worker/templates/preview_template.vpy @@ -2027,7 +2027,10 @@ for _ai_model in [{{AI_MODELS}}]: # AnimeJaNai: its SD model for a picture at most {{AI_SD_MAX_HEIGHT}} tall, else # the HD one (trained on 1080p anime), chosen per doubling. _ai_model = _ai_model[0] if _ai_rgb.height <= {{AI_SD_MAX_HEIGHT}} else _ai_model[1] - _ai_tiles = {} if _ai_rgb.width * _ai_rgb.height <= {{AI_TILE_ABOVE}} else dict(tilesize=[{{AI_TILE}}, {{AI_TILE}}], overlap=[16, 16]) + # A tile larger than the frame in either direction segfaults VSNCNN (a + # wide, short frame can pass the area test), so it never exceeds the frame. + _ai_tiles = {} if _ai_rgb.width * _ai_rgb.height <= {{AI_TILE_ABOVE}} else dict( + tilesize=[min({{AI_TILE}}, _ai_rgb.width), min({{AI_TILE}}, _ai_rgb.height)], overlap=[16, 16]) _ai_rgb = core.ncnn.Model(_ai_rgb, network_path=_ai_model, fp16=True, num_streams=2, **_ai_tiles) _ai_scale = _ai_rgb.width // _ai_in_w if _ai_pad_w or _ai_pad_h: diff --git a/worker/tests/filter_integration_test.rs b/worker/tests/filter_integration_test.rs index 6ee1f267..b20f8c89 100644 --- a/worker/tests/filter_integration_test.rs +++ b/worker/tests/filter_integration_test.rs @@ -6944,9 +6944,9 @@ fn test_185_upscale_addon_models_and_factors() { None => assert!(!script.contains("// _ai_scale"), "{label} {name}: stray resample"), } let tiles = if method == UpscaleMethod::RealEsrganPlus && factor == 4 { - "<= 384 * 384 else dict(tilesize=[384, 384]" + "<= 384 * 384 else dict(\n tilesize=[min(384, _ai_rgb.width), min(384, _ai_rgb.height)]" } else { - "<= 720 * 576 else dict(tilesize=[512, 512]" + "<= 720 * 576 else dict(\n tilesize=[min(512, _ai_rgb.width), min(512, _ai_rgb.height)]" }; assert!(script.contains(tiles), "{label} {name}: tiles"); assert!(!script.contains("AI_RESAMPLE") && !script.contains("AI_STRENGTH") && !script.contains("{{AI_"), "{label} {name}: unsubstituted placeholder"); From 03b35b2421ee7ace001dcae373c518ff803766eb Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 8 Nov 2026 18:38:41 +1100 Subject: [PATCH 15/61] feat(face-restore): convert RetinaFace, BiSeNet, RestoreFormer++, GFPGAN and CodeFormer to ONNX Conversion scripts for the face restoration add-ons: each pins the upstream checkpoint by URL and SHA-256 and the network source by commit, exports static-shape opset 17 / IR 8 graphs (loadable by ONNX Runtime 1.23.2), and records parity with PyTorch. GFPGAN exports with its fixed noise buffers (randomize_noise=False) so it is a pure function of its input; CodeFormer's fidelity w is a graph input (exact at w=0, where the fused branch adds zero). Co-Authored-By: Claude Opus 5.5 --- Scripts/models/face_models_common.py | 193 +++++++++++++++++++++++ Scripts/models/face_provider_bench.py | 79 ++++++++++ Scripts/models/make_face_codeformer.py | 128 +++++++++++++++ Scripts/models/make_face_gfpgan.py | 100 ++++++++++++ Scripts/models/make_face_restoration.py | 201 ++++++++++++++++++++++++ 5 files changed, 701 insertions(+) create mode 100644 Scripts/models/face_models_common.py create mode 100644 Scripts/models/face_provider_bench.py create mode 100644 Scripts/models/make_face_codeformer.py create mode 100644 Scripts/models/make_face_gfpgan.py create mode 100644 Scripts/models/make_face_restoration.py diff --git a/Scripts/models/face_models_common.py b/Scripts/models/face_models_common.py new file mode 100644 index 00000000..50936202 --- /dev/null +++ b/Scripts/models/face_models_common.py @@ -0,0 +1,193 @@ +"""Shared plumbing for the face-restoration model conversions +(make_face_restoration.py, make_face_gfpgan.py, make_face_codeformer.py). + +Each conversion fetches an upstream PyTorch checkpoint (pinned by URL and +SHA-256) and the upstream source that defines its network (pinned by commit), +exports it to ONNX with the TorchScript exporter at a fixed opset and IR +version, and checks the ONNX model against PyTorch on the same inputs. + +Opset 17 / IR 8 keeps every model loadable by the oldest ONNX Runtime the +ai-runtime add-on pins (1.23.2, the last with an Intel macOS wheel). + +Run in the conversion venv (docs/ADDONS.md). The work directory caches the +downloads; it defaults to addon-staging/face-work at the repo root +(FACE_MODELS_WORK overrides it, VAPOURBOX_ADDON_STAGING the staging directory). +""" + +import hashlib +import io +import json +import os +import sys +import tarfile +import time +import types +import urllib.request + +import numpy as np + +OPSET = 17 +IR_VERSION = 8 + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +STAGING = os.environ.get("VAPOURBOX_ADDON_STAGING") or os.path.join(REPO_ROOT, "addon-staging") + + +def work_dir(): + d = os.environ.get("FACE_MODELS_WORK") or os.path.join(STAGING, "face-work") + os.makedirs(d, exist_ok=True) + return d + + +def sha256_of(path): + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +def fetch(url, sha256): + """Download `url` into the work directory's ckpt/ (once) and check its hash.""" + dest_dir = os.path.join(work_dir(), "ckpt") + os.makedirs(dest_dir, exist_ok=True) + dest = os.path.join(dest_dir, url.rsplit("/", 1)[1]) + if not os.path.exists(dest): + print(f"downloading {url}") + tmp = dest + ".partial" + urllib.request.urlretrieve(url, tmp) + os.replace(tmp, dest) + got = sha256_of(dest) + if got != sha256: + raise SystemExit(f"{dest}: SHA-256 {got}, expected {sha256}") + return dest + + +def upstream_source(repo, commit): + """Unpack github.com/ at `commit` into the work directory's src/ and + return its path. Only the Python files are needed (the network classes).""" + name = repo.split("/")[1] + dest = os.path.join(work_dir(), "src", f"{name}-{commit[:12]}") + if not os.path.isdir(dest): + url = f"https://codeload.github.com/{repo}/tar.gz/{commit}" + print(f"downloading {url}") + data = urllib.request.urlopen(url).read() + os.makedirs(dest + ".partial", exist_ok=True) + with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar: + for m in tar.getmembers(): + parts = m.name.split("/", 1) + if len(parts) < 2 or not m.isfile() or not m.name.endswith(".py"): + continue + m.name = parts[1] + tar.extract(m, dest + ".partial", filter="data") + os.replace(dest + ".partial", dest) + return dest + + +def stub_module(name, **attrs): + """Insert an empty module (and its parents) into sys.modules, so upstream + code that imports training-only helpers can be imported for its network + classes alone.""" + parts = name.split(".") + for i in range(1, len(parts) + 1): + sub = ".".join(parts[:i]) + if sub not in sys.modules: + mod = types.ModuleType(sub) + mod.__path__ = [] + sys.modules[sub] = mod + if i > 1: + setattr(sys.modules[".".join(parts[: i - 1])], parts[i - 1], mod) + for k, v in attrs.items(): + setattr(sys.modules[name], k, v) + return sys.modules[name] + + +class Registry: + """Stand-in for basicsr's ARCH_REGISTRY: register() is a no-op decorator.""" + + def register(self, *args, **kwargs): + if args and callable(args[0]): + return args[0] + return lambda cls: cls + + +def load_checkpoint(path): + import torch + + return torch.load(path, map_location="cpu", weights_only=True) + + +def export(model, inputs, input_names, output_names, out_path, dynamic_axes=None): + """Export `model` (in eval mode) with the TorchScript exporter at OPSET, + stamp IR_VERSION, check it, and return the file's size and SHA-256.""" + import onnx + import torch + + model.eval() + tmp = out_path + ".tmp" + with torch.no_grad(): + torch.onnx.export( + model, + tuple(inputs), + tmp, + input_names=input_names, + output_names=output_names, + opset_version=OPSET, + dynamic_axes=dynamic_axes, + do_constant_folding=True, + dynamo=False, + ) + m = onnx.load(tmp) + m.ir_version = IR_VERSION + m.producer_name = "vapourbox" + onnx.checker.check_model(m) + onnx.save(m, out_path) + os.remove(tmp) + return {"file": os.path.basename(out_path), "size": os.path.getsize(out_path), "sha256": sha256_of(out_path)} + + +def parity(model, onnx_path, feeds, providers=("CPUExecutionProvider",)): + """Max and mean absolute difference between PyTorch and ONNX Runtime on + the same inputs, per output, and the time ONNX Runtime took.""" + import onnxruntime as ort + import torch + + with torch.no_grad(): + ref = model(*[torch.from_numpy(v) for v in feeds.values()]) + if not isinstance(ref, (tuple, list)): + ref = [ref] + ref = [r.numpy() for r in ref if isinstance(r, torch.Tensor)] + sess = ort.InferenceSession(onnx_path, providers=list(providers)) + t0 = time.perf_counter() + got = sess.run(None, feeds) + dt = time.perf_counter() - t0 + out = [] + for name, a, b in zip([o.name for o in sess.get_outputs()], ref, got): + d = np.abs(a.astype(np.float64) - b.astype(np.float64)) + out.append({"output": name, "max": float(d.max()), "mean": float(d.mean())}) + return {"providers": list(providers), "seconds": dt, "outputs": out} + + +def write_record(name, record): + """Write the conversion record (sizes, hashes, parity) beside the models.""" + path = os.path.join(STAGING, f"{name}.conversion.json") + with open(path, "w") as f: + json.dump(record, f, indent=2) + print(json.dumps(record, indent=2)) + return path + + +def face_test_input(seed=0): + """A deterministic face-like 512x512 test input in [-1, 1]: smooth blobs + plus noise, so the networks see structure rather than pure noise.""" + rng = np.random.default_rng(seed) + y, x = np.mgrid[0:512, 0:512].astype(np.float32) / 512.0 + img = np.stack( + [ + 0.6 * np.exp(-((x - 0.5) ** 2 + (y - 0.5) ** 2) / 0.08) - 0.2, + 0.5 * np.exp(-((x - 0.5) ** 2 + (y - 0.45) ** 2) / 0.06) - 0.3, + 0.4 * np.exp(-((x - 0.5) ** 2 + (y - 0.55) ** 2) / 0.1) - 0.35, + ] + ) + img += rng.normal(0, 0.05, img.shape).astype(np.float32) + return np.clip(img, -1, 1)[None].astype(np.float32) diff --git a/Scripts/models/face_provider_bench.py b/Scripts/models/face_provider_bench.py new file mode 100644 index 00000000..d3d8d405 --- /dev/null +++ b/Scripts/models/face_provider_bench.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Time each face-restoration ONNX model on every execution provider this +machine's onnxruntime offers, and compare each provider's output with the +CPU's. Used for the provider table in docs/reports/issue-face-restoration. + + python Scripts/models/face_provider_bench.py [runs] + +Session options mirror worker/templates/ai_runtime.py (Core ML as an +MLProgram on all compute units). +""" + +import json +import os +import sys +import time + +import numpy as np +import onnxruntime as ort + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from face_models_common import face_test_input # noqa: E402 + +MODELS = { + "face-restoration-retinaface-r50.onnx": lambda: {"input": det_input()}, + "face-restoration-retinaface-mnet.onnx": lambda: {"input": det_input()}, + "face-restoration-bisenet.onnx": lambda: {"input": face_test_input()}, + "face-restoration-restoreformerpp.onnx": lambda: {"input": face_test_input()}, + "face-gfpgan-v1.4.onnx": lambda: {"input": face_test_input()}, + "face-codeformer-v0.1.0.onnx": lambda: {"input": face_test_input(), "w": np.array([0.7], np.float32)}, +} +OPTIONS = {"CoreMLExecutionProvider": {"ModelFormat": "MLProgram", "MLComputeUnits": "ALL"}} + + +def det_input(): + rng = np.random.default_rng(1) + return (rng.uniform(0, 255, (1, 3, 640, 640)) - 117.0).astype(np.float32) + + +def session(path, provider): + opts = ort.SessionOptions() + opts.log_severity_level = 3 + providers = [(provider, OPTIONS[provider]) if provider in OPTIONS else provider] + if provider != "CPUExecutionProvider": + providers.append("CPUExecutionProvider") + return ort.InferenceSession(path, sess_options=opts, providers=providers) + + +def main(): + staging = sys.argv[1] + runs = int(sys.argv[2]) if len(sys.argv) > 2 else 5 + providers = [p for p in ort.get_available_providers() if p in ("CoreMLExecutionProvider", "CPUExecutionProvider")] + results = [] + for name, feeds in MODELS.items(): + path = os.path.join(staging, name) + if not os.path.exists(path): + continue + f = feeds() + cpu_out = None + for p in sorted(providers, key=lambda p: p != "CPUExecutionProvider"): + t0 = time.perf_counter() + s = session(path, p) + created = time.perf_counter() - t0 + out = s.run(None, f) # warm-up + t0 = time.perf_counter() + for _ in range(runs): + out = s.run(None, f) + per = (time.perf_counter() - t0) / runs + row = {"model": name, "provider": p, "sessionSeconds": round(created, 2), "msPerRun": round(per * 1000, 1)} + if p == "CPUExecutionProvider": + cpu_out = out + else: + row["maxDiffVsCpu"] = float(max(np.abs(a - b).max() for a, b in zip(out, cpu_out))) + print(json.dumps(row), flush=True) + results.append(row) + return results + + +if __name__ == "__main__": + main() diff --git a/Scripts/models/make_face_codeformer.py b/Scripts/models/make_face_codeformer.py new file mode 100644 index 00000000..efb45e5f --- /dev/null +++ b/Scripts/models/make_face_codeformer.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Convert CodeFormer (sczhou/CodeFormer, release v0.1.0's codeformer.pth) to +face-codeformer-v0.1.0.onnx for the face-codeformer add-on. + +Inputs: `input`, (1, 3, 512, 512) RGB in [-1, 1]; `w`, shape (1,), the +fidelity weight (0 = the codebook's best guess, 1 = closest to the input). +Output: `output`, (1, 3, 512, 512) RGB in [-1, 1]. float32, static shapes. +Upstream's inference runs with adain=True, as this does. + +Why `w` is an input rather than a few fixed exports: CodeFormer uses w in one +place, each fusion block's `out = dec + w * (dec * scale + shift)`, behind an +`if w > 0`. The export traces the w > 0 branch, and at w = 0 that branch adds +exactly zero, so one graph is the network at every w — where fixed exports +would mean a ~377 MB file per offered value. The parity record checks w = 0, +0.5, 0.7 and 1 against PyTorch (which skips the fusion at w = 0). + +Licence: S-Lab License 1.0 — redistribution for non-commercial purposes only, +with the copyright notice. The add-on carries that as its usageNote. + +Usage (conversion venv, see docs/ADDONS.md): + + VAPOURBOX_ADDON_STAGING=/path/to/addon-staging python Scripts/models/make_face_codeformer.py +""" + +import logging +import os +import sys + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import face_models_common as fm # noqa: E402 + +CODEFORMER = ("sczhou/CodeFormer", "b33cc7d639d6545bfcccc7e0bc6ae51f24e79c2b") +CKPT = ( + "https://github.com/sczhou/CodeFormer/releases/download/v0.1.0/codeformer.pth", + "1009e537e0c2a07d4cabce6355f53cb66767cd4b4297ec7a4a64ca4b8a5684b7", +) +W_CHECKS = [0.0, 0.5, 0.7, 1.0] + + +def codeformer(): + import torch + + src = fm.upstream_source(*CODEFORMER) + fm.stub_module("basicsr.utils", get_root_logger=lambda *a, **k: logging.getLogger("codeformer")) + fm.stub_module("basicsr.utils.registry", ARCH_REGISTRY=fm.Registry()) + fm.stub_module("basicsr.archs").__path__ = [os.path.join(src, "basicsr", "archs")] + from basicsr.archs.codeformer_arch import CodeFormer + + net = CodeFormer(dim_embd=512, codebook_size=1024, n_head=8, n_layers=9, connect_list=["32", "64", "128", "256"]) + ckpt = fm.load_checkpoint(fm.fetch(*CKPT)) + net.load_state_dict(ckpt["params_ema"], strict=True) + net.eval() + + import torch.nn.functional as F + from basicsr.archs.codeformer_arch import adaptive_instance_normalization + + class Restore(torch.nn.Module): + """CodeFormer.forward(x, w, adain=True)[0], line for line, except that + feature sizes are taken as Python ints (`str(x.shape[-1])` is a + tensor's repr under the tracer, which breaks the dict lookups) and the + fusion runs for every w (see the module docstring).""" + + def __init__(self, inner): + super().__init__() + self.inner = inner + + def forward(self, x, w): + m = self.inner + enc_feat_dict = {} + out_list = [m.fuse_encoder_block[f] for f in m.connect_list] + for i, block in enumerate(m.encoder.blocks): + x = block(x) + if i in out_list: + enc_feat_dict[str(int(x.shape[-1]))] = x.clone() + lq_feat = x + pos_emb = m.position_emb.unsqueeze(1).repeat(1, x.shape[0], 1) + query_emb = m.feat_emb(lq_feat.flatten(2).permute(2, 0, 1)) + for layer in m.ft_layers: + query_emb = layer(query_emb, query_pos=pos_emb) + logits = m.idx_pred_layer(query_emb).permute(1, 0, 2) + soft_one_hot = F.softmax(logits, dim=2) + _, top_idx = torch.topk(soft_one_hot, 1, dim=2) + quant_feat = m.quantize.get_codebook_feat(top_idx, shape=[x.shape[0], 16, 16, 256]) + quant_feat = adaptive_instance_normalization(quant_feat, lq_feat) + x = quant_feat + fuse_list = [m.fuse_generator_block[f] for f in m.connect_list] + for i, block in enumerate(m.generator.blocks): + x = block(x) + if i in fuse_list: + f_size = str(int(x.shape[-1])) + x = m.fuse_convs_dict[f_size](enc_feat_dict[f_size], x, w) + return x + + class Reference(torch.nn.Module): + """Upstream's own call, with w a Python float (so w = 0 skips the + fusion blocks, as it does in inference_codeformer.py).""" + + def __init__(self, inner): + super().__init__() + self.inner = inner + + def forward(self, x, w): + return self.inner(x, w=float(w.reshape(-1)[0]), adain=True)[0] + + return Restore(net).eval(), Reference(net).eval() + + +def main(): + import torch + + torch.manual_seed(0) + net, reference = codeformer() + out = os.path.join(fm.STAGING, "face-codeformer-v0.1.0.onnx") + x = fm.face_test_input() + w = np.array([0.5], np.float32) + info = fm.export(net, [torch.from_numpy(x), torch.from_numpy(w)], ["input", "w"], ["output"], out) + info["checkpoint"] = CKPT[0] + info["parity"] = {} + for wv in W_CHECKS: + info["parity"][f"w={wv}"] = fm.parity(reference, out, {"input": x, "w": np.array([wv], np.float32)}) + fm.write_record("face-codeformer", {"opset": fm.OPSET, "irVersion": fm.IR_VERSION, + "torch": torch.__version__, "models": [info]}) + + +if __name__ == "__main__": + main() diff --git a/Scripts/models/make_face_gfpgan.py b/Scripts/models/make_face_gfpgan.py new file mode 100644 index 00000000..3fa22e86 --- /dev/null +++ b/Scripts/models/make_face_gfpgan.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Convert GFPGAN v1.4 (TencentARC/GFPGAN, release v1.3.0's GFPGANv1.4.pth) to +face-gfpgan-v1.4.onnx for the face-gfpgan add-on. + +The network is GFPGANv1Clean (the "clean" architecture GFPGANer uses for +v1.4, with no custom CUDA ops). Input and output are (1, 3, 512, 512) RGB in +[-1, 1], float32, static. + +One deliberate difference from upstream inference: GFPGANer runs the +StyleGAN2 decoder with randomize_noise=True, drawing fresh noise for every +call. On video that is new random texture on every frame — flicker by +construction — so the export uses randomize_noise=False, the generator's +fixed noise buffers (part of the checkpoint), which makes the model a pure +function of its input. The parity record compares against PyTorch with the +same setting, and also states how far upstream's random-noise output sits +from it, for scale. + +Licence: GFPGAN is Apache-2.0 except its StyleGAN2-derived code, which is +under NVIDIA's licence (non-commercial: research or evaluation only). The +add-on carries that as its usageNote. + +Usage (conversion venv, see docs/ADDONS.md): + + VAPOURBOX_ADDON_STAGING=/path/to/addon-staging python Scripts/models/make_face_gfpgan.py +""" + +import os +import sys + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import face_models_common as fm # noqa: E402 + +GFPGAN = ("TencentARC/GFPGAN", "7552a7791caad982045a7bbe5634bbf1cd5c8679") +CKPT = ( + "https://github.com/TencentARC/GFPGAN/releases/download/v1.3.0/GFPGANv1.4.pth", + "e2cd4703ab14f4d01fd1383a8a8b266f9a5833dacee8e6a79d3bf21a1b6be5ad", +) + + +def gfpgan(): + import torch + + src = fm.upstream_source(*GFPGAN) + fm.stub_module("basicsr.utils.registry", ARCH_REGISTRY=fm.Registry()) + fm.stub_module("basicsr.archs.arch_util", default_init_weights=lambda *a, **k: None) + for pkg in ("gfpgan", "gfpgan.archs"): + fm.stub_module(pkg).__path__ = [os.path.join(src, *pkg.split("."))] + from gfpgan.archs.gfpganv1_clean_arch import GFPGANv1Clean + + net = GFPGANv1Clean( + out_size=512, + num_style_feat=512, + channel_multiplier=2, + decoder_load_path=None, + fix_decoder=False, + num_mlp=8, + input_is_latent=True, + different_w=True, + narrow=1, + sft_half=True, + ) + ckpt = fm.load_checkpoint(fm.fetch(*CKPT)) + net.load_state_dict(ckpt["params_ema"] if "params_ema" in ckpt else ckpt["params"], strict=True) + net.eval() + + class Restore(torch.nn.Module): + def __init__(self, inner, randomize_noise=False): + super().__init__() + self.inner = inner + self.randomize_noise = randomize_noise + + def forward(self, x): + return self.inner(x, return_rgb=False, randomize_noise=self.randomize_noise)[0] + + return Restore(net).eval(), Restore(net, randomize_noise=True).eval() + + +def main(): + import torch + + torch.manual_seed(0) + net, upstream = gfpgan() + out = os.path.join(fm.STAGING, "face-gfpgan-v1.4.onnx") + x = fm.face_test_input() + info = fm.export(net, [torch.from_numpy(x)], ["input"], ["output"], out) + info["checkpoint"] = CKPT[0] + info["parity"] = fm.parity(net, out, {"input": x}) + with torch.no_grad(): + fixed = net(torch.from_numpy(x)).numpy() + rand = upstream(torch.from_numpy(x)).numpy() + d = np.abs(fixed - rand) + info["fixedVsRandomNoise"] = {"max": float(d.max()), "mean": float(d.mean()), "unit": "[-1, 1] output"} + fm.write_record("face-gfpgan", {"opset": fm.OPSET, "irVersion": fm.IR_VERSION, + "torch": torch.__version__, "models": [info]}) + + +if __name__ == "__main__": + main() diff --git a/Scripts/models/make_face_restoration.py b/Scripts/models/make_face_restoration.py new file mode 100644 index 00000000..773d07e8 --- /dev/null +++ b/Scripts/models/make_face_restoration.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Convert the face-restoration add-on's three models to ONNX: + + face-restoration-retinaface-r50.onnx RetinaFace ResNet-50 face detector + (facexlib's detection_Resnet50_Final.pth, + network from biubug6/Pytorch_Retinaface, MIT) + face-restoration-bisenet.onnx BiSeNet face parser (facexlib's + parsing_bisenet.pth, network from + zllrunning/face-parsing.PyTorch, MIT) + face-restoration-restoreformerpp.onnx RestoreFormer++ (wzhouxiff/RestoreFormerPlusPlus + v1.0.0, Apache-2.0) + +`--detector mobilenet` also writes face-restoration-retinaface-mnet.onnx +(detection_mobilenet0.25_Final.pth), used only for the detector comparison in +docs/reports/issue-face-restoration (the add-on ships ResNet-50). + +Shapes are static, which Core ML needs to take a whole graph: the detector +takes (1, 3, 640, 640) BGR minus the (104, 117, 123) mean, as upstream does, +padded by the caller; BiSeNet takes (1, 3, 512, 512) RGB normalised with +ImageNet's mean and std; RestoreFormer++ takes and returns (1, 3, 512, 512) +RGB in [-1, 1]. All three are float32. + +Usage (conversion venv, see docs/ADDONS.md): + + VAPOURBOX_ADDON_STAGING=/path/to/addon-staging \\ + python Scripts/models/make_face_restoration.py [--detector mobilenet] + +prints, and writes beside the models, a record of each file's size, SHA-256 +and its parity with PyTorch (max / mean absolute difference, CPU provider). +""" + +import argparse +import os +import sys + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import face_models_common as fm # noqa: E402 + +FACEXLIB = ("xinntao/facexlib", "260620ae93990a300f4b16448df9bb459f1caba9") +RESTOREFORMER = ("wzhouxiff/RestoreFormerPlusPlus", "59d250f45d6b11a3e620d62e6e067ca1288f93a9") + +CKPT = { + "r50": ( + "https://github.com/xinntao/facexlib/releases/download/v0.1.0/detection_Resnet50_Final.pth", + "6d1de9c2944f2ccddca5f5e010ea5ae64a39845a86311af6fdf30841b0a5a16d", + ), + "mnet": ( + "https://github.com/xinntao/facexlib/releases/download/v0.1.0/detection_mobilenet0.25_Final.pth", + "2979b33ffafda5d74b6948cd7a5b9a7a62f62b949cef24e95fd15d2883a65220", + ), + "bisenet": ( + "https://github.com/xinntao/facexlib/releases/download/v0.2.0/parsing_bisenet.pth", + "468e13ca13a9b43cc0881a9f99083a430e9c0a38abd935431d1c28ee94b26567", + ), + "restoreformerpp": ( + "https://github.com/wzhouxiff/RestoreFormerPlusPlus/releases/download/v1.0.0/RestoreFormer++.ckpt", + "613fe52805f86bf8c2bffff08ae9f7a0b99f408be1bf221767af6183038be3a2", + ), +} + +DET_SIZE = 640 + + +def facexlib_packages(): + """Make facexlib's detection and parsing modules importable without + running its package __init__ files (which pull in every other model).""" + src = fm.upstream_source(*FACEXLIB) + for pkg in ("facexlib", "facexlib.detection", "facexlib.parsing"): + mod = fm.stub_module(pkg) + mod.__path__ = [os.path.join(src, *pkg.split("."))] + return src + + +def detector(kind): + import torch + + facexlib_packages() + from facexlib.detection.retinaface import RetinaFace + + net = RetinaFace(network_name="resnet50" if kind == "r50" else "mobile0.25", device=torch.device("cpu")) + state = fm.load_checkpoint(fm.fetch(*CKPT[kind])) + state = {k[7:] if k.startswith("module.") else k: v for k, v in state.items()} + net.load_state_dict(state, strict=True) + return net.eval() + + +def bisenet(): + import torch + + facexlib_packages() + from facexlib.parsing.bisenet import BiSeNet + + net = BiSeNet(num_class=19) + net.load_state_dict(fm.load_checkpoint(fm.fetch(*CKPT["bisenet"])), strict=True) + + class First(torch.nn.Module): + """BiSeNet returns (out, out16, out32); inference uses out only.""" + + def __init__(self, inner): + super().__init__() + self.inner = inner + + def forward(self, x): + return self.inner(x)[0] + + return First(net.eval()).eval() + + +def restoreformerpp(): + import torch + + src = fm.upstream_source(*RESTOREFORMER) + sys.path.insert(0, src) + from RestoreFormer.modules.vqvae.vqvae_arch import VQVAEGANMultiHeadTransformer + + net = VQVAEGANMultiHeadTransformer(head_size=4, ex_multi_scale_num=1) + ckpt = fm.load_checkpoint(fm.fetch(*CKPT["restoreformerpp"])) + state = {k[len("vqvae."):] if k.startswith("vqvae.") else k: v for k, v in ckpt["state_dict"].items()} + # Upstream loads with strict=False: the checkpoint also holds the + # training-only discriminator and perceptual-loss weights. Insist that + # every weight the network itself has is present. + missing, unexpected = net.load_state_dict(state, strict=False) + if missing: + raise SystemExit(f"RestoreFormer++ checkpoint lacks {missing[:5]}…") + + class Restore(torch.nn.Module): + """Upstream calls self.RF(x)[0]: the restored image.""" + + def __init__(self, inner): + super().__init__() + self.inner = inner + + def forward(self, x): + return self.inner(x)[0] + + print(f"RestoreFormer++: {len(unexpected)} checkpoint tensors unused (training-only)") + return Restore(net.eval()).eval() + + +def det_input(seed=1): + rng = np.random.default_rng(seed) + img = rng.uniform(0, 255, (1, 3, DET_SIZE, DET_SIZE)).astype(np.float32) + return img - np.array([104.0, 117.0, 123.0], np.float32)[None, :, None, None] + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--detector", choices=["r50", "mobilenet", "both"], default="r50") + ap.add_argument("--only", choices=["detector", "bisenet", "restoreformerpp"], action="append") + args = ap.parse_args() + import torch + + torch.manual_seed(0) + os.makedirs(fm.STAGING, exist_ok=True) + record = {"opset": fm.OPSET, "irVersion": fm.IR_VERSION, "torch": torch.__version__, "models": []} + only = set(args.only or ["detector", "bisenet", "restoreformerpp"]) + + if "detector" in only: + kinds = {"r50": ["r50"], "mobilenet": ["mnet"], "both": ["r50", "mnet"]}[args.detector] + for kind in kinds: + net = detector(kind) + out = os.path.join(fm.STAGING, f"face-restoration-retinaface-{kind}.onnx") + x = det_input() + info = fm.export(net, [torch.from_numpy(x)], ["input"], ["loc", "conf", "landmarks"], out) + info["checkpoint"] = CKPT[kind][0] + info["parity"] = fm.parity(net, out, {"input": x}) + record["models"].append(info) + + if "bisenet" in only: + net = bisenet() + out = os.path.join(fm.STAGING, "face-restoration-bisenet.onnx") + x = fm.face_test_input() + info = fm.export(net, [torch.from_numpy(x)], ["input"], ["parsing"], out) + info["checkpoint"] = CKPT["bisenet"][0] + p = fm.parity(net, out, {"input": x}) + # What the pass uses is the argmax class per pixel. + import onnxruntime as ort + + with torch.no_grad(): + ref = net(torch.from_numpy(x)).numpy().argmax(1) + got = ort.InferenceSession(out, providers=["CPUExecutionProvider"]).run(None, {"input": x})[0].argmax(1) + p["argmaxAgreement"] = float((ref == got).mean()) + info["parity"] = p + record["models"].append(info) + + if "restoreformerpp" in only: + net = restoreformerpp() + out = os.path.join(fm.STAGING, "face-restoration-restoreformerpp.onnx") + x = fm.face_test_input() + info = fm.export(net, [torch.from_numpy(x)], ["input"], ["output"], out) + info["checkpoint"] = CKPT["restoreformerpp"][0] + info["parity"] = fm.parity(net, out, {"input": x}) + record["models"].append(info) + + fm.write_record("face-restoration", record) + + +if __name__ == "__main__": + main() From 044025b4d808543494de3db395e9c9786d2533c0 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 8 Nov 2026 18:39:17 +1100 Subject: [PATCH 16/61] feat(face-restore): the Face Restoration pass in the worker face_restore.py (NumPy on ONNX Runtime from the ai-runtime add-on, no OpenCV): RetinaFace detection with priors and NMS, five-point similarity alignment to the FFHQ 512 template, RestoreFormer++ / GFPGAN / CodeFormer, a feathered BiSeNet mask of the face itself, and an inverse warp back at the clip's own resolution. Temporally stabilised as a pure function of the neighbours (landmarks smoothed with hysteresis and gap filling, restored faces blended in aligned space with motion-adaptive weights), and merged in the source format so every pixel outside the faces is bit-exact. Runs after Crop & Resize, before Sharpen, in both templates; the add-ons (face-restoration, face-gfpgan, face-codeformer, all requiring ai-runtime) are declared through required_addons(), and the preview reads twice the smoothing radius either side. Generic helpers (to_rgbs/from_rgbs, shifted, FrameMemo) go to ai_runtime.py. Fixture: a VHS-degraded clip of NASA public-domain footage. Co-Authored-By: Claude Opus 5.5 --- Tests/TestResources/face_restore_test.mp4 | Bin 0 -> 128823 bytes Tests/TestResources/generate_face_test.sh | 34 ++ app/assets/addons/face-codeformer.json | 26 + app/assets/addons/face-gfpgan.json | 26 + app/assets/addons/face-restoration.json | 60 ++ docs/ADDONS.md | 11 + worker/src/models/face_restore_parameters.rs | 205 +++++++ worker/src/models/mod.rs | 2 + worker/src/models/processing_pipeline.rs | 44 +- worker/src/script_generator.rs | 36 ++ worker/templates/ai_runtime.py | 95 +++ worker/templates/face_restore.py | 590 +++++++++++++++++++ worker/templates/pipeline_template.vpy | 31 + worker/templates/preview_template.vpy | 31 + worker/tests/filter_integration_test.rs | 148 +++++ 15 files changed, 1332 insertions(+), 7 deletions(-) create mode 100644 Tests/TestResources/face_restore_test.mp4 create mode 100755 Tests/TestResources/generate_face_test.sh create mode 100644 app/assets/addons/face-codeformer.json create mode 100644 app/assets/addons/face-gfpgan.json create mode 100644 app/assets/addons/face-restoration.json create mode 100644 worker/src/models/face_restore_parameters.rs create mode 100644 worker/templates/face_restore.py diff --git a/Tests/TestResources/face_restore_test.mp4 b/Tests/TestResources/face_restore_test.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..85cbe46234296a9439c250f8412ab3170690f615 GIT binary patch literal 128823 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 [ffmpeg] +# (the first ~80 MB of the original is enough: `curl -r 0-80000000 -o src.webm `) +set -euo pipefail +SRC="${1:?usage: $0 [ffmpeg]}" +FFMPEG="${2:-ffmpeg}" +OUT="$(cd "$(dirname "$0")" && pwd)/face_restore_test.mp4" + +"$FFMPEG" -y -v error -ss 50 -t 5 -i "$SRC" -an \ + -vf "fps=30000/1001,crop=720:540:728:140,scale=640:480:flags=lanczos,\ +scale=256:360:flags=area,scale=640:480:flags=bicubic,format=yuv420p,\ +split[y][c];[c]scale=160:480:flags=bicubic,scale=640:480:flags=bicubic[cb];\ +[y][cb]mergeplanes=0x001112:yuv420p,noise=alls=8:allf=t,setsar=1" \ + -c:v libx264 -preset slow -crf 27 -pix_fmt yuv420p \ + -color_primaries bt470bg -color_trc bt709 -colorspace smpte170m -color_range tv \ + -movflags +faststart "$OUT" +ls -l "$OUT" diff --git a/app/assets/addons/face-codeformer.json b/app/assets/addons/face-codeformer.json new file mode 100644 index 00000000..098abead --- /dev/null +++ b/app/assets/addons/face-codeformer.json @@ -0,0 +1,26 @@ +{ + "id": "face-codeformer", + "version": "1.0.0", + "name": "CodeFormer face restoration model", + "feature": "Face restoration", + "description": "CodeFormer, an alternative model for the Face Restoration pass with a fidelity control: lower trusts its learned faces more, higher stays closer to the picture. Converted to ONNX from S-Lab's published weights.", + "licence": "S-Lab License 1.0 (NC)", + "licenceUrl": "https://github.com/sczhou/CodeFormer/blob/master/LICENSE", + "usageNote": "Non-commercial use only (S-Lab License 1.0).", + "credit": "S-Lab", + "noticeName": "CodeFormer model", + "homepage": "https://github.com/sczhou/CodeFormer", + "requires": [ + "face-restoration" + ], + "kind": "files", + "platforms": "all", + "files": [ + { + "name": "face-codeformer-v0.1.0.onnx", + "url": "https://github.com/StuartCameronCode/VapourBox/releases/download/addon-models-v1/face-codeformer-v0.1.0.onnx", + "size": 376827102, + "sha256": "536db9b0d73400d09ca19ba1ea4978ba3b084e3bc0c3ab951597e5536c031e18" + } + ] +} diff --git a/app/assets/addons/face-gfpgan.json b/app/assets/addons/face-gfpgan.json new file mode 100644 index 00000000..17dc6f6d --- /dev/null +++ b/app/assets/addons/face-gfpgan.json @@ -0,0 +1,26 @@ +{ + "id": "face-gfpgan", + "version": "1.0.0", + "name": "GFPGAN v1.4 face restoration model", + "feature": "Face restoration", + "description": "GFPGAN v1.4, an alternative model for the Face Restoration pass: stronger, more stylised detail than RestoreFormer++. Converted to ONNX from Tencent ARC's published weights.", + "licence": "Apache-2.0 + NVIDIA Source Code License (NC)", + "licenceUrl": "https://github.com/TencentARC/GFPGAN/blob/master/LICENSE", + "usageNote": "Non-commercial use only (research or evaluation): GFPGAN's StyleGAN2-derived network is under NVIDIA's Source Code License.", + "credit": "THL A29 Limited, a Tencent company", + "noticeName": "GFPGAN v1.4 model", + "homepage": "https://github.com/TencentARC/GFPGAN", + "requires": [ + "face-restoration" + ], + "kind": "files", + "platforms": "all", + "files": [ + { + "name": "face-gfpgan-v1.4.onnx", + "url": "https://github.com/StuartCameronCode/VapourBox/releases/download/addon-models-v1/face-gfpgan-v1.4.onnx", + "size": 340357340, + "sha256": "c84d5c2bf1ddc5e61cfc71524b1a155b297c1c3e884255e6a8c0ce5230a91d94" + } + ] +} diff --git a/app/assets/addons/face-restoration.json b/app/assets/addons/face-restoration.json new file mode 100644 index 00000000..bb642382 --- /dev/null +++ b/app/assets/addons/face-restoration.json @@ -0,0 +1,60 @@ +{ + "id": "face-restoration", + "version": "1.0.0", + "name": "Face restoration (RestoreFormer++)", + "feature": "Face restoration", + "description": "Restores blurry, low-resolution faces in the Face Restoration pass: the RetinaFace face detector, the BiSeNet face parser that keeps the blend to the face itself, and RestoreFormer++, the default restoration model. Converted to ONNX from the projects' own published weights.", + "licence": "Apache-2.0", + "licenceUrl": "https://github.com/wzhouxiff/RestoreFormerPlusPlus/blob/main/LICENSE", + "credit": "Zhouxia Wang, Jiawei Zhang, Tianshui Chen, Wenping Wang and Ping Luo", + "noticeName": "RestoreFormer++ model", + "homepage": "https://github.com/wzhouxiff/RestoreFormerPlusPlus", + "components": [ + { + "noticeName": "RetinaFace face detector", + "licence": "MIT", + "licenceUrl": "https://github.com/biubug6/Pytorch_Retinaface/blob/master/LICENSE.MIT", + "credit": "biubug6 (Pytorch_Retinaface)", + "homepage": "https://github.com/biubug6/Pytorch_Retinaface" + }, + { + "noticeName": "BiSeNet face parser", + "licence": "MIT", + "licenceUrl": "https://github.com/zllrunning/face-parsing.PyTorch/blob/master/LICENSE", + "credit": "zll (face-parsing.PyTorch)", + "homepage": "https://github.com/zllrunning/face-parsing.PyTorch" + }, + { + "noticeName": "facexlib", + "licence": "MIT", + "licenceUrl": "https://github.com/xinntao/facexlib/blob/master/LICENSE", + "credit": "Xintao Wang", + "homepage": "https://github.com/xinntao/facexlib" + } + ], + "requires": [ + "ai-runtime" + ], + "kind": "files", + "platforms": "all", + "files": [ + { + "name": "face-restoration-retinaface-r50.onnx", + "url": "https://github.com/StuartCameronCode/VapourBox/releases/download/addon-models-v1/face-restoration-retinaface-r50.onnx", + "size": 109106945, + "sha256": "da4d5fa7473e3043b5b15df3aa3586d25c5cd94d5cbb53cb8d6309f525381098" + }, + { + "name": "face-restoration-bisenet.onnx", + "url": "https://github.com/StuartCameronCode/VapourBox/releases/download/addon-models-v1/face-restoration-bisenet.onnx", + "size": 52598302, + "sha256": "2e8a24164b64477200bf7d0c7022858a736f36409e184e4bf3c8cb39844bf048" + }, + { + "name": "face-restoration-restoreformerpp.onnx", + "url": "https://github.com/StuartCameronCode/VapourBox/releases/download/addon-models-v1/face-restoration-restoreformerpp.onnx", + "size": 295272527, + "sha256": "640da24f9eaae52fe4e840b4394c65583b69e0091947a0d659ccd2176c8cd86b" + } + ] +} diff --git a/docs/ADDONS.md b/docs/ADDONS.md index d58c1bf7..b005c19c 100644 --- a/docs/ADDONS.md +++ b/docs/ADDONS.md @@ -209,6 +209,17 @@ otherwise. A pass's own block then uses `templates/ai_runtime.py`: | `preferred_providers(force_cpu=False)`, `available_providers()`, `session_provider(sess)` | the provider choice. `VAPOURBOX_AI_FORCE_CPU=1` forces the CPU everywhere. | | `frame_to_nchw(frame)` / `nchw_to_frame(array, frame)` | planar RGBS `VideoFrame` ↔ float32 `(1, 3, h, w)`. | | `map_frames(clip, fn, width=None, height=None)` | apply `fn(nchw) -> nchw` to every frame of an RGBS clip, at an output size, keeping frame properties. | +| `to_rgbs(clip, matrix, range_)` / `from_rgbs(rgbs, fmt, matrix, range_)` | any clip ↔ planar float RGB with the source's matrix and range (zimg names), dithered on the way back — the conversion the AI upscaling block does, for a pass that works in RGB. | +| `shifted(clip, offset)` | the clip moved by `offset` frames, clamped at the ends. Pass several to `std.ModifyFrame` and a callback gets its neighbours' frames without calling `get_frame()` inside a request. | +| `FrameMemo(capacity)` | `.get(key, compute)`: a bounded cache of a pure per-frame computation, computed once even when several requests want it at the same time (several nodes reading one result, neighbours sharing a detection). | + +A pass that reads neighbouring frames must stay a **pure function of its +input frames**: VapourSynth requests frames in parallel and out of order, so +nothing may depend on which frame was computed first. Take neighbours through +`shifted` clips, share work through a `FrameMemo` (losing an entry only costs +recomputing it), and declare the reach as the pass's `FrameMap` radius so the +preview decodes enough context (`worker/templates/face_restore.py` is the +worked example). TensorRT/CUDA are hooks for a future NVIDIA add-on (its directory passed to `setup()` before `ai-runtime`'s); today's wheels don't offer them. diff --git a/worker/src/models/face_restore_parameters.rs b/worker/src/models/face_restore_parameters.rs new file mode 100644 index 00000000..0692bfc4 --- /dev/null +++ b/worker/src/models/face_restore_parameters.rs @@ -0,0 +1,205 @@ +//! Face Restoration: find the faces in each frame and restore them with a +//! neural network (RestoreFormer++, GFPGAN or CodeFormer), blended back into +//! the frame at the clip's own resolution. Runs on ONNX Runtime from the +//! ai-runtime add-on; the models are add-ons too (`face-restoration`, the +//! base with the detector, parser and RestoreFormer++; `face-gfpgan` and +//! `face-codeformer`, each non-commercial). See `worker/templates/face_restore.py`. + +use serde::{Deserialize, Serialize}; + +/// The base add-on: RetinaFace, BiSeNet and RestoreFormer++. +pub const FACE_RESTORATION_ADDON: &str = "face-restoration"; +pub const FACE_GFPGAN_ADDON: &str = "face-gfpgan"; +pub const FACE_CODEFORMER_ADDON: &str = "face-codeformer"; + +/// Model files, as the add-on manifests (`app/assets/addons/`) name them. +pub const FACE_DETECTOR_FILE: &str = "face-restoration-retinaface-r50.onnx"; +pub const FACE_PARSER_FILE: &str = "face-restoration-bisenet.onnx"; + +/// The temporal radius smoothing uses: landmarks smoothed over, and restored +/// faces blended with, one frame either side. Measured against 0 and 2 in +/// docs/reports/2026-11-face-restoration. +pub const FACE_SMOOTHING_RADIUS: u32 = 1; + +/// Which network restores the faces. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum FaceRestoreModel { + /// RestoreFormer++ (Apache-2.0): the default, in the base add-on. + #[default] + RestoreFormer, + /// GFPGAN v1.4 (non-commercial): its own add-on. + Gfpgan, + /// CodeFormer (S-Lab License, non-commercial): its own add-on, with a + /// fidelity control. + CodeFormer, +} + +impl FaceRestoreModel { + /// The add-on holding this model's file. + pub fn addon(&self) -> &'static str { + match self { + FaceRestoreModel::RestoreFormer => FACE_RESTORATION_ADDON, + FaceRestoreModel::Gfpgan => FACE_GFPGAN_ADDON, + FaceRestoreModel::CodeFormer => FACE_CODEFORMER_ADDON, + } + } + + /// The model's file in that add-on. + pub fn file(&self) -> &'static str { + match self { + FaceRestoreModel::RestoreFormer => "face-restoration-restoreformerpp.onnx", + FaceRestoreModel::Gfpgan => "face-gfpgan-v1.4.onnx", + FaceRestoreModel::CodeFormer => "face-codeformer-v0.1.0.onnx", + } + } + + /// The name `face_restore.restore_faces(model=...)` takes. + pub fn script_name(&self) -> &'static str { + match self { + FaceRestoreModel::RestoreFormer => "restoreformer", + FaceRestoreModel::Gfpgan => "gfpgan", + FaceRestoreModel::CodeFormer => "codeformer", + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FaceRestoreParameters { + #[serde(default)] + pub enabled: bool, + + #[serde(default)] + pub model: FaceRestoreModel, + + /// How much of the restored face replaces the original (0-1). + #[serde(default = "default_strength")] + pub strength: f64, + + /// CodeFormer's fidelity weight `w`: 0 trusts its learned faces, 1 stays + /// closest to the picture. Ignored by the other models. + #[serde(default = "default_fidelity")] + pub code_former_fidelity: f64, + + /// The detector score (0-1) a face needs. + #[serde(default = "default_threshold")] + pub detection_threshold: f64, + + /// Faces smaller than this (pixels, the larger side of the detected box, + /// at the pass's resolution) are left alone: there is too little in them + /// to restore, and a restorer invents a face rather than recovering one. + #[serde(default = "default_min_face")] + pub min_face_size: i32, + + /// At most this many faces (the largest) are restored per frame. + #[serde(default = "default_max_faces")] + pub max_faces: i32, + + /// Average each face's landmarks with its neighbours' (one frame either + /// side) and bridge one-frame detection gaps, so the restored face does + /// not jitter or blink. + #[serde(default = "default_true")] + pub temporal_smoothing: bool, +} + +fn default_strength() -> f64 { 0.8 } +fn default_fidelity() -> f64 { 0.7 } +fn default_threshold() -> f64 { 0.8 } +fn default_min_face() -> i32 { 32 } +fn default_max_faces() -> i32 { 8 } +fn default_true() -> bool { true } + +impl Default for FaceRestoreParameters { + fn default() -> Self { + Self { + enabled: false, + model: FaceRestoreModel::default(), + strength: default_strength(), + code_former_fidelity: default_fidelity(), + detection_threshold: default_threshold(), + min_face_size: default_min_face(), + max_faces: default_max_faces(), + temporal_smoothing: true, + } + } +} + +impl FaceRestoreParameters { + /// The add-ons a job with this pass needs: the base (detector and parser) + /// always, and the chosen model's own add-on. + pub fn required_addons(&self) -> Vec<&'static str> { + let mut ids = vec![FACE_RESTORATION_ADDON]; + if self.model.addon() != FACE_RESTORATION_ADDON { + ids.push(self.model.addon()); + } + ids + } + + /// The smoothing radius `restore_faces(temporal_radius=...)` takes: each + /// face's landmarks are smoothed, and each restored face blended, over + /// this many frames either side. 0 restores every frame on its own. + pub fn temporal_radius(&self) -> u32 { + if self.temporal_smoothing { FACE_SMOOTHING_RADIUS } else { 0 } + } + + /// Frames read either side of the one being restored (the preview's + /// temporal context): the blend reads `temporal_radius` neighbours, and + /// their landmarks are smoothed over `temporal_radius` more. + pub fn frames_read_either_side(&self) -> u32 { + self.temporal_radius() * 2 + } + + pub fn clamped_strength(&self) -> f64 { self.strength.clamp(0.0, 1.0) } + pub fn clamped_fidelity(&self) -> f64 { self.code_former_fidelity.clamp(0.0, 1.0) } + pub fn clamped_threshold(&self) -> f64 { self.detection_threshold.clamp(0.3, 0.99) } + pub fn clamped_min_face(&self) -> i32 { self.min_face_size.clamp(8, 1024) } + pub fn clamped_max_faces(&self) -> i32 { self.max_faces.clamp(1, 64) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_and_wire_names() { + let p: FaceRestoreParameters = serde_json::from_str("{}").unwrap(); + assert_eq!(p, FaceRestoreParameters::default()); + assert!(!p.enabled); + assert_eq!(p.model, FaceRestoreModel::RestoreFormer); + let json = serde_json::to_value(FaceRestoreParameters { model: FaceRestoreModel::CodeFormer, ..Default::default() }).unwrap(); + assert_eq!(json["model"], "codeFormer"); + assert_eq!(json["codeFormerFidelity"], 0.7); + assert_eq!(json["temporalSmoothing"], true); + for (m, name) in [ + (FaceRestoreModel::RestoreFormer, "\"restoreFormer\""), + (FaceRestoreModel::Gfpgan, "\"gfpgan\""), + (FaceRestoreModel::CodeFormer, "\"codeFormer\""), + ] { + assert_eq!(serde_json::to_string(&m).unwrap(), name); + } + } + + /// Every model needs the base add-on (detector and parser); the + /// non-commercial ones need their own as well. + #[test] + fn required_addons_per_model() { + let with = |model| FaceRestoreParameters { model, ..Default::default() }.required_addons(); + assert_eq!(with(FaceRestoreModel::RestoreFormer), vec!["face-restoration"]); + assert_eq!(with(FaceRestoreModel::Gfpgan), vec!["face-restoration", "face-gfpgan"]); + assert_eq!(with(FaceRestoreModel::CodeFormer), vec!["face-restoration", "face-codeformer"]); + } + + /// The files named here are the ones the manifests install. + #[test] + fn model_files_are_in_their_manifests() { + let repo = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); + let read = |id: &str| std::fs::read_to_string(repo.join("app/assets/addons").join(format!("{id}.json"))).unwrap(); + for m in [FaceRestoreModel::RestoreFormer, FaceRestoreModel::Gfpgan, FaceRestoreModel::CodeFormer] { + assert!(read(m.addon()).contains(&format!("\"name\": \"{}\"", m.file())), "{m:?}"); + } + let base = read(FACE_RESTORATION_ADDON); + assert!(base.contains(&format!("\"name\": \"{FACE_DETECTOR_FILE}\""))); + assert!(base.contains(&format!("\"name\": \"{FACE_PARSER_FILE}\""))); + } +} diff --git a/worker/src/models/mod.rs b/worker/src/models/mod.rs index 3300ac4d..085a13b5 100644 --- a/worker/src/models/mod.rs +++ b/worker/src/models/mod.rs @@ -22,6 +22,7 @@ mod deblock_parameters; mod deband_parameters; mod descratch_parameters; mod descale_parameters; +mod face_restore_parameters; mod spotless_parameters; mod stabilize_parameters; mod sharpen_parameters; @@ -49,6 +50,7 @@ pub use deblock_parameters::*; pub use deband_parameters::*; pub use descratch_parameters::*; pub use descale_parameters::*; +pub use face_restore_parameters::*; pub use stabilize_parameters::*; pub use spotless_parameters::*; pub use sharpen_parameters::*; diff --git a/worker/src/models/processing_pipeline.rs b/worker/src/models/processing_pipeline.rs index 827f957a..862f7695 100644 --- a/worker/src/models/processing_pipeline.rs +++ b/worker/src/models/processing_pipeline.rs @@ -8,7 +8,7 @@ use super::{ AntiAliasParameters, GeometryParameters, GrainParameters, StabilizeParameters, ChromaDenoiseParameters, ChromaFixParameters, ColorCorrectionParameters, CropResizeParameters, DebandParameters, DeblockParameters, DehaloParameters, DeinterlaceMethod, DeScratchParameters, - SpotLessParameters, SharpenParameters, NoiseReductionParameters, QTGMCParameters, FrameRateParameters, FrameRateMethod, NoiseReductionMethod, DeflickerParameters, EdgeRepairParameters, GhostRemovalParameters, DescaleParameters,}; + SpotLessParameters, SharpenParameters, NoiseReductionParameters, QTGMCParameters, FrameRateParameters, FrameRateMethod, NoiseReductionMethod, DeflickerParameters, EdgeRepairParameters, GhostRemovalParameters, DescaleParameters, FaceRestoreParameters,}; /// Minimum temporal context (frames on each side of the target) a windowed /// preview decodes, regardless of which filters are enabled. Motion-compensated @@ -169,6 +169,8 @@ pub enum PassType { ColorCorrection, ChromaFixes, CropResize, + /// Restore faces with a neural network (an add-on). + FaceRestore, } #[allow(dead_code)] @@ -197,6 +199,7 @@ impl PassType { PassType::ColorCorrection => "Color Correction", PassType::ChromaFixes => "Chroma Fixes", PassType::CropResize => "Crop / Resize", + PassType::FaceRestore => "Face Restoration", } } @@ -224,6 +227,7 @@ impl PassType { PassType::ColorCorrection => "Adjust brightness, contrast, and colors", PassType::ChromaFixes => "Fix chroma bleeding and crawl artifacts", PassType::CropResize => "Crop borders and resize output", + PassType::FaceRestore => "Restore detail in blurry, low-resolution faces with AI", } } } @@ -313,6 +317,10 @@ pub struct ProcessingPipeline { /// Crop and resize pass parameters. #[serde(default)] pub crop_resize: CropResizeParameters, + + /// Face restoration (RestoreFormer++ / GFPGAN / CodeFormer). Off by default. + #[serde(default)] + pub face_restore: FaceRestoreParameters, } impl Default for ProcessingPipeline { @@ -339,6 +347,7 @@ impl Default for ProcessingPipeline { color_correction: ColorCorrectionParameters::default(), chroma_fixes: ChromaFixParameters::default(), crop_resize: CropResizeParameters::default(), + face_restore: FaceRestoreParameters::default(), } } } @@ -369,6 +378,7 @@ impl ProcessingPipeline { color_correction: ColorCorrectionParameters { enabled: false, ..Default::default() }, chroma_fixes: ChromaFixParameters { enabled: false, ..Default::default() }, crop_resize: CropResizeParameters { enabled: false, ..Default::default() }, + face_restore: FaceRestoreParameters::default(), } } @@ -377,7 +387,8 @@ impl ProcessingPipeline { let mut passes = Vec::new(); // Order: deinterlace, damage, noise, dehalo, deblock, deband, anti-alias, - // chroma, color, stabilize, rotate, crop + resize, then sharpen, grain, + // chroma, color, stabilize, rotate, crop + resize, face restoration, then + // sharpen, grain, // frame rate. if self.deinterlace_enabled() { passes.push(PassType::Deinterlace); @@ -459,6 +470,15 @@ impl ProcessingPipeline { passes.push(PassType::CropResize); } + // Faces are restored at the delivered resolution, after the resize: + // an upscale gives the restorer more pixels per face, and a downscale + // after it would soften the detail it recovered. Before sharpening, + // which would otherwise sharpen the VHS face only to have it replaced, + // and before grain, which puts texture back over the smooth result. + if self.face_restore.enabled { + passes.push(PassType::FaceRestore); + } + // Sharpening follows the resize (issue #109), so it works on the // delivered pixels: sharpened earlier, a downscale softens the edges // again and an upscale enlarges the halos. It still follows @@ -504,6 +524,7 @@ impl ProcessingPipeline { if self.color_correction.enabled { count += 1; } if self.chroma_fixes.enabled { count += 1; } if self.crop_resize.enabled { count += 1; } + if self.face_restore.enabled { count += 1; } count } @@ -530,12 +551,17 @@ impl ProcessingPipeline { /// job, naming them and Settings → Add-ons, unless all are installed, and /// emits the `{{#AI_RUNTIME}}` block when any of them needs the AI runtime. /// - /// No pass uses one yet. A pass that runs a model from an add-on adds an - /// entry here when enabled, e.g. - /// `AddonNeed { feature: "Face restoration", ids: vec!["gfpgan"] }` - /// (a model add-on whose manifest `requires` `ai-runtime`). + /// A pass that runs a model from an add-on adds an entry here when + /// enabled (a model add-on whose manifest `requires` `ai-runtime`). pub fn required_addons(&self) -> Vec { - Vec::new() + let mut needs = Vec::new(); + if self.face_restore.enabled { + needs.push(crate::addons::AddonNeed { + feature: "Face restoration", + ids: self.face_restore.required_addons(), + }); + } + needs } /// Whether this pipeline denoises with KNLMeans, which needs a working @@ -574,6 +600,9 @@ impl ProcessingPipeline { PassType::EdgeRepair => FrameMap::Identity { radius: 0 }, PassType::GhostRemoval => FrameMap::Identity { radius: 0 }, PassType::Descale => FrameMap::Identity { radius: 0 }, + // Restored faces are blended with their neighbours', whose landmarks + // are smoothed over theirs: twice the smoothing radius. + PassType::FaceRestore => FrameMap::Identity { radius: self.face_restore.frames_read_either_side() }, // The one pass that emits a Retime. The ratio is known up front, so // the count is exact; `synthesizes` is true only for the // interpolating method, where an output frame has no single source. @@ -777,6 +806,7 @@ impl ProcessingPipeline { PassType::ColorCorrection => self.color_correction.enabled, PassType::ChromaFixes => self.chroma_fixes.enabled, PassType::CropResize => self.crop_resize.enabled, + PassType::FaceRestore => self.face_restore.enabled, } } } diff --git a/worker/src/script_generator.rs b/worker/src/script_generator.rs index 242f82cb..e2d77250 100644 --- a/worker/src/script_generator.rs +++ b/worker/src/script_generator.rs @@ -247,6 +247,37 @@ impl ScriptGenerator { ai_runtime_block(script, dir.as_deref()) } + /// Emit or drop the `{{#FACE_RESTORE}}` block (both templates), with the + /// model paths from the add-ons directory. A job that gets here with the + /// pass on has passed `ensure_methods_ready`, so its add-ons are installed. + fn substitute_face_restore(&self, script: String, job: &VideoJob, pipeline: &ProcessingPipeline) -> String { + use crate::models::{FACE_DETECTOR_FILE, FACE_PARSER_FILE, FACE_RESTORATION_ADDON}; + let fr = &pipeline.face_restore; + if !fr.enabled { + return remove_block("{{#FACE_RESTORE}}", "{{/FACE_RESTORE}}", script); + } + let path = |id: &str, file: &str| match &self.addons { + Some((root, _)) => py_str(&crate::addons::addon_dir(root, id).join(file)), + None => "''".to_string(), + }; + let color = job.color_metadata(); + script + .replace("{{#FACE_RESTORE}}", "") + .replace("{{/FACE_RESTORE}}", "") + .replace("{{FR_DETECTOR}}", &path(FACE_RESTORATION_ADDON, FACE_DETECTOR_FILE)) + .replace("{{FR_PARSER}}", &path(FACE_RESTORATION_ADDON, FACE_PARSER_FILE)) + .replace("{{FR_RESTORER}}", &path(fr.model.addon(), fr.model.file())) + .replace("{{FR_MODEL}}", fr.model.script_name()) + .replace("{{FR_STRENGTH}}", &format!("{:.3}", fr.clamped_strength())) + .replace("{{FR_FIDELITY}}", &format!("{:.3}", fr.clamped_fidelity())) + .replace("{{FR_THRESHOLD}}", &format!("{:.3}", fr.clamped_threshold())) + .replace("{{FR_MIN_FACE}}", &fr.clamped_min_face().to_string()) + .replace("{{FR_MAX_FACES}}", &fr.clamped_max_faces().to_string()) + .replace("{{FR_RADIUS}}", &fr.temporal_radius().to_string()) + .replace("{{FR_MATRIX}}", color.zimg_matrix(job.input_height)) + .replace("{{FR_RANGE}}", if color.is_full_range() { "full" } else { "limited" }) + } + /// Generate a .vpy script file for the given job. pub fn generate(&self, job: &VideoJob) -> Result { let pipeline = job.effective_pipeline(); @@ -1747,6 +1778,11 @@ impl ScriptGenerator { script = remove_block("{{#GEOMETRY}}", "{{/GEOMETRY}}", script); } + // ==================================================================== + // FACE RESTORATION PASS + // ==================================================================== + script = self.substitute_face_restore(script, job, pipeline); + // ==================================================================== // SHARPEN PASS // ==================================================================== diff --git a/worker/templates/ai_runtime.py b/worker/templates/ai_runtime.py index 497e3858..c0e96f60 100644 --- a/worker/templates/ai_runtime.py +++ b/worker/templates/ai_runtime.py @@ -263,3 +263,98 @@ def self_test(force_cpu=False): "fallback": last_fallback, "maxError": err, } + + +# ---------------------------------------------------------------------------- +# Helpers for passes that run a model on part of a frame (face restoration, +# and anything else that crops, warps or masks around a network). They keep a +# pass a pure function of its input frames, which VapourSynth needs: it asks +# for frames in parallel and out of order, so nothing may depend on the order +# frames were computed in. +# ---------------------------------------------------------------------------- + + +def to_rgbs(clip, matrix, range_): + """`clip` (YUV, GRAY or RGB, any depth) as planar float RGB, using the + source's matrix and range (zimg names, e.g. "709" / "limited") for YUV.""" + import vapoursynth as vs + + core = vs.core + family = clip.format.color_family + if family == vs.YUV: + return core.resize.Spline36(clip, format=vs.RGBS, matrix_in_s=matrix, range_in_s=range_) + if family == vs.GRAY: + gray = core.resize.Point(clip, format=vs.GRAYS, range_in_s=range_) + return core.std.ShufflePlanes(gray, [0, 0, 0], vs.RGB) + return core.resize.Point(clip, format=vs.RGBS) + + +def from_rgbs(rgbs, fmt, matrix, range_): + """The inverse of to_rgbs: back to `fmt` (a VideoFormat), dithered. A GRAY + source takes the green plane, as the AI upscaling block does.""" + import vapoursynth as vs + + core = vs.core + if fmt.color_family == vs.YUV: + return core.resize.Spline36(rgbs, format=fmt.id, matrix_s=matrix, range_s=range_, + dither_type="error_diffusion") + if fmt.color_family == vs.GRAY: + return core.resize.Point(core.std.ShufflePlanes(rgbs, 1, vs.GRAY), format=fmt.id, range_s=range_, + dither_type="error_diffusion") + return core.resize.Point(rgbs, format=fmt.id, dither_type="error_diffusion") + + +def shifted(clip, offset): + """`clip` moved by `offset` frames, clamped at both ends: frame n of the + result is frame clamp(n + offset) of `clip`. Passing these to + std.ModifyFrame alongside the clip gives a callback its neighbours' + frames without calling get_frame() from inside a frame request.""" + n = clip.num_frames + if offset == 0 or n < 2: + return clip + offset = max(-(n - 1), min(n - 1, offset)) + if offset > 0: + return clip[offset:] + clip[-1] * offset + return clip[0] * (-offset) + clip[: n + offset] + + +class FrameMemo: + """A bounded cache of a pure per-frame computation, computed once even + when several frame requests (several nodes reading the same result, or + neighbours sharing a detection) ask for the same key at the same time. + Losing an entry only costs recomputing it, which gives the same answer.""" + + def __init__(self, capacity=64): + import collections + + self._capacity = capacity + self._lock = threading.Lock() + self._done = collections.OrderedDict() + self._pending = {} + + def get(self, key, compute): + with self._lock: + if key in self._done: + self._done.move_to_end(key) + return self._done[key] + event = self._pending.get(key) + owner = event is None + if owner: + event = self._pending[key] = threading.Event() + if not owner: + event.wait() + with self._lock: + if key in self._done: + return self._done[key] + return compute() # evicted already (or the owner failed): recompute + try: + value = compute() + with self._lock: + self._done[key] = value + while len(self._done) > self._capacity: + self._done.popitem(last=False) + return value + finally: + with self._lock: + self._pending.pop(key, None) + event.set() diff --git a/worker/templates/face_restore.py b/worker/templates/face_restore.py new file mode 100644 index 00000000..2f403752 --- /dev/null +++ b/worker/templates/face_restore.py @@ -0,0 +1,590 @@ +""" +Face restoration for VapourSynth, on ONNX Runtime from the ai-runtime add-on. + + import face_restore + clip = face_restore.restore_faces(clip, detector=..., parser=..., restorer=..., + model="restoreformer", ...) + +For every frame: RetinaFace finds the faces and their five landmarks (eyes, +nose, mouth corners); each face is aligned to the FFHQ 512x512 template by a +similarity transform, restored by RestoreFormer++, GFPGAN or CodeFormer, and +parsed by BiSeNet into a mask of the face itself (skin, brows, eyes, glasses, +ears, nose, mouth and lips; not hair, neck or clothes), which is feathered, +warped back and used to blend the restored face into the frame at the clip's +own resolution. + +The pass is a pure function of its input frames, as VapourSynth requires +(frames are requested in parallel and out of order): + +- **Temporal smoothing** reads the detections of the `temporal_radius` frames + either side (std.ModifyFrame over clips shifted by -r..r, never get_frame() + from inside a request). Each face's landmarks are averaged with the faces it + matches in its neighbours (triangular weights), a face detected just under + the threshold is kept when a neighbour has it above the threshold + (hysteresis), and a face missing from one frame between two that have it is + filled in. Per-frame detections jitter by a pixel or two, and a restorer + turns that jitter into flicker; smoothing removes most of it. +- **Bit-exact outside the faces.** The restored faces are composited with + std.MaskedMerge in the source's own format, so every pixel the face mask + does not reach (and every frame with no face) is the input, unchanged. + +Detection runs once per frame (shared through a FrameMemo), on a copy scaled +to fit 640x640 and padded, so every model has a static input shape — which is +what lets Core ML take the whole graph. + +Pure NumPy: OpenCV is not in the deps bundle or the ai-runtime add-on, and +nothing here needs it. +""" + +import threading + +import numpy as np + +import ai_runtime + +# FFHQ's five-point template at 512x512 (facexlib's FaceRestoreHelper, the +# alignment every one of these restorers was trained on). +FFHQ_TEMPLATE = np.array( + [ + [192.98138, 239.94708], + [318.90277, 240.1936], + [256.63416, 314.01935], + [201.26117, 371.41043], + [313.08905, 371.15118], + ], + np.float64, +) +FACE_SIZE = 512 +# What facexlib fills outside the frame when it crops a face near an edge. +BORDER_RGB = np.array([132.0, 133.0, 135.0], np.float32) / 255.0 + +# RetinaFace (biubug6/Pytorch_Retinaface's configuration for both backbones). +DET_SIZE = 640 +DET_MEAN_BGR = np.array([104.0, 117.0, 123.0], np.float32) +DET_MIN_SIZES = [[16, 32], [64, 128], [256, 512]] +DET_STEPS = [8, 16, 32] +DET_VARIANCE = (0.1, 0.2) +DET_NMS = 0.4 + +# BiSeNet (CelebAMask-HQ labels): 1 skin, 2-3 brows, 4-5 eyes, 6 glasses, +# 7-9 ears and earrings, 10 nose, 11 mouth, 12-13 lips. Not 14 neck, +# 15 necklace, 16 cloth, 17 hair, 18 hat. +FACE_CLASSES = list(range(1, 14)) +PARSE_MEAN = np.array([0.485, 0.456, 0.406], np.float32)[:, None, None] +PARSE_STD = np.array([0.229, 0.224, 0.225], np.float32)[:, None, None] +# facexlib feathers its parse mask with two 101-tap Gaussian blurs of sigma 11 +# and clears a 10-pixel border; one blur of sigma 11 * sqrt(2) is the same. +MASK_SIGMA = 11.0 * np.sqrt(2.0) +MASK_BORDER = 10 +# Mask values below this count as zero (so the blend's reach is finite). +MASK_FLOOR = 1.0 / 1024.0 + +MODELS = ("restoreformer", "gfpgan", "codeformer") + + +# ---------------------------------------------------------------------------- +# Detection +# ---------------------------------------------------------------------------- + +_priors = None + + +def priors(): + """RetinaFace's anchors for a DET_SIZE square input, (N, 4) as + (cx, cy, w, h) in [0, 1], in the network's output order.""" + global _priors + if _priors is None: + rows = [] + for k, step in enumerate(DET_STEPS): + f = -(-DET_SIZE // step) + cy, cx = np.meshgrid((np.arange(f) + 0.5) * step / DET_SIZE, (np.arange(f) + 0.5) * step / DET_SIZE, + indexing="ij") + for size in DET_MIN_SIZES[k]: + s = np.full_like(cx, size / DET_SIZE) + rows.append(np.stack([cx, cy, s, s], -1)) + # Interleave the two sizes per cell, as PriorBox does. + a, b = rows[-2], rows[-1] + rows[-2:] = [np.stack([a, b], 2).reshape(-1, 4)] + _priors = np.concatenate(rows).astype(np.float32) + return _priors + + +def nms(boxes, scores, threshold): + """Greedy non-maximum suppression (Pytorch_Retinaface's py_cpu_nms).""" + x1, y1, x2, y2 = boxes.T + areas = (x2 - x1 + 1) * (y2 - y1 + 1) + order = scores.argsort()[::-1] + keep = [] + while order.size: + i = order[0] + keep.append(i) + xx1 = np.maximum(x1[i], x1[order[1:]]) + yy1 = np.maximum(y1[i], y1[order[1:]]) + xx2 = np.minimum(x2[i], x2[order[1:]]) + yy2 = np.minimum(y2[i], y2[order[1:]]) + inter = np.maximum(0.0, xx2 - xx1 + 1) * np.maximum(0.0, yy2 - yy1 + 1) + iou = inter / (areas[i] + areas[order[1:]] - inter) + order = order[1:][iou <= threshold] + return np.array(keep, np.int64) + + +def detect(session, rgb, scale, floor): + """Faces in one frame. `rgb` is the detection copy, (3, h, w) float RGB in + [0, 1] with h, w <= DET_SIZE; `scale` (x, y) maps its pixels to the + clip's (clip = (det + 0.5) / scale - 0.5). Returns (scores (k,), boxes (k, 4) + x1 y1 x2 y2, landmarks (k, 5, 2)) in clip pixels, every face scoring at + least `floor`, best first.""" + _, h, w = rgb.shape + x = np.zeros((1, 3, DET_SIZE, DET_SIZE), np.float32) + # BGR, 0-255, minus the mean; the padding is the mean (zero after it). + x[0, :, :h, :w] = rgb[::-1] * 255.0 - DET_MEAN_BGR[:, None, None] + loc, conf, landm = session.run(None, {session.get_inputs()[0].name: x}) + loc, scores, landm = loc[0], conf[0][:, 1], landm[0] + keep = scores >= floor + if not keep.any(): + return np.zeros(0, np.float32), np.zeros((0, 4)), np.zeros((0, 5, 2)) + p, loc, scores, landm = priors()[keep], loc[keep], scores[keep], landm[keep] + v0, v1 = DET_VARIANCE + centre = p[:, :2] + loc[:, :2] * v0 * p[:, 2:] + size = p[:, 2:] * np.exp(loc[:, 2:] * v1) + boxes = np.concatenate([centre - size / 2, centre + size / 2], 1) * DET_SIZE + marks = (p[:, None, :2] + landm.reshape(-1, 5, 2) * v0 * p[:, None, 2:]) * DET_SIZE + k = nms(boxes, scores, DET_NMS) + scale = np.asarray(scale, np.float64) + boxes = (boxes[k] + 0.5) / np.tile(scale, 2) - 0.5 + marks = (marks[k] + 0.5) / scale - 0.5 + return scores[k], boxes.astype(np.float64), marks.astype(np.float64) + + +# ---------------------------------------------------------------------------- +# Temporal smoothing: a pure function of the detections in frames n-r..n+r +# ---------------------------------------------------------------------------- + + +def _match(box, boxes): + """Index of the box in `boxes` overlapping `box` most (IoU > 0.3), or None.""" + if not len(boxes): + return None + x1 = np.maximum(box[0], boxes[:, 0]) + y1 = np.maximum(box[1], boxes[:, 1]) + x2 = np.minimum(box[2], boxes[:, 2]) + y2 = np.minimum(box[3], boxes[:, 3]) + inter = np.maximum(0, x2 - x1) * np.maximum(0, y2 - y1) + area = (box[2] - box[0]) * (box[3] - box[1]) + areas = (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1]) + iou = inter / np.maximum(area + areas - inter, 1e-6) + i = int(iou.argmax()) + return i if iou[i] > 0.3 else None + + +def select_faces(dets, radius, threshold, min_face, max_faces): + """The faces to restore in the centre frame of `dets` (a list of 2r+1 + detections, frame n-r first), as (box, landmarks) with smoothed + landmarks. `radius` 0 is per-frame detection, unsmoothed.""" + centre = dets[radius] + low = threshold * 0.75 + faces = [] + + def confirmed(box, score): + """Above the threshold here, or near it here and above it in a neighbour.""" + if score >= threshold: + return True + if score < low: + return False + for d in (dets[radius - 1], dets[radius + 1]) if radius else (): + j = _match(box, d[1]) + if j is not None and d[0][j] >= threshold: + return True + return False + + for i in range(len(centre[0])): + if confirmed(centre[1][i], centre[0][i]): + faces.append((centre[1][i], centre[2][i])) + + if radius: + # A face in the frames either side but not this one: a dropout. + prev, nxt = dets[radius - 1], dets[radius + 1] + for i in range(len(prev[0])): + if prev[0][i] < threshold: + continue + j = _match(prev[1][i], nxt[1]) + if j is None or nxt[0][j] < threshold: + continue + box = (prev[1][i] + nxt[1][j]) / 2 + if any(_match(box, np.array([f[0]])) is not None for f in faces): + continue + faces.append((box, (prev[2][i] + nxt[2][j]) / 2)) + + smoothed = [] + for box, marks in faces: + total, wsum = np.zeros((5, 2)), 0.0 + for k, d in enumerate(dets): + weight = radius + 1 - abs(k - radius) + if k == radius: + total += weight * marks + wsum += weight + continue + j = _match(box, d[1]) + if j is not None and d[0][j] >= low: + total += weight * d[2][j] + wsum += weight + smoothed.append((box, total / wsum)) + faces = smoothed + + faces = [f for f in faces if max(f[0][2] - f[0][0], f[0][3] - f[0][1]) >= min_face] + faces.sort(key=lambda f: -(f[0][2] - f[0][0]) * (f[0][3] - f[0][1])) + return faces[:max_faces] + + +# ---------------------------------------------------------------------------- +# Alignment and warping +# ---------------------------------------------------------------------------- + + +def similarity(src, dst): + """The least-squares similarity transform (rotation, uniform scale, + translation; Umeyama) taking points `src` to `dst`, as a 2x3 matrix.""" + mu_s, mu_d = src.mean(0), dst.mean(0) + s, d = src - mu_s, dst - mu_d + cov = d.T @ s / len(src) + u, sv, vt = np.linalg.svd(cov) + sign = np.eye(2) + if np.linalg.det(u) * np.linalg.det(vt) < 0: + sign[1, 1] = -1 + rot = u @ sign @ vt + scale = np.trace(np.diag(sv) @ sign) / ((s ** 2).sum() / len(src)) + t = mu_d - scale * rot @ mu_s + return np.hstack([scale * rot, t[:, None]]) + + +def invert(m): + a = np.vstack([m, [0.0, 0.0, 1.0]]) + return np.linalg.inv(a)[:2] + + +def bilinear(img, xs, ys, fill): + """Sample `img` (c, h, w) at float pixel coordinates (pixel centres at + integers, as OpenCV's warpAffine), `fill` (c,) outside it.""" + c, h, w = img.shape + x0 = np.floor(xs).astype(np.int64) + y0 = np.floor(ys).astype(np.int64) + fx = (xs - x0).astype(np.float32) + fy = (ys - y0).astype(np.float32) + out = np.zeros((c,) + xs.shape, np.float32) + for dy, wy in ((0, 1 - fy), (1, fy)): + for dx, wx in ((0, 1 - fx), (1, fx)): + xi, yi = x0 + dx, y0 + dy + inside = (xi >= 0) & (xi < w) & (yi >= 0) & (yi < h) + v = img[:, np.clip(yi, 0, h - 1), np.clip(xi, 0, w - 1)] + v = np.where(inside, v, fill[:, None, None] if v.ndim == 3 else fill[:, None]) + out += v * (wx * wy) + return out + + +_grid = None + + +def face_grid(): + global _grid + if _grid is None: + ys, xs = np.mgrid[0:FACE_SIZE, 0:FACE_SIZE].astype(np.float64) + _grid = (xs, ys) + return _grid + + +def align(rgb, m): + """The FACE_SIZE square face `m` (frame -> face) picks out of `rgb`.""" + xs, ys = face_grid() + inv = invert(m) + sx = inv[0, 0] * xs + inv[0, 1] * ys + inv[0, 2] + sy = inv[1, 0] * xs + inv[1, 1] * ys + inv[1, 2] + return bilinear(rgb, sx, sy, BORDER_RGB) + + +_blur = None + + +def _blur_matrix(): + """A FACE_SIZE x FACE_SIZE matrix applying a Gaussian of MASK_SIGMA with + reflected edges (OpenCV's default border), so blur(M) = G @ M @ G.T.""" + global _blur + if _blur is None: + r = int(np.ceil(3 * MASK_SIGMA)) + taps = np.exp(-0.5 * (np.arange(-r, r + 1) / MASK_SIGMA) ** 2) + taps /= taps.sum() + g = np.zeros((FACE_SIZE, FACE_SIZE)) + for i in range(FACE_SIZE): + for k, t in enumerate(taps): + j = i + k - r + if j < 0: + j = -j + if j >= FACE_SIZE: + j = 2 * (FACE_SIZE - 1) - j + g[i, j] += t + _blur = g.astype(np.float32) + return _blur + + +def face_mask(parsing): + """The feathered blend mask (FACE_SIZE square, [0, 1]) for BiSeNet's + logits (1, 19, FACE_SIZE, FACE_SIZE).""" + labels = parsing[0].argmax(0) + mask = np.isin(labels, FACE_CLASSES).astype(np.float32) + g = _blur_matrix() + mask = g @ mask @ g.T + b = MASK_BORDER + mask[:b] = 0 + mask[-b:] = 0 + mask[:, :b] = 0 + mask[:, -b:] = 0 + mask[mask < MASK_FLOOR] = 0 + return mask + + +# ---------------------------------------------------------------------------- +# The pass +# ---------------------------------------------------------------------------- + + +class FaceRestorer: + """The models for one restore_faces() call.""" + + def __init__(self, detector, parser, restorer, model, fidelity): + if model not in MODELS: + raise ValueError("face_restore: unknown model %r" % model) + self.det = ai_runtime.session(detector) + self.parse = ai_runtime.session(parser) + self.restore = ai_runtime.session(restorer) + self.model = model + self.fidelity = float(fidelity) + self.restore_input = self.restore.get_inputs()[0].name + self.parse_input = self.parse.get_inputs()[0].name + + def restore_face(self, face): + """A (3, 512, 512) RGB face in [0, 1], restored.""" + feeds = {self.restore_input: (face * 2.0 - 1.0)[None].astype(np.float32)} + if self.model == "codeformer": + feeds["w"] = np.array([self.fidelity], np.float32) + out = self.restore.run(None, feeds)[0][0] + return np.clip((out + 1.0) * 0.5, 0.0, 1.0) + + def mask_for(self, face): + x = ((face - PARSE_MEAN) / PARSE_STD)[None].astype(np.float32) + return face_mask(self.parse.run(None, {self.parse_input: x})[0]) + + +def paste(rgb, out, alpha, restored, mask, m): + """Write `restored`/`mask` (face space, `m` frame -> face) into `out` / + `alpha` (frame space) where this face's mask is the strongest so far.""" + _, h, w = rgb.shape + inv = invert(m) + corners = inv @ np.array([[0, 0, 1], [FACE_SIZE, 0, 1], [0, FACE_SIZE, 1], [FACE_SIZE, FACE_SIZE, 1]], np.float64).T + x0 = max(int(np.floor(corners[0].min())) - 1, 0) + x1 = min(int(np.ceil(corners[0].max())) + 2, w) + y0 = max(int(np.floor(corners[1].min())) - 1, 0) + y1 = min(int(np.ceil(corners[1].max())) + 2, h) + if x1 <= x0 or y1 <= y0: + return + ys, xs = np.mgrid[y0:y1, x0:x1].astype(np.float64) + fx = m[0, 0] * xs + m[0, 1] * ys + m[0, 2] + fy = m[1, 0] * xs + m[1, 1] * ys + m[1, 2] + a = bilinear(mask[None], fx, fy, np.zeros(1, np.float32))[0] + a[a < MASK_FLOOR] = 0 + take = a > alpha[y0:y1, x0:x1] + if not take.any(): + return + face = bilinear(restored, fx, fy, BORDER_RGB) + region = out[:, y0:y1, x0:x1] + region[:, take] = face[:, take] + alpha[y0:y1, x0:x1][take] = a[take] + + +# Temporal blending of the restored faces (see restore_faces). Motion is +# judged on the aligned *input* crops, luma averaged over 4x4 blocks: where a +# neighbour's crop differs from this frame's by MOTION_LIMIT (8-bit levels: 15) +# the neighbour contributes nothing, so a moving mouth or eye is not ghosted. +MOTION_BLOCK = 4 +MOTION_LIMIT = 15.0 / 255.0 + + +def motion_crop(face): + """A face crop's luma at 1/MOTION_BLOCK size, for comparing neighbours.""" + y = 0.299 * face[0] + 0.587 * face[1] + 0.114 * face[2] + b = MOTION_BLOCK + return y.reshape(FACE_SIZE // b, b, FACE_SIZE // b, b).mean((1, 3)).astype(np.float32) + + +def motion_weight(a, b): + """Per-pixel weight (FACE_SIZE square) for blending a neighbour whose + motion crop is `b` into a face whose motion crop is `a`.""" + d = np.abs(a - b) + # 3x3 box blur, edges repeated: one noisy block is not motion. + p = np.pad(d, 1, mode="edge") + d = sum(p[i:i + d.shape[0], j:j + d.shape[1]] for i in range(3) for j in range(3)) / 9.0 + w = np.clip(1.0 - d / MOTION_LIMIT, 0.0, 1.0) + return np.repeat(np.repeat(w, MOTION_BLOCK, 0), MOTION_BLOCK, 1) + + +def restore_faces( + clip, + detector, + parser, + restorer, + model="restoreformer", + strength=1.0, + fidelity=0.7, + threshold=0.8, + min_face=32, + max_faces=8, + temporal_radius=1, + temporal_blend=True, + matrix="709", + color_range="limited", +): + """Restore the faces in `clip` (any format) and return it in its own + format. + + `strength` (0-1) scales the blend; `fidelity` is CodeFormer's w. + `threshold` is the detector score a face needs, `min_face` the size (in + pixels, the larger side of its box) below which a face is left alone, + `max_faces` how many of the largest are restored per frame. `matrix` / + `color_range` are the source's (zimg names), for the conversion to RGB and + back. + + `temporal_radius` r > 0 stabilises the faces over time, in two steps: each + face's landmarks are smoothed over the detections r frames either side + (select_faces), and each restored face is averaged, in the aligned 512x512 + space where neighbouring frames' faces line up, with the restored faces + it matches in the r frames either side — weighted down to nothing where + the input itself moved (motion_weight). A restorer re-invents fine + texture for every frame from noisy input; the average keeps what the + frames agree on. `temporal_blend=False` keeps only the landmark smoothing. + Each output frame reads 2r frames either side (r for the blend, and r + more for those frames' landmark smoothing).""" + import vapoursynth as vs + + core = vs.core + fmt = clip.format + if fmt is None: + raise ValueError("face_restore needs a constant-format clip") + r = max(0, int(temporal_radius)) + blend_r = r if temporal_blend else 0 + strength = min(max(float(strength), 0.0), 1.0) + models = FaceRestorer(detector, parser, restorer, model, fidelity) + + rgbs = ai_runtime.to_rgbs(clip, matrix, color_range) + scale = min(1.0, DET_SIZE / max(clip.width, clip.height)) + dw, dh = max(1, round(clip.width * scale)), max(1, round(clip.height * scale)) + small = rgbs if (dw, dh) == (clip.width, clip.height) else core.resize.Bicubic(rgbs, width=dw, height=dh) + det_scale = (dw / clip.width, dh / clip.height) + floor = threshold * 0.75 + last = clip.num_frames - 1 + + def clamp(i): + return min(max(i, 0), last) + + # The frames each request reads: the RGB frames the blend needs (n-b..n+b) + # and the detection copies their landmark smoothing needs (n-b-r..n+b+r). + det_reach = blend_r + r + rgb_offsets = list(range(-blend_r, blend_r + 1)) + det_offsets = list(range(-det_reach, det_reach + 1)) + inputs = [ai_runtime.shifted(rgbs, k) for k in rgb_offsets] + [ai_runtime.shifted(small, k) for k in det_offsets] + + detections = ai_runtime.FrameMemo(capacity=64) + faces_memo = ai_runtime.FrameMemo(capacity=48) + results = ai_runtime.FrameMemo(capacity=16) + + def rgb_frame(frames, n, k): + return frames[rgb_offsets.index(k)] + + def det_frame(frames, n, k): + return frames[len(rgb_offsets) + det_offsets.index(k)] + + def detections_for(index, frame): + def run(): + rgb = np.stack([np.asarray(frame[p]) for p in range(3)]) + return detect(models.det, rgb, det_scale, floor) + + return detections.get(index, run) + + def faces_for(n, frames, k): + """Stage 1 for frame clamp(n + k): its faces, each aligned, restored + and parsed, from that frame and the detections r either side of it.""" + index = clamp(n + k) + + def run(): + dets = [detections_for(clamp(index + j), det_frame(frames, n, k + j)) for j in range(-r, r + 1)] + chosen = select_faces(dets, r, threshold, min_face, max_faces) + src = rgb_frame(frames, n, k) + rgb = np.stack([np.asarray(src[p]) for p in range(3)]).astype(np.float32) + out = [] + for box, marks in chosen: + m = similarity(marks, FFHQ_TEMPLATE) + face = align(rgb, m) + restored = models.restore_face(face) + out.append({ + "box": box, + "m": m, + "motion": motion_crop(face), + "restored": restored.astype(np.float16), + "mask": models.mask_for(restored).astype(np.float16), + }) + return out + + return faces_memo.get(index, run) + + def compute(n, frames): + def run(): + here = faces_for(n, frames, 0) + src = rgb_frame(frames, n, 0) + rgb = np.stack([np.asarray(src[p]) for p in range(3)]).astype(np.float32) + out = rgb.copy() + alpha = np.zeros(rgb.shape[1:], np.float32) + neighbours = [(k, faces_for(n, frames, k)) for k in rgb_offsets if k != 0 and clamp(n + k) != n] + for face in here: + restored = face["restored"].astype(np.float32) + if neighbours: + total = restored * float(blend_r + 1) + weight = np.full(restored.shape[1:], float(blend_r + 1), np.float32) + for k, faces in neighbours: + j = _match(face["box"], np.array([f["box"] for f in faces])) if faces else None + if j is None: + continue + w = (blend_r + 1 - abs(k)) * motion_weight(face["motion"], faces[j]["motion"]) + total += w * faces[j]["restored"].astype(np.float32) + weight += w + restored = total / weight + paste(rgb, out, alpha, restored, face["mask"].astype(np.float32), face["m"]) + return out, alpha * strength, len(here) + + return results.get(n, run) + + def restored_frame(n, f): + out_rgb, _alpha, count = compute(n, f[1:]) + dst = f[0].copy() + for p in range(3): + np.copyto(np.asarray(dst[p]), out_rgb[p]) + dst.props["FaceRestoreFaces"] = count + return dst + + def mask_frame(n, f): + _out, alpha, _count = compute(n, f[1:]) + dst = f[0].copy() + plane = np.asarray(dst[0]) + if mask_fmt.sample_type == vs.FLOAT: + np.copyto(plane, alpha) + else: + peak = (1 << mask_fmt.bits_per_sample) - 1 + np.copyto(plane, np.rint(alpha * peak).astype(plane.dtype)) + return dst + + mask_fmt = core.query_video_format(vs.GRAY, fmt.sample_type, fmt.bits_per_sample, 0, 0) + blank = core.std.BlankClip(clip, format=mask_fmt.id, keep=True) + restored = core.std.ModifyFrame(rgbs, [rgbs] + inputs, restored_frame) + mask = core.std.ModifyFrame(blank, [blank] + inputs, mask_frame) + restored = ai_runtime.from_rgbs(restored, fmt, matrix, color_range) + # Composited in the source's format, so the mask's zeros leave the input + # bit-exact. The mask is luma-sized; MaskedMerge resamples it for + # subsampled chroma. + merged = core.std.MaskedMerge(clip, restored, mask, first_plane=True) + return core.std.CopyFrameProps(merged, clip) diff --git a/worker/templates/pipeline_template.vpy b/worker/templates/pipeline_template.vpy index bb2e7015..d363881f 100644 --- a/worker/templates/pipeline_template.vpy +++ b/worker/templates/pipeline_template.vpy @@ -2183,6 +2183,37 @@ _aspect_box_h = _even(_box_h) if _box_h > 0 else 0 {{/RESIZE_STANDARD}} {{/RESIZE}} +# ============================================================================ +# PASS: FACE RESTORATION +# ============================================================================ +# Each face is found (RetinaFace), aligned to the FFHQ template, restored +# (RestoreFormer++, GFPGAN or CodeFormer), masked to the face itself (BiSeNet) +# and blended back at this resolution: after the resize, so an upscale gives the +# restorer more pixels and no later resample softens what it recovered; before +# sharpening and grain. ONNX Runtime comes from the ai-runtime add-on (the AI +# runtime block above) and the models are add-ons; face_restore.py sits beside +# pipe_source.py. Landmarks are smoothed over the detections either side, and +# the faces are merged in the source format, so every pixel outside them is the +# input, bit-exact. +{{#FACE_RESTORE}} +import face_restore as _face_restore +clip = _face_restore.restore_faces( + clip, + detector={{FR_DETECTOR}}, + parser={{FR_PARSER}}, + restorer={{FR_RESTORER}}, + model="{{FR_MODEL}}", + strength={{FR_STRENGTH}}, + fidelity={{FR_FIDELITY}}, + threshold={{FR_THRESHOLD}}, + min_face={{FR_MIN_FACE}}, + max_faces={{FR_MAX_FACES}}, + temporal_radius={{FR_RADIUS}}, + matrix="{{FR_MATRIX}}", + color_range="{{FR_RANGE}}", +) +{{/FACE_RESTORE}} + # ============================================================================ # PASS: SHARPEN # ============================================================================ diff --git a/worker/templates/preview_template.vpy b/worker/templates/preview_template.vpy index 1b97bb8d..90b08e77 100644 --- a/worker/templates/preview_template.vpy +++ b/worker/templates/preview_template.vpy @@ -2115,6 +2115,37 @@ _aspect_box_h = _even(_box_h) if _box_h > 0 else 0 {{/RESIZE_STANDARD}} {{/RESIZE}} +# ============================================================================ +# PASS: FACE RESTORATION +# ============================================================================ +# Each face is found (RetinaFace), aligned to the FFHQ template, restored +# (RestoreFormer++, GFPGAN or CodeFormer), masked to the face itself (BiSeNet) +# and blended back at this resolution: after the resize, so an upscale gives the +# restorer more pixels and no later resample softens what it recovered; before +# sharpening and grain. ONNX Runtime comes from the ai-runtime add-on (the AI +# runtime block above) and the models are add-ons; face_restore.py sits beside +# pipe_source.py. Landmarks are smoothed over the detections either side, and +# the faces are merged in the source format, so every pixel outside them is the +# input, bit-exact. +{{#FACE_RESTORE}} +import face_restore as _face_restore +clip = _face_restore.restore_faces( + clip, + detector={{FR_DETECTOR}}, + parser={{FR_PARSER}}, + restorer={{FR_RESTORER}}, + model="{{FR_MODEL}}", + strength={{FR_STRENGTH}}, + fidelity={{FR_FIDELITY}}, + threshold={{FR_THRESHOLD}}, + min_face={{FR_MIN_FACE}}, + max_faces={{FR_MAX_FACES}}, + temporal_radius={{FR_RADIUS}}, + matrix="{{FR_MATRIX}}", + color_range="{{FR_RANGE}}", +) +{{/FACE_RESTORE}} + # ============================================================================ # PASS: SHARPEN # ============================================================================ diff --git a/worker/tests/filter_integration_test.rs b/worker/tests/filter_integration_test.rs index 89c94cb9..a29c9863 100644 --- a/worker/tests/filter_integration_test.rs +++ b/worker/tests/filter_integration_test.rs @@ -6879,3 +6879,151 @@ fn test_184_flowfps_length_is_set_by_the_script() { assert!(call < trim && call < pad, "{name}: length set before FlowFPS"); } } + +/// A throwaway add-ons directory with `ids` (and what they require) installed +/// as far as the worker can tell: each manifest's expected paths present +/// (empty files will do for script generation) and its marker written. +fn fake_addons(name: &str, ids: &[&str], platform: &str) -> PathBuf { + use vapourbox_worker::addons; + let root = std::env::temp_dir().join(format!("vb-addons-{name}-{}", Uuid::new_v4())); + for id in addons::with_requirements(ids) { + let m = addons::manifest(&id).expect("manifest"); + let dir = addons::addon_dir(&root, &id); + for p in m.expected_paths(platform) { + let path = dir.join(&p); + if p.ends_with("site-packages") { + std::fs::create_dir_all(&path).unwrap(); + } else { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"").unwrap(); + } + } + std::fs::write( + dir.join(addons::MARKER), + serde_json::json!({"id": id, "version": m.version, "platform": platform}).to_string(), + ) + .unwrap(); + } + root +} + +fn face_restore_job(name: &str, fr: FaceRestoreParameters) -> VideoJob { + let mut job = create_base_job(name); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + crop_resize: CropResizeParameters { + enabled: true, + resize_enabled: true, + target_width: Some(1280), + target_height: Some(960), + ..Default::default() + }, + sharpen: SharpenParameters { enabled: true, ..Default::default() }, + face_restore: FaceRestoreParameters { enabled: true, ..fr }, + ..ProcessingPipeline::default() + }); + job +} + +/// Face Restoration runs face_restore.restore_faces on ONNX Runtime from the +/// add-ons, in both scripts: after the resize and before sharpening, with the +/// AI runtime set up first and every model path taken from its add-on. +#[test] +fn test_185_face_restore_script_and_models() { + create_output_dir(); + let platform = "macos-arm64"; + let root = fake_addons("185", &["face-restoration", "face-codeformer"], platform); + let generator = ScriptGenerator::new().unwrap().with_addons(root.clone(), platform); + let job = face_restore_job("test_185_face_restore", FaceRestoreParameters { + model: FaceRestoreModel::CodeFormer, + strength: 0.6, + code_former_fidelity: 0.5, + detection_threshold: 0.7, + min_face_size: 40, + max_faces: 3, + temporal_smoothing: true, + ..Default::default() + }); + let py = |id: &str, f: &str| vapourbox_worker::gpu_plugin::py_str(&root.join(id).join(f)); + let runtime = vapourbox_worker::gpu_plugin::py_str(&root.join("ai-runtime")); + let (encode, preview) = both_scripts_with(&generator, &job).unwrap(); + for (name, script) in [("encode", &encode), ("preview", &preview)] { + let setup = script + .find(&format!("ai_runtime.setup({runtime})")) + .unwrap_or_else(|| panic!("{name}: no AI runtime")); + let call = script.find("_face_restore.restore_faces(").unwrap_or_else(|| panic!("{name}: no call")); + for want in [ + format!("detector={}", py("face-restoration", "face-restoration-retinaface-r50.onnx")), + format!("parser={}", py("face-restoration", "face-restoration-bisenet.onnx")), + format!("restorer={}", py("face-codeformer", "face-codeformer-v0.1.0.onnx")), + "model=\"codeformer\"".into(), + "strength=0.600".into(), + "fidelity=0.500".into(), + "threshold=0.700".into(), + "min_face=40".into(), + "max_faces=3".into(), + "temporal_radius=1".into(), + "color_range=\"limited\"".into(), + ] { + assert!(script.contains(&want), "{name}: missing {want}"); + } + assert!(setup < call, "{name}: runtime set up after the pass"); + let resize = script.find("target_w = 1280").expect("resize"); + let sharpen = script.find("haf.LSFmod(").expect("sharpen"); + assert!(resize < call && call < sharpen, "{name}: after the resize, before sharpening"); + assert!(!script.contains("{{FR_") && !script.contains("FACE_RESTORE}}"), "{name}: unsubstituted"); + } + // Pass order and the preview's temporal context agree with the script. + let pipeline = job.effective_pipeline(); + let passes = pipeline.enabled_passes(); + let at = |p| passes.iter().position(|x| *x == p).unwrap(); + assert!(at(PassType::CropResize) < at(PassType::FaceRestore)); + assert!(at(PassType::FaceRestore) < at(PassType::Sharpen)); + assert_eq!(pipeline.output_count(100), 100); + // The blend reads one neighbour each side, whose landmarks read one more. + let face_map = pipeline.frame_maps()[at(PassType::FaceRestore)].clone(); + assert_eq!(face_map.radius(), 2); + let mut off = job.clone(); + off.processing_pipeline.as_mut().unwrap().face_restore.temporal_smoothing = false; + assert!(script_text_with(&generator, &off).contains("temporal_radius=0")); + let _ = std::fs::remove_dir_all(&root); +} + +/// Without its add-ons a Face Restoration job is refused, naming them and +/// Settings → Add-ons; with the pass off nothing needs them and the script +/// has neither the runtime nor the call. +#[test] +fn test_186_face_restore_refused_without_addons() { + create_output_dir(); + let platform = "macos-arm64"; + // The base installed but not GFPGAN's add-on. + let root = fake_addons("186", &["face-restoration"], platform); + let generator = ScriptGenerator::new().unwrap().with_addons(root.clone(), platform); + let gfpgan = face_restore_job("test_186_gfpgan", FaceRestoreParameters { + model: FaceRestoreModel::Gfpgan, + ..Default::default() + }); + let err = both_scripts_with(&generator, &gfpgan).unwrap_err().to_string(); + assert!(err.contains("Face restoration"), "{err}"); + assert!(err.contains("Settings → Add-ons"), "{err}"); + assert!(err.to_lowercase().contains("gfpgan"), "{err}"); + // RestoreFormer++ needs only the base. + let rf = face_restore_job("test_186_rf", FaceRestoreParameters::default()); + let (encode, _) = both_scripts_with(&generator, &rf).expect("the base add-on is enough"); + assert!(encode.contains("model=\"restoreformer\"")); + assert!(encode.contains("strength=0.800")); + // Nothing installed at all. + let empty = std::env::temp_dir().join(format!("vb-addons-186-empty-{}", Uuid::new_v4())); + let bare = ScriptGenerator::new().unwrap().with_addons(empty, platform); + assert!(both_scripts_with(&bare, &rf).is_err()); + // Pass off: no add-on needed, no runtime, no call. + let mut off = rf.clone(); + off.processing_pipeline.as_mut().unwrap().face_restore.enabled = false; + let (encode, preview) = both_scripts_with(&bare, &off).unwrap(); + for script in [&encode, &preview] { + assert!(!script.contains("import ai_runtime")); + assert!(!script.contains("restore_faces(")); + } + let _ = std::fs::remove_dir_all(&root); +} From c926cde0893ec893486edad15811cb466f79bf66 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 8 Nov 2026 18:39:17 +1100 Subject: [PATCH 17/61] docs(audio): rules, report and notes; test that subtitles follow the offset CLAUDE.md (Audio Restoration section, the subtitle and -af rules), README, docs/ADDONS.md (executable files), ENGINEERING_NOTES and the report docs/reports/2026-11-audio-restoration: afftdn's 25 ms delay and noise floor, atempo's ~20 ms shift, alimiter's latency and auto-level, the late-start gap, hum notch depth, loudness accuracy, DeepFilterNet's delay. Adds the heavy test that Whisper's cues move with a 2 s sync offset, and WorkerHarness.ensureWhisper for running it locally. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 98 +++++++- README.md | 3 +- .../integration_audio_restoration_test.dart | 56 +++++ app/test/support/worker_harness.dart | 23 ++ docs/ADDONS.md | 14 +- docs/ENGINEERING_NOTES.md | 55 +++++ .../2026-11-audio-restoration/index.html | 214 ++++++++++++++++++ .../scripts/common.py | 62 +++++ .../scripts/delay_probe.py | 78 +++++++ .../scripts/quality_probe.py | 132 +++++++++++ .../2026-11-audio-restoration/scripts/run.sh | 25 ++ .../scripts/start_offset_probe.py | 44 ++++ .../scripts/tempo_denoise_probe.py | 67 ++++++ docs/reports/README.md | 1 + 14 files changed, 861 insertions(+), 11 deletions(-) create mode 100644 docs/reports/2026-11-audio-restoration/index.html create mode 100644 docs/reports/2026-11-audio-restoration/scripts/common.py create mode 100644 docs/reports/2026-11-audio-restoration/scripts/delay_probe.py create mode 100644 docs/reports/2026-11-audio-restoration/scripts/quality_probe.py create mode 100755 docs/reports/2026-11-audio-restoration/scripts/run.sh create mode 100644 docs/reports/2026-11-audio-restoration/scripts/start_offset_probe.py create mode 100644 docs/reports/2026-11-audio-restoration/scripts/tempo_denoise_probe.py diff --git a/CLAUDE.md b/CLAUDE.md index 092e2396..16e986e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,6 +125,7 @@ VapourBox/ | `worker/src/comment_metadata.rs` | The source's comment tag, prefixed with the version and passed to ffmpeg as an FFMETADATA file (issue #138) | | `worker/src/hdr_metadata.rs` | HDR10 mastering display / MaxCLL read from the source for x265, and the Dolby Vision profile (see "HDR Sources and Dolby Vision") | | `worker/src/native_res.rs` | `--detect-native`: the resolution and kernel a source was upscaled from, for the Descale pass (see "Descale") | +| `worker/src/audio_restoration.rs` | Audio restoration: probes, the one `AudioPlan` (every `-af` filter, in order), the render before the encode, and the encoder's audio map/codec args (see "Audio Restoration") | | `worker/templates/pipeline_template.vpy` | VapourSynth script template | | `worker/tests/filter_integration_test.rs` | Filter integration tests | @@ -142,6 +143,7 @@ VapourBox/ | `app/lib/models/native_res_report.dart` | What `--detect-native` found, as the Descale panel's Detect shows and applies it | | `app/lib/services/addon_service.dart` | Every optional third-party download (Whisper, AnimeJaNai): catalogue, install with requirements, remove with dependants (see "Add-ons") | | `app/lib/services/manifest_addon.dart` | `AddonManifest` + `ManifestAddonInstaller`: the generic installer for `app/assets/addons/*.json` (docs/ADDONS.md) | +| `app/lib/models/audio_restoration_parameters.dart` | The Audio Restoration pass (`ProcessingPipeline.audio`, `PassType.audioRestoration`) and the DeepFilterNet add-on prompt | | `app/lib/models/ai_runtime_status.dart` | The worker's `--probe-ai-runtime` result | | `app/lib/models/hdr_display.dart` | The display tone-map for the app's own source decodes (twin of `ColorMetadata::display_tonemap_filter`) and Dolby Vision detection | | `app/lib/models/gpu_plugin_status.dart` | The worker's GPU-plugin probe results, and the panels' notice decision | @@ -690,19 +692,27 @@ default pipeline must be completely silent — both are asserted in `pass_advice_test.dart` / `pass_relevance_test.dart`. **Subtitles: transcribe first, mux last.** The pipeline order is -`transcribe the source -> encode (burning in if asked) -> mux as a post-pass`, -and it is load-bearing: burn-in needs the transcript before the encoder runs; -muxing needs the encoded file to exist first. Transcription must run against the -same trimmed audio window the encode uses (`extract_audio_range`), or cues drift -by exactly the trimmed-off head — this is safe only because **nothing in the -pipeline retimes audio**; a future audio-retiming pass would need to account for -it. `SubtitleOutput::{burns_in, muxes, keeps_srt_file}` are asserted to cover -every mode, so a mode that does none of the three can't silently produce nothing. +`render restored audio (if on) -> transcribe -> encode (burning in if asked) -> +mux as a post-pass`, and it is load-bearing: burn-in needs the transcript before +the encoder runs; muxing needs the encoded file to exist first. **Transcription +must hear exactly the output's soundtrack.** With Audio Restoration on, that is +the rendered track (`AudioRestorer::render`), which is the output's audio +sample for sample — trimmed, offset, drift-corrected — so cues follow any +retiming by construction (`integration_audio_restoration_test.dart` checks a +2 s offset moves the cues ~2 s). Without it, transcription reads the same +trimmed window the encode uses (`extract_audio_range`), or cues drift by exactly +the trimmed-off head; that is safe because nothing *else* retimes audio (IVTC +and frame-rate conversion change the video timeline only). Any new step that +moves audio in time belongs in `audio_restoration`, never in the encode. +`SubtitleOutput::{burns_in, muxes, keeps_srt_file}` are asserted to cover every +mode, so a mode that does none of the three can't silently produce nothing. **FFmpeg's `-vf` chain accumulates into one `Vec` joined with commas — never push a second bare `-vf`.** ffmpeg only honours the *last* `-vf`, so two separate pushes (e.g. aspect stamping and subtitle burn-in) would silently drop -the first one's effect. +the first one's effect. The same goes for `-af`: every audio filter is chosen in +`AudioPlan::new` and each ffmpeg run gets exactly one `-af`; the encode itself +never filters audio (`test_real_ffmpeg_builder_delegates_audio_to_audio_restoration`). **Nothing of unbounded length goes on a child process's command line.** Windows caps it at 32,767 characters and refuses to start the process (os @@ -1375,6 +1385,10 @@ by `AddonService.addonsDir`), only when the user asks. Rules: is staged in `.new` and ends with the `.vapourbox-addon.json` marker (id, version, platform); a manifest `version` bump makes the old install count as missing. Whisper and AnimeJaNai keep their hand-written installers. + A plain file marked `"executable": true` is a program the worker runs + (DeepFilterNet): the installer sets its executable bit and, on macOS, signs + it ad hoc if `codesign --verify` fails (Apple Silicon will not run an + unsigned arm64 binary); the worker finds it with `Manifest::executable_for`. - **A pass declares its add-ons in `ProcessingPipeline::required_addons()`** (`AddonNeed { feature, ids }`); `ScriptGenerator::ensure_methods_ready` refuses the job via `addons::require` (naming them and Settings → Add-ons), @@ -1400,6 +1414,72 @@ by `AddonService.addonsDir`), only when the user asks. Rules: (heavy) installs `ai-runtime` and runs the probe. `manifest_addon_test.dart` asserts the worker (`--list-addons`) compiled in the same manifests. +### Audio Restoration + +The one pass that works on the soundtrack (`AudioRestorationParameters`, +`ProcessingPipeline.audio`, app 1.7.0), in the pass list's "Audio & Subtitles" +stage. **Not a VapourSynth pass**: it is not in `PassType` (Rust) or +`enabled_passes`, never touches either `.vpy` (`test_185`), and the preview is +picture only (the panel says so; its edits don't regenerate the preview). +Rules, from docs/reports/2026-11-audio-restoration: + +- **Rendered before the encode, to a WAV, then mapped by the encoder** — never + an `-af` on the encode. That is what makes DeepFilterNet (a separate + program), two-pass loudness, a negative offset (reading audio from before the + trim point) and transcribing the output's own soundtrack possible, and it + keeps audio independent of how the video encode is run. `RenderedAudio` owns + the scratch directory and is held to the end of the job (after the mux). +- **One decision site, fixed order.** `AudioPlan::new` chooses every filter: + `aresample=first_pts=0` (a stream that starts after the video keeps its gap — + a WAV has no timestamps) → `pan` → `adeclip` → `adeclick` → rumble `highpass` + → hum `bandreject` ×3 → `afftdn` + `atrim` of its delay → drift resample → + lead `adelay` → [DeepFilterNet] → `loudnorm` + `aresample` → `alimiter` → + `apad`+`atrim` to the picture's length. Each ffmpeg run gets one `-af` + (`every_combination_keeps_the_order_and_one_af_per_run`, 512 × 3 cases). +- **Measured traps, each now handled in the plan:** `afftdn` outputs 25 ms + late at every rate (`atrim=start_sample=rate/40`); its default `nf=-50` took + 0.7 dB off −39 dB hiss — it gets the source's measured floor (quietest tenth + of 50 ms windows, clamped −80…−30), and never `tn=1`, which undid it; + `alimiter` needs `latency=1` (else it delays by its attack) and `level=0` + (else it normalises up to 0 dBFS); `loudnorm` resamples to 192 kHz, so an + `aresample` follows it; DeepFilterNet delays 30 ms — `-D` compensates and + shortens the tail, which `apad` restores. +- **Drift is corrected by resampling, not `atempo`.** atempo moved every click + ~20 ms early; drift is a sample-clock error, so resampling restores pitch as + well as length. `asetrate` takes only an integer, so the plan upsamples to an + `r1` in 4–8× the rate where `s / r1` is within ~1e-11 of the factor + (`resample_ratio`). The factor is (whole source's audio length) / (whole + encoded picture's length, `ProcessingPipeline::output_duration_secs` — + output frames over output rate from the `FrameMap`s); applied only when the + difference is > 0.05 s and ≤ the user's cap (0.5% default), skipped within + 1e-6 of 1. +- **Offset and drift compose on the source's timeline**: source time for output + time t is `(start + t − offset) × factor`; a positive offset from the start + is silence (`adelay`), never a negative seek. +- **The window is the encoded picture's** (`OutputWindow::for_job`): from the + trim's in-point, `output_duration_secs` long. The rendered track is exactly + that many samples, so with restoration the output's audio is the picture's + length (passthrough could leave it longer). +- **One stream is restored** (`streamIndex`, default the first). The encoder + maps the rendered track first, then the source's other audio streams + untouched, each with its own tags (`encode_map_args`; naming one stream's + `-map_metadata` switches off ffmpeg's copying for the rest). Passthrough + copies the others but must encode the restored one: FLAC in MKV, AAC at the + chosen quality in MP4/MOV, PCM in AVI (`restored_passthrough_codec`). +- **Automatic choices are measured, never guessed**: a dead channel (≥30 dB + below the other, which is above −60 dBFS) and 50/60 Hz hum (Goertzel over + 2 s blocks, a line ≥12 dB above its neighbours) are read from a minute in + the middle of the window; finding nothing leaves the audio alone and says so. +- **DeepFilterNet is the `deepfilternet` manifest add-on** (upstream + deep-filter 0.5.6 binaries pinned by hash, MIT OR Apache-2.0, `minOs` 13.0 + on macOS since upstream builds target it). The worker checks it in + `AudioRestorer::plan` (`DEEP_FILTER_FEATURE`), **not** in + `required_addons()`: that list gates the script generator, and a preview + must never be refused for an audio add-on. +- `vapourbox-worker --audio-plan --config job.json` prints every argument list + without rendering (`integration_audio_plan_test.dart`, push gate); + `integration_audio_restoration_test.dart` (heavy) measures the encoded audio. + ### BM3D and KNLMeans Noise Reduction Two advanced-only Noise Reduction methods (`bm3d`, `knlmeans`, app 1.5.0; diff --git a/README.md b/README.md index db5518a1..0940b355 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ VapourBox runs [VapourSynth](https://www.vapoursynth.com/), QTGMC and FFmpeg for - **Cleans up old footage.** Optional filters for tape noise, smeared colour, dust and scratches, flicker, ghosting, compression blocking, banding, camera shake and dark, flat pictures. They're off until you turn them on, and the presets turn on what a given kind of source needs. - **Restores anime.** **Descale** undoes the upscale most HD anime went through in production, **BM3D** cleans flat colour without smearing lines, and **AI upscaling** redraws line art at 2× or 4×: Real-ESRGAN (sharpest, fastest), Real-CUGAN (gentler, with denoising) or AnimeJaNai (trained on HD anime). - **Converts frame rates** — the broadcast standards or any rate you type in (24 → 48 or 60, say) — with motion interpolation or the **RIFE** AI interpolator. +- **Restores the sound too.** Replaces a dead channel, removes hum, rumble, clicks, clipping and hiss, cleans up speech with the **DeepFilterNet** AI model, puts sound that is out of step or drifting back in sync, and evens out loudness. - **Writes subtitles** from speech with Whisper AI, as an `.srt` file, an embedded track, or burnt into the picture. ## How it works @@ -82,7 +83,7 @@ Where an AppImage can't be mounted, use the `.tar.gz` of the same build: extract **GPU features.** RIFE frame interpolation and AI upscaling run on the graphics card through Vulkan: on any Apple Silicon Mac, and on Windows and Linux x64 PCs with a current graphics driver (RIFE on Windows also needs the Microsoft Visual C++ Redistributable). They aren't available on Intel Macs or Linux on ARM yet. GPU deinterlacing and KNLMeans denoising use OpenCL instead; without it, deinterlacing falls back to the CPU. Whenever you choose a GPU feature your machine can't run, VapourBox says what's missing and how to fix it. -**Add-ons.** A few features use third-party downloads that aren't part of VapourBox: the Whisper engine and language models for subtitles, the AnimeJaNai models for AI upscaling, and the AI runtime (Microsoft's ONNX Runtime) that AI model add-ons run on. VapourBox offers to download one the first time you need it, stating its source, size and licence, and **Settings → Add-ons** installs or removes them at any time. AnimeJaNai is free for **non-commercial use only** (CC BY-NC-SA 4.0); for paid work, use Real-ESRGAN or Real-CUGAN instead. +**Add-ons.** A few features use third-party downloads that aren't part of VapourBox: the Whisper engine and language models for subtitles, the AnimeJaNai models for AI upscaling, DeepFilterNet for speech enhancement in Audio Restoration, and the AI runtime (Microsoft's ONNX Runtime) that AI model add-ons run on. VapourBox offers to download one the first time you need it, stating its source, size and licence, and **Settings → Add-ons** installs or removes them at any time. AnimeJaNai is free for **non-commercial use only** (CC BY-NC-SA 4.0); for paid work, use Real-ESRGAN or Real-CUGAN instead. **Older processors.** Intel and AMD CPUs from before about 2013 (no AVX2) are supported on every platform. VapourBox checks the processor on first launch and installs a matching set of components, which runs some filters more slowly but never differently. diff --git a/app/test/integration_audio_restoration_test.dart b/app/test/integration_audio_restoration_test.dart index e41d36b0..ca096571 100644 --- a/app/test/integration_audio_restoration_test.dart +++ b/app/test/integration_audio_restoration_test.dart @@ -15,6 +15,7 @@ import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; import 'support/audio_fixtures.dart'; import 'support/worker_harness.dart'; @@ -254,6 +255,61 @@ void main() { }); }); + test('subtitles are transcribed from the restored sound, so cues follow the offset', () async { + // CI provisions Whisper; locally, VAPOURBOX_FETCH_WHISPER=1 installs the + // engine and the small model (466 MB) with the app's own installers. + if (Platform.environment['VAPOURBOX_FETCH_WHISPER'] == '1') await WorkerHarness.ensureWhisper(); + // Where the worker looks (DependencyLocator::whisper_path / whisper_model_path). + final whisper = p.join(WorkerHarness.addonsDirectory.path, 'whisper'); + final cli = Platform.isWindows ? p.join(whisper, 'whisper-cli.exe') : p.join(whisper, 'bin', 'whisper-cli'); + if (!File(cli).existsSync() || !File(p.join(whisper, 'models', 'ggml-small.bin')).existsSync()) { + markTestSkipped('whisper add-on not present (VAPOURBOX_FETCH_WHISPER=1 installs it)'); + return; + } + final clip = p.join(WorkerHarness.repoRoot, 'Tests', 'TestResources', 'small_clip.mp4'); + final video = await WorkerHarness.firstStream(clip, + selector: 'v:0', entries: ['width', 'height', 'pix_fmt']); + /// Cue start times by their text (letters only, lower case), for texts + /// that occur once. + Future> cues(int offsetMs) async { + final job = AudioFixtures.job(clip, {'offsetMs': offsetMs}, + label: 'subtitles_offset_$offsetMs', + extra: { + // The first 30 s. + 'startFrame': 0, 'endFrame': 899, 'totalFrames': 900, + 'inputFrameRate': 29.97, 'inputWidth': video!['width'], 'inputHeight': video['height'], + 'inputPixelFormat': video['pix_fmt'], + 'subtitleSettings': {'enabled': true, 'model': 'small', 'output': 'srt_file', 'language': 'en'}, + }); + final out = await _encode(job); + final srt = File(p.setExtension(out, '.srt')).readAsStringSync().replaceAll('\r\n', '\n'); + final byText = >{}; + for (final block in srt.split(RegExp(r'\n\s*\n'))) { + final m = RegExp(r'(\d\d):(\d\d):(\d\d),(\d\d\d) -->[^\n]*\n([\s\S]*)').firstMatch(block); + if (m == null) continue; + final start = int.parse(m.group(1)!) * 3600 + int.parse(m.group(2)!) * 60 + + int.parse(m.group(3)!) + int.parse(m.group(4)!) / 1000; + final text = m.group(5)!.toLowerCase().replaceAll(RegExp(r'[^a-z]'), ''); + if (text.length >= 8) byText.putIfAbsent(text, () => []).add(start); + } + return {for (final e in byText.entries) if (e.value.length == 1) e.key: e.value.single}; + } + + final plain = await cues(0); + final delayed = await cues(2000); + final shifts = [ + for (final text in plain.keys) + if (delayed.containsKey(text)) delayed[text]! - plain[text]!, + ]..sort(); + // Whisper segments the two runs differently and its timestamps are + // coarse, so the cues it transcribed identically are compared, by their + // median shift, to the 2 s the sound was moved. + expect(shifts.length, greaterThanOrEqualTo(2), + reason: 'too few cues transcribed alike to compare: $plain / $delayed'); + expect(shifts[shifts.length ~/ 2], closeTo(2.0, 0.6), + reason: 'cue shifts $shifts (cues $plain, then $delayed)'); + }, timeout: const Timeout(Duration(minutes: 15))); + test('DeepFilterNet lowers stationary noise and keeps the timing', () async { await WorkerHarness.ensureAddon('deepfilternet'); // A voiced, syllable-modulated harmonic complex in bursts, over pink noise. diff --git a/app/test/support/worker_harness.dart b/app/test/support/worker_harness.dart index 61364128..761892ee 100644 --- a/app/test/support/worker_harness.dart +++ b/app/test/support/worker_harness.dart @@ -33,6 +33,7 @@ import 'package:path/path.dart' as p; import 'package:vapourbox/services/addon_manager.dart'; import 'package:vapourbox/services/addon_service.dart'; import 'package:vapourbox/services/manifest_addon.dart'; +import 'package:vapourbox/services/whisper_addon_manager.dart'; import 'package:vapourbox/services/dependency_manager.dart' show DepsVersionInfo, DependencyManager; @@ -195,6 +196,28 @@ class WorkerHarness { return Directory(p.join(addons.path, id)); } + /// Install the Whisper engine and one language model where the worker finds + /// them, with the app's own installers (from `assets/whisper-addon.json`). + /// For a local run of the subtitle tests (CI provisions the add-on itself); + /// the small model is 466 MB. No-op for what is already installed. + static Future ensureWhisper({String model = 'small'}) async { + final addons = addonsDirectory; + final manager = WhisperAddonManager( + jsonDecode(File(p.join(repoRoot, 'app', 'assets', 'whisper-addon.json')).readAsStringSync()) + as Map, + platform); + for (final installer in manager.installers()) { + final id = installer.info.id; + if (id != WhisperAddonManager.engineAddonId && id != WhisperAddonManager.modelAddonId(model)) { + continue; + } + if (installer.isInstalled(addons)) continue; + stderr.writeln('[harness] installing $id into ${addons.path}'); + await installer.install(addons, _IoDownloader()); + } + return Directory(p.join(addons.path, WhisperAddonManager.dirName)); + } + /// Whether the whisper add-on (whisper-cli) is available. Subtitle tests that /// run the full pipeline must skip when this is false (local dev w/o add-on). static bool get whisperAvailable { diff --git a/docs/ADDONS.md b/docs/ADDONS.md index d58c1bf7..ac9b1c87 100644 --- a/docs/ADDONS.md +++ b/docs/ADDONS.md @@ -84,11 +84,23 @@ source of truth for what an add-on is and what an install of it holds. | `kind` | `files` — a model pack: each file is saved as `addons//`, or for an entry with `extract`, only the listed members are kept (each checked against its own hash; nothing else in the archive is written). `python` — every file is a wheel, unpacked into `addons//site-packages/` as pip lays it out (`*.data/purelib`/`platlib` at the top, other `.data` dropped). | | `python` | for a python add-on, the wheel tag it targets (`cp312`: the deps bundle's Python on every platform). | | `platforms` | `"all"`, or a table of the deps platform ids (`macos-arm64`, `macos-x64`, `windows-x64`, `linux-x64`, `linux-arm64`) it supports, each with optional `minOs` (macOS only, checked at run time against the running system). Anything else gets an `unavailableReason` and cannot be installed. | -| `files[]` | `name` (a plain file name), `url` (https), `size` (bytes), `sha256`; optional `platforms` (default: every supported platform) and `extract` (members: `path` in the archive, `name` to install as, default its basename, and `sha256`). Every supported platform must have at least one file. | +| `files[]` | `name` (a plain file name), `url` (https), `size` (bytes), `sha256`; optional `platforms` (default: every supported platform), `extract` (members: `path` in the archive, `name` to install as, default its basename, and `sha256`) and `executable` (see below). Every supported platform must have at least one file. | `AddonManifest.fromJson` validates all of this, so a malformed manifest fails `manifest_addon_test.dart` rather than half-working. +### Programs: `executable` + +A plain file (not an archive entry, not in a `python` add-on) marked +`"executable": true` is a program the worker runs — DeepFilterNet's +`deep-filter` (`app/assets/addons/deepfilternet.json`) is the first. On install +the app sets its executable bit (downloads have none) and, on macOS, signs it +ad hoc when `codesign --verify` fails: Apple Silicon refuses to run an unsigned +arm64 binary, and upstream builds are often unsigned (DeepFilterNet's Intel one +is; its arm64 one is linker-signed and left alone). Quarantine is cleared with +the rest of the install. Each platform's program can keep upstream's own file +name: the worker finds it with `addons::Manifest::executable_for(platform)`. + ### What an install looks like ``` diff --git a/docs/ENGINEERING_NOTES.md b/docs/ENGINEERING_NOTES.md index e64a1907..97d6e722 100644 --- a/docs/ENGINEERING_NOTES.md +++ b/docs/ENGINEERING_NOTES.md @@ -92,6 +92,13 @@ them, and neither can move. > original duration; audio is only ever `-ss` seeked and `-shortest` truncated. > If a pass is ever added that *does* retime audio (an `atempo` for a declared > frame-rate change, say), this breaks and the subtitles drift. +> +> **2026-11-08: one now does.** Audio Restoration's sync offset and drift +> correction move audio in time. It renders the output's soundtrack before the +> encode, and transcription reads that rendered track instead of the source +> window, so cues follow the retiming by construction (see "Audio restoration" +> below). The rule that survives: transcription hears exactly the output's +> audio, and anything that moves audio lives in `audio_restoration`. `SubtitleOutput::{burns_in, muxes, keeps_srt_file}` answer the three independent questions rather than matching the enum in three places. A test asserts every @@ -2915,6 +2922,54 @@ date (groundwork, not a quality comparison, so no report): first; whether Core ML or the CPU fallback executed the two-node model is not distinguishable from `get_providers()`. +### Audio restoration: rendered before the encode; four filter traps measured (2026-11-08) + +Full report with scripts: +[docs/reports/2026-11-audio-restoration/](reports/2026-11-audio-restoration/index.html). + +A new pass, Audio Restoration, renders one audio stream to a WAV before the +video encode (channel fix, sync offset, drift, declip/declick/rumble/hum, +afftdn, DeepFilterNet as an add-on, two-pass loudness, limiter); the encoder +maps that track first and keeps the other streams. Rendering first, not an +`-af` on the encode, is what DeepFilterNet, two-pass loudness, negative offsets +and subtitle alignment all need. Measured with ffmpeg 9.0.2 and deep-filter +0.5.6 on Apple Silicon, on lavfi-generated signals: + +- **afftdn delays by 25 ms at every rate and strength** (1200 samples at + 48 kHz, 1102 at 44.1, 800 at 32; Video-Enhancer's 1200 confirmed) — trimmed + with `atrim=start_sample`. Its default `nf=-50` took 0.7 dB off −39 dB white + noise; with the measured floor (quietest tenth of 50 ms windows) 5.5/9.8/ + 13.5/15.2 dB at nr 6/12/20/30, a 440 Hz tone unchanged; `tn=1` (noise + tracking) undid the measured floor entirely (0.7 dB again), so it is off. +- **atempo moved every click ~20 ms early** (≈ −19 to −21 ms after the first, + at 1.004, 0.996 and 1.02), on top of the stretch. Drift is now an exact + resample (`aresample` to 4–8× → integer `asetrate` → `aresample` back, + ratio within ~1e-11): click positions within 0.01 ms. Resampling is also + the physically right inverse of a sample-clock error (it restores pitch). +- **`alimiter` delays by its attack unless `latency=1`, and normalises to + 0 dBFS unless `level=0`**: measured 239 samples (5 ms) late at 48 kHz, and a + limit of −6 dB came out peaking at 0.00 dBFS; with both set, 0 lag and + −6.02 dBFS. loudnorm, adeclick, adeclip, highpass, the notches and the + drift resample measured zero lag; loudnorm outputs 192 kHz. +- **A late-starting audio stream** (capture audio 0.5 s after the video) lost + its gap in a WAV: `aresample=first_pts=0` restores it with no seek; with an + input seek the window starts at the seek point as before. +- **DeepFilterNet** delays 30 ms (1440 samples at 48 kHz); `-D` compensates + and shortens the output by as much, padded back. It takes 44.1 kHz input and + always writes 48 kHz. On a synthetic voiced signal in pink noise: pauses + 25.3/22.5/13.7 dB quieter at `-a` 100/30/15, voiced level −1.1 dB, ~4 s per + minute (mono, M-series CPU). Its SNR *inside* the synthetic voice dropped + (20.9 → 13.8 dB): a harmonic test tone is not speech, which is the network's + domain — judge it by ear on real dialogue. Not tested on Windows/Linux, or + the macOS x64 build (unsigned; the installer signs it). +- Hum notches (`bandreject` Q 8 at f/2f/3f): 47/61/71 dB down at 50/100/150 Hz, + 1 kHz unchanged. Two-pass linear loudnorm hit −23 and −16 LUFS to 0.0 LU. +- Found on the way, not fixed (another area): `whisper-addon.json` states + `ggml-small.bin` as 466,616,853 bytes; HuggingFace serves 487,601,967. The + app only uses the size for progress, but `WorkerHarness`'s downloader + enforces it, so `WorkerHarness.ensureWhisper(model: 'small')` fails until + the manifest is corrected. + ### 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/2026-11-audio-restoration/index.html b/docs/reports/2026-11-audio-restoration/index.html new file mode 100644 index 00000000..ec41597e --- /dev/null +++ b/docs/reports/2026-11-audio-restoration/index.html @@ -0,0 +1,214 @@ + + + + + +Audio Restoration Probe + + + +
+

Audio restoration: where the sound moves, and what each step does

+

2026-11-08 · ffmpeg 9.0.2 (deps bundle, macOS arm64) · deep-filter 0.5.6 · Apple Silicon · +signals generated with ffmpeg's lavfi sources · scripts in scripts/ (run.sh reproduces every number)

+ +
+

Question. VapourBox copied or re-encoded audio and never processed it. The Audio Restoration pass adds channel fixes, a sync offset, drift correction, declip/declick, rumble and hum removal, broadband denoise (afftdn), DeepFilterNet speech enhancement, loudness normalisation and a limiter. Before building it on ffmpeg's filters: which of them move the sound in time, which defaults are wrong for this use, and does each actually do its job on a known signal?

+

Outcome: built. Four filters move or mis-handle the audio with their defaults; each is handled in the one place every filter is chosen (AudioPlan::new, worker/src/audio_restoration.rs):

+
    +
  • afftdn outputs its input 25 ms late at every rate and strength → trimmed with atrim=start_sample; its default noise floor (nf=-50) barely touched real hiss → the source's measured floor is passed; its noise tracking (tn=1) undid that → off.
  • +
  • atempo moved every click ~20 ms early on top of the stretch → drift is corrected by an exact resample instead.
  • +
  • alimiter delays by its attack and normalises back up to 0 dBFS by default → latency=1:level=0.
  • +
  • Rendering to a WAV loses a late-starting audio stream's gap → aresample=first_pts=0 first.
  • +
+

DeepFilterNet (an add-on) delays by 30 ms; its own -D compensates. The architecture decision — render the soundtrack once, before the video encode, rather than filtering in the encode — is what DeepFilterNet, two-pass loudness, negative offsets and subtitle alignment all need.

+
+ +

1. Which steps move the sound in time

+

A click track (3 ms bursts of 3 kHz at irregular times, plus a 440 Hz tone and pink noise) is filtered and cross-correlated with itself (delay_probe.py). Positive lag = output late.

+
+ + + + + + + + + +
Lag in samples
Filter48 kHz44.1 kHz32 kHz
afftdn=nr=6 / 12 / 24:nf=-4012001102800
alimiter=limit=0.5 (defaults)239219159
alimiter=limit=0.5:level=0:latency=1000
adeclick, adeclip000
highpass=f=40, bandreject ×2000
loudnorm (+ aresample back from 192 kHz)000
drift resample (aresample 4× → asetrate → aresample)000
+

afftdn's delay is exactly 25 ms (rate / 40 samples) whatever its strength — Video-Enhancer's 1200 samples at 48 kHz is the same number. The plan trims round(rate × 0.025) samples at the source's own rate (1103 at 44.1 kHz; the measured 1102 is the correlation peak's integer position, within a sample).

+

alimiter's default also normalises: a click track peaking at −1.34 dBFS, limited to −6 dB, came out peaking at 0.00 dBFS with defaults and −6.02 dBFS with level=0:latency=1.

+ +

DeepFilterNet

+
+ + + + + + +
deep-filter 0.5.6 on a 12 s click track
InputFlagsOutput samples (48 kHz)Lag (samples at 48 kHz)
48 kHz, 576000 samples—5760001440
48 kHz-D5745600
44.1 kHz—5760001439
44.1 kHz-D574560−1
+

It accepts 44.1 kHz and always writes 48 kHz. -D removes its 30 ms STFT + lookahead delay and leaves the track that much short; the plan pads every track to the picture's exact sample count at the end (apad=whole_len + atrim=end_sample), so that is harmless.

+ +

atempo stretches and shifts

+

tempo_denoise_probe.py: each click's position after the stretch against the expected t / factor.

+
+ + + + + + +
Click position error
ChainMeanFirst clickLast click
atempo=1.004−19.13 ms0.00 ms−21.18 ms
atempo=0.996−19.38 ms0.00 ms−21.42 ms
atempo=1.02−19.04 ms0.00 ms−19.21 ms
asetrate + aresample (×1.004, ×0.996, ×1.02)0.01 ms0.00 ms≤0.01 ms
+

The brief said atempo; the measurement says no. Drift between a capture's audio and its picture is a sample-clock error, so resampling is the exact inverse — it restores the original pitch along with the length (at most 0.5% by default, about 9 cents). asetrate only takes an integer rate, which straight from 48 kHz would be off by up to 1e-5 (37 ms over an hour), so the plan upsamples to the rate between 4× and 8× where round(r1 × factor) / r1 is closest to the factor — within ~1e-11 (resample_ratio, unit-tested to under 0.4 ms an hour). The heavy test measures the encoded result: a 20 s picture with 20.08 s of audio puts every click within 2 ms of t / 1.004.

+ +

2. afftdn's noise floor

+

White noise at −38.8 dBFS RMS with clicks; the noise level measured in a click-free stretch after afftdn has adapted (tempo_denoise_probe.py).

+
+ + + + + + +
Noise floor after afftdn, nr=20
ChainFloorDown by
afftdn=nr=20:nf=-50:tn=1 (first build)−39.6 dB0.7 dB
afftdn=nr=20:nf=-50 (default floor)−39.9 dB1.1 dB
afftdn=nr=20:nf=-39 (measured floor)−52.4 dB13.6 dB
afftdn=nr=20:nf=-39:tn=1−39.6 dB0.7 dB
+

Correction, kept visible: the first build passed nf=-50:tn=1, reasoning that noise tracking would find the floor itself. The first heavy-test run showed 0.7 dB of reduction. Tracking does not rescue a wrong floor here — it defeats a right one.

+

The floor afftdn is given is the source's: the RMS of the quietest tenth of 50 ms windows in a minute from the middle of the window, decoded at full bandwidth (white hiss is 8 dB quieter below 4 kHz than across 24, so an 8 kHz analysis would underestimate it), clamped to −80…−30 dB. The upper clamp guards against content with no pauses (continuous music), whose "floor" is the content itself.

+
+ + + + 0 + 5 + 10 + 15 + + + + + + + 5.5 + 9.8 + 13.5 + 15.2 + nr 6 + nr 12 + nr 20 + nr 30 + dB down in a pause + +
afftdn with the measured floor (−38.8 dB) on a gated 440 Hz tone over white noise (quality_probe.py): noise removed in the pauses at each strength. The tone's level in the bursts changed by less than 0.01 dB at every strength.
+
+ +

3. The other steps on known signals

+
+ + + + + + +
Hum notches, bandreject Q 8 at f, 2f, 3f (1 kHz tone + 50/100/150 Hz)
FrequencyDown by
50 Hz47.1 dB
100 Hz60.7 dB
150 Hz70.5 dB
1 kHz0.0 dB
+

Automatic hum detection reads the same minute as the noise floor: Goertzel power at 50/100/150 and 60/120/180 Hz, each in 2 s blocks (0.5 Hz resolution, so mains wandering by a tenth of a hertz stays in the bin), against six neighbours 4–7 Hz away. A line 12 dB above its neighbours counts; whichever mains frequency stands out more wins; nothing standing out means nothing is notched (and the job log says so). Unit tests cover 50.1 Hz hum with harmonics under noise and a tone, 60 Hz hum whose third harmonic dominates, and clean audio.

+ +
+ + + + +
Loudness, two passes (print_format=json, then linear=true with the measurement), 20 s syllable-modulated 200 Hz signal at −34.4 LUFS
TargetResult (ebur128)Error
−23 LUFS−23.0 LUFS0.0 LU
−16 LUFS−16.0 LUFS0.0 LU
+ +
+ + + + + +
DeepFilterNet (-D) on a voiced, syllable-modulated harmonic signal in pink noise; input: pauses −44.8 dB, voiced −24.0 dB, SNR in the voiced bursts 20.9 dB
Attenuation limit (-a)Pauses down byVoiced levelSNR in voicedLagTime per minute (mono)
100 (no limit)25.3 dB−1.1 dB13.8 dB03.8 s
3022.5 dB−1.1 dB14.0 dB04.0 s
1513.7 dB−0.9 dB15.4 dB03.9 s
+

What this metric can't tell you. The signal is a test tone with a speech-like envelope, not speech. DeepFilterNet is trained on speech; it removed the noise in the pauses as expected, but it distorted the synthetic "voice" (SNR inside the bursts fell from 20.9 to ~14 dB), which says little about real dialogue. Judge it by ear on real recordings; the default (no limit) may be stronger than a natural-sounding result wants, which is why the limit is exposed.

+ +
+ + + + + + + + +
Speed: seconds to process one minute of 48 kHz stereo (pink noise), Apple Silicon
StepSeconds
adeclip2.94
loudnorm (one pass)2.08
adeclick1.18
afftdn0.29
drift resample0.29
hum notches0.10
+

Everything is far faster than the video it accompanies; a two-hour tape with every step on adds a few minutes.

+ +

4. A late-starting audio stream

+

start_offset_probe.py: a file whose audio starts 0.5 s after its video. Mapping the source's stream straight into the output (as the encoder always did) keeps that gap through the container's timestamps; a WAV has no timestamps.

+
+ + + + +
First sound and length of the rendered WAV
RenderNo seekSeek to 2 s
anull0.000 s, 9.5 s long0.000 s, 8.0 s
aresample=first_pts=00.500 s, 10.0 s long0.000 s, 8.0 s
+

So aresample=first_pts=0 is the first filter of every render. The heavy test encodes such a file and finds every click 0.5 s later than in the stream itself.

+ +

5. Decisions

+
    +
  • Render the soundtrack before the encode (one WAV, 32-bit float so adeclip's rebuilt peaks and later gain don't clip), and map it into the encode — never an -af on the encode. DeepFilterNet is a separate program; two-pass loudness must measure the whole track first; a negative offset needs audio from before the trim point; and the subtitles transcribe this file, which is the output's audio sample for sample, so their cues follow offset, drift and trim by construction. (Heavy test: a 2 s offset moved Whisper's cues by a median of 2.3 s — its timestamps are coarse.)
  • +
  • One decision site, fixed order, one -af per ffmpeg run — the encoder's -vf rule, for the same reason. Order: start padding → channels → declip → declick → rumble → hum → afftdn (+ its trim) → drift resample → lead silence → [DeepFilterNet] → loudness → limiter → length. Asserted for all 512 combinations of the toggles, with no, positive and negative offsets.
  • +
  • Drift factor = whole source audio length / whole encoded picture length, the picture's from output frames over output rate (FrameMap), applied only above 0.05 s and up to a cap (0.5% default) — a bigger mismatch is a missing stretch or the wrong track, not drift.
  • +
  • Offset and drift compose on the source timeline: source time = (in-point + t − offset) × factor. A positive offset from the start becomes silence; a negative one reads later — never adelay alone.
  • +
  • The track is cut or padded to the picture's exact length.
  • +
  • One stream restored, first in the output; the others copied untouched with their own tags (naming one stream's -map_metadata stopped ffmpeg copying the rest's — found by the heavy test). With audio set to copy, the restored stream is encoded (FLAC/MKV, AAC/MP4·MOV, PCM/AVI).
  • +
  • DeepFilterNet as a manifest add-on, upstream's own v0.5.6 binaries pinned by hash (MIT OR Apache-2.0). macOS builds need macOS 13 (their minos); the arm64 build is linker-signed, the x64 build unsigned — the installer signs a program that fails codesign --verify.
  • +
  • No preview: the preview is picture only, and the panel says so.
  • +
+ +

6. Not tested

+
    +
  • Windows and Linux (the filters are ffmpeg's, the same 9.0 series everywhere; DeepFilterNet's Windows, Linux musl and Linux aarch64 binaries never ran here), and the macOS x64 DeepFilterNet build (needs macOS 13 on Intel).
  • +
  • Real recordings: every number here is from generated signals. Hum detection on real ground-loop hum with a strong bass line, the noise-floor estimate on content without pauses, and DeepFilterNet on real dialogue all deserve a listen.
  • +
  • Very long sources: drift accuracy was measured over 20 s; the resample ratio's accuracy is computed (≤0.4 ms an hour), not measured over hours.
  • +
  • Sources with more than two channels: the channel fix applies to stereo only and says so; the other steps run on every channel.
  • +
+
+ + diff --git a/docs/reports/2026-11-audio-restoration/scripts/common.py b/docs/reports/2026-11-audio-restoration/scripts/common.py new file mode 100644 index 00000000..3ed6598d --- /dev/null +++ b/docs/reports/2026-11-audio-restoration/scripts/common.py @@ -0,0 +1,62 @@ +"""Shared helpers for the audio restoration probes. + +Environment: + FFMPEG path to the deps bundle's ffmpeg (default: ffmpeg on PATH) + DEEP_FILTER path to a deep-filter v0.5.6 binary (optional; DFN probes skip) + WORK scratch directory for generated fixtures (required) +""" +import os +import subprocess +import sys + +import numpy as np + +FFMPEG = os.environ.get("FFMPEG", "ffmpeg") +DEEP_FILTER = os.environ.get("DEEP_FILTER") +WORK = os.environ.get("WORK") +if not WORK: + sys.exit("set WORK to a scratch directory") +os.makedirs(WORK, exist_ok=True) + + +def ff(*args): + """Run ffmpeg quietly; return stderr text.""" + r = subprocess.run([FFMPEG, "-hide_banner", "-nostdin", "-y", *args], + capture_output=True, text=True) + if r.returncode != 0: + raise RuntimeError(r.stderr[-2000:]) + return r.stderr + + +def decode(path, rate, channels=1, af=None): + """Decode to float32 numpy array (frames, channels).""" + cmd = [FFMPEG, "-hide_banner", "-nostdin", "-v", "error", "-i", path] + if af: + cmd += ["-af", af] + cmd += ["-ac", str(channels), "-ar", str(rate), "-f", "f32le", "-"] + raw = subprocess.run(cmd, capture_output=True, check=True).stdout + return np.frombuffer(raw, dtype=np.float32).reshape(-1, channels) + + +def lag_samples(ref, sig, max_lag): + """Lag (samples) of sig relative to ref by cross-correlation; + = sig late.""" + n = min(len(ref), len(sig)) + a = ref[:n] - ref[:n].mean() + b = sig[:n] - sig[:n].mean() + size = 1 << int(np.ceil(np.log2(2 * n))) + c = np.fft.irfft(np.fft.rfft(b, size) * np.conj(np.fft.rfft(a, size)), size) + lags = np.concatenate([c[: max_lag + 1], c[-max_lag:]]) + idx = np.concatenate([np.arange(0, max_lag + 1), np.arange(-max_lag, 0)]) + return int(idx[np.argmax(lags)]) + + +def band_db(x, rate, f, width=2.0): + """Energy (dB) in a narrow band around f, from one long FFT.""" + spec = np.abs(np.fft.rfft(x * np.hanning(len(x)))) ** 2 + freqs = np.fft.rfftfreq(len(x), 1.0 / rate) + sel = (freqs > f - width) & (freqs < f + width) + return 10 * np.log10(spec[sel].sum() + 1e-30) + + +def rms_db(x): + return 20 * np.log10(np.sqrt(np.mean(np.square(x))) + 1e-12) diff --git a/docs/reports/2026-11-audio-restoration/scripts/delay_probe.py b/docs/reports/2026-11-audio-restoration/scripts/delay_probe.py new file mode 100644 index 00000000..d451e32f --- /dev/null +++ b/docs/reports/2026-11-audio-restoration/scripts/delay_probe.py @@ -0,0 +1,78 @@ +"""How far does each processing step move the audio in time? + +Measures, by cross-correlating a click track before and after: + - afftdn at several sample rates and strengths (Video-Enhancer reported 1200 + samples at 48 kHz); + - DeepFilterNet's deep-filter, with and without -D (--compensate-delay); + - atempo, adeclick, adeclip, highpass/bandreject, loudnorm (expected 0). + +Run: WORK=/scratch FFMPEG=.../ffmpeg [DEEP_FILTER=.../deep-filter] python delay_probe.py +""" +import os +import shutil +import subprocess + +from common import WORK, DEEP_FILTER, ff, decode, lag_samples + +CLICKS = ("aevalsrc='if(lt(mod(t\\,0.37)\\,0.002)\\,0.8*sin(2*PI*2000*t)\\,0)" + "+0.05*sin(2*PI*440*t)':s={rate}:d=12") + + +def fixture(rate): + path = os.path.join(WORK, f"clicks_{rate}.wav") + ff("-f", "lavfi", "-i", CLICKS.format(rate=rate) + ",aformat=channel_layouts=mono", + "-f", "lavfi", "-i", f"anoisesrc=c=pink:a=0.02:r={rate}:d=12", + "-filter_complex", "[0][1]amix=inputs=2:normalize=0", "-c:a", "pcm_f32le", path) + return path + + +def measure(src, rate, af): + ref = decode(src, rate)[:, 0] + out = decode(src, rate, af=af)[:, 0] + return lag_samples(ref, out, rate // 2) + + +def peak(src, rate, af): + import numpy as np + return 20 * np.log10(np.abs(decode(src, rate, af=af)[:, 0]).max()) + + +def main(): + src48 = fixture(48000) + print("== alimiter level: peak dBFS of a 0.85-peak click track, limit 0.5 (-6 dB) ==") + for af in ("anull", "alimiter=limit=0.5", "alimiter=limit=0.5:level=0:latency=1"): + print(f" {af:40s} peak {peak(src48, 48000, af):6.2f} dBFS") + print("== ffmpeg filters (lag in samples, + = output late) ==") + for rate in (48000, 44100, 32000): + src = fixture(rate) + for af in ["afftdn=nr=12", "afftdn=nr=24:nf=-40", "afftdn=nr=6", + "adeclick", "adeclip", "highpass=f=40", + "bandreject=f=50:width_type=q:w=4,bandreject=f=100:width_type=q:w=4", + "loudnorm=I=-16:TP=-1.5:LRA=11,aresample=%d" % rate, + "alimiter=limit=0.5", "alimiter=limit=0.5:level=0:latency=1", + "aresample=%d,asetrate=%d,aresample=%d" % (rate * 4, rate * 4, rate)]: + print(f"{rate:6d} Hz {af:60s} lag={measure(src, rate, af):6d}") + + if not DEEP_FILTER: + print("DEEP_FILTER not set; skipping DeepFilterNet") + return + print("== deep-filter ==") + for rate in (48000, 44100): + src = fixture(rate) + for flags in ([], ["-D"]): + out_dir = os.path.join(WORK, "dfn_out") + shutil.rmtree(out_dir, ignore_errors=True) + r = subprocess.run([DEEP_FILTER, *flags, "-o", out_dir, src], + capture_output=True, text=True) + if r.returncode != 0: + print(f"{rate} Hz {flags}: FAILED {r.stderr.strip()[-300:]}") + continue + out = os.path.join(out_dir, os.path.basename(src)) + ref = decode(src, 48000)[:, 0] + got = decode(out, 48000)[:, 0] + print(f"{rate:6d} Hz in, flags={flags}: out frames={len(got)} " + f"(in {len(ref)}), lag={lag_samples(ref, got, 24000)} samples @48k") + + +if __name__ == "__main__": + main() diff --git a/docs/reports/2026-11-audio-restoration/scripts/quality_probe.py b/docs/reports/2026-11-audio-restoration/scripts/quality_probe.py new file mode 100644 index 00000000..845fef2f --- /dev/null +++ b/docs/reports/2026-11-audio-restoration/scripts/quality_probe.py @@ -0,0 +1,132 @@ +"""What each step does to a known signal, and how fast it runs. + +Uses the filter strings the worker generates (see worker/src/audio_restoration.rs). + + - hum notches: attenuation at 50/100/150 Hz, change at 1 kHz; + - afftdn: noise floor drop at nr 6/12/20/30 with the measured floor; + - loudnorm (two passes, linear) + aresample: integrated loudness error; + - DeepFilterNet: noise drop in speech pauses, change in the voiced bursts, + delay with -D, at attenuation limits 100/30/15; + - speed: seconds per minute of 48 kHz stereo for each step. + +Run: WORK=/scratch FFMPEG=.../ffmpeg [DEEP_FILTER=.../deep-filter] python quality_probe.py +""" +import json +import os +import re +import shutil +import subprocess +import time + +import numpy as np + +from common import WORK, FFMPEG, DEEP_FILTER, ff, decode, band_db, rms_db, lag_samples + +RATE = 48000 + + +def make(name, graph, secs): + path = os.path.join(WORK, f"q_{name}.wav") + ff("-filter_complex", graph, "-map", "[a]", "-t", str(secs), "-c:a", "pcm_f32le", path) + return path + + +def timed(args): + t = time.time() + ff(*args) + return time.time() - t + + +def main(): + # --- hum + hum = make("hum", "sine=f=1000:r=48000:d=10[t];sine=f=50:r=48000:d=10[a1];" + "sine=f=100:r=48000:d=10[a2];sine=f=150:r=48000:d=10[a3];" + "[t][a1][a2][a3]amix=inputs=4:weights='1 0.8 0.4 0.3':normalize=0[a]", 10) + notch = ",".join(f"bandreject=f={f}:width_type=q:w=8" for f in (50, 100, 150)) + x = decode(hum, RATE)[2 * RATE:8 * RATE, 0] + y = decode(hum, RATE, af=notch)[2 * RATE:8 * RATE, 0] + print("== hum notches (Q 8) ==") + for f in (50, 100, 150, 1000): + print(f" {f:5d} Hz: {band_db(x, RATE, f) - band_db(y, RATE, f):6.1f} dB down") + + # --- afftdn + noisy = make("noise", "sine=f=440:r=48000:d=10,volume=0.3," + "aeval='val(0)*lt(mod(t\\,2)\\,1)'[t];" + "anoisesrc=c=white:a=0.02:r=48000:d=10:seed=7[n];[t][n]amix=inputs=2:normalize=0[a]", 10) + x = decode(noisy, RATE)[:, 0] + frames = x[: len(x) // 2400 * 2400].reshape(-1, 2400) + floor = np.percentile(20 * np.log10(np.sqrt((frames ** 2).mean(axis=1)) + 1e-12), 10) + gap = slice(int(5.2 * RATE), int(5.9 * RATE)) # a pause, after adaptation + tone = slice(int(6.2 * RATE), int(6.9 * RATE)) + print(f"== afftdn (measured floor {floor:.1f} dB) ==") + for nr in (6, 12, 20, 30): + y = decode(noisy, RATE, af=f"afftdn=nr={nr}:nf={floor:.0f}")[:, 0] + y = y[1200:] # its 25 ms delay + print(f" nr={nr:2d}: pause {rms_db(x[gap]) - rms_db(y[gap]):5.1f} dB down, " + f"tone {band_db(y[tone], RATE, 440) - band_db(x[tone], RATE, 440):+5.2f} dB") + + # --- loudnorm + print("== loudnorm, two passes, linear ==") + quiet = make("quiet", "aevalsrc='0.05*sin(2*PI*200*t)*(0.6+0.4*sin(2*PI*4*t))" + "*lt(mod(t\\,1.5)\\,1.0)':s=48000:d=20[a]", 20) + for target in (-23, -16): + r = subprocess.run([FFMPEG, "-hide_banner", "-nostats", "-i", quiet, "-af", + f"loudnorm=I={target}:TP=-1.5:LRA=11:print_format=json", "-f", "null", "-"], + capture_output=True, text=True).stderr + m = json.loads(r[r.rfind("{"):r.rfind("}") + 1]) + out = os.path.join(WORK, "q_ln.wav") + ff("-i", quiet, "-af", + f"loudnorm=I={target}:TP=-1.5:LRA=11:measured_I={m['input_i']}:measured_TP={m['input_tp']}:" + f"measured_LRA={m['input_lra']}:measured_thresh={m['input_thresh']}:offset={m['target_offset']}:" + f"linear=true,aresample=48000", "-c:a", "pcm_f32le", out) + r = subprocess.run([FFMPEG, "-hide_banner", "-nostats", "-i", out, "-af", "ebur128", "-f", "null", "-"], + capture_output=True, text=True).stderr + got = float(re.findall(r"I:\s+(-?[\d.]+) LUFS", r)[-1]) + print(f" target {target}: input {m['input_i']} LUFS -> {got} LUFS (error {got - target:+.1f} LU)") + + # --- speed (60 s stereo) + print("== speed: seconds per minute of 48 kHz stereo ==") + minute = make("minute", "anoisesrc=c=pink:a=0.1:r=48000:d=60,pan=stereo|c0=c0|c1=c0[a]", 60) + for label, af in [("adeclick", "adeclick"), ("adeclip", "adeclip"), + ("afftdn", "afftdn=nr=12:nf=-40"), ("hum notches", notch), + ("drift resample", "aresample=192000,asetrate=192768,aresample=48000"), + ("loudnorm (one pass)", "loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000")]: + secs = timed(["-i", minute, "-af", af, "-f", "null", "-"]) + print(f" {label:22s} {secs:6.2f} s") + + if not DEEP_FILTER: + print("DEEP_FILTER not set; skipping DeepFilterNet") + return + # --- DeepFilterNet + speech = make("speech", "aevalsrc='(0.12*sin(2*PI*140*t)+0.08*sin(2*PI*280*t)+0.05*sin(2*PI*420*t)" + "+0.03*sin(2*PI*840*t))*(0.5+0.5*sin(2*PI*5*t))*lt(mod(t\\,1.6)\\,0.9)':s=48000:d=16[v];" + "anoisesrc=c=pink:a=0.03:r=48000:d=16:seed=3[n];[v][n]amix=inputs=2:normalize=0[a]", 16) + clean = make("speech_clean", "aevalsrc='(0.12*sin(2*PI*140*t)+0.08*sin(2*PI*280*t)+0.05*sin(2*PI*420*t)" + "+0.03*sin(2*PI*840*t))*(0.5+0.5*sin(2*PI*5*t))*lt(mod(t\\,1.6)\\,0.9)':s=48000:d=16[a]", 16) + x = decode(speech, RATE)[:, 0] + c = decode(clean, RATE)[:, 0] + pauses = np.concatenate([np.arange(int((k * 1.6 + 0.95) * RATE), int((k * 1.6 + 1.55) * RATE)) + for k in range(1, 9)]) + voiced = np.concatenate([np.arange(int((k * 1.6 + 0.1) * RATE), int((k * 1.6 + 0.8) * RATE)) + for k in range(1, 9)]) + print("== DeepFilterNet (deep-filter 0.5.6, -D) ==") + print(f" input: pause {rms_db(x[pauses]):.1f} dB, voiced {rms_db(x[voiced]):.1f} dB, " + f"SNR in voiced {rms_db(c[voiced]) - rms_db((x - c)[voiced]):.1f} dB") + for atten in (100, 30, 15): + out_dir = os.path.join(WORK, "q_dfn") + shutil.rmtree(out_dir, ignore_errors=True) + t = time.time() + subprocess.run([DEEP_FILTER, "-D", "-a", str(atten), "-o", out_dir, speech], + check=True, capture_output=True) + secs = time.time() - t + y = decode(os.path.join(out_dir, os.path.basename(speech)), RATE)[:, 0] + y = np.pad(y, (0, len(x) - len(y))) + err = y - c + print(f" -a {atten:3d}: pause {rms_db(x[pauses]) - rms_db(y[pauses]):5.1f} dB down, " + f"voiced {rms_db(y[voiced]) - rms_db(x[voiced]):+5.1f} dB, " + f"SNR in voiced {rms_db(c[voiced]) - rms_db(err[voiced]):5.1f} dB, " + f"lag {lag_samples(c, y, 4800)} samples, {secs / 16 * 60:5.1f} s per minute (mono)") + + +if __name__ == "__main__": + main() diff --git a/docs/reports/2026-11-audio-restoration/scripts/run.sh b/docs/reports/2026-11-audio-restoration/scripts/run.sh new file mode 100755 index 00000000..a9abc0d2 --- /dev/null +++ b/docs/reports/2026-11-audio-restoration/scripts/run.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Reproduce every number in the report. +# +# WORK=/scratch/dir FFMPEG=/ffmpeg/ffmpeg [DEEP_FILTER=] ./run.sh +# +# Needs Python 3 with NumPy. FFMPEG defaults to the macOS arm64 deps bundle +# beside the repo; DEEP_FILTER to the add-on as the app installs it, if present. +set -e +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$HERE/../../../.." && pwd)" +: "${WORK:?set WORK to a scratch directory}" +: "${FFMPEG:=$REPO/deps/macos-arm64/ffmpeg/ffmpeg}" +export WORK FFMPEG +if [ -z "$DEEP_FILTER" ]; then + for f in "$REPO"/addons/deepfilternet/deep-filter-0.5.6-*; do + [ -x "$f" ] && DEEP_FILTER="$f" + done +fi +[ -n "$DEEP_FILTER" ] && export DEEP_FILTER +PY="${PYTHON:-python3}" +cd "$HERE" +"$PY" delay_probe.py +"$PY" start_offset_probe.py +"$PY" tempo_denoise_probe.py +"$PY" quality_probe.py diff --git a/docs/reports/2026-11-audio-restoration/scripts/start_offset_probe.py b/docs/reports/2026-11-audio-restoration/scripts/start_offset_probe.py new file mode 100644 index 00000000..13cb71ad --- /dev/null +++ b/docs/reports/2026-11-audio-restoration/scripts/start_offset_probe.py @@ -0,0 +1,44 @@ +"""Does rendering audio to a separate WAV keep a container's audio start offset? + +The encode used to map the source's audio straight into the output, where +the muxer keeps an audio stream that starts later than the video (common in +captures) at its real position. Rendering the restored audio to a WAV loses +timestamps, so the render has to pad that gap itself. This checks which +aresample settings do so, with and without an input seek. +""" +import os +import subprocess + +import numpy as np + +from common import WORK, FFMPEG, ff, decode + +FFPROBE = os.environ.get("FFPROBE", os.path.join(os.path.dirname(FFMPEG), "ffprobe")) + + +def first_sound(path): + x = decode(path, 48000)[:, 0] + idx = np.argmax(np.abs(x) > 0.05) + return idx / 48000.0, len(x) / 48000.0 + + +def main(): + src = os.path.join(WORK, "late_audio.mkv") + ff("-f", "lavfi", "-i", "testsrc2=s=320x240:r=25:d=10", + "-itsoffset", "0.5", "-f", "lavfi", "-i", "sine=f=1000:d=9.5:r=48000", + "-map", "0", "-map", "1", "-c:v", "libx264", "-c:a", "flac", src) + print(subprocess.run([FFPROBE, "-v", "error", "-show_entries", + "stream=codec_type,start_time:format=start_time", + "-of", "compact", src], capture_output=True, text=True).stdout) + for seek in (None, "2"): + for af in ("anull", "aresample=first_pts=0", "aresample=async=1:first_pts=0"): + out = os.path.join(WORK, "late_out.wav") + args = (["-ss", seek] if seek else []) + ["-i", src, "-map", "0:a", + "-af", af, "-c:a", "pcm_f32le", out] + ff(*args) + onset, dur = first_sound(out) + print(f"seek={seek!s:4s} af={af:32s} first sound at {onset:.3f}s, length {dur:.3f}s") + + +if __name__ == "__main__": + main() diff --git a/docs/reports/2026-11-audio-restoration/scripts/tempo_denoise_probe.py b/docs/reports/2026-11-audio-restoration/scripts/tempo_denoise_probe.py new file mode 100644 index 00000000..dd2b26b0 --- /dev/null +++ b/docs/reports/2026-11-audio-restoration/scripts/tempo_denoise_probe.py @@ -0,0 +1,67 @@ +"""Two follow-ups from the first heavy-test run. + +1. Does atempo shift the audio as well as stretching it? (The drift test + found every click ~20 ms early.) +2. Why did afftdn take only 0.7 dB off white noise at -39 dBFS with nr=20? + Its noise floor `nf` defaults to -50 dB; try the measured floor. + +Run: WORK=/scratch FFMPEG=.../ffmpeg python tempo_denoise_probe.py +""" +import os + +import numpy as np + +from common import WORK, ff, decode, rms_db + +RATE = 48000 +CLICKS = ("aevalsrc='0.8*sin(2*PI*3000*t)*(lt(mod(t\\,0.7)\\,0.003)" + "+lt(mod(t+0.23\\,1.1)\\,0.003))':s=48000:d=8") + + +def onsets(x, thr=0.3, gap=0.1): + out, last = [], -1e9 + for i in np.flatnonzero(np.abs(x) > thr): + t = i / RATE + if t - last > gap: + out.append(t) + last = t + return np.array(out) + + +def main(): + src = os.path.join(WORK, "probe_clicks.wav") + ff("-f", "lavfi", "-i", CLICKS, "-c:a", "pcm_f32le", src) + ref = onsets(decode(src, RATE)[:, 0]) + print("== atempo: first click positions vs expected t / factor ==") + for factor in (1.004, 0.996, 1.02): + for chain in (f"atempo={factor}", f"asetrate={RATE*factor:.3f},aresample={RATE}", + f"aresample={RATE}:async=0,rubberband=tempo={factor}" if False else None): + if chain is None: + continue + got = onsets(decode(src, RATE, af=chain)[:, 0]) + exp = ref / factor + n = min(len(got), len(exp)) + err = got[:n] - exp[:n] + print(f" {chain:40s} error mean {err.mean()*1000:7.2f} ms, " + f"first {err[0]*1000:7.2f} ms, last {err[n-1]*1000:7.2f} ms") + + print("== afftdn on white noise (-39 dBFS RMS) + clicks ==") + noisy = os.path.join(WORK, "probe_noise.wav") + ff("-f", "lavfi", "-i", CLICKS, "-f", "lavfi", "-i", + "anoisesrc=c=white:a=0.02:r=48000:d=8:seed=7", + "-filter_complex", "[0][1]amix=inputs=2:normalize=0", "-c:a", "pcm_f32le", noisy) + x = decode(noisy, RATE)[:, 0] + win = slice(int(4.0 * RATE), int(4.12 * RATE)) + # The floor afftdn would be told: the 10th percentile of 50 ms RMS. + frames = x[: len(x) // 2400 * 2400].reshape(-1, 2400) + floor = np.percentile(20 * np.log10(np.sqrt((frames ** 2).mean(axis=1)) + 1e-12), 10) + print(f" input floor {rms_db(x[win]):.1f} dB; 10th-percentile 50 ms RMS {floor:.1f} dB") + for chain in ("afftdn=nr=20:nf=-50:tn=1", "afftdn=nr=20:nf=-50", + f"afftdn=nr=20:nf={floor:.0f}", f"afftdn=nr=20:nf={floor:.0f}:tn=1", + f"afftdn=nr=12:nf={floor:.0f}:tn=1"): + y = decode(noisy, RATE, af=chain)[:, 0] + print(f" {chain:34s} floor {rms_db(y[win]):6.1f} dB (down {rms_db(x[win]) - rms_db(y[win]):5.1f})") + + +if __name__ == "__main__": + main() diff --git a/docs/reports/README.md b/docs/reports/README.md index 5368b2a5..729dc3a3 100644 --- a/docs/reports/README.md +++ b/docs/reports/README.md @@ -66,6 +66,7 @@ to it. | Date | Report | Outcome | |---|---|---| +| 2026-11-08 | [Audio restoration: filter delays, drift and DeepFilterNet](2026-11-audio-restoration/index.html) | Built: render the soundtrack before the encode; trim afftdn's 25 ms delay and give it the measured noise floor; correct drift by resampling (atempo moved audio ~20 ms); alimiter `latency=1:level=0`; DeepFilterNet as an add-on with `-D` | | 2026-11-03 | [Deblock before or after deinterlacing?](2026-11-deblock-order/index.html) | Keep Deblock after Deinterlace (≤0.05 dB gain from moving it); when deinterlacing is off, deblock per field rather than on the woven frame (+0.03 to +0.30 dB; DCTFilter on the woven frame lost 1.5–3.7 dB); built | | 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 | From 8db1b6e2a64d2992d947cb1642f0bb1bd345c0d9 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 8 Nov 2026 18:50:47 +1100 Subject: [PATCH 18/61] docs(audio): no built-in preset turns audio restoration on, and why Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 16e986e3..e01387cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1476,6 +1476,11 @@ Rules, from docs/reports/2026-11-audio-restoration: `AudioRestorer::plan` (`DEEP_FILTER_FEATURE`), **not** in `required_addons()`: that list gates the script generator, and a preview must never be refused for an audio add-on. +- **No built-in preset turns it on** (audited when it shipped): every step + depends on the recording, and switching the pass on re-encodes audio that + the presets otherwise copy. A source preset may later enable only the + self-checking steps (automatic channel fix, automatic hum), which do nothing + when they find nothing. - `vapourbox-worker --audio-plan --config job.json` prints every argument list without rendering (`integration_audio_plan_test.dart`, push gate); `integration_audio_restoration_test.dart` (heavy) measures the encoded audio. From 65174131025e99c5e7179dae6c879f9c1117d88c Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 8 Nov 2026 18:51:33 +1100 Subject: [PATCH 19/61] feat(face-restore): the Face Restoration pass in the app Schema (model, strength, CodeFormer fidelity, steady-faces toggle; detection settings advanced-only; the VapourSynth call and models shown in advanced mode), Dart model, pipeline and converter wiring, the pass row as the first of the Finishing stage, and the add-on prompt when the pass is switched on or its model changes, with GFPGAN's and CodeFormer's non-commercial terms shown whenever they are chosen. Credits for RestoreFormer++, RetinaFace, BiSeNet, facexlib, GFPGAN and CodeFormer in NOTICES, the About dialog and the README, each from the upstream LICENSE. No built-in preset turns the pass on. Co-Authored-By: Claude Opus 5.5 --- README.md | 16 +- app/assets/filters/core/face_restore.json | 196 ++++++++++++++++++ app/assets/filters/manifest.json | 3 +- app/lib/models/face_restore_parameters.dart | 131 ++++++++++++ app/lib/models/parameter_converter.dart | 37 ++++ app/lib/models/pass_relevance.dart | 2 + app/lib/models/processing_pipeline.dart | 27 +++ app/lib/viewmodels/main_viewmodel.dart | 9 + app/lib/views/about_dialog.dart | 36 ++++ app/lib/views/pass_list/pass_list_item.dart | 2 + app/lib/views/pass_list/pass_list_panel.dart | 36 +++- .../pass_settings/pass_settings_inline.dart | 95 +++++++++ app/test/attribution_test.dart | 1 + app/test/face_restore_parameters_test.dart | 117 +++++++++++ .../integration_filter_parameters_test.dart | 68 ++++++ app/test/new_badges_shipped_test.dart | 23 ++ app/test/parameter_copy_with_test.dart | 5 +- app/test/pass_list_stages_test.dart | 14 ++ licenses/NOTICES.txt | 67 +++++- 19 files changed, 875 insertions(+), 10 deletions(-) create mode 100644 app/assets/filters/core/face_restore.json create mode 100644 app/lib/models/face_restore_parameters.dart create mode 100644 app/test/face_restore_parameters_test.dart diff --git a/README.md b/README.md index e08e9584..db57b9f4 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ VapourBox runs [VapourSynth](https://www.vapoursynth.com/), QTGMC and FFmpeg for - **Converts and archives.** Reads camcorder DV and AVCHD, MPEG-2 from discs and recorders, MXF, and ordinary MP4/MKV/AVI/MOV files, including formats consumer converters mishandle (NTSC DV's 4:1:1 chroma, 10–16-bit film scans). Writes H.264, H.265, ProRes or lossless FFV1, with hardware encoding where available. DVDs and `VIDEO_TS` folders are read directly, and aspect ratio is carried through correctly. - **Fixes how the picture is stored.** Detects interlaced and telecined sources, then deinterlaces with QTGMC or recovers the original film frames with inverse telecine (or by stripping soft-telecine flags). - **Cleans up old footage.** Optional filters for tape noise, smeared colour, dust and scratches, flicker, ghosting, compression blocking, banding, camera shake and dark, flat pictures. They're off until you turn them on, and the presets turn on what a given kind of source needs. +- **Restores faces.** **Face Restoration** finds the faces in a soft VHS or low-resolution picture and redraws them with AI (RestoreFormer++, or GFPGAN or CodeFormer for non-commercial use) at the output resolution, steadied across frames to keep flicker down, leaving every other pixel untouched. - **Restores anime.** **Descale** undoes the upscale most HD anime went through in production, **BM3D** cleans flat colour without smearing lines, and **AI upscaling** redraws line art at 2× or 4×: Real-ESRGAN (sharpest, fastest), Real-CUGAN (gentler, with denoising) or AnimeJaNai (trained on HD anime). - **Converts frame rates** — the broadcast standards or any rate you type in (24 → 48 or 60, say) — with motion interpolation or the **RIFE** AI interpolator. - **Writes subtitles** from speech with Whisper AI, as an `.srt` file, an embedded track, or burnt into the picture. @@ -80,9 +81,9 @@ Where an AppImage can't be mounted, use the `.tar.gz` of the same build: extract ## Hardware and add-ons -**GPU features.** RIFE frame interpolation and AI upscaling run on the graphics card through Vulkan: on any Apple Silicon Mac, and on Windows and Linux x64 PCs with a current graphics driver (RIFE on Windows also needs the Microsoft Visual C++ Redistributable). They aren't available on Intel Macs or Linux on ARM yet. GPU deinterlacing and KNLMeans denoising use OpenCL instead; without it, deinterlacing falls back to the CPU. Whenever you choose a GPU feature your machine can't run, VapourBox says what's missing and how to fix it. +**GPU features.** RIFE frame interpolation and AI upscaling run on the graphics card through Vulkan: on any Apple Silicon Mac, and on Windows and Linux x64 PCs with a current graphics driver (RIFE on Windows also needs the Microsoft Visual C++ Redistributable). They aren't available on Intel Macs or Linux on ARM yet. GPU deinterlacing and KNLMeans denoising use OpenCL instead; without it, deinterlacing falls back to the CPU. Whenever you choose a GPU feature your machine can't run, VapourBox says what's missing and how to fix it. Face restoration runs through the AI runtime instead: on the graphics card and Neural Engine on a Mac (Core ML), on any DirectX 12 graphics card on Windows (DirectML), and on the CPU on Linux, which is several times slower. -**Add-ons.** A few features use third-party downloads that aren't part of VapourBox: the Whisper engine and language models for subtitles, the AnimeJaNai models for AI upscaling, and the AI runtime (Microsoft's ONNX Runtime) that AI model add-ons run on. VapourBox offers to download one the first time you need it, stating its source, size and licence, and **Settings → Add-ons** installs or removes them at any time. AnimeJaNai is free for **non-commercial use only** (CC BY-NC-SA 4.0); for paid work, use Real-ESRGAN or Real-CUGAN instead. +**Add-ons.** A few features use third-party downloads that aren't part of VapourBox: the Whisper engine and language models for subtitles, the AnimeJaNai models for AI upscaling, the face restoration models, and the AI runtime (Microsoft's ONNX Runtime) that AI model add-ons run on. VapourBox offers to download one the first time you need it, stating its source, size and licence, and **Settings → Add-ons** installs or removes them at any time. AnimeJaNai is free for **non-commercial use only** (CC BY-NC-SA 4.0); for paid work, use Real-ESRGAN or Real-CUGAN instead. Of the face restoration models, RestoreFormer++ (the default) is free for any use; GFPGAN and CodeFormer are for **non-commercial use only**. **Older processors.** Intel and AMD CPUs from before about 2013 (no AVX2) are supported on every platform. VapourBox checks the processor on first launch and installs a matching set of components, which runs some filters more slowly but never differently. @@ -123,10 +124,11 @@ Most sources need only a few of these, and the presets pick them for you. Filter | 16 | **Stabilize** | Shake and weave from telecine, film scans or a handheld camera | | 17 | **Rotate / Flip** | Sideways, upside-down or mirrored footage | | 18 | **Crop & Resize** | Overscan, scaling and upscaling (edge-directed or AI), and borders to reach an exact frame size | -| 19 | **Sharpen** | Soft pictures, sharpened after the resize so the result isn't softened again | -| 20 | **Film Grain** | Grain added back after denoising, so the picture doesn't look plastic | -| 21 | **Frame Rate** | PAL ↔ NTSC and other rate changes, or any custom rate, by motion interpolation, RIFE, or frame repetition | -| 22 | **Subtitles** | Speech-to-text with Whisper AI | +| 19 | **Face Restoration** | Blurry, low-resolution faces, redrawn by AI at the output resolution (optional download) | +| 20 | **Sharpen** | Soft pictures, sharpened after the resize so the result isn't softened again | +| 21 | **Film Grain** | Grain added back after denoising, so the picture doesn't look plastic | +| 22 | **Frame Rate** | PAL ↔ NTSC and other rate changes, or any custom rate, by motion interpolation, RIFE, or frame repetition | +| 23 | **Subtitles** | Speech-to-text with Whisper AI | The order is designed so each step works on the right picture: damage and noise are removed first, the picture is framed and resized next, and only then sharpened and given grain. The frame rate is converted last, so every other filter works on real frames. Borders are added after everything else, so nothing touches them. @@ -275,6 +277,8 @@ VapourBox is a front end to other people's work. Thanks in particular to: - **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 - **Real-ESRGAN** by Xintao Wang and **Real-CUGAN** by bilibili — the AI upscaling networks, run through AmusementClub's **vs-mlrt** (VSNCNN) - **AnimeJaNai** by the-database — anime upscaling models, an optional download (CC BY-NC-SA 4.0, non-commercial use only) +- **RestoreFormer++** by Zhouxia Wang, Jiawei Zhang, Tianshui Chen, Wenping Wang and Ping Luo — the default face restoration model, with the **RetinaFace** detector by biubug6 (Pytorch_Retinaface), the **BiSeNet** face parser by zll (face-parsing.PyTorch) and **facexlib** by Xintao Wang, an optional download +- **GFPGAN** by THL A29 Limited, a Tencent company, and **CodeFormer** by S-Lab — alternative face restoration models, optional downloads for non-commercial use only - **whisper.cpp** by the ggml authors and OpenAI's **Whisper** models — speech recognition for subtitles, an optional download - **ONNX Runtime** by Microsoft Corporation, with **NumPy** by the NumPy Developers and, on Windows, Microsoft's **DirectML** — the AI runtime add-on that runs the optional AI models, an optional download - **libdvdread** by VideoLAN — DVD reading diff --git a/app/assets/filters/core/face_restore.json b/app/assets/filters/core/face_restore.json new file mode 100644 index 00000000..9894f70d --- /dev/null +++ b/app/assets/filters/core/face_restore.json @@ -0,0 +1,196 @@ +{ + "$schema": "https://vapourbox.app/schemas/filter-v1.json", + "id": "face_restore", + "version": "1.0.0", + "sinceAppVersion": "1.7.0", + "name": "Face Restoration", + "description": "Restore detail in blurry, low-resolution faces with AI", + "longDescription": "Finds the faces in every frame and redraws them with a neural network trained on hundreds of thousands of portraits, so eyes, teeth and skin come back from a soft VHS or low-resolution picture. Only the face itself is replaced (not hair, neck or background), blended in at the video's own resolution, and every pixel outside the faces is left exactly as it was.\n\nIt runs after Crop & Resize, so upscaling first gives the network more to work with. A restored face is the network's best reconstruction, not the original: it can look smoother or slightly different from the person, which is what Strength is for. Faces smaller than the minimum size are left alone, since there is too little in them to restore.\n\nThe models are separate downloads. RestoreFormer++ is the default and free for any use. GFPGAN and CodeFormer are alternatives for non-commercial use only; CodeFormer adds a fidelity control. On a Mac they run on the graphics card and Neural Engine, on Windows on any DirectX 12 graphics card, and otherwise on the CPU, which is much slower.", + "category": "enhancement", + "icon": "face_retouching_natural", + "methods": [ + { + "id": "restore", + "name": "Face Restoration", + "description": "Detect each face, restore it with the chosen model, blend it back", + "function": "custom", + "parameters": [ + "model", + "strength", + "codeFormerFidelity", + "temporalSmoothing", + "detectionThreshold", + "minFaceSize", + "maxFaces" + ] + } + ], + "parameters": { + "enabled": { + "type": "boolean", + "default": false, + "ui": { + "hidden": true + } + }, + "model": { + "type": "enum", + "default": "restoreFormer", + "options": [ + "restoreFormer", + "gfpgan", + "codeFormer" + ], + "ui": { + "label": "Model", + "optionLabels": { + "restoreFormer": "RestoreFormer++", + "gfpgan": "GFPGAN v1.4 (download, non-commercial)", + "codeFormer": "CodeFormer (download, non-commercial)" + }, + "description": "The network that redraws the faces. RestoreFormer++ is natural-looking and free for any use. GFPGAN gives stronger, more stylised detail; CodeFormer is good on very damaged faces and has a fidelity control. Both are separate downloads for non-commercial use only", + "widget": "dropdown" + } + }, + "strength": { + "type": "number", + "default": 0.8, + "min": 0.0, + "max": 1.0, + "step": 0.05, + "ui": { + "label": "Strength", + "description": "How much of the restored face replaces the original. Lower keeps more of the real face and its texture, and flickers less", + "widget": "slider", + "precision": 2 + } + }, + "codeFormerFidelity": { + "type": "number", + "default": 0.7, + "min": 0.0, + "max": 1.0, + "step": 0.05, + "ui": { + "label": "Fidelity", + "description": "Lower trusts CodeFormer's learned faces more (cleaner, less like the person); higher stays closer to the picture. 0.5 to 0.7 suits most video", + "widget": "slider", + "precision": 2, + "visibleWhen": { + "model": [ + "codeFormer" + ] + } + } + }, + "temporalSmoothing": { + "type": "boolean", + "default": true, + "ui": { + "label": "Steady faces between frames", + "description": "Steady each face over the frames either side: its position is smoothed, one-frame gaps in detection are bridged, and the restored face is averaged with its neighbours wherever the picture is still, so detail the network invents doesn't shimmer. Turn off to restore every frame on its own", + "widget": "checkbox" + } + }, + "detectionThreshold": { + "type": "number", + "default": 0.8, + "min": 0.3, + "max": 0.99, + "step": 0.01, + "ui": { + "label": "Detection Confidence", + "description": "How sure the face detector must be. Lower finds more faces in poor pictures, and more things that are not faces", + "widget": "slider", + "precision": 2 + } + }, + "minFaceSize": { + "type": "integer", + "default": 32, + "min": 8, + "max": 1024, + "step": 1, + "ui": { + "label": "Minimum Face Size", + "description": "Faces smaller than this many pixels are left alone: there is too little in them to restore, and the network would invent a face rather than recover one", + "widget": "number" + } + }, + "maxFaces": { + "type": "integer", + "default": 8, + "min": 1, + "max": 64, + "step": 1, + "ui": { + "label": "Maximum Faces", + "description": "Restore at most this many faces per frame, the largest first. Each face costs as much time as the next, so a crowd is slow", + "widget": "number" + } + } + }, + "implementation": [ + { + "function": "face_restore.restore_faces", + "role": "finds, aligns, restores and blends back each face (ONNX Runtime)" + }, + { + "function": "RetinaFace (ResNet-50)", + "role": "finds the faces and their eyes, nose and mouth" + }, + { + "function": "RestoreFormer++", + "role": "restores each face", + "activeWhen": { + "model": "restoreFormer" + } + }, + { + "function": "GFPGAN v1.4", + "role": "restores each face", + "activeWhen": { + "model": "gfpgan" + } + }, + { + "function": "CodeFormer", + "role": "restores each face, as close to the picture as Fidelity asks", + "activeWhen": { + "model": "codeFormer" + } + }, + { + "function": "BiSeNet", + "role": "masks the blend to the face itself" + }, + { + "function": "core.std.MaskedMerge", + "role": "blends the faces in, leaving every other pixel untouched" + } + ], + "ui": { + "sections": [ + { + "title": "Restoration", + "parameters": [ + "model", + "strength", + "codeFormerFidelity", + "temporalSmoothing" + ], + "expanded": true + }, + { + "title": "Face detection", + "parameters": [ + "detectionThreshold", + "minFaceSize", + "maxFaces" + ], + "expanded": false, + "advancedOnly": true + } + ] + } +} diff --git a/app/assets/filters/manifest.json b/app/assets/filters/manifest.json index 00ac3319..103cc29d 100644 --- a/app/assets/filters/manifest.json +++ b/app/assets/filters/manifest.json @@ -20,5 +20,6 @@ "deflicker.json", "edge_repair.json", "ghost_removal.json", - "descale.json" + "descale.json", + "face_restore.json" ] diff --git a/app/lib/models/face_restore_parameters.dart b/app/lib/models/face_restore_parameters.dart new file mode 100644 index 00000000..72e37ee3 --- /dev/null +++ b/app/lib/models/face_restore_parameters.dart @@ -0,0 +1,131 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'face_restore_parameters.g.dart'; + +/// Which network restores the faces. Each lives in an add-on (see +/// [FaceRestoreModel.addonIds]); the wire names match the worker's +/// `FaceRestoreModel` (`worker/src/models/face_restore_parameters.rs`). +enum FaceRestoreModel { + /// RestoreFormer++ (Apache-2.0), in the base `face-restoration` add-on. + @JsonValue('restoreFormer') + restoreFormer('RestoreFormer++', 'face-restoration'), + + /// GFPGAN v1.4: non-commercial (NVIDIA's licence on its StyleGAN2 code). + @JsonValue('gfpgan') + gfpgan('GFPGAN v1.4', 'face-gfpgan'), + + /// CodeFormer: non-commercial (S-Lab License 1.0), with a fidelity control. + @JsonValue('codeFormer') + codeFormer('CodeFormer', 'face-codeformer'); + + const FaceRestoreModel(this.displayName, this.addonId); + final String displayName; + + /// The add-on holding this model's file. + final String addonId; + + /// The wire value (`restoreFormer`, `gfpgan`, `codeFormer`). + String get value => switch (this) { + FaceRestoreModel.restoreFormer => 'restoreFormer', + FaceRestoreModel.gfpgan => 'gfpgan', + FaceRestoreModel.codeFormer => 'codeFormer', + }; + + static FaceRestoreModel fromValue(String? value) => FaceRestoreModel.values + .firstWhere((m) => m.value == value, orElse: () => FaceRestoreModel.restoreFormer); +} + +/// The base add-on: the face detector, the face parser and RestoreFormer++. +const faceRestorationAddonId = 'face-restoration'; + +/// The add-ons a Face Restoration job with [model] needs: the base always +/// (detector and parser), plus the model's own. The worker refuses a job +/// without them (`FaceRestoreParameters::required_addons`); the manifests' +/// `requires` bring in the AI runtime. +List faceRestoreAddonIds(FaceRestoreModel model) => [ + faceRestorationAddonId, + if (model.addonId != faceRestorationAddonId) model.addonId, + ]; + +/// What the add-on prompt says the download is for. +const faceRestoreAddonReason = 'Face restoration needs its AI models.'; + +/// The add-ons the Face Restoration panel's current [values] need, or null +/// for any other panel, or while the pass is off. +List? faceRestoreAddonsSelectedBy(String filterId, bool enabled, Map values) => + filterId == 'face_restore' && enabled + ? faceRestoreAddonIds(FaceRestoreModel.fromValue(values['model'] as String?)) + : null; + +/// Face Restoration: find the faces in each frame and restore them with a +/// neural network, blended back at the clip's own resolution. +@JsonSerializable() +class FaceRestoreParameters { + final bool enabled; + final FaceRestoreModel model; + + /// How much of the restored face replaces the original (0-1). + final double strength; + + /// CodeFormer's fidelity weight: 0 trusts its learned faces, 1 stays + /// closest to the picture. Ignored by the other models. + final double codeFormerFidelity; + + /// The detector score (0-1) a face needs. + final double detectionThreshold; + + /// Faces smaller than this (pixels, the larger side of the face's box) are + /// left alone. + final int minFaceSize; + + /// At most this many faces (the largest) are restored per frame. + final int maxFaces; + + /// Steady each face over the frames either side: smooth its landmarks, + /// bridge one-frame detection gaps, and average the restored face with its + /// neighbours' where the picture is still, so it doesn't jitter, blink or + /// shimmer. + final bool temporalSmoothing; + + const FaceRestoreParameters({ + this.enabled = false, + this.model = FaceRestoreModel.restoreFormer, + this.strength = 0.8, + this.codeFormerFidelity = 0.7, + this.detectionThreshold = 0.8, + this.minFaceSize = 32, + this.maxFaces = 8, + this.temporalSmoothing = true, + }); + + FaceRestoreParameters copyWith({ + bool? enabled, + FaceRestoreModel? model, + double? strength, + double? codeFormerFidelity, + double? detectionThreshold, + int? minFaceSize, + int? maxFaces, + bool? temporalSmoothing, + }) { + return FaceRestoreParameters( + enabled: enabled ?? this.enabled, + model: model ?? this.model, + strength: strength ?? this.strength, + codeFormerFidelity: codeFormerFidelity ?? this.codeFormerFidelity, + detectionThreshold: detectionThreshold ?? this.detectionThreshold, + minFaceSize: minFaceSize ?? this.minFaceSize, + maxFaces: maxFaces ?? this.maxFaces, + temporalSmoothing: temporalSmoothing ?? this.temporalSmoothing, + ); + } + + /// The add-ons this configuration needs. + List get addonIds => faceRestoreAddonIds(model); + + String get summary => enabled ? model.displayName : 'Off'; + + factory FaceRestoreParameters.fromJson(Map json) => + _$FaceRestoreParametersFromJson(json); + Map toJson() => _$FaceRestoreParametersToJson(this); +} diff --git a/app/lib/models/parameter_converter.dart b/app/lib/models/parameter_converter.dart index 936375d1..c724ac7f 100644 --- a/app/lib/models/parameter_converter.dart +++ b/app/lib/models/parameter_converter.dart @@ -3,6 +3,7 @@ import 'anti_alias_parameters.dart'; import 'geometry_parameters.dart'; import 'deflicker_parameters.dart'; import 'descale_parameters.dart'; +import 'face_restore_parameters.dart'; import 'edge_repair_parameters.dart'; import 'frame_rate_parameters.dart'; import 'ghost_removal_parameters.dart'; @@ -492,6 +493,38 @@ class ParameterConverter { ); } + /// Convert face restoration parameters to dynamic format. + static DynamicParameters fromFaceRestore(FaceRestoreParameters params) { + return DynamicParameters( + filterId: 'face_restore', + enabled: params.enabled, + values: { + 'model': params.model.value, + 'strength': params.strength, + 'codeFormerFidelity': params.codeFormerFidelity, + 'detectionThreshold': params.detectionThreshold, + 'minFaceSize': params.minFaceSize, + 'maxFaces': params.maxFaces, + 'temporalSmoothing': params.temporalSmoothing, + }, + ); + } + + /// Convert dynamic parameters to face restoration parameters. + static FaceRestoreParameters toFaceRestore(DynamicParameters params) { + final v = params.values; + return FaceRestoreParameters( + enabled: params.enabled, + model: FaceRestoreModel.fromValue(v['model'] as String?), + strength: (v['strength'] as num?)?.toDouble() ?? 0.8, + codeFormerFidelity: (v['codeFormerFidelity'] as num?)?.toDouble() ?? 0.7, + detectionThreshold: (v['detectionThreshold'] as num?)?.toDouble() ?? 0.8, + minFaceSize: _asInt(v['minFaceSize']) ?? 32, + maxFaces: _asInt(v['maxFaces']) ?? 8, + temporalSmoothing: v['temporalSmoothing'] as bool? ?? true, + ); + } + /// Convert deflicker parameters to dynamic format. static DynamicParameters fromDeflicker(DeflickerParameters params) { return DynamicParameters( @@ -997,6 +1030,7 @@ class ParameterConverter { 'color_correction': fromColorCorrection(pipeline.colorCorrection), 'chroma_fixes': fromChromaFixes(pipeline.chromaFixes), 'crop_resize': fromCropResize(pipeline.cropResize), + 'face_restore': fromFaceRestore(pipeline.faceRestore), 'subtitles': fromSubtitles(pipeline.subtitles), }, ); @@ -1614,6 +1648,9 @@ class ParameterConverter { descale: dynamic.get('descale') != null ? toDescale(dynamic.get('descale')!) : const DescaleParameters(), + faceRestore: dynamic.get('face_restore') != null + ? toFaceRestore(dynamic.get('face_restore')!) + : const FaceRestoreParameters(), edgeRepair: dynamic.get('edge_repair') != null ? toEdgeRepair(dynamic.get('edge_repair')!) : const EdgeRepairParameters(), diff --git a/app/lib/models/pass_relevance.dart b/app/lib/models/pass_relevance.dart index 33e4b380..2c4ae14b 100644 --- a/app/lib/models/pass_relevance.dart +++ b/app/lib/models/pass_relevance.dart @@ -134,6 +134,8 @@ PassRelevanceResult relevanceFor(PassType pass, VideoInfo? info) { case PassType.deband: case PassType.sharpen: case PassType.colorCorrection: + // Whether a video has faces worth restoring is not in its metadata. + case PassType.faceRestore: case PassType.subtitles: // Anti-aliasing and stabilisation are tempting to tie to detection — // jaggies follow deinterlacing, shake follows handheld capture — but diff --git a/app/lib/models/processing_pipeline.dart b/app/lib/models/processing_pipeline.dart index 3ea43f04..b3370ad9 100644 --- a/app/lib/models/processing_pipeline.dart +++ b/app/lib/models/processing_pipeline.dart @@ -5,6 +5,7 @@ import 'geometry_parameters.dart'; import 'deflicker_parameters.dart'; import 'descale_parameters.dart'; import 'edge_repair_parameters.dart'; +import 'face_restore_parameters.dart'; import 'frame_rate_parameters.dart'; import 'ghost_removal_parameters.dart'; import 'grain_parameters.dart'; @@ -50,6 +51,7 @@ enum PassType { colorCorrection, chromaFixes, cropResize, + faceRestore, subtitles, } @@ -99,6 +101,8 @@ extension PassTypeExtension on PassType { return 'Chroma Fixes'; case PassType.cropResize: return 'Crop / Resize'; + case PassType.faceRestore: + return 'Face Restoration'; case PassType.subtitles: return 'Subtitles'; } @@ -148,6 +152,8 @@ extension PassTypeExtension on PassType { return 'Fix chroma bleeding and crawl artifacts'; case PassType.cropResize: return 'Crop borders and resize output'; + case PassType.faceRestore: + return 'Restore detail in blurry, low-resolution faces with AI'; case PassType.subtitles: return 'Generate subtitles using Whisper AI'; } @@ -203,6 +209,9 @@ class ProcessingPipeline { /// Crop and resize pass parameters. final CropResizeParameters cropResize; + /// Face restoration pass parameters (an add-on model). + final FaceRestoreParameters faceRestore; + /// Subtitle generation pass parameters (Whisper AI). final SubtitleParameters subtitles; @@ -228,6 +237,7 @@ class ProcessingPipeline { this.colorCorrection = const ColorCorrectionParameters(), this.chromaFixes = const ChromaFixParameters(), this.cropResize = const CropResizeParameters(), + this.faceRestore = const FaceRestoreParameters(), this.subtitles = const SubtitleParameters(), }); @@ -256,6 +266,7 @@ class ProcessingPipeline { colorCorrection: const ColorCorrectionParameters(enabled: false), chromaFixes: const ChromaFixParameters(enabled: false), cropResize: const CropResizeParameters(enabled: false), + faceRestore: const FaceRestoreParameters(enabled: false), subtitles: const SubtitleParameters(enabled: false), ); } @@ -341,6 +352,12 @@ class ProcessingPipeline { (cropResize.cropEnabled || cropResize.resizeEnabled)) { passes.add(PassType.cropResize); } + // Faces are restored at the delivered resolution, after the resize, and + // before the sharpening and grain that would otherwise act on the face + // the restorer is about to replace. + if (faceRestore.enabled) { + passes.add(PassType.faceRestore); + } // Sharpening follows the resize (issue #109), so it works on the delivered // pixels: sharpened earlier, a downscale softens the edges again and an // upscale enlarges the halos. It still follows anti-aliasing, and precedes @@ -386,6 +403,7 @@ class ProcessingPipeline { if (colorCorrection.enabled) count++; if (chromaFixes.enabled) count++; if (cropResize.enabled) count++; + if (faceRestore.enabled) count++; if (subtitles.enabled) count++; return count; } @@ -414,6 +432,7 @@ class ProcessingPipeline { if (colorCorrection.enabled) count++; if (chromaFixes.enabled) count++; if (cropResize.enabled) count++; + if (faceRestore.enabled) count++; return count; } @@ -462,6 +481,8 @@ class ProcessingPipeline { return chromaFixes.enabled; case PassType.cropResize: return cropResize.enabled; + case PassType.faceRestore: + return faceRestore.enabled; case PassType.subtitles: return subtitles.enabled; } @@ -514,6 +535,8 @@ class ProcessingPipeline { return chromaFixes.summary; case PassType.cropResize: return cropResize.summary; + case PassType.faceRestore: + return faceRestore.summary; case PassType.subtitles: return subtitles.summary; } @@ -541,6 +564,7 @@ class ProcessingPipeline { ColorCorrectionParameters? colorCorrection, ChromaFixParameters? chromaFixes, CropResizeParameters? cropResize, + FaceRestoreParameters? faceRestore, SubtitleParameters? subtitles, }) { return ProcessingPipeline( @@ -565,6 +589,7 @@ class ProcessingPipeline { colorCorrection: colorCorrection ?? this.colorCorrection, chromaFixes: chromaFixes ?? this.chromaFixes, cropResize: cropResize ?? this.cropResize, + faceRestore: faceRestore ?? this.faceRestore, subtitles: subtitles ?? this.subtitles, ); } @@ -650,6 +675,8 @@ class ProcessingPipeline { return copyWith( cropResize: cropResize.copyWith(enabled: enabled), ); + case PassType.faceRestore: + return copyWith(faceRestore: faceRestore.copyWith(enabled: enabled)); case PassType.subtitles: return copyWith( subtitles: subtitles.copyWith(enabled: enabled), diff --git a/app/lib/viewmodels/main_viewmodel.dart b/app/lib/viewmodels/main_viewmodel.dart index 43dbac94..2f2a8e21 100644 --- a/app/lib/viewmodels/main_viewmodel.dart +++ b/app/lib/viewmodels/main_viewmodel.dart @@ -391,6 +391,8 @@ class MainViewModel extends ChangeNotifier { return ParameterConverter.fromChromaFixes(_processingPipeline.chromaFixes); case 'crop_resize': return ParameterConverter.fromCropResize(_processingPipeline.cropResize); + case 'face_restore': + return ParameterConverter.fromFaceRestore(_processingPipeline.faceRestore); case 'subtitles': return ParameterConverter.fromSubtitles(_processingPipeline.subtitles); default: @@ -476,6 +478,11 @@ class MainViewModel extends ChangeNotifier { descale: ParameterConverter.toDescale(params), ); break; + case 'face_restore': + _processingPipeline = _processingPipeline.copyWith( + faceRestore: ParameterConverter.toFaceRestore(params), + ); + break; case 'edge_repair': _processingPipeline = _processingPipeline.copyWith( edgeRepair: ParameterConverter.toEdgeRepair(params), @@ -1380,6 +1387,8 @@ class MainViewModel extends ChangeNotifier { return 'chroma_fixes'; case PassType.cropResize: return 'crop_resize'; + case PassType.faceRestore: + return 'face_restore'; case PassType.subtitles: return 'subtitles'; } diff --git a/app/lib/views/about_dialog.dart b/app/lib/views/about_dialog.dart index d5c78955..dabe98bb 100644 --- a/app/lib/views/about_dialog.dart +++ b/app/lib/views/about_dialog.dart @@ -307,6 +307,42 @@ class _AboutDialogState extends State { copyright: 'the-database', url: 'https://github.com/the-database/mpv-upscale-2x_animejanai', ), + _ComponentTile( + name: 'RestoreFormer++ model (optional download, face restoration add-on)', + license: 'Apache-2.0', + copyright: 'Zhouxia Wang, Jiawei Zhang, Tianshui Chen, Wenping Wang and Ping Luo', + url: 'https://github.com/wzhouxiff/RestoreFormerPlusPlus', + ), + _ComponentTile( + name: 'RetinaFace face detector (optional download, face restoration add-on)', + license: 'MIT', + copyright: 'biubug6 (Pytorch_Retinaface)', + url: 'https://github.com/biubug6/Pytorch_Retinaface', + ), + _ComponentTile( + name: 'BiSeNet face parser (optional download, face restoration add-on)', + license: 'MIT', + copyright: 'zll (face-parsing.PyTorch)', + url: 'https://github.com/zllrunning/face-parsing.PyTorch', + ), + _ComponentTile( + name: 'facexlib (optional download, face restoration add-on)', + license: 'MIT', + copyright: 'Xintao Wang', + url: 'https://github.com/xinntao/facexlib', + ), + _ComponentTile( + name: 'GFPGAN v1.4 model (optional download, non-commercial use only)', + license: 'Apache-2.0 + NVIDIA Source Code License (NC)', + copyright: 'THL A29 Limited, a Tencent company', + url: 'https://github.com/TencentARC/GFPGAN', + ), + _ComponentTile( + name: 'CodeFormer model (optional download, non-commercial use only)', + license: 'S-Lab License 1.0 (NC)', + copyright: 'S-Lab', + url: 'https://github.com/sczhou/CodeFormer', + ), _ComponentTile( name: 'Real-CUGAN models', license: 'MIT', diff --git a/app/lib/views/pass_list/pass_list_item.dart b/app/lib/views/pass_list/pass_list_item.dart index 7c1f4412..c56e4642 100644 --- a/app/lib/views/pass_list/pass_list_item.dart +++ b/app/lib/views/pass_list/pass_list_item.dart @@ -263,6 +263,8 @@ class PassListItem extends StatelessWidget { return Icons.tune; case PassType.cropResize: return Icons.crop; + case PassType.faceRestore: + return Icons.face_retouching_natural; case PassType.subtitles: return Icons.subtitles; } diff --git a/app/lib/views/pass_list/pass_list_panel.dart b/app/lib/views/pass_list/pass_list_panel.dart index d1aaf80b..2c0ff94c 100644 --- a/app/lib/views/pass_list/pass_list_panel.dart +++ b/app/lib/views/pass_list/pass_list_panel.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../models/face_restore_parameters.dart'; import '../../models/filter_registry.dart'; import '../../models/pass_relevance.dart'; import '../../models/processing_pipeline.dart'; @@ -69,7 +70,12 @@ class PassListPanel extends StatelessWidget { ), ( title: 'Finishing', - passes: [PassType.sharpen, PassType.grain, PassType.frameRate], + passes: [ + PassType.faceRestore, + PassType.sharpen, + PassType.grain, + PassType.frameRate, + ], ), ( title: 'Post-Processing', @@ -176,6 +182,15 @@ class PassListPanel extends StatelessWidget { case PassType.cropResize: return item(passType, 'Crop / Resize', pipeline.cropResize.summary, pipeline.cropResize.enabled); + case PassType.faceRestore: + return item( + passType, + 'Face Restoration', + pipeline.faceRestore.summary, + pipeline.faceRestore.enabled, + onToggle: (enabled) => + _handleFaceRestoreToggle(context, viewModel, enabled), + ); case PassType.subtitles: return item( passType, @@ -284,6 +299,25 @@ class PassListPanel extends StatelessWidget { viewModel.togglePass(PassType.subtitles, enabled); } + /// Face Restoration's models are add-ons: switching the pass on offers to + /// download the ones its chosen model needs (with their licence terms; the + /// GFPGAN and CodeFormer ones are non-commercial). Declining leaves it off. + Future _handleFaceRestoreToggle( + BuildContext context, + MainViewModel viewModel, + bool enabled, + ) async { + if (enabled) { + final ready = await AddonPrompt.ensure( + context, + viewModel.processingPipeline.faceRestore.addonIds, + reason: faceRestoreAddonReason, + ); + if (!ready) return; + } + viewModel.togglePass(PassType.faceRestore, enabled); + } + String _getPassSummary(ProcessingPipeline pipeline) { final videoCount = pipeline.videoPassCount; final hasSubtitles = pipeline.subtitles.enabled; diff --git a/app/lib/views/pass_settings/pass_settings_inline.dart b/app/lib/views/pass_settings/pass_settings_inline.dart index d9d963ca..91fefd5f 100644 --- a/app/lib/views/pass_settings/pass_settings_inline.dart +++ b/app/lib/views/pass_settings/pass_settings_inline.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../models/dynamic_parameters.dart'; +import '../../models/face_restore_parameters.dart'; import '../../models/filter_registry.dart'; import '../../models/filter_schema.dart'; import '../../models/pass_advice.dart'; @@ -74,6 +75,8 @@ class PassSettingsInline extends StatelessWidget { return 'chroma_fixes'; case PassType.cropResize: return 'crop_resize'; + case PassType.faceRestore: + return 'face_restore'; case PassType.subtitles: return 'subtitles'; } @@ -104,6 +107,10 @@ class PassSettingsInline extends StatelessWidget { _buildKnlmNotice(viewModel, filterId, params), if (animeJanaiSelectedBy(filterId, params.values)) const AnimeJanaiNotice(), + if (filterId == 'face_restore') + FaceRestoreNotice( + model: FaceRestoreModel.fromValue(params.values['model'] as String?), + ), if (filterId == 'descale') _buildDescaleDetect(context, viewModel), _buildBitDepthWarning(context, viewModel, schema, params), DynamicFilterPanelCompact( @@ -375,6 +382,21 @@ class PassSettingsInline extends StatelessWidget { ); if (!ready) return; } + // Face Restoration's models are add-ons: switching it on, or choosing + // another model while it is on, offers to download what that model needs + // (with its terms: GFPGAN and CodeFormer are non-commercial). + final faceAddons = + faceRestoreAddonsSelectedBy(filterId, newParams.enabled, newParams.values); + if (faceAddons != null && + (!oldParams.enabled || oldParams.values['model'] != newParams.values['model'])) { + if (!context.mounted) return; + final ready = await AddonPrompt.ensure( + context, + faceAddons, + reason: faceRestoreAddonReason, + ); + if (!ready) return; + } viewModel.updateDynamicParams(filterId, newParams); } @@ -607,3 +629,76 @@ class _AnimeJanaiNoticeState extends State { ); } } + +/// Under Face Restoration: whether the chosen model's add-ons are installed +/// (with a button to download them if not — deleted since, or chosen by a +/// preset), and the model add-on's terms. GFPGAN's and CodeFormer's +/// non-commercial restriction is shown in bold whenever either is chosen, +/// since it is the user's to keep in mind on every job. Advisory only: the +/// worker refuses a job whose add-ons are missing. +class FaceRestoreNotice extends StatefulWidget { + final FaceRestoreModel model; + final AddonService? service; + const FaceRestoreNotice({super.key, required this.model, this.service}); + + @override + State createState() => _FaceRestoreNoticeState(); +} + +class _FaceRestoreNoticeState extends State { + AddonService get _service => widget.service ?? AddonService.instance; + + @override + void initState() { + super.initState(); + _service.load(); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _service, + builder: (context, _) { + final ids = faceRestoreAddonIds(widget.model); + final info = _service.info(widget.model.addonId); + if (info == null) return const SizedBox.shrink(); + final missing = _service.missing(ids); + final installed = missing.isEmpty; + final bytes = missing + .map(_service.info) + .whereType() + .fold(0, (sum, i) => sum + i.downloadBytes); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + installed + ? '${widget.model.displayName} is installed.' + : 'The ${widget.model.displayName} add-on is not installed.', + style: Theme.of(context).textTheme.bodySmall, + ), + AddonTerms(info: info), + ], + ), + ), + if (!installed) + OutlinedButton.icon( + onPressed: _service.installingId != null + ? null + : () => AddonPrompt.ensure(context, ids, + reason: faceRestoreAddonReason, service: _service), + icon: const Icon(Icons.download, size: 16), + label: Text('Download (${formatAddonSize(bytes)})'), + ), + ], + ), + ); + }, + ); + } +} diff --git a/app/test/attribution_test.dart b/app/test/attribution_test.dart index 22ff90c3..659edbcd 100644 --- a/app/test/attribution_test.dart +++ b/app/test/attribution_test.dart @@ -341,6 +341,7 @@ Set _licenceFamilies(String text) { 'psf': 'psf', 'python software': 'psf', 'zlib': 'zlib', 'unlicense': 'unlicense', 'no licence': 'none stated', 'no license': 'none stated', 'no copyright': 'no copyright', 'microsoft software license': 'microsoft proprietary', + 's-lab license': 's-lab', 'nvidia source code license': 'nvidia-nc', }; final out = {for (final MapEntry(key: w, value: f) in words.entries) if (t.contains(w)) f}; for (final m in RegExp(r'(?); + expect(back.toJson(), p.toJson()); + }); + + test('the converter carries every field both ways', () { + const p = FaceRestoreParameters( + enabled: true, + model: FaceRestoreModel.codeFormer, + strength: 0.4, + codeFormerFidelity: 0.9, + detectionThreshold: 0.6, + minFaceSize: 20, + maxFaces: 5, + temporalSmoothing: false, + ); + final dyn = ParameterConverter.fromFaceRestore(p); + expect(dyn.filterId, 'face_restore'); + expect(ParameterConverter.toFaceRestore(dyn).toJson(), p.toJson()); + // And through a whole pipeline. + final pipeline = const ProcessingPipeline().copyWith(faceRestore: p); + expect(ParameterConverter.toPipeline(ParameterConverter.fromPipeline(pipeline)).faceRestore.toJson(), + p.toJson()); + }); + + test('each model names the add-ons the worker requires', () { + expect(faceRestoreAddonIds(FaceRestoreModel.restoreFormer), ['face-restoration']); + expect(faceRestoreAddonIds(FaceRestoreModel.gfpgan), ['face-restoration', 'face-gfpgan']); + expect(faceRestoreAddonIds(FaceRestoreModel.codeFormer), + ['face-restoration', 'face-codeformer']); + for (final m in FaceRestoreModel.values) { + expect(File('assets/addons/${m.addonId}.json').existsSync(), isTrue, reason: m.addonId); + } + expect(faceRestoreAddonsSelectedBy('face_restore', false, const {'model': 'gfpgan'}), isNull, + reason: 'nothing to offer while the pass is off'); + expect(faceRestoreAddonsSelectedBy('crop_resize', true, const {}), isNull); + expect(faceRestoreAddonsSelectedBy('face_restore', true, const {'model': 'gfpgan'}), + ['face-restoration', 'face-gfpgan']); + }); + + test('the pass runs after Crop & Resize and before Sharpen, and toggles cleanly', () { + const custom = FaceRestoreParameters(model: FaceRestoreModel.gfpgan, strength: 0.3); + final pipeline = const ProcessingPipeline().copyWith( + faceRestore: custom, + ).togglePass(PassType.faceRestore, true); + expect(pipeline.isPassEnabled(PassType.faceRestore), isTrue); + expect(pipeline.faceRestore.model, FaceRestoreModel.gfpgan, + reason: 'togglePass must not reset the model'); + expect(pipeline.faceRestore.strength, 0.3); + expect(pipeline.getPassSummary(PassType.faceRestore), 'GFPGAN v1.4'); + expect(pipeline.togglePass(PassType.faceRestore, false).getPassSummary(PassType.faceRestore), + 'Off'); + }); +} diff --git a/app/test/integration_filter_parameters_test.dart b/app/test/integration_filter_parameters_test.dart index d961387d..539e6bf8 100644 --- a/app/test/integration_filter_parameters_test.dart +++ b/app/test/integration_filter_parameters_test.dart @@ -24,6 +24,7 @@ import 'package:vapourbox/models/descale_parameters.dart'; import 'package:vapourbox/models/descratch_parameters.dart'; import 'package:vapourbox/models/dynamic_parameters.dart'; import 'package:vapourbox/models/encoding_settings.dart'; +import 'package:vapourbox/models/face_restore_parameters.dart'; import 'package:vapourbox/models/frame_rate_parameters.dart'; import 'package:vapourbox/models/filter_schema.dart'; import 'package:vapourbox/models/noise_reduction_parameters.dart'; @@ -35,6 +36,8 @@ import 'package:vapourbox/models/spotless_parameters.dart'; import 'package:vapourbox/models/stabilize_parameters.dart'; import 'package:vapourbox/models/video_job.dart'; +import 'package:vapourbox/services/manifest_addon.dart'; + import 'support/vfr_fixture.dart'; import 'support/worker_harness.dart'; @@ -177,6 +180,7 @@ VideoJob buildJob({ GeometryParameters? geometry, GrainParameters? grain, DescaleParameters? descale, + FaceRestoreParameters? faceRestore, }) => VideoJob( id: const Uuid().v4(), inputPath: TestConfig.inputFile, @@ -199,6 +203,7 @@ VideoJob buildJob({ geometry: geometry ?? const GeometryParameters(), grain: grain ?? const GrainParameters(), descale: descale ?? const DescaleParameters(), + faceRestore: faceRestore ?? const FaceRestoreParameters(), ), encodingSettings: const EncodingSettings( codec: VideoCodec.h264, container: ContainerFormat.mkv, audioMode: AudioMode.none, @@ -1189,6 +1194,69 @@ void main() { print(' PASS'); }, timeout: const Timeout(Duration(minutes: 2))); + test('face restoration: every setting reaches the script, or the job is refused', + () async { + // The models are add-ons of ~700 MB (base + CodeFormer), far too much + // for a push-gate test to download — and until the addon-models-v1 + // release exists there is nothing to download. So: with + // VAPOURBOX_ADDON_MIRROR set (the converted files in addon-staging), + // install them the app's way and check the script; if they are already + // installed, check the script; otherwise check the refusal, which is + // what CI sees, and which names the add-ons and Settings → Add-ons. + loadSchema('face_restore'); + const typed = FaceRestoreParameters( + enabled: true, + model: FaceRestoreModel.codeFormer, + strength: 0.6, + codeFormerFidelity: 0.5, + detectionThreshold: 0.7, + minFaceSize: 40, + maxFaces: 3, + temporalSmoothing: false, + ); + // Through the converter, as the panel would hand it to the worker. + final roundTripped = + ParameterConverter.toFaceRestore(ParameterConverter.fromFaceRestore(typed)); + final job = buildJob(testName: 'face_restore', faceRestore: roundTripped); + + if (Platform.environment['VAPOURBOX_ADDON_MIRROR']?.isNotEmpty ?? false) { + await WorkerHarness.ensureAddon('face-codeformer'); + } + final manifests = { + for (final m in AddonManifest.loadDirectory( + Directory('${TestConfig.projectRoot}/app/assets/addons'))) + m.id: m, + }; + final installed = ['ai-runtime', ...roundTripped.addonIds].every((id) => + ManifestAddonInstaller(manifests[id]!, WorkerHarness.platform) + .isInstalled(WorkerHarness.addonsDirectory)); + + if (installed) { + print(' Generating Face Restoration script...'); + final script = await generateScriptViaWorker(job); + expect(script, contains('import ai_runtime\n')); + expect(script, contains('_face_restore.restore_faces(')); + expect(script, contains('face-codeformer-v0.1.0.onnx')); + expect(script, contains('face-restoration-retinaface-r50.onnx')); + expect(script, contains('face-restoration-bisenet.onnx')); + expect(script, contains('model="codeformer"')); + expect(script, contains('strength=0.600')); + expect(script, contains('fidelity=0.500')); + expect(script, contains('threshold=0.700')); + expect(script, contains('min_face=40')); + expect(script, contains('max_faces=3')); + expect(script, contains('temporal_radius=0')); + } else { + print(' Face restoration add-ons not installed: checking the refusal'); + final result = await WorkerHarness.runJob(job.toJson(), label: 'face_restore'); + expect(result.success, isFalse); + final text = '${result.error}\n${result.logs.join('\n')}'; + expect(text, contains('Face restoration needs')); + expect(text, contains('Settings → Add-ons')); + } + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 5))); + test('sharpen: aWarpSharp2 params', () async { loadSchema('sharpen'); final typed = const SharpenParameters( diff --git a/app/test/new_badges_shipped_test.dart b/app/test/new_badges_shipped_test.dart index e2d0c2bb..10d8a311 100644 --- a/app/test/new_badges_shipped_test.dart +++ b/app/test/new_badges_shipped_test.dart @@ -73,4 +73,27 @@ void main() { test('a filter with nothing new in 1.5.0 is not badged', () { expect(service.isSchemaNew(_load('deband.json')), isFalse); }); + + group('1.7.0', () { + setUp(() async { + // Upgraded from 1.6.0 to 1.7.0. + SharedPreferences.setMockInitialValues({ + 'lastRunAppVersion': '1.6.0', + 'lastSeenAppVersion': '1.6.0', + }); + PackageInfo.setMockInitialValues( + appName: 'VapourBox', packageName: 'app.vapourbox.vapourbox', + version: '1.7.0', buildNumber: '1', buildSignature: ''); + service.resetForTesting(); + await service.initialize(); + }); + + test('Face Restoration is a new filter', () { + expect(service.isSchemaNew(_load('face_restore.json')), isTrue); + }); + + test('Descale, new in 1.5.0, is no longer badged', () { + expect(service.isSchemaNew(_load('descale.json')), isFalse); + }); + }); } diff --git a/app/test/parameter_copy_with_test.dart b/app/test/parameter_copy_with_test.dart index 4d6539dc..db6c3123 100644 --- a/app/test/parameter_copy_with_test.dart +++ b/app/test/parameter_copy_with_test.dart @@ -36,6 +36,7 @@ import 'package:vapourbox/models/deflicker_parameters.dart'; import 'package:vapourbox/models/dehalo_parameters.dart'; import 'package:vapourbox/models/descratch_parameters.dart'; import 'package:vapourbox/models/edge_repair_parameters.dart'; +import 'package:vapourbox/models/face_restore_parameters.dart'; import 'package:vapourbox/models/frame_rate_parameters.dart'; import 'package:vapourbox/models/geometry_parameters.dart'; import 'package:vapourbox/models/ghost_removal_parameters.dart'; @@ -94,6 +95,8 @@ void main() { (j) => DeScratchParameters.fromJson(j).copyWith().toJson()), _Model('EdgeRepairParameters', () => const EdgeRepairParameters().toJson(), (j) => EdgeRepairParameters.fromJson(j).copyWith().toJson()), + _Model('FaceRestoreParameters', () => const FaceRestoreParameters().toJson(), + (j) => FaceRestoreParameters.fromJson(j).copyWith().toJson()), _Model('FrameRateParameters', () => const FrameRateParameters().toJson(), (j) => FrameRateParameters.fromJson(j).copyWith().toJson()), _Model('GeometryParameters', () => const GeometryParameters().toJson(), @@ -140,7 +143,7 @@ void main() { test('every parameter model is covered', () { // The count is the guard: a new model with a forgetful copyWith would // otherwise be caught by nothing at all. - expect(models.length, 21, + expect(models.length, 22, reason: 'a parameter model was added or removed — add it to this ' 'list and update the count'); }); diff --git a/app/test/pass_list_stages_test.dart b/app/test/pass_list_stages_test.dart index 29d00367..14cc0339 100644 --- a/app/test/pass_list_stages_test.dart +++ b/app/test/pass_list_stages_test.dart @@ -54,6 +54,7 @@ void main() { PassType.stabilize, PassType.geometry, PassType.cropResize, + PassType.faceRestore, PassType.sharpen, PassType.grain, PassType.frameRate, @@ -103,6 +104,19 @@ void main() { }); }); + test('faces are restored after the resize and before sharpening', () { + // At the delivered resolution: an upscale gives the restorer more pixels + // per face, and nothing resamples its detail away afterwards. Sharpening + // and grain follow, so they act on the restored face rather than the one + // it replaced. + expect(flattened.indexOf(PassType.faceRestore), + greaterThan(flattened.indexOf(PassType.cropResize))); + expect(flattened.indexOf(PassType.faceRestore), + lessThan(flattened.indexOf(PassType.sharpen))); + expect(flattened.indexOf(PassType.faceRestore), + lessThan(flattened.indexOf(PassType.grain))); + }); + test('grain is the last video pass', () { // Grain added before the resize is resampled away and before the deband is // smoothed away, so it has to follow both. diff --git a/licenses/NOTICES.txt b/licenses/NOTICES.txt index 8b239e64..79a0fba6 100644 --- a/licenses/NOTICES.txt +++ b/licenses/NOTICES.txt @@ -294,6 +294,70 @@ AnimeJaNai models — CC BY-NC-SA 4.0 (non-commercial) upscaler). Free for non-commercial use only; anyone using them must keep to that. +RestoreFormer++ model — Apache-2.0 + By Zhouxia Wang, Jiawei Zhang, Tianshui Chen, Wenping Wang and Ping Luo (the + authors its README cites); its LICENSE names no copyright holder. + https://github.com/wzhouxiff/RestoreFormerPlusPlus + Licence text: Apache-2.0.txt + The trained RestoreFormer++ network (release v1.0.0), the Face Restoration + pass's default model. NOT distributed with VapourBox: part of the optional + face restoration add-on, which the app downloads only when the user asks + (Settings → Add-ons, or on switching the pass on), as an ONNX conversion of + the published weights (Scripts/models/make_face_restoration.py). + +RetinaFace face detector — MIT + By biubug6 (Pytorch_Retinaface). Its LICENSE reads "Copyright (c) 2019" + and names no holder. + https://github.com/biubug6/Pytorch_Retinaface + The RetinaFace ResNet-50 face detector (detection_Resnet50_Final.pth as + facexlib publishes it), which finds the faces and their landmarks for the + Face Restoration pass. NOT distributed with VapourBox: part of the optional + face restoration add-on, downloaded as an ONNX conversion only when the user + asks. + +BiSeNet face parser — MIT + Copyright (c) 2019 zll (face-parsing.PyTorch) + https://github.com/zllrunning/face-parsing.PyTorch + The BiSeNet face-parsing network (parsing_bisenet.pth as facexlib publishes + it), which limits the Face Restoration pass's blend to the face itself. NOT + distributed with VapourBox: part of the optional face restoration add-on, + downloaded as an ONNX conversion only when the user asks. + +facexlib — MIT + Copyright (c) 2020 Xintao Wang + https://github.com/xinntao/facexlib + The face-restoration reference code whose detector and parser weights the + face restoration add-on converts, and whose FFHQ alignment template and + paste-back the pass reproduces (worker/templates/face_restore.py). Its + weights reach users only through that optional add-on, downloaded when the + user asks. + +GFPGAN v1.4 model — Apache-2.0 + NVIDIA Source Code License (NC) + Copyright (C) 2021 THL A29 Limited, a Tencent company. All rights reserved. + https://github.com/TencentARC/GFPGAN + Licence: https://github.com/TencentARC/GFPGAN/blob/master/LICENSE + The trained GFPGAN v1.4 network (GFPGANv1.4.pth), an alternative model for + the Face Restoration pass. GFPGAN is Apache-2.0 except its third-party + components; its StyleGAN2-derived network code is under NVIDIA's Source Code + License, whose section 3.3 says the work "only may be used or intended for + use non-commercially", where "non-commercially" means "for research or + evaluation purposes only". NOT distributed with VapourBox: an optional + add-on (face-gfpgan), downloaded as an ONNX conversion only when the user + asks, after stating that restriction. Non-commercial use only; anyone using + it must keep to that. + +CodeFormer model — S-Lab License 1.0 (NC) + Copyright 2022 S-Lab + https://github.com/sczhou/CodeFormer + Licence: https://github.com/sczhou/CodeFormer/blob/master/LICENSE + The trained CodeFormer network (codeformer.pth, release v0.1.0), an + alternative model for the Face Restoration pass. The S-Lab License 1.0 + permits redistribution and use for non-commercial purposes, with the + copyright notice and its disclaimer; commercial use requires contacting the + contributors. NOT distributed with VapourBox: an optional add-on + (face-codeformer), downloaded as an ONNX conversion only when the user asks, + after stating that restriction. Non-commercial use only. + Real-CUGAN models — MIT Copyright (c) 2022 bilibili https://github.com/bilibili/ailab/tree/main/Real-CUGAN @@ -564,7 +628,8 @@ MIT License (Applies to zsmooth, vszip and the zigimg library it includes, CAS, DCTFilter, BM3D, descale, the RIFE plugin, rife-ncnn-vulkan, the RIFE model and ECCV2022-RIFE, the Real-CUGAN models, whisper.cpp, the Whisper models, ONNX -Runtime, pip and the Clense filter +Runtime, the RetinaFace face detector, the BiSeNet face parser, facexlib, pip +and the Clense filter in vs-removegrain, each under the copyright notice given in its entry above.) From 09d42614f6f90bdb90d2e28e5afae40e643bb657 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 8 Nov 2026 18:55:38 +1100 Subject: [PATCH 20/61] fix(face-restore): serialise model runs, keep only face regions in the memo, heavy test Concurrent Core ML predictions from VapourSynth's threads ran the GPU out of memory; ai_runtime.Runner serialises a pass's model runs (one lock for the detector, parser and restorer) and retries a failed GPU run on the CPU. The per-frame memo now holds only the region the faces reach, not whole RGB frames. The script logs which provider the restorer runs on. integration_face_restore_test.dart (heavy): RestoreFormer++ changes the face and leaves every sample outside it bit-exact (8- and 10-bit), strength 0 is the input, GFPGAN and CodeFormer run with and without smoothing, and the preview is the encode's frame. Co-Authored-By: Claude Opus 5.5 --- app/test/integration_face_restore_test.dart | 228 +++++++++++++++++++ worker/src/models/face_restore_parameters.rs | 7 +- worker/src/script_generator.rs | 2 +- worker/templates/ai_runtime.py | 39 ++++ worker/templates/face_restore.py | 59 +++-- 5 files changed, 315 insertions(+), 20 deletions(-) create mode 100644 app/test/integration_face_restore_test.dart diff --git a/app/test/integration_face_restore_test.dart b/app/test/integration_face_restore_test.dart new file mode 100644 index 00000000..f314bd0c --- /dev/null +++ b/app/test/integration_face_restore_test.dart @@ -0,0 +1,228 @@ +// Face Restoration end to end, on a VHS-like clip of a real face +// (Tests/TestResources/face_restore_test.mp4: NASA public-domain footage, +// see generate_face_test.sh), with the add-ons installed by the app's own +// installer (WorkerHarness.ensureAddon; set VAPOURBOX_ADDON_MIRROR to test +// files not uploaded yet): +// +// * RestoreFormer++ finds the face and changes it, and every pixel outside a +// generous box around the face is bit-exact to the same job with the pass +// off (the pass composites in the source format with a mask that is zero +// there) — in every plane, every frame; +// * the GFPGAN and CodeFormer add-ons run, with and without smoothing; +// * the preview is the encode's frame; +// * a 10-bit source comes out 10-bit, changed in the face and bit-exact +// outside it. +@Tags(['heavy']) +@Timeout(Duration(minutes: 40)) +library; + +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:uuid/uuid.dart'; + +import 'package:vapourbox/models/encoding_settings.dart'; +import 'package:vapourbox/models/face_restore_parameters.dart'; +import 'package:vapourbox/models/processing_pipeline.dart'; +import 'package:vapourbox/models/qtgmc_parameters.dart'; +import 'package:vapourbox/models/video_job.dart'; + +import 'support/worker_harness.dart'; + +const _w = 640, _h = 480; +// Frames 30-45 of the fixture: the encode keeps 16 frames. +const _start = 30, _end = 45; +const _count = _end - _start + 1; + +/// The face's detected box in the fixture is about x 255-365, y 149-286 +/// (docs/reports/2026-11-face-restoration). Changes must stay inside this box, +/// which adds a little over half the face's size on every side — the +/// feathered mask reaches past the face itself, and the head moves. +const _outer = (x0: 190, y0: 70, x1: 430, y1: 370); + +/// The middle of the face, which restoration must visibly change. +const _inner = (x0: 280, y0: 180, x1: 340, y1: 255); + +String get _fixture => p.join(WorkerHarness.repoRoot, 'Tests', 'TestResources', 'face_restore_test.mp4'); +String get _dir => p.join(WorkerHarness.outputDir, 'face_restore'); + +/// A job over the trimmed range ([trimmed]), or the whole fixture (the +/// preview's job: its `--frame` is a source frame, and the worker takes +/// `totalFrames` as the post-trim count). +Map _job(String name, FaceRestoreParameters? fr, + {String? input, String pixFmt = 'yuv420p', bool trimmed = true}) { + final job = VideoJob( + id: const Uuid().v4(), + inputPath: input ?? _fixture, + outputPath: p.join(_dir, '$name.mkv'), + processingPipeline: ProcessingPipeline( + deinterlace: const QTGMCParameters(enabled: false), + faceRestore: fr ?? const FaceRestoreParameters(enabled: false), + ), + encodingSettings: const EncodingSettings( + codec: VideoCodec.ffv1, container: ContainerFormat.mkv, audioMode: AudioMode.none), + inputWidth: _w, + inputHeight: _h, + inputPixelFormat: pixFmt, + inputFrameRate: 29.97, + totalFrames: trimmed ? _count : 150, + startFrame: trimmed ? _start : null, + endFrame: trimmed ? _end : null, + ); + return job.toJson(); +} + +Future _encode(String name, FaceRestoreParameters? fr, + {String? input, String pixFmt = 'yuv420p'}) async { + final r = await WorkerHarness.runJob(_job(name, fr, input: input, pixFmt: pixFmt), + label: name, timeout: const Duration(minutes: 15)); + expect(r.success, isTrue, reason: '${r.error}\n${r.logs.reversed.take(30).toList().reversed.join('\n')}'); + // ignore: avoid_print + print(' $name: ${r.duration.inMilliseconds / 1000}s for $_count frames'); + return r.outputPath ?? p.join(_dir, '$name.mkv'); +} + +/// Every frame of [path] as raw planar samples in [pixFmt]. +Future _raw(String path, String pixFmt) async { + final r = await Process.run( + WorkerHarness.ffmpegPath, + ['-v', 'error', '-i', path, '-f', 'rawvideo', '-pix_fmt', pixFmt, '-'], + environment: WorkerHarness.ffmpegEnv, + stdoutEncoding: null, + ); + expect(r.exitCode, 0, reason: '${r.stderr}'); + return Uint8List.fromList(r.stdout as List); +} + +/// Compare two decodes of 4:2:0 video sample by sample ([bytes] per sample). +/// Returns the mean absolute luma difference inside [_inner] (in 8-bit +/// levels) and the number of samples that differ outside [_outer]. +({double innerDiff, int outsideChanged, int frames}) _compare(Uint8List a, Uint8List b, {int bytes = 1}) { + expect(a.length, b.length, reason: 'the two encodes differ in size'); + final cw = _w ~/ 2, ch = _h ~/ 2; + final frameSamples = _w * _h + 2 * cw * ch; + final frames = a.length ~/ (frameSamples * bytes); + int sample(Uint8List d, int i) => bytes == 1 ? d[i] : d[i * 2] | (d[i * 2 + 1] << 8); + final scale = bytes == 1 ? 1.0 : 1 / 4.0; + var inner = 0.0, innerN = 0, outside = 0; + for (var f = 0; f < frames; f++) { + final base = f * frameSamples; + for (var y = 0; y < _h; y++) { + for (var x = 0; x < _w; x++) { + final i = base + y * _w + x; + final d = (sample(a, i) - sample(b, i)).abs(); + final inOuter = x >= _outer.x0 && x < _outer.x1 && y >= _outer.y0 && y < _outer.y1; + if (!inOuter && d != 0) outside++; + if (x >= _inner.x0 && x < _inner.x1 && y >= _inner.y0 && y < _inner.y1) { + inner += d * scale; + innerN++; + } + } + } + for (var plane = 0; plane < 2; plane++) { + final pbase = base + _w * _h + plane * cw * ch; + for (var y = 0; y < ch; y++) { + for (var x = 0; x < cw; x++) { + final inOuter = x * 2 >= _outer.x0 && x * 2 < _outer.x1 && y * 2 >= _outer.y0 && y * 2 < _outer.y1; + if (!inOuter && sample(a, pbase + y * cw + x) != sample(b, pbase + y * cw + x)) outside++; + } + } + } + } + return (innerDiff: inner / innerN, outsideChanged: outside, frames: frames); +} + +void main() { + late String baseline; + late Uint8List baselineRaw; + + setUpAll(() async { + await WorkerHarness.ensureReady(); + await Directory(_dir).create(recursive: true); + // The base add-on (detector, parser, RestoreFormer++) and the two + // non-commercial models, each with its requirements (the AI runtime). + await WorkerHarness.ensureAddon('face-restoration'); + await WorkerHarness.ensureAddon('face-gfpgan'); + await WorkerHarness.ensureAddon('face-codeformer'); + baseline = await _encode('baseline', null); + baselineRaw = await _raw(baseline, 'yuv420p'); + }); + + test('RestoreFormer++ changes the face and nothing outside it', () async { + final out = await _encode('restoreformer', const FaceRestoreParameters(enabled: true)); + final c = _compare(baselineRaw, await _raw(out, 'yuv420p')); + // ignore: avoid_print + print(' restoreformer: face mean |diff| ${c.innerDiff.toStringAsFixed(2)}, ' + '${c.outsideChanged} samples changed outside the face box'); + expect(c.frames, _count); + expect(c.innerDiff, greaterThan(2.0), reason: 'the face was not restored'); + expect(c.outsideChanged, 0, reason: 'pixels outside the face changed'); + }); + + test('strength 0 is the input, bit-exact', () async { + final out = await _encode('strength0', const FaceRestoreParameters(enabled: true, strength: 0)); + final c = _compare(baselineRaw, await _raw(out, 'yuv420p')); + expect(c.innerDiff, 0); + expect(c.outsideChanged, 0); + }); + + for (final (model, smoothing) in [ + (FaceRestoreModel.gfpgan, true), + (FaceRestoreModel.codeFormer, true), + (FaceRestoreModel.codeFormer, false), + ]) { + test('${model.displayName} runs (smoothing ${smoothing ? 'on' : 'off'})', () async { + final out = await _encode('${model.value}_${smoothing ? 'smooth' : 'plain'}', + FaceRestoreParameters(enabled: true, model: model, temporalSmoothing: smoothing)); + final c = _compare(baselineRaw, await _raw(out, 'yuv420p')); + // ignore: avoid_print + print(' ${model.value}: face mean |diff| ${c.innerDiff.toStringAsFixed(2)}'); + expect(c.innerDiff, greaterThan(2.0), reason: 'the face was not restored'); + expect(c.outsideChanged, 0, reason: 'pixels outside the face changed'); + }); + } + + test('the preview is the encode\'s frame', () async { + const fr = FaceRestoreParameters(enabled: true); + final out = await _encode('preview_ref', fr); + // Two frames or more from the trim's ends, so the encode's temporal + // neighbours (two either side) are the preview's. + for (final frame in [_start + 2, _start + 10]) { + final pv = await WorkerHarness.runPreview(_job('preview', fr, trimmed: false), frame: frame, + timeout: const Duration(minutes: 5)); + expect(pv.success, isTrue, reason: '${pv.error}\n${pv.logs}'); + final preview = await WorkerHarness.imageToRgb24(pv.png!, label: 'fr_pv'); + final encoded = await WorkerHarness.frameRgb24(out, frame - _start, label: 'fr_enc'); + final input = await WorkerHarness.frameRgb24(baseline, frame - _start, label: 'fr_in'); + final diff = WorkerHarness.meanAbsDiff(preview, encoded); + final restored = WorkerHarness.meanAbsDiff(input, encoded); + // ignore: avoid_print + print(' frame $frame: preview vs encode $diff, input vs encode $restored'); + expect(diff, lessThan(0.5), reason: 'the preview shows a different picture'); + expect(restored, greaterThan(diff * 4), reason: 'the comparison cannot tell restored from not'); + } + }); + + test('a 10-bit source stays 10-bit, restored in the face and exact outside it', () async { + final src10 = p.join(_dir, 'face_10bit.mkv'); + final r = await Process.run( + WorkerHarness.ffmpegPath, + ['-y', '-v', 'error', '-i', _fixture, '-vf', 'format=yuv420p10le', '-c:v', 'ffv1', src10], + environment: WorkerHarness.ffmpegEnv, + ); + expect(r.exitCode, 0, reason: '${r.stderr}'); + final off = await _encode('baseline_10bit', null, input: src10, pixFmt: 'yuv420p10le'); + final on = await _encode('restoreformer_10bit', const FaceRestoreParameters(enabled: true), + input: src10, pixFmt: 'yuv420p10le'); + final stream = await WorkerHarness.firstStream(on, selector: 'v:0', entries: ['pix_fmt']); + expect(stream?['pix_fmt'], 'yuv420p10le'); + final c = _compare(await _raw(off, 'yuv420p10le'), await _raw(on, 'yuv420p10le'), bytes: 2); + // ignore: avoid_print + print(' 10-bit: face mean |diff| ${c.innerDiff.toStringAsFixed(2)} (8-bit levels), ' + '${c.outsideChanged} samples changed outside'); + expect(c.innerDiff, greaterThan(2.0)); + expect(c.outsideChanged, 0); + }); +} diff --git a/worker/src/models/face_restore_parameters.rs b/worker/src/models/face_restore_parameters.rs index 0692bfc4..ee651c9f 100644 --- a/worker/src/models/face_restore_parameters.rs +++ b/worker/src/models/face_restore_parameters.rs @@ -96,9 +96,10 @@ pub struct FaceRestoreParameters { #[serde(default = "default_max_faces")] pub max_faces: i32, - /// Average each face's landmarks with its neighbours' (one frame either - /// side) and bridge one-frame detection gaps, so the restored face does - /// not jitter or blink. + /// Steady each face over one frame either side: its landmarks smoothed, + /// one-frame detection gaps bridged, and the restored face averaged (in + /// the aligned face space, weighted down where the input moved) with its + /// neighbours', so it does not jitter, blink or shimmer. #[serde(default = "default_true")] pub temporal_smoothing: bool, } diff --git a/worker/src/script_generator.rs b/worker/src/script_generator.rs index e2d77250..d7aa9074 100644 --- a/worker/src/script_generator.rs +++ b/worker/src/script_generator.rs @@ -2627,7 +2627,7 @@ mod tests { } } - /// No pass needs an add-on yet, so a generated script never has the + /// A job whose passes need no add-on (the default pipeline) never has the /// block, whatever the generator was told about add-ons. #[test] fn a_job_without_addon_passes_has_no_ai_runtime_block() { diff --git a/worker/templates/ai_runtime.py b/worker/templates/ai_runtime.py index c0e96f60..f90332e0 100644 --- a/worker/templates/ai_runtime.py +++ b/worker/templates/ai_runtime.py @@ -358,3 +358,42 @@ def get(self, key, compute): with self._lock: self._pending.pop(key, None) event.set() + + +class Runner: + """One model's session for a pass that calls it from frame requests. + + Runs are serialised: VapourSynth asks for several frames at once, and on + a GPU provider concurrent runs each allocate their own working memory + without running any faster (Core ML ran out of GPU memory on a 16 GB Mac + with two jobs' worth). A run that fails on a GPU provider (out of memory, + an unsupported shape at run time) is retried on the CPU, which this + runner then keeps using; `fallback` says why. It runs like an + InferenceSession (`run(output_names, feeds)`, `get_inputs()`), so code + written against a session takes either.""" + + def __init__(self, model, force_cpu=False, lock=None): + """`lock`: share one between the runners of a pass to serialise all + of its models' runs, not just each model's.""" + self.model = model + self.session = session(model, force_cpu=force_cpu) + self.fallback = last_fallback + self._lock = lock or threading.Lock() + + @property + def provider(self): + return session_provider(self.session) + + def get_inputs(self): + return self.session.get_inputs() + + def run(self, output_names, feeds): + with self._lock: + try: + return self.session.run(output_names, feeds) + except Exception as e: + if self.provider == CPU_PROVIDER: + raise + self.fallback = "%s failed at run time: %s" % (self.provider, e) + self.session = session(self.model, force_cpu=True) + return self.session.run(output_names, feeds) diff --git a/worker/templates/face_restore.py b/worker/templates/face_restore.py index 2f403752..ff416824 100644 --- a/worker/templates/face_restore.py +++ b/worker/templates/face_restore.py @@ -81,6 +81,11 @@ MODELS = ("restoreformer", "gfpgan", "codeformer") +# The runners of the most recent restore_faces() call ({"detector", "parser", +# "restorer"} -> ai_runtime.Runner), for diagnostics: which provider each +# model runs on and any fallback to the CPU. +last_restorer = {} + # ---------------------------------------------------------------------------- # Detection @@ -129,7 +134,7 @@ def nms(boxes, scores, threshold): def detect(session, rgb, scale, floor): - """Faces in one frame. `rgb` is the detection copy, (3, h, w) float RGB in + """Faces in one frame (`session`: an InferenceSession or ai_runtime.Runner). `rgb` is the detection copy, (3, h, w) float RGB in [0, 1] with h, w <= DET_SIZE; `scale` (x, y) maps its pixels to the clip's (clip = (det + 0.5) / scale - 0.5). Returns (scores (k,), boxes (k, 4) x1 y1 x2 y2, landmarks (k, 5, 2)) in clip pixels, every face scoring at @@ -352,9 +357,14 @@ class FaceRestorer: def __init__(self, detector, parser, restorer, model, fidelity): if model not in MODELS: raise ValueError("face_restore: unknown model %r" % model) - self.det = ai_runtime.session(detector) - self.parse = ai_runtime.session(parser) - self.restore = ai_runtime.session(restorer) + # One lock for all three models: their runs are serialised (one GPU, + # and concurrent Core ML predictions ran it out of memory), with a CPU + # retry (see ai_runtime.Runner). The NumPy work around them, and the + # rest of the script, stay parallel. + lock = threading.Lock() + self.det = ai_runtime.Runner(detector, lock=lock) + self.parse = ai_runtime.Runner(parser, lock=lock) + self.restore = ai_runtime.Runner(restorer, lock=lock) self.model = model self.fidelity = float(fidelity) self.restore_input = self.restore.get_inputs()[0].name @@ -471,6 +481,13 @@ def restore_faces( blend_r = r if temporal_blend else 0 strength = min(max(float(strength), 0.0), 1.0) models = FaceRestorer(detector, parser, restorer, model, fidelity) + last_restorer.clear() + last_restorer.update(detector=models.det, parser=models.parse, restorer=models.restore) + import sys + + sys.stderr.write("Face restoration: %s on %s%s\n" % ( + model, models.restore.provider, + "" if not models.restore.fallback else " (%s)" % models.restore.fallback)) rgbs = ai_runtime.to_rgbs(clip, matrix, color_range) scale = min(1.0, DET_SIZE / max(clip.width, clip.height)) @@ -555,27 +572,37 @@ def run(): weight += w restored = total / weight paste(rgb, out, alpha, restored, face["mask"].astype(np.float32), face["m"]) - return out, alpha * strength, len(here) + # Keep only the region the faces reach: a whole 1080p RGB frame + # is 25 MB, and the memo holds several. + ys, xs = np.nonzero(alpha) + if not len(ys): + return None, None, None, len(here) + y0, y1, x0, x1 = ys.min(), ys.max() + 1, xs.min(), xs.max() + 1 + return (y0, y1, x0, x1), out[:, y0:y1, x0:x1].copy(), alpha[y0:y1, x0:x1] * strength, len(here) return results.get(n, run) def restored_frame(n, f): - out_rgb, _alpha, count = compute(n, f[1:]) + region, patch, _alpha, count = compute(n, f[1:]) dst = f[0].copy() - for p in range(3): - np.copyto(np.asarray(dst[p]), out_rgb[p]) + if region is not None: + y0, y1, x0, x1 = region + for p in range(3): + np.asarray(dst[p])[y0:y1, x0:x1] = patch[p] dst.props["FaceRestoreFaces"] = count return dst def mask_frame(n, f): - _out, alpha, _count = compute(n, f[1:]) - dst = f[0].copy() - plane = np.asarray(dst[0]) - if mask_fmt.sample_type == vs.FLOAT: - np.copyto(plane, alpha) - else: - peak = (1 << mask_fmt.bits_per_sample) - 1 - np.copyto(plane, np.rint(alpha * peak).astype(plane.dtype)) + region, _patch, alpha, _count = compute(n, f[1:]) + dst = f[0].copy() # a blank (zero) frame + if region is not None: + y0, y1, x0, x1 = region + plane = np.asarray(dst[0]) + if mask_fmt.sample_type == vs.FLOAT: + plane[y0:y1, x0:x1] = alpha + else: + peak = (1 << mask_fmt.bits_per_sample) - 1 + plane[y0:y1, x0:x1] = np.rint(alpha * peak).astype(plane.dtype) return dst mask_fmt = core.query_video_format(vs.GRAY, fmt.sample_type, fmt.bits_per_sample, 0, 0) From d87e2f27c43296042d75627f9cd74d1ddfbbf387 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 8 Nov 2026 18:55:59 +1100 Subject: [PATCH 21/61] feat(filters): SangNom AA, NLMeans (CPU), vine dehalo, mpeg2stinx, IVTC fade fix - Anti-aliasing: SangNom method (2x supersample, SangNom per axis, back down; aa in 8-bit units, scaled by the plugin). havsfunc's santiag(type=sangnom) stays pinned off: it calls SangNom(order=0), which r45 treats as double-rate and refuses without _FieldBased. - Noise Reduction: NLMeans (CPU) advanced method on vszip 24's NLMeans, sharing the KNLMeans settings (same algorithm and output). The KNLMeans no-device message now names it as the way out. - Ports from vs-jetpack 3.0.0 (MIT) into both templates, through _expr() and _nnedi3(): vine_dehalo as an advanced Dehalo method, mpeg2stinx as an advanced Deblock method (whole frame, never per field; preview radius 1), and FixInterlacedFades as an IVTC option (average/match) right after VFM. mpeg2stinx and the fade fix match vs-jetpack to 0.04 / 0.00 levels. - Tests: Rust test_200-205; Flutter script-gen cases, the high-bit-depth parity passes, and a new heavy integration_classical_filters_test (fade fix closes the field gap 4.12 -> 0.06; NLMeans == KNLMeansCL to 0.0003). Co-Authored-By: Claude Opus 5.5 --- app/assets/filters/core/anti_alias.json | 35 ++- app/assets/filters/core/deblock.json | 12 +- app/assets/filters/core/dehalo.json | 77 ++++- app/assets/filters/core/deinterlace.json | 45 ++- app/assets/filters/core/noise_reduction.json | 35 ++- app/lib/models/anti_alias_parameters.dart | 17 +- app/lib/models/deblock_parameters.dart | 7 +- app/lib/models/dehalo_parameters.dart | 27 +- .../models/noise_reduction_parameters.dart | 4 +- app/lib/models/parameter_converter.dart | 37 ++- app/lib/models/qtgmc_parameters.dart | 14 + app/lib/services/worker_manager.dart | 3 +- app/test/filter_schema_curation_test.dart | 9 +- .../integration_classical_filters_test.dart | 227 ++++++++++++++ .../integration_filter_parameters_test.dart | 115 ++++++- ...tegration_high_bit_depth_filters_test.dart | 48 +++ worker/src/dependency_locator.rs | 5 +- worker/src/models/anti_alias_parameters.rs | 53 +++- worker/src/models/deblock_parameters.rs | 6 + worker/src/models/dehalo_parameters.rs | 42 +++ .../src/models/noise_reduction_parameters.rs | 8 +- worker/src/models/processing_pipeline.rs | 4 + worker/src/models/qtgmc_parameters.rs | 26 ++ worker/src/script_generator.rs | 73 ++++- worker/templates/pipeline_template.vpy | 183 +++++++++++- worker/templates/preview_template.vpy | 183 +++++++++++- worker/tests/filter_integration_test.rs | 282 +++++++++++++++++- 27 files changed, 1517 insertions(+), 60 deletions(-) create mode 100644 app/test/integration_classical_filters_test.dart diff --git a/app/assets/filters/core/anti_alias.json b/app/assets/filters/core/anti_alias.json index 6ecd2118..5f8468fc 100644 --- a/app/assets/filters/core/anti_alias.json +++ b/app/assets/filters/core/anti_alias.json @@ -4,7 +4,7 @@ "version": "1.0.0", "name": "Anti-Aliasing", "description": "Smooth stair-stepping on diagonal edges", - "longDescription": "Removes the stair-stepping — \"jaggies\" — that appears along diagonal edges. Both methods work by re-interpolating the frame with an edge-directed kernel and keeping the smoother result, so the edge is rebuilt rather than blurred.\n\nUse it after deinterlacing and after upscaling, which are the two things that create the stepping. It runs before sharpening on purpose: sharpening a stair-stepped edge makes the steps more visible, not less. On a source with no diagonal detail it does nothing but cost time.", + "longDescription": "Removes the stair-stepping — \"jaggies\" — that appears along diagonal edges. Every method works by re-interpolating the frame with an edge-directed kernel and keeping the smoother result, so the edge is rebuilt rather than blurred.\n\nUse it after deinterlacing and after upscaling, which are the two things that create the stepping. It runs before sharpening on purpose: sharpening a stair-stepped edge makes the steps more visible, not less. On a source with no diagonal detail it does nothing but cost time.", "category": "enhancement", "icon": "gesture", "order": 7, @@ -33,6 +33,16 @@ "santiagStrh", "santiagStrv" ] + }, + { + "id": "sangnom", + "sinceAppVersion": "1.7.0", + "name": "SangNom", + "description": "The fastest and the sharpest: rebuilds edges at twice the size with SangNom, then scales back down. Strong enough to thin line art slightly, which suits anime more than live action", + "function": "core.sangnom.SangNom", + "parameters": [ + "sangnomAa" + ] } ], "parameters": { @@ -48,7 +58,8 @@ "default": "daa", "options": [ "daa", - "santiag" + "santiag", + "sangnom" ], "ui": { "hidden": true @@ -71,6 +82,23 @@ } } }, + "sangnomAa": { + "type": "integer", + "default": 48, + "min": 0, + "max": 128, + "step": 4, + "ui": { + "label": "Strength", + "description": "How hard SangNom smooths along the edges it finds. 48 is the plugin's default; 0 still rebuilds the edges but without smoothing them", + "widget": "slider", + "visibleWhen": { + "method": [ + "sangnom" + ] + } + } + }, "santiagStrv": { "type": "integer", "default": 1, @@ -95,7 +123,8 @@ "title": "Settings", "parameters": [ "santiagStrh", - "santiagStrv" + "santiagStrv", + "sangnomAa" ], "expanded": true } diff --git a/app/assets/filters/core/deblock.json b/app/assets/filters/core/deblock.json index ff2a6eef..22e97b36 100644 --- a/app/assets/filters/core/deblock.json +++ b/app/assets/filters/core/deblock.json @@ -49,6 +49,15 @@ "dctStrength", "dctPlanes" ] + }, + { + "id": "mpeg2stinx", + "sinceAppVersion": "1.7.0", + "name": "mpeg2stinx", + "description": "For film DVDs after inverse telecine: removes the comb-like noise left where progressive frames were encoded as interlaced MPEG-2. Not a general deblocker; on clean or detailed sources it softens vertical detail", + "function": "mpeg2stinx (vs-jetpack, ported): core.bwdif.Bwdif + _nnedi3", + "parameters": [], + "advancedOnly": true } ], "parameters": { @@ -65,7 +74,8 @@ "options": [ "deblock_qed", "deblock", - "dctfilter" + "dctfilter", + "mpeg2stinx" ], "ui": { "hidden": true diff --git a/app/assets/filters/core/dehalo.json b/app/assets/filters/core/dehalo.json index 12ac5ef8..4921116b 100644 --- a/app/assets/filters/core/dehalo.json +++ b/app/assets/filters/core/dehalo.json @@ -123,6 +123,19 @@ "deringThr", "deringDarkthr" ] + }, + { + "id": "vine", + "sinceAppVersion": "1.7.0", + "name": "vine (NLMeans)", + "description": "Removes halos with non-local means and then puts back the edge detail the halo was riding on, so edges stay crisp where DeHalo_alpha softens them. Slower; best on line art and clean sources", + "function": "vine_dehalo (vs-jetpack, ported): core.vszip.NLMeans + _nnedi3", + "parameters": [ + "vineStrength", + "vineSharp", + "vineSigma" + ], + "advancedOnly": true } ], "parameters": { @@ -144,7 +157,8 @@ "edge_cleaner", "vinverse", "vinverse2", - "hq_deringmod" + "hq_deringmod", + "vine" ], "ui": { "hidden": true @@ -748,6 +762,60 @@ ] } } + }, + "vineStrength": { + "type": "number", + "default": 16.0, + "min": 1.0, + "max": 64.0, + "step": 1.0, + "ui": { + "label": "Strength", + "description": "How hard halos are smoothed away. Higher removes wider, brighter halos and more fine texture with them", + "widget": "slider", + "precision": 0, + "visibleWhen": { + "method": [ + "vine" + ] + } + } + }, + "vineSharp": { + "type": "number", + "default": 0.5, + "min": 0.05, + "max": 1.0, + "step": 0.05, + "ui": { + "label": "Sharpness", + "description": "How much of the twice-size pass is blended in, which keeps edges finer", + "widget": "slider", + "precision": 2, + "visibleWhen": { + "method": [ + "vine" + ] + } + } + }, + "vineSigma": { + "type": "number", + "default": 1.0, + "min": 0.3, + "max": 4.0, + "step": 0.1, + "ui": { + "label": "Detail split", + "description": "Where fine detail ends and halo begins (a blur radius). Larger treats wider structures as halo", + "widget": "slider", + "precision": 1, + "visibleWhen": { + "method": [ + "vine" + ] + } + } } }, "ui": { @@ -770,7 +838,9 @@ "vinverseChroma", "deringMrad", "deringMsmooth", - "deringThr" + "deringThr", + "vineStrength", + "vineSharp" ], "expanded": true }, @@ -789,7 +859,8 @@ "edgeSmallMode", "edgeHotPixels", "deringMthr", - "deringDarkthr" + "deringDarkthr", + "vineSigma" ], "expanded": false, "advancedOnly": true diff --git a/app/assets/filters/core/deinterlace.json b/app/assets/filters/core/deinterlace.json index 5dbc463c..407859c0 100644 --- a/app/assets/filters/core/deinterlace.json +++ b/app/assets/filters/core/deinterlace.json @@ -1961,6 +1961,45 @@ ] } } + }, + "ivtcFixFades": { + "type": "boolean", + "default": false, + "sinceAppVersion": "1.7.0", + "ui": { + "label": "Fix fades added after telecine", + "description": "For fades that still comb after inverse telecine (the fade was applied to the telecined video). Rescales each field so both sit at the same point in the fade, without blurring. Leaves other frames practically unchanged", + "widget": "checkbox", + "visibleWhen": { + "method": [ + "ivtc" + ] + } + } + }, + "ivtcFadeMode": { + "type": "enum", + "default": "average", + "options": [ + "average", + "match" + ], + "sinceAppVersion": "1.7.0", + "ui": { + "label": "Fade fix mode", + "description": "Average puts both fields at their mean level; Match darkens the brighter field to the darker one, which also cleans the last frames of a fade to full black", + "widget": "dropdown", + "optionLabels": { + "average": "Average", + "match": "Match" + }, + "visibleWhen": { + "method": [ + "ivtc" + ], + "ivtcFixFades": true + } + } } }, "parameterPresets": { @@ -2095,7 +2134,8 @@ "title": "IVTC Settings", "parameters": [ "ivtcMode", - "ivtcCycle" + "ivtcCycle", + "ivtcFixFades" ], "expanded": true }, @@ -2107,7 +2147,8 @@ "ivtcBlockX", "ivtcBlockY", "ivtcDupthresh", - "ivtcScthresh" + "ivtcScthresh", + "ivtcFadeMode" ], "expanded": false, "advancedOnly": true diff --git a/app/assets/filters/core/noise_reduction.json b/app/assets/filters/core/noise_reduction.json index 2e7e0585..29b4a6b3 100644 --- a/app/assets/filters/core/noise_reduction.json +++ b/app/assets/filters/core/noise_reduction.json @@ -204,6 +204,21 @@ "knlmChroma" ], "advancedOnly": true + }, + { + "id": "nlmeans", + "sinceAppVersion": "1.7.0", + "name": "NLMeans (CPU)", + "description": "The same non-local means filter as KNLMeansCL, with the same settings and the same result, run on the processor instead of the graphics card. Works on every computer; slower than KNLMeansCL where a GPU is available", + "function": "core.vszip.NLMeans", + "parameters": [ + "knlmH", + "knlmD", + "knlmA", + "knlmS", + "knlmChroma" + ], + "advancedOnly": true } ], "parameters": { @@ -232,7 +247,8 @@ "mclean", "temporal_degrain2", "bm3d", - "knlmeans" + "knlmeans", + "nlmeans" ], "ui": { "hidden": true @@ -1184,7 +1200,8 @@ "precision": 1, "visibleWhen": { "method": [ - "knlmeans" + "knlmeans", + "nlmeans" ] } } @@ -1202,7 +1219,8 @@ "widget": "slider", "visibleWhen": { "method": [ - "knlmeans" + "knlmeans", + "nlmeans" ] } } @@ -1220,7 +1238,8 @@ "widget": "slider", "visibleWhen": { "method": [ - "knlmeans" + "knlmeans", + "nlmeans" ] } } @@ -1238,7 +1257,8 @@ "widget": "slider", "visibleWhen": { "method": [ - "knlmeans" + "knlmeans", + "nlmeans" ] } } @@ -1253,7 +1273,8 @@ "widget": "checkbox", "visibleWhen": { "method": [ - "knlmeans" + "knlmeans", + "nlmeans" ] } } @@ -1412,7 +1433,7 @@ "expanded": true }, { - "title": "KNLMeansCL", + "title": "Non-local means (KNLMeansCL / NLMeans)", "parameters": [ "knlmH", "knlmD", diff --git a/app/lib/models/anti_alias_parameters.dart b/app/lib/models/anti_alias_parameters.dart index 26977c7d..18513f83 100644 --- a/app/lib/models/anti_alias_parameters.dart +++ b/app/lib/models/anti_alias_parameters.dart @@ -11,7 +11,9 @@ enum AntiAliasMethod { @JsonValue('daa') daa('daa', 'daa'), @JsonValue('santiag') - santiag('santiag', 'santiag'); + santiag('santiag', 'santiag'), + @JsonValue('sangnom') + sangnom('sangnom', 'SangNom'); const AntiAliasMethod(this.value, this.displayName); final String value; @@ -37,17 +39,21 @@ class AntiAliasParameters { /// Horizontal strength for santiag (0 disables the horizontal pass). final int santiagStrh; - /// Interpolator for santiag. Only `nnedi3` is bundled, so the worker pins it - /// there regardless — havsfunc's other options would fail at script - /// evaluation. + /// Interpolator for santiag. The worker pins it to `nnedi3` regardless: + /// eedi2 is not bundled, and havsfunc's `sangnom` type is broken against + /// SangNom r45 (SangNom is its own method instead). final String santiagType; + /// SangNom's anti-aliasing strength, 0-128 in 8-bit units. + final int sangnomAa; + const AntiAliasParameters({ this.enabled = false, this.method = AntiAliasMethod.daa, this.santiagStrv = 1, this.santiagStrh = 1, this.santiagType = 'nnedi3', + this.sangnomAa = 48, }); AntiAliasParameters copyWith({ @@ -56,6 +62,7 @@ class AntiAliasParameters { int? santiagStrv, int? santiagStrh, String? santiagType, + int? sangnomAa, }) => AntiAliasParameters( enabled: enabled ?? this.enabled, @@ -63,6 +70,7 @@ class AntiAliasParameters { santiagStrv: santiagStrv ?? this.santiagStrv, santiagStrh: santiagStrh ?? this.santiagStrh, santiagType: santiagType ?? this.santiagType, + sangnomAa: sangnomAa ?? this.sangnomAa, ); /// Short summary for the pass list row. @@ -71,6 +79,7 @@ class AntiAliasParameters { if (method == AntiAliasMethod.santiag) { return 'santiag (H$santiagStrh / V$santiagStrv)'; } + if (method == AntiAliasMethod.sangnom) return 'SangNom ($sangnomAa)'; return method.displayName; } diff --git a/app/lib/models/deblock_parameters.dart b/app/lib/models/deblock_parameters.dart index fb490f3b..b7a2e387 100644 --- a/app/lib/models/deblock_parameters.dart +++ b/app/lib/models/deblock_parameters.dart @@ -10,7 +10,9 @@ enum DeblockMethod { @JsonValue('Deblock') deblock('Deblock', 'Deblock'), @JsonValue('DCTFilter') - dctFilter('DCTFilter', 'DCTFilter'); + dctFilter('DCTFilter', 'DCTFilter'), + @JsonValue('mpeg2stinx') + mpeg2stinx('mpeg2stinx', 'mpeg2stinx'); const DeblockMethod(this.value, this.displayName); final String value; @@ -25,6 +27,9 @@ enum DeblockMethod { case DeblockMethod.dctFilter: return 'Attenuates high DCT frequency bands — targets ringing and ' 'mosquito noise rather than block edges'; + case DeblockMethod.mpeg2stinx: + return 'Comb-like noise in film DVDs encoded as interlaced MPEG-2 ' + '(vs-jetpack\'s mpeg2stinx)'; } } } diff --git a/app/lib/models/dehalo_parameters.dart b/app/lib/models/dehalo_parameters.dart index cdeb0af9..f01d9677 100644 --- a/app/lib/models/dehalo_parameters.dart +++ b/app/lib/models/dehalo_parameters.dart @@ -20,7 +20,9 @@ enum DehaloMethod { @JsonValue('Vinverse2') vinverse2('Vinverse2', 'Vinverse 2 (de-ghost)'), @JsonValue('HQDeringmod') - hqDeringmod('HQDeringmod', 'HQDeringmod (de-ring)'); + hqDeringmod('HQDeringmod', 'HQDeringmod (de-ring)'), + @JsonValue('vine') + vine('vine', 'vine (NLMeans)'); const DehaloMethod(this.value, this.displayName); final String value; @@ -45,6 +47,9 @@ enum DehaloMethod { case DehaloMethod.hqDeringmod: return 'Masked ring removal — treats the overshoot beside an edge ' 'while protecting the edge itself'; + case DehaloMethod.vine: + return 'Non-local-means dehalo that keeps the edge detail the halo ' + 'rides on (vs-jetpack\'s vine_dehalo)'; } } } @@ -165,6 +170,17 @@ class DehaloParameters { /// `thr / 4`, which is usually what you want. final double? deringDarkthr; + // --- vine parameters --- + + /// NLMeans strength (vs-jetpack's `strength`). + final double vineStrength; + + /// How much of the supersampled pass is blended in (`sharp`, 0-1]. + final double vineSharp; + + /// Gaussian sigma of the low/high frequency split. + final double vineSigma; + const DehaloParameters({ this.enabled = false, this.method = DehaloMethod.dehaloAlpha, @@ -197,6 +213,9 @@ class DehaloParameters { this.deringMthr, this.deringThr, this.deringDarkthr, + this.vineStrength = 16.0, + this.vineSharp = 0.5, + this.vineSigma = 1.0, }); DehaloParameters copyWith({ @@ -231,6 +250,9 @@ class DehaloParameters { int? deringMthr, double? deringThr, double? deringDarkthr, + double? vineStrength, + double? vineSharp, + double? vineSigma, }) { return DehaloParameters( enabled: enabled ?? this.enabled, @@ -264,6 +286,9 @@ class DehaloParameters { deringMthr: deringMthr ?? this.deringMthr, deringThr: deringThr ?? this.deringThr, deringDarkthr: deringDarkthr ?? this.deringDarkthr, + vineStrength: vineStrength ?? this.vineStrength, + vineSharp: vineSharp ?? this.vineSharp, + vineSigma: vineSigma ?? this.vineSigma, ); } diff --git a/app/lib/models/noise_reduction_parameters.dart b/app/lib/models/noise_reduction_parameters.dart index 13d077ac..8024e851 100644 --- a/app/lib/models/noise_reduction_parameters.dart +++ b/app/lib/models/noise_reduction_parameters.dart @@ -38,7 +38,9 @@ enum NoiseReductionMethod { @JsonValue('bm3d') bm3d('BM3D'), @JsonValue('knlMeans') - knlMeans('KNLMeansCL'); + knlMeans('KNLMeansCL'), + @JsonValue('nlMeans') + nlMeans('NLMeans (CPU)'); const NoiseReductionMethod(this.displayName); final String displayName; diff --git a/app/lib/models/parameter_converter.dart b/app/lib/models/parameter_converter.dart index 936375d1..8334857e 100644 --- a/app/lib/models/parameter_converter.dart +++ b/app/lib/models/parameter_converter.dart @@ -139,6 +139,8 @@ class ParameterConverter { 'ivtcCycle': params.ivtcCycle, 'ivtcDupthresh': params.ivtcDupthresh, 'ivtcScthresh': params.ivtcScthresh, + 'ivtcFixFades': params.ivtcFixFades, + 'ivtcFadeMode': params.ivtcFadeMode, }, ); } @@ -192,6 +194,9 @@ class ParameterConverter { case NoiseReductionMethod.knlMeans: method = 'knlmeans'; break; + case NoiseReductionMethod.nlMeans: + method = 'nlmeans'; + break; } return DynamicParameters( @@ -281,6 +286,7 @@ class ParameterConverter { DehaloMethod.vinverse => 'vinverse', DehaloMethod.vinverse2 => 'vinverse2', DehaloMethod.hqDeringmod => 'hq_deringmod', + DehaloMethod.vine => 'vine', }; /// Convert chroma denoise (CCD) parameters to dynamic format. @@ -326,6 +332,9 @@ class ParameterConverter { 'highThreshold': params.highThreshold, 'yahrBlur': params.yahrBlur, 'yahrDepth': params.yahrDepth, + 'vineStrength': params.vineStrength, + 'vineSharp': params.vineSharp, + 'vineSigma': params.vineSigma, }; final lastOptional = {}; @@ -377,9 +386,10 @@ class ParameterConverter { filterId: 'anti_alias', enabled: params.enabled, values: { - 'method': params.method == AntiAliasMethod.santiag ? 'santiag' : 'daa', + 'method': params.method.value, 'santiagStrh': params.santiagStrh, 'santiagStrv': params.santiagStrv, + 'sangnomAa': params.sangnomAa, }, ); } @@ -389,12 +399,14 @@ class ParameterConverter { final v = params.values; return AntiAliasParameters( enabled: params.enabled, - method: v['method'] == 'santiag' - ? AntiAliasMethod.santiag - : AntiAliasMethod.daa, + method: AntiAliasMethod.values.firstWhere( + (m) => m.value == v['method'], + orElse: () => AntiAliasMethod.daa, + ), santiagStrh: _asInt(v['santiagStrh']) ?? 1, santiagStrv: _asInt(v['santiagStrv']) ?? 1, - // Not exposed: only nnedi3 is bundled, and the worker pins it there. + sangnomAa: _asInt(v['sangnomAa']) ?? 48, + // santiagType is not exposed: the worker pins it to nnedi3. ); } @@ -693,6 +705,9 @@ class ParameterConverter { case DeblockMethod.dctFilter: method = 'dctfilter'; break; + case DeblockMethod.mpeg2stinx: + method = 'mpeg2stinx'; + break; } return DynamicParameters( @@ -1121,6 +1136,8 @@ class ParameterConverter { ivtcCycle: v['ivtcCycle'] as int?, ivtcDupthresh: (v['ivtcDupthresh'] as num?)?.toDouble(), ivtcScthresh: (v['ivtcScthresh'] as num?)?.toDouble(), + ivtcFixFades: v['ivtcFixFades'] as bool? ?? false, + ivtcFadeMode: v['ivtcFadeMode'] as String? ?? 'average', ); } @@ -1172,6 +1189,9 @@ class ParameterConverter { case 'knlmeans': method = NoiseReductionMethod.knlMeans; break; + case 'nlmeans': + method = NoiseReductionMethod.nlMeans; + break; default: method = NoiseReductionMethod.smDegrain; } @@ -1274,6 +1294,7 @@ class ParameterConverter { 'vinverse' => DehaloMethod.vinverse, 'vinverse2' => DehaloMethod.vinverse2, 'hq_deringmod' => DehaloMethod.hqDeringmod, + 'vine' => DehaloMethod.vine, _ => DehaloMethod.dehaloAlpha, }; @@ -1310,6 +1331,9 @@ class ParameterConverter { deringMthr: _asInt(v['deringMthr']), deringThr: (v['deringThr'] as num?)?.toDouble(), deringDarkthr: (v['deringDarkthr'] as num?)?.toDouble(), + vineStrength: (v['vineStrength'] as num?)?.toDouble() ?? 16.0, + vineSharp: (v['vineSharp'] as num?)?.toDouble() ?? 0.5, + vineSigma: (v['vineSigma'] as num?)?.toDouble() ?? 1.0, ); } @@ -1325,6 +1349,9 @@ class ParameterConverter { case 'dctfilter': method = DeblockMethod.dctFilter; break; + case 'mpeg2stinx': + method = DeblockMethod.mpeg2stinx; + break; default: method = DeblockMethod.deblockQed; } diff --git a/app/lib/models/qtgmc_parameters.dart b/app/lib/models/qtgmc_parameters.dart index 9d63d0ef..7c4634e2 100644 --- a/app/lib/models/qtgmc_parameters.dart +++ b/app/lib/models/qtgmc_parameters.dart @@ -271,6 +271,14 @@ class QTGMCParameters { /// VDecimate scene change threshold final double? ivtcScthresh; + /// Fix fades added after telecine (vs-jetpack's FixInterlacedFades), right + /// after field matching. + final bool ivtcFixFades; + + /// `average` (both fields to their mean) or `match` (the brighter field to + /// the darker). + final String ivtcFadeMode; + const QTGMCParameters({ this.enabled = true, this.method = DeinterlaceMethod.qtgmc, @@ -363,6 +371,8 @@ class QTGMCParameters { this.ivtcCycle, this.ivtcDupthresh, this.ivtcScthresh, + this.ivtcFixFades = false, + this.ivtcFadeMode = 'average', }); factory QTGMCParameters.fromJson(Map json) => @@ -461,6 +471,8 @@ class QTGMCParameters { double? ivtcDupthresh, double? ivtcScthresh, bool? bwdifEdeint, + bool? ivtcFixFades, + String? ivtcFadeMode, }) { return QTGMCParameters( enabled: enabled ?? this.enabled, @@ -554,6 +566,8 @@ class QTGMCParameters { ivtcDupthresh: ivtcDupthresh ?? this.ivtcDupthresh, ivtcScthresh: ivtcScthresh ?? this.ivtcScthresh, bwdifEdeint: bwdifEdeint ?? this.bwdifEdeint, + ivtcFixFades: ivtcFixFades ?? this.ivtcFixFades, + ivtcFadeMode: ivtcFadeMode ?? this.ivtcFadeMode, ); } } diff --git a/app/lib/services/worker_manager.dart b/app/lib/services/worker_manager.dart index 57dc742a..26a22cf2 100644 --- a/app/lib/services/worker_manager.dart +++ b/app/lib/services/worker_manager.dart @@ -190,7 +190,8 @@ class WorkerManager implements JobRunner { opencl: false, knlm: false, knlmMessage: 'VapourBox could not check for an OpenCL device, which ' - 'KNLMeans needs. BM3D does the same job on the CPU.', + 'KNLMeans needs. NLMeans (CPU) is the same filter run on the ' + 'processor.', ); final toolLocator = ToolLocator.instance; final workerPath = toolLocator.workerPath; diff --git a/app/test/filter_schema_curation_test.dart b/app/test/filter_schema_curation_test.dart index d3c30b2e..214ad182 100644 --- a/app/test/filter_schema_curation_test.dart +++ b/app/test/filter_schema_curation_test.dart @@ -210,8 +210,13 @@ void main() { ['dehalo_alpha', 'fine_dehalo', 'yahr', 'hq_deringmod'], ); // Vinverse is offered by Chroma Fixes too; Fine Dehalo 2 is a follow-up - // pass rather than a first choice. - expect(dehalo.methods.length, 8); + // pass rather than a first choice; vine (app 1.7.0) is the slower, + // detail-keeping alternative. + expect(dehalo.methods.length, 9); + expect( + dehalo.methods.where((m) => m.advancedOnly).map((m) => m.id), + contains('vine'), + ); }); test('the filters added from the gap analysis are behind advanced mode', () { diff --git a/app/test/integration_classical_filters_test.dart b/app/test/integration_classical_filters_test.dart new file mode 100644 index 00000000..1f423565 --- /dev/null +++ b/app/test/integration_classical_filters_test.dart @@ -0,0 +1,227 @@ +/// End-to-end checks for the classical filters added with deps 1.16.0 +/// (app 1.7.0): SangNom anti-aliasing, NLMeans on the CPU (vszip 24), and the +/// three vs-jetpack ports — vine dehalo, mpeg2stinx, and the IVTC fade fix. +/// +/// Each runs the worker for real (vspipe | ffmpeg) and checks a valid output; +/// the fade fix and NLMeans are also checked by what they do to the picture. +/// The measurements behind these methods are in +/// docs/reports/2026-11-classical-filters. +/// +/// Heavy (full-encode) — tagged so it runs in the nightly workflow. +@Tags(['heavy']) +library; + +// ignore_for_file: avoid_print — these tests print diagnostics to the test log. + +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:uuid/uuid.dart'; + +import 'package:vapourbox/models/anti_alias_parameters.dart'; +import 'package:vapourbox/models/deblock_parameters.dart'; +import 'package:vapourbox/models/dehalo_parameters.dart'; +import 'package:vapourbox/models/encoding_settings.dart'; +import 'package:vapourbox/models/noise_reduction_parameters.dart'; +import 'package:vapourbox/models/processing_pipeline.dart'; +import 'package:vapourbox/models/qtgmc_parameters.dart'; +import 'package:vapourbox/models/video_job.dart'; + +import 'support/worker_harness.dart'; + +String get _outDir => '${WorkerHarness.outputDir}/classical_filters'; + +VideoJob _job(String name, ProcessingPipeline pipeline, + {String? input, int? frames, double? fps, int? width, int? height}) => + VideoJob( + id: const Uuid().v4(), + inputPath: input ?? WorkerHarness.inputFile, + outputPath: '$_outDir/$name.mkv', + processingPipeline: pipeline, + encodingSettings: const EncodingSettings( + codec: VideoCodec.ffv1, + container: ContainerFormat.mkv, + audioMode: AudioMode.none, + ), + totalFrames: frames, + inputFrameRate: fps, + inputWidth: width, + inputHeight: height, + inputPixelFormat: input == null ? null : 'yuv420p', + ); + +Future _expectValidVideo(JobResult result) async { + final tail = result.logs.length > 25 + ? result.logs.sublist(result.logs.length - 25) + : result.logs; + expect(result.success, isTrue, + reason: '${result.error}\n--- worker log (tail) ---\n${tail.join('\n')}'); + final v = await WorkerHarness.firstStream(result.outputPath!, + selector: 'v:0', entries: ['codec_name', 'width', 'height']); + expect(v, isNotNull, reason: 'output should contain a video stream'); +} + +/// Mean |even-row mean - odd-row mean| of an rgb24 frame, 0-255: how far the +/// two fields sit apart in brightness. +double _fieldGap(Uint8List rgb, int width) { + final rows = rgb.length ~/ (width * 3); + var even = 0.0, odd = 0.0; + for (var y = 0; y < rows; y++) { + var sum = 0; + final start = y * width * 3; + for (var i = start; i < start + width * 3; i++) { + sum += rgb[i]; + } + if (y.isEven) { + even += sum; + } else { + odd += sum; + } + } + final perRow = width * 3.0; + return ((even / (rows / 2)) - (odd / (rows / 2))).abs() / perRow; +} + +void main() { + setUpAll(() async { + await WorkerHarness.ensureReady(); + await Directory(_outDir).create(recursive: true); + }); + + const progressive = QTGMCParameters(enabled: false); + + group('deps 1.16.0 classical filters', () { + test('anti-alias: SangNom runs end-to-end', () async { + final result = await WorkerHarness.runJob( + _job('aa_sangnom', const ProcessingPipeline( + deinterlace: progressive, + antiAlias: AntiAliasParameters(enabled: true, method: AntiAliasMethod.sangnom), + )).toJson(), + label: 'aa_sangnom'); + await _expectValidVideo(result); + }, timeout: const Timeout(Duration(minutes: 6))); + + test('noise reduction: NLMeans (CPU) runs end-to-end without OpenCL', + () async { + // OpenCL switched off for the worker: the CPU method must not care. + final result = await WorkerHarness.runJob( + _job('nr_nlmeans_cpu', const ProcessingPipeline( + deinterlace: progressive, + noiseReduction: NoiseReductionParameters( + enabled: true, method: NoiseReductionMethod.nlMeans, knlmH: 2.0), + )).toJson(), + label: 'nr_nlmeans_cpu', + extraEnv: const {'VAPOURBOX_DISABLE_OPENCL': '1'}); + await _expectValidVideo(result); + }, timeout: const Timeout(Duration(minutes: 6))); + + test('noise reduction: NLMeans (CPU) gives KNLMeansCL\'s picture', () async { + final probe = await Process.run(WorkerHarness.workerPath, ['--probe-opencl'], + environment: WorkerHarness.workerEnv, + workingDirectory: File(WorkerHarness.workerPath).parent.path); + if (probe.stdout.toString().contains('"knlmDevice":null')) { + print(' skipped: no OpenCL device for the KNLMeansCL reference'); + return; + } + Future frame(NoiseReductionMethod method) async { + final r = await WorkerHarness.runPreview( + _job('nlm_vs_knlm_${method.name}', ProcessingPipeline( + deinterlace: progressive, + noiseReduction: NoiseReductionParameters( + enabled: true, method: method, knlmH: 2.5, knlmD: 1), + )).toJson(), + frame: 10, + label: 'nlm_${method.name}'); + expect(r.success, isTrue, reason: r.errorTail); + return WorkerHarness.imageToRgb24(r.png!, label: 'nlm_${method.name}'); + } + + final cpu = await frame(NoiseReductionMethod.nlMeans); + final gpu = await frame(NoiseReductionMethod.knlMeans); + final diff = WorkerHarness.meanAbsDiff(cpu, gpu); + print(' NLMeans (CPU) vs KNLMeansCL: mean |diff| $diff'); + // Same algorithm and arguments: identical to the level on the probe + // (docs/reports/2026-11-classical-filters); the slack is GPU float order. + expect(diff, lessThan(0.5)); + }, timeout: const Timeout(Duration(minutes: 6))); + + test('dehalo: vine runs end-to-end', () async { + final result = await WorkerHarness.runJob( + _job('dehalo_vine', const ProcessingPipeline( + deinterlace: progressive, + dehalo: DehaloParameters(enabled: true, method: DehaloMethod.vine), + )).toJson(), + label: 'dehalo_vine'); + await _expectValidVideo(result); + }, timeout: const Timeout(Duration(minutes: 8))); + + test('deblock: mpeg2stinx runs end-to-end after IVTC', () async { + final result = await WorkerHarness.runJob( + _job('deblock_mpeg2stinx', const ProcessingPipeline( + deinterlace: QTGMCParameters( + enabled: true, method: DeinterlaceMethod.ivtc, tff: true), + deblock: DeblockParameters(enabled: true, method: DeblockMethod.mpeg2stinx), + )).toJson(), + label: 'deblock_mpeg2stinx'); + await _expectValidVideo(result); + }, timeout: const Timeout(Duration(minutes: 8))); + + test('ivtc: the fade fix closes the gap between a fading frame\'s fields', + () async { + // A fade applied AFTER the fields were split (as one applied to + // telecined video is): each field fades at its own time, so every frame + // of the fade has a brighter and a darker field — the comb no field + // match can remove. 320x240, 30 frames (brightened so the fade has + // somewhere to go), fading to black over half a second. + final fixture = p.join(_outDir, 'fade_after_split.mkv'); + final make = await Process.run( + WorkerHarness.ffmpegPath, + [ + '-y', '-v', 'error', '-i', + p.join(WorkerHarness.repoRoot, 'Tests', 'TestResources', 'small_clip.mp4'), + '-vf', 'select=between(n\\,1400\\,1429),setpts=N/30/TB,eq=brightness=0.35,separatefields,' + 'fade=t=out:st=0:d=0.5,weave=first_field=top,format=yuv420p', + '-r', '30', '-c:v', 'ffv1', fixture, + ], + environment: WorkerHarness.ffmpegEnv, + ); + expect(make.exitCode, 0, reason: make.stderr.toString()); + + Future gap(bool fix) async { + final result = await WorkerHarness.runJob( + _job( + 'ivtc_fades_$fix', + ProcessingPipeline( + deinterlace: QTGMCParameters( + enabled: true, + method: DeinterlaceMethod.ivtc, + tff: true, + ivtcFixFades: fix, + ), + ), + input: fixture, + frames: 30, + fps: 30, + width: 320, + height: 240, + ).toJson(), + label: 'ivtc_fades_$fix'); + await _expectValidVideo(result); + // Frames well inside the fade (before it reaches black). + var total = 0.0; + for (final i in [2, 4, 6, 8]) { + total += _fieldGap(await WorkerHarness.frameRgb24(result.outputPath!, i), 320); + } + return total / 4; + } + + final before = await gap(false); + final after = await gap(true); + print(' field brightness gap: without fix $before, with fix $after'); + expect(before, greaterThan(1.0), reason: 'the fixture has no fade comb'); + expect(after, lessThan(before * 0.25)); + }, timeout: const Timeout(Duration(minutes: 8))); + }); +} diff --git a/app/test/integration_filter_parameters_test.dart b/app/test/integration_filter_parameters_test.dart index d961387d..c4eb6d7f 100644 --- a/app/test/integration_filter_parameters_test.dart +++ b/app/test/integration_filter_parameters_test.dart @@ -1487,7 +1487,10 @@ void main() { // Deinterlacing off + a detected field order: every method runs between // SeparateFields and DoubleWeave (docs/reports/2026-11-deblock-order). loadSchema('deblock'); - for (final method in DeblockMethod.values) { + // mpeg2stinx is a cross-field filter and always takes the whole frame + // (asserted in its own test below). + for (final method + in DeblockMethod.values.where((m) => m != DeblockMethod.mpeg2stinx)) { final job = buildJob( testName: 'deblock_per_field_${method.name}', deblock: DeblockParameters(enabled: true, method: method), @@ -1501,6 +1504,7 @@ void main() { DeblockMethod.deblockQed => 'haf.Deblock_QED(', DeblockMethod.deblock => 'core.deblock.Deblock(', DeblockMethod.dctFilter => 'core.dctf.DCTFilter(', + DeblockMethod.mpeg2stinx => throw StateError('excluded above'), }; final at = script.indexOf(call); expect(at > split && at < weave, isTrue, @@ -1516,6 +1520,115 @@ void main() { print(' PASS'); }, timeout: const Timeout(Duration(minutes: 2))); + // --- deps 1.16.0: SangNom, vszip NLMeans, vs-jetpack ports (app 1.7.0) --- + + test('anti-alias: SangNom AA, strength reaching both axes', () async { + loadSchema('anti_alias'); + final job = buildJob( + testName: 'aa_sangnom', + antiAlias: const AntiAliasParameters( + enabled: true, + method: AntiAliasMethod.sangnom, + sangnomAa: 72, + ), + ); + final script = await generateScriptViaWorker(job); + expect(script, contains('core.sangnom.SangNom(_aa, order=1, aa=72)')); + expect(script, + contains('core.sangnom.SangNom(core.std.Transpose(_aa), order=1, aa=72)')); + expect(script, isNot(contains('haf.santiag('))); + expect(script, isNot(contains('haf.daa('))); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('noise reduction: NLMeans (CPU) uses the KNLMeans settings, no OpenCL', + () async { + loadSchema('noise_reduction'); + final job = buildJob( + testName: 'nr_nlm_cpu', + noiseReduction: const NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.nlMeans, + knlmH: 2.5, + knlmD: 2, + knlmChroma: false, + ), + ); + // No OpenCL involved (test_201 checks it runs where KNLMeans is refused). + final script = await generateScriptViaWorker(job); + expect(script, + contains('core.vszip.NLMeans(clip, d=2, a=2, s=4, h=2.50, channels="Y")')); + expect(script, contains(' if False:')); + expect(script, isNot(contains('core.knlm.KNLMeansCL('))); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('dehalo: vine params reach the ported block', () async { + loadSchema('dehalo'); + final job = buildJob( + testName: 'dehalo_vine', + dehalo: const DehaloParameters( + enabled: true, + method: DehaloMethod.vine, + vineStrength: 20.0, + vineSharp: 0.75, + vineSigma: 1.5, + ), + ); + final script = await generateScriptViaWorker(job); + expect(script, contains('_vine_strength = 20.000')); + expect(script, contains('_vine_sharp = 0.750')); + expect(script, contains('_vine_sigma = 1.500')); + expect(script, contains('core.vszip.NLMeans(_vine_y, d=0, a=2, s=0, h=_vine_strength)')); + expect(script, isNot(contains('haf.DeHalo_alpha('))); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('deblock: mpeg2stinx takes the whole frame with the detected order', + () async { + loadSchema('deblock'); + final job = buildJob( + testName: 'deblock_mpeg2stinx', + deblock: const DeblockParameters( + enabled: true, method: DeblockMethod.mpeg2stinx), + ).copyWith(detectedFieldOrder: FieldOrder.bottomFieldFirst); + final script = await generateScriptViaWorker(job); + expect(script, contains('_stx_tff = False')); + expect(script, + contains('clip = core.std.SetFieldBased(core.std.Merge(_stx_1, _stx_2), 0)')); + expect(script, isNot(contains('_deblock_per_field')), + reason: 'mpeg2stinx is a cross-field filter: never per field'); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('ivtc: fade fix follows field matching, in the chosen mode', () async { + loadSchema('deinterlace'); + for (final (fix, mode) in [(true, 'match'), (false, 'average')]) { + final job = buildJob( + testName: 'ivtc_fades_$fix', + deinterlace: QTGMCParameters( + enabled: true, + method: DeinterlaceMethod.ivtc, + tff: true, + ivtcFixFades: fix, + ivtcFadeMode: mode, + ), + ); + final script = await generateScriptViaWorker(job); + if (fix) { + final vfm = script.indexOf('core.vivtc.VFM('); + final at = script.indexOf('_fade_fields = core.std.SeparateFields('); + final decimate = script.indexOf('core.vivtc.VDecimate('); + expect(at, greaterThan(vfm)); + expect(at, lessThan(decimate)); + expect(script, contains("if 'match' == 'average' else min(a, b)")); + } else { + expect(script, isNot(contains('_fade_fields'))); + } + } + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + test('grain: AddGrain params are not depth-scaled', () async { loadSchema('grain'); final job = buildJob( diff --git a/app/test/integration_high_bit_depth_filters_test.dart b/app/test/integration_high_bit_depth_filters_test.dart index cd3e31e1..bfb7b1cd 100644 --- a/app/test/integration_high_bit_depth_filters_test.dart +++ b/app/test/integration_high_bit_depth_filters_test.dart @@ -46,6 +46,7 @@ import 'package:vapourbox/models/chroma_fix_parameters.dart'; import 'package:vapourbox/models/color_correction_parameters.dart'; import 'package:vapourbox/models/crop_resize_parameters.dart'; import 'package:vapourbox/models/deband_parameters.dart'; +import 'package:vapourbox/models/anti_alias_parameters.dart'; import 'package:vapourbox/models/deblock_parameters.dart'; import 'package:vapourbox/models/dehalo_parameters.dart'; import 'package:vapourbox/models/deflicker_parameters.dart'; @@ -289,6 +290,7 @@ ProcessingPipeline _only({ GhostRemovalParameters ghostRemoval = const GhostRemovalParameters(), FrameRateParameters frameRate = const FrameRateParameters(), DescaleParameters descale = const DescaleParameters(), + AntiAliasParameters antiAlias = const AntiAliasParameters(), }) { return ProcessingPipeline( deinterlace: deinterlace, @@ -308,6 +310,7 @@ ProcessingPipeline _only({ ghostRemoval: ghostRemoval, frameRate: frameRate, descale: descale, + antiAlias: antiAlias, ); } @@ -470,6 +473,51 @@ List<_Pass> _passes() => [ needsKnlmDevice: true, ), + // NLMeans on the CPU (vszip, deps 1.16.0): KNLMeansCL's arguments, every + // depth natively, h depth-independent. Same h as the KNLMeans case above + // so the two read alike; unlike it, this one runs on every runner. + _Pass( + 'noise_nlmeans_cpu', + _only( + noiseReduction: const NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.nlMeans, + knlmH: 3.0, + ), + ), + ), + + // SangNom AA: `aa` is in 8-bit units and the plugin scales it itself + // (measured: an 8-bit and a 10-bit run agree to 0.05 levels); the 2x + // Spline36 round trip adds the rest. + _Pass( + 'anti_alias_sangnom', + _only( + antiAlias: const AntiAliasParameters( + enabled: true, + method: AntiAliasMethod.sangnom, + ), + ), + ), + + // The IVTC fade fix works in float, so a correct block is depth-blind. + // This fixture holds no fade, and on a frame that is not fading the fix + // changes ~0.1 levels by design — so it cannot be required to change + // the picture; the parity check is what this case is for (the fix's + // effect is integration_classical_filters_test's). + _Pass( + 'ivtc_fix_fades', + _only( + deinterlace: const QTGMCParameters( + enabled: true, + method: DeinterlaceMethod.ivtc, + tff: true, + ivtcFixFades: true, + ), + ), + changesPicture: false, + ), + // Deflicker measures frame brightness against a windowed average, so its // correction is a ratio and should be depth-independent by construction. _Pass( diff --git a/worker/src/dependency_locator.rs b/worker/src/dependency_locator.rs index 9427c021..e5b55c07 100644 --- a/worker/src/dependency_locator.rs +++ b/worker/src/dependency_locator.rs @@ -12,9 +12,10 @@ use crate::gpu_plugin::{self, GpuPlugin, GpuPluginFiles, GpuPluginReason, GpuPlu /// Why the KNLMeans noise-reduction method can't run, as the app shows it. pub const KNLM_NO_DEVICE_MESSAGE: &str = "KNLMeans runs on the graphics card through OpenCL, \ and no OpenCL device that works with it was found on this computer. Updating the graphics \ -driver may fix this. BM3D does the same job on the CPU and needs nothing extra."; +driver may fix this. Or choose NLMeans (CPU): the same filter with the same settings, run \ +on the processor instead, so it needs nothing extra."; pub const KNLM_DISABLED_MESSAGE: &str = "KNLMeans is turned off on this computer \ -(VAPOURBOX_DISABLE_OPENCL is set). BM3D does the same job on the CPU."; +(VAPOURBOX_DISABLE_OPENCL is set). NLMeans (CPU) is the same filter run on the processor."; /// Platform-specific dependency locator. pub struct DependencyLocator { diff --git a/worker/src/models/anti_alias_parameters.rs b/worker/src/models/anti_alias_parameters.rs index 3755edff..9cfd8cf9 100644 --- a/worker/src/models/anti_alias_parameters.rs +++ b/worker/src/models/anti_alias_parameters.rs @@ -5,8 +5,9 @@ //! the frame with an edge-directed kernel and take the smoother of the two //! results, so the edge is rebuilt rather than blurred. //! -//! Neither has a bit-depth limit — verified against the bundle at 8/10/12/16-bit -//! and 4:2:2, so no conversion guard is needed. +//! None has a bit-depth limit — verified against the bundle at 8/10/12/16-bit +//! and 4:2:2, so no conversion guard is needed. SangNom's `aa` is in 8-bit +//! units at every depth; the plugin scales it itself. use serde::{Deserialize, Serialize}; @@ -21,6 +22,10 @@ pub enum AntiAliasMethod { /// santiag: separate horizontal and vertical strength, so it can be aimed /// at one axis. Stronger, and slower. Santiag, + /// SangNom AA: supersample 2x, SangNom along each axis, back down. The + /// fastest and sharpest of the three (app 1.7.0, deps 1.16.0). + #[serde(rename = "sangnom")] + SangNom, } /// Parameters for the anti-aliasing pass. @@ -46,13 +51,22 @@ pub struct AntiAliasParameters { pub santiag_strh: i32, /// Interpolator for the vertical pass: `nnedi3`, `eedi2` or `sangnom`. - /// Only `nnedi3` is bundled, so the others are not offered. + /// Pinned to `nnedi3` (see [`Self::effective_santiag_type`]). #[serde(default = "default_santiag_type")] pub santiag_type: String, + + // --- SangNom parameters --- + + /// SangNom's anti-aliasing strength, 0-128 in 8-bit units (the plugin's + /// default is 48). 0 still re-interpolates every other line; it only stops + /// SangNom smoothing the edge direction it finds. + #[serde(default = "default_sangnom_aa")] + pub sangnom_aa: i32, } fn default_strength() -> i32 { 1 } fn default_santiag_type() -> String { "nnedi3".to_string() } +fn default_sangnom_aa() -> i32 { 48 } impl Default for AntiAliasParameters { fn default() -> Self { @@ -62,17 +76,20 @@ impl Default for AntiAliasParameters { santiag_strv: default_strength(), santiag_strh: default_strength(), santiag_type: default_santiag_type(), + sangnom_aa: default_sangnom_aa(), } } } impl AntiAliasParameters { - /// santiag's interpolator name, restricted to what is bundled. + /// santiag's interpolator name, pinned to nnedi3. /// - /// havsfunc accepts `nnedi3`, `eedi2` and `sangnom`; only nnedi3 ships here, - /// and naming an absent one fails at script evaluation with a bare "no - /// attribute" error. Anything unrecognised falls back to nnedi3 rather than - /// reaching havsfunc. + /// havsfunc accepts `nnedi3`, `eedi2` and `sangnom`. eedi2 is not bundled + /// (naming it fails at script evaluation with a bare "no attribute"), and + /// `sangnom`, although SangNom ships from deps 1.16.0, is broken in + /// havsfunc: its strength-0 step calls `SangNom(order=0)`, which SangNom r45 + /// treats as double-rate and refuses without `_FieldBased` (measured). + /// SangNom is offered as its own method instead. pub fn effective_santiag_type(&self) -> &'static str { match self.santiag_type.to_ascii_lowercase().as_str() { "nnedi3" => "nnedi3", @@ -89,6 +106,12 @@ impl AntiAliasParameters { pub fn effective_strh(&self) -> i32 { self.santiag_strh.clamp(0, 3) } + + /// SangNom's `aa`, clamped to the 0-128 the plugin accepts (it rejects + /// anything outside with a script error). + pub fn effective_sangnom_aa(&self) -> i32 { + self.sangnom_aa.clamp(0, 128) + } } #[cfg(test)] @@ -106,7 +129,8 @@ mod tests { #[test] fn test_santiag_type_is_restricted_to_what_is_bundled() { - // eedi2 and sangnom are not in the deps bundle; naming one would fail at + // eedi2 is not in the deps bundle, and havsfunc's sangnom type calls + // SangNom(order=0), which SangNom r45 refuses; either would fail at // script evaluation rather than degrade. let with = |t: &str| AntiAliasParameters { santiag_type: t.to_string(), @@ -130,6 +154,17 @@ mod tests { assert_eq!(with(9).effective_strh(), 3); } + #[test] + fn test_sangnom_method_wire_name_and_strength() { + let p: AntiAliasParameters = + serde_json::from_str(r#"{"enabled":true,"method":"sangnom","sangnomAa":200}"#).unwrap(); + assert_eq!(p.method, AntiAliasMethod::SangNom); + assert_eq!(p.effective_sangnom_aa(), 128); + assert_eq!(AntiAliasParameters::default().sangnom_aa, 48); + let json = serde_json::to_string(&p).unwrap(); + assert!(json.contains("\"method\":\"sangnom\""), "{json}"); + } + #[test] fn test_serialization() { let json = serde_json::to_string(&AntiAliasParameters::default()).unwrap(); diff --git a/worker/src/models/deblock_parameters.rs b/worker/src/models/deblock_parameters.rs index 71a49124..10dc1fa3 100644 --- a/worker/src/models/deblock_parameters.rs +++ b/worker/src/models/deblock_parameters.rs @@ -12,6 +12,11 @@ pub enum DeblockMethod { /// ringing and mosquito noise rather than block edges. #[serde(rename = "DCTFilter")] DctFilter, + /// mpeg2stinx: the comb-like noise of a film DVD whose progressive frames + /// were encoded as interlaced MPEG-2 (vs-jetpack's mpeg2stinx, ported into + /// the templates). Temporal (one frame each side); never per field. + #[serde(rename = "mpeg2stinx")] + Mpeg2Stinx, } #[allow(dead_code)] @@ -21,6 +26,7 @@ impl DeblockMethod { DeblockMethod::DeblockQed => "Deblock_QED", DeblockMethod::Deblock => "Deblock", DeblockMethod::DctFilter => "DCTFilter", + DeblockMethod::Mpeg2Stinx => "mpeg2stinx", } } } diff --git a/worker/src/models/dehalo_parameters.rs b/worker/src/models/dehalo_parameters.rs index 3aa47376..90ef8c5e 100644 --- a/worker/src/models/dehalo_parameters.rs +++ b/worker/src/models/dehalo_parameters.rs @@ -27,6 +27,12 @@ pub enum DehaloMethod { /// dehalo's wider bright band. #[serde(rename = "HQDeringmod")] HqDeringmod, + /// vine: dehalo by non-local-means filtering of the error (vs-jetpack's + /// vine_dehalo, ported into the templates). Keeps the edge detail the halo + /// rides on rather than blurring the band (app 1.7.0, deps 1.16.0: needs + /// vszip's NLMeans). + #[serde(rename = "vine")] + Vine, } #[allow(dead_code)] @@ -41,6 +47,7 @@ impl DehaloMethod { DehaloMethod::Vinverse => "Vinverse", DehaloMethod::Vinverse2 => "Vinverse2", DehaloMethod::HqDeringmod => "HQDeringmod", + DehaloMethod::Vine => "vine", } } @@ -203,6 +210,20 @@ pub struct DehaloParameters { /// inside havsfunc, which is usually what you want. #[serde(default)] pub dering_darkthr: Option, + + // --- vine parameters --- + + /// NLMeans strength (vs-jetpack's `strength`, default 16). Depth-independent. + #[serde(default = "default_vine_strength")] + pub vine_strength: f64, + + /// How much of the supersampled pass to blend in (`sharp`, default 0.5). + #[serde(default = "default_vine_sharp")] + pub vine_sharp: f64, + + /// Gaussian sigma of the low/high frequency split (`sigma`, default 1.0). + #[serde(default = "default_vine_sigma")] + pub vine_sigma: f64, } fn default_rx() -> f64 { 2.0 } @@ -213,6 +234,9 @@ fn default_low_threshold() -> i32 { 80 } fn default_high_threshold() -> i32 { 128 } fn default_yahr_blur() -> i32 { 2 } fn default_yahr_depth() -> i32 { 32 } +fn default_vine_strength() -> f64 { 16.0 } +fn default_vine_sharp() -> f64 { 0.5 } +fn default_vine_sigma() -> f64 { 1.0 } impl Default for DehaloParameters { fn default() -> Self { @@ -248,6 +272,24 @@ impl Default for DehaloParameters { dering_mthr: None, dering_thr: None, dering_darkthr: None, + vine_strength: default_vine_strength(), + vine_sharp: default_vine_sharp(), + vine_sigma: default_vine_sigma(), } } } + +impl DehaloParameters { + /// vine's (strength, sharp, sigma), clamped to what the script can take: + /// NLMeans needs h > 0; vs-jetpack refuses sharp <= 0 and caps its effect + /// at 1; the Gaussian kernel is limited to 25 taps (sigma 4). A non-finite + /// value falls back to the default rather than reaching the script. + pub fn vine_effective(&self) -> (f64, f64, f64) { + let pick = |v: f64, d: f64, lo: f64, hi: f64| if v.is_finite() { v.clamp(lo, hi) } else { d }; + ( + pick(self.vine_strength, default_vine_strength(), 1.0, 64.0), + pick(self.vine_sharp, default_vine_sharp(), 0.05, 1.0), + pick(self.vine_sigma, default_vine_sigma(), 0.3, 4.0), + ) + } +} diff --git a/worker/src/models/noise_reduction_parameters.rs b/worker/src/models/noise_reduction_parameters.rs index 9e869475..e14f2b84 100644 --- a/worker/src/models/noise_reduction_parameters.rs +++ b/worker/src/models/noise_reduction_parameters.rs @@ -46,6 +46,11 @@ pub enum NoiseReductionMethod { /// works; refused with a reason where none does (see /// `DependencyLocator::knlm_device`). KnlMeans, + /// vszip's NLMeans: KNLMeansCL's algorithm, arguments and defaults on the + /// CPU, so it runs anywhere (app 1.7.0, deps 1.16.0). Shares the `knlm_*` + /// parameters with KnlMeans: the two take the same arguments and give the + /// same picture. + NlMeans, } /// Noise reduction preset levels. @@ -478,7 +483,7 @@ impl NoiseReductionParameters { let r = self.bm3d_effective_radius(); if self.bm3d_final { 2 * r } else { r } } - NoiseReductionMethod::KnlMeans => self.knlm_effective().1, + NoiseReductionMethod::KnlMeans | NoiseReductionMethod::NlMeans => self.knlm_effective().1, _ => 0, }; own.max(3) as u32 @@ -686,6 +691,7 @@ mod tests { (NoiseReductionMethod::Ctmf, "ctmf"), (NoiseReductionMethod::Bm3d, "bm3d"), (NoiseReductionMethod::KnlMeans, "knlMeans"), + (NoiseReductionMethod::NlMeans, "nlMeans"), ] { let json = name(method); assert!( diff --git a/worker/src/models/processing_pipeline.rs b/worker/src/models/processing_pipeline.rs index 827f957a..9375d885 100644 --- a/worker/src/models/processing_pipeline.rs +++ b/worker/src/models/processing_pipeline.rs @@ -574,6 +574,10 @@ impl ProcessingPipeline { PassType::EdgeRepair => FrameMap::Identity { radius: 0 }, PassType::GhostRemoval => FrameMap::Identity { radius: 0 }, PassType::Descale => FrameMap::Identity { radius: 0 }, + // mpeg2stinx limits each change by the neighbouring frames. + PassType::Deblock => FrameMap::Identity { + radius: if self.deblock.method == super::DeblockMethod::Mpeg2Stinx { 1 } else { 0 }, + }, // The one pass that emits a Retime. The ratio is known up front, so // the count is exact; `synthesizes` is true only for the // interpolating method, where an output frame has no single source. diff --git a/worker/src/models/qtgmc_parameters.rs b/worker/src/models/qtgmc_parameters.rs index b3ab1a85..2677b126 100644 --- a/worker/src/models/qtgmc_parameters.rs +++ b/worker/src/models/qtgmc_parameters.rs @@ -411,8 +411,22 @@ pub struct QTGMCParameters { /// VDecimate scene change threshold #[serde(skip_serializing_if = "Option::is_none")] pub ivtc_scthresh: Option, + + /// Fix fades added after telecine (vs-jetpack's FixInterlacedFades, run + /// right after field matching). Off by default: it only helps a fade that + /// combs, and touches every frame. + #[serde(default)] + pub ivtc_fix_fades: bool, + + /// How the two fields of a fading frame are reconciled: `average` (both + /// to their mean, upstream's AVERAGE) or `match` (the brighter to the + /// darker, MATCH). See [`Self::effective_ivtc_fade_mode`]. + #[serde(default = "default_ivtc_fade_mode")] + pub ivtc_fade_mode: String, } +fn default_ivtc_fade_mode() -> String { "average".to_string() } + // Default value functions fn default_true() -> bool { true } @@ -423,6 +437,16 @@ fn default_true() -> bool { true } const SUPPORTED_CHROMA_EDI: [&str; 2] = ["nnedi3", "bob"]; impl QTGMCParameters { + /// The fade fix's mode as the template spells it. Anything but `match` + /// is `average`, upstream's first and gentler mode: it keeps a fade's + /// overall level, where `match` darkens every frame to its darker field. + /// `match` is the one that also cleans the last frames of a fade that + /// reaches full black, where one field is already black and `average` + /// leaves the other at half. + pub fn effective_ivtc_fade_mode(&self) -> &'static str { + if self.ivtc_fade_mode.eq_ignore_ascii_case("match") { "match" } else { "average" } + } + /// Whether to upsample 4:2:0 chroma to 4:2:2 around the deinterlace pass. /// Defaults to disabled: it is the more correct way to handle interlaced /// 4:2:0 chroma, but it costs roughly 30% throughput, so it is offered as @@ -548,6 +572,8 @@ impl Default for QTGMCParameters { ivtc_cycle: None, ivtc_dupthresh: None, ivtc_scthresh: None, + ivtc_fix_fades: false, + ivtc_fade_mode: default_ivtc_fade_mode(), } } } diff --git a/worker/src/script_generator.rs b/worker/src/script_generator.rs index 242f82cb..e086ac84 100644 --- a/worker/src/script_generator.rs +++ b/worker/src/script_generator.rs @@ -802,6 +802,15 @@ impl ScriptGenerator { }; script = script.replace("{{IVTC_ORDER}}", &order.to_string()); + // Fades added after telecine (vs-jetpack's FixInterlacedFades). + if params.ivtc_fix_fades { + script = script.replace("{{#DEINT_IVTC_FIX_FADES}}", ""); + script = script.replace("{{/DEINT_IVTC_FIX_FADES}}", ""); + script = script.replace("{{IVTC_FADE_MODE}}", params.effective_ivtc_fade_mode()); + } else { + script = remove_block("{{#DEINT_IVTC_FIX_FADES}}", "{{/DEINT_IVTC_FIX_FADES}}", script); + } + // VFM parameters script = process_optional_int("IVTC_MODE", params.ivtc_mode, script); script = process_optional_int("IVTC_CTHRESH", params.ivtc_cthresh, script); @@ -929,17 +938,20 @@ impl ScriptGenerator { script = script.replace("{{#NOISE_REDUCTION}}", ""); script = script.replace("{{/NOISE_REDUCTION}}", ""); - // BM3D and KNLMeans are removed here rather than in every other - // method's arm below. + // BM3D, KNLMeans and NLMeans are removed here rather than in every + // other method's arm below. if nr.method != NoiseReductionMethod::Bm3d { script = remove_block("{{#NR_BM3D}}", "{{/NR_BM3D}}", script); } if nr.method != NoiseReductionMethod::KnlMeans { script = remove_block("{{#NR_KNLM}}", "{{/NR_KNLM}}", script); } + if nr.method != NoiseReductionMethod::NlMeans { + script = remove_block("{{#NR_NLM}}", "{{/NR_NLM}}", script); + } match nr.method { - NoiseReductionMethod::Bm3d | NoiseReductionMethod::KnlMeans => { + NoiseReductionMethod::Bm3d | NoiseReductionMethod::KnlMeans | NoiseReductionMethod::NlMeans => { for other in [ "NR_SMDEGRAIN", "NR_MCTD", "NR_MCDEGRAINSHARP", "NR_CTMF", "NR_FLUXSMOOTH_T", "NR_FLUXSMOOTH_ST", "NR_STPRESSO", "NR_MCLEAN", @@ -956,6 +968,17 @@ impl ScriptGenerator { script = script.replace("{{NR_BM3D_RADIUS}}", &nr.bm3d_effective_radius().to_string()); script = script.replace("{{NR_BM3D_FINAL}}", if nr.bm3d_final { "True" } else { "False" }); script = script.replace("{{NR_BM3D_PROFILE}}", nr.bm3d_effective_profile()); + } else if nr.method == NoiseReductionMethod::NlMeans { + // The same arguments as KNLMeansCL (vszip's port takes + // its argument names and defaults), on the CPU. + let (h, d, a, s) = nr.knlm_effective(); + script = script.replace("{{#NR_NLM}}", ""); + script = script.replace("{{/NR_NLM}}", ""); + script = script.replace("{{NR_NLM_H}}", &format!("{h:.2}")); + script = script.replace("{{NR_NLM_D}}", &d.to_string()); + script = script.replace("{{NR_NLM_A}}", &a.to_string()); + script = script.replace("{{NR_NLM_S}}", &s.to_string()); + script = script.replace("{{NR_NLM_CHROMA}}", if nr.knlm_chroma { "True" } else { "False" }); } else { let (h, d, a, s) = nr.knlm_effective(); // ensure_methods_ready has already refused a job without one. @@ -1428,7 +1451,7 @@ impl ScriptGenerator { // every block per arm is what the other passes do, but with seven // methods it stops being readable — so keep the block the method // selects and drop the others by difference. - const DEHALO_BLOCKS: [&str; 7] = [ + const DEHALO_BLOCKS: [&str; 8] = [ "DEHALO_DEHALO_ALPHA", "DEHALO_FINE_DEHALO", "DEHALO_FINE_DEHALO2", @@ -1436,6 +1459,7 @@ impl ScriptGenerator { "DEHALO_EDGE_CLEANER", "DEHALO_VINVERSE", "DEHALO_HQDERING", + "DEHALO_VINE", ]; let selected = match dehalo.method { @@ -1448,6 +1472,7 @@ impl ScriptGenerator { // name differs. DehaloMethod::Vinverse | DehaloMethod::Vinverse2 => "DEHALO_VINVERSE", DehaloMethod::HqDeringmod => "DEHALO_HQDERING", + DehaloMethod::Vine => "DEHALO_VINE", }; for block in DEHALO_BLOCKS { @@ -1501,6 +1526,12 @@ impl ScriptGenerator { script = process_optional_double("DEHALO_DERING_THR", dehalo.dering_thr, script); script = process_optional_double("DEHALO_DERING_DARKTHR", dehalo.dering_darkthr, script); } + DehaloMethod::Vine => { + let (strength, sharp, sigma) = dehalo.vine_effective(); + script = script.replace("{{DEHALO_VINE_STRENGTH}}", &format!("{strength:.3}")); + script = script.replace("{{DEHALO_VINE_SHARP}}", &format!("{sharp:.3}")); + script = script.replace("{{DEHALO_VINE_SIGMA}}", &format!("{sigma:.3}")); + } } // Radius and strength are shared by DeHalo_alpha and FineDehalo only. @@ -1524,8 +1555,14 @@ impl ScriptGenerator { // Still interlaced: deblock each field on its own, never the woven // frame (docs/reports/2026-11-deblock-order). Deinterlaced output - // is progressive and keeps the whole-frame path. - match Self::still_interlaced_tff(job, pipeline) { + // is progressive and keeps the whole-frame path. mpeg2stinx is a + // cross-field filter by design and always takes the whole frame. + let per_field = if deblock.method == DeblockMethod::Mpeg2Stinx { + None + } else { + Self::still_interlaced_tff(job, pipeline) + }; + match per_field { Some(tff) => { script = script.replace("{{#DEBLOCK_PER_FIELD}}", ""); script = script.replace("{{/DEBLOCK_PER_FIELD}}", ""); @@ -1537,7 +1574,20 @@ impl ScriptGenerator { } match deblock.method { + DeblockMethod::Mpeg2Stinx => { + for other in ["DEBLOCK_QED", "DEBLOCK_SIMPLE", "DEBLOCK_DCTFILTER"] { + script = remove_block(&format!("{{{{#{other}}}}}"), &format!("{{{{/{other}}}}}"), script); + } + script = script.replace("{{#DEBLOCK_MPEG2STINX}}", ""); + script = script.replace("{{/DEBLOCK_MPEG2STINX}}", ""); + // The field order the frames were ENCODED with: the + // deinterlace pass's (IVTC) when it runs, else what was + // detected, else top field first (DVD's usual order). + let tff = Self::field_based_for(job, pipeline).map_or(true, |fb| fb == 2); + script = script.replace("{{DEBLOCK_STINX_TFF}}", if tff { "True" } else { "False" }); + } DeblockMethod::DeblockQed => { + script = remove_block("{{#DEBLOCK_MPEG2STINX}}", "{{/DEBLOCK_MPEG2STINX}}", script); script = script.replace("{{#DEBLOCK_QED}}", ""); script = script.replace("{{/DEBLOCK_QED}}", ""); script = remove_block("{{#DEBLOCK_SIMPLE}}", "{{/DEBLOCK_SIMPLE}}", script); @@ -1549,6 +1599,7 @@ impl ScriptGenerator { script = process_optional_int("DEBLOCK_AOFFSET2", Some(deblock.a_offset2), script); } DeblockMethod::Deblock => { + script = remove_block("{{#DEBLOCK_MPEG2STINX}}", "{{/DEBLOCK_MPEG2STINX}}", script); script = remove_block("{{#DEBLOCK_QED}}", "{{/DEBLOCK_QED}}", script); script = script.replace("{{#DEBLOCK_SIMPLE}}", ""); script = script.replace("{{/DEBLOCK_SIMPLE}}", ""); @@ -1557,6 +1608,7 @@ impl ScriptGenerator { script = process_optional_int("DEBLOCK_QUANT1", Some(deblock.quant1), script); } DeblockMethod::DctFilter => { + script = remove_block("{{#DEBLOCK_MPEG2STINX}}", "{{/DEBLOCK_MPEG2STINX}}", script); script = remove_block("{{#DEBLOCK_QED}}", "{{/DEBLOCK_QED}}", script); script = remove_block("{{#DEBLOCK_SIMPLE}}", "{{/DEBLOCK_SIMPLE}}", script); script = script.replace("{{#DEBLOCK_DCTFILTER}}", ""); @@ -1628,9 +1680,18 @@ impl ScriptGenerator { script = script.replace("{{#AA_DAA}}", ""); script = script.replace("{{/AA_DAA}}", ""); script = remove_block("{{#AA_SANTIAG}}", "{{/AA_SANTIAG}}", script); + script = remove_block("{{#AA_SANGNOM}}", "{{/AA_SANGNOM}}", script); + } + AntiAliasMethod::SangNom => { + script = remove_block("{{#AA_DAA}}", "{{/AA_DAA}}", script); + script = remove_block("{{#AA_SANTIAG}}", "{{/AA_SANTIAG}}", script); + script = script.replace("{{#AA_SANGNOM}}", ""); + script = script.replace("{{/AA_SANGNOM}}", ""); + script = script.replace("{{AA_SANGNOM_AA}}", &anti_alias.effective_sangnom_aa().to_string()); } AntiAliasMethod::Santiag => { script = remove_block("{{#AA_DAA}}", "{{/AA_DAA}}", script); + script = remove_block("{{#AA_SANGNOM}}", "{{/AA_SANGNOM}}", script); script = script.replace("{{#AA_SANTIAG}}", ""); script = script.replace("{{/AA_SANTIAG}}", ""); diff --git a/worker/templates/pipeline_template.vpy b/worker/templates/pipeline_template.vpy index bb2e7015..3f56f97a 100644 --- a/worker/templates/pipeline_template.vpy +++ b/worker/templates/pipeline_template.vpy @@ -457,6 +457,48 @@ clip = core.vivtc.VFM(_ivtc_metrics, order={{IVTC_ORDER}}, blocky={{IVTC_BLOCK_Y}}, {{/IVTC_BLOCK_Y}} clip2=_ivtc_src) +{{#DEINT_IVTC_FIX_FADES}} +# Fades added after telecine (vs-jetpack's FixInterlacedFades, MIT, ported from +# release v3.0.0; upstream calls it an improved Fix-Telecined-Fades). A fade +# applied to the telecined video changes brightness between the two fields of +# a frame, so no field match is clean and the fade combs. Each field is scaled +# about black (neutral for chroma) so its mean distance from it equals the +# {{IVTC_FADE_MODE}} of the two fields': exact for fades to or from black, and +# blur-free. On frames that are not fading the two fields' means agree and the +# change is negligible (measured ~0.1 levels). Float, as upstream requires; +# FrameEval with constants rather than akarin's frame-property Expr, which +# macOS x64 lacks. +_fade_fmt = clip.format +_fade_f = core.resize.Point(clip, format=_fade_fmt.replace(sample_type=vs.FLOAT, bits_per_sample=32).id) +_fade_fields = core.std.SeparateFields(core.std.SetFieldBased(_fade_f, 0), tff=True) +_fade_dist = _expr([_fade_fields], 'x abs') +for _fade_i in range(_fade_fmt.num_planes): + _fade_dist = core.std.PlaneStats(_fade_dist, plane=_fade_i, prop=f'P{_fade_i}') + + +def _fade_scale(n, f, c, own, other): + gains = [] + for i in range(_fade_fmt.num_planes): + a, b = f[own].props[f'P{i}Average'], f[other].props[f'P{i}Average'] + target = (a + b) / 2 if '{{IVTC_FADE_MODE}}' == 'average' else min(a, b) + gains.append(target / a if a > 1e-6 else 1.0) + if all(abs(g - 1.0) < 1e-6 for g in gains): + return c + return _expr([c], [f'x {g} *' for g in gains]) + + +_fade_top, _fade_bot = _fade_fields[::2], _fade_fields[1::2] +_fade_props = [_fade_dist[::2], _fade_dist[1::2]] +_fade_top = core.std.FrameEval(_fade_top, functools.partial(_fade_scale, c=_fade_top, own=0, other=1), + prop_src=_fade_props) +_fade_bot = core.std.FrameEval(_fade_bot, functools.partial(_fade_scale, c=_fade_bot, own=1, other=0), + prop_src=_fade_props) +clip = core.std.DoubleWeave(core.std.Interleave([_fade_top, _fade_bot]), tff=True)[::2] +# DoubleWeave flags its output interlaced; field-matched frames are +# progressive, and a later resize would otherwise treat them as fields. +clip = core.std.SetFieldBased(clip, 0) +clip = core.resize.Point(clip, format=_fade_fmt.id, dither_type="error_diffusion") +{{/DEINT_IVTC_FIX_FADES}} # VFM output is full-depth again; VDecimate accepts 8..16 bit natively. clip = core.vivtc.VDecimate(clip, {{#IVTC_CYCLE}} @@ -981,6 +1023,20 @@ clip = core.knlm.KNLMeansCL(clip, d={{NR_KNLM_D}}, a={{NR_KNLM_A}}, s={{NR_KNLM_ if {{NR_KNLM_CHROMA}} and clip.format.color_family == vs.YUV: clip = core.knlm.KNLMeansCL(clip, d={{NR_KNLM_D}}, a={{NR_KNLM_A}}, s={{NR_KNLM_S}}, h={{NR_KNLM_H}}, channels="UV", device_type="{{NR_KNLM_DEVICE}}") {{/NR_KNLM}} +{{#NR_NLM}} +# NLMeans on the CPU (vszip >= 24.0.0): a port of KNLMeansCL with its +# arguments and defaults, so it needs no OpenCL device. Same output as +# KNLMeansCL for the same arguments (measured: identical to the level on the +# same input). Every depth natively; h is depth-independent (an 8-bit and a +# 10-bit run agree to ~0.2 levels). Chroma in its own pass, as for KNLMeansCL: +# channels="YUV" needs 4:4:4. +if clip.format.color_family == vs.YUV: + clip = core.vszip.NLMeans(clip, d={{NR_NLM_D}}, a={{NR_NLM_A}}, s={{NR_NLM_S}}, h={{NR_NLM_H}}, channels="Y") + if {{NR_NLM_CHROMA}}: + clip = core.vszip.NLMeans(clip, d={{NR_NLM_D}}, a={{NR_NLM_A}}, s={{NR_NLM_S}}, h={{NR_NLM_H}}, channels="UV") +else: + clip = core.vszip.NLMeans(clip, d={{NR_NLM_D}}, a={{NR_NLM_A}}, s={{NR_NLM_S}}, h={{NR_NLM_H}}) +{{/NR_NLM}} {{#NR_MCLEAN}} # mClean — denoise, then put detail and grain back, so the result does not look # plastic. The only method here that is a goal rather than a mechanism. @@ -1247,6 +1303,53 @@ clip = haf.HQDeringmod( {{/DEHALO_DERING_DARKTHR}} ) {{/DEHALO_HQDERING}} +{{#DEHALO_VINE}} +# vine: dehalo by non-local-means filtering of the error (vs-jetpack's +# vine_dehalo, MIT, ported from release v3.0.0). Luma only, as upstream. +# 1. NLMeans (single-pixel similarity, s=0) on a 2x NNEDI3 supersample and on +# the picture itself, blended by a weight derived from `sharp`; +# 2. keep the source's low frequencies and the smoothed picture's high ones +# (a Gaussian split at `sigma`); +# 3. NLMeans the difference between the source and that, guided by it, and +# add it back: the halo goes, the edge detail it was riding on returns. +# The constants are upstream's. NLMeans' h is depth-independent, so nothing is +# rescaled for >8-bit sources (8-bit and 10-bit runs agree to within rounding). +import math as _vine_math + + +def _vine_gauss(c, sigma): + r = max(1, min(12, int(_vine_math.ceil(sigma * 3)))) + k = [_vine_math.exp(-(x * x) / (2.0 * sigma * sigma)) for x in range(-r, r + 1)] + return core.std.Convolution(core.std.Convolution(c, matrix=k, mode='h'), matrix=k, mode='v') + + +_vine_strength = {{DEHALO_VINE_STRENGTH}} +_vine_sharp = {{DEHALO_VINE_SHARP}} +_vine_sigma = {{DEHALO_VINE_SIGMA}} +_vine_c0 = 0.3926327792690057290863679493724 * min(_vine_sharp, 1.0) +_vine_weight = _vine_c0 * _vine_math.log(1 + 1 / _vine_c0) +_vine_h_refine = 18.880334973195822973214959957208 * ( + _vine_strength / 18.880334973195822973214959957208) ** 0.5862453661304626725671053478676 +_vine_y = core.std.ShufflePlanes(clip, 0, vs.GRAY) if clip.format.num_planes > 1 else clip +# 2x supersample: NNEDI3 keeping the source lines on the even rows (field=1), +# then the half-pixel shift that leaves is folded into the Catrom downscale. +_vine_ss = _nnedi3(_vine_y, field=1, dh=True) +_vine_ss = core.std.Transpose(_nnedi3(core.std.Transpose(_vine_ss), field=1, dh=True)) +_vine_ss = core.vszip.NLMeans(_vine_ss, d=0, a=2, s=0, h=_vine_strength) +_vine_ss = core.resize.Bicubic(_vine_ss, _vine_y.width, _vine_y.height, filter_param_a=0, + filter_param_b=0.5, src_left=-0.5, src_top=-0.5) +_vine_sm = core.vszip.NLMeans(_vine_y, d=0, a=2, s=0, h=_vine_strength) +_vine_sm = core.std.Merge(_vine_ss, _vine_sm, _vine_weight) +_vine_high = core.std.MergeDiff(_vine_gauss(_vine_y, _vine_sigma), + core.std.MakeDiff(_vine_sm, _vine_gauss(_vine_sm, _vine_sigma))) +_vine_ref = core.vszip.NLMeans(core.std.MakeDiff(_vine_y, _vine_high), d=0, a=2, s=4, + h=_vine_h_refine, rclip=_vine_high) +_vine_y = core.std.MergeDiff(_vine_high, _vine_ref) +if clip.format.num_planes > 1: + clip = core.std.ShufflePlanes([_vine_y, clip], [0, 1, 2], clip.format.color_family) +else: + clip = _vine_y +{{/DEHALO_VINE}} {{/DEHALO}} # ============================================================================ @@ -1309,6 +1412,64 @@ clip = core.dctf.DCTFilter( planes={{DEBLOCK_DCT_PLANES}}, ) {{/DEBLOCK_DCTFILTER}} +{{#DEBLOCK_MPEG2STINX}} +# mpeg2stinx: removes the comb-like noise a film DVD picks up when its +# progressive frames were encoded as interlaced MPEG-2 (vs-jetpack's +# mpeg2stinx, MIT, ported from release v3.0.0, with its defaults: NNEDI3 bob +# motion-masked by BWDIF, radius 2, limit 1.0). Each field is clamped to the +# local range of a bob of the other field; that is done twice, each time +# limited to how much the frame differs from its neighbours, and the two +# results averaged. It works on the whole frame by design (it is a cross-field +# filter), so it never takes the per-field path. Every operation is a min/max +# or a ratio, so nothing needs rescaling for >8-bit sources. +_stx_tff = {{DEBLOCK_STINX_TFF}} +_stx_field = 3 if _stx_tff else 2 + + +def _stx_morpho(c, rx, ry, grow): + # vsmasktools' Morpho.expand/inpand: a (2rx+1)x(2ry+1) rectangle. + f = core.std.Maximum if grow else core.std.Minimum + for i in range(max(rx, ry)): + h, v = i < rx, i < ry + coords = [1] * 8 if (h and v) else ([0, 0, 0, 1, 1, 0, 0, 0] if h else [0, 1, 0, 0, 0, 0, 1, 0]) + c = f(c, coordinates=coords) + return c + + +def _stx_weave(c): + return core.std.DoubleWeave(c, tff=_stx_tff)[::2] + + +def _stx_fields(c): + # _FieldBased=0 so SeparateFields honours tff (a set 1/2 overrides it). + return core.std.SeparateFields(core.std.SetFieldBased(c, 0), tff=_stx_tff) + + +def _stx_bob(c): + c = core.std.SetFieldBased(c, 2 if _stx_tff else 1) + return core.bwdif.Bwdif(c, field=_stx_field, edeint=_nnedi3(c, field=_stx_field)) + + +def _stx_crossfield(c, bobbed): + lo, hi = _stx_morpho(bobbed, 2, 2, False), _stx_morpho(bobbed, 2, 2, True) + rep = _expr([core.std.Interleave([c, c]), lo, hi], 'x y max z min') + return _stx_weave(core.std.SelectEvery(_stx_fields(rep), 4, (2, 1))) + + +def _stx_limit(src, flt, adj): + d = _expr([core.std.Interleave([src, src]), adj], 'x y - abs') + d = _stx_fields(d) + d = _expr([core.std.SelectEvery(d, 4, (0, 1)), core.std.SelectEvery(d, 4, (2, 3))], 'x y min') + d = _stx_weave(_stx_morpho(d, 2, 1, True)) + return _expr([flt, src, d], 'x y z - max y z + min') + + +clip = core.std.SetFieldBased(clip, 0) +_stx_adj = core.std.Interleave([clip[0] + clip[:-1], clip[1:] + clip[-1]]) +_stx_1 = _stx_limit(clip, _stx_crossfield(clip, _stx_bob(clip)), _stx_adj) +_stx_2 = _stx_limit(_stx_1, _stx_crossfield(_stx_1, _stx_bob(_stx_1)), _stx_adj) +clip = core.std.SetFieldBased(core.std.Merge(_stx_1, _stx_2), 0) +{{/DEBLOCK_MPEG2STINX}} {{#DEBLOCK_PER_FIELD}} if _deblock_per_field: clip = core.std.DoubleWeave(clip, tff={{DEBLOCK_TFF}})[::2] @@ -1378,9 +1539,11 @@ clip = haf.daa(clip) {{#AA_SANTIAG}} # santiag - stronger anti-aliasing with independent horizontal and vertical -# strength, so it can be aimed at one axis. `type` is pinned to nnedi3: havsfunc -# also accepts eedi2 and sangnom, neither of which is in the deps bundle, and -# naming an absent one fails at script evaluation with a bare "no attribute". +# strength, so it can be aimed at one axis. `type` is pinned to nnedi3: eedi2 is +# not in the deps bundle, and havsfunc's `sangnom` type calls SangNom with +# order=0, which SangNom r45 treats as double-rate (it refuses the clip without +# _FieldBased, and would double the frame count with it). SangNom has its own +# method below. clip = haf.santiag( clip, strh={{AA_STRH}}, @@ -1388,6 +1551,20 @@ clip = haf.santiag( type="{{AA_TYPE}}", ) {{/AA_SANTIAG}} +{{#AA_SANGNOM}} +# SangNom AA: supersample 2x, run SangNom along each axis keeping the even +# lines (order=1: same rate, no shift) and come back down. Much faster than +# santiag (45 vs 17 fps at SD on an M1 Pro) and the sharper of the three. `aa` +# is in 8-bit units at every depth: the plugin scales it itself (an 8-bit and +# a 10-bit run agree to 0.05 levels). SangNom flags its output progressive, so +# the field order set above is put back. +_aa_w, _aa_h = clip.width, clip.height +_aa = core.resize.Spline36(clip, _aa_w * 2, _aa_h * 2) +_aa = core.sangnom.SangNom(_aa, order=1, aa={{AA_SANGNOM_AA}}) +_aa = core.sangnom.SangNom(core.std.Transpose(_aa), order=1, aa={{AA_SANGNOM_AA}}) +clip = core.resize.Spline36(core.std.Transpose(_aa), _aa_w, _aa_h) +clip = core.std.SetFieldBased(clip, {{AA_FIELD_BASED_VALUE}}) +{{/AA_SANGNOM}} {{/ANTI_ALIAS}} # ============================================================================ diff --git a/worker/templates/preview_template.vpy b/worker/templates/preview_template.vpy index 1b97bb8d..218f8ec8 100644 --- a/worker/templates/preview_template.vpy +++ b/worker/templates/preview_template.vpy @@ -426,6 +426,48 @@ clip = core.vivtc.VFM(_ivtc_metrics, order={{IVTC_ORDER}}, blocky={{IVTC_BLOCK_Y}}, {{/IVTC_BLOCK_Y}} clip2=_ivtc_src) +{{#DEINT_IVTC_FIX_FADES}} +# Fades added after telecine (vs-jetpack's FixInterlacedFades, MIT, ported from +# release v3.0.0; upstream calls it an improved Fix-Telecined-Fades). A fade +# applied to the telecined video changes brightness between the two fields of +# a frame, so no field match is clean and the fade combs. Each field is scaled +# about black (neutral for chroma) so its mean distance from it equals the +# {{IVTC_FADE_MODE}} of the two fields': exact for fades to or from black, and +# blur-free. On frames that are not fading the two fields' means agree and the +# change is negligible (measured ~0.1 levels). Float, as upstream requires; +# FrameEval with constants rather than akarin's frame-property Expr, which +# macOS x64 lacks. +_fade_fmt = clip.format +_fade_f = core.resize.Point(clip, format=_fade_fmt.replace(sample_type=vs.FLOAT, bits_per_sample=32).id) +_fade_fields = core.std.SeparateFields(core.std.SetFieldBased(_fade_f, 0), tff=True) +_fade_dist = _expr([_fade_fields], 'x abs') +for _fade_i in range(_fade_fmt.num_planes): + _fade_dist = core.std.PlaneStats(_fade_dist, plane=_fade_i, prop=f'P{_fade_i}') + + +def _fade_scale(n, f, c, own, other): + gains = [] + for i in range(_fade_fmt.num_planes): + a, b = f[own].props[f'P{i}Average'], f[other].props[f'P{i}Average'] + target = (a + b) / 2 if '{{IVTC_FADE_MODE}}' == 'average' else min(a, b) + gains.append(target / a if a > 1e-6 else 1.0) + if all(abs(g - 1.0) < 1e-6 for g in gains): + return c + return _expr([c], [f'x {g} *' for g in gains]) + + +_fade_top, _fade_bot = _fade_fields[::2], _fade_fields[1::2] +_fade_props = [_fade_dist[::2], _fade_dist[1::2]] +_fade_top = core.std.FrameEval(_fade_top, functools.partial(_fade_scale, c=_fade_top, own=0, other=1), + prop_src=_fade_props) +_fade_bot = core.std.FrameEval(_fade_bot, functools.partial(_fade_scale, c=_fade_bot, own=1, other=0), + prop_src=_fade_props) +clip = core.std.DoubleWeave(core.std.Interleave([_fade_top, _fade_bot]), tff=True)[::2] +# DoubleWeave flags its output interlaced; field-matched frames are +# progressive, and a later resize would otherwise treat them as fields. +clip = core.std.SetFieldBased(clip, 0) +clip = core.resize.Point(clip, format=_fade_fmt.id, dither_type="error_diffusion") +{{/DEINT_IVTC_FIX_FADES}} {{/DEINT_IVTC}} {{/DEINTERLACE}} @@ -924,6 +966,20 @@ clip = core.knlm.KNLMeansCL(clip, d={{NR_KNLM_D}}, a={{NR_KNLM_A}}, s={{NR_KNLM_ if {{NR_KNLM_CHROMA}} and clip.format.color_family == vs.YUV: clip = core.knlm.KNLMeansCL(clip, d={{NR_KNLM_D}}, a={{NR_KNLM_A}}, s={{NR_KNLM_S}}, h={{NR_KNLM_H}}, channels="UV", device_type="{{NR_KNLM_DEVICE}}") {{/NR_KNLM}} +{{#NR_NLM}} +# NLMeans on the CPU (vszip >= 24.0.0): a port of KNLMeansCL with its +# arguments and defaults, so it needs no OpenCL device. Same output as +# KNLMeansCL for the same arguments (measured: identical to the level on the +# same input). Every depth natively; h is depth-independent (an 8-bit and a +# 10-bit run agree to ~0.2 levels). Chroma in its own pass, as for KNLMeansCL: +# channels="YUV" needs 4:4:4. +if clip.format.color_family == vs.YUV: + clip = core.vszip.NLMeans(clip, d={{NR_NLM_D}}, a={{NR_NLM_A}}, s={{NR_NLM_S}}, h={{NR_NLM_H}}, channels="Y") + if {{NR_NLM_CHROMA}}: + clip = core.vszip.NLMeans(clip, d={{NR_NLM_D}}, a={{NR_NLM_A}}, s={{NR_NLM_S}}, h={{NR_NLM_H}}, channels="UV") +else: + clip = core.vszip.NLMeans(clip, d={{NR_NLM_D}}, a={{NR_NLM_A}}, s={{NR_NLM_S}}, h={{NR_NLM_H}}) +{{/NR_NLM}} {{#NR_MCLEAN}} # mClean — denoise, then put detail and grain back, so the result does not look # plastic. The only method here that is a goal rather than a mechanism. @@ -1190,6 +1246,53 @@ clip = haf.HQDeringmod( {{/DEHALO_DERING_DARKTHR}} ) {{/DEHALO_HQDERING}} +{{#DEHALO_VINE}} +# vine: dehalo by non-local-means filtering of the error (vs-jetpack's +# vine_dehalo, MIT, ported from release v3.0.0). Luma only, as upstream. +# 1. NLMeans (single-pixel similarity, s=0) on a 2x NNEDI3 supersample and on +# the picture itself, blended by a weight derived from `sharp`; +# 2. keep the source's low frequencies and the smoothed picture's high ones +# (a Gaussian split at `sigma`); +# 3. NLMeans the difference between the source and that, guided by it, and +# add it back: the halo goes, the edge detail it was riding on returns. +# The constants are upstream's. NLMeans' h is depth-independent, so nothing is +# rescaled for >8-bit sources (8-bit and 10-bit runs agree to within rounding). +import math as _vine_math + + +def _vine_gauss(c, sigma): + r = max(1, min(12, int(_vine_math.ceil(sigma * 3)))) + k = [_vine_math.exp(-(x * x) / (2.0 * sigma * sigma)) for x in range(-r, r + 1)] + return core.std.Convolution(core.std.Convolution(c, matrix=k, mode='h'), matrix=k, mode='v') + + +_vine_strength = {{DEHALO_VINE_STRENGTH}} +_vine_sharp = {{DEHALO_VINE_SHARP}} +_vine_sigma = {{DEHALO_VINE_SIGMA}} +_vine_c0 = 0.3926327792690057290863679493724 * min(_vine_sharp, 1.0) +_vine_weight = _vine_c0 * _vine_math.log(1 + 1 / _vine_c0) +_vine_h_refine = 18.880334973195822973214959957208 * ( + _vine_strength / 18.880334973195822973214959957208) ** 0.5862453661304626725671053478676 +_vine_y = core.std.ShufflePlanes(clip, 0, vs.GRAY) if clip.format.num_planes > 1 else clip +# 2x supersample: NNEDI3 keeping the source lines on the even rows (field=1), +# then the half-pixel shift that leaves is folded into the Catrom downscale. +_vine_ss = _nnedi3(_vine_y, field=1, dh=True) +_vine_ss = core.std.Transpose(_nnedi3(core.std.Transpose(_vine_ss), field=1, dh=True)) +_vine_ss = core.vszip.NLMeans(_vine_ss, d=0, a=2, s=0, h=_vine_strength) +_vine_ss = core.resize.Bicubic(_vine_ss, _vine_y.width, _vine_y.height, filter_param_a=0, + filter_param_b=0.5, src_left=-0.5, src_top=-0.5) +_vine_sm = core.vszip.NLMeans(_vine_y, d=0, a=2, s=0, h=_vine_strength) +_vine_sm = core.std.Merge(_vine_ss, _vine_sm, _vine_weight) +_vine_high = core.std.MergeDiff(_vine_gauss(_vine_y, _vine_sigma), + core.std.MakeDiff(_vine_sm, _vine_gauss(_vine_sm, _vine_sigma))) +_vine_ref = core.vszip.NLMeans(core.std.MakeDiff(_vine_y, _vine_high), d=0, a=2, s=4, + h=_vine_h_refine, rclip=_vine_high) +_vine_y = core.std.MergeDiff(_vine_high, _vine_ref) +if clip.format.num_planes > 1: + clip = core.std.ShufflePlanes([_vine_y, clip], [0, 1, 2], clip.format.color_family) +else: + clip = _vine_y +{{/DEHALO_VINE}} {{/DEHALO}} # ============================================================================ @@ -1252,6 +1355,64 @@ clip = core.dctf.DCTFilter( planes={{DEBLOCK_DCT_PLANES}}, ) {{/DEBLOCK_DCTFILTER}} +{{#DEBLOCK_MPEG2STINX}} +# mpeg2stinx: removes the comb-like noise a film DVD picks up when its +# progressive frames were encoded as interlaced MPEG-2 (vs-jetpack's +# mpeg2stinx, MIT, ported from release v3.0.0, with its defaults: NNEDI3 bob +# motion-masked by BWDIF, radius 2, limit 1.0). Each field is clamped to the +# local range of a bob of the other field; that is done twice, each time +# limited to how much the frame differs from its neighbours, and the two +# results averaged. It works on the whole frame by design (it is a cross-field +# filter), so it never takes the per-field path. Every operation is a min/max +# or a ratio, so nothing needs rescaling for >8-bit sources. +_stx_tff = {{DEBLOCK_STINX_TFF}} +_stx_field = 3 if _stx_tff else 2 + + +def _stx_morpho(c, rx, ry, grow): + # vsmasktools' Morpho.expand/inpand: a (2rx+1)x(2ry+1) rectangle. + f = core.std.Maximum if grow else core.std.Minimum + for i in range(max(rx, ry)): + h, v = i < rx, i < ry + coords = [1] * 8 if (h and v) else ([0, 0, 0, 1, 1, 0, 0, 0] if h else [0, 1, 0, 0, 0, 0, 1, 0]) + c = f(c, coordinates=coords) + return c + + +def _stx_weave(c): + return core.std.DoubleWeave(c, tff=_stx_tff)[::2] + + +def _stx_fields(c): + # _FieldBased=0 so SeparateFields honours tff (a set 1/2 overrides it). + return core.std.SeparateFields(core.std.SetFieldBased(c, 0), tff=_stx_tff) + + +def _stx_bob(c): + c = core.std.SetFieldBased(c, 2 if _stx_tff else 1) + return core.bwdif.Bwdif(c, field=_stx_field, edeint=_nnedi3(c, field=_stx_field)) + + +def _stx_crossfield(c, bobbed): + lo, hi = _stx_morpho(bobbed, 2, 2, False), _stx_morpho(bobbed, 2, 2, True) + rep = _expr([core.std.Interleave([c, c]), lo, hi], 'x y max z min') + return _stx_weave(core.std.SelectEvery(_stx_fields(rep), 4, (2, 1))) + + +def _stx_limit(src, flt, adj): + d = _expr([core.std.Interleave([src, src]), adj], 'x y - abs') + d = _stx_fields(d) + d = _expr([core.std.SelectEvery(d, 4, (0, 1)), core.std.SelectEvery(d, 4, (2, 3))], 'x y min') + d = _stx_weave(_stx_morpho(d, 2, 1, True)) + return _expr([flt, src, d], 'x y z - max y z + min') + + +clip = core.std.SetFieldBased(clip, 0) +_stx_adj = core.std.Interleave([clip[0] + clip[:-1], clip[1:] + clip[-1]]) +_stx_1 = _stx_limit(clip, _stx_crossfield(clip, _stx_bob(clip)), _stx_adj) +_stx_2 = _stx_limit(_stx_1, _stx_crossfield(_stx_1, _stx_bob(_stx_1)), _stx_adj) +clip = core.std.SetFieldBased(core.std.Merge(_stx_1, _stx_2), 0) +{{/DEBLOCK_MPEG2STINX}} {{#DEBLOCK_PER_FIELD}} if _deblock_per_field: clip = core.std.DoubleWeave(clip, tff={{DEBLOCK_TFF}})[::2] @@ -1310,9 +1471,11 @@ clip = haf.daa(clip) {{#AA_SANTIAG}} # santiag - stronger anti-aliasing with independent horizontal and vertical -# strength, so it can be aimed at one axis. `type` is pinned to nnedi3: havsfunc -# also accepts eedi2 and sangnom, neither of which is in the deps bundle, and -# naming an absent one fails at script evaluation with a bare "no attribute". +# strength, so it can be aimed at one axis. `type` is pinned to nnedi3: eedi2 is +# not in the deps bundle, and havsfunc's `sangnom` type calls SangNom with +# order=0, which SangNom r45 treats as double-rate (it refuses the clip without +# _FieldBased, and would double the frame count with it). SangNom has its own +# method below. clip = haf.santiag( clip, strh={{AA_STRH}}, @@ -1320,6 +1483,20 @@ clip = haf.santiag( type="{{AA_TYPE}}", ) {{/AA_SANTIAG}} +{{#AA_SANGNOM}} +# SangNom AA: supersample 2x, run SangNom along each axis keeping the even +# lines (order=1: same rate, no shift) and come back down. Much faster than +# santiag (45 vs 17 fps at SD on an M1 Pro) and the sharper of the three. `aa` +# is in 8-bit units at every depth: the plugin scales it itself (an 8-bit and +# a 10-bit run agree to 0.05 levels). SangNom flags its output progressive, so +# the field order set above is put back. +_aa_w, _aa_h = clip.width, clip.height +_aa = core.resize.Spline36(clip, _aa_w * 2, _aa_h * 2) +_aa = core.sangnom.SangNom(_aa, order=1, aa={{AA_SANGNOM_AA}}) +_aa = core.sangnom.SangNom(core.std.Transpose(_aa), order=1, aa={{AA_SANGNOM_AA}}) +clip = core.resize.Spline36(core.std.Transpose(_aa), _aa_w, _aa_h) +clip = core.std.SetFieldBased(clip, {{AA_FIELD_BASED_VALUE}}) +{{/AA_SANGNOM}} {{/ANTI_ALIAS}} # ============================================================================ diff --git a/worker/tests/filter_integration_test.rs b/worker/tests/filter_integration_test.rs index 89c94cb9..53b6887e 100644 --- a/worker/tests/filter_integration_test.rs +++ b/worker/tests/filter_integration_test.rs @@ -4022,8 +4022,9 @@ fn test_105_anti_alias_santiag() { #[test] fn test_106_santiag_type_cannot_name_an_unbundled_interpolator() { - // havsfunc accepts eedi2 and sangnom; neither is in the deps bundle, and - // naming one fails at script evaluation rather than degrading. + // havsfunc accepts eedi2 and sangnom. eedi2 is not in the deps bundle, and + // havsfunc's sangnom type calls SangNom(order=0), which SangNom r45 refuses + // without _FieldBased; either fails at script evaluation. create_output_dir(); let mut job = create_base_job("test_106_aa_santiag_type"); job.qtgmc_parameters.enabled = false; @@ -4325,6 +4326,7 @@ fn test_115_every_noise_method_still_emits_only_its_own_filter() { ("stpresso", "haf.STPresso"), ("bm3d", "core.bm3d."), ("knlm", "core.knlm.KNLMeansCL("), + ("nlm", "core.vszip.NLMeans("), ]; let methods = [ @@ -4338,6 +4340,7 @@ fn test_115_every_noise_method_still_emits_only_its_own_filter() { (NoiseReductionMethod::StPresso, "stpresso"), (NoiseReductionMethod::Bm3d, "bm3d"), (NoiseReductionMethod::KnlMeans, "knlm"), + (NoiseReductionMethod::NlMeans, "nlm"), ]; for (method, key) in methods { @@ -5467,6 +5470,7 @@ fn test_149_every_noise_reduction_method_emits_its_filter() { (NoiseReductionMethod::TemporalDegrain2, "_TemporalDegrain2("), (NoiseReductionMethod::Bm3d, "core.bm3d.VBasic("), (NoiseReductionMethod::KnlMeans, "core.knlm.KNLMeansCL("), + (NoiseReductionMethod::NlMeans, "core.vszip.NLMeans("), ]; // The list above is hand-written; this match is not. A new method fails to // compile here until it is added both here and to the list. @@ -5475,11 +5479,11 @@ fn test_149_every_noise_reduction_method_emits_its_filter() { match m { SmDegrain | McTemporalDenoise | McDegrainSharp | DfTtest | Fft3dFilter | TTempSmooth | FluxSmoothT | FluxSmoothSt | StPresso | Ctmf | MClean - | TemporalDegrain2 | Bm3d | KnlMeans => true, + | TemporalDegrain2 | Bm3d | KnlMeans | NlMeans => true, QtgmcBuiltin => false, // denoises inside the QTGMC pass } } - assert_eq!(expected.len(), 14); + assert_eq!(expected.len(), 15); assert!(expected.iter().all(|(m, _)| listed(*m))); for (method, call) in expected { @@ -6818,6 +6822,8 @@ fn test_183_deblock_per_field_when_still_interlaced() { DeblockMethod::DeblockQed => "haf.Deblock_QED(", DeblockMethod::Deblock => "core.deblock.Deblock(", DeblockMethod::DctFilter => "core.dctf.DCTFilter(", + // Never per field; test_203's. + DeblockMethod::Mpeg2Stinx => unreachable!("mpeg2stinx takes the whole frame"), }; for method in &[DeblockMethod::DeblockQed, DeblockMethod::Deblock, DeblockMethod::DctFilter] { @@ -6879,3 +6885,271 @@ fn test_184_flowfps_length_is_set_by_the_script() { assert!(call < trim && call < pad, "{name}: length set before FlowFPS"); } } + + +// =========================================================================== +// deps 1.16.0: SangNom, vszip NLMeans, and the vs-jetpack ports (app 1.7.0) +// =========================================================================== + +/// SangNom AA: its own block, `aa` clamped to the plugin's 0-128, the field +/// order put back afterwards, and neither of the other methods emitted. +#[test] +fn test_200_anti_alias_sangnom() { + create_output_dir(); + let job_with = |aa: i32| { + let mut job = create_base_job("test_200_aa_sangnom"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + anti_alias: AntiAliasParameters { + enabled: true, + method: AntiAliasMethod::SangNom, + sangnom_aa: aa, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + job + }; + let (encode, preview) = generate_both_scripts(&job_with(64)); + for (name, script) in [("encode", &encode), ("preview", &preview)] { + assert!(script.contains("_aa = core.sangnom.SangNom(_aa, order=1, aa=64)"), "{name}: first axis"); + assert!( + script.contains("_aa = core.sangnom.SangNom(core.std.Transpose(_aa), order=1, aa=64)"), + "{name}: second axis" + ); + assert!(script.contains("core.resize.Spline36(clip, _aa_w * 2, _aa_h * 2)"), "{name}: supersample"); + let sangnom = script.find("core.sangnom.SangNom(").unwrap(); + let restore = script.rfind("clip = core.std.SetFieldBased(clip, ").unwrap(); + assert!(restore > sangnom, "{name}: field order not put back after SangNom"); + assert!(!script.contains("haf.daa("), "{name}: daa emitted too"); + assert!(!script.contains("haf.santiag("), "{name}: santiag emitted too"); + assert!(!script.contains("{{AA_"), "{name}: placeholder left"); + } + assert!(script_text(&job_with(500)).contains("order=1, aa=128)")); + assert!(script_text(&job_with(-3)).contains("order=1, aa=0)")); +} + +/// NLMeans (CPU): vszip's NLMeans with the knlm_* values, chroma in its own +/// pass, and it runs where KNLMeansCL is refused for want of a device. +#[test] +fn test_201_noise_reduction_nlmeans_cpu() { + create_output_dir(); + let job = nr_job("test_201_nlm", NoiseReductionParameters { + method: NoiseReductionMethod::NlMeans, + knlm_h: 2.5, + knlm_d: 2, + knlm_a: 3, + knlm_s: 1, + knlm_chroma: true, + ..Default::default() + }); + // No OpenCL device at all: NLMeans must not care. + let message = vapourbox_worker::dependency_locator::KNLM_NO_DEVICE_MESSAGE; + let generator = ScriptGenerator::new().unwrap().with_knlm(Err(message.into())); + let (encode, preview) = both_scripts_with(&generator, &job).expect("NLMeans needs no OpenCL"); + for (name, script) in [("encode", &encode), ("preview", &preview)] { + assert!(script.contains( + "clip = core.vszip.NLMeans(clip, d=2, a=3, s=1, h=2.50, channels=\"Y\")"), "{name}: luma"); + assert!(script.contains( + "clip = core.vszip.NLMeans(clip, d=2, a=3, s=1, h=2.50, channels=\"UV\")"), "{name}: chroma"); + assert!(script.contains(" if True:"), "{name}: chroma switch"); + assert!(!script.contains("core.knlm.KNLMeansCL("), "{name}: KNLMeansCL emitted"); + assert!(!script.contains("core.bm3d."), "{name}: BM3D emitted"); + assert!(!script.contains("{{NR_NLM"), "{name}: placeholder left"); + } + // The refusal names the CPU method as the way out. + assert!(message.contains("NLMeans (CPU)"), "{message}"); + // Same preview radius as KNLMeans: d frames each side, at least 3. + let mut nr = NoiseReductionParameters { method: NoiseReductionMethod::NlMeans, knlm_d: 4, ..Default::default() }; + assert_eq!(nr.temporal_radius(), 4); + nr.knlm_d = 1; + assert_eq!(nr.temporal_radius(), 3); +} + +/// vine: the ported block with the clamped values, through the helpers. +#[test] +fn test_202_dehalo_vine() { + create_output_dir(); + let job_with = |strength: f64, sharp: f64, sigma: f64| { + let mut job = create_base_job("test_202_vine"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + dehalo: DehaloParameters { + enabled: true, + method: DehaloMethod::Vine, + vine_strength: strength, + vine_sharp: sharp, + vine_sigma: sigma, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + job + }; + let (encode, preview) = generate_both_scripts(&job_with(24.0, 0.25, 1.5)); + for (name, script) in [("encode", &encode), ("preview", &preview)] { + assert!(script.contains("_vine_strength = 24.000"), "{name}"); + assert!(script.contains("_vine_sharp = 0.250"), "{name}"); + assert!(script.contains("_vine_sigma = 1.500"), "{name}"); + assert!(script.contains("_nnedi3(_vine_y, field=1, dh=True)"), "{name}: supersample"); + assert!(script.contains("core.vszip.NLMeans(_vine_ss, d=0, a=2, s=0, h=_vine_strength)"), "{name}"); + assert!(script.contains("rclip=_vine_high"), "{name}: refine pass"); + assert!(!script.contains("haf.DeHalo_alpha("), "{name}: other method emitted"); + assert!(!script.contains("{{DEHALO_VINE"), "{name}: placeholder left"); + } + // NLMeans refuses h <= 0; vs-jetpack refuses sharp <= 0; 25 taps max. + let clamped = script_text(&job_with(0.0, -1.0, 99.0)); + assert!(clamped.contains("_vine_strength = 1.000")); + assert!(clamped.contains("_vine_sharp = 0.050")); + assert!(clamped.contains("_vine_sigma = 4.000")); + let nan = script_text(&job_with(f64::NAN, f64::INFINITY, f64::NAN)); + assert!(nan.contains("_vine_strength = 16.000") && nan.contains("_vine_sharp = 0.500")); +} + +/// mpeg2stinx: always the whole frame (it is a cross-field filter), with the +/// encode's field order, and a preview window of one frame each side. +#[test] +fn test_203_deblock_mpeg2stinx() { + create_output_dir(); + let job_for = |deinterlace: bool, detected: Option| { + let mut job = create_base_job("test_203_mpeg2stinx"); + job.detected_field_order = detected; + job.qtgmc_parameters.enabled = deinterlace; + job.qtgmc_parameters.tff = Some(false); + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: job.qtgmc_parameters.clone(), + deblock: DeblockParameters { enabled: true, method: DeblockMethod::Mpeg2Stinx, ..Default::default() }, + ..ProcessingPipeline::default() + }); + job + }; + for (job, tff) in [ + (job_for(false, Some(FieldOrder::BottomFieldFirst)), "False"), + (job_for(false, Some(FieldOrder::TopFieldFirst)), "True"), + (job_for(false, None), "True"), + (job_for(true, Some(FieldOrder::TopFieldFirst)), "False"), // the deinterlace pass's order wins + ] { + let (encode, preview) = generate_both_scripts(&job); + for (name, script) in [("encode", &encode), ("preview", &preview)] { + assert!(script.contains(&format!("_stx_tff = {tff}")), "{name}: field order {tff}"); + assert!(script.contains("core.bwdif.Bwdif(c, field=_stx_field, edeint=_nnedi3(c, field=_stx_field))")); + assert!(script.contains("clip = core.std.SetFieldBased(core.std.Merge(_stx_1, _stx_2), 0)")); + assert!(!script.contains("_deblock_per_field"), "{name}: mpeg2stinx must not run per field"); + assert!(!script.contains("haf.Deblock_QED(") && !script.contains("core.dctf.DCTFilter(")); + assert!(!script.contains("{{DEBLOCK"), "{name}: placeholder left"); + } + } + let p = job_for(false, None).processing_pipeline.unwrap(); + let deblock_map = p + .enabled_passes() + .into_iter() + .zip(p.frame_maps()) + .find(|(pass, _)| *pass == PassType::Deblock) + .map(|(_, m)| m) + .unwrap(); + assert_eq!(deblock_map, FrameMap::Identity { radius: 1 }); +} + +/// The IVTC fade fix: right after VFM in both scripts when ticked, absent +/// otherwise and for every other deinterlace method. +#[test] +fn test_204_ivtc_fix_fades() { + create_output_dir(); + let job_for = |method: DeinterlaceMethod, fix: bool, mode: &str| { + let mut job = create_base_job("test_204_ivtc_fades"); + job.qtgmc_parameters = QTGMCParameters { + enabled: true, + method, + tff: Some(true), + ivtc_fix_fades: fix, + ivtc_fade_mode: mode.to_string(), + ..Default::default() + }; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: job.qtgmc_parameters.clone(), + ..ProcessingPipeline::default() + }); + job + }; + for (mode, expect) in [("average", "average"), ("match", "match"), ("MATCH", "match"), ("bogus", "average")] { + let (encode, preview) = generate_both_scripts(&job_for(DeinterlaceMethod::Ivtc, true, mode)); + for (name, script) in [("encode", &encode), ("preview", &preview)] { + let vfm = script.find("core.vivtc.VFM(").expect("VFM"); + let fix = script.find("_fade_fields = core.std.SeparateFields(").unwrap_or_else(|| panic!("{name}: no fix")); + assert!(fix > vfm, "{name}: the fix must follow field matching"); + assert!(script.contains(&format!("if '{expect}' == 'average' else min(a, b)")), "{name}: mode {mode}"); + if let Some(decimate) = script.find("core.vivtc.VDecimate(") { + assert!(fix < decimate, "{name}: before decimation"); + } + assert!(!script.contains("{{IVTC_FADE_MODE}}") && !script.contains("DEINT_IVTC_FIX_FADES")); + } + } + for job in [ + job_for(DeinterlaceMethod::Ivtc, false, "average"), + job_for(DeinterlaceMethod::Qtgmc, true, "average"), + ] { + let (encode, preview) = generate_both_scripts(&job); + for script in [&encode, &preview] { + assert!(!script.contains("_fade_fields"), "fade fix emitted when not asked for"); + assert!(!script.contains("DEINT_IVTC_FIX_FADES"), "marker left"); + } + } + let p: QTGMCParameters = serde_json::from_str("{}").unwrap(); + assert!(!p.ivtc_fix_fades); + assert_eq!(p.effective_ivtc_fade_mode(), "average"); +} + +/// Every block added for deps 1.16.0 goes through the templates' helpers +/// (test_92 / test_93 enforce the rule template-wide; this pins it to the new +/// code paths by generating each one) and leaves no placeholder behind. +#[test] +fn test_205_new_blocks_use_the_helpers() { + create_output_dir(); + let base = || { + let mut job = create_base_job("test_205_helpers"); + job.qtgmc_parameters.enabled = false; + job + }; + let pipelines = [ + ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + anti_alias: AntiAliasParameters { enabled: true, method: AntiAliasMethod::SangNom, ..Default::default() }, + dehalo: DehaloParameters { enabled: true, method: DehaloMethod::Vine, ..Default::default() }, + deblock: DeblockParameters { enabled: true, method: DeblockMethod::Mpeg2Stinx, ..Default::default() }, + noise_reduction: NoiseReductionParameters { + enabled: true, + method: NoiseReductionMethod::NlMeans, + ..Default::default() + }, + ..ProcessingPipeline::default() + }, + ProcessingPipeline { + deinterlace: QTGMCParameters { + enabled: true, + method: DeinterlaceMethod::Ivtc, + ivtc_fix_fades: true, + ..Default::default() + }, + ..ProcessingPipeline::default() + }, + ]; + for pipeline in pipelines { + let mut job = base(); + job.qtgmc_parameters = pipeline.deinterlace.clone(); + job.processing_pipeline = Some(pipeline); + let (encode, preview) = generate_both_scripts(&job); + for script in [&encode, &preview] { + for banned in ["core.std.Expr(", "core.znedi3.nnedi3(", "core.nnedi3.nnedi3("] { + // The helper's own body is the only call site. + let uses = script.matches(banned).count(); + assert!(uses <= 1, "{banned} called outside its helper ({uses} uses)"); + } + for marker in ["{{AA_", "NR_NLM", "DEHALO_VINE", "DEBLOCK_STINX", "DEBLOCK_MPEG2STINX", "IVTC_FADE", + "DEINT_IVTC_FIX_FADES"] { + assert!(!script.contains(marker), "{marker} left in a generated script"); + } + } + } +} From 8d9897c7cd914a11295f4c7b4859f1fbfb5c8c13 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 8 Nov 2026 18:56:29 +1100 Subject: [PATCH 22/61] fix(whisper): correct the small and large-v3-turbo model sizes HuggingFace serves ggml-small.bin at 487,601,967 bytes and ggml-large-v3-turbo.bin at 1,624,555,275 (x-linked-size). The app only uses the size for progress, but WorkerHarness's downloader enforces it, so ensureWhisper failed on the small model. Co-Authored-By: Claude Opus 5.5 --- app/assets/whisper-addon.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/whisper-addon.json b/app/assets/whisper-addon.json index 0efd468a..a650ae93 100644 --- a/app/assets/whisper-addon.json +++ b/app/assets/whisper-addon.json @@ -43,7 +43,7 @@ "small": { "url": "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin", "filename": "ggml-small.bin", - "size": 466616853 + "size": 487601967 }, "medium": { "url": "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin", @@ -53,7 +53,7 @@ "large-v3-turbo": { "url": "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin", "filename": "ggml-large-v3-turbo.bin", - "size": 1623277662 + "size": 1624555275 } } } From c54b08f84025079df647302925a8265f6a7b0a41 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 8 Nov 2026 18:56:59 +1100 Subject: [PATCH 23/61] docs(face-restore): CLAUDE.md rules and key files for Face Restoration Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 092e2396..fa1fb9d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,7 +121,9 @@ VapourBox/ | `worker/src/animejanai.rs` | AnimeJaNai's add-on models: where they live, which one a job runs, the refusal when they're missing (see "Add-ons") | | `worker/src/addons.rs` | Manifest add-ons (compiled in from `app/assets/addons/` by `worker/build.rs`): install state, `require`, `addon_dir` (see "Add-ons", docs/ADDONS.md) | | `worker/src/ai_runtime.rs` | The AI runtime add-on's `--probe-ai-runtime`: runs the self-test model through `templates/ai_runtime.py`, classifies failures | -| `worker/templates/ai_runtime.py` | ONNX Runtime helpers for passes: `setup`, `session` (provider choice), `map_frames`, NCHW frame conversion | +| `worker/templates/ai_runtime.py` | ONNX Runtime helpers for passes: `setup`, `session` (provider choice), `map_frames`, NCHW frame conversion, `to_rgbs`/`from_rgbs`, `shifted`, `FrameMemo`, `Runner` | +| `worker/templates/face_restore.py` | The Face Restoration pass: RetinaFace decode, FFHQ alignment, restore, BiSeNet mask, temporal smoothing, bit-exact merge (see "Face Restoration") | +| `worker/src/models/face_restore_parameters.rs` | Face Restoration's parameters, its add-ons per model, and its temporal reach | | `worker/src/comment_metadata.rs` | The source's comment tag, prefixed with the version and passed to ffmpeg as an FFMETADATA file (issue #138) | | `worker/src/hdr_metadata.rs` | HDR10 mastering display / MaxCLL read from the source for x265, and the Dolby Vision profile (see "HDR Sources and Dolby Vision") | | `worker/src/native_res.rs` | `--detect-native`: the resolution and kernel a source was upscaled from, for the Descale pass (see "Descale") | @@ -143,6 +145,7 @@ VapourBox/ | `app/lib/services/addon_service.dart` | Every optional third-party download (Whisper, AnimeJaNai): catalogue, install with requirements, remove with dependants (see "Add-ons") | | `app/lib/services/manifest_addon.dart` | `AddonManifest` + `ManifestAddonInstaller`: the generic installer for `app/assets/addons/*.json` (docs/ADDONS.md) | | `app/lib/models/ai_runtime_status.dart` | The worker's `--probe-ai-runtime` result | +| `app/lib/models/face_restore_parameters.dart` | Face Restoration's parameters and the add-ons each model needs (`faceRestoreAddonIds`) | | `app/lib/models/hdr_display.dart` | The display tone-map for the app's own source decodes (twin of `ColorMetadata::display_tonemap_filter`) and Dolby Vision detection | | `app/lib/models/gpu_plugin_status.dart` | The worker's GPU-plugin probe results, and the panels' notice decision | | `app/assets/filters/core/*.json` | Built-in filter schema definitions | @@ -1400,6 +1403,54 @@ by `AddonService.addonsDir`), only when the user asks. Rules: (heavy) installs `ai-runtime` and runs the probe. `manifest_addon_test.dart` asserts the worker (`--list-addons`) compiled in the same manifests. +### Face Restoration + +The Face Restoration pass (`FaceRestoreParameters`, `PassType::FaceRestore`, +app 1.7.0) finds faces with RetinaFace, aligns each to the FFHQ 512 template, +restores it with RestoreFormer++ (default), GFPGAN v1.4 or CodeFormer, masks +it to the face itself with BiSeNet and blends it back +(`worker/templates/face_restore.py`, on `ai_runtime`). Rules: + +- **Models are add-ons, converted by committed scripts.** `face-restoration` + (RetinaFace R50 + BiSeNet + RestoreFormer++, the base), `face-gfpgan` and + `face-codeformer` (each `requires` the base; both non-commercial, with a + `usageNote`). `Scripts/models/make_face_*.py` reproduce the ONNX files from + upstream checkpoints pinned by hash; files are hosted on `addon-models-v1`. + Static shapes (detector 640x640 padded, 512x512 faces) so Core ML takes the + whole graph. GFPGAN is exported with its **fixed noise buffers** + (upstream draws new noise per call: flicker by construction); CodeFormer's + `w` is a graph **input** (exact at w = 0). Never ParseNet (CC BY-NC-SA, from + PSFRGAN) or InsightFace's detectors (non-commercial), whatever upstreams + default to. +- **After Crop & Resize, before Sharpen** — restored at the delivered + resolution; first row of the Finishing stage. +- **A pure function of neighbouring frames, never stateful.** Neighbours come + through `ai_runtime.shifted` clips into `std.ModifyFrame`; shared work + (detections, each frame's restored faces) through `ai_runtime.FrameMemo`. + Temporal smoothing (radius 1): landmarks averaged with hysteresis and + one-frame gap filling, and each restored face averaged in aligned space with + its neighbours', weighted down where the *input* moved. The preview's + `FrameMap` radius is **twice** that (`frames_read_either_side`): the blend + reads a neighbour, whose landmarks read one more. +- **Bit-exact outside the faces**: the restored RGB frame is converted back + to the source format and composited with `std.MaskedMerge` (mask luma-sized, + `first_plane=True`), so a zero mask leaves every plane untouched. Never blend + in RGB and convert the whole frame back. +- **Model runs are serialised per pass** (`ai_runtime.Runner` with one shared + lock), with a CPU retry if a GPU run fails: concurrent Core ML predictions + from VapourSynth's threads ran a 16 GB Mac out of GPU memory. +- **NumPy only** — no OpenCV (it is in neither the deps bundle nor the + ai-runtime add-on); `pipeline_parity.py` in the report checks the NumPy + detection, alignment and paste-back against facexlib/OpenCV. +- The panel prompts for the model's add-ons (`AddonPrompt.ensure`) when the + pass is turned on or the model changes, and shows the non-commercial note + for GFPGAN/CodeFormer; the worker refuses through `required_addons()`. + +`integration_face_restore_test.dart` (heavy) checks the face changes and every +sample outside it is bit-exact (8- and 10-bit), each model runs, and preview = +encode. See docs/ENGINEERING_NOTES.md (2026-11-08) and +docs/reports/2026-11-face-restoration. + ### BM3D and KNLMeans Noise Reduction Two advanced-only Noise Reduction methods (`bm3d`, `knlmeans`, app 1.5.0; From 86d46c63a470f89ca23bd25e18538f76f2a35249 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 8 Nov 2026 18:57:47 +1100 Subject: [PATCH 24/61] docs: report on the live-action AI upscalers, AI strength and the 2xVHS2HD probe docs/reports/2026-11-ai-upscale-addons: conversion parity (PyTorch vs ONNX <= 0.017 levels; VSNCNN fp32 vs ONNX Runtime <= 0.012), PSNR on a restored broadcast frame (and why General's 2x keeps Spline36), crops, flat-colour drift and bar position per model, AI strength, speed and x4plus tiling on an M1 Pro, the tile-larger-than-frame segfault, and why 2xVHS2HD (RealPLKSR) can't run on ncnn. Scripts reproduce every number. ENGINEERING_NOTES entry, reports index row, CLAUDE.md rules, and a note in docs/ADDONS.md that VSNCNN-run add-ons need no ai-runtime. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 29 ++ Scripts/models/convert_realesrgan.py | 2 +- docs/ADDONS.md | 2 +- docs/ENGINEERING_NOTES.md | 47 +++ .../img/01-original.png | Bin 0 -> 86482 bytes .../img/02-spline36.png | Bin 0 -> 78655 bytes .../img/03-general.png | Bin 0 -> 77428 bytes .../img/04-general-denoise.png | Bin 0 -> 91670 bytes .../img/05-live-action-span.png | Bin 0 -> 85241 bytes .../img/06-x2plus.png | Bin 0 -> 89656 bytes .../img/07-anime-esrgan.png | Bin 0 -> 105313 bytes .../2026-11-ai-upscale-addons/index.html | 294 ++++++++++++++++++ .../scripts/metrics.py | 47 +++ .../2026-11-ai-upscale-addons/scripts/run.sh | 161 ++++++++++ .../scripts/upscale.vpy | 62 ++++ docs/reports/README.md | 1 + worker/src/models/crop_resize_parameters.rs | 2 +- worker/templates/pipeline_template.vpy | 2 +- worker/templates/preview_template.vpy | 2 +- 19 files changed, 646 insertions(+), 5 deletions(-) create mode 100644 docs/reports/2026-11-ai-upscale-addons/img/01-original.png create mode 100644 docs/reports/2026-11-ai-upscale-addons/img/02-spline36.png create mode 100644 docs/reports/2026-11-ai-upscale-addons/img/03-general.png create mode 100644 docs/reports/2026-11-ai-upscale-addons/img/04-general-denoise.png create mode 100644 docs/reports/2026-11-ai-upscale-addons/img/05-live-action-span.png create mode 100644 docs/reports/2026-11-ai-upscale-addons/img/06-x2plus.png create mode 100644 docs/reports/2026-11-ai-upscale-addons/img/07-anime-esrgan.png create mode 100644 docs/reports/2026-11-ai-upscale-addons/index.html create mode 100644 docs/reports/2026-11-ai-upscale-addons/scripts/metrics.py create mode 100644 docs/reports/2026-11-ai-upscale-addons/scripts/run.sh create mode 100644 docs/reports/2026-11-ai-upscale-addons/scripts/upscale.vpy diff --git a/CLAUDE.md b/CLAUDE.md index 092e2396..fc788d7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,6 +119,7 @@ VapourBox/ | `worker/src/source_timing.rs` | A variable-frame-rate source's stored frames placed on the constant-rate grid (see "Variable Frame Rate Sources") | | `worker/src/gpu_plugin.rs` | The ncnn-on-Vulkan plugins (RIFE, AI upscaling): file layout, the `--probe-rife` / `--probe-upscale` probe, and the user-facing reason/remedy when they can't run (see "GPU Plugins") | | `worker/src/animejanai.rs` | AnimeJaNai's add-on models: where they live, which one a job runs, the refusal when they're missing (see "Add-ons") | +| `worker/src/upscale_addons.rs` | The manifest add-on upscalers (Real-ESRGAN General, Live Action SPAN, Real-ESRGAN x4plus/x2plus): which add-on holds which model, each network's own factor (see "GPU Plugins") | | `worker/src/addons.rs` | Manifest add-ons (compiled in from `app/assets/addons/` by `worker/build.rs`): install state, `require`, `addon_dir` (see "Add-ons", docs/ADDONS.md) | | `worker/src/ai_runtime.rs` | The AI runtime add-on's `--probe-ai-runtime`: runs the self-test model through `templates/ai_runtime.py`, classifies failures | | `worker/templates/ai_runtime.py` | ONNX Runtime helpers for passes: `setup`, `session` (provider choice), `map_frames`, NCHW frame conversion | @@ -1273,6 +1274,34 @@ than fail cleanly. `ensure_methods_ready` also refuses it when `animejanai::runtime` finds the models missing (`MISSING_MESSAGE`). It keeps flat colour within 0.5 levels (Real-ESRGAN: up to 11). +- **Live-action models are manifest add-ons on the same block** (app 1.7.0, + `upscale_addons.rs`): `upscale-general` — Real-ESRGAN General + (`realesr-general-x4v3`, optional denoise variant `-wdn-`) and jcj83429's + 2xLiveActionV1 SPAN (Apache-2.0, upstream's ONNX pinned at a commit) — and + `upscale-esrgan-plus` — Real-ESRGAN x4plus/x2plus. Real-ESRGAN publishes + `.pth` only: `Scripts/models/convert_realesrgan.py` converts (opset 13, + x2plus's `pixel_unshuffle` as a one-hot stride-2 conv, which ncnn imports) + and we host the ONNX on `addon-models-v1`. They run on VSNCNN, so they need + no `ai-runtime` and exist exactly where VSNCNN ships (asserted). Declared in + `required_addons()` via `UpscaleMethod::addon`; the panel prompts with + `upscaleAddonSelectedBy`. **Factor rules:** General is 4x only, so 2x runs + 4x and resamples down (`{{#AI_RESAMPLE}}`, Spline36 in float RGB — as + upstream's `--outscale`); SPAN is 2x only, 4x runs it twice; x2plus/x4plus + by factor. **x4plus runs in 384-px tiles above 384x384** (`ai_tiling`): + whole-frame at 720x576 lost the Vulkan device on a 16 GB Mac and 512 tiles + thrashed (29 s a frame); ~7 s a DVD frame at 4x. **A tile never exceeds + the frame** (`min(tile, width/height)`, every method): a tile larger than + the frame in either direction segfaults VSNCNN, and a wide, short picture + passes the area test. Flat-colour drift vs + Spline36: General 2.9, SPAN 0.6 (2x) / 1.3 (4x), x2plus 4.2, x4plus 12.2. + 2xVHS2HD (RealPLKSR) was probed and **rejected**: ncnn's importer has no + ScatterND/Shape/Range/Where in its ONNX (see + docs/reports/2026-11-ai-upscale-addons). +- **AI strength** (`aiStrength`, 0-1, default 1) applies to every AI method: + one `std.Merge` of a Spline36 upscale of the same frame with the network's + result, at the clip's format, before the props copy (`{{#AI_STRENGTH}}`). + At 1.0 the block is removed and the script is byte-identical to one without + the field (`test_187`); 0.5 halves a model's tint (11.2 → 5.8 levels). - **VSNCNN is built from source on macOS and Linux** (only Windows has a published build): static protobuf v3.21.12 and onnx at vs-mlrt's pinned commit, Tencent's prebuilt ncnn 20260526, VapourSynth's API 3 headers from diff --git a/Scripts/models/convert_realesrgan.py b/Scripts/models/convert_realesrgan.py index 9d8c8e4c..d4d2a16e 100644 --- a/Scripts/models/convert_realesrgan.py +++ b/Scripts/models/convert_realesrgan.py @@ -35,7 +35,7 @@ After export the script checks PyTorch against ONNX Runtime on a seeded random image and a smooth synthetic one, and prints the maximum and mean absolute difference (in 0..1 units) — the parity numbers in -docs/reports/issue-ai-upscale-addons/. +docs/reports/2026-11-ai-upscale-addons/. Usage (in the conversion venv, see docs/ADDONS.md): diff --git a/docs/ADDONS.md b/docs/ADDONS.md index d58c1bf7..1b1721ba 100644 --- a/docs/ADDONS.md +++ b/docs/ADDONS.md @@ -80,7 +80,7 @@ source of truth for what an add-on is and what an install of it holds. | `licence`, `licenceUrl`, `credit`, `noticeName`, `homepage` | the main component's credit (see "Credits" below). | | `usageNote` | required exactly when the licence is non-commercial (`NC`), and shown bold wherever the add-on is offered. | | `components` | further components credited separately, each optionally limited to `platforms`. | -| `requires` | add-on ids installed first, and removed with this one's dependants. A model run through ONNX Runtime requires `ai-runtime`. | +| `requires` | add-on ids installed first, and removed with this one's dependants. A model run through ONNX Runtime requires `ai-runtime`; one run by the bundled VSNCNN plugin (the Crop & Resize upscalers, `upscale-*.json`) requires nothing, and lists only the platforms VSNCNN ships on. | | `kind` | `files` — a model pack: each file is saved as `addons//`, or for an entry with `extract`, only the listed members are kept (each checked against its own hash; nothing else in the archive is written). `python` — every file is a wheel, unpacked into `addons//site-packages/` as pip lays it out (`*.data/purelib`/`platlib` at the top, other `.data` dropped). | | `python` | for a python add-on, the wheel tag it targets (`cp312`: the deps bundle's Python on every platform). | | `platforms` | `"all"`, or a table of the deps platform ids (`macos-arm64`, `macos-x64`, `windows-x64`, `linux-x64`, `linux-arm64`) it supports, each with optional `minOs` (macOS only, checked at run time against the running system). Anything else gets an `unavailableReason` and cannot be installed. | diff --git a/docs/ENGINEERING_NOTES.md b/docs/ENGINEERING_NOTES.md index e64a1907..424372b5 100644 --- a/docs/ENGINEERING_NOTES.md +++ b/docs/ENGINEERING_NOTES.md @@ -2915,6 +2915,53 @@ date (groundwork, not a quality comparison, so no report): first; whether Core ML or the CPU fallback executed the two-node model is not distinguishable from `get_providers()`. +### Live-action AI upscalers as add-ons, and AI strength (2026-11-08) + +Full report with crops, every number and the scripts: +[docs/reports/2026-11-ai-upscale-addons/](reports/2026-11-ai-upscale-addons/index.html). +Crop & Resize's AI upscalers were all trained on anime; three more methods +now cover real-world footage, run by the same VSNCNN block, with their +models in two manifest add-ons (`upscale-general`, `upscale-esrgan-plus`). +Measured on an M1 Pro (16 GB): + +- **Converting Real-ESRGAN's `.pth`** (`Scripts/models/convert_realesrgan.py`): + SRVGGNetCompact and RRDBNet transcribed, opset 13 / IR 7 like vs-mlrt's own + conversions. PyTorch vs ONNX Runtime: at most 0.017 of an 8-bit level. The + TorchScript export of x2plus's `pixel_unshuffle` is a 6-D Reshape/Transpose + ncnn can't import; a one-hot stride-2 convolution is exactly equal and + imports. Two runs are byte-identical. +- **VSNCNN fp32 reproduces ONNX Runtime exactly** (≤0.005 levels) for all + five models; fp16 (what the template uses) differs by a mean 0.05–0.12 + levels, the same order as the bundled anime model's 0.03. +- **2xVHS2HD (RealPLKSR) can't run on VSNCNN.** Its official ONNX (dynamic + shapes, in-place slice assignment) has 225 Shape, 56 Range/Where/Equal/ + ConstantOfShape/Cast and 28 ScatterND nodes, none of which ncnn's ONNX + importer supports ("load param failed"). Re-exporting from the `.pth` with + concatenation instead of slice assignment might work; not attempted (the + brief was to stop there). +- **Real-ESRGAN x4plus needs tiles at any size.** Whole-frame at 720x576 lost + the Vulkan device (`vkWaitForFences failed -4`, then a segfault); 512-px + tiles thrashed at 29 s a frame; 384 is the fastest (6.8 s a frame), 256 + gave 10 s. x2plus runs a DVD frame whole at 0.8 fps. +- **A tile larger than the frame segfaults VSNCNN** (found converting the + PSNR run: 384 tiles on a 360x288 frame). The template tiled whenever the + area passed the threshold, so an 800x200 picture (x4plus) — or, for every + AI method, a frame above 720x576 in area but under 512 lines — would have + crashed. Tiles are now `min(tile, width/height)`; the heavy suite runs + x4plus 4x on an 800x200 source. +- **General at 2x = 4x, then resample down**, as upstream's own `--outscale`. + The kernel moves PSNR by under 0.7 dB, and softer kernels score higher + (bilinear 28.40, Spline36 27.75, Lanczos 27.70) because PSNR rewards blur; + Spline36 is the pipeline's kernel and was kept. +- **Flat colour vs Spline36** on the synthetic pattern (worst channel): General + 2.9 levels, Live Action SPAN 0.6 at 2x / 1.3 at 4x, x2plus 4.2, x4plus 12.2. + Bar centre within 0.3 px for all. **AI strength** 0.5 lands within 0.38 + levels of the mid-point of network and Spline36 and halves Real-ESRGAN 4x's + tint (11.2 → 5.8). +- Speed at 720x576 (fp16, two streams, quiet machine): SPAN 2x 8.9 fps, General 2x 4.0, + x2plus 0.9, SPAN 4x 1.5, General 4x 3.9, x4plus 4x 0.15; bundled anime + Real-ESRGAN 2x 8.3 and CUGAN 4.8 for scale. + ### 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/2026-11-ai-upscale-addons/img/01-original.png b/docs/reports/2026-11-ai-upscale-addons/img/01-original.png new file mode 100644 index 0000000000000000000000000000000000000000..b1272b1cfb77aa687858b7a0d35b24d4995f4e14 GIT binary patch literal 86482 zcmYhiWl$W^oAwk?v@017@R?Z!vMh<7@P?72b>Yt+w9n`{*T-+lU1rfk%h2jd9=b&_^r^{ahvQncrrecZSeV5*U8}DE-p#f|!Knw@ZiI zhXjN?T(0`(a~Hdt18W4`+XE+IY~jz>*}#9_x_~o^rsn^4n7`c3{M&yHLvR&2JmZ!31QXGSa5W1cLFXQYf=hpNKXVgy#8 z8RFMJUBT<^_0z^8HHDrTycc;iG~UyQ%Fg5NuKl6dx1CQyR;J(uKY-rVc8>GkuKMI7 z2iITb-LBNJ%ME7X_dU%`Wid%q$4NyaK`zUlrXwxvi^joS_3dYp%S-j^Hm;K|ZeWeu zv+|T!*{43R*{aCivDBI&3af9;ouBf^om&cPT#dzV9rU+t;6tesrZN;gM0!_qPGFjoD+hR8+qT1~-_j-srl}Ct z`rhk>QJf^YsqmBi=m^={*z&~!#HGXXlicKKwIuB7mU(3KmhyDXwf^!((x`BKXkGd` z5AhXn|Ltdp0<0!1{Y*3FlNWHiyX&T3_2#qC<90860tOW_9hD{V(kXTwROrAzur_gfTGJQc2< ztNiysoLkL%^_%DHM6%7k@iSS)4`{FMaNWFK3+(wX&r7*)*R8B}A3sb$EMh1dPSrcw zg#QSTDHLG`7RIc_bmt#GJdLMbMin~mNIJTYIS1>I5wiE@?406!7o*$_ZuUQWt>Irg z9e4%*g=4c}BpLHW>FG@XST_YztgOva%*9r%f(<1xJ>r0{qTC5jex*xy4fjpz>79CKyV#X5EtNWnCTiJ3&(-Y!cgIe|MP?? zy4{}GrJ`Mv%i)QU=8~e(aBqeUX!P;R1yo$IiFpRMJjgpZJ>+t?cgxJmZ`h?|%KB2f zdqeh#ouAr6G6xR(T;%8*+ULD?+-C;YjF~vxo!6-!aK|6&3G+AX?Cflt4nE${9UTQ< z<+Mw?P=Cthr92hu3%dE&K@`PF^yf!SU}b$*-F%ti=MMJL4=&<(yar(}V~LNvndTqY z8ruU2$|nlGpQZ@u(LO{?H&Ig`9@_Y>jrr(l8vToZb5>i=*d=>-Nkz+9k2V#`$_q91 zrExU;G>MlR*z=;BasdZy=S|0)S)_)Xvx9;BCyyJhVKqI$>G;nwg(WmJZqfnB z;;uRy2gLHl!S$Wr;_WJ#>b`+XVmk}QgU8nh!9F5F?>B_}B7Puu4N~@?4fhO^80hKI zvqd;5i-+8w^!%PMBX10EaEky?}D2 zY-zLAcH{JifQJF0S)dB~=o(5QV%z#WSQYU*^sA0e3fnU7YenA+eP{_PE6Vei+J&ia zNmA%#(^b^+*+!$8p2c6ddVxX-Vk?<#uwSn6xmwJpqDc_~lNE)pT`-7MBPK)Y`~OB{ z*R?59t$+Sac2Juo2$Y6w1ogzaVjHoCL{GB4|QGHqSh1vQ54hoYGeIUE-XVMol1 zV)Y;|?~kvY+Z5bbJgvFq?i9R(jOa(|0SGUE_IehU`?z@;8vE>8~!yd7gl+V-M_$LzB zJe`3EzbH4P1HuKzQK8Zu4-?0;Ue3meM$27^V2GEk(!Ic*rryzjmIeI*(oc$*YTOHu zPyN!W0i-6zxGqk=ev>C-^r5ZyrD3kU!)qwOzLDQ~sS>wx-t#rfRM5sS2a?V2rji^0 z4xrsRDuruoGEc}_sr;H#B0%36c3KzyO1>G@ZP}jZnrk%&Y{H01-qz#_DE-8OAt1n3 zvY@vn#5DJ(F~$3WHjU7DW!TV>B9~n@FZuJR7=YI8n$f~0H6S~Zu=pdvSMc56OulzfLTw;N{_5v>fEWr886{EKF8fz8>)M zY)`>Blrs_)bz#`P6$g7Tt?r_&zx*A-TV!=-jwkuRa!s*5lQ{GA1pQOF;JSmkzg)5# zsJME02)S*wzjc+mU7?tCnL*gk=ihHw8Ii0FosGQ~*a$ zIB4g??9ael0h-IY&0Ox3t?Xj8OqxBtrsY;0@LVO%D8aHvj0ySqUKIl`o8R|118YsOSsmVb9GSne{bD$Zb+6jOnpL}*i0QM{xVfst0< z{&;R26S0{ZlPD}>t40H~^+tjXpJ?O<*2s`0G5O`O;_O@uy%l37$YF$!3>ePdL}%et zLw)GGqzXd1g9hSdP4LcLlUc>LQBxJA1RXbA8E-)x6&6_o0hKbQz2ebM9p0b0mp>6B z{kSeMS}nuq$_(oS^qNAL0pah&-emylXV34Ns)}qc{Vv~E*J#-y$nd@8NVkLK8GrLE5jg&9UdTa$y~5EHbcn zx9bMtRFma#ADcje&AeyPh^4+OOtHR~$11Zliuu_7#&^MSPWfi<1~9UF5uhB0vJijr z&(3-2&SktKK{uV--&9K!A>oWm;^5qRsWBOj2P_Z8w*epCFvVTV5 zgF%dnj{2>>F&7z*qlh7q)BJc7OVjL+{w?5|Xr-snpEYFD=3$@}GkPeAP7gB0q`=}H z-g;N23G+|;j)hZzSqAc?*p$I@h_wuc>oKjHd)B|2Ggh_udirEu2I^W9r~3Syl(kCK zw*y4dh-E&_>z{0mKkFS~L5}(K7fGw=7jNr!Xv3Z4nk*uHRLl3Kn>aP>J5ZE76Zl|+ zJY~VnhUJ;=VH=qsv}T0A?(cbuA*q%Zxy=OmvpDT~-D_fwC{OEa%-tn4!aDyoLt&Fu z;$1>LB{D3ykyEr6K=7K8Vb?n??VHs0PG~ERv=_cbt82&#NO1Vl(!0Cmirh@R(7AGy zej(L+X!2@dFFaUx`?nD?yV&-J9fvgIA4y`&xtI` zlGuB*8f99Qw5us$)8mvPvZBSV2#Vhr259>ZTi8%Ngc9F5MzE|{o zKIG5J-XOg==aE9yojsfHiCTpuPnI^K=%3PLKkK&>UyBj+-&4Ao!@YV~$*$`R;%Hz= zY!17xYtnsrpw`XQT%c7a*14bvn| zTVN2zH};6Rw0P_3mm`kTb7-)rwWq_gfondDo@Q-)C?P^3k6uj&C056Z7~7r5nXu$b z0#2VNS7fZ!}cqL_+OodOH1!bp1JWuVsR=u0Bi9z zMW^Hg`N!3-PW@wvSMG%##q?hH{%gUuoZPLC)ii$8cMK2>pEUlW%ek?E%2OJxiT` zP^XGxFYBgozDe>>&u;^Kj_uSvW>pFtknx8ig6YI~hqaHqO=l$?a5ES^J(DYH$$fsa z8aTq@H4f}Q7v;q#cVtQ?Ol6#n)Fk73kA)243m}9enzd!kbJ=KaP&gfBx@m1Q4u91b zYHSw{KEB5(g*gl_meR@{$i|cxawm6wgc=apN|syhUB}I@QCW@RmKDa{4>xt45noks zWt>$H?}V)og!3d=e6%}^P&dyw;$H6V?pDADt z8$8xgBjGwg??tJ{OQ~Wi-}JUq*iwK^>$?~~!|aFaL+=f!ga0s5%R7@!*Kwu7y%5v* zv}SG2t~3*95)NILi-H#dq_DEV=+QsHJ|s z(HC+&W)m@_7w6sLRwd4Jlt9>(jPDy;Njvcz6I1(U6u2ehX?Y-?=Fam2Z1$Nrh_nAp zkH4Vmm?^SCZ)ClxVgyMeqQvn<4II4PMM0kFJ?%rDj62fxvvF_x(;tl8b&Q8wKCjxN z+GD$v$7?q==v7Xs0soK4lQ%-ePBq^Vk5o>ZP#77nxD3f<|b}BoIN{diqLj9wit?vX8cL5{m9_=4m2K=1XIS@ z75h58c~r+R9(qd;ySt}|O*Z#V*Y(4nuSRuuP;%)m7KR$lT?bLT=HIqyfSNb+^KY#( z{(C_BSDj@c^KY-Ooc?b0pv}-&YKI8l3g;M4 zrh;cWH|X@zsgUAuk&5dgtWBd_Lf{G^M0Tz%_@p1p*L()35Jvp4(7*PGs~i6p7@a@h zt(q8z6GeF8^jG?CE=$xT-<6Uv8{oOYCF>NCDG~>GH_`FZ4pG&H*bGrO-=g2yH*3Ri z%p<-bvHyv<(yqkyFkvMe#g{*5ZAo7*?XsMb1jcwT@f<{yTufaFzUqEAyUoXc^*O9q zHnB}ow_h9b{QjCCIziYb==UPs&+7i%NM(IMmoKo_L9|H7LG(I4@lsc;KI1cG@uoXDDPwH*^Y@lp^$)Ld=|H{>GwyB z;`Bk5c1kH7U0|g_^7(50EZaEIH@w751TzYM))H%e9(et}kG+skdd)zV0Lh!k??e`P zSgd)(desi&{o4IuR{HtQnvFMI`tdwgSE4zWLXs2>>Q8`v>WIA`HyS;rV&=ZPRs0`) zt3`TtCeX(LHn93^(aOaq(wKKB>TH=iE|v&w7!@b$jt6IjCdY8kz8{j>%}w3PFow*W zPYyC+`uXYOjGLnEV?;`c>dVuhAj+3ssdq3aGJ}S9`akp*G5y{gVIfn$!A4gPw+sMV z6tWzhA7fjyvn2>Ye%AkX&{71nrKsXyu5_jay`fiVko?)NG9U`V_o+T*<@MqvQQpC= zm0z{r9Lzy5@p8O>SXXWx54vsE;K_`N50h^behV`^QD8*-k0QJ>ogEb_67LsAIoKP~vo%HNd-@}Nz|}@i z4uES{Njqw(g{;oVmkSMns=&iegcfF2H7@nEqI&(#sB;C@_A2ZT zZ|oUm+OCCBAJ{v8&fvH1&I;iP;Qgfi`%S=kI1SV?K8v+XJ6bgPXAFB}bQxIeJ>Lb8 zl7#_I3BQ274MbXUL7!QUvL&5$1bQ5($=7vHDurO}f36?3O=2!0c^Ho|#B@JU^yW#K zj=yyG(joJly^Ir59fcr&)GX6n7Iy`Y>U`l_UlRdA=B?^e z%s#bHF}&fnIf4y7@cdlFlKo%1L%!|B*Ul2fwY&*$bXS}TN|#DT6Pxd7e@=N>p_Zw7 z60tBT)F+wY0aYGLH=t9k-vj14+ z@tT^azDh9TpW^K|%+#ahu?GIS_~IsSJorPFvR}0x_{dRif=1zWxFS_B7}aP)%DdDi zs7JCd2+cIPY$xX24#eOLnw4B{SBtg+$RWA=5v-1xkE3G%LS5q%io?g(_C^&iqX$8n zH}T~QR_oWw{xcF3?fS&s23B7>W%G3TYj@s%wVF6$te$NGKC18xHZHDH6yc2O(mx9I z1tXS>NZkr+J^nMq1m2=Q7=pAtRo*$n>h|RQUO4B4`d-cxT%o7&ib&)iWSI&}OlR6G zdop_|&R`#Ab?S;ah)%Idly>d3T&lxwLVx)H!nw5rcS)bf*M*lJhV3b8&1GgwP25H+ z>(ocu?(bf6uLpq?ujtrJLbi6lxq84CT`?ueF&F)Sy;I|ww1QV5?h2QhW~_72 zH3aI+I3Ul5JSy3RSovgyb>c=xQPhT{ut@YED45}L!dM|Q$jh)JSZNc_xR=qJx`z=iZk`7fxE3M z#lY6nBv(3uOYZRd8C`zLonaY)Uv2Sea~x?*Ry+)zMdd@Rm%;>T9rSwMuHSh_Ea zh`8;+t_Tpn`1N$i{R{8fggFb7G$if!X1@>B4>^&w3A<&xTj=!hl(*oPAvCbiR^7JB zdn7ns)nvO0g(Pv%DrG`3A!eW)T{0maWl|Rc$%n0z)jKfc>4tt}++1re_|-p*$~{lF z1Rsi)#mBMDozdcVnz?nPQp-9(l(0OWO1|e*P}>lt9FND6ld-HyHY32fR7+8t>zRWH z9cKslu#}r(SMEOq3F^4sGa8~7bMvX5uYbQs(7NhW;FD_VS0&)CEl&8^P5(4Si&-5q z82yo1^q#CK8KW0djcv5cJWvkh!E{cZx&SjNwUmu-*O9yb#{}-sRTT^w&saqe*E(~-fO`}ZGrcE+D~k4|Kw!wPt?S@<_br3_2j8Vw zi5!ym4!r@HzmMc|c|G1X?ht9x)RT|uJZ}U%y?==bdAP1J+b^Szn-iCe@j^iri05cs zY?y}U>P5hw4gt>BtArdPN$VR2{9%uGbkm{iWHcfD_kC%1*bDF1H(+gX$o3zx+^2m1 z!(D`5KFsi>(V{{1Tm&iGwo;^@Su=$h*GAGW>d=k#Z|2+2eQw?8HtTwa%ya!0Hn_s6 z)%8~}08a1m6rcU$*j|gGjj%qCHT9*~k+EYyf9KOm$t2&kKU8;>KAV-*iCq-yyv89f zbU|HpZHiVd7`6dK&SQ0v8L-zE^Alc z_!%-2``s(%k$LvI@(MRUn4aaXw+OYhE?JG9k;wS{`rMILf=?A z8ST4Qoto2H&btESr&WEFiJA=jgXuNm7Vd}HfrEh~ut)vbGHYScg#2SiY#W*jUlwAa z-)LAApqv1J`!OVazXS>2Jnk)DnlGHD^uS}F6u~T^9Mfgb9V++Yam}&IbMotX zEe(vYaGm^1t%<#|J&JyQSOxI52XfX*cq;hYHc`AYnQA}+`N`>)uy2wtYq|zcswDfP z7w~lk{jU~~tEVOFt^cJ%JFr`5H?Qxta4&zj6zDJiDq&$wf>H-JQ@KylyFxZDel40&wE8O&dC!?z3r-kLS+T zFTFKCIlklNybv|7A)qwyQoKlP6l=e}@n*bgFRf!9a&thzMTZGwaPlJmboGSlte*|q zVLD{3lLY^|K=-FOl0Y?Lp1|$pks_Mx2{)r)7tZg4$wE9wkN{6}^eEaN%I%adGhCS_ zH?=b+gHkd@l&!ls*;yY&%!2l|!-9|vfhp96;FZ`K>~Nev%xic|f+|D`3b{f4fEH(& zlrbvY$gswmp+w6Jigfa1DILw5F1++ZGAc_#p&H!(UqSP@PYN#yj%)K@*0uKdm2&^| zs{N1qP;quP7QSE-s{)XJf26xzRCwT!)For|&~z4UtOA8$xY_~<(T*YonUOsaMeWn7 zqnF6u!ChPdoilv3q@d&?H~3uIHH1QEv?1!RsofLP5mdFVG>BXw(#i*DF84k*b**iI zvUa%8Fljre*;WKWG&EUn2pb{4NWr>_wTn5jma*BpqRvCx1+tWKI2ld?#ZssO1 zJUCo9f+tFo*Vev`?*8jvV&C34#FV)H5=?F$UiqY^lhYuwgw>E^hw(K5)ODq#EAI5I z+u6&RfFj&yiN%968JCEs3)x>A9`a#QTnJ*+$sb|N{WnWoeM{JyHp-Whd3^lY*AFTG( z(%DsfZ8iLP_AmR;UHp}l6p*?3DAsd4)(M-8{1wyjzMXJtyL4%_J`Q16~ZVmH^~lL}TF@Myja z@qG?A#@~Sf{6me=j^Uu5Tt;wN4^>q>frKgSlP`RS+Mj?(^xNg0Ddw5%nd=|_&E5@fBB&K9SEs?6y2h?nhrJmSwKkL|EkX(_r78dsH7p82HbarfFX2ZU-Ur8l;u5XZuNGNTT*^s z%SkwR$$XV{efZ?Ho3b5Cn?cym4`PD^Y<(!p*iAw8@zENs;}EP6uQ}XyeD!Qlx3Pk3 zV<5kEur1B_Lv8X%^f=D7q#Ez{mdD=~cyko30DI$8gD!!U{L4I6$Y6MK$QdQ2KnVQnav5`H!sHPS)uP3w2#%kZ~sgy zCydB)X#FcY5TvtVg6CG%+q&f0^|!%Zes`6)@*a7<*W-4UZ?_h8SGbvtXVctzWs6HD zx~UAWLwLIWpj9LRuzgIuAKff9DxQ#@!8v*CfbMv*DR8x#DmH9CSB}JN?o}0xOv%~5 z#1E(>{`qd1P-;V&vi|<}`XsXhRC50wIdFT87a!lg`W5Bjx;TA~gT}XCWfv=eF$*ti z(swv9xZa&Ff@Zvnd0?(3jgV;+pdFcy0?tBb{@^>%8HCHqzmq=LI^Hp|J6a7o!!1e> z=l(ht65gk|PrWt1vy7%#;(yks$)tKzewd*RqO6-nyz`FiD@s5{sW)690~hW~UpFy% zGpKbryK6O|TZ1qNqXVk-i5E0`c8>K8U7OoNAaViq|63yt_oup)s%~=tlXBNq|7{55 zqm9K-U;-Q%>$A#S^eI}Yl_hxgYo!YFG`w^TEs3RN$9a}D<85KEiNUR>IpUxpKJims z#k&xJ1g2x;K&H6td7=|eg(@mSz1v) zW??EVHy{!TRiIu_vOeJc@K|^|)nCMp@cy2)9JFM04iGoyJvwYy+$HQw`p!`o0C6rT z3?w!Rc%&QYNovvA`kbqLt$%Tn0!TToMUYy);opdoM+y2``lQm)5?lB*(Ol_qfS!QK zSlvl;StM;(t} zyB%cOJ7cjaS{KbTm^Yb$XBpac3!=aE?l8l@{vV6e>}CGbhoM2dq!iUx`>@;0r($90 z4}?>s>*+1Nw@(KTF-M<>t_cDmXE$XHVcr#Jo za4#TV>fbz^pH}Z|5gm)YK|tg-P6@CowuaZpxG4h~X{G3wF5BzP%~U+JD8;=egVDI8 zW7Slr@{@t5DJ>DLbqfpyRS(6LLWUBue?Cq>smb>J0rhY2+*NmKB$@F(sZ~t{XI#J$ z@x(jG0~mLCuRY@;QsSFgvFr$#PZ4jD8Ev`!!bi=~B2tH#M@k zKcd*#ZHxDx`O9#aKkw&5AI!Vdy`Xy5v5J;zS)rpDnY!R~j$qNZa$?0$U)ZEEs1}F( zvyv;*b9H{wWk}Y!`mi~~Aq>2BY5TZcd2S&}zUBoe(aE}N%9ZXA=_h)`@Dg}k%}*3z z!s0669L&Iw%g8*{x;{EX@mJHdliL#NO_k^b2TnG1PWt;4M&AKD zm6;#W0ZRrt$%mhYI$HJgKQ|NXzGKY2BI?;#5KGy6eD9S~;cne);X5Ir;L+RB6!PPI zP;rOAA#QtzIRVbF1M($YI_~aHoUjZItPS2RR9DIm~mqeHnv8IusO%`KT524MLLSFNm}^-r}>zxFGH z++(IQDLDw0JRY9YK!pVk_?~$I4HRe$ueL9s=tX^Ib9!f)t8-%LLKn%ejwR?hpzdj= z7#&KR2i4kZ&Tx=DRBck$K$ShDbTC{Gv?yh}upXA-QPx}xUsddTw^u*+y+sKWT@k;B z3lGiM)qqO$9s4x<-L-uH(`G5zGiLQGDPXEwq`c z$_*I)b*nL2*Finv3)Qeg`vM=1;0W(f;@8{l{Byq~V8Oghn;oX7E*!=^Vlt$cZXbJ( zl#=5uJ0t)UUA@li^t_N5Lc4;IhUzt%XTaW{L zFvD`;`3+S0DI^mUGLWO{0?x~|53{sw>A(4i-p@%fes@}W2RStjKyaxlGjN$0yLvj# z{4i|IugCqwFS|cDJuNT>UD4T{HyVdaXq2)$5R1S&C3e>4*D45evZKl|XI`UaVe}Ke zxz1IR_bSYg8lf@102VRsc=}KcX6)3*D~J>k)J^^!q73l!<0?*ltuHn4MlQF(p}3ZM zu9Mr;mU8hy%0lxpYDF^3>U02n$OWlNjstCfi}JPY8aUJN;(6e$e~T20d*g1g$d1=4 znB5&J>*5+;(O+0KZtW$e=*^@WYV*wHOmW|^^&8z=!OpjIk1i2kweN-2zO^PD`A{6O zE;pGVthC3$95wC=XUn`d0moUbowI5a2E&Ufj=pv3#GisB5G{0u8KfNv=fQjh=R9#V zVsL$#cROx@BU=>Jw(GY-&<~yikFpz(U}JglQwDa?;0Krfu(n&-zrre@kq{24T`*E_ zp!q-lQb7yXdJWnCY60L;zSRFc^Jg0sJw=2iC-^LV>SO=?d4p?i-vQe)kov-2?3_!G z((+6h-pbJIfU)njt8F9ajmoIAx2lw(uhN|7^1`L!lxkFopG^<;)6-mNC@=N;@-hAm zh(PXSr6=i;&s#Y0HbfJ(L*4L+3o&IR_@=MuUa`3Swt>)+h^QAZh;LraoSIiXm~C6Y zHKX4ejmpph!Gk#d#rsR*RP2@PaK-24_3O-!TPzt%Aap=lPLJcd?+<5^#Xk={n<0xP z!vp;-=w;|EKZI=0L))ITr=sew$m_MAu7zz^dq)XXfTUZOuPyI`&d*bM8OXPrsqrS( z(-Aec9EoBdp?J2Gvly#|XKASe2ici#o%0*!3!I#_Z4-A|V=G?k|15MAt&~~P)q0?) zw_mJ&SD^quYSJ^MfOhlys^obz(Xw~4c2gOatFHd~b}h?9Q2)MH?kAw{foKXbG$iZF z*>j%mXtXdLh1MKC-mHW(wTuWtHAPPn-e!g28AXOuPvA+QUWDGBBut6a0iMz?w*Eu= zHXl)Xr=;uf;xlz}NJw+ffG@B~=-Vt~xO6yOlwlY{QxTS4kZ<~Cy8kWAX(H0CnnhVl zll>XUVkQaaG4qe}zZ(Q2`B<1hR}{SM9}EQ&$*2 zZ`SFyq)D@e5)Q<_wSB}uecM}7g>vIj7i!O4kI#p^leo)Vn3#QpShUu-Jf>}JEYB8Stxk(C&vN(&nJOl zyz8$Tn+nEucCC)qN5~4}*4zVHMdPz0+cw%BIJq$$PBEXzc;}>f(pi$nJBui;ih~&^ zH}UfDy7nIo*DNTn9hxH7i=*sNZHl&=Fk<#k=}V{pm~{hCo=u@gm+-SuJwvZZ>)&a0B&*z> zU_BC-4zls3B`8Gt`TPH~#Q+|uC|h4lrOF9YNqQFHoAxJ<4{f#*2p6u_NEIzy*tZ(b zB3yT#0DIYgIun&cnTRyY5KAtasCbx#N8b0)&PEC7n>UM#W`78a&-fAGfaJ7Z2w6*y z$i!!sq{oY9g7N}=PqW03QiPSRDs4W%DZ(SWqc6@o6Od#dYqs^vKIf^xb_t--9GVqq zRPT5gZF;P5+cS$*WxjI$ot9_TU%Rkk4YV`P%)q_bi>#0CxPIVe_T34qu)U4bq8*g; z7w|Sy64y0-Dt^M>wQfDuW0OAj0^vsy=ECH}>vilQ|BdZ8QvF%dd^iGQVGMVHRnZJEjoFIXm__ZknA7 zs#B-aQj>9V5DAm)E}zC0aC@g%0tV}LY*kdm>V9T#wi>vLs({T;(NWdYzs4wP}(N+@1yU#&)t1qP-7|}NwL-l^ew!|bEb9)2}+r7385J| zas!l{%USnr#wC%UN$;qc zQXZ5QSvHd@rL?xrQP7)Xqnxf;HZR__2rU%3{rQzljhJ^l=f<6b;cDW3LI{^bA;)^) zi`MM0ea*0cgmpmWOK^kc+(*_{%3_Cym7NeCF zd~-*ClyvrgnaT7F%wj^Egsto42gz3aP2is_dtS08S)3uYGX*h+)&A(# zQPfM-#%_|EQJ?d?V+Ju@h(h!IKY0#3LO05+mUMR~jtKq92c&^X%NLoJ9d68wll-|t zDmmywQ3Qk04zw@PYbKYcuyE=XeHdW7f(~+ew1p$%!lr@lNA|`TyShyZcmVmy&JW3| z@0Ujv@i&o^vViJ02(@1EVdHkzekmf{ya?`Ro7Ss>AsH)kIGV7V@NsaKV&lFz^02TZLGazLJqF7Hx$r0!~ z=UmX!QFk-@!5a}rkMKxkIKpG`uf&=1p3nj|e7J`>jHzcC7Ukdx(vf@Xl@^>rA+iVz zQVj{SRx*)jds^EboVz&w>b6F&WT4GI>a+&@Pja6nGZt{hR!p#9ay=i)f_@;bxbNTT zS8J~b+_W8?QtdYhkDOi0ISmxXrMT|ali&8yBVrJ8QRe5coFnb>N)ihCv_y19sA{0F zN@BrvE7{G{O!oo+E5#6<$yCmgKNZeNdPUh=-B%*V%qE1n(Ov{}$VG{6N+@fDeI!2D zeG${wk(-_ff`{6`4YHK&I47m5!`rdm#C#~UO(ytOo<=D^vU1w8raEV>Eg-%_fufU= zFn1O7m^d^Hd{36Emn}sq=^i$9b;uXt}U{pwMN%oTN)>o+ODB*`YV)MZ(xqlmE=BD2~Ugj%X&(|5|7 z;P*%`WK}{h%LDZYX=KE`ZqJ9MLvT3*SKr2ZZwN$hqAzC};;{5}XPGdUGF>^3a+GdY z4{szmU+qdc|AeD^H3;}-sLbyOe{BLE>yhrcqiKgT! zc*^wWNINK&3RT&E`a<3%Qm~p|`t~jQHIcYLeueA?@}^jTLc{qIx%@D1Tz6!-qf8%a zi-51@veHf&R#paYPTd*1mNRkj$J-_ z-bmJA{EAbU#g>)XwL2|>CjFoUA;;FzO4<>tu(UUSmQOgt_j~wn?+a3H4Wqmx2l~w_ z{Yxh`CW9Ee0gBzFZ605Xq&1SiTVY0*MsreG2D?sLeq-srC3I#x2W*grJkjpGNVsyal?DC5-~Zgg8iYBYmloUC4* z?8j3Mt zLd3K^o}vB}s-jMXom|0ovwRrdjFkU)kTtJH#F&Dq;4h)o%}(pM4X<1MS4CC@e>6xR zbwn4{)E_C|P#4c_YkU`v`rGHYtv3gnWsgVJM(o%zhAb8FuAMFG)=#pS5l?

jX1iX}@-PGtIpLimE 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 qklFHAd literal 0 HcmV?d00001 diff --git a/docs/reports/2026-11-ai-upscale-addons/img/02-spline36.png b/docs/reports/2026-11-ai-upscale-addons/img/02-spline36.png new file mode 100644 index 0000000000000000000000000000000000000000..016cf6bdc8b6696f84968c78de5e194a51a73c62 GIT binary patch literal 78655 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

4(#zHo%LKcP-# zabC&WpA(Ygb+SDJvB*kmm(je9&ZHRXkreQtIm5p6T$j~CdBcWQ&P|$8ZQyon^l?9v z9yKUae;mL{Ur-Cq`h;UTpiIKEiOnK&d}IVPjNkm1HlP;I$1YY=*-E&ldZ>0f0dlXS z1Si-puS<0}Q@f1ur+Q6ShLKyL6B68I#uUiWtWPCCOG%!7asp95>_2@ZtpiE@6iMB;y$W<9_>HV}`KMHM z*qn-&IEO>a8Vce#oLK!BhtYWXYgyCjb9EPw#49ttF(COkd)dP3!MQC(NM&nQDDcA{ zf$(7p#3Fbo^Im5n0mVbXSIsI9|FBPa9rTN$&dvjbmH4{iAp~0izv4|=UHle1y)RWR zVD}H93mx~FA4w)izWQOaU>{1dB-i|oTI{pUXT0qM$D0(m-H+ zK+}QU07t;lOTgv3x>M+?@yXH%@iD2RH`oHquZlyKOw@VSwhdvipCqRYr?o;k=WzWv zW?|`;9^Yl7+tJj(`QH-k7EU7s)a$eH8p3bQ+MhhS=+N1y`Z?#PaP2$Bk;^gmET-3o zBC3pLL5bJpx=LzVz_}5dSjd0%`!#t{?O1(gA|78 zr%R}N;(U|z>l|Ub1B(npnhjlXl)fk*9;uKB@O{e3BhW;>!D4Z8p32#Ft9UQ-Jp8or zPO_7|h%XW+PXU2D9Eh!f^vyGySiRTI3CrufD)&W*j8AD(T$JgX{E3n>;k#XQ7d1u4 zz=5q$$`r#ac!1Y*cVSXcOkwq!5!4=&d&~V$vn<%=(Ppw3b&syVGN(1nyB50<9M~RP zh+25FmU~|W5JL+Ha($}XXb-Wi^sD9yZfJHOD}>Eo4)N)8b|zcwkqPpKgsbcy^p9;D z3*6ji)0jBkDsh4=BE10+8l$RWrYiz&^{bI;)N7oLeI+@uWOmE!;2eu}+onx(;J-?Q zzY~*Az|5HBx{_~Iw6xNzG64OA!+5dZgOmRI0;#O?@spMFPRVV*fI1{8f&=lY5xD}l zC)VtPS{>;r*UtZ`Z{@RIr4Urge+FMT$nu3_e*?znO^{~RnesqS^o(do)^O@qXng~^5Ov(N~@b{+1+nAXZe*bnlW@5iD z!ZNVI`xU_>N1gU&GHXfXDYu`eMo*qJgiech$A!JJvuJ0)?*N9QAucCC7q3H^!ja*X zbDH+T>GonhVU2{d$Kq;h5Yzo>>6~d}`aex$vdX-An+gBH0qM}(h+fP8eno>yHPhK- zmnpdg{8KI}leUI0FL1FIT6M2abU_k;p!>8StHr>jmM*KsRX>gfm7vS>qa}Ax(m6lu z?2GnIf$qBo$I5JH7lSPL;B*XLd$LGJD>r?H?71?^rmO9KiGU+cds{0f2duexvXDmOc2lhw)( zW_1<0j#`_RIGpQX_V9AZGFA3!LKGS}*i~L4ZC{{8v&h$EHxq>ZZN}dO!KuXk z<<|Kx-3}A6@MP$7Z0Tt;7+R7OmCewk$iZtdEguEplVo#>u0&l~R)5~0M6piu8}HP{ zS=ufucWM%i%pyNZq6BIG(Zo8>KrzuFC6@~g9_1_i#ISZp zoy~@_1ldssM9=V9q1UDq(wNxVe-GJburIUowX`#O$w8*~vEJ66OCG}5&xd4><9c!> z929{^AXu#Gn&B9C4>afL*Id?9he_Y7z~*bVr`%sR;7 z%~A6X-k7ua3<2!~2Ni=P(Y*|}QB;xQJj1Uh`=_zHsRv-E{eyzt80<<@rcqj(G(e>| zPm3b&Q>rGX2dnp~khzp46s6`b#XGV}cnQUxD4mM?VjGub&OSI|tW}KlFNQVX8$TK- za(Cqhqc+>o*1_^Iy>n91n3gbnmTkm`oP$rpGC4J$KE5HFK1EIs%qzH$^ewqxgibKU z4QoY9Ozi2El*c@caxKdCCH@>bL}H0|BAG+8d}3wszv}pC(eKBi6HOWStz~t9WGz3_ zf|zArq~FC5g~CvHlb2Z>`H&`gVp2~=)Zg|CyR0Ux>ivk1%%rYN?}t|7sDFCRM+D|3 zdhr_+HNiD+q~eX$1qtSBjVM)c$8xsi8Ocl(3N(SccQE{_=f`CmL4Z4h!LvA2tJ-tz zNf7%a#ZN?y>^mg9s*ZoX2xlA&5pu9p|6lf{fW^4B%!XS`%|XX_z3TT(1GwF+$EUk& znHCm+z}tW>QNZh7XpjgXXdNZUd9m}QYAwNwN9&?`X0nRCa&lEMYrHx8CnlR%ayd2O z?!<2U-*ce{g69s!T1pe-xq7C~RqbJiLd;XuYLVI7LH2wJbimX9(S$+R3ey5 zk#rx8M<8u68Lf)yTGzdhU_uz4`6MbILrEvFYr!?tFxKr_vK>>keaE2w83)rCHwO{J z5G`+%&K7{@;%u4C^xz&#Nsg$qQxRp1*Y%BEBTdyuiq)~xUPl(dKT$}v!eIJTRu{Q! z^)RMLK$(WO1;S(H`Y_+Q>TP|-E^5>D9(LO;3Nv}f(_qN1XeH)Q%>^f|!_8BiRy@kO zCwP=4fs7u08MBJ_2Da4Nnc+E~UeG`UIeB{~-`>X(QsMa4{`QI^V7z`@=BDcW+uZT3Wz&jOmR}>Vq*!eKN_WQQWh1ZF5J4Km#FLtA2QEw{w@)sr%PVWG`PhGg+ zG?C0c&hLbW;NUa_l!G_#f}2;)nu*|94(f6+x?{@tHor~tq_TyC<;1smVXSP|C#@Xr zR>|ely-C8M(aH>@(_s-m3u;oxWJACZY(Q@);$I$TR~L6!;giq@`Y6xG`XKi21VSC6 z!BN_X{QR=9Dor2d<-I*+)unD_C*41n$B?x-ioPsrpE^nFYuD%sCA;CRsn3uZvY+EO5F)(RqbdHq^KQgHWql}d`b@!HcG=u;`erDJ z`NLA4BO2$OAg(E)Ke5N*TTmxgcOu;cag~!@_V5p7p)n8sX0VNd`7eU|forWqlN@U> zQafIkj3Fq^W$+gGvbxGao^ebR8b_k`=m_3gwGe9In~z>(>R-g+4vcjwmW$o`oE$r+ z`?(OiJuXt#f+&_Uj;-c&9)CpBXV{5a?cVSqoLd3W5#8bZUN2+72hc{nW%MhYTU%E( zy1rDWePHG#;?~231MipLis%B`p|dIZLHm!cgK%Zul@4TDmLZ z=v*T8d8jiYZ}vq5V(pC7!*URP*c)Cz$}Apx8DgAh9x*BWPx+I*v()weU%rQd72@!- zNj044k>jpJI%)_`*a>`s^bg~!S*@9K?3Kn~Ts;_V-W~3=g#DyYB)eCzBIu19^Ck88 zd%MmyZgi(rCI3!e>155&fU>SSaIr{*fO%T)KeGTMhFoN4!fjEsE#B0I^>nNRyqC}! zH`BlM=iGf$dpxJx#ClCqf8L0=QGK)r2J%&M@3(~%mw$L2-8~=O;kit7eJYSr{fE-E z)q8Ay%M!8hli;6Uelhzc>LY2ohFI)pP;C?|_r(QvaMM|ClWp-6nwl8p zkRY1ArW1K5r$4KT)@U^9BOB1A=7q`BaYsluR61Kf)}^N4W`AR8RqiOVV+v06_FM^G z*tl_?RFT9uhT17uWJv_~gb-9K?xf}m-0upmaz1f2r?v9^uDP?Qhtd>x9Z#Qd%Ul+e zoEhdV5C@E&D)^YsIzD6RXok*F3v(Ui55>=X_Qm{3pbdX>3t33{#G0buYNa>py{sPK zLd;gLfK?v5Uo-XJ<}A;W+1eIeDdBZg7z!-+VyxEdBkvH^tF!0)U3uOZ=yJ~gc}`{N z_GNxextc;A8QVs&m7c7^)-etoc&z_YIWCtK3fM=`u`38RB2y8r7wHLK!B*01&Y$J#%HAlpoH#-EK#nsvVZyTu;9rWmlT;X?fbZgBl&z(q?w{}Yx){+xP3 z0+`Mx#q#Rg*ehL|2l(da_rPH3uhe^b1Hk z+dAHbvJVYKJnWRW@iXhWx;kf$Z)#wyF4GB5E-W0Px`QAr7reV=wnSIi<6ZsBLc7rG z0JDoKBh})6H8uXUbutu=SvHwP4^pc(w}!cJ5;q0eRCgaF&v~bAI=W6*rf!T>!Qxh@ z)7gGg7$$<)!BySN#eYG)e*1+p4B$e_9YhUik**hb%m!b8ifgCeIX)`8GNfsT~;#OXCSkE#OfZEvq7Z`#;?ERlV7 zh&qsZ&Bi5>Xa#Gx^Jkf?^K(PVoMPDx-42ER;`ouqdvxQQAHrXVcr8=20{Xie@79LV zu|ateasoC`?dV^{%#|D@kpu`cNROJ7+#|toUmxK*Yfq7{Z8T<|4JVrWAV`j ziS|O~hB~!E%}xzIrLd}6#qqjR2mub<_MEtWJC!>>0D5TlOr`s~9~-L+&?ONkKN74E z9eMnp6#Qn&8|#p&Z)O&+T?T^E~ssW+AhUI=EH>!f@48!ypH#~Odkhv9NhfcenK&I`x;HaC!k3)4B@glZNBEtzw z-w{+}dq)yRm)_^4SX8vs*tetDt9Qr4=E$bw8XEqt3D;T6u2MR6T#(YC88u%*gWZ1s zv)C)JwYhskBt)(di_lBO&naD~$s87>n4R(e>WiIH{Na#YFcRkTktu^OBaDoh)Hgy4 zZi2gX9`9xg0L_l@V`uGD3;*VLCLen9v_i8JT5dIB-Ajcu84| zXI*25JWNYCa+9=bA?_2QCT>3^kwrWKpQ+d6m(}m4ej?mmd?GQM87*paYl%q#iwPX} z&2A2*I-B5fqkKFe>ZIdkCww+LDo;&Ryt8ol^2by16&>I-nBM&Xxr@?)>=^ps>=?cO zpQ4xqzz$O~!eHF8TP_}t^?ST;d6juRi7z>Zyo|l_0@g0+7IIFG*Pe4eYK))^OU70} zQZ%P2_Y2JY)R58sai3levGRs6QcfR`>8D92H=o7*5I`U~fhnD0rkq$~u!jYpd!Re| z1$}6EZUc{rn#@D;0I^giMGay^6Y$%pd?uuXMpSylti z39?psb-R%lBV5VXoi91>tDl=86$jp%=wcElr9eKT%SD2D#c37103Ul8cFvCzdS9=!-tycYaj6@5Li9 za4T~syo1E-yd@0Wi!mGI)m5ZqxBJL$Sk{@$z*uO~cbO5Yia%*s+#yA!0E>QX?{>K3zWM zG})54gr!pZzGOF8fik066y}=xkkfH*TPD0)h}QFQ-6h>7-KKz~2GIFoP~eifbZ@9J zgRBFcx~~d=gT0|brqF3?Q~F+yeC{{rw8e$|jW~J5z>q0DGTbG0Zd?PmCVBT(cj%3t zeT%}Ytt(Z19eI*%!ic#vaMObeHIXFl444y~%{vS^7nQnbT35l(&ZX->3I|MGERof82_k~a zqdSMkIG}x7SF2i7;G{EAx{u$8GA>bz6Z9;ubx^pJ{G5U68J|0>;kdN6!M>O;5Uad> z?vKLFkLiEfTF70Y>pMGs34F6XO}yX3tv}{sfrp;m+Fb8@-aMKp8CJ#(2T1|w33=kj zWoE7aqtje_0LB8PS_iO>ZH)J2PG8p>pMT~(_S-#*OP+~O-B754`P-}!c!`sH*=z+7 zP4r0oX8MxHZa4H%KsRTslsU~f;@K`&OioW8u6$U0=yR3rQTGk+`?fo0!c#~uZ@K1} zEsKs%5KqwOnyeT{36 z<3^8c5oMx_Lf72CqCwf>d-|Pz)|3XpPtTT=W3ormpKj%j4Rd33J(>}Q)E;Pq+Uz;YPfc?>QlOtXt{fZZW?%4$AH@%WtF{6xO3M`)H2Fedd4>Q)18bt8e|T}ARS4H_5Iv-lq!p-)(F@cB!9gX2B%hV0jTFW zDedgb2_htiOqHBy4KYRWuKr+vi;TafG8R}Ojle+xr6p-iWcK45sRTIg9`OtN|MNAR z9fVtWbXZ9VbVr;z?kQB;Uh$a+pJ^91L-nM+CBD#bjcC)c0wB@Tb|Bjb&I?If2LvH6w8|_nnYC{*=nlpl#FlspCDH)-C^` zbW9MEcy?yjUGfH{wyozqmIrYIUT2q9vw0q~-H-X?v zLZgKk?my{YHwvY$hggCRVx3-^?fj>zKmKPH@aPnDeVb^(3cUY>64Kp&_}bmtTvQZOW5QPrfXpV>7)|8T5KWvi4E~tl zkMN|8ate1U2#IL}XI5dSeV<^V#o1(2f?&m*;j(QsV>sEy`)zgt^Rvy=XzUT~?Ev&x z!L+`U6;aR3M}K~ouLxx4Yi$W%5;Y$R$sZGMl8P3sIOOje&$b%XU?s!VytlZOjm02Z zQ6D2n5ZK#EM=*nmsyQr918o)0~JbvZqS)=J>Z&m|9eq7#CaB#~NCpw@o5Z3ko*ys;@Zc zc3#d6=$O;iIeRH+{iMVF;$ADmuVOH;-?#r;n!-bw6s~*luf#9$V4$#hcWvn`j{GyF zog+Ip9M7kY^FC;#eZL9PLTD>Gu;}hWHU?Ivm)Tt0Eqg#d@xpeSP(klU*7dccM9ywlL=o&ddun_>f90K%_qiGd0`EFr&v6)Di&e|69J{XG zY!JchZa8ie$B-R@;oIk)Jf$T_(8ojC>g-Xalb4@jI!751Q*j>|s!cjX0G!4{zTJm4 z-u;cwBQ+Do|D0ap-V_6>`_Q1QSNl%#?62j0jW0ZYEEhrP{1JY1&XB|zE`ED;PhXe-wZT@eI3EzM7o%n+=AYBp4(7zX(T05Id)Or{x2ai z8u?#c>(xAEim4YubAJ&SZYw@n715lu0Buk`6o`s8-ocBU2#E^IPU{Gyj{qmCS?M6{ zKj~Vn-V?pt6k6zOtI z`sJ9r5{A^C=i~N)0%c%OL)MS-AepP;|1k9yZc#_w`Zt1vG$>snf=YKt3J3}UBHb~R zbax0y2t!M^NHcVI=fHq;4c##eLk$DN%X7Ww{Lb?y%v|fc*IxHt_vfzZ+QTn$tzSwK zQkJHODSBNK7C8Sp*YwNz5c?qW$9ZG6`uvrO4-GH&NBwBzV&ZnrLb6wJr*d$2nLg#7 zP#kro=~pzmF{ESd57cWY{91fAM1cE0*oEc>@j2=Xk~BZn$8?t5 zG{U%N+qJDwWZ^F%{8eBK%Xg7cEP{KV4uZb>LCUx!uD(`cBhDH)3g5g`#B~|4gk~^j z%)Yg2`+-fwt7;K6>ro=Q(nd_rI;mHkn|x@SPGmD2c3f5y3^0`xW&TRVOmSEL$Bie4 zBnzIe>3}&_v7T!e0dQo74n<+QNjcc7*NhK7Hzu+x1*@#rM)4i|ZG26;GU402@&ts{ z2Cwqu-lln_T>WvCep%yORWhYai9}pV;EMB?0RJYVw^^NmXG+Rc_jmf|pWgg>{t0wQ z@GM;v?f>Xz4(VIKjj8!2);SNuBa&kc(#q3~?rX3Ow43@-{?yQ}e66wg9+|oL@I2bgbzpl?L0<3%OKjQ} z{9=5kqgHw=l$m0(BB8t_v%Z(ifA2e$yofFDW&ZXQ?R#o52}Hw%<8MgoCrNXYJo_0# zqs)}8$(7uMwd!S-904K0m49kRd;hC%)l=@To7|6fr8fp$P%Wk+yB-uDoR_4|x69Ah z5*iyaX%?h?OeWaxMsmTU``7*tUMkC89rsRqMvm5jhiAM_CdeDR;*;ey}$FwA{#MhjiRWzQPk z+4Z;xi8Y3b7S4W~4wg|$>(>KGZ`y3s2E1!MR>NkkGPtNd++;(OV>c$JNP_-1`Nl-q(3w9+}RvWe|%|vQr#?R;ktDFHoA;T zfcvP$F^omj`{wT%Xup}*#D^=9sH;Zqs=d&rJw7}(ZQ}IpbP+(w<`M4!NVc{9l01KTjA1uYB`yzUxg>4YI_$ zUB5cn21`{wt%7qI{I4ZoYL0PrqU1~jntOi;MfFKbICLFkc1k^9yIra(G^hog6--}A zqi`Sk>hI6qxOJ{I*dgz|B$It?O~G;+@)e%;R>InaVNNvMl-(cPh3dFA;~I%OGtp)Ok>62gr@x~S)R^qwo3lTi7V@K78==6EofA=x znx7@@lfLI0rVX?&;qOJ|*@X{f+V<5VfbuAg%2+Inor-1=;3lc$v@+=-w*KGnj8(!@ zYOX)U>%X_&%?<-OnEWlaEt2!>*~19mEJ?~rzN0_j0HDE|;)J$TeOxSsecu05ZA?jR%>XC!ZQcfa~Ig*AV=k(C6)3$iOIQoeMC8Q7?nMQY zjiiNTjDPz$AAJ&nj2Jw<#gj9#Ro&=u@`e!%&fa>FN%1iOR2_bF5Mc9hL=WOhHpCw; zfTK4#_oa|`5RE0)<4nCDF4{3?n;&9M$M}M{BJ`1HvDBkAfyTe0d0Q&9>sU-wSa&<< zgHE%dt7uSpD`_~(VdxN@8hoix3ROt63GLC8ugH8ZIpD0JLFoSZ%eZtZ;%amF$&k<| zVegYo*ve!RRkL{W%!fa$ijNYx^`Aw)vxYwwo#d+*U>R9Ogrv=iHEk8m$XrR1 zWD_5=tdU-lcbh*Z8J?waBY)<*&9}>sQ)>z-v#L+r-9&2~0PZQ%im+GVClPTCe=hy|`?@$2%-%O55loL0i7vA^)@U3~amtkg}y~3%Td8Yuuo{$2TgkKp08!35CJ(No*bQty}JYb~3M5>ZY$Yb@uGLIY_KM z&IvA`ive5(U&4Z_}S+g?r9ff;8b=h32h zqo}PCx#gG8wwN0((|gmxV}XEY{@9&wosZVtf-??q!^to{y$qnmr<{9R!yHVE8$~da z&RBAIGIr?-q6c@zER@CU^{Crc9Bf6ATIWqD4@o^WE-G%c2!XdcN57^bRtc`+FD^?` zIUX6g&lYS1;Pb(t-Zw%1;HkG)&nlCIDaw3AzC>vKu{Ie_ub~?g@ZL!%y0PxrOMEToGof*zi(YQjA z&x0QTvFr!Mf{e)l7WPTi$X{q5QNIfA*HT#CjdCs~Z7&N}vIW^wNKOzHy%VGm0U;ev z!5jBih=MxbZzZFm=KbLx{}0UwLqiRX&gijKZ;Hat$q%r6Qv8Ma>l_6 zJ#rAZ)Ap}j0t$4rDZHb&TjlooWYy9?RTJ(0{gTocmrm-h5hmoz`vJqCA}b_z?>;Ur zxY?ny4$Kb#lM;B&y#rx8|x$-8~ZZBK55P+D>yhV zFn(^Q8Awx-Ow52?@Z8sUd6DKC@8yfj*fJp;xQl0uz+mwTINho*tg)xNY&gs3udc?% z^Jq8(k5DxRB9)s90b!fRNfI7sb%6{cWI8J=DI;&23zgWr>g*tWQ@#{Nqyt`KkKk`E zP0~m(Z<*F2Q?aX8hbVD2frdOeqCy$SJ6Tza`1jn!b65M7z7F?5f?gB0SA;s%Rg;|_ zGvy1Z?BdxQHb=TGv8j^ZyhO(;PvE?b^a zD2%@N<_Q^ggBf&jp@bghaHSG!nLY;MBA6GFK$iPD4uMDK110#nCmDh1WftI?qBq!w z!Hj+W03&|tFOPW&#ASC&hD1Za6P#wAIXNRI0^)gRUn=wf1IK|GvB+OjJX#aGl)V4d z0*vXu+Hxcn+OzntACCH0o^ag^nmfW2w`n#xks1(xKE$~iwXn~2qE>KCCkUJl;b473 z|MsJ{F1rrV;2gzx@Nw2i)TL!(Ms{q$h{P8m)>+!_iN*%#AK|T(LnqV=yI?v5HkTV~ zP1t@2N8dzDsi?tR8s5{B)6k-6nSdDXi-UgjoJ)z#$_YeRSDN=rFu?%+2Nsuq3m7Mu zeYC3mT0j>{oo{lp#1@ap>4#?%#8o7O7EJo7DP^!t+^zsQ7VL)jSLC6o0% zvwBIgN(H!gR}A3-~>@@q?|#ZQWY&v8A2reWc+MVwSjdv6S+7M-9SJ6WoHFE;L$ zye<0sHNPp77xPxs={|V~5f4{WwN+ULlw%%>$gZr;mF9gCKcsZQnmy7I%c%R;gRp(E zzYA&XvJrM)mF**Z;O>Xt!+XcNSG{R{e1zu2?5V6iDqQqoO%|}F?dV@!v`1*tqJDWZ ztPN4*Xo+u(+VU=y&34cs11e;Ds>rlrDE}XGuYpPh+sFM)Tf<3)mm7b-*B(Kx+JrvZ z>)}#5qfz|f!n)`r?&0F`9W1nshWVR{nW(!B5Lw%>y8L(xi)e}G@VsWx{gf$b-}wFG zz+z~5A&|v3YW^!;1?Er>&DNxLE^MVO6~E0w5h?@fX=M*zYl$Tss5D})o%cMS1r4Vh zuGmOTIW%uF=vA8X&|6vtV32$wkpM#*Uj*3C>Pn#xBpj=}5jw?SzVstIiZTa?6aCo8 z;{Y*IPb>Sg>uEVzY(BPj3J8477e1F~WCjwjjmf{~wVI3dxo(at#|jzTU(^L1VA@iH;l*#mc+c<()f_7 zVj1$zvsJXy_B%(hvW8jruT@zH#)+bJz@m`5w4e(W?Mz!~sP)CbT_ku#kyo2wX3IN; z>itv3zp4^pv5}uN{*bN^R1D2n+#$qy(wheBAbTaJA!7d|uZFQkvcJWCRmYp<51Tz1 z0EL}~0xl@mVv}Q#qn)s2Zqe%+GR~F$4&y27sY-W(6Us{BwbPq$rMFraQ&FF{o@r*Q zv6e{ZR3#l!iR(X22YU``!Dy)@K5hS=(a3a+COa7~&=vP&Qil=N6*S!q)TPY^!nFZt z%kEp1Hank8XChfP1gT!ef7wV4cy`ja&+SaIA08{f$1Z8N#|%x;*-rGDy8ys*b#R}`3 zT92SQ%afRhOQl1pA0DgKwkA@&2%cTtM|kK>mCq|gfB}M{=Y!(5iC&@eTaMd;@n7YY zx^hcj$?r&imjw@;^NC|7=s&<#rgFuZv zxnfx3#ZbBXkQPw(q*WDT%XI5AlDl4^$Gpm!YX6ryYT)2WR+YSAq#5HDyVa!QpwVMQ zjiuqrUJrLzdQ32DaPLbt>H6mq*6<@x>bV=<{5dXH?hmxT!S=Y)1S9VaslN9-W8MRw z)Odx`r;FUfcNbsT&B}3FKp{<_3mag}w}Uv6FaC2>=GzQrKcn4S>>CnqUy9*IBC21V z-x2uuSJS$m=riU@ZiU?Im$~{^IOHV;a2@eolIo1Q2_S_3NHP_c0ezgNK!Bj=k-6Qn zE@65}SpcQW?0*f>qsrPujK^ZIf z?`GUOx1HOaZWX^tdn1ii-FPg0Nl1;I?TQ1Sd3J_HX0dx>*nO#I?#0W=pWQB8zq(eR zRQqE~hSx~Tv}HvA2DO7Y%Zg*_81uds35zl94cD@EofS&>Rw1J*ehDXS3of8aIHgAM zUF{@)?*1H>w`Uv94xZ{ODG_o~!i7m9;kv?M`f!G6OjlVk8NfrAYS?=C zR+MRvy7Jwe9LLA><8O)T)*5Od9Y!ozwqfNOK@@pFjF<~-E2(@*k`5bvEA%sUOSx5n z=8QPpGLlrOHB65Xv015#QFI5v%fv0tnsqjl&-Q~s)$BhaiB275s`fuijOfF#%C^&S z!`9mtTcvMhP83T-Z=OaGrv`F*nq^Bylqfvh#Q?M8IBWf9&I7BA>f4|4XGRX*x^^XM zqK+kJc?$nR-%(7J^UZYs9R?+fcH8Bq8tAO(hiIvtGZgz}HOtQb$@ymK&iZMNV#g(@ z<<%>Hca3M_YB6prnopzX{FZXt(u355uU&VCfRLF=r9t-bEm zPCKB7{K3yU6z)07sDJ22ZcXuNU866E!+`E6uF<8K^h44A!&IJl1h_w?))KRpKq z2<^0#ER7GtDM+^0(4l?6{%cU9NQXvnT1LsJ;jb;)-;A28b6K_;>_R^hAXpU{ag9z; zs%H}zjYf5q5y)~H-0@_WijJj~@Y!Y5HEekb%rk!WqH%^`x<}ZFgeVgO-hdOH-*GZ{ zGlN6>tnE*X**3&q%GzO7gwt)}VqJ@imZC(7ms*C&Lc+~WZ6P1n_p(8~BwV}L3i#U4 zk?+V{D2Soe6udl1HcXM&p-MQi#-+(-FJay4Ed*wN&^`xiPBsyC2N;s!9Cthm!qn&B5P32}5i- zLD4L31MuE@8P2W79+5MgSUb>5ZYx+%)rhM5o*|QT8qz^p%PfKXGp9V^V9b=2GiC}%~*i|0|4*WYaj$WRPqxH9=H{L;tF!YBkWpcU#Tbd0rn z(Z~@LrjB1G#Y88($hSD;E3zfJzhhTo_v6D4wjVuhUtzC1Ywf70qI)Jz^$gx!F@+X_ zwgcoDV|WijZ+xGFyz&dY{Awq`Hajv>bli1f6=sw0^ z@0M~!TpPEkIM#!pPw{N{@vDrX2SGd1$u2y6-y48lH7C5>6iZGmX?V37J@|g$Jxjif zOsB~kv!$0x%W&(FZJmLB}8nv8@8HIbDiA_W7m8L5RI;*WJ zsayDWR8+*d`1LI?JLu%vt?*<#z@Qfw;P+c6hG}ukZKBO29hosNnKHErkWO4JEN3o& zc@CE+O>90P0Nl#q{xIen59X`Cqv{|`G` zasC&OZwi7Yqo_WbxtB*9eEzSU9e&b}SknHi{rMu_%v4n;%DBdy(G{`Ik@E9-y_$!- z2=cPV%*R(jj+M)CX;^?BI~mKX3RF;{(n*lDjH6FKp7gWK*gplw()1!8L`7lz@&gnq z&OIL){KM^{vcxXZM=O|vo|R%!6Lewu`2g;CX2 zT=71$9P7%~v_H6AiPLf4E)hbq8OF2(xxG~xwWn^$FI?sucVkXF0`onnUM{4n2Q0o=sogCV#?`EI{J#0Fcg zbObQoEK@PiPHn2@zR^CXvzs3~P z>+t3DkyeZ3XKNP|O@&~RPN${7&GZUO^c*bEb_snN@nErp?&>-(pZ4?{nv-;pYBz^6 zK$b?&Q4hQ*#ja~{{_EC2iQK#+L{5XI7DnBdrk&Sr$JALb7qTc%`zq{Am#oJ=$y=EM z>c()||9z0UP{~|$)Gcht^b+t4Jdh%-cPSWbh4#^&lHLrj9{*VSp{s!}gjXbS1RC$2 zue9!j*{%Jy%a!&P>Iy_F%-Q?H2k7P=D(RnV8F>QB%|AL+XPIsXKS6c(Rx;N37~3{$ zzKgdqufab4KJU29qV36hVGHjmT1x@+1~>p_Cs(g&6!B7M@Y65x%c!?jyKew&O`j}d z4$0kBp4mrkU4yYds!Ic=kAXua(KaZ0`X$;Q2a-L0vUX-6c>FU&wOe1trZV1#CpR8g zV+M%S(&tOpNEZ!T9}Psua{J2JkkhxkMDrF&4Lo0n>6JIg^x=uLb}1xgm*`x@{?l8O z#rmsOH+i10{Ksz>?){?!Y#V6&+yfnCM<{167Xn%M2>95wNkPII_YwuV!5-DKHH%7m z8={~-{5DLwAV};R{CJ9v0l;P0ZlzfkUGMlyZI&2osOJXZ7tz2ZzWdOF4F6v(fSTdl zH%_$uQeG30<3R1lwm7XrX0C!Q*^fB(aQksRy0*dM3x=aedYo!FiZRIJ z>Q9@u(B7h7r(8l9zo)q^Vvd4MMNWF^rn>3t(YV8$O|roG3-D)E%dkcKHsobK z-^4H#CV8nRFO#g1sbT>>wPWL)2pqh;263Yz6`bqWaYqg>i?qWVrmuP;_?J5+hi|9f zKmze8!)gqzfZRyzzQTXIn5qMZjA#VZ_SGUA=Z~o*r zmN!bizA{8@Z}*|2PN29@}6BoU#*LY_`I0(LK;bQ-Z6-O6+vhM3VVy2VsQ{ za=m@46gDl0aLzlyVLIE7!E2z1@Xz@_!j~^Svxl7LUnSlLlnVh)e^4|7=p>!1#EU2# z4CtQthEg9nsPzUj?_-JY2K2p**sSq#7k}`uL>upMr-}3q+#O#QnKFpz6}A`iBDwH~i|m%>BC51;Uf zw2X4_yFA-9(->a!8P}*v{2`jNj00+0T&4V8+s1#zdR|%;f!d-*zdi*-VBtk`y*Iz+ z`i1^_BGd8n^39XXAtT0B*G_*Rehu3Sx8jH_z0IA?ko_wqUB14#i4fhP9MAWdwNY%H zac927LiomHAd;!&Cu^L`jhjivLd-_CUWg9k|9eWkr4{^|GcJouQ!JP&Kp;!O_w&aS@IezZZ7`G6-eo5WKmiCeiP8W3*NXsDHnGrRABCM())1S zY;f31+|OzdmM zH3rD7KCZjB@d2ZUAy@D6<|%Z3eR*e}UUYfrZU?+%9?^XPJ!NS}H zoGbTQdiAhBR{pkMg7ZL4yrNCsskHs)eZpd_#|0}lE*|IbDzb@ynTQu^H0KEvYt{2THy6oX-Q5v2P zSfxv3*(Yg#xGp}a3)({!orph-1&KO5%&J)&yG`yang;Pcol=x^x{V-8GkhgLB%sY| z*UV9|r6FV=I6ApOI~GdVH^ek?BkPf1Aa@~StGQGOM$J}Vb!6(X5)EN_PfcB8y(Iy* z_sq;o`UWg@?rP~x1R#6vXo;P31K+n9b$PQ4;oIHvJT72^Z5ZbwaFJC^H`8kRPHy!s zvspDbM`9B7KXQb#3{UX<9sDV)1&}s=OcR8YQJAiwT&#}x-QBbt-$b19{&ssVZsG}V z>gKZrMMVjGZ3hO*NtjIO9>`4-(#zhP>!?4Hsv5n3CdN%Zxki9;7%fMWo&6HAEwwW7 z#z(jJhQa69AHTz3O9p3T{UwZ0@apWXxH~=Vxu$@QQQN)*a!|!2D|9f<$q&7M;(dlE z_9zG=-di;>2rbJ@vhtH#CBj_$Lx`+fFh%vx>SFd&b^Zi@zRD7jvw5x0E@}UhZj^tN zOz>e6LLM3TqUyu#UX4CWtVyb|nOJY2JvBgm2=gz5iSg(JpX{>t_fkzD01!6( zgPs3Zk_2SVSEyeJXP#oac*f}T(DhEqJGzDhL^c<(&e;VWQ>y+iuZ4h{Oq@+%!p1mm zrKSJvh?Kq&aJA`2=UPX017v%i`oT)ysXisPGD0Rc=%NF>8uZXm0Sr7HX~%y6Z>8r$ zBW#;Ra=4vRi$?KS`^c}&f9a|?a^;2%jA0uel}El3z_oxHY&5=oA^bF$)^`Ns^NW^H z5JZ;J%y;a=s`#jL z3Yh3`4>6?E&60ry*JW|KLb_15UgtEB9dXp=+U;@+ny$sE>q`28=jbF`M1p~GHI95Bdg3d4R8=nAo02AJA@5i^_$dX%c@el&LiYB}qo@l?~LY>b$*@qLu zX(ECPr#a(U=g%g0GGV(CxlUc#O-$`cxg*7A?UHeCenz3NUr6P+XH(!F*>>{z)00CU z=T8XtTb+KuaIp5h_qC8n!3G>NAR^=sIkq6oiR(RSU|Gbj?Q zwXs0()BEz9!sNB1b?h-7bW(^KoT&yibb{oJaE^>sSsNnID4$DYcAL+b*}>gx`);6( zy2DCHK@G*idatir$@>2I2H{XzCq)5e1MUoUyk(LWU^DDVS~S_UvWgjd3ydJRtyQH-kce9;3G>)Fwv_|M^Uc<(`k;o(oqj;hc1(zUl?wwYC2$`+}rfAI6dGEm2lmof=C}zf?8X zvmo|VrmvO>kr^AwJ)CdSUS0^;*dMMm4r(J1UL<)gQZkHH^*SnNzzZobto z6DKc*k%r1|KSOUEJ(;WWbc5j5=j|uIcsiP)Xs8{}4sC0sNP$4QkFDI_UgQ%pdXkkj zfC8fhyV>q#)UidNa9USFXSd{^CyXoO4!R);BFVCoQ%7%TByo(~>Ifb#?gjSC{U^t& zb`}@PO~J^S`($f@a&*5O(y09X%^+v#$+P~^KdQ8+rgEG#3oYdLeJ_!>IKPgR-$C66 zhS@LPF6=SK3jVcsGnx6Gd%j4J+~Lz1jSw&2^^aa6(UIXW_xydVV=G`^+Ed-QD_oy- z-3+x7ZtOBplJ@4=-LkH^-GN4kqD5l@2KK5Kqu4K0H=hPOn2Lw0c#h^Vq85W#nDoC~ayJm&w8~o-$p<;7Rt$sbxh?Uac;@kxHtP!tx}g!pHHMuMxJ1H-*nTf6+9I zZ_Lw^|GThUN=l{()N4#LDZaT(Xy&519|EN9c`DMtxayuK8x}s z)ECIalA;cQ7njW&=5rCV&s~>dTmllhaCA+*(wp*D=G|zwd-{Tvt7ork@&cJ?_gL~| z*=0N@o0`uY{#OggL8L7nPrV`O@SUTV?$(3tfof*c!j5&)35>a1#}(foOZ^V$9v(Hk z;fofvy$XbMb2{p7B2&8idBb@u=kE!R=d^^jez?AwGM#obNav0>@8}1w$p*ZLiPr_( zk52r$<#%OpEPlRm!##pw`S-PDw+CP^MjGE5D_kpHBic_!6l%Q7$Mbz=;@@eS3 z))K7`OyV*q@Hxm2OCibO-tJ{^vs$E5FWTxeFRAWd`o2=2SHH@FsaPr7^_aZfA=Xi& z;Q~mIb<`&p$V@~5X#`yKpf-q@!{hYh93~RuL-;O=h`?~tLSjv%jCx&iEGH=PJLkPsx%%f`!60o%~V7v@*IMhbD%u1G>5>mO}8LoGRK(ivLYOynvoOo$_PF<@@cyIiWQ7a;YQ+rBy@E^$!LP(j-TO?54fV z+u|BvMk}ddAVb7hH$!vWko+hZ)U+4*81y(YIZg^wFI zrfICyU#?A+w;HBZoUM>``OISB02*EITZTkiO9^W=2E1ywj$9bpp(3RH{GR^k=J3b| zdy{_`SNh!F7~AJgAt!)X^SC=T!E0njH2n6+MVW;3u%FACMZL~P{9-FmZmh$!m7Uav zHup!u7-gzd-^9C|Lt_cPtX=HN`b+klFzbGN{4K5%J&N(re%AM@icX3JQJ-Ydfsw->{3ie?p zYaK8vsS{q~ce|1aa^)JjIi1X+P95K)aZ(IzS(3^MV|yx@T>8b5y|})asouA4^zr9$ z+rcLoE&h7KJgw~MfE3nO0^huEriKQipn>;XGZ<^eO5B8PmW{39zWN_rmru7efDh4q z+Xe9Mgr7$_i7|gF0saMQnLC;(Umo#$k_7;a_zA)-#=7``3o~uUV<^Ora#G?rbSK^H z1WhfvPrQj0cWPEC^Wvqkm%w_ecKvb+lP^Xl5DS7~AW8JsA4&L#`O3dLwU4W?L!BSPTA3Md^`gP|-8{QC+w^_t&+h2hwn5Z;Tm{n0V?;@!`=FtYr% zG^VWecFlLx33xj=n@2bHD~jk#xmF5U0)JFsj>EBtJbco7y+XOdbHz6vc6`KRY1AKd zu7)Nn8)>-TeYjdHP&=P)uy%2jxz$V~P0BEFqphk;w2^#IOxu`T@(AJJOTHv3kn}na zXb-asI^uCvh6ZwoGjPY9OZskD1!@?3vqL;Y3l5!)vT_%V>K6a58&;0~O}jq(zGon_ zn;o#AcbDlrlyy)2j$wGh$mABTv?Ir38~m0wt}#lBbo%EfRrlypg9M{ObTRF+;YZ5? zCAk-5jT~Pa8Exk+BhKburnz`&{2~ytq=Keq|Fi+K?6QnIQNk;jHQtN57Ftux>!!c) z7BQwe+OCS?7%w0M+d3h5)tAy(l$tvU$>DafnVNC&6y0beo4Y;hiNDYOryrJbf$sC7 zza3^82!-S8rS)z!0l6A0v|&!B&o#=J)9*2sVL9B+wFpj!g^@Qex-n9@T_7P zi^4Fr1Ta=S6BuMOU0~xY6OH4| z$?C}KQ)_x(S9(Wl@yS8*@A`k!!`$G&8sagIKjK%cXNnLgShrN@8q2KUYu^fZZW2pV zPA9#gJ<_eiL!{~MWZ9?wVgCs)|3am}8P&@2UQ|%!9-}KSRDASqS4tDy7MX9pE91r| ztePaD51sye-1YHy&06yi`JL95K>YE;AOAeM6^R*QEHW;wtahI6uIoHj*mFdc0F`+C zp@6KHshjCu$Nl~GF{6O)Rmwm@IU;z~kMbfT6?B~%_`M@w%TJ8t zo=$o=p|VZ0cpb>4HS3s_;D2eziv839c($D&ly*(AYyE~IH^!eQc2XH8J9a_)TlHEB zYl#uP+WQI^-`2XMNOh%*t-@wwILQrxA8EO3O0xXpp0Y zG-Qh_G|(Zjcd`F3e zIO7(-^sTYVtyr{1*jPsyTYk?oG{1}RJpd2q0Cpv%(=_QHFHxFBu2IV69Qmpm8)_^p zh#SPhI1n@Nfdu^$hOzn$tIIHG`&M_#uFCT6dhmQa=89_N~)*> z`+Z3s8(YHka%<5WxAHu_B$O;g>x7d7P2FFnoq>omgR#3Gn*vu1`-a5c@Ed*zB{Wt)In9ABR6q_6LX2BqTpt z7)ueuEfGdvp5HM$--q_0npsTeIHSmY#(Ngb&68-iN$g+4^jPeMjz!PIV@k&&;KrG) zb#a{@li*8?Jb?N@ZEA^rY1ZAC16zn)f*7_9{JvnNkE>o%d{U?mH|-Z+`Wm;ov>z5a z%myhnQLBc4zPQOY*?Ipo8>$+9cg)b44bWo>VuPII1TtF>_@k2VR$NuOn%bn9x;{qO zNO=^o2vd^lh(vR4feR9@5UWVM05n?MXYPiPUjKAu1oFUI@wEMBCF$rSCK@@cY+C*+ zBX#qW&PUuhJ$+07ajzfSSn=~#JTL!PDw%-wW3GjNQb(CmnziSe9}OgYs~PF$$L^|l zV)sDL18I(kqL>WEI>^7&D-w^*Mz^*QW)PmXk?GJ#K^o2`xTVP3otFOku0XO@l)>`9 zhO7jYZNE;HALo3aR4EkWoR{$FpLssOYj?@m&XUubpvB=^D+1^#p>|W{ENW|g-TLp`|SKdt)!}4p%#eKS1w}Jmd@x-elB+8v}voKJe*6d zxi=y1FWouCTUPd!Qh%eLgG^@6?rh`5GpBf>?J9TsrW&p=pkg4!!A)9##qr@xj^?k>F8J zYgw7icR%bGSzHmmm8m`TAH)+mjAi-L!0{WW-!7|$Xv-_wOJ2q{@px&}F|#h_Y-&`B zj-SG+kuRd%yNoM$?4>nd$Mbm#bK(7?~EdU-h$t zs=wDzx$i~8t}W@DI=db7p~cU9xqZPyL3!12n>|*eQ_h?gRm&RiyI$Ox!F2%6in8%* zi8t2VMjvN~57Ym19sbX4rM8|?ijjpY8m{r!7cnXLwa?~oBY(B#TuZo0Dpnc!yneW! z@~Z+h1zoKk55RYFhmqR*^t%b)x2yT}&HgTmk@|{HQZPz(>pzepReS}4p}lrRzlt1Z zj$c}GlxQcZx7%Y(mFkrRnt#OP;CFh>SC5_d=bLweuLvmiI)09xgrzgQL->Yd+>QKL8bbi{1J7U3fBW^wT$FCmhqz>KFY^No%D z+UMWZwsJ(5{N&ONTRR0)VCwaUhp)bY|4!dDF*w!9WL9qz1!mvsqqyQbxfB+>kSG>) z>@4HY%J?`YD>qSkMV@8j!!+1YZe7s8$w->y<)mY}2;0io>g|E1_lqrnEOg%^oz% zAUuvYSfHVIeAE3=N;syt^s-d%0pz$B3<|U*4;b-Bljfs~RN@v5* z(nN2+k{GRcRl4g8_PH`n#}XBrFnCnWywN?i-6V_OG#Z8baiwh{{Ai4%0=VX#t215p zkGy3?y*9q=;01I>@0UiO^#$PZrQc<`q)}DL0MG>{hEBQ2mfSIGv0%H=2(iHi{RG8{ zd&KVYq5&KvBG4&C@R5jOQxjCSMN5Fn%Avc?-$UYwHLisOT}yh`9lL6+OH^V*$3?2F z6;oHmIwnJ^O_Z+o=XCv6EK5+UX2hUE?Vr~==XNvLFSgk(m_)7rR|_C5XHlF|DNgf% zpQcU9r&0&2`{X~RHWNf?fFX3EF#jznyUqXz{k3wc{~DpYnm>kvM5=3sD2|X(7q0IdPY;k zDE9|n^j(P%tgRzpR(A&<(tb3A$PqKog!Con@NEBA0U@65_9hG)*6trZ9c&EAxS3L9 zQd#s8WwB|6Ph6ez-0w-DdVNusK`8N~6R(F?fRrYaF}P-&;kYg!VSQTL`h{Ca7N?qY z>eMypx^8+Q2xW3_eRLv?hM_YruV}OU+vI?<9eO&2-^9KeKwVrLLh{PjVT5nUWqA?Q z3|U-bJQ;HJqEBOsqNWvu-!FUch0h=6a&!#bR5I`?lCYq_jHHfn?bPAf5igeoS!fjf z#=utgD0}-XpYvbu(>`r*Ig~@SEB0thK&{^F@g|1Qqf6Xn+*pr()$!XR8~JG~Jsa z&EnlHlB+Yl%g&}zEzeNf-;MWYCZb;YW?jh zApjM3Acs?Rh~0c?us;&FZtHqv3}x&E?GVmPL!=Te(R(D3`%@3ym%HWGH5pUVhR<}P zxMgYnjcO~sBKRW)AaotFdo{i8nPih|=OWXnSP|FaxoWH}76=d{O#{KuUsWOU8Tx;FNtG{ya=+D2;9pc|8z%nWeBNl4|AuWQLL!|-b|KQ0?AEi|mTJT~VG`)t5paqGe}J*zo|trIEp|5< zkA1n=totgL>4jT6($nDBlKXhVZ&E#M_@Yc6a7~Pyy3ic|IdrbkhzIiyNgSBIax4x6 z{UZf0(Sw}xW<`rh|Hb(S;8}XX1k~S-Z%pnU308 z8d$*+807pO`%JGOuG2Fmo&zQKW=jk%B`LEs?B16qQhR36>I3!r>P{~Qa3*%kZm}f( zJ4gd!z}ygas9pv((d*Zqxvb4HS2Q4TXGNV8?SQ7rWI+Q`guiV&@LyCPCybM)N@8JJ zrlb>ilWV&Z+3(X6ag)SyTSQSsDjNL@BDVIqzSNmpj)RVJBA9=dfpJ=*?exrNe=hwC z09&O=g^l9wK4zk92s0s>s}Yj&-~UM>mp)YP_4d&SNk4r^LSP`GlaH1dSk<>Ap2t~5 zM`iIvyGf)Erm!DY{25v)#c6Blok_A()-~G>yn1|v?69~V*XkCAOe+gEFM({QRphH)s*7>g<-ebrC)ZvSOyLP7M zeV?ZZOdYY8sn(vJwY8we-b~y0?PG4r_a4?%s+WSbZj&KB1#3`{=Ba9vHrJpKbMnLX8+1<^qoeIwnpwrWGx|n00?fmk%6;*sLXF z$atB3oA30-+6)+4vhybKy2VAl#E2v8bQ6S`qCVZ%#5339wEdO2i(bcu=DZj5YlH~- zw4Wmz{{(w9wn&^v02TCvidukmAQ}&Ghg0j`&S>mx2Zvu=8%nn1t`c=nhE#reS@6h6 zAWUzoBm5z5DCT0ZzDC4)Q(b2Dbt5uB40mm5uzx!d_?I)E85Q$KrWsI8xm`8OL=a~P z*ASbg;c8<=#Baz`Hcx)!-QN4XWE?Npw^_<`R>d(v#{dI7zu z6n#Zu<&cL!!p+2r7m5~4V_z4(^p>ch82Uv1RKS*@Sgjj^+u>{UY@>rhYAZq#7|nH! zxAUtyQLS7klY9UF6sc-FgT*%m)OFv(zo&{$b)F_tvmz+8T&I$lfu8R7UZFm|F9ifg zC*w-H^0tnEyv*LSo*cYGy;@7HkOvJb(L2_x(@nT8ez0$iP?GJ_v(l^C>m143`)PN8 zm@0@N1k~J^X%)GlZ^FJ;!QJPI`Y_QIXuZhG$rL;;DTGMSV${{5Vr=tIXh z-l$Y_W-+L6T@FHoH{U{TDej3_b4BHH^EotTj*UHBX*|^nT{=YUl~Hm=w++g6F3pEv z*>(q27P;PebnY>WU7m}>GyZW>k4!UgX13TuhWfzZQl+VfRbBCkEh~~8=qq<*Y!^Uu zm&iymp}5lhVtl#40L{@?7O+PwoTy%Y4kILU9Z->pT4BeYwsQS=Y;BVJuj`@=M#}Zh z(-~`jPvHFC6{G^4p+~Z{JNXcdV(D1MoFJ|2y1+lUN80x`LYtX`}r)(HgJTaz2^^|5>T}m0O$Kn%<@3 zXBSKEgr#Llw{Ja{fBo9L&O{iDoiHyb0ut|=_IkZ+-swR-j{VQ)Uj1^21F}{wZ6QMz zciOdqN)d-44SBx(@I8K=eGfvrx!qbMRJyrt#1jcT@$7D2>%R6O#(cfpt%`hQZ(#Mj zyYwV*YIc*Y{B`djnA~&oQu{J?b6e!u_;Ke8a$x1RO2D-Q^gFJXnIb8(;Nv|JD&-3wV9w%g5k{^_a3Yb%0{AZjR+3NBF;17BnxKEd9| zf@IvLF0R0|`V#1u&q7yjFOn0^`!p>pFVEi8{N*bBH{jCT$gL%RF0#v_>?Mmi{B-ZB^ne7dlYDPv`~`slpXPW9k~?f_ zp1C9I9i9@?{wET%GnPjK=Gq`igjVC2M8}6(aM!mGb4)Rqf&*36GhKoUW-3@Z7EvQ9 zIW=BSbb>jZJaiL#QobgSbmHEhMILs5Jn5)ySEo4u)T|M=l$~=@_*WG;nW=~@qcMmF zZA*|H^^Az@5rzBl@S$qTe}~M47GuwaQ&BZis^tqx`aZd z`8gMn&)*dG3-^w!F15qzL)pg4aVtr~wkbZw?z_)oMw-@=nuu9WIiBiLf7WfR{5!6c z`#Dxn^Hn|9*hl2|t0;Fr;rgJONLI>W=(itJ6Rq!AMO``6BeRhm?V1)k-!9f*ewR0A z-vs($>_j%ynbf4?zJY&)Pe`;?8WAR&#Wf?DUm)A^Tq_U~up6eYl=eN;?$d_t-vOPEX#TK68CP0tLMqN25!0*e|5toa<4G-!P4uC^ zBmrLP0XhG~XG3<|dWk}PCTFXyH_hVS4?VBocihfvwLL>9aJh800;6Djrm{CDgcO6KVJ zbJmVq)-??OWHzkuPf$Z0VcQik$4-R#j)xd4ly-Fs9W`SfBT#V%{&LAH@{Edy0Ss-D zsUKewZ72#aOhZ1X>9<#l0BP?wd0DV?>>he*dV-qY!w1|sNp}-qSvwoRl9YSAhI22^ zTV9w~rO|1%2a7P5LMo>4z&P4_hLqc=96?JiP7f`r-H$n``e$Zjim4)-<8r+!H2IhS zin?>rudM`wC{dX}aXQI-!}<+BSbQ1}+ui5B<>}CCLLBC~4 zIQWBTCErP_@u{EJ9@V^ zl5u)-cX{vhI!QR)+?9WKX7CqP99S?<3_evzFF^I)Zm!ze?;tGV2=4QAr_X6fQis6- zqPHcz1Q{x*iC9FPR=PE9aMN69!Ber9bIkURb%j4<+<|*_aJ>a0@-VhA_Da6~Tab1% zpL9`v`^n-_*2ZE)XF;JCSN(onhW-OFF;t`ZXQX3WIxRR|jfEf=E+D!MspijV%nwOn#e1OJMbNKzmZvQD zY~>0GS>`>^7g`z+`{ip49OcsBZ03CUc}!NVIMJBCR3fl*uI$JeM;mfI#^|RYc=r9L z#SV2Y-2xQIuK`4I85BhnFTV(Zi=bcGD13RPIZ}7ff~JR+ms8;FOmEC##V?B){J5-a z()=GS0Iv|&r`jYf6C^?L@wJIEOgL4k7Au~55mRc3<1o7{iuDOwscbr`>*6>rd$b0A z4d0T<46wM0DwuL6$u)b(K+eNvpI09vc%Y67)F52Bz$?VG0S?K29i6=D0LkS_6G|ES zbJs#Bfyns-)7;e9huc|jx~_MHbpvxMyUIdBpC zn*jWGpBAkjZ>8R&r2@C=Z*FnzGsYtd$bgU(YSJtHSzPS}DR;jyCzh+@L^TWkWt%SE z%L2FhLXY2FR~Rd3%@79es>YxB1kB*zPYhTqM`$O6v?^lB<9oEsDh&KP@IV1wxgAAj zG6yVjX6I|-=RcN1KMObic8Mb0{flJk{l|!1z?63)jE*c_e|T7L88e^!JR0Q`IfSR< zsXEEfaVOR9rQIQ~ZD)o!|NA`<3CQ!_+BI#1bRiy!vUKOl#OQ^c;iEA*Et_B%@es_Ep0 zCGayVmX>|{v?VcqMIvN~@B)oN>+e62_%R~Wz@^3^-pMc`zoV(x@S*Oh_P+m!JesQp z_A{*L!ZFtL&wyIbtB8vSlJU{l^#@&W;bp(j`0yQ@(MZSdJ;9e5pC{xL;`K*e;20Ai zcX2PCcxTDu06_qf@d(O{X`}I85X$D+_goumdwLDmIZ%NW@qG>bmj~R7vc(@7QOwP) z&riGroR1AG$w&qe3=K1X)M5I?Th3~suQLn1IG+k7+-yW@-PItu4;#f+JK%I?G&+$$ zQka#aA0bA07Hx5}o{S+(c18xPBEf%WlOq}wRQ|QA8svK^$ov}fpszXLU`M5wA+<&1 zG!%!v6PH~`4u$DryxiC+Qc9Q}FhPH){@wX)ZTeL17;n>PrJ1&lUyca{ixa8*_ZtUM z&5R>}`uJ}{CDD_b%aPNYtjQs0s3_itJ^H8GkOAjjpAY7?E9W0XOff%TvS_LCGx;eM zlVT3b3T!yP*NV{zr`RHmm%Qgr!s3TFN9i2454RPdO*4&u?f3Ox2I(iTNLgB$$*m^c zDx>-}$+b(tA2n(jy53rzT&wRqA{N7Ly)u)P6-^HV9!H!7iP}l{CS#ohED#(on0MCc zqV+XmW1GAa~C82jhYf|h2@n+Ffd`!qf;B}Bvgp<)M933szq6VDI=ZN=&V?7&jkrR zav(8tVx|PI`5J=~-Y$X$0hyUP-CPTHNF}cSU!yV|lZpsaC7>hVM4tkS+*=3`sq`nz zkgRqn=>gx%FIt`qZVSonpWa#<%<6(we@{|y<`0*!DzvVRL6$4DQZHJ0A&04Ob=Jt` z3RUWz@On#r>d!hno0eaTz8q;GTrG^{j6iTduRVKYDkwg#;M#C1HX%eY8r10YN$>myl59KqLv1rTUh8q*4t<^iU(VhjK2J}> zhc8!3mYy9<{({>_f!+_EY8Ls8T-Qfu7KHxB)NCA%fF$&5&$r8Ym#;#K*jV(`F7|#$ z*?}Evuh;87EbYrKCX8)@PT{ag3u&EVrftG2FT#RZ>E^a8!;3-36jH;B&M}S52%HE# z(Ks#x+l#@jrU)!gGUP?P(cjHCn^Px`%sSN;P;{Cwp;(AIP{!x8R%v-c1hF{l6!oy~+ zQU{S^c+5+6bJ_s5-c(62mbiIb9CztvhW$4?NGZ}m`wmx(vtX-=AbOl=q^_?-S74f z44{G+oH<>}fj;RbZPeyTA%V!$J>{WwZT zfP>94s^y?@ra0q>egnqfyC)=eC~u&A0lK>&)W!5)EU)Z63ejTHh0hz6CE|&^NXbI7Fn)bDhgC-NBo~%4bc7E};2-O7Owi3tAL^3ZAp87eGe92O zPlDJ;bnuh}z{6DE6>S&i^yk#7x5J#uc$lunoH-xUA|W!OSH#6#56S)|n3}w_3*T0g zLA)1f853)Mwsc=l^7^Jwv0gKZbMb!v`!^aocL|c~r6D=Oe2vV95&U1R$vwzH*C)qf z*YZ=Iy;ShdIO<63f`pR+?P>Pt@p^eqf)sb)uc<}jG(IES*4y^xxdjGS?OL&S=AQ6r zcq{>(PS-gq2wOzC&!-E`(&`oF2PqL-YfaDIkhOM@xl!PI5Is$a7O`c5r~H5R(z!V? zK_k0gSXz*w);OlR@gOfHfpok*oIw8V{n~(+yZL33XKV1DzuRWx(aEmV)#1)^U}oZ_ zr{U<*9)v%=2e2@{6t8t=W)%160jxv7X{f!h;d1S+_0NcZ#_7r8n0&i4%t^p!E;iO#xzrEEC;x_6|sJ~Jqz>~Czv^+{f z&mO2E6E}vl&>WQRrGUpEoN_tVbDt?P>{}2p4zi8@09xk3V{x(%rxrGDOT!lY?%MPW zLv&X5D#^LoL=m9I!hM4sQ-=$Y=Wyn?ZbeXVYQj2dNqKW5is9S zgXCIm3rk;O!%XAFzq}?04k~mGZIu)$Zp>4NZS>cAk!cfKFwR>kBKVDPm-=DFsK15V zMT9}9X=;04Ap6LuSI9IAIfBvA( zjB=x}kG$1BLj2{f|Mic)+xjsOwN2G7%Bg9}Gv~~o69oIx*>iqY(;E1AQsonHqZa{h zm#^9&4GvK)qZ!>RP6>*o>d%hc+Ft2;jJ z!kfk-*Efvfwv5|mj4gqjxx#1RTnU!;6{^NpMndm!c{(FFOma)o|M==7)5|glfnwJ*bAR0xY>BTG{X3Wo@3Ydg|3@Xx2r`}olbcK= za1X9D8#*)pR{M_f`l8-Y2-Ed9S|44Wh}jnnkp2>^iB%|jS@K*fn}eZ*sxVr zmy){1f(^^+>hn5RH=JvD%C{AgFbvd2I!jgnJ@GymBAtVn)HkD`a@v5Rt?Jgrq;hT( zQR5{F@RP*B#fBmHqUl%g_)0^Mt6^%7IdSp?Hw2AjflTS=vbf(LETn1V^dZ@f2ClCh z9QWMf79$2xH@~DDpc)<6vWK&YQ13_~>i;E8n^4L)hnfr}@0VO&poKMU{`rA7B;jws zz{mL4qMKW-x6(J?cOVY(<^%xTCUk7cqm%Kjgy5j(nriYXBU!jqg) z{ibyW5R+zi5ADAKTd)JQ=hhR*Eubv_B&bKth}sY}$DF_V#8^cb;^}*nd@MyS)Kdmj z^PQuZ3UtAMmH~bIP~3a}uoG}j8*uQf#Xzk5UOec-iiANfdkNJvq8i#G1bnM~iaq3OeG zQ}riO{O|bTLTLx7N9qIVSR?N+M;5KlX_f%0*vxjj=~lW(5gNeP>@}sZS@2DID*X4* zgW}fs$jl{Y%S@HJ+;JeVJl@YB zG|ozU@=ajL;!Lq(C~qexqbHx^`IIE!61otk_%t10b7k>g$X0;vGt&^Av3uyV@x?_x zIsXA9rYP{*@!4!tr-Lo)6?$d~d@y?Pm~-s9>7Q4-r3t&jBwU)l_#Z8Rp9@>+8QYa& z?(VUl8S|UuPZn=?na2*k}~-_5<;s? zUIQRzDKoE9>qpjTnZ77sxqb|+aI3*?WRJ~KqtHOoTKlr*X{eE*qtbxwEjD1Ap%Us^ zHlI1xl?V!7;@n%Gq`rQs315q zW8LWa9%fiSAPb(h8Adxl`hBeLbMwp4l0`XNhxl?swtc=>sJMbJEkV{_jzO7RdlL%c z@Yh?qY#B%s%nr%?LILylwrGub4v?u{6z|WSSVWuTc|}P}{~^YkfcH+?SRNzc{kWJd z(rg3|PZD$J$tQq_l{KL^d0>~&+ubEg&r@tnN#KK4gW3>@-`;zL1|wOwot&eW-u|li z4?MBz#lh^rP)V}}KxI1ceVC4ZQr<{%JS3xXLgO55Q}1W`-SBmabS)mQ+0#uTyX}K* zoA^W)4@0D3Dm^m{A#iP#$A245$U-ONqP#8>A8zl9^fwN2U!*ou*7meZU$mT5addhv zQ->3fJ7b#r<-)K7+`69rSg&FkzPg-p^sC4`0Ww`=_pJJ!@KA|J2^Ep7)3#;e-Y#0V z&(7VKmc@MWM*|Xn?mpyW6(Z-yg(;*jho_#_U23oVpIakDOaCD@wuS^iWqI_>;}(L? zBe#|}h85?rwvL-gPe(gw-#Q!S8lY`YZZC#kq5(v<6q3nTsZum%5b--@w1-ZcNbT~++D)DjMbu=hR(`ss;>iqH)B zQ2VkKU)3sfnY_4usujS%=l*>8Zksqd$@hQC-9f>1t+EDtFcsuI&*lYaGqtH34h@^l+&|lLGo7+_sx!7x^s(G0s_g4& zKOvejmB;6PpJS{l(pq&{=})y1lj#H9Hnix;ynGX2#zy%Ps^%%``A@%JkhLy%aqNVD zAfPB$ClIrX&A_=lX|9SfD(3lYVOqTx4C}O@=5kL8DN~G~vH1yux+aO@fO5eDuP47Ol$C zMjcZU+mvS=P@uJ#y-oi0BF5a(5mM}2Ai)wtPtTb@+ea=fOX8Te7rJ?KiSvg@$>Yu; zFLb@z-LuL9tyWyHKPK}hA^qYW@Y!;|5T`Lp)Y`O_aqUr&UzmM6=Jpxc_0*OtALRR| z5g&IXrwL)p=fD53nkJxY76UKsqTk|+6;)`aYZ?8xrU8QjsK1oZn>mEi7t;doNnTHG zZqHs`@^#*k6TZNfwb#bLG=qY`CjuR=mv@tr75V~6Oi;97JZEeC)h*tB`q|2eJ_d|c zuITCktP-=%C?QW?ES5H8n}8>Bd@#1Eaxf1@sh&%m#%`@GjAqz*Y>7^3xH;gF9*0OU zrujnIB3RL_CV^!!x8ozrjiRFzL`@-R6xx>Tnqi{+BtH(TvV*d0VWZG6{x~zbayY0e7YP@`-gt2CD_Tv&K z>w%fUB>b3rLulY`Opt|CNM$=eMXx$raD`I(5T5jdg#Pk*{5<&R?!ctmFax z8>ijoBSi+79hZ#cz|I2$aw1D+AR!Id?9K(gx@pLFiQr4*>~z+YL=<{kerwVTluXC* zKeZ*EG*A6&SC7TyJ})~2cDp|Rz5t- zhw&z(u;&_-Uo1eLI^<bdj`;B|b1Dpj?v-@gbMItK1)y^Z@k z5%i=A2p9&^S$?X`xc1Ex$oKds;;9{eg3(5hlObJ!5wm!Avj9I{Ro0=d*Ew`A!`VLP zoOHDMyUu0|K^llx+3%D{^{E(9qrS_+q-70ntLvB!3gVmAP;msA0&<;EYq7344_ydrgV3}oCG#cqC&1r7q> zu+jEaklE(k7gEIbV&?Y$5yVY{b>+Ba*wY3Kz7D2T%(wB0B?iH~C?LnAeHzMy52V|g zy!!jeMT`F^Q?^hsma|C*ZXL(DqH?gbPie3+!je3ax|yVL@Eg)Lzg0}ApmrZA-DwTn z#8Q<6V9XxK#2B-ayUP`X$9MKYL~TsOAX$JK^pgy|FD@OuC;1(Oxrb5AX*7ydGReZ6zw}1@a1Hc&xf?v;BGhU)zwVSc9Ro8pjn*Q&I>i zLTR+sB;Nb%(oq7Na6JLlY1qf|?}qFRS-i!#bKGG$J~Mc@@NzdF{9fw>QaZkkJmKBF zEIFF3UK<4n`8szwvBmR@b*_(b{F`5Jxyxew$l>a*e@`QUM~o@ar6dl#_^QBMZ4y%) zyDzpMpq%OHv`@z)t5*dW3rjZja9zbD{Oe#T9y;pHgYkXZhS{-;x8I*=QaNVXok(jO z-8Vc_=NDC4(tHk&Q5jN6L!bVl*{sNr0}?33``v*u_gIbhl%^%fCW|M9aUz=8|7DD2 zr9YPIm=jRC%<0q3%1*=vOKhW+Xfpn5WLc6fmc1h}Q;{>)_gL+;7Or3RTj5hNoXZ7| zPSa=!LoSG+%wjX=&zBqxEVfDZxoxy$G!~Bo`Z+%faeqfyipw6(JH#EZJKU8@q632l zLIL9hu2A6Gl-KO-fvlz{yP&dqzo74D!KF<{p*ec2^wF;x2v}PfI^U&d=kmCbY7NY` z1jkZ%1~!AA$6Q_f_&qq+6X$X~wumh0>j`=XWQ8dm!+_cjt+F32PGq+A(;o9FO{&X< zeecVU3m~&uue~GC(61M9u5AC&8X5eK;fdJ77feZj+Paa%EyF`GN(QZC44Fhw5IA6> zaXTh=uQO9ek&nlOdDZ_{iTJ(R9? zXNlNMVko%NoA1~7ckC;AT=YGQ*Y>WT=fj}YA#`_#MQ`+S?jNopiJaE2C>_$lS`uz= zy`iq$c`I`!$1R3i1dF!KWlTQ3EmiI1;u@^t5V{TqKj<>vr}1Fw`*? zbv0MV&ujK7oeuiQ;lr49`ETJ+c>1&q=dLg%AcS=&g&2+w|HX0g@5lV>*NWxlUD3aX zT~Pvv)kxt8lLkMuY#~9jMzJ@QVDZIeU=y~pJO=;1&29$5K1c>@XlY@KI>>jwaNgEv zuz9{O($*nGl$vVPXK2Y8#7z)cncR3irMqg{D6r2J7xafm$N>%WpM0L{k3IvCObIbm~3n08sMi( zh4TMU4QK0UZbD-x!ZNsJvO+PERek0h&Y@-HH}5VIP0sJz`&x^zk2@lge-8Fv{05K{ zL+qhNU)3NDQ{=fjXQyZHhY=*Rv+eQW9!D_W<(r8`x}e{7^*iM;ACM6rnDAoASB5jZ z(*ixn(c8kqOdv6qTp8kYG=VAXa=`M_?-?$8K)WF!7W~Iy65HJ#A*ccsm?01DKVWB_ zPeZ3VPZ{*77mD>pK7)!dhi-k#EoxiV+)(ymVRA~tbV*rgoMyd}6KGL0HX|qWN#bVT z6-Jnj#KaqEG(P>>Yj+zqgO=NzjXqjfV99=SS-DT*U4pMt;`@o{QA)iSe%NbvAY*RF zxT`dE1u0wR8Y6YcE;)xvXMlVjOLJR;dIY=n;V#91lD9^^XjLlTsvBz@r|9wPi9ojh z(sKB0=sR3I&E9md1RMso7|%^au#C0<$- zqf)Z7bPMz-O4})IwUjYflE8Af`q^a^d!1B%bCIL(`#f@U+t>f#X*Z~SRJ~5pv*Jbk zUpo_tkc{U5J{atsBjy~Q6G&4r{qcgvweT`TjG(_m1!q!-oK2n^_k|ke#5hdttX;1%XbOwbnd?h^aSPU>2g zjN|Z22l*(|Q!BU<(NUB)Jd;u4B`*6P`J5a^fu#?!%I`|0Q19xQf(xsG<=z3-!=?$T zeC^uXUMHV!0uzlDESD{fA1Ma&;)4<-o*~WG7o3Z^l|U*-X}eGn$pdwnabj`D&W_AV zrkpXG?Bp3t#~L6deqnmIs?2!HTp1`BQz&1xxp?ki-ZCTq=D0y=gN=YYkc%i3GV(Q{YHuBX8w;BVBMNqD2rtoV;q%cfJv0E z*-=>G6HhCg?t3G7jCtjjVGHwucYcT9*xj%e@H8Lj3RtyggXo=^VIjCSP@E5|>tw*{ zkO^+#!IGDLcSEo6RfvkAKmB34NA6|hF1L@V6?7Kyikl3?hYI_t_t5gyKIrq0e*SE5 zNpl?qH<6SqA$de+TxozsAYFQHb`BtgNtclMIBCuS{9UtpRCGg}M~YDp=&hOfT^aZ=tPpIZ;|iRLsQN=~_z^UGn{;->z{MIsU< zYGiTg6t6l>!H9C7W21f({3LgjM%&AwhdR1mqJjHc&ioX&b-tgV=x=(auE9!-GmQw3Xp-Dh2a6AmSqDIL^yi41b0Zn+$}EUiz& z%W!FkBjgIiwDHb6u~PZ*bqZc9QatxP#)cz|CsGkOkGmCHB6)P&9{kMZ5c2?JFL=-S z{N_xsD3uyYa=rKisyg{j6A&~C>qP2ItrGW8@TFglE+*$cxLx9zF*Pa;L3__JPdc4eD-QV|0Xt zaN;c!-)5Q<+t9+eMpDh|k7_3JDemzGh_&TQe}5AQ{yLhJ zs1+j-Qb~A~?g_0Y9sM+p_PvoztfY`&lfIXnc6tFr^Y5d%AP-ygya^MFW^6UaZR3W3 z*rwiM?$_tChZdq%Aj_exUGW)EvpGzD^Jg6FVx^37Olyu7Ff;AE#RH)nd9mx3q6VjC z=(Bt^mlt!Y+eGVWIL>H7K&6I;mw)vg_E@v+yA9}hE-|w`3@6eadI_ChbNW4;lFnUYJ-{guXp4+`)>eZ z(kO@ok~?6 zPTpWJDUQ(Mg7ZKHQUfFnA%w-t*)RyaJ>~Pqi+UEW8Vw-ZL@){S>5p}2fGPH-8Q8Dj zGpAxK7&k=WI+ZFGl)e6$jP0&%I7|y~iyWU45$Ti7{g}4;7D`oiG>!-5RfUTr#@!^( z_ynLVJWL?t_nAB!QWGl|a_H+&PNX2T<}Q9nw|Os1fEVv>X)wTlX}p&_p~1YSv9hd7 zgMD!Z=USDLGh2h+ z^Xi(Xeo;DE;P0OO`g`0wa5K=phbQ}G8i`(uFE0BEr7YMM2PZ_^~XvdaN=`og<9&wbQx{zCiOaieK$-amvGffpo{xG>c5yca4X@g%omnuZ%|d#)JY64hK>N5^MSgDDE38e6v`>*hKt_mGsq;HH}Ii zS%Jp!h3Rf73s1j&Q!pS};3tt-&O(e-M@1=tuOwC*Z~m;6;hMRt6X(v4t4N9*FE``O zPWuZ436jzEzURYukzN$r@|M@4-rT;tM))<6KogTV?S$GXQ{7@n!nN*SgWv$1vuJ@g zZ`Azdl2~8ev6aGSxQO(YVrldRwIN$1#>Pp}Pl%erzYmrCfqF;qC7c&W(;+EWf${)rfZc&LR~3 zeYLcK)sLCNH5ea{Tate~$=u7Fp+f7u%s@t;EdkViC}WushH|})$IyaAx_{$@Ta;Cr zG5Bi2nNW7Hul}}%?<4BF#x*$qRpC{vwo!L7Cu;GIftN9<>N^y8HRC0b(Hv-ubCjYG zD}svd*hOQG8@cTkH15R`2$Qoq`p>;`T0hreR#{gc?)bECkuBcxX0L|`F1gW%VVhG? zbd)m=g2Et?M}~*>@cu~O#wv-$IJzpTJadR4sjN$DXmi#~HnhQkmqW)StW# zT&{|5uxx1ShLRH&BcwVoLgy@mehpLm#SS<`sx$$q#dm~*kQym3d4Sh3xP-)q4-j@tSV%=AJBk5gZ%o+hi{T)_4*MRzs@N&*6pWX(>eYH*j- z+vUu*F$>FH*w`a}1ly)QQOo(a7J1ImEgq zn#ALr&(zBg3+!L+>pMNz*JR-GW-Uiju%MWl@m$K@8aCFnBfAS&;IaB@^V^@f^HOiF z4l@~Pu=)i$IBE!@T}G6in77SOR&CBjE}qCo)_F3Wm`gQX#rZ%|tHdbl@j`p4%CnH% zynQ`GMdK6y_nZ%@T;6bITCQ$1gt&*kBW_@KhqsbIkBpU8Ou1#`mw#pU5gLfIp6%IE zAiJaiG~~kEAJ3Lil0*4|PsrcaU34c~O<(4O^kIH4n?o`_Y-XKgqR%_NxVt!_ff+1G;|8LCQwEi(dhne7Sn)eCz z?;xX5??#@fg36Sabp5El*_A>N(RHVXDkKP0XOe5VVuP86W-KO8|O$ZInGRM~&Y?GCzuuEOss*SjhC@&DV02Rc&$s8KLC}8p$ z9n5^B!&0$_KS&7#5sXnJZB3i3QAwxCck~nM6Zxc`n(OD*vS)um`MZrsP8-XWFEfVa zh=IcvOvzG2z!>AH=D!V}sn^yI%O_*>YuwbPeQGN-`3yU2y-7m}(PPcqQiF8(Sm9;; zL|UTR`j~Q8%vL~E`1#MKX@7Y#I9ve{0G|4-zYUXJZ>AK*WPPhWF`(0a)|4J+qdHT z;v@v~|8zbpi96$dV`-IKoW*bcdKPk~m<<1#_9!4yWc@syS@hR3zTXqFQ(qQZm_TTDnD*#E1=8heQH9+RZ=`brFAkBph~WDvCaU z$6281v3?Rkeca(?hzLR8MDj`-9^Axp`r5ou2+Kg;8CGwf_0rKozwM2xQ4=|edA=IM zf81N4C-C=QIAbaV_AIeJL4Eyf(vlx)*h2dObzK)~2#!kHMO8Pi>Biq2;S>Cn55DzG z8q={%7JtkYZ^E|G7p&w|i76dh6{5AaJl~c9ALUp(jjmDlFDP2BMe!uisKgFERdGHl>sV*w@+AOS3Z8j39}(Sg+(7|uif6WL1^1WD z^%;$~G${njJrM@+Wk1c#J054=18_!@y*tgW|3E8uVyD)5Pq=lgH+cSQ$>M}YaJJu@ z;{@(0G5-ED(vR3ZQAnR*lG&Fu$4mpH+i4(2$fAg@^CA6~Iiap+(xlN;DsH*zHA@s% zkkz-H_*?I9C)W09Sg8uZad%*M1x zo~Ju1W%snN9?@=BB?1GYplB4T8Fx9kR63I(jf9eB8nd!=diL72!U>f6nd z*!Jnvc6n89><4MFKMH2tGg0h$Y%=oY0`}0#(j#GOyLi2?m-x>7A{9usg^GC-nn>ab zOnV9}Fy}1=AGv}PjcC`%gU50XTr8Nb9Caq^JMBC3lG~oO6ZFe-`U99 zkMNHal*fC@RZWt`S<|&P9~H4G{znTaq+zS7hIsTM3l954QpB0Wxm8%Tle)#NmAtJ^ z5htbpotyoY=oE;3G>c$kNNW1M{$Foz`;K=1%5Ydl;E#S=T>aYwBsKxNt&4syoeq7w z1taBn301r(V@qVyF~AG3LdG#>mF-;+-m;n}h``8v{JJL6`Q7SD)f=L(iLHT!?%a>Y zT|ChwyGCf9{6+d$q*4_9n63Jkd)WCCfk+hg!kvAX=EBbX5qejaM8nIYq$-f>M zQeFvEjy6(D5@-9kTY{@Tt!_>Nb=WIpo6F!lsrHMM>W9uhuJVIye`jbd7>g|mPbh;= zbH^@jP81zx(a`}-I+nzMx_oow?<_wzY-_kH@dxg;b~kL`unI=4_8UQCtP-D4DNy5T zU7B!ao$MDB1ac!a9!T=|9Z+Q~I&ICSBy^C;mQ?EgxqBk&%Md&|+ons06xVn;=N4J; zE2sJ}W=G+bwmzU;@-CTc1u6OVDu@wDrx`AAR+^$px8Q3z4*=w793rBu`Crji$rtI{ z$N14>vlUI{^dYuaW%Xfw&8Bj68egWk9QQ>ZiLLyLn363ajeyI$I_H&pi_9=CX4d>e z6Lag&F^(K^$ka?D>KYA_s?QZpH6^9<9M#{hkXmTb;<zh}FYt=QW*f9DBt2#QA??ONRqsw{c_wS+CudT>dNA zB<*t{%7oofQWRqJCd0w2v1vL~q;B<=OOjSY$ElV%wc?enMGsh5F8`;y$ z!x)0#@!I<5$6-rx|MqKSh-O-e;z}CER+gGVNaFw|y=gYI#<*fucf4-+67-onxdo%X zls4FEfme|g+aqpe)LP$jI`^EoLRjhL?4#79*eo?&IDNj7l}6vR@7X7jmY# zuMViDLTyc!NPIW~n4Xz${}suZmUCHmQI8yT$rMi{VzcRqzTH75Tyb&MyW2c6>E@kx zQ7PCl)o8*+-f3#w9L4>^A$UXfu=Kl;Y+m%IWyS_poOVTdTbN_y7vPtLJ;cX#BpfCm z4ozgDyBS5vSBn~sTI^oiO87bWvG$MC4Y$M)sM&B~yqa^i;&`wsH9+D+- zZ?}opG&*sD&o5`8i-7h?WzU}IcIiym&iK=MF3MADjR953${+t6?BafNMdQhu3#wZ= zni2Ia8VmY5OSN@3{MsH0-81_CnmX&hCfK%(drK(YDIw0FMt4h!fTKHvfpm=S2I=k; z5Q)L)F6nMYN{tl8=&qMv-sirrKjG@naeR;S)V6k=Zm!M^B+?xJ28Edc!f<_6KNCW| zzx8?C4je1w*a#Dx8EUgkIn8McLSk2c$7TP}-~Y-bBQPJ~*=A~!2a=ie>NKoM16Omb zVV)>LE5Y~ZZ@W()8DPwqeG^9f$}IyF|4N;I)gk^!eJ0G zgkYO*E7H|*{^bkVPQwfnZp+eT`R2@%PngmDpHRJ$fGMPNr+Lovr`2b{8@ ztoU-8;2w$bF1$TI;mZLBrsvQr z4WdFnAe884M4KJr%Gk`FxvsMV-_W7F_j1fCK&KG)u)9a5w#SK#IapR$-j$M$*Wc;i zB)vzixu*XwEIH+yK)@7PezJ7v69v4%z>A#UFt|Z?%jMqOP5YK-+5%xINT-UMuUc|@z)`dQGhJ(WxdB3*5SzR#}pPV*w3$7Xl~HC ze|$JJN1QA?sF;0U-!nyJI;K8N_v;X#IJQeK@558it`;`9J-MPYwO3=W8|cTD!wr6M z*8Q^{(E?rnIlULmCF;=E@!-u95n^`rwzjQH5)3E=$k1*=YtGn_NXa_4GfR&f={mxV4OU*c2s_!m;^lA*zGM(_hFDWZ#*gG`K z?z^z)a|>AhrB*E#?pD6&iD~7bX`uAB#-lHFQJ4&yKD&bp=jzxv=8d7T?RH4+)$zIO z3#b}F4528a6aIa65`Lr>TeI3X!BX=7T>sR3OVsh&m|JnAk@ zl@16QP+Y-FpV`ttOA501&y1gu66%eTd($8XM(Z`9cdD|w}3J54T(2ZK?`4+a=x`eJo&u&Q?^I8Gd%OV+R|vD&7*nQgujp%?#`(4fVZ)mHW|7D)Cg4Q`-zc259XUEAe3)lv^}3l0}}{7%Bl z3Ro@qeKp}$SI7=*x3*B2!QY-Nqe+pVb!t}THy3KY=wx?ldG^rkyFTSk#=@b@vSC}0 zoe{El@d-xas`&x-wT_7`z(XT)N}e?RHT&Uug zSi#cU?kL=~kVtIX*Ch(9UKm^~oJEvU^OmVpfNzD|{O|Ku&3?|cIsmjgwj=lPF3vF< zha|l#4KTfxey$lr(Xy4}$;E`ptN-N4k)Jp8Nr_No%YcVb-Kp;W$sXL|R_pB@2=21C zudN?}5zD3Ita0_$Hvx{lM1X9jsTRftms3<}wp$e4T!$}B;F*iDsd{2)LWfC4SR2_? zDvB3Wi#ykKcbP{3zx$$oA&sr<&1eP3ii4=dfArE8(J!YS{zsJpQ7>#eHUhea;2Owc z)6{R5gyZP_%b(L^Y}tG?ZP?Oi^>%(dc`w>Mdk!tlxjJ9ptgh(ezu6m?ZI#SaoMlIx zT{Se>rj@F2wOQfANZN0hZ{iFq) z`wYH6QQGAa2xxDb{#T*dbUrGLIo+!r2uonRTomk(@ZeO3yB-h9+*nix;3zi8Lit85_7? zHg!8+MuAnx3a|L(&RT$kR=_l=B6#?%0k zijzd1dn2<3gaTH<@hW;EN7z%MObK`*D0|4+wQCCMEnHn`gpN5HxQ1szDbMmHvDBeR z&WSdH$&Ui@>2WJOT`3*J#Zj?`YWm&=!Cgt;m&c0XF}fRis$&?t;>~O(meKq-jp3a7 zr}drKIO7~lywK9RLlk;_`0tYBz%&08-%UWVA#sqE1#^t~uGzt~5l9)!rw_D)4a3`? zx;E0An)+NZR_ZY{b%8;`^^G4@J_}7D`!8@qGc@3YWecujzXcJP<6R*e=zkij#;dE5 zV6U&iU*gsCph|--vs1S8r{%V^Y@8YC{pXOCQnGUe{!gO;yVir7%TOZ@FY~4-7Q~f3 z4T~;I4socmB0X(Zi|3VwZSAbGL#I0C$vIS^>RpG;ecwG~PFp1NO>C4Gf0$;5t!1y# zJZqDb%l~TubFcE>Et40N3fruJ$1A^j%ZigL+C3#~>YjNqwa#q&;AKX`^APH} zjtI#LaNrw;M17CEwUln@x+g0T;_ZM==r0x8$lx`F2IOLXw>w{QXVWRfDABRWVK#0! z;|m%g_5?@3MKGuz!OeqgW^eB)9^*?iJGu;hAX;peZ2nk8@k_P>WJBJd5ePdH{{#)Mg* z*M21Vby=CJe_MOlN5(!r+zWaMK0fHX-^HKTFOS#k`I4QhH;-}J7^R-U`|ljB61VsX zAHV$jg(Azu)q5DbxU3Y!zgms(04twfHMds5l>+849^akgbZQ)h>l@{Ck~OO#ky6@= zzsEE=Rw7^vbx#`PeoCoQn@~t0uFOa&248 zXO!xC4FYqVC**2PDu4}KPSTw>(VO?L&>NUR{rl5M!JswGY)d#-BBOG`uAwmCs4qfp zTkBHVk39daOr1!i^n&1 z0ntusIv*!R0H^>G^BH&`kn4{Yh0O(_&lX+EA9|{!E4!Q)h)Y|W^Sj5BvoJJ^Z`X$7 z=E5cFL8Q8ewH~XHyZf=%<$)$(>gFl7Eovs`((%hjeBA*gcVM9SQf0}Jc2C6{mXys7L(_7mi2;SuH|s| z{{b|*0LSUm$EZ&GVvK-+_of784tLLS=DBD79{U<(Rbj!D!TFejssobatwTq*R!EW% zHJ^Zb1lrthY}tZFK`ckb4JD^LYY}^qfHqUhg;2w~;>zR_XHeZyr-P^%>PhjrO89Gw zn|N{^#0H>Y7EykAN_CG3(&W+|**N`7XQJ`AB)*?ic3^gwP9NVAeNqKYzAvq!i zwhUrC-Q%=-Np~9BiOr9U_cfcoL!Co=*sof6WTR^8?4}{iXc$9va`aQh7q%3K!sm+Eyv`YkVk|r8ehAU%V@O2XcM%y zvKJEa7Lewk*NoP?p+kdqHMYNp@84vEm=GoNc}bXGPT=x%HJsQX49_$+!}f3A_ty06 z@Acn^sw$|E6)p$LV?~B-ruNXz6PZ#si)%=jvbHv(f1!BF~$6i($DfTWG_m6`{ zL92pX?z?-R-^<<)eX@E~U*HNLgg00!sm1f@GMw%ED(^!u@W{oRB45zCG4ucUBY_I_|biTtfHZBX)=bTh0 z3K;G&#a9B(Zu7A%j~XE}RjN_*A5SSdv14AW>-(9g9=OJn**zsv%-PwhG+wgaCn6h1Hv`s#Rm zFf>R9>vPP7l2g44vtMq6h=!<2o;*O3)K<++9C~;zCe$wXR1=;T@h40xw#>e{^q8Fa z^p=ik(!$2}gNX2CmPO%>3uu2nJYj{Wc^*4}*#pz#+Y(AGxLA2f_5G7A} z&3>r>p&|`Ir34HtkB>D4mi=<@16rupNzEh>I2H;OsQP^RxL$N@ zf6mML4ED_HoS+2VtF%qpoNe6#SU2xzR5tX}oF-GyGjzVrE^DrBX41i%rhfVj z@o%DrEPx6Aq7H$a!XNqq`id}=vwmjNv;Lv$bZ*P2s6y&eEUq->6CGjD!$=xg-fs1o zmil4#!Gybw0Wc2y**^bn@L#=KQze`dnw(W~Hz2I_#v7_`ZZ!%Nx1iragHTj@3DYbu z5*Co08qTfx-$)_Kv`JRqlz5l(VRYD7MU%S6jcj1wrc$c>?SNc=o)xG56_YpT7}!W5 zx02CMW{yQ|AL=WB0?0;|AM}vMtl)=d36*kCQe|EwCL7=~JKXt|jMZxR$ZP zeSU~U5<(GrBh9n&ttsb4Ovdx}*EpNol$Z1Clg6=i2s7h9xr~9f7=8-Rb)~gA)i8_x z*)9C++`hMr+(YtQV2wBaYSm|ktd{isq} zfAewHm%3%;_sk++H&O_Ys<(d``JDE6Byq)6C;Y3DPFv;Gd_aWn&D2(rb9*Wo_0E7m z_fCHeJ15&epo|4#ybbx?W$kvyDQGW^|LATsh92zuyoM|e( z0V^cI$(x3mpuoTHq}>!pp!3Wjc8lym5g1%ZMQ2Kj$)v;cZBd|z@lnx=TEFx0nkLs@ zHJXDrVU5^^ZY7Yu(;$)eZC@{lPY2HN1EnyEZgU~7P#vDJEPD^xH20?{9Qz868p(%F zz@&Fjq~qrNRWCSH(hJro9glRHt8qU`g$e^q}exTL}gw zYUTHSZr#*I-+i5*dgrl*t@hweZx!t${a$UWOVD|-IWt?D@5s%6Or(9~H4FK#ZR(ct z)-uplI*TtibQIJG{@2=Wv5m{K)20)qFUuu2NAP{eggXj#MI=TQoV!c2vt+Jhj6yQ0 z<_E}Kx-f+ZUI8cT+;70j(0x|Qi&tDjkF2TEj2^+Q$47ucf+-RF93TgWd-`{dmtSBP zFmgF2vR1lD!nq8s6o`InUgC^=dDSK%WLZYkS3&duUE)fd__{BrHYzd<*QT>TMaq_0 z3;d;uI>tLzZX$@iWsUIf0H-yY7IWx5WqHHQVBNV`xP7RIvl*QTiWqK1&0ct<411fw zavtc7cX*`NqkLAW*(*aG+u+TQutg-&ALw0Jab(JqZiQ}316J*s5IDb1z_on=kv{hf zuz_vUGAg>Uf)2zAN1qQhg-Ps{&v-IrEg%7lYmX8@KxzT_; ze5%~Wi+eE=t6gSGFiCc3V@#~54HsmCJq?&Z0G-k?YV;zvl<4zQyBrB!ggt7&K6-mWBTnF zE3K%#!gpLA2}M-$yJq<){O5&vqH!B7;0HQeKJjo2sS3xBdkv`hrW3X*7LD6zIG1At zDtFla3_m|`L7+Xnl$DtjFg4z42opd4ger+Bkr9!3*zQZzxyiDq=(#n4n&iGdO#4^) zo{b365oj;50OK%0R}=gi1vQHS(t@Ej(ba(+G&ONhV%%O%TYjiO*v7Z68)>clIe9KonyI?nlDlto_h_$Pea@7C@aHP z^?v9^vMH>?zV3F*e#?raqhP9xf`qv2mH<}lQ!g4rMttvF zN`9_RX``KsWTClq0UrGhvPT`4IBf`dEIEjX`fDg>)8e8aT*6_|X5|$Q<>1>{6MwIDQbk5{BRNyRBiHebTmZ35m;`;#7D&RyjtQe&C{XM)C8SzPKjH0%fzp)v zYE0~@c!vZ(3}ya)_Sf;lHF{C6)C#8-ew|5AT?cor!XwVaf~dJHx}LEoHfsV@$DY2S zHPfCPi}kRE^8Q%F>fL-q2%xFOcXZu3*2;WCOjJ}H95uL{A1$NRola)hLR}=3#o{GS zc=ogcTtoc8VcAEm!8zY0sQU~1AAav`Odca?=i98qRFH8mv-yv>ruj*Ep%&%ijM}-nC~wzJG-1_P-KhklN3)+ zk@QbH802}W2j%O>3~*ZHgmhK7bPMpz9LA{+a*j!OYhCtRCInNF91xc^_Aj);Uws4J z%uq-f#%9PbO(d^*IckVo@Bc|?)omErE$_DMoKa;*KyAdfD2d=$k_-6r-_J!6Uv}SX z@$&MB+>q|FzY!rZw)K~?mba@jdOUcbrpXS7&p!vSZjZdkM&BjLEv$7%ux*C@`IfIr z+bAStaO47qJV`j=PUYLkn~>|;o5jS82=!MT6Ob`@LcJ4%M&6F*w41l8lCfi2V8f37 zTypX}!Jh5dL_@I_7}@%;sIlRf=GuxXc!SkVsj&Z(p*}t5?DH+A_rV|njV_l+UiNz& z#LG?pk@tB;{iBG;!GnnzR=ibkbCjb0-vwM7%SQP4J&s{{OhA#ieap>h_XcXY-P<=w zq7k+n6}4Q8sd;sUkG(Zn!{RP~e!#4s!V92~74&2q*e((^y0iY~`Z78)OK+Wh{OVa* zLwV}PD}Z(5c%|$2u%YAm)$)4 zxxY#|a275K0;7?~j7)p*hga5qr&|xp?m9w+gO^SDox6dJMB64TAaTwd<{nqMP4l~iwqZL zAci{81Y2+rx7r`dQwECDwdZZO(uOt)#k;#d01x6&T14enIDG%x|BugJeNVu9WWQT^ P_B7;WzDUC)zXtvvu1DQi literal 0 HcmV?d00001 diff --git a/docs/reports/2026-11-ai-upscale-addons/img/05-live-action-span.png b/docs/reports/2026-11-ai-upscale-addons/img/05-live-action-span.png new file mode 100644 index 0000000000000000000000000000000000000000..cf0af8671f755048c276f6200d925603e2277400 GIT binary patch literal 85241 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 literal 0 HcmV?d00001 diff --git a/docs/reports/2026-11-ai-upscale-addons/img/03-general.png b/docs/reports/2026-11-ai-upscale-addons/img/03-general.png new file mode 100644 index 0000000000000000000000000000000000000000..d0e818b8e05ea2b5ba15adb00fc3d9dc88d1e1f8 GIT binary patch literal 77428 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%0Ah1Zi0;U418BMBBU_ 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 U;h?1q8{F%Yl~j_b5jPC_f8hT6x&QzG literal 0 HcmV?d00001 diff --git a/docs/reports/2026-11-ai-upscale-addons/img/04-general-denoise.png b/docs/reports/2026-11-ai-upscale-addons/img/04-general-denoise.png new file mode 100644 index 0000000000000000000000000000000000000000..8c84b3765b57e74153178096bd6759566d02c012 GIT binary patch literal 91670 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 XPNr;5u^yoU9ty!+gWFq9NpePth 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