diff --git a/.github/scripts/fetch-deps-bundle.sh b/.github/scripts/fetch-deps-bundle.sh new file mode 100755 index 00000000..37a36788 --- /dev/null +++ b/.github/scripts/fetch-deps-bundle.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# +# Fetch one platform's dependency bundle for a CI run and print the path of the +# downloaded .zip on stdout. Everything else goes to stderr, so the caller can +# do: +# +# ZIP=$(bash .github/scripts/fetch-deps-bundle.sh macos-arm64 deps-v1.9.0 1.9.0) +# +# Two sources, chosen by whether $DEPS_RUN_ID is set: +# +# (default) the published release named by app/assets/deps-version.json. This +# is the real thing — the same tag, asset name and URL a shipped app +# downloads at runtime — so the normal gate exercises the normal +# path. +# +# $DEPS_RUN_ID the workflow-run artifact from a build-deps-* run. Lets a deps +# change be tested BEFORE anything is published, which is otherwise +# a chicken-and-egg problem: ci-test.yml and nightly.yml can only +# download published assets, so a new bundle had to be released to +# find out whether it worked. Artifacts are private to the repo, +# need no tag, and expire on their own, so there is nothing to clean +# up and nothing a user could stumble into. Pass it via +# workflow_dispatch: +# +# gh workflow run ci-test.yml --ref \ +# -f deps_run_id=",," +# +# It takes a LIST because one CI dispatch runs all four platform +# jobs, while macOS, Windows and Linux are three separate +# build-deps-* workflows and therefore three separate run IDs. Each +# job tries every ID in turn and takes the first that holds an +# artifact for its platform, so the same list works for all of them +# and the order does not matter. +# +# Requires `actions: read` on the workflow token; the callers +# declare it. +# +# The artifact path deliberately does NOT check the version in the artifact +# name against deps-version.json. The whole point is testing a bundle that has +# not been released yet, and it may well be a throwaway version string. +set -euo pipefail + +PLATFORM="${1:?usage: fetch-deps-bundle.sh }" +TAG="${2:?missing release tag}" +VER="${3:?missing version}" + +if [ -n "${DEPS_RUN_ID:-}" ]; then + rm -rf .deps-artifact + ZIP="" + FROM_RUN="" + + # A run that built a different platform simply has no matching artifact, and + # `gh run download` exits non-zero for that. That is expected here, not a + # failure, so keep trying the rest of the list before giving up. + for RUN in $(echo "$DEPS_RUN_ID" | tr ',' ' '); do + echo "looking for a $PLATFORM artifact in run $RUN" >&2 + if gh run download "$RUN" \ + --pattern "VapourBox-deps-*-${PLATFORM}" --dir .deps-artifact >&2 2>/dev/null; then + # gh nests each artifact in a directory named after it, so glob rather + # than assuming a layout. + ZIP=$(find .deps-artifact -name "VapourBox-deps-*-${PLATFORM}.zip" | head -1) + if [ -n "$ZIP" ]; then FROM_RUN="$RUN"; break; fi + fi + rm -rf .deps-artifact + done + + if [ -z "$ZIP" ]; then + echo "::error::no $PLATFORM deps artifact in any of: $DEPS_RUN_ID" >&2 + echo "Check the build-deps-* run actually produced one — the artifact is" >&2 + echo "named VapourBox-deps--${PLATFORM}." >&2 + exit 1 + fi + echo "::warning::Testing an UNPUBLISHED deps bundle for ${PLATFORM} from run ${FROM_RUN}, not ${TAG}." >&2 +else + echo "deps source: release $TAG" >&2 + ZIP="VapourBox-deps-${VER}-${PLATFORM}.zip" + gh release download "$TAG" --pattern "$ZIP" --dir . --clobber >&2 +fi + +echo "$ZIP" diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 65299491..b7647d4f 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -13,11 +13,25 @@ on: pull_request: paths-ignore: ['**.md', 'docs/**', 'licenses/**'] workflow_dispatch: + inputs: + deps_run_id: + description: 'Optional build-deps-* run IDs (comma-separated) to take the deps bundles from, instead of the release named in deps-version.json' + required: false + type: string concurrency: group: ci-test-${{ github.ref }} cancel-in-progress: true +# `actions: read` is what `gh run download` needs for the deps_run_id path +# below. The repo default is the restricted token (contents + packages read, +# everything else none), so without this an artifact fetch 404s. Deliberately +# read-only: pulling an unpublished bundle must not require a write-scoped +# token on a workflow that runs against pull requests. +permissions: + contents: read + actions: read + env: WHISPER_MODEL_URL: https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin @@ -58,14 +72,15 @@ jobs: - name: Download dependencies (${{ matrix.arch }}) env: GH_TOKEN: ${{ github.token }} + DEPS_RUN_ID: ${{ inputs.deps_run_id }} run: | - VER="${{ steps.deps.outputs.ver }}" - ZIP="VapourBox-deps-${VER}-macos-${{ matrix.arch }}.zip" - mkdir -p deps/macos-${{ matrix.arch }} - gh release download "${{ steps.deps.outputs.tag }}" --pattern "$ZIP" --dir . --clobber - unzip -q -o "$ZIP" -d deps/macos-${{ matrix.arch }} - rm -f "$ZIP" - xattr -cr deps/macos-${{ matrix.arch }} 2>/dev/null || true + PLATFORM="macos-${{ matrix.arch }}" + mkdir -p "deps/$PLATFORM" + ZIP=$(bash .github/scripts/fetch-deps-bundle.sh "$PLATFORM" \ + "${{ steps.deps.outputs.tag }}" "${{ steps.deps.outputs.ver }}") + unzip -q -o "$ZIP" -d "deps/$PLATFORM" + rm -rf "$ZIP" .deps-artifact + xattr -cr "deps/$PLATFORM" 2>/dev/null || true # The whisper small model (~466 MB) is architecture-independent; cache it. - name: Cache whisper model @@ -132,15 +147,15 @@ jobs: shell: bash env: GH_TOKEN: ${{ github.token }} + DEPS_RUN_ID: ${{ inputs.deps_run_id }} run: | - VER="${{ steps.deps.outputs.ver }}" - ZIP="VapourBox-deps-${VER}-windows-x64.zip" mkdir -p deps/windows-x64 - gh release download "${{ steps.deps.outputs.tag }}" --pattern "$ZIP" --dir . --clobber + ZIP=$(bash .github/scripts/fetch-deps-bundle.sh windows-x64 \ + "${{ steps.deps.outputs.tag }}" "${{ steps.deps.outputs.ver }}") # The Windows deps zip uses backslash separators; 7-Zip handles that # (info-zip `unzip` warns and exits non-zero, tripping `set -e`). 7z x "$ZIP" -o"deps/windows-x64" -y >/dev/null - rm -f "$ZIP" + rm -rf "$ZIP" .deps-artifact - name: Cache whisper model uses: actions/cache@v5 @@ -214,13 +229,13 @@ jobs: - name: Download dependencies (linux-x64) env: GH_TOKEN: ${{ github.token }} + DEPS_RUN_ID: ${{ inputs.deps_run_id }} run: | - VER="${{ steps.deps.outputs.ver }}" - ZIP="VapourBox-deps-${VER}-linux-x64.zip" mkdir -p deps/linux-x64 - gh release download "${{ steps.deps.outputs.tag }}" --pattern "$ZIP" --dir . --clobber + ZIP=$(bash .github/scripts/fetch-deps-bundle.sh linux-x64 \ + "${{ steps.deps.outputs.tag }}" "${{ steps.deps.outputs.ver }}") unzip -q -o "$ZIP" -d deps/linux-x64 - rm -f "$ZIP" + rm -rf "$ZIP" .deps-artifact - name: Cache whisper model uses: actions/cache@v5 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index a6debe12..a62332cf 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -20,11 +20,21 @@ on: - windows - linux default: all + deps_run_id: + description: 'Optional build-deps-* run IDs (comma-separated) to take the deps bundles from, instead of the release named in deps-version.json' + required: false + type: string concurrency: group: nightly-${{ github.ref }} cancel-in-progress: true +# See ci-test.yml: `actions: read` is what the deps_run_id path needs, and the +# repo's default token does not grant it. +permissions: + contents: read + actions: read + env: WHISPER_MODEL_URL: https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin @@ -64,14 +74,15 @@ jobs: - name: Download dependencies (${{ matrix.arch }}) env: GH_TOKEN: ${{ github.token }} + DEPS_RUN_ID: ${{ inputs.deps_run_id }} run: | - VER="${{ steps.deps.outputs.ver }}" - ZIP="VapourBox-deps-${VER}-macos-${{ matrix.arch }}.zip" - mkdir -p deps/macos-${{ matrix.arch }} - gh release download "${{ steps.deps.outputs.tag }}" --pattern "$ZIP" --dir . --clobber - unzip -q -o "$ZIP" -d deps/macos-${{ matrix.arch }} - rm -f "$ZIP" - xattr -cr deps/macos-${{ matrix.arch }} 2>/dev/null || true + PLATFORM="macos-${{ matrix.arch }}" + mkdir -p "deps/$PLATFORM" + ZIP=$(bash .github/scripts/fetch-deps-bundle.sh "$PLATFORM" \ + "${{ steps.deps.outputs.tag }}" "${{ steps.deps.outputs.ver }}") + unzip -q -o "$ZIP" -d "deps/$PLATFORM" + rm -rf "$ZIP" .deps-artifact + xattr -cr "deps/$PLATFORM" 2>/dev/null || true - name: Cache whisper model uses: actions/cache@v5 @@ -139,13 +150,13 @@ jobs: shell: bash env: GH_TOKEN: ${{ github.token }} + DEPS_RUN_ID: ${{ inputs.deps_run_id }} run: | - VER="${{ steps.deps.outputs.ver }}" - ZIP="VapourBox-deps-${VER}-windows-x64.zip" mkdir -p deps/windows-x64 - gh release download "${{ steps.deps.outputs.tag }}" --pattern "$ZIP" --dir . --clobber + ZIP=$(bash .github/scripts/fetch-deps-bundle.sh windows-x64 \ + "${{ steps.deps.outputs.tag }}" "${{ steps.deps.outputs.ver }}") 7z x "$ZIP" -o"deps/windows-x64" -y >/dev/null - rm -f "$ZIP" + rm -rf "$ZIP" .deps-artifact - name: Cache whisper model uses: actions/cache@v5 @@ -217,13 +228,13 @@ jobs: - name: Download dependencies (linux-x64) env: GH_TOKEN: ${{ github.token }} + DEPS_RUN_ID: ${{ inputs.deps_run_id }} run: | - VER="${{ steps.deps.outputs.ver }}" - ZIP="VapourBox-deps-${VER}-linux-x64.zip" mkdir -p deps/linux-x64 - gh release download "${{ steps.deps.outputs.tag }}" --pattern "$ZIP" --dir . --clobber + ZIP=$(bash .github/scripts/fetch-deps-bundle.sh linux-x64 \ + "${{ steps.deps.outputs.tag }}" "${{ steps.deps.outputs.ver }}") unzip -q -o "$ZIP" -d deps/linux-x64 - rm -f "$ZIP" + rm -rf "$ZIP" .deps-artifact - name: Cache whisper model uses: actions/cache@v5 diff --git a/CLAUDE.md b/CLAUDE.md index 8ddd4dc7..8441ecc5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -299,8 +299,13 @@ Adding a filter touches many files. Missing any step causes silent failures (fil - Add to `fromPipeline()` map - Add to `toPipeline()` construction -**6. UI Wiring (ALL FOUR locations — missing any causes silent failures)** -- `app/lib/views/pass_list/pass_list_panel.dart` — add `PassListItem` entry +**6. UI Wiring (ALL FIVE locations — missing any causes silent failures)** +- `app/lib/views/pass_list/pass_list_panel.dart` — add a `row()` case **and** put + the pass in a `PassListPanel.stages` entry. A `PassType` absent from `stages` + renders nothing at all — no error, the pass is simply unreachable. + `pass_list_stages_test.dart` fails if one is missed. Stages are labels over the + **existing pipeline order**, so a pass goes in the stage its position already + falls in; never reorder rows to suit a grouping. - `app/lib/views/pass_list/pass_list_item.dart` — add icon in `_getIconForPass()` - `app/lib/views/pass_settings/pass_settings_inline.dart` — add case in `_getFilterId()` - `app/lib/viewmodels/main_viewmodel.dart` — add case in BOTH `_convertToParams()` AND `_updatePipelineFromDynamic()` @@ -369,6 +374,17 @@ reliable way to check. their own. The design rationale — and two rejected concepts, so they are not tried again — is in the renderer's header comment. +> **Audit the presets whenever a pass ships.** Measured 2026-08-17: **8 of the +> 16 shipped passes are used by no built-in preset at all** — Chroma Denoise, +> Sharpen, Anti-Aliasing, Stabilize, Rotate/Flip, Film Grain, Colour Correction +> and Crop/Resize. Every batch added capability that nothing turns on. The +> sharpest case: **Stabilize shipped specifically for film scans and the +> "8mm / Super 8 Film Scan" preset does not use it**, while gate weave is the +> first thing anyone notices on a cine scan; and Chroma Denoise (CCD) — the +> single filter VideoHelp prescribes most for tape — is in no preset, including +> "VHS Cleanup". Wiring an existing pass into the preset that names its source +> costs no new code and is the cheapest capability this project has. + ### Adding a New Built-in Preset 1. Edit `app/lib/models/processing_preset.dart` @@ -378,6 +394,38 @@ tried again — is in the renderer's header comment. 3. Configure `pipeline` with the desired filter settings and `encodingSettings` 4. Add it to the list returned by `ProcessingPreset.builtInPresets()` — nothing picks it up otherwise +5. Assert in `app/test/processing_preset_test.dart` that it does what its **name** + says. That file is where two real bugs in the source presets were caught, both + of them silent: + +> **`ProcessingPipeline()`'s default deinterlaces.** `QTGMCParameters.enabled` +> defaults to **`true`**, so a preset that simply doesn't mention deinterlacing +> gets it anyway — which on a progressive source (a film scan) just softens the +> picture for no reason. Pass `deinterlace: QTGMCParameters(enabled: false)` +> explicitly when you don't want it. +> +> **A `preset:` enum on its own is only a label.** `NoiseReductionParameters( +> preset: NoiseReductionPreset.light)` leaves every threshold at its default, so +> "light" denoises exactly as hard as "moderate". Use the +> `NoiseReductionParameters.fromPreset(...)` factory, which applies the matching +> values. + +**Name presets after the source, not the technique.** The user knows they +captured a DV tape; they don't know it wants SMDegrain with a chroma-bleed fix. +That is why the set includes `DV Camcorder Tape`, `PAL DVD / Broadcast`, +`Anime DVD` and `8mm / Super 8 Film Scan` alongside the three quality tiers, and +it is the cheapest way to add capability — a preset costs no UI complexity at +all. + +**Set `category`.** `PresetCategory.quality` for a speed/quality tier, +`PresetCategory.source` for one shaped around a kind of source; the menu in +`main_window.dart` groups on it ("For Your Source" / "Quality Only"). It is +declared by the factory rather than looked up from a list of ids, so a new +preset cannot land in the wrong group — but one that omits it defaults to +`custom` and files itself under the source presets, so +`processing_preset_test.dart` asserts every built-in declares one. The default is +right for user-saved presets and for presets on disk from before the field +existed. ### Adding a New QTGMC Parameter @@ -496,6 +544,622 @@ Full field reference: **[docs/FILTER_SCHEMA.md](docs/FILTER_SCHEMA.md)**. **`optional: true`**: Shows enable checkbox; when disabled, parameter is omitted (uses VS default). **`visibleWhen`**: Conditional visibility, e.g. `{ "method": ["method_a"] }`. +### Filters added from the gap analysis (2026-08-15) + +Five filters whose plugins were **already in the deps bundle and unused**, so no +deps release was needed: **DFTTest**, **FFT3DFilter** and **TTempSmooth** as +Noise Reduction methods, **aWarpSharp2** as a Sharpen method, and +**HQDeringmod** as a Dehalo method. The three new denoisers are `advancedOnly`; +aWarpSharp2 and HQDeringmod are visible, because each is a different *mechanism* +rather than a variant (and Dehalo's pass name already covers ringing). + +Two lessons from doing it, both of which cost a debugging cycle: + +> **Probe the bundled plugin, don't read about it.** Running each candidate +> against `deps/macos-arm64` at 8/10/12/16-bit before writing any wiring is what +> kept **KNLMeansCL** out of the batch: its OpenCL path does not initialise +> everywhere (the app's own `knlm-probe.json` reports `false` on the development +> Mac), CI deliberately excludes OpenCL-only plugins from +> `vapoursynth_integration_test`'s required list, and `channels="YUV"` demands +> 4:4:4 — which none of this app's sources are. It looked like a one-line win and +> was not low risk at all. +> +> **A plugin's VapourSynth port may not share its Avisynth parameter +> vocabulary.** `warp.AWarpSharp2` takes `chroma` as **0 or 1** and rejects +> anything else at script evaluation; Avisynth's takes 0-6, where 4 means "warp +> chroma with the luma mask". Shipping the Avisynth value killed vspipe outright. +> Script-generation tests passed the whole time — only the **heavy end-to-end +> test** caught it, which is the argument for keeping that suite. `chroma` is now +> deliberately not passed at all, asserted from both sides, in line with how +> every other optional plugin argument here is treated. + +### Fifth filter batch (2026-08-15): two more deps plugins + +**Bifrost** (Chroma Fixes) and **Retinex** (Color Correction), joining the +already-pending deps 1.9.0 rather than forcing another bump — the tag was still +unpublished, so it was free to grow. + +Screening the remaining effort-2 candidates against the Windows-binary rule is +now the first step, and it disqualifies about half of them: + +| plugin | latest release ships a Windows binary? | +|---|---| +| Bifrost v3.0, Retinex r4, MiniDeen v2, MSmooth v1.1, Descale r8 | yes | +| DeDot v3, FillBorders v4, EEDI2 r7.1, EdgeFixer r3, TDeintMod r10.1 | **no** | + +> **Bifrost is 8-bit only** — "Only constant format 8 bit integer YUV input +> supported", verified at 10/12/16-bit and 4:2:2. It gets DeScratch's +> convert-down-and-restore guard. Low impact in practice: the composite captures +> it targets are 8-bit anyway. Measured on an alternating-chroma clip, it halves +> the frame-to-frame chroma swing. +> +> **Retinex rejects subsampled formats outright** ("sub-sampled format is not +> supported"), and *every* source this app handles is 4:2:0 or 4:2:2. Rather +> than round-trip the clip through 4:4:4 and resample chroma twice for what is a +> brightness operation, the luma plane is extracted as greyscale, processed, and +> put back — colour comes through bit-identical. Verified working that way at +> 8/10/12/16-bit and 4:2:2. +> +> **bifrost includes ``**, not ``, +> so `-I"$VS_INC_DIR"` is not enough — the scripts stage an include root with a +> `vapoursynth/` subdirectory and pass its parent. + +### Subtitles: transcribe first, mux last + +The order is load-bearing and was wrong until 2026-08-17. + +``` +transcribe the source -> encode (burning in if asked) -> mux as a post-pass +``` + +Whisper used to run only *after* the encode, which made burn-in structurally +impossible — the encoder needs the file while it is running. Muxing genuinely +must be a post-pass, because the file it goes into does not exist until the +encode finishes. So the two ends of the pipeline both have subtitle work in +them, and neither can move. + +> **Transcribing the source means honouring the trim, or every cue lands +> early.** The encoder seeks the audio input to the trim point +> (`-ss start/fps` on input 1), so the output's audio starts there. A transcript +> of the *whole* source is offset by exactly the trimmed-off head, with no error +> anywhere — `extract_audio_range` takes the same window the encode uses. +> +> What makes this safe is that **nothing in the pipeline retimes audio**. IVTC +> and frame-rate conversion change the video timeline and leave audio at its +> 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. + +`SubtitleOutput::{burns_in, muxes, keeps_srt_file}` answer the three independent +questions rather than matching the enum in three places. A test asserts every +mode does at least one of them, because a mode that does none produces no +subtitles at all and looks like a silent failure. + +### The 2026-08-17 build-out: 18 filters, 4 new passes, deps 1.9.0 + +The plan from the probe rounds was executed in full. Pipeline went from 16 +passes to 20 — **Edge Repair**, **Deflicker**, **Ghost Removal** and +**Frame Rate** — plus methods inside existing passes (Bwdif, Cnr4, RemoveDirt, +mClean, TemporalDegrain2, Auto Gain, Auto White Balance, ContraSharpening, +DeDot, automatic chroma alignment), subtitle burn-in, custom VapourSynth +injection, and an app-side histogram. + +Traps found by writing it, none of which the probing predicted: + +> **The `-vf` slot was single-use, and nothing would have caught it.** +> `build_ffmpeg_args` appended either `setsar` **or** `setdar` — and ffmpeg +> takes the **last** `-vf` and silently drops earlier ones. Adding subtitle +> burn-in as a second `-vf` would have thrown away the aspect stamp, re-breaking +> issue #50's third leg with no error anywhere. Filters now accumulate into a +> `Vec` joined with commas. **Never append a bare `-vf`** — push onto +> that vec. Verified: a 16:11 anamorphic source with burnt-in subtitles comes +> out still tagged 16:11. + +> **Adding a `remove_block` by pattern-matching on a sibling line misses an +> arm.** The new blocks were added by appending to +> `remove_block("{{#NR_STPRESSO}}"...)`, which appears in every method arm +> *except STPresso's own* — so STPresso alone left unsubstituted placeholders. +> A missed `remove_block` chains two denoisers silently: valid VapourSynth, +> twice the runtime, not what the user asked for. `test_115` caught it. When a +> method is added, walk **every** arm programmatically. + +> **Whisper burn-in is not a harder version of burn-in, it is a different +> feature.** `SubtitleGenerator` runs *after* the encode (`main.rs`: +> "Post-encode subtitle generation"), so the transcript does not exist when the +> encoder needs it. Burn-in ships for a **user-supplied** file; the two +> burn-in output modes deliberately fall back to writing the sidecar. Moving +> transcription before the encode is separate work. + +> **The frame count is the hazard in custom code, not the code.** Arbitrary +> execution is not a new risk in a process that already loads arbitrary plugins +> — `custom_ffmpeg_args` predates this. But a snippet calling `Trim` or +> `SelectEvery` changes the real output length while the declared total stays +> put, which makes the progress bar lie *and* makes frame-accurate preview show +> a different frame than its label, both silently. The generated script captures +> `len(clip)` before the snippet and raises afterwards if it changed. Do not +> relax that without giving the user a way to declare a `FrameMap`. + +> **`FrameMap::Retime` existed and nothing emitted one.** Before adding a +> variant, check whether the one you need is already there. FlowFPS was chosen +> over BlockFPS specifically because its output count matches +> `Retime::output_count` exactly across 35 combinations while BlockFPS is off by +> one in 14 — the arithmetic decided the filter, not the picture quality. Also +> **reduce the ratio**: 25 → 29.97 is 1200/1001, not the plugin's 30000/1001, +> and `Retime` multiplies a frame count by that pair. + +> **A wheel's macOS tag is a floor, not a promise.** `vapoursynth_dedot` 3.0 +> publishes `macosx_15_0_x86_64`, which fails the x64 bundle's 12.0 +> `STRICT_MIN_OS` guard — so that arch builds from source while every other +> platform takes the wheel. Check `minos` on the actual binary, not the filename. + +> **Enum values and schema options are asserted against each other.** +> `schema_converter_integration_test` failed the moment two subtitle modes were +> added to the Dart enum without adding them to `subtitles.json`. That test +> earns its place; do not weaken it. + +### The 2026-08-17 probe round: measure the premise, not just the plugin + +Seven parallel read-only agents probed all 25 unshipped Core/Strong candidates +from the gap analysis against `deps/macos-arm64` before any plan was written. +**Probing changed the verdict on nine of the twenty-five.** The plan, the +simple-vs-advanced calls and the preset defaults are in the artifact — see +[[reference-hybrid-filter-gap-analysis]]. The transferable lessons: + +> **A forum consensus about AviSynth is not evidence about this pipeline.** +> `TIVTC` was the top-rated candidate on the strength of "TIVTC is definitely +> better for complex DVDs than VIVTC", repeated across VideoHelp. Measured +> against the repo's own `hard_telecine_test.avi`, TFM+TDecimate and the +> `vivtc` VFM+VDecimate path already shipping are **bit-identical** — 0.0000/255 +> after matching and after decimation, same 90→72 frames. On a deliberately +> broken cadence they differ by 0.0002. The claim is true; it is true of +> AviSynth's TIVTC against AviSynth's alternatives, not of this app. **Measure +> the premise before pricing the work**, especially when the rating came from +> reading rather than running. + +> **Two candidates were already implemented.** EDI upscaling is complete in +> `pipeline_template.vpy` with per-plane centroid correction and measured within +> 0.055 px of a reference resample — it is invisible because its whole schema +> section is `advancedOnly, expanded: false` and reaching it needs two separate +> checkboxes. And `GrayWorld` is not a second filter: in YUV the grey-world +> assumption reduces exactly to shifting the U/V plane means onto neutral, which +> is what AutoWhite does. **Check whether the thing exists before costing it.** + +> **Upstream defaults can be no-ops or hard failures — probe the default call, +> not just the function.** `zsmooth.Cnr4` defaults `scenechange=True` and needs +> frame properties this pipeline never sets, so a naive `core.zsmooth.Cnr4(clip)` +> fails **100% of jobs on every platform**; prepend `misc.SCDetect`. `Checkmate` +> is the mirror image: at its own default `tthr2=0` it measured 0.000 difference +> on every dot-crawl pattern tested — shipping upstream's default gives a filter +> that silently does nothing. + +> **A plugin can carry a bug that only bites one architecture.** ReduceFlicker's +> `proc_filter.h` reads `prevp[0]/[2]` where its SIMD path correctly reads +> `nextp[0]/[2]`, and the SIMD block is `#if defined(__SSE2__)` — so aarch64 has +> no path but the buggy one, and the ARM bundles would have rendered differently +> from x86. Same failure shape as the znedi3 `_FieldBased` trap that cost two +> nightly cycles. It is transcribed to `Expr` instead, validated against a numpy +> model of the C source at max 1 level difference. + +> **"Faster" and "better" are different claims and both need measuring.** +> RemoveDirtMC is *not* additive over the shipped SpotLess (9.99 MAE vs 9.21, +> and 1.4x slower) — but plain RemoveDirt runs **908 fps against 143** for 60% +> of the removal. The filter is worth shipping for the axis the forums actually +> praised it on, and would have been wasted effort on the other. + +### Second probe round (2026-08-17): the Useful tier, 21 of 22 deferred + +The same seven-agent treatment over every remaining Useful-tier candidate. +**One promotion out of twenty-two** — MVTools `FlowFPS` as a Frame Rate pass — +and that ratio is the finding, not a disappointment: the Useful tier is where +second answers live, and measuring is how you learn they are second. Lessons +that generalise: + +> **A "new" filter is often the shipped one with different arguments.** +> `KillerSpots` measured **bit-identical** to `spotless.py` (max diff 0.0) with +> three mvtools arguments changed — the third instance of this after +> `lostfunc.DeSpot` and `GrayWorld`. But those arguments are *better*: spot MAE +> 10.49 → 9.33 at 318 → 424 fps, i.e. **a three-line change to shipped code is +> worth more than the filter was**. Diff the algorithm before costing the port. + +> **An automatic filter must be tested on the material it should ignore.** +> `AutoDeblock`'s detection is *inverted*: on genuinely blocked MPEG-2 it never +> escalated past "weak" and altered the picture **less** than it altered clean +> footage, while on grainy-but-unblocked content it fired strong on 99% of +> frames. Heavy quantisation collapses inter-frame detail, so the temporal gate +> it keys on drops exactly when blocking rises. Any "auto" filter gets a +> three-way test — damaged, clean, and noisy-but-clean — and the clean cases +> matter more than the damaged one. + +> **Check the filter against the content this app's presets create.** +> `FillDrops` cannot distinguish a dropped frame from a held animation cel — +> both are bit-exact duplicates — so it destroyed 39 of 80 held frames at +> *every* threshold, since none can be below zero. VapourBox ships **Anime DVD** +> and **DVD IVTC** presets where duplicated frames are normal. A filter that is +> safe on live action can be destructive on the sources we advertise. + +> **Prefer a crash you can catch.** `vs-placebo` constructs its node with no +> exception when Vulkan is absent and then **segfaults on the first frame** — +> no error to detect, no fallback possible, and vspipe dies as "signal 11" for +> both job and preview. That is strictly worse than KNLMeansCL, which at least +> raises. macOS has no Vulkan driver and the wheels ship no MoltenVK, so it can +> never work on either Mac bundle. + +> **`FrameMap::Retime` exists and nothing emits one.** `frame_map_for` produces +> only `Identity`, `Fanout` and `Decimate`. FlowFPS's output count matches +> `Retime::output_count` exactly across 35 combinations; BlockFPS is off by one +> in 14 of them. Choosing FlowFPS makes existing code correct as written — +> check for an unused variant before adding one. + +> **Synthetic uniform grain is a bad fixture for a motion-compensated +> denoiser.** A probe reported `SMDegrain` as a near no-op "at the app's +> defaults"; reproducing it showed the repro omitted `RefineMotion` and +> `prefilter`, which the template always emits. With the real defaults it +> removes 3.34 of 4.36 grain, and the reachable parameter space (27 +> combinations) is well-behaved throughout. **No bug** — but bare `SMDegrain` +> on uniform noise finds perfect motion matches everywhere and gates everything +> out, so use real footage when validating MC denoisers. + +### Fourth filter batch (2026-08-15): probe agents, and what they caught + +Five more effort-1 filters: **CTMF** (Noise Reduction), **DCTFilter** (Deblock), +a **Film Grain** pass (AddGrain + GrainFactory3), a **Rotate / Flip** pass, and +**SmoothLevels** as an option on the existing Levels control. All from plugins +already in the bundle, so no deps change. + +This batch was probed by parallel read-only agents before any wiring was +written, and that is the only reason it works. The traps they found, none of +which any documentation would have shown: + +> **A quarter turn changes the pixel FORMAT, and ffmpeg refuses the result.** +> `std.Turn90` swaps the chroma subsampling axes, so 4:2:2 becomes 4:4:0 — which +> vspipe emits as `C440` and ffmpeg rejects with "YUV4MPEG stream contains an +> unknown pixel format" — and 4:1:1 becomes a format with no y4m identifier at +> all, killing vspipe itself. Both are hard job failures and 4:2:2 is the common +> 10-bit ProRes case. The template captures `clip.format.id` before the turn and +> converts back after, like the LUTDeCrawl guard. +> +> **SAR must be inverted on a quarter turn, in TWO places.** SAR is pixel width +> : height, so turning exchanges them. `GeometryParameters::adjusted_sar` feeds +> both the ffmpeg-side declaration in `pipeline_executor.rs` *and* the +> `{{SOURCE_SAR}}` used by the square-pixel fitting path in the template. Miss +> either and an anamorphic source comes out the wrong shape. +> +> **Turning interlaced material is unrecoverable, not merely lossy.** Fields are +> alternating rows; a quarter turn puts them in alternating *columns*, where +> `SeparateFields` returns two "fields" that each still contain both. No +> deinterlacer can fix it afterwards, and `_FieldBased` still claims the clip is +> fine. `pass_advice.dart` warns when a quarter turn is set with deinterlacing +> off — the pass order already puts deinterlacing first. +> +> **CTMF rejects 9-bit, and 9-bit is reachable.** `pixel_format.rs` rounds an odd +> source depth up through `[8, 9, 10, 12, 14, 16]` and `pipe_source` maps +> `yuv420p9le`, so a 9-bit source would kill the job. Guarded in both templates. +> Its `memsize` is also pinned to 16 MiB: at the plugin's 1 MiB default, 16-bit +> radius 3 measures **0.79 fps against 42 fps**, for bit-identical output. +> +> **DCTFilter accepts NaN and silently blackens the frame.** Its own range check +> is `factor < 0.0 || factor > 1.0`, and both are false for NaN. The worker +> builds the eight factors from a cutoff and a strength and guarantees every one +> is finite. Its coefficient mapping is also **separable** (`factors[u] * +> factors[v]`), not the `max(u, v)` the Avisynth filter of the same name uses. +> +> **`grain.Add`'s `var` must NOT be depth-scaled**, unlike every other level in +> this app. It is already in 8-bit units and the plugin rescales internally; +> applying the `_levels_8bit()` treatment would quadruple the grain at 10-bit. +> Measured identical 8-bit-equivalent output at 8/10/12/16-bit. +> +> **SmoothLevels' default configuration cannot run at all.** havsfunc calls +> `core.f3kdb.Deband` and this bundle ships **`neo_f3kdb`** under a different +> namespace, so `useDB=True` — the default — raises "no attribute named f3kdb" +> on every format. It is pinned `False`; fixing it properly needs a havsfunc +> patch 8 and therefore a deps release. Its levels are also read in the clip's +> own range, so six arguments are scaled in-script; and it **crashes** when +> `input_low > 0` and `1/gamma` is not an integer (a negative base to a +> fractional power yields a Python complex), so the worker drops the black point +> for that combination. + +> **TemporalDegrain2 was requested and is NOT effort 1.** Its upstream repo +> declares **no licence**, so vendoring ~4,300 lines of it is a legal decision +> rather than a technical one. Beyond that: it needs five modules, not one; +> `postFFT=5` **aborts the process** rather than raising; `postFFT=4` is broken +> two ways; `extraSharp=True` is a `NameError` at exactly 16-bit; and both +> `limitSigma` and mvtools' `limit=255` default are depth-dependent, so +> `outputStage=0` is a **complete no-op at >=12-bit** and 73% of the degraining +> is silently lost at 16-bit. Good news: bm3d is never reached, so it needs no +> deps addition. Implementable, but effort 3 and blocked on the licence. + +### Third filter batch (2026-08-15): the first deps change + +**fluxsmooth** is the first plugin this work has *added* to the bundle rather +than found already in it, so it is the first batch that needs a **deps release** +(1.8.0 → **1.9.0**). It unlocks three Noise Reduction methods: `FluxSmoothT`, +`FluxSmoothST`, and **STPresso**, which was dropped from the second batch for +exactly this missing dependency. + +What an effort-2 addition actually costs, beyond the usual filter wiring: + +1. A build block in **all three** `download-deps-*` scripts. +2. An entry per platform in `Scripts/deps-expected-plugins.json` — the packaging + guard that turns a dead download URL into a red build. +3. The namespace in `app/test/vapoursynth_integration_test.dart`'s required + list, or a bundle missing it passes CI and fails at job time. +4. A version + tag bump in `app/assets/deps-version.json`. +5. **A deps release actually built and published**, which is CI work and cannot + be done or verified locally — see the rc flow in "Testing a deps change". + +> **Windows has no from-source build path, and that decides the version.** +> `download-deps-windows.ps1` only fetches published release archives, so a +> plugin is only addable if upstream ships a Windows binary — and every platform +> must then pin the version Windows can get. FillBorders and Bwdif were the first +> two candidates and were **rejected on this basis**: their newest Windows +> binaries are several releases behind their source (FillBorders v2 vs v4, Bwdif +> r4.1 vs r5.1), and pinning everything back that far would have cost features +> that only exist in the newer source. Check +> `gh api repos///releases --jq '.[] | "\(.tag_name) \(.assets|length)"'` +> **before** planning any effort-2 addition. +> +> **But that check is no longer sufficient on its own — plugins are migrating to +> PyPI.** Re-probing on 2026-08-17 found the rule intact and *three of its +> conclusions stale*, because upstream had changed distribution channel rather +> than stopping: +> +> | plugin | GitHub releases say | reality | +> |---|---|---| +> | **Bwdif** | last asset r4.1 (2021) | r5 moved to PyPI; `vapoursynth-bwdif` 5.1 ships wheels for **all five** targets | +> | **DeDot** | v2/v3 have no assets | `vapoursynth_dedot` 3.0 ships wheels for all five | +> | **EdgeFixer** | (assumed absent) | r3 (2026-07-22) **does** ship `EdgeFixer_r3.7z`, and it is the newest tag | +> +> So the check is now **two** commands, and the second is the one that was +> missing: `curl -s https://pypi.org/pypi/vapoursynth-/json`. The akarin +> block in `download-deps-windows.ps1` is already a working PyPI-wheel fetcher +> (resolve the hashed URL through the JSON API; a wheel is a zip) — copy it +> rather than concluding a plugin is unavailable. +> +> Two caveats found the same day: a wheel's macOS tag is a **floor, not a +> promise** — dedot's `macosx_15_0_x86_64` fails this bundle's `STRICT_MIN_OS=1` +> 12.0 guard, so x64 still builds from source — and **FillBorders v2 vs v4 is +> still real**, but measured bit-identical at even border widths, differing only +> at odd widths where v2 leaves subsampled chroma unrepaired. Constraining the UI +> to `step: 2` (as every crop control already is) erases the difference. + +> **Yes, one filter justified this deps release — that was a deliberate call.** +> zsmooth already provides `FluxSmoothT`/`FluxSmoothST`, so those two methods +> never needed the plugin; they call the canonical `flux` namespace only because +> it is present. **STPresso is the only filter that actually required it**, +> because havsfunc hardcodes `core.flux.SmoothT` and cannot see zsmooth's +> equivalent. The alternative — point the two FluxSmooth methods at zsmooth, drop +> STPresso, revert to deps 1.8.0 — was considered and rejected on 2026-08-15: +> STPresso is well regarded, the plugin is 34 KB, and a deps release is a +> one-time cost. Don't re-litigate this; if the plugin ever needs removing, it is +> STPresso that goes with it. + +> **Prefer compiling a small plugin directly over running its build system.** +> fluxsmooth is autotools, and adding autoconf/automake/libtool to three CI +> deps workflows for one plugin is a poor trade. It is a single C file, so the +> macOS and Linux scripts call the compiler directly — one line, no new +> toolchain, and identical output. + +### The download scripts do NOT share a vocabulary — porting a block costs four checks + +Adding these three plugins took **four** red deps builds, each a different cause +with the same symptom (`deps-expected-plugins.json` reporting missing plugins). +Every one came from writing a block in one platform's script and copying it to +another, carrying an assumption that silently did not hold. Before assuming a +copied block works, check all four: + +| | macOS | Linux | +|---|---|---| +| VS headers | `$VS_INC_DIR` | **`$VS_INCLUDE_DIR`** | +| pkg-config for meson | `build_plugin` sets it internally | must prefix **`$PLUGIN_BUILD_ENV`** | +| `` include style | farm added 2026-08-16 (was absent) | permanent symlink farm | +| arch handling | **split**: x64 pre-built / arm64 from-source | both from source, no split | + +The failures, in the order they appeared: + +1. **`$VS_INC_DIR` is unset on Linux**, so it reached `cc` as a bare `-I` + ("missing path after '-I'"). Both direct-compile blocks now assert + `${VS_INCLUDE_DIR:?}` so a rename fails naming the plugin and the variable. +2. **retinex lost `$PLUGIN_BUILD_ENV`** on Linux — macOS has no such prefix, so + copying its `build_plugin` call across dropped `PKG_CONFIG_PATH` and meson + could not see VapourSynth at all. +3. **retinex includes ``**, so pkg-config *finding* + VapourSynth is not sufficient — the include root needs a child directory + named `vapoursynth`. Linux had kept one for years; macOS had none, which is + why bifrost staged a private tree and retinex (which resolves through + pkg-config and cannot be handed one) could not work at all. macOS now mirrors + the farm, so a single `-I"$VS_INC_DIR"` satisfies both include styles. + Note this needs the **API3** headers: R78 installs only the API4 set, and + both scripts top up `VapourSynth.h` from the source tree. +4. **The blocks sat inside the macOS arch split's arm64 branch**, so x64 never + reached them — invisible on arm64, where everything passed. Anything built + from source on *both* arches belongs after + `fi # end plugin arch split`, where zsmooth already lives. + +> **A green Windows deps build proves nothing about the other two.** +> `download-deps-windows.ps1` only downloads published binaries, so it passed on +> the first attempt and every attempt after, while macOS and Linux were failing +> for three different reasons. Don't read it as a signal. + +### Second filter batch (2026-08-15): two new passes + +**Anti-Aliasing** (`daa`, `santiag`) and **Stabilize** (`Stab`) are the first +whole *categories* added rather than alternatives inside an existing pass, plus +**LUTDeRainbow** as a Chroma Fixes toggle. All three come from `havsfunc` and +MVTools, already in the bundle, so again no deps release. + +Two orderings are load-bearing and asserted from both sides +(`test_110` in Rust, `pass_list_stages_test.dart` in Dart): + +- **Anti-aliasing runs before Sharpen.** Sharpening a stair-stepped edge makes + the stepping more visible, not less. +- **Stabilize runs last before Crop/Resize.** It shifts the picture within the + frame and exposes thin empty edges, so a crop afterwards removes them. + +> **`santiag`'s `type` is pinned to `nnedi3`.** havsfunc also accepts `eedi2` +> and `sangnom`; **neither is in the deps bundle**, and naming an absent one +> fails at script evaluation with a bare "no attribute" error. `AntiAliasParameters::effective_santiag_type` +> drops anything else. The same shape as `normalized_chroma_edi` — don't bypass it. +> +> **LUTDeRainbow shares LUTDeCrawl's 8-10 bit limit** ("This is not an 8-10 bit +> YUV or YCoCg clip"), so it gets the same convert-down-and-restore guard in both +> templates. Found by probing the bundle, not from documentation. +> +> **STPresso was dropped from this batch.** havsfunc implements it with +> `core.flux.SmoothT`, and the **fluxsmooth plugin is not bundled** — zsmooth +> provides `FluxSmoothT` under a different namespace, which havsfunc does not +> know about. That makes it effort 2, not 1. + +> **Probe the signature, not just the call.** `Stab` shipped with a `range` +> argument that the bundled havsfunc does not have +> (`Stab(clp, dxmax, dymax, mirror)`), so every job using it died with a +> `TypeError`. The earlier probe called `haf.Stab(clip)` with no arguments and +> passed, which proved only that the function exists. `inspect.signature` against +> the bundled module is the check that would have caught it — the same lesson as +> aWarpSharp2's `chroma`, one level deeper. + +> **A terse plugin error naming a property tells you the property is involved, +> not in which direction.** `daa` failed on macOS x64 and Linux x64 with +> `Failed to retrieve frame 0 with error: znedi3: _FieldBased`. Read as "znedi3 +> rejects field-based clips", it produced a fix that cleared the property — and +> changed nothing, because the truth is the opposite. Probed against the bundled +> plugin: +> +> | `field` | no `_FieldBased` | `=0` | `=2` | +> |---|---|---|---| +> | 1 | OK | OK | OK | +> | **3** | **ERROR** | OK | OK | +> +> znedi3's **double-rate** mode *requires* the property; havsfunc's `daa` uses +> `field=3`, and this pipeline only sets `_FieldBased` when a field order is +> **known** — so an ordinary source with none killed the pass. The Anti-Aliasing +> block therefore always marks the clip: `0` after deinterlacing (that output is +> progressive) or when nothing was detected, the detected order otherwise. +> `test_139`–`test_141` pin all three cases. +> +> It survived on macOS arm64 (nnedi3 via patch 6) and on Windows (whose +> *prebuilt* znedi3 tolerates the absence) and died on the two bundles that +> build znedi3 from source — the worst shape a bug can have, since the same job +> worked or failed depending on the user's machine. **Two platforms passing is +> not evidence**; that is the same trap as a green Windows deps build. +> +> The whole detour cost two nightly cycles and would have been avoided by a +> two-minute `vspipe` probe against `deps/` — which is what the two notes above +> already say to do. + +> **The heavy tests run the worker BINARY, not the library.** `cargo test` +> compiles `src/` into its own test executable, so the Rust suite can pass +> against new code while `app/test/integration_*` exercises a stale +> `worker/target/debug/vapourbox-worker`. The symptom is badly misleading: +> generated scripts full of unsubstituted `{{PLACEHOLDER}}` and a bare Python +> `SyntaxError` from vspipe, which reads like a template bug. `WorkerHarness` +> now prints a loud warning when the binary is older than anything in +> `worker/src` or `worker/templates` — **run `cargo build` before the heavy +> suite**. + +> **The same trap on the Dart side: a stale `.g.dart`.** `app/lib/**/*.g.dart` +> is gitignored and every CI job runs `dart run build_runner build` immediately +> before testing, so **CI can never reproduce this** — it is purely a +> developer-machine failure. Add a field to a model, forget to rebuild, and +> `toJson()` keeps emitting the old key set; the worker's serde models carry +> `#[serde(default)]`, so the field arrives as its default and the pass runs +> with the wrong settings, with no error anywhere. You end up debugging the +> template. `app/test/generated_code_freshness_test.dart` fails the push gate +> when a declared field has no generated code, naming the field and the fix. +> +> It checks **field names, not mtimes**: build_runner is incremental and leaves +> a generated file alone when its output is unchanged, so an mtime comparison +> reports eight models stale straight after a clean build. Don't "simplify" it +> back to timestamps. + +### Advanced mode is one app-wide setting, and it is the complexity lever + +`AdvancedModeService` (**Settings → General → Show advanced options**, persisted +under `showAdvancedOptions`) gates three things: `advancedOnly` **sections**, +preset-controlled parameters, and `advancedOnly` **methods**. It is provided +through `MultiProvider` in `main.dart` and read with +`context.watch()`, so every panel agrees and the choice +survives collapsing a pass. + +It used to be `bool _advancedMode` inside `_DynamicFilterPanelCompactState` — +per-panel, defaulting off, **reset on every collapse**. That made it useless as +a lever: an expert re-flipped it constantly, so nothing could be hidden behind +it aggressively enough to matter. Adding filters to this app means adding +*methods to existing passes* far more often than new passes, so `advancedOnly` +on a method is what keeps a slot's dropdown short. Field reference and the three +rules for using it: **[docs/FILTER_SCHEMA.md](docs/FILTER_SCHEMA.md)**. + +### A method dispatch that removes N-1 blocks is quadratic, and it fails silently + +`script_generator.rs` selects a filter method with one `match` arm per method, +each *removing* every sibling template block and then enabling its own. So a +pass with twelve methods has twelve arms each naming eleven blocks, and adding a +method means editing all twelve. A blanket edit that appends the new +`remove_block` to every arm therefore also appends it to the **new arm**, which +then deletes its own block before the `replace` that would have enabled it. + +**mClean and TemporalDegrain2 both shipped that way** and were completely +unreachable: the pass was on, the script contained no denoiser at all, and the +encode produced a passthrough. Nothing failed — not the job, not the preview, +not `cargo test`, because neither method had a script-generation test. It was +found only because the parity suite asserts each pass differs from a +passthrough frame. + +`test_149_every_noise_reduction_method_emits_its_filter` now walks the whole +enum and asserts each method's own call appears. **Enumerate the enum** — a test +per method only covers the method you thought to write one for, which is never +the broken one. `integration_filter_parameters_test.dart` carries the two +methods too, because the Rust test builds the struct directly and so cannot +catch a Dart `@JsonValue` drifting from the Rust serde name. + +> The parameter-name parser in that Dart suite **preserves case**, and the +> spellings genuinely differ between plugins: mvtools takes `thsad`, mClean's +> wrapper takes `thSAD`. Don't "normalise" one to the other. + +### Suggestions and advice are hints, and must stay hints + +Two small pure-function models sit beside the pass list, and both are +deliberately toothless — neither blocks a job, disables a control or changes a +value: + +- **`pass_relevance.dart`** (`relevanceFor`) decides whether a pass is + `recommended` / `neutral` / `notApplicable` for the loaded file, from the + `VideoInfo` detection already does (scan type, height, codec, SAR). It drives a + "Suggested" badge and a reason line, and **never reorders the list** — row + order is pipeline order, asserted by `pass_list_stages_test.dart`. + The load-bearing property is restraint: a badge on nine of thirteen rows is + decoration, not a recommendation, so passes detection cannot judge (dirt, + scratches, grain, halos, banding, colour) return `neutral` and say nothing. + `pass_relevance_test.dart` bounds the number of suggestions per source and + asserts those nine stay silent for every scan type / height / codec + combination. `ScanType.unknown` must stay neutral too — detection failed, so + claiming either way is worse than silence. +- **`pass_advice.dart`** (`adviseOn` / `adviceFor`) comments on pass + *combinations*, which is the complexity that actually bites: sharpening that + the denoiser will undo, an FPS divisor that IVTC ignores, Vinverse with + deinterlacing off. Rendered through the existing `WarningBanner` in + `pass_settings_inline.dart`. Every combination it mentions still produces a + valid render, so none of it is validation. Advice must only ever attach to an + **enabled** pass — a banner on a pass the user isn't using is how advisory UI + gets learned-to-ignore — and `pass_advice_test.dart` asserts that, plus that a + default pipeline is completely silent. + +**Curation is asserted, not just recommended.** +`app/test/filter_schema_curation_test.dart` lints every shipped schema: the first +method is never `advancedOnly` (it is the resolved default), at least one method +survives simple mode, every method carries a description (the only guidance the +dropdown shows), and **no schema offers more than 4 methods in simple mode**. +That last one is the load-bearing assertion — adding methods is expected, letting +a simple-mode dropdown grow without curating is what it catches. Currently +curated: dehalo 7 → 3, noise_reduction 4 → 3, crop_resize 3 → 2. + +> The one trap: `FilterSchema.visibleMethods` **always keeps the currently +> selected method**, advanced-only or not. A preset can select one, and hiding it +> would both misreport the pipeline and hand `DropdownButtonFormField` a value +> that isn't in its items — a thrown assertion, not a graceful fallback. Don't +> "simplify" that argument away; `dynamic_filter_panel_advanced_test.dart` +> asserts it, along with the case that a filter filtered down to a single method +> still tells the user more exist. + ### Aspect Ratio (issue #50) Three things decide the shape of the output, and they live in different places: @@ -529,6 +1193,57 @@ Whichever axis you change, `parse_ratio` accepts `16:9`, `16/9` and `1.7778`, an returns `None` for anything else so a typo falls back to the source's own aspect rather than reaching ffmpeg as a broken filter argument. +### Colour metadata: read it, carry it, re-stamp it (fixed 2026-08-17) + +Same shape as the SAR bug and the same fix site. Colour tags used to be dropped +at all three stages — never read (ffprobe returns them; the parser discarded +them), never carried on either `VideoJob`, never stamped. So **every file this +app wrote was untagged**, and an untagged file is read as BT.601 limited by +every player, silently shifting the colours of any BT.709 or full-range source. + +Measured before and after on a `bt709` + full-range source, end to end through +the worker: + +| | `color_space`, `color_range` | +|---|---| +| source | `bt709, pc` | +| output **before** | `unknown, tv` | +| output **after** | `bt709, pc` | + +Three things to keep in mind if you touch this: + +- **`SetFrameProps` in the script would be inert.** The Y4M pipe strips frame + properties exactly as it strips SAR — verified: a clip carrying `_Matrix=5` + produces a header with no matrix, primaries or transfer. The tags have to be + **output-stream flags on the encoder** (`-colorspace`/`-color_primaries`/ + `-color_trc`/`-color_range`), which is where they now are, immediately after + the `setsar` block in `pipeline_executor.rs`. +- **Values are validated, not forwarded.** `ColorMetadata::from_raw` + (`worker/src/models/color_metadata.rs`) drops anything not on FFmpeg's own + accepted list, on the same principle as `parse_ratio`. ffprobe says + `"unknown"` for an untagged stream, and forwarding that would fail the whole + encode on an argument the user can neither see nor fix. Each tag is + independent: a source declaring only a matrix gets only `-colorspace`, and an + untagged source is left untagged rather than guessed at. +- **`build_ffmpeg_args_for_test` duplicates `build_ffmpeg_args`** rather than + calling it, so anything added to one must be added to the other. The SAR block + was missed that way and is *still* absent from the test helper; the colour + block is mirrored, and there is a comment saying so. + +This also fixed a companion bug in the preview. It hardcoded +`-vf scale=in_range=tv:out_range=pc` with **no `in_color_matrix`**, so swscale +guessed the matrix — while the app's "before" thumbnail comes from a separate +ffmpeg call on the original file that *does* see the real tags. On a 709-tagged +source the comparison therefore showed a hue shift no filter had caused, and a +full-range source was range-stretched twice. `swscale_input_opts()` now supplies +both from the source's own tags. + +Guarded by `models::color_metadata` unit tests, three `pipeline_executor` tests +on the emitted arguments, `app/test/color_metadata_test.dart` for the ffprobe +side, and a heavy end-to-end round trip in +`integration_chroma_subsampling_test.dart` — which is the only level that can +prove ffmpeg honoured the flags. + ### Source Pixel Formats (issue #50) `templates/pipe_source.py` reads **raw planar frames off stdin**, so it can only @@ -801,7 +1516,11 @@ Headless Dart-VM tests. Three groups: - **Pure unit tests** — `dynamic_parameters`, `filter_schema`, `parameter_converter`, `widget_test`, `scan_type_detection`, `attribution` (NOTICES/About-dialog/deps-manifest agreement — see - "Attribution" below). + "Attribution" below), `advanced_mode_service`, + `dynamic_filter_panel_advanced` (a widget test — pumps the generated panel + through a `ChangeNotifierProvider`, no desktop needed), + `filter_schema_curation` (lints every shipped schema), `pass_list_stages`, + `pass_relevance`, `pass_advice`, `processing_preset`. - **Shell-out tests** — `vapoursynth_integration_test`, `schema_converter_integration_test`; need the per-arch `deps/` and (for whisper) `addons/`. @@ -1039,6 +1758,12 @@ Three things to keep straight: `test_93` fails the build if either calls `core.std.Expr` directly, and also if the helper's fallback calls *itself* (a blanket search-and-replace made it infinitely recursive once — the fallback must name `core.std.Expr`). + **`test_93` only scans the two `.vpy` templates**, not vendored `.py` modules + in `worker/templates/`. That has never mattered because `spotless.py` uses no + `Expr` at all — but TemporalDegrain2 and mClean both do, so vendoring either + as-is would silently take the 21x scalar-interpreter path on ARM with nothing + failing. Extend the assertion to `worker/templates/*.py` before vendoring + anything that calls `Expr`. - **macOS x64 deliberately does not get it.** The only wheel is `macosx_14_0_x86_64` and that bundle targets **12.0** (issue #39), so shipping it would raise the Intel floor to macOS 14 — for a platform that already has @@ -1106,20 +1831,72 @@ README's platform table. The app and whisper builds deliberately stay on floor there costs nothing — but the effective requirement is the highest of the components, which is the deps. -### Testing a deps change before publishing: release-candidate tags +### Testing a deps change before publishing A PR that changes `deps/` has a chicken-and-egg problem: `ci-test.yml` and -`nightly.yml` download the bundle named by `app/assets/deps-version.json` from a -**published** release, so a deps change cannot be tested until it is published — -and publishing an untested bundle is what you were trying to avoid. - -**Draft releases do not solve it** (their assets need an authenticated API call, -so neither CI nor the app can fetch them). **Prereleases do**: they are publicly -downloadable at the ordinary `releases/download//` URL, and every -consumer here is tag-driven — `getDownloadUrl()` builds the URL from -`releaseTag`, and nothing in the repo uses `/releases/latest`. So a prerelease -is indistinguishable from a stable release to CI, the nightly suite *and* a real -app build. +`nightly.yml` download the bundle named by `app/assets/deps-version.json`, so a +deps change could not be tested until it was published — and publishing an +untested bundle is what you were trying to avoid. + +There are two ways out, and **the artifact one is now the default answer** +because nothing about it is public. + +#### 1. `deps_run_id` — test an unpublished bundle (preferred) + +Both `ci-test.yml` and `nightly.yml` take an optional `deps_run_id` +`workflow_dispatch` input. Set it, and `.github/scripts/fetch-deps-bundle.sh` +pulls the zip from that `build-deps-*` **workflow run's artifact** instead of +from a release. No tag, no release, nothing publicly visible, and nothing to +clean up — artifacts expire on their own. + +```bash +# 1. Build the bundles. release_tag may be empty (artifact only) or name a +# draft release to stage the assets in — the artifact is uploaded either way. +gh workflow run build-deps-macos.yml -f version=1.9.0 -f arch=both +gh workflow run build-deps-windows.yml -f version=1.9.0 +gh workflow run build-deps-linux.yml -f version=1.9.0 -f arch=both + +# 2. Point a CI run at all three runs at once. Must be workflow_dispatch — a +# push/PR trigger cannot carry an input, so it always takes the release path. +gh workflow run ci-test.yml --ref \ + -f deps_run_id=",," +``` + +Three things to know: + +- **It needs `actions: read`**, which the repo's *restricted* default workflow + token (`contents` + `packages` read, everything else none) does **not** grant. + Both workflows therefore declare an explicit read-only `permissions:` block. + Don't "simplify" it away — the failure is a 404 on the artifact fetch. +- **`deps_run_id` is a list, and has to be.** One CI dispatch runs all four + platform jobs, but macOS, Windows and Linux are three separate `build-deps-*` + workflows and therefore three separate run IDs (macOS produces both arches in + one run). Each job tries every ID and takes the first holding an artifact for + *its* platform, so order doesn't matter and a partial list is fine — the jobs + whose platform is missing fail with `no deps artifact in any of:` + rather than silently testing the wrong thing. +- **The version isn't cross-checked** against `deps-version.json`, deliberately — + the bundle under test is unreleased and may carry a throwaway version. The + script logs a `::warning::` naming the run, so a green tick can't be mistaken + for a run against the released bundle. + +> **Draft releases were the obvious idea and are the wrong one.** Not for the +> reason this section used to give — CI *does* authenticate (`gh release +> download` with `GH_TOKEN`), so the old claim that "neither CI nor the app can +> fetch them" was only ever true of the app. The real blocker is narrower: draft +> assets need **push** access, and the default token is read-only, so it would +> take widening the token to `contents: write` on a workflow that runs against +> pull requests. The artifact route gets the same privacy for a read-only scope. + +#### 2. Release-candidate prereleases — when the download path itself is the thing under test + +A prerelease is publicly downloadable at the ordinary +`releases/download//` URL, and every consumer here is tag-driven — +`getDownloadUrl()` builds the URL from `releaseTag`, and nothing in the repo uses +`/releases/latest`. So a prerelease is indistinguishable from a stable release to +CI, the nightly suite *and* a real app build. That last one is what `deps_run_id` +can't do: an installed app has no token and cannot read artifacts, so **testing +the actual first-run download flow still needs an rc**. The workflow: @@ -1322,7 +2099,14 @@ version skew between platforms would change chroma per-OS. 2. **JSON mismatch**: Compare Rust and Dart model serialization 3. **Plugin load failures**: Check environment variables in `DependencyLocator` 4. **Encoding fails**: Run generated .vpy script manually with vspipe -5. **Template not found**: Check search paths in `script_generator.rs` +5. **Template not found**: Check search paths in `script_generator.rs`. Note + that `load_template_by_name` **normalizes CRLF to LF** on read: a Windows + checkout gives the `.vpy` files CRLF, so any substitution whose pattern spans + a line break silently stops matching there, leaving a plausible-looking + script rather than an error. That shipped once — the SmoothLevels path elides + the plain `std.Levels` call with a two-line `replace()`, and on Windows alone + both calls survived. `templates_load_with_lf_endings_only` guards it on every + platform; prefer single-line patterns regardless. 6. **Filter not appearing**: Check JSON syntax, verify `id` is unique 7. **macOS build fails with "Unable to find module dependency"**: Build Pods-Runner scheme first, then Runner for arm64 only (see Build Commands) 8. **App crashes silently on video drop (release build)**: Bundle is incomplete. Use packaging scripts. Run from terminal to see errors. @@ -1660,4 +2444,5 @@ Create the app-specific password at appleid.apple.com → Sign-In and Security | 1.0.0 | 2025-01-15 | Initial release | | … | | (1.1.0–1.6.0 went unrecorded) | | 1.7.0 | 2026-08-01 | Fixes QTGMC Placebo/Very Slow brightening and near-black Draft on arm64, via `Scripts/patches/fmtconv-r31-arm-int-scaler.patch` (root cause: sign constants in fmtconv's non-SIMD integer scaler) plus havsfunc patch 5 as defence in depth; fmtconv r30 → **r31**, now pinned and sourced from GitLab on every platform. **Rebuilt 2026-08-02** to add the **zsmooth** plugin (MIT), providing `core.zsmooth.CCD` plus `Cnr4` and a set of RemoveGrain/TemporalMedian-family filters. Version pinned to 0.19.0 in all three download scripts — keep them in step so the same job can't produce different chroma per OS. Taken pre-built everywhere except macOS x64, which builds it with Zig to reach `minos 12.0` (see the macOS platform notes) | +| 1.9.0 | 2026-08-15 | Adds three plugins. **fluxsmooth** (`core.flux.SmoothT` / `SmoothST`), which also unlocks havsfunc's **STPresso** — it calls `core.flux.SmoothT` internally and raised "No attribute with the name flux exists" without it. Pinned to **v2** on every platform: that is the newest tag with a published Windows binary, and `download-deps-windows.ps1` has no from-source path, so macOS/Linux track the version Windows can get rather than letting the same job denoise differently per OS. Built on macOS/Linux by invoking the compiler directly on its single C file rather than through its autotools build, so no new build dependency (autoconf/automake/libtool) is added to CI. Also adds **bifrost** (`core.bifrost.Bifrost`, temporal rainbow/dot-crawl removal, pinned v3.0) and **retinex** (`core.retinex.MSRCP`, shadow-detail lift, pinned r4) — both chosen because their *newest* release ships a Windows binary, so no version skew, and both link nothing beyond system libraries. bifrost is another single C file compiled directly, but it includes `` so the scripts stage a small include root whose parent is passed to `-I`; retinex is an ordinary meson build resolving headers through pkg-config | | 1.8.0 | 2026-08-07 | VapourSynth **R73 → R78** on every platform, which moves Windows to a Python 3.12 wheel layout and makes `deps//vapoursynth/` the Python package itself on macOS/Linux (see the R78 sections). Adds the **akarin** plugin (LGPL-3.0, statically links LLVM 22.1.2) supplying an LLVM JIT for `std.Expr`, routed in via havsfunc **patch 7** and the templates' `_expr()` helper — worth **4.1x** on arm64 QTGMC Slow, since VapourSynth's own Expr JIT is x86-only. **Not** shipped on macos-x64, whose only wheel would raise the Intel floor to macOS 14 (issue #39). Fixes the **nnedi3** build on linux-arm64, which had never produced a binary (`-mfpu=neon` and `HWCAP_ARM_*` are both 32-bit-ARM-only), and drops the plugin from linux-x64's expected list to match the other x86 bundles. **BestSource removed** — nothing had called it since the pipe source replaced it. Linux now needs **glibc 2.39** (ubuntu-24.04), so Ubuntu 22.04 and Debian 12 can no longer run it | diff --git a/README.md b/README.md index 05859c63..4e5aa464 100644 --- a/README.md +++ b/README.md @@ -20,14 +20,14 @@ VapourBox runs [VapourSynth](https://www.vapoursynth.com/), QTGMC and FFmpeg — **Correcting how the picture is stored.** Most consumer and broadcast video recorded before the 2010s is interlaced — VHS, Video8 and Hi8, DV camcorders, DVDs, off-air recordings — and needs converting to progressive to display properly on a modern screen. Film transferred to video was padded with pulldown instead, which can be reversed to recover the original 23.976 fps frames. VapourBox detects which case applies and handles both: QTGMC for deinterlacing, IVTC for telecined film. -**Cleanup, when the source needs it.** Thirteen optional filters covering tape noise and smeared color, dust and scratches on scanned film, blocking from a heavily compressed disc or recorder, and halos, banding and color balance. All off by default; you turn on what a given source actually needs. +**Cleanup, when the source needs it.** Twenty-one optional filters covering tape noise and smeared color, dust and scratches on scanned film, the dirty rows at the edge of a capture, ghosting from an aerial, the brightness flicker of scanned cine film, blocking from a heavily compressed disc or recorder, jagged diagonal edges, camera shake, and halos, banding and color balance. All off by default, and the presets turn on what a given kind of source actually needs. **Subtitles.** Speech is transcribed with Whisper AI to a separate `.srt`, embedded in the output, or both. ## How it works 1. **Add the source.** Drop in a file, a folder of files, a `VIDEO_TS` folder, or open a DVD in the drive. VapourBox inspects it and reports what it found — interlaced, telecined or progressive — so the choice isn't guesswork. -2. **Set the output and any processing.** Pick a codec and container, then either start from a preset (Fast, Balanced, High Quality, VHS Cleanup, DVD IVTC) or switch individual filters on. Each filter's controls open in place beneath it, with a summary of what it does and when it applies. +2. **Set the output and any processing.** Pick a codec and container, then either start from a preset — three quality tiers, plus six named after the source, from VHS Cleanup to 8mm / Super 8 Film Scan — or switch individual filters on. Each filter's controls open in place beneath it, with a summary of what it does and when it applies. 3. **Check the preview, then run it.** The preview shows source and processed output side by side and updates as settings change, so settings can be judged before a long encode. Multiple files queue up and process unattended. ## Download @@ -91,26 +91,38 @@ GPU-accelerated deinterlacing (NNEDI3CL) needs your GPU's OpenCL driver installe ## The filter pipeline -Thirteen filters, each switchable independently, applied in a fixed order. Most sources need none or a few. +Twenty-one filters, each switchable independently, applied in a fixed order. Most sources need none or a few. | Filter | What it addresses | |--------|-------------------| -| **Deinterlace** | Comb-like jagged edges on moving objects. QTGMC for interlaced video, or IVTC to recover the original film frames from telecined DVD. | +| **Deinterlace** | Comb-like jagged edges on moving objects. QTGMC for interlaced video, IVTC to recover the original film frames from telecined DVD, or Bwdif when you want it done in a fraction of the time. | +| **Edge Repair** | The dirty rows and columns at the very edge of a tape capture — rebuilt from the picture just inside, instead of cropped away. | +| **Ghost Removal** | A faint second copy of the picture shifted sideways, left behind by an aerial or a long cable run. | +| **Deflicker** | Brightness pulsing between frames, which is what scanned cine film almost always has. | | **DeScratch** | Vertical scratch lines on scanned film. | | **SpotLess** | Dust, dirt and single-frame specks. | -| **Noise Reduction** | Grain and video noise across the whole frame. Motion-compensated, so detail is preserved. | +| **Noise Reduction** | Grain and video noise across the whole frame. Motion-compensated by default; DFTTest, FFT3DFilter, TTempSmooth, FluxSmooth, STPresso and a large-window median are available under advanced options for noise the default handles badly. | | **Chroma Denoise** | Blotchy, smeared color — common on VHS captures and old camcorder footage. Leaves luma detail untouched. | -| **Dehalo** | Bright outlines around edges, ringing, and residual ghosting left by a deinterlacer. | -| **Deblock** | Square blocking from heavy compression. | +| **Dehalo** | Bright outlines around edges, ringing, and residual ghosting left by a deinterlacer. HQDeringmod targets ringing specifically. | +| **Deblock** | Square blocking from heavy compression, and the ringing around edges that comes with it. | | **Deband** | Visible steps in gradients and skies. | -| **Sharpen** | Soft sources needing edge and fine detail recovery. | -| **Chroma Fixes** | Color bleeding past edges, rainbowing, dot crawl. | -| **Color Correction** | Brightness, contrast, saturation, hue, levels, and white balance (warm/cool, green/magenta). | +| **Anti-Aliasing** | Stair-stepping on diagonal edges, left by deinterlacing or upscaling. Runs before sharpening, which would otherwise make the steps more visible. | +| **Stabilize** | Shake and weave — telecine wobble, jittery film scans, handheld footage. Runs last before cropping, so a small crop removes the edges it exposes. | +| **Film Grain** | Grain added back after denoising, so the picture is not left plastic — and to hide banding in skies and fades. | +| **Rotate / Flip** | Footage shot sideways, mirrored captures, scans that came off the scanner the wrong way round. | +| **Sharpen** | Soft sources needing edge and fine detail recovery. aWarpSharp2 sharpens by warping edges instead of raising contrast, so it adds no halos. | +| **Chroma Fixes** | Color bleeding past edges, rainbowing and dot crawl — including the shimmering kind that only shows when the picture moves — and residual combing. | +| **Color Correction** | Brightness, contrast, saturation, hue, levels, white balance (warm/cool, green/magenta), and lifting detail out of the shadows of underexposed footage. | | **Crop & Resize** | Trimming overscan, scaling, and edge-directed upscaling. | +| **Frame Rate** | Converting between PAL and NTSC rates, for a tape that was already converted once and now plays at the wrong speed. | | **Subtitles** | Whisper AI speech-to-text, to `.srt`, embedded, or both. | Each filter leads with a plain-language summary and a **More** expander describing what it does and when it's the right choice, so the settings can be understood in place rather than looked up elsewhere. +The list also reacts to the file you dropped in. Filters that match what was detected in your source are marked **Suggested** with the reason — "source is hard telecine (3:2 pulldown)", "anamorphic source (10:11) — check pixel aspect" — and ones that can't apply say so, such as deinterlacing a progressive file. Nothing is switched on or off for you; detection is sometimes wrong, so it stays a hint. Filters whose problems can't be spotted from the file alone — dirt, scratches, grain, halos — say nothing either way. + +Where two filters work against each other, the one that loses out says so when you open it: sharpening ahead of a denoiser that will undo it, for instance. + ## Details
@@ -157,7 +169,22 @@ VapourBox decides between the two by looking for a DVD IFO: a folder is only tre
Presets -Presets store the whole pipeline plus encoding settings. Built-in: Fast, Balanced, High Quality, VHS Cleanup, DVD IVTC. Your own save alongside them and persist across sessions. +Presets store the whole pipeline plus encoding settings, and the menu splits them by the question they answer. + +**For Your Source** — pick the one matching what you have and you can ignore the pass list entirely: VHS Cleanup, DV Camcorder Tape, PAL DVD / Broadcast, DVD IVTC, Anime DVD, 8mm / Super 8 Film Scan. + +**Quality Only** — Fast, Balanced and High Quality just deinterlace, at three levels of effort. Use one when the picture is already clean. + +Your own presets save alongside them and persist across sessions. + +
+ +
+Advanced options + +Each filter shows a short, curated set of choices by default. **Settings → General → Show advanced options** reveals the rest — every method and every parameter, in every filter. The switch beside a filter's options does the same thing; either way it applies everywhere and is remembered. + +Turning it off never changes what a filter is doing: if a preset selected an advanced method, that method stays selected and stays visible.
diff --git a/Scripts/deps-expected-plugins.json b/Scripts/deps-expected-plugins.json index 03ec5f67..8e9631cf 100644 --- a/Scripts/deps-expected-plugins.json +++ b/Scripts/deps-expected-plugins.json @@ -1,5 +1,5 @@ { - "_comment": "Required VapourSynth plugin filenames per platform — the contract for a COMPLETE deps bundle. The package-deps-* scripts assert every file listed here exists in the staged bundle before zipping and FAIL the build if any are missing, so a silently-failed download (e.g. a dead upstream URL) becomes a red build instead of an incomplete bundle shipping. Plugin directory: windows-x64 = vapoursynth/vs-plugins, macos/linux = vapoursynth/plugins. Lists exclude data files (nnedi3 weights) and runtime libs (fftw); they cover the VapourSynth plugin binaries only. Update this when adding or removing a plugin.", + "_comment": "Required VapourSynth plugin filenames per platform \u2014 the contract for a COMPLETE deps bundle. The package-deps-* scripts assert every file listed here exists in the staged bundle before zipping and FAIL the build if any are missing, so a silently-failed download (e.g. a dead upstream URL) becomes a red build instead of an incomplete bundle shipping. Plugin directory: windows-x64 = vapoursynth/vs-plugins, macos/linux = vapoursynth/plugins. Lists exclude data files (nnedi3 weights) and runtime libs (fftw); they cover the VapourSynth plugin binaries only. Update this when adding or removing a plugin.", "windows-x64": [ "AddGrain.dll", "CAS.dll", @@ -10,69 +10,93 @@ "Deblock.dll", "EEDI3m.dll", "KNLMeansCL.dll", + "LGhost.dll", "MiscFilters.dll", "NNEDI3CL.dll", + "RemoveDirt.dll", "RemoveGrainVS.dll", + "Retinex.dll", "TCanny.dll", "TTempSmooth.dll", "VIVTC.dll", - "zsmooth.dll", + "bifrost.dll", + "bwdif.dll", + "dedot.dll", "fft3dfilter.dll", "fmtconv.dll", + "libakarin.dll", "libawarpsharp2.dll", + "libfillborders.dll", + "libfluxsmooth.dll", "libmvtools.dll", "libtemporalmedian.dll", + "libzstd.dll", "neo-f3kdb.dll", "vsznedi3.dll", - "libakarin.dll", - "libzstd.dll" + "zsmooth.dll" ], "macos-arm64": [ "libaddgrain.dylib", + "libakarin.dylib", "libawarpsharp2.dylib", + "libbifrost.dylib", "libbm3d.dylib", + "libbwdif.dylib", "libcas.dylib", "libctmf.dylib", "libdctfilter.dylib", "libdeblock.dylib", + "libdedot.dylib", "libdescratch.dylib", "libdfttest.dylib", "libeedi3m.dylib", "libfft3dfilter.dylib", + "libfillborders.dylib", + "libfluxsmooth.dylib", "libfmtconv.dylib", "libknlmeanscl.dylib", + "liblghost.dylib", "libmiscfilters.dylib", "libmvtools.dylib", "libneo-f3kdb.dylib", "libnnedi3.dylib", "libnnedi3cl.dylib", + "libremovedirt.dylib", "libremovegrain.dylib", + "libretinex.dylib", "libtcanny.dylib", "libtmedian.dylib", "libttempsmooth.dylib", "libvivtc.dylib", "libznedi3.dylib", - "libzsmooth.dylib", - "libakarin.dylib" + "libzsmooth.dylib" ], "macos-x64": [ "libaddgrain.dylib", "libawarpsharp2.dylib", + "libbifrost.dylib", + "libbwdif.dylib", "libcas.dylib", "libctmf.dylib", "libdctfilter.dylib", "libdeblock.dylib", + "libdedot.dylib", "libdescratch.dylib", "libdfttest.dylib", "libeedi3m.dylib", "libfft3dfilter.dylib", + "libfillborders.dylib", + "libfluxsmooth.dylib", "libfmtconv.dylib", "libknlmeanscl.dylib", + "liblghost.dylib", "libmiscfilters.dylib", "libmvtools.dylib", "libneo-f3kdb.dylib", "libnnedi3cl.dylib", + "libremovedirt.dylib", "libremovegrain.dylib", + "libretinex.dylib", "libtcanny.dylib", "libtmedian.dylib", "libttempsmooth.dylib", @@ -82,55 +106,71 @@ ], "linux-x64": [ "libaddgrain.so", + "libakarin.so", "libawarpsharp2.so", + "libbifrost.so", + "libbwdif.so", "libcas.so", "libctmf.so", "libdctfilter.so", "libdeblock.so", + "libdedot.so", "libdescratch.so", "libdfttest.so", "libeedi3m.so", "libfft3dfilter.so", + "libfillborders.so", + "libfluxsmooth.so", "libfmtconv.so", "libknlmeanscl.so", + "liblghost.so", "libmiscfilters.so", "libmvtools.so", "libneo-f3kdb.so", "libnnedi3cl.so", + "libremovedirt.so", "libremovegrain.so", + "libretinex.so", "libtcanny.so", "libtmedian.so", "libttempsmooth.so", "libvivtc.so", "libznedi3.so", - "libzsmooth.so", - "libakarin.so" + "libzsmooth.so" ], "linux-arm64": [ "libaddgrain.so", + "libakarin.so", "libawarpsharp2.so", + "libbifrost.so", + "libbwdif.so", "libcas.so", "libctmf.so", "libdctfilter.so", "libdeblock.so", + "libdedot.so", "libdescratch.so", "libdfttest.so", "libeedi3m.so", "libfft3dfilter.so", + "libfillborders.so", + "libfluxsmooth.so", "libfmtconv.so", "libknlmeanscl.so", + "liblghost.so", "libmiscfilters.so", "libmvtools.so", "libneo-f3kdb.so", "libnnedi3.so", "libnnedi3cl.so", + "libremovedirt.so", "libremovegrain.so", + "libretinex.so", "libtcanny.so", "libtmedian.so", "libttempsmooth.so", "libvivtc.so", "libznedi3.so", - "libzsmooth.so", - "libakarin.so" + "libzsmooth.so" ] } diff --git a/Scripts/download-deps-linux.sh b/Scripts/download-deps-linux.sh index d08f94d9..1263596d 100755 --- a/Scripts/download-deps-linux.sh +++ b/Scripts/download-deps-linux.sh @@ -811,6 +811,89 @@ build_plugin "removegrain" \ "libremovegrain.so" \ "$PLUGIN_BUILD_ENV meson setup build --buildtype=release && ninja -C build" +# FluxSmooth (core.flux.SmoothT / SmoothST). Also what havsfunc's STPresso calls +# internally — without this plugin STPresso raises "No attribute with the name +# flux exists", which is why it is not offered without it. +# +# Pinned to v2, the newest tag with a published Windows binary. Windows has no +# from-source build path here (download-deps-windows.ps1 only fetches release +# archives), so every platform tracks the version Windows can get; a version +# skew would make the same job denoise differently per OS. +# +# Built by invoking the compiler directly rather than through its autotools +# build: it is one C file, and autoconf/automake/libtool are not otherwise +# required by any deps build. +FLUXSMOOTH_TAG="v2" +if [ "$FORCE" = true ] || [ ! -f "$PLUGINS_DIR/libfluxsmooth.so" ]; then + echo "" + echo "=== Building fluxsmooth ($FLUXSMOOTH_TAG) ===" + rm -rf fluxsmooth + # Fail loudly if the include variable is ever renamed again: an empty one + # reaches cc as a bare `-I` and the error ("missing path after -I") says + # nothing about which variable was wrong. + : "${VS_INCLUDE_DIR:?VS_INCLUDE_DIR is unset — fluxsmooth cannot find the VapourSynth headers}" + if git clone --depth 1 --branch "$FLUXSMOOTH_TAG" -q \ + https://github.com/dubhater/vapoursynth-fluxsmooth.git fluxsmooth 2>/dev/null \ + && cc -std=c99 -O2 -fPIC -shared \ + -o "$PLUGINS_DIR/libfluxsmooth.so" \ + fluxsmooth/src/fluxsmooth.c -I"$VS_INCLUDE_DIR"; then + echo " Built fluxsmooth -> libfluxsmooth.so" + BUILT_PLUGINS+=("fluxsmooth") + else + echo " Failed to build fluxsmooth" + FAILED_PLUGINS+=("fluxsmooth") + fi +else + echo " fluxsmooth already exists, skipping" +fi + +# Bifrost (core.bifrost.Bifrost) - temporal rainbow / dot-crawl removal for +# composite captures. Pinned to v3.0, the newest tag with a published Windows +# binary; see the fluxsmooth note above for why every platform tracks that. +# +# Its source is one C file, so it is compiled directly rather than through its +# autotools build — no autoconf/automake/libtool needed in CI. It includes +# , so the include path has to be the PARENT of a +# directory called vapoursynth. Unlike macOS, this script already maintains +# exactly that: $VS_INCLUDE_DIR/vapoursynth/ is a farm of symlinks created near +# the top for plugins using this include style, so point -I at its parent and +# stage nothing. +BIFROST_TAG="v3.0" +if [ "$FORCE" = true ] || [ ! -f "$PLUGINS_DIR/libbifrost.so" ]; then + echo "" + echo "=== Building bifrost ($BIFROST_TAG) ===" + rm -rf bifrost + : "${VS_INCLUDE_DIR:?VS_INCLUDE_DIR is unset — bifrost cannot find the VapourSynth headers}" + [ -f "$VS_INCLUDE_DIR/vapoursynth/VapourSynth4.h" ] || { + echo " ERROR: $VS_INCLUDE_DIR/vapoursynth/VapourSynth4.h missing — the" + echo " include symlink farm did not get created." + exit 1 + } + if git clone --depth 1 --branch "$BIFROST_TAG" -q \ + https://github.com/dubhater/vapoursynth-bifrost.git bifrost 2>/dev/null \ + && cc -std=c99 -O2 -fPIC -shared \ + -o "$PLUGINS_DIR/libbifrost.so" \ + bifrost/src/bifrost.c -I"$VS_INCLUDE_DIR"; then + echo " Built bifrost -> libbifrost.so" + BUILT_PLUGINS+=("bifrost") + else + echo " Failed to build bifrost" + FAILED_PLUGINS+=("bifrost") + fi +else + echo " bifrost already exists, skipping" +fi + +# Retinex (core.retinex.MSRCP) - see the macOS script for the rationale. +# It resolves VapourSynth through pkg-config, so it needs $PLUGIN_BUILD_ENV +# like every other meson plugin here — the macOS script has no equivalent +# prefix, so copying its invocation across drops PKG_CONFIG_PATH and meson +# fails with 'Dependency "vapoursynth" not found'. +build_plugin "retinex" \ + "https://github.com/HomeOfVapourSynthEvolution/VapourSynth-Retinex.git" \ + "libretinex.so" \ + "$PLUGIN_BUILD_ENV meson setup build --buildtype=release && ninja -C build" + # AddGrain build_plugin "addgrain" \ "https://github.com/HomeOfVapourSynthEvolution/VapourSynth-AddGrain.git" \ @@ -977,6 +1060,247 @@ else echo " DeScratch already exists, skipping" fi +# ============================================================================ +# Bwdif / FillBorders / RemoveDirt / DeDot / LGhost +# ============================================================================ +# Two of these come from PyPI wheels, one is compiled directly, and two split by +# arch. Note the vocabulary differences from download-deps-macos.sh, which is +# where a copied block goes wrong: the include variable is VS_INCLUDE_DIR here +# (VS_INC_DIR there), and every meson build needs the $PLUGIN_BUILD_ENV prefix +# (macOS's build_plugin sets PKG_CONFIG_PATH internally and has no equivalent). + +# Fetch a pre-built Linux plugin .so from a Stefan-Olt/vs-plugin-build release +# asset, relink it against our layout, and report a failure rather than leaving +# a silent gap (deps-expected-plugins.json turns that into a red build). +download_prebuilt_plugin_linux() { + local label="$1" out_name="$2" url="$3" + + if [ "$FORCE" = false ] && [ -f "$PLUGINS_DIR/$out_name" ]; then + echo " $label already exists, skipping" + return 0 + fi + + local tmp="$BUILD_DIR/prebuilt-$out_name" + rm -rf "$tmp"; mkdir -p "$tmp" + if curl -sL "$url" -o "$tmp/plugin.zip" && unzip -q -o "$tmp/plugin.zip" -d "$tmp"; then + local found + found=$(find "$tmp" -name "*.so" -type f 2>/dev/null | head -1) + if [ -n "$found" ]; then + cp "$found" "$PLUGINS_DIR/$out_name" + patchelf --set-rpath '$ORIGIN:$ORIGIN/../../lib' "$PLUGINS_DIR/$out_name" 2>/dev/null || true + rm -rf "$tmp" + echo " Downloaded pre-built $label -> $out_name" + BUILT_PLUGINS+=("$label") + return 0 + fi + fi + rm -rf "$tmp" + echo " Warning: failed to fetch pre-built $label" + FAILED_PLUGINS+=("$label") + return 1 +} + +# Bwdif (core.bwdif.Bwdif) — BobWeaver deinterlacer, ported from FFmpeg's +# libavfilter. Taken from the PyPI wheel: upstream publishes no GitHub release +# assets, and a wheel is just a zip holding vapoursynth/plugins/bwdif.so. Only +# the *musllinux* wheels carry private libgcc/libstdc++ copies; the manylinux +# ones we take link nothing beyond the system C++ runtime, so unlike akarin +# there is nothing to stage into lib/ and nothing to relink. manylinux_2_28 is +# satisfied by our glibc 2.39 floor (ubuntu-24.04). +BWDIF_VERSION="5.1" +echo "" +echo "=== Downloading Bwdif $BWDIF_VERSION ===" +if [ "$FORCE" = true ] || [ ! -f "$PLUGINS_DIR/libbwdif.so" ]; then + BWDIF_URL=$("$PYTHON_BIN" - "$BWDIF_VERSION" "$ARCH" <<'PYEOF' +import json, sys, urllib.request +ver, arch = sys.argv[1], sys.argv[2] +d = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/vapoursynth-bwdif/{ver}/json")) +print(next(f["url"] for f in d["urls"] + if f["filename"].endswith(f"manylinux_2_28_{arch}.whl"))) +PYEOF +) + rm -rf "$BUILD_DIR/bwdif" "$BUILD_DIR/bwdif.whl" + mkdir -p "$BUILD_DIR/bwdif" + if curl -fL -o "$BUILD_DIR/bwdif.whl" "$BWDIF_URL" \ + && unzip -q "$BUILD_DIR/bwdif.whl" -d "$BUILD_DIR/bwdif" \ + && [ -f "$BUILD_DIR/bwdif/vapoursynth/plugins/bwdif.so" ]; then + cp "$BUILD_DIR/bwdif/vapoursynth/plugins/bwdif.so" "$PLUGINS_DIR/libbwdif.so" + chmod u+w "$PLUGINS_DIR/libbwdif.so" + patchelf --set-rpath '$ORIGIN:$ORIGIN/../../lib' "$PLUGINS_DIR/libbwdif.so" 2>/dev/null || true + echo " Installed Bwdif -> libbwdif.so" + BUILT_PLUGINS+=("bwdif") + else + echo " Failed to install Bwdif" + FAILED_PLUGINS+=("bwdif") + fi + rm -rf "$BUILD_DIR/bwdif" "$BUILD_DIR/bwdif.whl" +else + echo " Bwdif already exists, skipping" +fi + +# FillBorders (core.fb.FillBorders) — fills dead edges left by a capture. +# +# Pinned to v2: v3 and v4 exist as tags but publish NO release assets, and +# download-deps-windows.ps1 has no from-source path, so every platform tracks +# the newest version Windows can get (the same rule fluxsmooth and bifrost +# follow). One C++ file including / (API3), so it +# is compiled directly rather than through its autotools or meson build. +FILLBORDERS_TAG="v2" +if [ "$FORCE" = true ] || [ ! -f "$PLUGINS_DIR/libfillborders.so" ]; then + echo "" + echo "=== Building FillBorders ($FILLBORDERS_TAG) ===" + rm -rf fillborders + # Fail loudly if the include variable is ever renamed: an empty one reaches + # the compiler as a bare `-I` ("missing path after '-I'"), an error that + # names neither the plugin nor the variable. + : "${VS_INCLUDE_DIR:?VS_INCLUDE_DIR is unset — FillBorders cannot find the VapourSynth headers}" + if git clone --depth 1 --branch "$FILLBORDERS_TAG" -q \ + https://github.com/dubhater/vapoursynth-fillborders.git fillborders 2>/dev/null \ + && c++ -std=c++11 -O2 -fPIC -shared \ + -o "$PLUGINS_DIR/libfillborders.so" \ + fillborders/src/fillborders.cpp -I"$VS_INCLUDE_DIR"; then + patchelf --set-rpath '$ORIGIN:$ORIGIN/../../lib' "$PLUGINS_DIR/libfillborders.so" 2>/dev/null || true + echo " Built FillBorders -> libfillborders.so" + BUILT_PLUGINS+=("fillborders") + else + echo " Failed to build FillBorders" + FAILED_PLUGINS+=("fillborders") + fi +else + echo " FillBorders already exists, skipping" +fi + +# RemoveDirt (core.removedirt.RestoreMotionBlocks / SCSelect) — dirt and spot +# removal for film scans. Pinned to v1.1. +# +# x86_64 takes the pre-built Stefan-Olt binary (max symbol requirement +# GLIBC_2.14, so our 2.39 floor covers it); aarch64 has no such build and +# compiles from source. Its CMake detects the target processor and turns the +# Intel SIMD translation units off on anything non-x86 by itself, so no flag is +# needed. The repo vendors its own VapourSynth4.h/VSHelper4.h, so unlike every +# other from-source plugin here it needs no include or pkg-config wiring. +REMOVEDIRT_TAG="v1.1" +echo "" +if [ "$ARCH" = "x86_64" ]; then + echo "=== Downloading RemoveDirt $REMOVEDIRT_TAG ===" + download_prebuilt_plugin_linux "RemoveDirt" "libremovedirt.so" \ + "https://github.com/Stefan-Olt/vs-plugin-build/releases/download/vsplugin/com.vapoursynth.removedirt/v1.1/linux-glibc-x86_64/2026-01-07T00.36.36%2B00.00Z/RemoveDirt-v1.1-linux-glibc-x86_64.zip" +elif [ "$FORCE" = true ] || [ ! -f "$PLUGINS_DIR/libremovedirt.so" ]; then + echo "=== Building RemoveDirt $REMOVEDIRT_TAG ===" + rm -rf removedirt + if git clone --depth 1 --branch "$REMOVEDIRT_TAG" -q \ + https://github.com/pinterf/RemoveDirt.git removedirt 2>/dev/null; then + cd removedirt + if cmake -B build -S . -DCMAKE_BUILD_TYPE=Release \ + && cmake --build build --config Release -j"$NPROC"; then + so_path=$(find build -name "libremovedirt.so" -type f 2>/dev/null | head -1) + if [ -n "$so_path" ]; then + cp "$so_path" "$PLUGINS_DIR/libremovedirt.so" + patchelf --set-rpath '$ORIGIN:$ORIGIN/../../lib' "$PLUGINS_DIR/libremovedirt.so" 2>/dev/null || true + echo " Built RemoveDirt -> libremovedirt.so" + BUILT_PLUGINS+=("removedirt") + else + echo " Warning: no libremovedirt.so found after build" + FAILED_PLUGINS+=("removedirt") + fi + else + echo " Failed to build RemoveDirt" + FAILED_PLUGINS+=("removedirt") + fi + cd "$BUILD_DIR" + else + echo " Failed to clone RemoveDirt" + FAILED_PLUGINS+=("removedirt") + fi +else + echo " RemoveDirt already exists, skipping" +fi + +# DeDot (core.dedot.Dedot) — temporal cross-colour (rainbow) and cross-luma +# (dotcrawl) reduction for composite captures. From the PyPI wheel, like Bwdif; +# wheel 3.0 and git tag v3 are the same release. macOS x64 has to build this +# from source because its wheel is minos 15.0, but the manylinux wheels carry +# no such floor, so both Linux arches take the wheel. +DEDOT_VERSION="3.0" +echo "" +echo "=== Downloading DeDot $DEDOT_VERSION ===" +if [ "$FORCE" = true ] || [ ! -f "$PLUGINS_DIR/libdedot.so" ]; then + DEDOT_URL=$("$PYTHON_BIN" - "$DEDOT_VERSION" "$ARCH" <<'PYEOF' +import json, sys, urllib.request +ver, arch = sys.argv[1], sys.argv[2] +d = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/vapoursynth-dedot/{ver}/json")) +print(next(f["url"] for f in d["urls"] + if f["filename"].endswith(f"manylinux_2_28_{arch}.whl"))) +PYEOF +) + rm -rf "$BUILD_DIR/dedot" "$BUILD_DIR/dedot.whl" + mkdir -p "$BUILD_DIR/dedot" + if curl -fL -o "$BUILD_DIR/dedot.whl" "$DEDOT_URL" \ + && unzip -q "$BUILD_DIR/dedot.whl" -d "$BUILD_DIR/dedot" \ + && [ -f "$BUILD_DIR/dedot/vapoursynth/plugins/dedot.so" ]; then + cp "$BUILD_DIR/dedot/vapoursynth/plugins/dedot.so" "$PLUGINS_DIR/libdedot.so" + chmod u+w "$PLUGINS_DIR/libdedot.so" + patchelf --set-rpath '$ORIGIN:$ORIGIN/../../lib' "$PLUGINS_DIR/libdedot.so" 2>/dev/null || true + echo " Installed DeDot -> libdedot.so" + BUILT_PLUGINS+=("dedot") + else + echo " Failed to install DeDot" + FAILED_PLUGINS+=("dedot") + fi + rm -rf "$BUILD_DIR/dedot" "$BUILD_DIR/dedot.whl" +else + echo " DeDot already exists, skipping" +fi + +# LGhost (core.lghost.LGhost) — luminance-ghost / edge-ghost (ringing) +# reduction, the classic fix for RF and long-cable analogue captures. Pinned to +# r1, the only tag upstream has published. +# +# x86_64 takes the pre-built Stefan-Olt binary; aarch64 builds from source, +# where its meson skips the whole VCL2 x86 SIMD stack (`host_machine +# .cpu_family().startswith('x86')`) and compiles the scalar path only. It is +# cloned by tag rather than through build_plugin, which can only clone a default +# branch — pinning matters because Windows can only get r1. It resolves +# VapourSynth through pkg-config and reads `libdir` out of the .pc file, so the +# $PLUGIN_BUILD_ENV prefix is required (dropping it is how retinex once failed +# with 'Dependency "vapoursynth" not found'). +LGHOST_TAG="r1" +echo "" +if [ "$ARCH" = "x86_64" ]; then + echo "=== Downloading LGhost $LGHOST_TAG ===" + download_prebuilt_plugin_linux "LGhost" "liblghost.so" \ + "https://github.com/Stefan-Olt/vs-plugin-build/releases/download/vsplugin/com.holywu.lghost/r1/linux-glibc-x86_64/2024-09-30T20.53.59%2B00.00Z/LGhost-r1-linux-glibc-x86_64.zip" +elif [ "$FORCE" = true ] || [ ! -f "$PLUGINS_DIR/liblghost.so" ]; then + echo "=== Building LGhost $LGHOST_TAG ===" + rm -rf lghost + if git clone --depth 1 --branch "$LGHOST_TAG" -q \ + https://github.com/HomeOfVapourSynthEvolution/VapourSynth-LGhost.git lghost 2>/dev/null; then + cd lghost + if env $PLUGIN_BUILD_ENV meson setup build --buildtype=release \ + && ninja -C build; then + so_path=$(find build -name "*.so" -type f 2>/dev/null | head -1) + if [ -n "$so_path" ]; then + cp "$so_path" "$PLUGINS_DIR/liblghost.so" + patchelf --set-rpath '$ORIGIN:$ORIGIN/../../lib' "$PLUGINS_DIR/liblghost.so" 2>/dev/null || true + echo " Built LGhost -> liblghost.so" + BUILT_PLUGINS+=("lghost") + else + echo " Warning: no .so found after building LGhost" + FAILED_PLUGINS+=("lghost") + fi + else + echo " Failed to build LGhost" + FAILED_PLUGINS+=("lghost") + fi + cd "$BUILD_DIR" + else + echo " Failed to clone LGhost" + FAILED_PLUGINS+=("lghost") + fi +else + echo " LGhost already exists, skipping" +fi + # ============================================================================ # akarin — LLVM JIT for std.Expr # ============================================================================ diff --git a/Scripts/download-deps-macos.sh b/Scripts/download-deps-macos.sh index 48f6e0e9..2a096939 100755 --- a/Scripts/download-deps-macos.sh +++ b/Scripts/download-deps-macos.sh @@ -843,6 +843,25 @@ if [ -n "$VS_INC_DIR" ] && [ -d "$VS_BUILD_DIR/include" ]; then done fi +# Some plugins spell their includes rather than +# — retinex (API3) and bifrost (API4) both do — so they need an +# include root whose CHILD is a directory called vapoursynth. Mirror the +# permanent symlink farm download-deps-linux.sh keeps, so that on both platforms +# a single -I"$VS_INC_DIR" satisfies either include style and no plugin has to +# stage a private include tree. +# +# retinex resolves its headers through pkg-config, which yields this same +# directory, so it needs no build-command change once the subdirectory exists. +if [ -n "$VS_INC_DIR" ]; then + mkdir -p "$VS_INC_DIR/vapoursynth" + for hdr in "$VS_INC_DIR"/*.h; do + [ -f "$hdr" ] || continue + ln -sf "../$(basename "$hdr")" "$VS_INC_DIR/vapoursynth/$(basename "$hdr")" + done + [ -f "$VS_INC_DIR/vapoursynth/VapourSynth.h" ] || \ + echo " WARNING: no API3 header to link into $VS_INC_DIR/vapoursynth (retinex will fail)" +fi + if [ "$ARCH" = "x86_64" ]; then # ======================================================================== # x86_64 plugins: download pre-built darwin-x86_64 binaries from @@ -1170,6 +1189,7 @@ build_plugin "removegrain" \ "libremovegrain.dylib" \ "meson setup build --buildtype=release && ninja -C build" + # AddGrain build_plugin "addgrain" \ "https://github.com/HomeOfVapourSynthEvolution/VapourSynth-AddGrain.git" \ @@ -1338,6 +1358,87 @@ download_prebuilt_plugin "TemporalMedian" "libtmedian.dylib" "$TMEDIAN_URL" fi # end plugin arch split (x86_64 pre-built / arm64 from-source) +# The three plugins below are built from source on BOTH arches, so they sit +# outside the arch split above. They started inside its arm64 branch, where +# x64 never reached them and the packaging guard failed on all three missing +# dylibs. x64 takes pre-built binaries for most plugins, but Stefan-Olt ships +# none of these, and building them is cheap: two single C files and one small +# meson project. MACOSX_DEPLOYMENT_TARGET is exported near the top, so the x64 +# builds inherit the 12.0 floor and pass the minos guard (issue #39). + +# FluxSmooth (core.flux.SmoothT / SmoothST). Also what havsfunc's STPresso calls +# internally — without this plugin STPresso raises "No attribute with the name +# flux exists", which is why it is not offered without it. +# +# Pinned to v2, the newest tag with a published Windows binary. Windows has no +# from-source build path here (download-deps-windows.ps1 only fetches release +# archives), so every platform tracks the version Windows can get; a version +# skew would make the same job denoise differently per OS. +# +# Built by invoking the compiler directly rather than through its autotools +# build: it is one C file, and autoconf/automake/libtool are not otherwise +# required by any deps build. +FLUXSMOOTH_TAG="v2" +if [ "$FORCE" = true ] || [ ! -f "$PLUGINS_DIR/libfluxsmooth.dylib" ]; then + echo "" + echo "=== Building fluxsmooth ($FLUXSMOOTH_TAG) ===" + rm -rf fluxsmooth + if git clone --depth 1 --branch "$FLUXSMOOTH_TAG" -q \ + https://github.com/dubhater/vapoursynth-fluxsmooth.git fluxsmooth 2>/dev/null \ + && cc -std=c99 -O2 -fPIC -shared \ + -o "$PLUGINS_DIR/libfluxsmooth.dylib" \ + fluxsmooth/src/fluxsmooth.c -I"$VS_INC_DIR"; then + codesign -s - -f "$PLUGINS_DIR/libfluxsmooth.dylib" 2>/dev/null || true + echo " Built fluxsmooth -> libfluxsmooth.dylib" + BUILT_PLUGINS+=("fluxsmooth") + else + echo " Failed to build fluxsmooth" + FAILED_PLUGINS+=("fluxsmooth") + fi +else + echo " fluxsmooth already exists, skipping" +fi + +# Bifrost (core.bifrost.Bifrost) - temporal rainbow / dot-crawl removal for +# composite captures. Pinned to v3.0, the newest tag with a published Windows +# binary; see the fluxsmooth note above for why every platform tracks that. +# +# Its source is one C file, so it is compiled directly rather than through its +# autotools build — no autoconf/automake/libtool needed in CI. It includes +# , so the include path has to be the PARENT of a +# directory called vapoursynth — which is what the symlink farm created next to +# VS_INC_DIR above provides. +BIFROST_TAG="v3.0" +if [ "$FORCE" = true ] || [ ! -f "$PLUGINS_DIR/libbifrost.dylib" ]; then + echo "" + echo "=== Building bifrost ($BIFROST_TAG) ===" + rm -rf bifrost + if git clone --depth 1 --branch "$BIFROST_TAG" -q \ + https://github.com/dubhater/vapoursynth-bifrost.git bifrost 2>/dev/null \ + && cc -std=c99 -O2 -fPIC -shared \ + -o "$PLUGINS_DIR/libbifrost.dylib" \ + bifrost/src/bifrost.c -I"$VS_INC_DIR"; then + codesign -s - -f "$PLUGINS_DIR/libbifrost.dylib" 2>/dev/null || true + echo " Built bifrost -> libbifrost.dylib" + BUILT_PLUGINS+=("bifrost") + else + echo " Failed to build bifrost" + FAILED_PLUGINS+=("bifrost") + fi +else + echo " bifrost already exists, skipping" +fi + +# Retinex (core.retinex.MSRCP) - multi-scale retinex, used here to lift shadow +# detail out of underexposed footage. Pinned to r4, the newest tag with a +# published Windows binary. Ordinary meson build; it finds the VapourSynth +# headers through pkg-config, which build_plugin already points at our +# from-source install. +build_plugin "retinex" \ + "https://github.com/HomeOfVapourSynthEvolution/VapourSynth-Retinex.git" \ + "libretinex.dylib" \ + "meson setup build --buildtype=release && ninja -C build" + # zsmooth (core.zsmooth.CCD - chroma denoiser; also Cnr4 and a set of # RemoveGrain/TemporalMedian-family filters). # @@ -1409,6 +1510,180 @@ else fi fi +# ============================================================================ +# Bwdif / FillBorders / RemoveDirt / DeDot / LGhost +# ============================================================================ +# All five sit OUTSIDE the arch split above (see the fluxsmooth note): a block +# placed inside the arm64 branch never runs on x64, which is how three plugins +# once shipped missing from the Intel bundle. Each one below picks its own +# per-arch source where the arches differ, in the same shape as TemporalMedian +# and zsmooth. + +# Bwdif (core.bwdif.Bwdif) — BobWeaver deinterlacer, ported from FFmpeg's +# libavfilter. Taken from the PyPI wheel: upstream publishes no GitHub release +# assets, and the wheel is just a zip holding vapoursynth/plugins/bwdif.dylib. +# Unlike akarin's wheel it bundles no private dylibs — it links only +# /usr/lib/libc++ and libSystem — so nothing needs repointing or staging into +# lib/. Wheel tags differ per arch (macosx_10_15_x86_64 / macosx_11_0_arm64), +# both comfortably under this bundle's floor on either arch. +BWDIF_VERSION="5.1" +if [ "$ARCH" = "arm64" ]; then + BWDIF_WHEEL_TAG="macosx_11_0_arm64" +else + BWDIF_WHEEL_TAG="macosx_10_15_x86_64" +fi +echo "" +echo "=== Downloading Bwdif $BWDIF_VERSION ===" +if [ "$FORCE" = true ] || [ ! -f "$PLUGINS_DIR/libbwdif.dylib" ]; then + BWDIF_URL=$("$PYTHON_BIN" - "$BWDIF_VERSION" "$BWDIF_WHEEL_TAG" <<'PYEOF' +import json, sys, urllib.request +ver, tag = sys.argv[1], sys.argv[2] +d = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/vapoursynth-bwdif/{ver}/json")) +print(next(f["url"] for f in d["urls"] if f["filename"].endswith(tag + ".whl"))) +PYEOF +) + rm -rf "$BUILD_DIR/bwdif" "$BUILD_DIR/bwdif.whl" + mkdir -p "$BUILD_DIR/bwdif" + # A wheel is a zip; take the plugin binary only, never pip install it into + # the embedded interpreter. + if curl -fL -o "$BUILD_DIR/bwdif.whl" "$BWDIF_URL" \ + && unzip -q "$BUILD_DIR/bwdif.whl" -d "$BUILD_DIR/bwdif" \ + && [ -f "$BUILD_DIR/bwdif/vapoursynth/plugins/bwdif.dylib" ]; then + cp "$BUILD_DIR/bwdif/vapoursynth/plugins/bwdif.dylib" "$PLUGINS_DIR/libbwdif.dylib" + chmod u+w "$PLUGINS_DIR/libbwdif.dylib" + install_name_tool -id "@loader_path/libbwdif.dylib" "$PLUGINS_DIR/libbwdif.dylib" 2>/dev/null || true + codesign -s - -f "$PLUGINS_DIR/libbwdif.dylib" 2>/dev/null || true + echo " Installed Bwdif -> libbwdif.dylib" + BUILT_PLUGINS+=("bwdif") + else + echo " Failed to install Bwdif" + FAILED_PLUGINS+=("bwdif") + fi + rm -rf "$BUILD_DIR/bwdif" "$BUILD_DIR/bwdif.whl" +else + echo " Bwdif already exists, skipping" +fi + +# FillBorders (core.fb.FillBorders) — fills dead edges left by a capture. +# +# Pinned to v2: v3 and v4 exist as tags but publish NO release assets, and +# download-deps-windows.ps1 has no from-source path, so every platform tracks +# the newest version Windows can get (the same rule fluxsmooth and bifrost +# follow). One C++ file including / (API3), so it +# is compiled directly rather than through its autotools or meson build — no +# new toolchain in CI, and MACOSX_DEPLOYMENT_TARGET (exported near the top) +# gives the x64 build the 12.0 floor the minos guard demands. +FILLBORDERS_TAG="v2" +if [ "$FORCE" = true ] || [ ! -f "$PLUGINS_DIR/libfillborders.dylib" ]; then + echo "" + echo "=== Building FillBorders ($FILLBORDERS_TAG) ===" + rm -rf fillborders + # Fail loudly if the include variable is ever renamed: an empty one reaches + # the compiler as a bare `-I` and the error says nothing about which + # variable was wrong. Note this is VS_INC_DIR here and VS_INCLUDE_DIR in + # download-deps-linux.sh — the two scripts do not share a vocabulary. + : "${VS_INC_DIR:?VS_INC_DIR is unset — FillBorders cannot find the VapourSynth headers}" + if git clone --depth 1 --branch "$FILLBORDERS_TAG" -q \ + https://github.com/dubhater/vapoursynth-fillborders.git fillborders 2>/dev/null \ + && c++ -std=c++11 -O2 -fPIC -shared \ + -o "$PLUGINS_DIR/libfillborders.dylib" \ + fillborders/src/fillborders.cpp -I"$VS_INC_DIR"; then + install_name_tool -id "@loader_path/libfillborders.dylib" \ + "$PLUGINS_DIR/libfillborders.dylib" 2>/dev/null || true + codesign -s - -f "$PLUGINS_DIR/libfillborders.dylib" 2>/dev/null || true + echo " Built FillBorders -> libfillborders.dylib" + BUILT_PLUGINS+=("fillborders") + else + echo " Failed to build FillBorders" + FAILED_PLUGINS+=("fillborders") + fi +else + echo " FillBorders already exists, skipping" +fi + +# RemoveDirt (core.removedirt.RestoreMotionBlocks / SCSelect) — dirt and spot +# removal for film scans. Taken pre-built from Stefan-Olt/vs-plugin-build on +# both arches, the same source TemporalMedian uses. Both builds link only +# /usr/lib/libc++ and libSystem, and the x86_64 one is LC_VERSION_MIN_MACOSX +# 10.11, so it passes the STRICT_MIN_OS=1 guard at the end of this script. +if [ "$ARCH" = "arm64" ]; then + REMOVEDIRT_URL="$STEFANOLT/com.vapoursynth.removedirt/v1.1/darwin-aarch64/2026-01-07T00.39.06%2B00.00Z/RemoveDirt-v1.1-darwin-aarch64.zip" +else + REMOVEDIRT_URL="$STEFANOLT/com.vapoursynth.removedirt/v1.1/darwin-x86_64/2026-01-07T00.39.26%2B00.00Z/RemoveDirt-v1.1-darwin-x86_64.zip" +fi +echo "" +download_prebuilt_plugin "RemoveDirt" "libremovedirt.dylib" "$REMOVEDIRT_URL" + +# DeDot (core.dedot.Dedot) — temporal cross-colour (rainbow) and cross-luma +# (dotcrawl) reduction for composite captures. +# +# arm64 takes the PyPI wheel; x64 must NOT. Both dedot wheels are built minos +# 15.0, and this bundle's Intel floor is 12.0 with STRICT_MIN_OS=1 — the wheel +# would fail the guard and, shipped anyway, would refuse to load on Monterey +# (issue #39). It is one C++ file with no SIMD and no dependencies, so the x64 +# branch compiles it directly, exactly as zsmooth splits for the same reason. +# Wheel 3.0 and git tag v3 are the same release; keep the two in step. +DEDOT_VERSION="3.0" +DEDOT_TAG="v3" +echo "" +echo "=== Installing DeDot $DEDOT_VERSION ===" +if [ "$FORCE" = true ] || [ ! -f "$PLUGINS_DIR/libdedot.dylib" ]; then + if [ "$ARCH" = "arm64" ]; then + DEDOT_URL=$("$PYTHON_BIN" - "$DEDOT_VERSION" "macosx_15_0_arm64" <<'PYEOF' +import json, sys, urllib.request +ver, tag = sys.argv[1], sys.argv[2] +d = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/vapoursynth-dedot/{ver}/json")) +print(next(f["url"] for f in d["urls"] if f["filename"].endswith(tag + ".whl"))) +PYEOF +) + rm -rf "$BUILD_DIR/dedot-whl" "$BUILD_DIR/dedot.whl" + mkdir -p "$BUILD_DIR/dedot-whl" + if curl -fL -o "$BUILD_DIR/dedot.whl" "$DEDOT_URL" \ + && unzip -q "$BUILD_DIR/dedot.whl" -d "$BUILD_DIR/dedot-whl" \ + && [ -f "$BUILD_DIR/dedot-whl/vapoursynth/plugins/dedot.dylib" ]; then + cp "$BUILD_DIR/dedot-whl/vapoursynth/plugins/dedot.dylib" "$PLUGINS_DIR/libdedot.dylib" + chmod u+w "$PLUGINS_DIR/libdedot.dylib" + echo " Installed DeDot (wheel) -> libdedot.dylib" + fi + rm -rf "$BUILD_DIR/dedot-whl" "$BUILD_DIR/dedot.whl" + else + : "${VS_INC_DIR:?VS_INC_DIR is unset — DeDot cannot find the VapourSynth headers}" + rm -rf dedot + if git clone --depth 1 --branch "$DEDOT_TAG" -q \ + https://github.com/dubhatervapoursynth/vapoursynth-dedot.git dedot 2>/dev/null \ + && c++ -std=c++17 -O2 -fPIC -shared \ + -o "$PLUGINS_DIR/libdedot.dylib" \ + dedot/src/dedot.cpp -I"$VS_INC_DIR"; then + echo " Built DeDot ($DEDOT_TAG) -> libdedot.dylib" + fi + fi + + if [ -f "$PLUGINS_DIR/libdedot.dylib" ]; then + install_name_tool -id "@loader_path/libdedot.dylib" "$PLUGINS_DIR/libdedot.dylib" 2>/dev/null || true + codesign -s - -f "$PLUGINS_DIR/libdedot.dylib" 2>/dev/null || true + BUILT_PLUGINS+=("dedot") + else + echo " Failed to install DeDot" + FAILED_PLUGINS+=("dedot") + fi +else + echo " DeDot already exists, skipping" +fi + +# LGhost (core.lghost.LGhost) — luminance-ghost / edge-ghost (ringing) +# reduction, the classic fix for RF and long-cable analogue captures. Pinned to +# r1, the only tag upstream has published. Taken pre-built from +# Stefan-Olt/vs-plugin-build on both arches: its meson build compiles a stack of +# x86 SIMD translation units through VCL2, which is dead weight here, and both +# published dylibs link nothing outside /usr/lib. +if [ "$ARCH" = "arm64" ]; then + LGHOST_URL="$STEFANOLT/com.holywu.lghost/r1/darwin-aarch64/2024-09-30T20.54.34%2B00.00Z/LGhost-r1-darwin-aarch64.zip" +else + LGHOST_URL="$STEFANOLT/com.holywu.lghost/r1/darwin-x86_64/2024-09-30T20.57.30%2B00.00Z/LGhost-r1-darwin-x86_64.zip" +fi +echo "" +download_prebuilt_plugin "LGhost" "liblghost.dylib" "$LGHOST_URL" + # ============================================================================ # akarin — LLVM JIT for std.Expr (arm64 only) # ============================================================================ diff --git a/Scripts/download-deps-windows.ps1 b/Scripts/download-deps-windows.ps1 index 2374dfd6..4ed0d493 100644 --- a/Scripts/download-deps-windows.ps1 +++ b/Scripts/download-deps-windows.ps1 @@ -247,6 +247,30 @@ $Plugins7z = @( Url = "https://github.com/dubhater/vapoursynth-awarpsharp2/releases/download/v4/vapoursynth-awarpsharp2-v4-win64.7z" Check = "libawarpsharp2.dll" }, + @{ + # Bifrost (core.bifrost.Bifrost) - temporal rainbow / dot-crawl removal. + # The archive ships x86\ and x64\ folders; the loop below prefers x64. + Name = "bifrost" + Url = "https://github.com/dubhater/vapoursynth-bifrost/releases/download/v3.0/Bifrost-3.0.7z" + Check = "bifrost.dll" + }, + @{ + # Retinex (core.retinex.MSRCP) - lifts shadow detail out of + # underexposed footage. Ships Win32\ and x64\ folders. + Name = "retinex" + Url = "https://github.com/HomeOfVapourSynthEvolution/VapourSynth-Retinex/releases/download/r4/Retinex-r4.7z" + Check = "Retinex.dll" + }, + @{ + # FluxSmooth (core.flux.SmoothT / SmoothST), and what havsfunc's STPresso + # calls internally. Pinned to v2 on every platform: this is the newest + # tag with a published Windows binary, and Windows has no from-source + # build path here, so macOS/Linux track the version Windows can get + # rather than letting the same job denoise differently per OS. + Name = "fluxsmooth" + Url = "https://github.com/dubhater/vapoursynth-fluxsmooth/releases/download/v2/vapoursynth-fluxsmooth-v2-win64.7z" + Check = "libfluxsmooth.dll" + }, @{ Name = "removegrain" Url = "https://github.com/vapoursynth/vs-removegrain/releases/download/R1/removegrain-r1.7z" @@ -289,6 +313,37 @@ $Plugins7z = @( Name = "temporalmedian" Url = "https://github.com/dubhater/vapoursynth-temporalmedian/releases/download/v1/vapoursynth-temporalmedian-v1-win64.7z" Check = "libtemporalmedian.dll" + }, + @{ + # core.fb.FillBorders - fills dead edges left by a capture. + # Pinned to v2: tags v3 and v4 exist but publish NO release assets, and + # this script has no from-source path, so macOS/Linux track v2 too + # rather than letting the same job fill borders differently per OS. + Name = "fillborders" + Url = "https://github.com/dubhater/vapoursynth-fillborders/releases/download/v2/vapoursynth-fillborders-v2-win64.7z" + Check = "libfillborders.dll" + }, + @{ + # core.removedirt.RestoreMotionBlocks / SCSelect - dirt and spot removal. + # The archive ships FOUR builds of the same DLL name: x64\, x64_Clang\, + # x86\ and x86_Clang\. The generic $Win64 filter matches both x64 dirs, + # so without Prefer the winner is decided by copy order and could change + # between runs. Upstream states no preference between the MSVC and Clang + # builds; pinning one is about determinism, not about which is better. + Name = "removedirt" + Url = "https://github.com/pinterf/RemoveDirt/releases/download/v1.1/RemoveDirt-1.1.7z" + Check = "RemoveDirt.dll" + Prefer = '(?i)x64_Clang' + }, + @{ + # core.lghost.LGhost - luminance/edge ghost (ringing) reduction for RF + # and long-cable analogue captures. r1 is upstream's only tag. Its DLL + # sits under plugins64\, which the generic $Win64 filter does NOT match + # (no 'x64'/'win64' in the name) - harmless here because the archive + # ships nothing else, and the PE-arch verifier would catch it if it did. + Name = "lghost" + Url = "https://github.com/HomeOfVapourSynthEvolution/VapourSynth-LGhost/releases/download/r1/LGhost-r1.7z" + Check = "LGhost.dll" } ) @@ -355,6 +410,13 @@ foreach ($Plugin in $Plugins7z) { $Dlls = Get-ChildItem -Path $ExtractDir -Recurse -Filter "*.dll" $Win64 = $Dlls | Where-Object { $_.DirectoryName -match '(?i)win64|x64|amd64|x86_64' } if ($Win64) { $Dlls = $Win64 } + # An archive can ship several x64 builds of the same DLL (RemoveDirt + # has x64\ and x64_Clang\), in which case the filter above leaves the + # choice to copy order. Prefer pins one. + if ($Plugin.Prefer) { + $Preferred = $Dlls | Where-Object { $_.FullName -match $Plugin.Prefer } + if ($Preferred) { $Dlls = $Preferred } + } $Dlls | ForEach-Object { Copy-Item $_.FullName $PluginsDir -Force Write-Host " Copied: $($_.Name)" -ForegroundColor Gray @@ -391,6 +453,10 @@ foreach ($Plugin in $PluginsZip) { $Dlls = Get-ChildItem -Path $ExtractDir -Recurse -Filter "*.dll" $Win64 = $Dlls | Where-Object { $_.DirectoryName -match '(?i)win64|x64|amd64|x86_64' } if ($Win64) { $Dlls = $Win64 } + if ($Plugin.Prefer) { + $Preferred = $Dlls | Where-Object { $_.FullName -match $Plugin.Prefer } + if ($Preferred) { $Dlls = $Preferred } + } $Dlls | ForEach-Object { Copy-Item $_.FullName $PluginsDir -Force Write-Host " Copied: $($_.Name)" -ForegroundColor Gray @@ -561,6 +627,62 @@ if (-not (Test-Path "$PluginsDir\libakarin.dll")) { Write-Host " akarin already installed" -ForegroundColor Gray } +# ============================================================================= +# Bwdif + DeDot - PyPI wheels +# ============================================================================= +# Neither upstream publishes a Windows release asset, so both come from their +# PyPI wheel, resolved through the JSON API the same way akarin is (never +# hardcode the hashed file URL, and never pip install into the embedded +# interpreter - a wheel is just a zip). +# +# Unlike akarin's wheel, neither of these bundles a private DLL: each holds +# exactly vapoursynth\plugins\.dll, linking only the system runtime, so +# there is nothing to place beside the plugin. +# +# Keep these versions in step with download-deps-{macos,linux}.sh. macOS x64 +# builds DeDot from source instead of taking its wheel - that wheel is minos +# 15.0 and the Intel bundle floor is 12.0 (issue #39) - but it is the same +# upstream release (wheel 3.0 == git tag v3), so the versions still match. +$WheelPlugins = @( + @{ Name = "bwdif"; Package = "vapoursynth-bwdif"; Version = "5.1"; Dll = "bwdif.dll" }, + @{ Name = "dedot"; Package = "vapoursynth-dedot"; Version = "3.0"; Dll = "dedot.dll" } +) + +foreach ($W in $WheelPlugins) { + Write-Host "" + Write-Host "Downloading $($W.Name) $($W.Version) (PyPI wheel)..." -ForegroundColor Yellow + if (-not (Test-Path "$PluginsDir\$($W.Dll)")) { + try { + $Meta = Invoke-RestMethod -Uri "https://pypi.org/pypi/$($W.Package)/$($W.Version)/json" + $Url = ($Meta.urls | Where-Object { $_.filename -like "*win_amd64.whl" } | + Select-Object -First 1).url + if (-not $Url) { throw "no win_amd64 wheel for $($W.Package) $($W.Version)" } + + $Whl = Join-Path $TempDir "$($W.Name).whl" + $Zip = Join-Path $TempDir "$($W.Name)-wheel.zip" + $Out = Join-Path $TempDir "$($W.Name)-extract" + Invoke-WebRequest -Uri $Url -OutFile $Whl -UseBasicParsing + # Expand-Archive validates the *extension* and refuses .whl outright. + Copy-Item $Whl $Zip -Force + Remove-Item $Out -Recurse -Force -ErrorAction SilentlyContinue + Expand-Archive -Path $Zip -DestinationPath $Out -Force + + $Src = Join-Path $Out "vapoursynth\plugins\$($W.Dll)" + if (-not (Test-Path $Src)) { throw "$($W.Dll) missing from the $($W.Package) wheel" } + Copy-Item $Src (Join-Path $PluginsDir $W.Dll) -Force + Write-Host " Copied: $($W.Dll)" -ForegroundColor Gray + + Remove-Item $Whl, $Zip -Force -ErrorAction SilentlyContinue + Remove-Item $Out -Recurse -Force -ErrorAction SilentlyContinue + Write-Host " $($W.Name) installed" -ForegroundColor Green + } catch { + Write-Host " Failed: $_" -ForegroundColor Red + } + } else { + Write-Host " $($W.Name) already installed" -ForegroundColor Gray + } +} + # ============================================================================= # 5. Python Packages (havsfunc, mvsfunc, adjust) # ============================================================================= diff --git a/Scripts/package-linux.sh b/Scripts/package-linux.sh index 284e19ea..4ff7fb0c 100755 --- a/Scripts/package-linux.sh +++ b/Scripts/package-linux.sh @@ -107,7 +107,8 @@ chmod +x "$PACKAGE_DIR/vapourbox-worker" # Copy VapourSynth templates mkdir -p "$PACKAGE_DIR/templates" cp "$PROJECT_ROOT/worker/templates/"*.vpy "$PACKAGE_DIR/templates/" -cp "$PROJECT_ROOT/worker/templates/pipe_source.py" "$PACKAGE_DIR/templates/" +# Glob, not a list of names — see the note in package-macos.sh. +cp "$PROJECT_ROOT/worker/templates/"*.py "$PACKAGE_DIR/templates/" cp "$PROJECT_ROOT/worker/templates/spotless.py" "$PACKAGE_DIR/templates/" # Copy licenses diff --git a/Scripts/package-macos.sh b/Scripts/package-macos.sh index 0eb1cb27..40868f87 100755 --- a/Scripts/package-macos.sh +++ b/Scripts/package-macos.sh @@ -237,8 +237,12 @@ chmod +x "$CONTENTS/MacOS/vapourbox-worker" # everything in MacOS/ to be signed Mach-O binaries) cp "$PROJECT_ROOT/worker/templates/pipeline_template.vpy" "$CONTENTS/Resources/templates/" cp "$PROJECT_ROOT/worker/templates/preview_template.vpy" "$CONTENTS/Resources/templates/" -cp "$PROJECT_ROOT/worker/templates/pipe_source.py" "$CONTENTS/Resources/templates/" -cp "$PROJECT_ROOT/worker/templates/spotless.py" "$CONTENTS/Resources/templates/" +# Glob, not a list of names. Naming each file individually meant every new +# vendored module was silently left out of the package — the filter works in +# development (where the worker finds worker/templates/ by searching upward) +# and dies with ModuleNotFoundError in a release build. packaging_test.dart +# guards this now. +cp "$PROJECT_ROOT/worker/templates/"*.py "$CONTENTS/Resources/templates/" # Copy licenses cp -r "$PROJECT_ROOT/licenses/"* "$CONTENTS/Resources/licenses/" diff --git a/Scripts/package-windows.ps1 b/Scripts/package-windows.ps1 index 0711a5cf..f1b53519 100644 --- a/Scripts/package-windows.ps1 +++ b/Scripts/package-windows.ps1 @@ -100,8 +100,8 @@ Copy-Item $WorkerExe "$PackageDir\" # Copy VapourSynth script templates Copy-Item (Join-Path $ProjectRoot "worker\templates\pipeline_template.vpy") "$PackageDir\templates\" Copy-Item (Join-Path $ProjectRoot "worker\templates\preview_template.vpy") "$PackageDir\templates\" -Copy-Item (Join-Path $ProjectRoot "worker\templates\pipe_source.py") "$PackageDir\templates\" -Copy-Item (Join-Path $ProjectRoot "worker\templates\spotless.py") "$PackageDir\templates\" +# Glob, not a list of names — see the note in package-macos.sh. +Copy-Item (Join-Path $ProjectRoot "worker\templates\*.py") "$PackageDir\templates\" # Copy licenses Write-Host " Copying licenses..." diff --git a/app/assets/deps-version.json b/app/assets/deps-version.json index 91d87a95..61668132 100644 --- a/app/assets/deps-version.json +++ b/app/assets/deps-version.json @@ -1,6 +1,6 @@ { - "version": "1.8.0", - "releaseTag": "deps-v1.8.0", - "releaseDate": "2026-08-07", + "version": "1.9.0", + "releaseTag": "deps-v1.9.0", + "releaseDate": "2026-08-15", "githubRepo": "StuartCameronCode/VapourBox" } diff --git a/app/assets/filters/core/anti_alias.json b/app/assets/filters/core/anti_alias.json new file mode 100644 index 00000000..6ecd2118 --- /dev/null +++ b/app/assets/filters/core/anti_alias.json @@ -0,0 +1,110 @@ +{ + "$schema": "https://vapourbox.app/schemas/filter-v1.json", + "id": "anti_alias", + "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.", + "category": "enhancement", + "icon": "gesture", + "order": 7, + "dependencies": { + "plugins": [ + "havsfunc" + ], + "vs_plugins": [ + "vsznedi3.dll" + ] + }, + "methods": [ + { + "id": "daa", + "name": "daa", + "description": "Best first choice. Interpolates both fields and averages them — gentle, and rarely damages anything", + "function": "haf.daa", + "parameters": [] + }, + { + "id": "santiag", + "name": "santiag", + "description": "Stronger, with separate horizontal and vertical strength so it can be aimed at one axis. Slower", + "function": "haf.santiag", + "parameters": [ + "santiagStrh", + "santiagStrv" + ] + } + ], + "parameters": { + "enabled": { + "type": "boolean", + "default": false, + "ui": { + "hidden": true + } + }, + "method": { + "type": "enum", + "default": "daa", + "options": [ + "daa", + "santiag" + ], + "ui": { + "hidden": true + } + }, + "santiagStrh": { + "type": "integer", + "default": 1, + "min": 0, + "max": 3, + "step": 1, + "ui": { + "label": "Horizontal Strength", + "description": "0 skips the horizontal pass entirely", + "widget": "slider", + "visibleWhen": { + "method": [ + "santiag" + ] + } + } + }, + "santiagStrv": { + "type": "integer", + "default": 1, + "min": 0, + "max": 3, + "step": 1, + "ui": { + "label": "Vertical Strength", + "description": "0 skips the vertical pass entirely", + "widget": "slider", + "visibleWhen": { + "method": [ + "santiag" + ] + } + } + } + }, + "ui": { + "sections": [ + { + "title": "Settings", + "parameters": [ + "santiagStrh", + "santiagStrv" + ], + "expanded": true + } + ] + }, + "codeTemplate": { + "imports": [ + "import havsfunc as haf" + ], + "generate": "method" + } +} diff --git a/app/assets/filters/core/chroma_denoise.json b/app/assets/filters/core/chroma_denoise.json index deaec9ed..15b3cb11 100644 --- a/app/assets/filters/core/chroma_denoise.json +++ b/app/assets/filters/core/chroma_denoise.json @@ -3,26 +3,45 @@ "id": "chroma_denoise", "version": "1.0.0", "name": "Chroma Denoise", - "description": "Remove blotchy colour noise (CCD)", - "longDescription": "CCD — Camcorder Colour Denoise — cleans up noisy colour while leaving detail in the picture alone. It averages each pixel's colour with nearby pixels whose colour is close enough to be the same thing, so flat areas smooth out and edges stay put.\n\nUse it when the picture is watchable but the colour is a mess: the crawling red and blue blotches on VHS captures, and the chroma noise old camcorders produce in low light. Because it never touches luma, it can be pushed much harder than a general denoiser before the picture looks soft.\n\nRun it after deinterlacing. Raising Temporal Radius above 0 pulls in neighbouring frames, which cleans up more but can smear fast colour movement.", + "description": "Remove blotchy or swimming colour noise", + "longDescription": "CCD \u2014 Camcorder Colour Denoise \u2014 cleans up noisy colour while leaving detail in the picture alone. It averages each pixel's colour with nearby pixels whose colour is close enough to be the same thing, so flat areas smooth out and edges stay put.\n\nUse it when the picture is watchable but the colour is a mess: the crawling red and blue blotches on VHS captures, and the chroma noise old camcorders produce in low light. Because it never touches luma, it can be pushed much harder than a general denoiser before the picture looks soft.\n\nRun it after deinterlacing. Raising Temporal Radius above 0 pulls in neighbouring frames, which cleans up more but can smear fast colour movement.", "category": "cleanup", "icon": "palette", "order": 6, - "dependencies": { - "vs_plugins": ["zsmooth.dll"] + "vs_plugins": [ + "zsmooth.dll" + ] }, - "methods": [ { "id": "ccd", "name": "CCD", - "description": "Camcorder Colour Denoise — chroma-only spatial/temporal denoiser", + "description": "Camcorder Colour Denoise \u2014 spatial, for blotches that stay put", "function": "core.zsmooth.CCD", - "parameters": ["threshold", "temporalRadius", "pointsLow", "pointsMedium", "pointsHigh", "scale"] + "parameters": [ + "threshold", + "temporalRadius", + "pointsLow", + "pointsMedium", + "pointsHigh", + "scale" + ] + }, + { + "id": "cnr4", + "name": "Cnr4", + "description": "Temporal, gated on movement \u2014 for colour that swims or shimmers frame to frame rather than sitting still", + "function": "core.zsmooth.Cnr4", + "parameters": [ + "cnr4Sense", + "cnr4Strength", + "cnr4Radius", + "cnr4Tmode", + "cnr4Wmode" + ] } ], - "parameters": { "enabled": { "type": "boolean", @@ -34,8 +53,15 @@ "method": { "type": "enum", "default": "ccd", - "options": ["ccd"], - "ui": { "hidden": true } + "options": [ + "ccd", + "cnr4" + ], + "ui": { + "label": "Method", + "description": "CCD smooths blotchy colour in place. Cnr4 settles colour that shifts between frames.", + "widget": "dropdown" + } }, "threshold": { "type": "number", @@ -113,25 +139,161 @@ "widget": "slider", "precision": 1 } + }, + "cnr4Strength": { + "type": "integer", + "default": 192, + "min": 0, + "max": 255, + "step": 8, + "visibleWhen": { + "method": [ + "cnr4" + ] + }, + "ui": { + "label": "Strength", + "description": "How far colour is pulled toward the surrounding frames. The default already sits near the top of the range, so there is far more room downward than up.", + "widget": "slider" + } + }, + "cnr4Sense": { + "type": "integer", + "default": 35, + "min": 0, + "max": 255, + "step": 5, + "visibleWhen": { + "method": [ + "cnr4" + ] + }, + "ui": { + "label": "Motion sensitivity", + "description": "How much movement is tolerated before the filter stops correcting. Raise it if noise survives in moving areas; lower it if moving colour smears.", + "widget": "slider" + } + }, + "cnr4Radius": { + "type": "integer", + "default": 2, + "min": 1, + "max": 8, + "step": 1, + "visibleWhen": { + "method": [ + "cnr4" + ] + }, + "ui": { + "label": "Temporal radius", + "description": "How many frames either side are considered. Higher is stronger and slower.", + "widget": "slider" + } + }, + "cnr4Tmode": { + "type": "integer", + "default": 0, + "min": 0, + "max": 3, + "step": 1, + "visibleWhen": { + "method": [ + "cnr4" + ] + }, + "ui": { + "label": "Detail retention", + "description": "Higher settings protect fine chroma detail at the cost of some smoothing.", + "widget": "slider" + } + }, + "cnr4Wmode": { + "type": "integer", + "default": 0, + "min": 0, + "max": 2, + "step": 1, + "visibleWhen": { + "method": [ + "cnr4" + ] + }, + "ui": { + "label": "Weighting", + "description": "How neighbouring frames are weighted against the current one.", + "widget": "slider" + } } }, - "ui": { "sections": [ { - "title": "Settings", - "parameters": ["threshold", "temporalRadius"], + "title": "Method", + "parameters": [ + "method" + ], "expanded": true }, + { + "title": "Settings", + "parameters": [ + "threshold", + "temporalRadius" + ], + "expanded": true, + "visibleWhen": { + "method": [ + "ccd" + ] + } + }, { "title": "Sampling", - "parameters": ["pointsLow", "pointsMedium", "pointsHigh", "scale"], + "parameters": [ + "pointsLow", + "pointsMedium", + "pointsHigh", + "scale" + ], + "expanded": false, + "advancedOnly": true, + "visibleWhen": { + "method": [ + "ccd" + ] + } + }, + { + "title": "Cnr4 settings", + "parameters": [ + "cnr4Strength", + "cnr4Sense", + "cnr4Radius" + ], + "expanded": true, + "visibleWhen": { + "method": [ + "cnr4" + ] + } + }, + { + "title": "Cnr4 tuning", + "parameters": [ + "cnr4Tmode", + "cnr4Wmode" + ], "expanded": false, - "advancedOnly": true + "advancedOnly": true, + "visibleWhen": { + "method": [ + "cnr4" + ] + } } ] }, - "codeTemplate": { "imports": [], "generate": "method" diff --git a/app/assets/filters/core/chroma_fixes.json b/app/assets/filters/core/chroma_fixes.json index cda414aa..02c93880 100644 --- a/app/assets/filters/core/chroma_fixes.json +++ b/app/assets/filters/core/chroma_fixes.json @@ -4,30 +4,50 @@ "version": "1.0.0", "name": "Chroma Fixes", "description": "Fix chroma bleeding, rainbows, and dot crawl", - "longDescription": "Repairs colour-specific damage from analog and composite video: chroma shifted sideways from the luma it belongs to, colour bleeding past edges, rainbow shimmer over fine patterns, and dots crawling along edges.\n\nUse it for VHS, Video8 and other composite captures. Each fix is enabled separately, so turn on only the ones matching what you can actually see in the preview — every one of them costs some colour detail.", + "longDescription": "Repairs colour-specific damage from analog and composite video: chroma shifted sideways from the luma it belongs to, colour bleeding past edges, rainbow shimmer over fine patterns, and dots crawling along edges.\n\nUse it for VHS, Video8 and other composite captures. Each fix is enabled separately, so turn on only the ones matching what you can actually see in the preview \u2014 every one of them costs some colour detail.", "category": "cleanup", "icon": "palette", "order": 8, - "dependencies": { - "plugins": ["havsfunc"] + "plugins": [ + "havsfunc" + ], + "vs_plugins": [ + "bifrost.dll" + ] }, - "methods": [ { "id": "chroma_fixes", "name": "Chroma Fixes", "description": "Fix chroma bleeding, dot crawl, and combing artifacts (enable individually below)", "function": "custom", - "parameters": ["applyChromaShift", "chromaShiftH", "chromaShiftV", "applyChromaBleedingFix", "chromaBleedCx", "chromaBleedCy", "chromaBleedCBlur", "chromaBleedStrength", "applyDeCrawl", "deCrawlYThresh", "deCrawlCThresh", "deCrawlMaxDiff", "applyVinverse", "vinverseSstr", "vinverseAmnt"] + "parameters": [ + "applyChromaShift", + "chromaShiftH", + "chromaShiftV", + "applyChromaBleedingFix", + "chromaBleedCx", + "chromaBleedCy", + "chromaBleedCBlur", + "chromaBleedStrength", + "applyDeCrawl", + "deCrawlYThresh", + "deCrawlCThresh", + "deCrawlMaxDiff", + "applyVinverse", + "vinverseSstr", + "vinverseAmnt" + ] } ], - "parameters": { "enabled": { "type": "boolean", "default": false, - "ui": { "hidden": true } + "ui": { + "hidden": true + } }, "applyChromaShift": { "type": "boolean", @@ -43,13 +63,17 @@ "min": -8.0, "max": 8.0, "step": 0.25, - "vapoursynth": { "name": "src_left" }, + "vapoursynth": { + "name": "src_left" + }, "ui": { "label": "Horizontal Shift", "description": "Shift chroma left/right in pixels (negative = left, positive = right)", "widget": "slider", "precision": 2, - "visibleWhen": { "applyChromaShift": true } + "visibleWhen": { + "applyChromaShift": true + } } }, "chromaShiftV": { @@ -58,13 +82,17 @@ "min": -4.0, "max": 4.0, "step": 0.25, - "vapoursynth": { "name": "src_top" }, + "vapoursynth": { + "name": "src_top" + }, "ui": { "label": "Vertical Shift", "description": "Shift chroma up/down in pixels (negative = up, positive = down)", "widget": "slider", "precision": 2, - "visibleWhen": { "applyChromaShift": true } + "visibleWhen": { + "applyChromaShift": true + } } }, "applyChromaBleedingFix": { @@ -82,12 +110,16 @@ "max": 16, "step": 1, "optional": true, - "vapoursynth": { "name": "cx" }, + "vapoursynth": { + "name": "cx" + }, "ui": { "label": "Horizontal Offset", "description": "Chroma X offset correction", "widget": "slider", - "visibleWhen": { "applyChromaBleedingFix": true } + "visibleWhen": { + "applyChromaBleedingFix": true + } } }, "chromaBleedCy": { @@ -97,12 +129,16 @@ "max": 16, "step": 1, "optional": true, - "vapoursynth": { "name": "cy" }, + "vapoursynth": { + "name": "cy" + }, "ui": { "label": "Vertical Offset", "description": "Chroma Y offset correction", "widget": "slider", - "visibleWhen": { "applyChromaBleedingFix": true } + "visibleWhen": { + "applyChromaBleedingFix": true + } } }, "chromaBleedCBlur": { @@ -112,13 +148,17 @@ "max": 2.0, "step": 0.1, "optional": true, - "vapoursynth": { "name": "thr" }, + "vapoursynth": { + "name": "thr" + }, "ui": { "label": "Chroma Blur", "description": "Chroma blur strength", "widget": "slider", "precision": 1, - "visibleWhen": { "applyChromaBleedingFix": true } + "visibleWhen": { + "applyChromaBleedingFix": true + } } }, "chromaBleedStrength": { @@ -128,13 +168,17 @@ "max": 1.0, "step": 0.1, "optional": true, - "vapoursynth": { "name": "strength" }, + "vapoursynth": { + "name": "strength" + }, "ui": { "label": "Strength", "description": "Fix strength", "widget": "slider", "precision": 1, - "visibleWhen": { "applyChromaBleedingFix": true } + "visibleWhen": { + "applyChromaBleedingFix": true + } } }, "applyDeCrawl": { @@ -152,12 +196,16 @@ "max": 50, "step": 1, "optional": true, - "vapoursynth": { "name": "ythresh" }, + "vapoursynth": { + "name": "ythresh" + }, "ui": { "label": "Luma Threshold", "description": "Luma threshold for de-crawl", "widget": "slider", - "visibleWhen": { "applyDeCrawl": true } + "visibleWhen": { + "applyDeCrawl": true + } } }, "deCrawlCThresh": { @@ -167,12 +215,16 @@ "max": 50, "step": 1, "optional": true, - "vapoursynth": { "name": "cthresh" }, + "vapoursynth": { + "name": "cthresh" + }, "ui": { "label": "Chroma Threshold", "description": "Chroma threshold for de-crawl", "widget": "slider", - "visibleWhen": { "applyDeCrawl": true } + "visibleWhen": { + "applyDeCrawl": true + } } }, "deCrawlMaxDiff": { @@ -182,12 +234,16 @@ "max": 255, "step": 5, "optional": true, - "vapoursynth": { "name": "maxdiff" }, + "vapoursynth": { + "name": "maxdiff" + }, "ui": { "label": "Max Difference", "description": "Maximum difference allowed", "widget": "slider", - "visibleWhen": { "applyDeCrawl": true } + "visibleWhen": { + "applyDeCrawl": true + } } }, "applyVinverse": { @@ -205,13 +261,17 @@ "max": 10.0, "step": 0.1, "optional": true, - "vapoursynth": { "name": "sstr" }, + "vapoursynth": { + "name": "sstr" + }, "ui": { "label": "Strength", "description": "Spatial strength", "widget": "slider", "precision": 1, - "visibleWhen": { "applyVinverse": true } + "visibleWhen": { + "applyVinverse": true + } } }, "vinverseAmnt": { @@ -221,43 +281,332 @@ "max": 255, "step": 5, "optional": true, - "vapoursynth": { "name": "amnt" }, + "vapoursynth": { + "name": "amnt" + }, "ui": { "label": "Amount", "description": "Blend amount (0-255)", "widget": "slider", - "visibleWhen": { "applyVinverse": true } + "visibleWhen": { + "applyVinverse": true + } + } + }, + "applyDeRainbow": { + "type": "boolean", + "default": false, + "ui": { + "label": "Remove Rainbowing", + "description": "Shimmering colour bands over fine detail on composite captures. The companion to dot crawl removal, which targets the dots along colour edges" + } + }, + "deRainbowCThresh": { + "type": "integer", + "default": 10, + "min": 0, + "max": 255, + "step": 1, + "ui": { + "label": "Rainbow Chroma Threshold", + "description": "How different chroma must be before it is treated as rainbowing. Lower catches more", + "widget": "slider", + "visibleWhen": { + "applyDeRainbow": true + } + } + }, + "deRainbowYThresh": { + "type": "integer", + "default": 10, + "min": 0, + "max": 255, + "step": 1, + "ui": { + "label": "Rainbow Luma Threshold", + "description": "Areas moving more than this are left alone, which protects real motion", + "widget": "slider", + "visibleWhen": { + "applyDeRainbow": true + } + } + }, + "applyBifrost": { + "type": "boolean", + "default": false, + "ui": { + "label": "Remove Shimmering Rainbows", + "description": "Compares across frames rather than within one, so it catches rainbowing that shimmers as the picture moves. Works alongside the other rainbow removal, not instead of it" + } + }, + "bifrostLumaThresh": { + "type": "number", + "default": 10.0, + "min": 0.0, + "max": 64.0, + "step": 1.0, + "ui": { + "label": "Motion Threshold", + "description": "Areas changing more than this between frames are treated as movement and left alone", + "widget": "slider", + "precision": 1, + "visibleWhen": { + "applyBifrost": true + } + } + }, + "bifrostVariation": { + "type": "integer", + "default": 5, + "min": 0, + "max": 10, + "step": 1, + "ui": { + "label": "Caution", + "description": "How much agreement is needed before a pixel is changed. Higher is safer and removes less", + "widget": "slider", + "visibleWhen": { + "applyBifrost": true + } + } + }, + "applyDedot": { + "type": "boolean", + "default": false, + "ui": { + "label": "Remove dot crawl (DeDot)", + "description": "Removes the crawling dotted pattern along sharp colour edges on composite captures. Works on brightness and colour together, and skips anything that is moving.", + "widget": "checkbox" + } + }, + "dedotLuma2d": { + "type": "integer", + "default": 20, + "min": 0, + "max": 510, + "step": 1, + "visibleWhen": { + "applyDedot": [ + true + ] + }, + "ui": { + "label": "Brightness (within a frame)", + "description": "How different a pixel must be from its neighbours to count as dot crawl.", + "widget": "slider" + } + }, + "dedotLumaT": { + "type": "integer", + "default": 20, + "min": 0, + "max": 255, + "step": 1, + "visibleWhen": { + "applyDedot": [ + true + ] + }, + "ui": { + "label": "Brightness (between frames)", + "description": "How much a pixel may change between frames and still be treated as dot crawl rather than motion.", + "widget": "slider" + } + }, + "dedotChromaT1": { + "type": "integer", + "default": 15, + "min": 0, + "max": 255, + "step": 1, + "visibleWhen": { + "applyDedot": [ + true + ] + }, + "ui": { + "label": "Colour threshold", + "description": "How strongly colour is corrected.", + "widget": "slider" + } + }, + "dedotChromaT2": { + "type": "integer", + "default": 5, + "min": 0, + "max": 255, + "step": 1, + "visibleWhen": { + "applyDedot": [ + true + ] + }, + "ui": { + "label": "Colour motion limit", + "description": "Raise towards 255 to leave colour alone entirely.", + "widget": "slider" + } + }, + "applyAutoChroma": { + "type": "boolean", + "default": false, + "ui": { + "label": "Detect colour alignment automatically", + "description": "Measures how far the colour has slipped from the picture and corrects it, instead of you guessing on the sliders below. If it cannot measure the source reliably it leaves it alone rather than guessing.", + "widget": "checkbox" + } + }, + "autoChromaMaxShift": { + "type": "integer", + "default": 2, + "min": 1, + "max": 8, + "step": 1, + "visibleWhen": { + "applyAutoChroma": [ + true + ] + }, + "ui": { + "label": "Search range", + "description": "The largest misalignment to look for, in pixels.", + "widget": "slider" + } + }, + "autoChromaAccuracy": { + "type": "number", + "default": 0.25, + "min": 0.05, + "max": 1.0, + "step": 0.05, + "visibleWhen": { + "applyAutoChroma": [ + true + ] + }, + "ui": { + "label": "Precision", + "description": "Smaller finds sub-pixel shifts, at some cost.", + "widget": "slider", + "precision": 2 + } + }, + "autoChromaReferenceFrame": { + "type": "integer", + "default": 0, + "min": -1, + "max": 100000, + "step": 1, + "visibleWhen": { + "applyAutoChroma": [ + true + ] + }, + "ui": { + "label": "Reference frame", + "description": "Measure once on this frame. -1 measures every frame, which is about 23 times slower and rarely worth it.", + "widget": "number" } } }, - "ui": { "sections": [ + { + "title": "Automatic alignment", + "parameters": [ + "applyAutoChroma", + "autoChromaMaxShift" + ], + "expanded": true + }, { "title": "Y/C Delay (Chroma Shift)", - "parameters": ["applyChromaShift", "chromaShiftH", "chromaShiftV"], + "parameters": [ + "applyChromaShift", + "chromaShiftH", + "chromaShiftV" + ], "expanded": true }, { "title": "Chroma Bleeding Fix", - "parameters": ["applyChromaBleedingFix", "chromaBleedCx", "chromaBleedCy", "chromaBleedCBlur", "chromaBleedStrength"], + "parameters": [ + "applyChromaBleedingFix", + "chromaBleedCx", + "chromaBleedCy", + "chromaBleedCBlur", + "chromaBleedStrength" + ], "expanded": true }, { "title": "DeCrawl (Dot Crawl)", - "parameters": ["applyDeCrawl", "deCrawlYThresh", "deCrawlCThresh", "deCrawlMaxDiff"], + "parameters": [ + "applyDeCrawl", + "deCrawlYThresh", + "deCrawlCThresh", + "deCrawlMaxDiff", + "applyDeRainbow", + "deRainbowCThresh", + "deRainbowYThresh", + "applyBifrost", + "bifrostLumaThresh", + "bifrostVariation" + ], "expanded": true }, { "title": "Vinverse (Combing Fix)", - "parameters": ["applyVinverse", "vinverseSstr", "vinverseAmnt"], + "parameters": [ + "applyVinverse", + "vinverseSstr", + "vinverseAmnt" + ], "expanded": true + }, + { + "title": "Dot crawl (DeDot)", + "parameters": [ + "applyDedot", + "dedotLuma2d", + "dedotLumaT" + ], + "expanded": true + }, + { + "title": "Dot crawl tuning", + "parameters": [ + "dedotChromaT1", + "dedotChromaT2" + ], + "expanded": false, + "advancedOnly": true, + "visibleWhen": { + "applyDedot": [ + true + ] + } + }, + { + "title": "Automatic alignment tuning", + "parameters": [ + "autoChromaAccuracy", + "autoChromaReferenceFrame" + ], + "expanded": false, + "advancedOnly": true, + "visibleWhen": { + "applyAutoChroma": [ + true + ] + } } ] }, - "codeTemplate": { - "imports": ["import havsfunc as haf"], + "imports": [ + "import havsfunc as haf" + ], "generate": "custom" } } diff --git a/app/assets/filters/core/color_correction.json b/app/assets/filters/core/color_correction.json index ff170e4c..45614403 100644 --- a/app/assets/filters/core/color_correction.json +++ b/app/assets/filters/core/color_correction.json @@ -11,6 +11,9 @@ "dependencies": { "plugins": [ "havsfunc" + ], + "vs_plugins": [ + "Retinex.dll" ] }, "methods": [ @@ -262,10 +265,145 @@ "widget": "slider", "precision": 0 } + }, + "smoothLevels": { + "type": "boolean", + "default": false, + "ui": { + "label": "Smooth Levels", + "description": "Dithers and limits the adjustment as it goes, so stretching a narrow range does not leave visible steps in skies and fades. Slower, and it cannot lift the black point and change gamma at the same time", + "visibleWhen": { + "applyLevels": true + } + } + }, + "applyShadowDetail": { + "type": "boolean", + "default": false, + "ui": { + "label": "Lift Shadow Detail", + "description": "Opens up detail hidden in dark areas of underexposed footage, by comparing each part of the picture to its surroundings rather than raising the black level. Brightness only \u2014 colour is untouched" + } + }, + "shadowSigma": { + "type": "number", + "default": 100.0, + "min": 1.0, + "max": 500.0, + "step": 10.0, + "ui": { + "label": "Area Size", + "description": "How wide a neighbourhood each pixel is judged against. Larger opens up broad shadows; smaller brings out local texture", + "widget": "slider", + "precision": 0, + "visibleWhen": { + "applyShadowDetail": true + } + } + }, + "applyAutoLevels": { + "type": "boolean", + "default": false, + "ui": { + "label": "Auto levels", + "description": "Stretch the picture so its darkest and brightest parts reach the target black and white. The usual fix for a washed-out capture.", + "widget": "checkbox" + } + }, + "autoLevelsBlack": { + "type": "integer", + "default": 16, + "min": 0, + "max": 64, + "step": 1, + "visibleWhen": { + "applyAutoLevels": [ + true + ] + }, + "ui": { + "label": "Target black", + "description": "Where the darkest part of the picture should land. 16 is broadcast black.", + "widget": "slider" + } + }, + "autoLevelsWhite": { + "type": "integer", + "default": 235, + "min": 192, + "max": 255, + "step": 1, + "visibleWhen": { + "applyAutoLevels": [ + true + ] + }, + "ui": { + "label": "Target white", + "description": "Where the brightest part should land. 235 is broadcast white.", + "widget": "slider" + } + }, + "autoLevelsStrength": { + "type": "number", + "default": 1.0, + "min": 0.0, + "max": 1.0, + "step": 0.05, + "visibleWhen": { + "applyAutoLevels": [ + true + ] + }, + "ui": { + "label": "Strength", + "description": "Lower this if the correction moves too much between shots.", + "widget": "slider", + "precision": 2 + } + }, + "applyAutoWhiteBalance": { + "type": "boolean", + "default": false, + "ui": { + "label": "Auto white balance", + "description": "Remove an overall colour cast by assuming the scene should average out to neutral grey. The automatic counterpart to the temperature and tint sliders.", + "widget": "checkbox" + } + }, + "autoWhiteBalanceStrength": { + "type": "number", + "default": 1.0, + "min": 0.0, + "max": 1.0, + "step": 0.05, + "visibleWhen": { + "applyAutoWhiteBalance": [ + true + ] + }, + "ui": { + "label": "Strength", + "description": "Lower this to keep some of the original cast \u2014 useful when the cast is meant to be there, like firelight.", + "widget": "slider", + "precision": 2 + } } }, "ui": { "sections": [ + { + "title": "Automatic", + "parameters": [ + "applyAutoLevels", + "autoLevelsBlack", + "autoLevelsWhite", + "autoLevelsStrength", + "applyAutoWhiteBalance", + "autoWhiteBalanceStrength" + ], + "expanded": true + }, { "title": "Basic Adjustments", "parameters": [ @@ -273,7 +411,9 @@ "contrast", "saturation", "hue", - "coring" + "coring", + "applyShadowDetail", + "shadowSigma" ], "expanded": true }, @@ -285,7 +425,8 @@ "inputHigh", "outputLow", "outputHigh", - "gamma" + "gamma", + "smoothLevels" ], "expanded": false }, diff --git a/app/assets/filters/core/crop_resize.json b/app/assets/filters/core/crop_resize.json index 86b7cfb3..9f8202f6 100644 --- a/app/assets/filters/core/crop_resize.json +++ b/app/assets/filters/core/crop_resize.json @@ -33,7 +33,7 @@ { "id": "nnedi3_2x", "name": "NNEDI3 Upscale", - "description": "Edge-directed integer doubling", + "description": "Doubles size with far cleaner edges than a resampler. Best choice for upscaling. Slow", "function": "core.znedi3.nnedi3", "parameters": [ "upscaleNsize", @@ -46,7 +46,7 @@ { "id": "eedi3_2x", "name": "EEDI3 Upscale", - "description": "Edge-directed integer doubling, guided by NNEDI3", + "description": "Doubling guided by NNEDI3. Marginally better on hard diagonals, several times slower", "function": "core.eedi3m.EEDI3", "parameters": [ "upscaleAlpha", @@ -54,7 +54,8 @@ "upscaleGamma", "upscaleNrad", "upscaleMdis" - ] + ], + "advancedOnly": true } ], "parameters": { @@ -725,11 +726,18 @@ "expanded": true }, { - "title": "Upscale (NNEDI3 / EEDI3)", + "title": "Upscale", + "description": "Enlarge using an edge-directed interpolator rather than a plain resize. Doubles at a time, so pick the factor that reaches or exceeds your target and set a Resize below to land on it exactly.", "parameters": [ "useIntegerUpscale", "upscaleMethod", - "upscaleFactor", + "upscaleFactor" + ], + "expanded": false + }, + { + "title": "Upscale tuning", + "parameters": [ "upscaleNsize", "upscaleNeurons", "upscaleQual", diff --git a/app/assets/filters/core/deblock.json b/app/assets/filters/core/deblock.json index 73cb92a8..f3f63210 100644 --- a/app/assets/filters/core/deblock.json +++ b/app/assets/filters/core/deblock.json @@ -8,40 +8,68 @@ "category": "cleanup", "icon": "grid_off", "order": 3, - "dependencies": { - "plugins": ["havsfunc"], - "vs_plugins": ["DCTFilter.dll", "Deblock.dll"] + "plugins": [ + "havsfunc" + ], + "vs_plugins": [ + "DCTFilter.dll", + "Deblock.dll" + ] }, - "methods": [ { "id": "deblock_qed", "name": "Deblock QED", - "description": "Quality Edge-Directed Deblock - good for all sources", + "description": "Best first choice. Edge-aware, so it flattens blocks without smearing detail across them", "function": "haf.Deblock_QED", - "parameters": ["quant1", "quant2", "aOffset1", "aOffset2"] + "parameters": [ + "quant1", + "quant2", + "aOffset1", + "aOffset2" + ] }, { "id": "deblock", "name": "Deblock", - "description": "Simple deblock filter - faster but less quality", + "description": "The h.264 in-loop deblocker. Much faster, and enough for lightly blocked sources", "function": "core.deblock.Deblock", - "parameters": ["quant1"] + "parameters": [ + "quant1" + ] + }, + { + "id": "dctfilter", + "name": "DCTFilter", + "description": "Removes the highest-frequency detail rather than smoothing block edges. Aimed at ringing and mosquito noise around edges, which the other two do not touch", + "function": "core.dctf.DCTFilter", + "parameters": [ + "dctCutoff", + "dctStrength", + "dctPlanes" + ] } ], - "parameters": { "enabled": { "type": "boolean", "default": false, - "ui": { "hidden": true } + "ui": { + "hidden": true + } }, "method": { "type": "enum", "default": "deblock_qed", - "options": ["deblock_qed", "deblock"], - "ui": { "hidden": true } + "options": [ + "deblock_qed", + "deblock", + "dctfilter" + ], + "ui": { + "hidden": true + } }, "quant1": { "type": "integer", @@ -50,7 +78,9 @@ "max": 60, "step": 1, "optional": true, - "vapoursynth": { "name": "quant1" }, + "vapoursynth": { + "name": "quant1" + }, "ui": { "label": "Quant 1", "description": "Quantization level for luma deblocking (20-28 for VHS)", @@ -64,12 +94,18 @@ "max": 60, "step": 1, "optional": true, - "vapoursynth": { "name": "quant2" }, + "vapoursynth": { + "name": "quant2" + }, "ui": { "label": "Quant 2", "description": "Quantization level for chroma deblocking", "widget": "slider", - "visibleWhen": { "method": ["deblock_qed"] } + "visibleWhen": { + "method": [ + "deblock_qed" + ] + } } }, "aOffset1": { @@ -79,12 +115,18 @@ "max": 4, "step": 1, "optional": true, - "vapoursynth": { "name": "aOff1" }, + "vapoursynth": { + "name": "aOff1" + }, "ui": { "label": "Alpha Offset 1", "description": "Luma alpha offset for edge detection", "widget": "slider", - "visibleWhen": { "method": ["deblock_qed"] } + "visibleWhen": { + "method": [ + "deblock_qed" + ] + } } }, "aOffset2": { @@ -94,28 +136,102 @@ "max": 4, "step": 1, "optional": true, - "vapoursynth": { "name": "aOff2" }, + "vapoursynth": { + "name": "aOff2" + }, "ui": { "label": "Alpha Offset 2", "description": "Chroma alpha offset for edge detection", "widget": "slider", - "visibleWhen": { "method": ["deblock_qed"] } + "visibleWhen": { + "method": [ + "deblock_qed" + ] + } + } + }, + "dctCutoff": { + "type": "integer", + "default": 5, + "min": 0, + "max": 7, + "step": 1, + "ui": { + "label": "Keep Detail Up To", + "description": "Frequency bands up to here are untouched; everything finer is reduced. Lower removes more, and softens more", + "widget": "slider", + "visibleWhen": { + "method": [ + "dctfilter" + ] + } + } + }, + "dctStrength": { + "type": "number", + "default": 0.6, + "min": 0.0, + "max": 1.0, + "step": 0.05, + "ui": { + "label": "Strength", + "description": "How hard the finer bands are reduced. 0 changes nothing at all, 1 removes them completely", + "widget": "slider", + "precision": 2, + "visibleWhen": { + "method": [ + "dctfilter" + ] + } + } + }, + "dctPlanes": { + "type": "enum", + "default": "0", + "options": [ + "0", + "1", + "2" + ], + "ui": { + "label": "Apply To", + "description": "Brightness only, colour only, or both", + "widget": "dropdown", + "visibleWhen": { + "method": [ + "dctfilter" + ] + } } } }, - "ui": { "sections": [ { "title": "Settings", - "parameters": ["quant1", "quant2", "aOffset1", "aOffset2"], + "parameters": [ + "quant1", + "quant2", + "aOffset1", + "aOffset2" + ], + "expanded": true + }, + { + "title": "DCTFilter", + "parameters": [ + "dctCutoff", + "dctStrength", + "dctPlanes" + ], "expanded": true } ] }, - "codeTemplate": { - "imports": ["import havsfunc as haf"], + "imports": [ + "import havsfunc as haf" + ], "generate": "method" } } diff --git a/app/assets/filters/core/deflicker.json b/app/assets/filters/core/deflicker.json new file mode 100644 index 00000000..6517093e --- /dev/null +++ b/app/assets/filters/core/deflicker.json @@ -0,0 +1,156 @@ +{ + "$schema": "https://vapourbox.app/schemas/filter-v1.json", + "id": "deflicker", + "version": "1.0.0", + "name": "Deflicker", + "description": "Even out brightness pulsing between frames", + "longDescription": "Film shot on a cine camera often pulses in brightness, because the shutter and the film advance were never perfectly in step. Scanned, that pulsing survives as a flicker that is far more obvious on a screen than it ever was on a projector.\n\n\"Whole frame\" is the one to reach for: it measures each frame's exposure against its neighbours and corrects both the brightness and the contrast. It removes about 83% of typical cine flicker.\n\n\"Across the frame\" handles the case where different parts of the picture flicker differently, which the whole-frame measurement cannot see. It is gentler and less complete; use it when the first option leaves something behind.\n\nRuns after deinterlacing and before the cleanup passes, which all assume the exposure is steady.", + "category": "cleanup", + "methods": [ + { + "id": "global", + "name": "Whole frame", + "description": "Corrects each frame's overall exposure against its neighbours \u2014 the usual cine-film case", + "function": "deflicker.global_deflicker", + "parameters": [ + "strength", + "window" + ] + }, + { + "id": "local", + "name": "Across the frame", + "description": "For flicker that varies from one part of the picture to another", + "function": "deflicker.reduce_flicker", + "parameters": [ + "localStrength", + "aggressive" + ] + } + ], + "parameters": { + "enabled": { + "type": "boolean", + "default": false, + "ui": { + "hidden": true + } + }, + "method": { + "type": "enum", + "default": "global", + "options": [ + "global", + "local" + ], + "ui": { + "label": "Method", + "description": "Whole-frame is the usual choice for scanned film.", + "widget": "dropdown" + } + }, + "strength": { + "type": "number", + "default": 1.0, + "min": 0.0, + "max": 1.0, + "step": 0.05, + "visibleWhen": { + "method": [ + "global" + ] + }, + "ui": { + "label": "Strength", + "description": "Lower this if the correction is over-eager on a shot with a genuine brightness change, like a lamp being switched on.", + "widget": "slider", + "precision": 2 + } + }, + "window": { + "type": "integer", + "default": 5, + "min": 1, + "max": 12, + "step": 1, + "visibleWhen": { + "method": [ + "global" + ] + }, + "ui": { + "label": "Frames compared", + "description": "How many frames either side are averaged to decide what the exposure should have been.", + "widget": "slider" + } + }, + "localStrength": { + "type": "integer", + "default": 2, + "min": 1, + "max": 3, + "step": 1, + "visibleWhen": { + "method": [ + "local" + ] + }, + "ui": { + "label": "Strength", + "description": "Higher compares against more distant frames.", + "widget": "slider" + } + }, + "aggressive": { + "type": "boolean", + "default": false, + "visibleWhen": { + "method": [ + "local" + ] + }, + "ui": { + "label": "Aggressive", + "description": "Corrects harder, at more risk of smearing genuine movement.", + "widget": "checkbox" + } + } + }, + "ui": { + "sections": [ + { + "title": "Method", + "parameters": [ + "method" + ], + "expanded": true + }, + { + "title": "Whole frame", + "parameters": [ + "strength", + "window" + ], + "expanded": true, + "visibleWhen": { + "method": [ + "global" + ] + } + }, + { + "title": "Across the frame", + "parameters": [ + "localStrength", + "aggressive" + ], + "expanded": true, + "visibleWhen": { + "method": [ + "local" + ] + } + } + ] + } +} diff --git a/app/assets/filters/core/dehalo.json b/app/assets/filters/core/dehalo.json index fc131020..805b495b 100644 --- a/app/assets/filters/core/dehalo.json +++ b/app/assets/filters/core/dehalo.json @@ -4,7 +4,7 @@ "version": "1.1.0", "name": "Dehalo", "description": "Remove halo, ringing and ghosting around edges", - "longDescription": "Removes the bright outline that sits alongside high-contrast edges \u2014 the ringing left by over-sharpening, upscaling or heavy compression.\n\nUse it when edges look traced with a light pen: common on VHS run through a sharpening time-base corrector, and on upscaled or hard-compressed material. Apply it after deinterlacing and denoising. Too much strength eats the fine detail right next to the edge.\n\nThe two Vinverse methods target a different artifact: the vertical comb or ghost residue a deinterlacer leaves behind. They blur vertically, so only run them on progressive frames \u2014 after the deinterlace pass, never instead of it.", + "longDescription": "Removes the bright outline that sits alongside high-contrast edges — the ringing left by over-sharpening, upscaling or heavy compression.\n\nUse it when edges look traced with a light pen: common on VHS run through a sharpening time-base corrector, and on upscaled or hard-compressed material. Apply it after deinterlacing and denoising. Too much strength eats the fine detail right next to the edge.\n\nThe two Vinverse methods target a different artifact: the vertical comb or ghost residue a deinterlacer leaves behind. They blur vertically, so only run them on progressive frames — after the deinterlace pass, never instead of it.", "category": "cleanup", "icon": "blur_off", "order": 4, @@ -24,7 +24,7 @@ { "id": "dehalo_alpha", "name": "DeHalo Alpha", - "description": "General purpose dehalo, good for most sources", + "description": "Best first choice. Handles the bright outlines left by over-sharpening on most sources", "function": "haf.DeHalo_alpha", "parameters": [ "rx", @@ -39,7 +39,7 @@ { "id": "fine_dehalo", "name": "Fine Dehalo", - "description": "More precise, better edge preservation", + "description": "Masked, so it keeps real edges better than DeHalo Alpha. Slower. Use when DeHalo Alpha softens the picture", "function": "haf.FineDehalo", "parameters": [ "rx", @@ -58,14 +58,15 @@ { "id": "fine_dehalo2", "name": "Fine Dehalo 2", - "description": "Removes the ringing left on sharp edges \u2014 run after Fine Dehalo", + "description": "Removes the ringing left on sharp edges. A follow-up pass — run it after Fine Dehalo, not instead of it", "function": "haf.FineDehalo2", - "parameters": [] + "parameters": [], + "advancedOnly": true }, { "id": "yahr", "name": "YAHR", - "description": "Yet Another Halo Remover - fast and effective", + "description": "Fastest option, and good on halos that come with ringing. Can soften fine texture", "function": "haf.YAHR", "parameters": [ "yahrBlur", @@ -75,7 +76,7 @@ { "id": "edge_cleaner", "name": "Edge Cleaner", - "description": "Cleans edge noise and weak halos (aWarpSharp2)", + "description": "Cleans edge noise and weak halos by warping edges (aWarpSharp2). Niche — for line art rather than live action", "function": "haf.EdgeCleaner", "parameters": [ "edgeStrength", @@ -83,28 +84,44 @@ "edgeRepairMode", "edgeSmallMode", "edgeHotPixels" - ] + ], + "advancedOnly": true }, { "id": "vinverse", "name": "Vinverse (de-ghost)", - "description": "Removes comb/ghost residue left by deinterlacing", + "description": "Removes comb/ghost residue left by deinterlacing. Also offered in Chroma Fixes, which is the better place for it", "function": "haf.Vinverse", "parameters": [ "vinverseStrength", "vinverseAmount", "vinverseChroma" - ] + ], + "advancedOnly": true }, { "id": "vinverse2", "name": "Vinverse 2 (de-ghost)", - "description": "Like Vinverse but keeps more vertical detail", + "description": "Like Vinverse but keeps more vertical detail. Also offered in Chroma Fixes", "function": "haf.Vinverse2", "parameters": [ "vinverseStrength", "vinverseAmount", "vinverseChroma" + ], + "advancedOnly": true + }, + { + "id": "hq_deringmod", + "name": "HQDeringmod", + "description": "Removes ringing — the overshoot immediately beside an edge — while a mask protects the edge itself. A narrower target than dehalo, for over-sharpened or heavily compressed sources", + "function": "haf.HQDeringmod", + "parameters": [ + "deringMrad", + "deringMsmooth", + "deringMthr", + "deringThr", + "deringDarkthr" ] } ], @@ -126,7 +143,8 @@ "yahr", "edge_cleaner", "vinverse", - "vinverse2" + "vinverse2", + "hq_deringmod" ], "ui": { "hidden": true @@ -190,7 +208,7 @@ }, "ui": { "label": "Dark Halo Strength", - "description": "Strength of dark halo removal. Above 1.0 overshoots \u2014 it pushes past the original pixel rather than blending back to it", + "description": "Strength of dark halo removal. Above 1.0 overshoots — it pushes past the original pixel rather than blending back to it", "widget": "slider", "precision": 2, "visibleWhen": { @@ -213,7 +231,7 @@ }, "ui": { "label": "Bright Halo Strength", - "description": "Strength of bright halo removal. Above 1.0 overshoots \u2014 it pushes past the original pixel rather than blending back to it", + "description": "Strength of bright halo removal. Above 1.0 overshoots — it pushes past the original pixel rather than blending back to it", "widget": "slider", "precision": 2, "visibleWhen": { @@ -342,7 +360,7 @@ }, "ui": { "label": "Limit Low", - "description": "Below this edge strength, dehaloing is fully limited \u2014 protects faint detail", + "description": "Below this edge strength, dehaloing is fully limited — protects faint detail", "widget": "slider", "visibleWhen": { "method": [ @@ -638,6 +656,98 @@ ] } } + }, + "deringMrad": { + "type": "integer", + "default": 1, + "min": 1, + "max": 3, + "step": 1, + "optional": true, + "ui": { + "label": "Ring Radius", + "description": "How wide a band beside each edge to treat", + "widget": "slider", + "visibleWhen": { + "method": [ + "hq_deringmod" + ] + } + } + }, + "deringMsmooth": { + "type": "integer", + "default": 1, + "min": 0, + "max": 4, + "step": 1, + "optional": true, + "ui": { + "label": "Mask Smoothing", + "description": "Softens where the mask takes effect", + "widget": "slider", + "visibleWhen": { + "method": [ + "hq_deringmod" + ] + } + } + }, + "deringMthr": { + "type": "integer", + "default": 60, + "min": 0, + "max": 255, + "step": 5, + "optional": true, + "ui": { + "label": "Edge Threshold", + "description": "Lower finds more edges to protect", + "widget": "slider", + "visibleWhen": { + "method": [ + "hq_deringmod" + ] + } + } + }, + "deringThr": { + "type": "number", + "default": 12.0, + "min": 0.0, + "max": 64.0, + "step": 0.5, + "optional": true, + "ui": { + "label": "Limit", + "description": "How far a pixel may be changed, in 8-bit levels", + "widget": "slider", + "precision": 1, + "visibleWhen": { + "method": [ + "hq_deringmod" + ] + } + } + }, + "deringDarkthr": { + "type": "number", + "default": 3.0, + "min": 0.0, + "max": 64.0, + "step": 0.5, + "optional": true, + "ui": { + "label": "Dark Limit", + "description": "Separate limit for the dark side of an edge. Left off, havsfunc uses a quarter of the main limit", + "widget": "slider", + "precision": 1, + "visibleWhen": { + "method": [ + "hq_deringmod" + ] + } + } } }, "ui": { @@ -657,7 +767,10 @@ "edgeRepair", "vinverseStrength", "vinverseAmount", - "vinverseChroma" + "vinverseChroma", + "deringMrad", + "deringMsmooth", + "deringThr" ], "expanded": true }, @@ -674,7 +787,9 @@ "edgeProc", "edgeRepairMode", "edgeSmallMode", - "edgeHotPixels" + "edgeHotPixels", + "deringMthr", + "deringDarkthr" ], "expanded": false, "advancedOnly": true diff --git a/app/assets/filters/core/deinterlace.json b/app/assets/filters/core/deinterlace.json index 7f9fda88..9f3fa6f7 100644 --- a/app/assets/filters/core/deinterlace.json +++ b/app/assets/filters/core/deinterlace.json @@ -4,7 +4,7 @@ "version": "1.0.0", "name": "Deinterlace", "description": "QTGMC deinterlacing or IVTC inverse telecine", - "longDescription": "Turns interlaced video — where each frame holds two half-height fields captured at different moments — into whole progressive frames, or removes the pulldown pattern from film that was telecined to video. QTGMC rebuilds every field into a full frame for the smoothest motion; IVTC instead recovers the original film frames and drops the duplicates.\n\nUse it on anything from tape or broadcast — VHS, Video8, DV, DVD — where moving edges show comb teeth. Leave it off for footage that is already progressive. Run it first: every other filter works better on whole frames.", + "longDescription": "Turns interlaced video \u2014 where each frame holds two half-height fields captured at different moments \u2014 into whole progressive frames, or removes the pulldown pattern from film that was telecined to video. QTGMC rebuilds every field into a full frame for the smoothest motion; IVTC instead recovers the original film frames and drops the duplicates.\n\nUse it on anything from tape or broadcast \u2014 VHS, Video8, DV, DVD \u2014 where moving edges show comb teeth. Leave it off for footage that is already progressive. Run it first: every other filter works better on whole frames.", "category": "deinterlace", "icon": "layers", "order": 1, @@ -113,7 +113,7 @@ { "id": "ivtc", "name": "IVTC", - "description": "Inverse telecine for DVD sources with 3:2 pulldown (29.97i → 23.976p)", + "description": "Inverse telecine for DVD sources with 3:2 pulldown (29.97i \u2192 23.976p)", "function": "core.vivtc.VFM + core.vivtc.VDecimate", "parameters": [ "ivtcOrder", @@ -133,6 +133,17 @@ "description": "Fix frame rate for DVD soft telecine sources misdetected as 29.97fps", "function": "core.std.AssumeFPS", "parameters": [] + }, + { + "id": "bwdif", + "name": "Bwdif (fast)", + "description": "Four to five times faster than QTGMC at most of the quality \u2014 for long captures, or a quick look before committing to a full run", + "function": "core.bwdif.Bwdif", + "parameters": [ + "tff", + "fpsDivisor", + "bwdifEdeint" + ] } ], "parameters": { @@ -149,7 +160,8 @@ "options": [ "qtgmc", "ivtc", - "soft_telecine" + "soft_telecine", + "bwdif" ], "ui": { "hidden": true @@ -1935,6 +1947,20 @@ ] } } + }, + "bwdifEdeint": { + "type": "boolean", + "default": false, + "visibleWhen": { + "method": [ + "bwdif" + ] + }, + "ui": { + "label": "High-quality interpolation", + "description": "Use the NNEDI3 neural interpolator instead of the built-in one. Sharper edges, noticeably slower \u2014 still far quicker than QTGMC.", + "widget": "checkbox" + } } }, "parameterPresets": { @@ -1943,7 +1969,9 @@ "description": "Which field comes first in the interlaced source", "default": "Top Field First", "visibleWhen": { - "method": ["qtgmc"] + "method": [ + "qtgmc" + ] }, "options": { "Top Field First": { @@ -2229,6 +2257,18 @@ ], "expanded": false, "advancedOnly": true + }, + { + "title": "Bwdif", + "parameters": [ + "bwdifEdeint" + ], + "expanded": true, + "visibleWhen": { + "method": [ + "bwdif" + ] + } } ] }, diff --git a/app/assets/filters/core/edge_repair.json b/app/assets/filters/core/edge_repair.json new file mode 100644 index 00000000..98c0d0d5 --- /dev/null +++ b/app/assets/filters/core/edge_repair.json @@ -0,0 +1,117 @@ +{ + "$schema": "https://vapourbox.app/schemas/filter-v1.json", + "id": "edge_repair", + "version": "1.0.0", + "name": "Edge Repair", + "description": "Rebuild the dirty rows and columns at the frame border", + "longDescription": "Tape captures almost always have a few bad rows or columns right at the edge of the frame \u2014 black, half-black, or a smear of the wrong colour. Cropping them away works, but throws picture away with them.\n\nThis rebuilds them from the pixels just inside instead, so the frame stays the size it was. Set each edge to the number of bad rows or columns you can see; the counts move in twos, which is what keeps colour aligned on subsampled video.\n\nIt runs before the cleanup passes on purpose. Denoising or sharpening a bad edge first smears it inward, and resizing spreads it across the picture.", + "category": "cleanup", + "methods": [ + { + "id": "fillborders", + "name": "Fill borders", + "description": "Rebuilds the edge from the pixels just inside it", + "function": "core.fb.FillBorders", + "parameters": [ + "left", + "right", + "top", + "bottom", + "mode" + ] + } + ], + "parameters": { + "enabled": { + "type": "boolean", + "default": false, + "ui": { + "hidden": true + } + }, + "left": { + "type": "integer", + "default": 0, + "min": 0, + "max": 16, + "step": 2, + "ui": { + "label": "Left", + "description": "Rows to rebuild on the left edge.", + "widget": "slider" + } + }, + "right": { + "type": "integer", + "default": 0, + "min": 0, + "max": 16, + "step": 2, + "ui": { + "label": "Right", + "description": "Rows to rebuild on the right edge.", + "widget": "slider" + } + }, + "top": { + "type": "integer", + "default": 0, + "min": 0, + "max": 16, + "step": 2, + "ui": { + "label": "Top", + "description": "Rows to rebuild along the top.", + "widget": "slider" + } + }, + "bottom": { + "type": "integer", + "default": 0, + "min": 0, + "max": 16, + "step": 2, + "ui": { + "label": "Bottom", + "description": "Rows to rebuild along the bottom.", + "widget": "slider" + } + }, + "mode": { + "type": "enum", + "default": "fillmargins", + "options": [ + "fillmargins", + "repeat", + "mirror" + ], + "ui": { + "label": "Fill method", + "description": "How the rebuilt pixels are derived. The default suits almost everything \u2014 measured, the alternatives score the same.", + "widget": "dropdown" + } + } + }, + "ui": { + "sections": [ + { + "title": "Edges", + "parameters": [ + "left", + "right", + "top", + "bottom" + ], + "expanded": true + }, + { + "title": "Advanced", + "parameters": [ + "mode" + ], + "expanded": false, + "advancedOnly": true + } + ] + } +} diff --git a/app/assets/filters/core/frame_rate.json b/app/assets/filters/core/frame_rate.json new file mode 100644 index 00000000..10bd73ae --- /dev/null +++ b/app/assets/filters/core/frame_rate.json @@ -0,0 +1,127 @@ +{ + "$schema": "https://vapourbox.app/schemas/filter-v1.json", + "id": "frame_rate", + "version": "1.0.0", + "name": "Frame Rate", + "description": "Convert between PAL and NTSC frame rates", + "longDescription": "Changes how many frames per second the output runs at. This is for standards conversion \u2014 a tape that was converted from NTSC to PAL (or the reverse) at some point in its life, and now needs to play at the right speed on your equipment.\n\nIt is deliberately not a \"make motion smooth\" feature. Interpolating a master to a higher rate invents frames that were never photographed, which makes the file a worse record of what was shot. Converting an already-converted tape is the opposite case: the damage is already in the source, and leaving it alone means either judder or a 4% speed error.\n\nMotion interpolation gives the smoothest result and is usually invisible on a pan, but it can warp edges where something passes in front of something else. Repeat frames invents nothing at all and judders instead \u2014 the honest choice for an archival master.\n\nThis pass runs last, after everything else.", + "category": "enhancement", + "methods": [ + { + "id": "flowFps", + "name": "Motion interpolation", + "description": "Builds the new frames from the motion between the real ones. Smoothest result; can warp edges where objects overlap.", + "function": "core.mv.FlowFPS", + "parameters": [ + "blockSize", + "overlap" + ] + }, + { + "id": "duplicate", + "name": "Repeat frames", + "description": "Repeats or drops whole frames. Invents nothing, but motion judders.", + "function": "havsfunc.ChangeFPS", + "parameters": [] + } + ], + "parameters": { + "enabled": { + "type": "boolean", + "default": false, + "ui": { + "hidden": true + } + }, + "method": { + "type": "enum", + "default": "flowFps", + "options": [ + "flowFps", + "duplicate" + ], + "ui": { + "label": "Method", + "description": "How the new frames are produced.", + "widget": "dropdown" + } + }, + "target": { + "type": "enum", + "default": "pal25", + "options": [ + "pal25", + "ntsc2997", + "film23976", + "film24", + "pal50", + "ntsc5994" + ], + "ui": { + "label": "Target rate", + "description": "The frame rate the output should run at.", + "widget": "dropdown" + } + }, + "blockSize": { + "type": "integer", + "default": 16, + "min": 4, + "max": 32, + "step": 4, + "visibleWhen": { + "method": [ + "flowFps" + ] + }, + "ui": { + "label": "Block size", + "description": "Motion is estimated in blocks of this size. Larger is faster and coarser.", + "widget": "slider" + } + }, + "overlap": { + "type": "integer", + "default": 8, + "min": 0, + "max": 16, + "step": 2, + "visibleWhen": { + "method": [ + "flowFps" + ] + }, + "ui": { + "label": "Block overlap", + "description": "How much neighbouring blocks overlap. More overlap hides block edges at some cost in speed.", + "widget": "slider" + } + } + }, + "ui": { + "sections": [ + { + "title": "Conversion", + "parameters": [ + "target", + "method" + ], + "expanded": true + }, + { + "title": "Motion estimation", + "parameters": [ + "blockSize", + "overlap" + ], + "expanded": false, + "advancedOnly": true, + "visibleWhen": { + "method": [ + "flowFps" + ] + } + } + ] + } +} diff --git a/app/assets/filters/core/geometry.json b/app/assets/filters/core/geometry.json new file mode 100644 index 00000000..c4d376e1 --- /dev/null +++ b/app/assets/filters/core/geometry.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://vapourbox.app/schemas/filter-v1.json", + "id": "geometry", + "version": "1.0.0", + "name": "Rotate / Flip", + "description": "Rotate or mirror the picture", + "longDescription": "Turns the picture in quarter steps and mirrors it. For footage shot sideways on a phone, camcorder captures that came out mirrored, and film scans that came off the scanner the wrong way round.\n\nIt runs after deinterlacing and before cropping, and both of those matter. Fields run in horizontal lines, so turning a frame that has not been deinterlaced yet shears them into nonsense — deinterlace first. And a quarter turn swaps the width and height, so it has to settle before anything decides how to crop, scale or shape the output.", + "category": "transform", + "icon": "rotate_90_degrees_cw", + "order": 12, + "methods": [ + { + "id": "transform", + "name": "Rotate / Flip", + "description": "Quarter-turn rotation and mirroring, applied in that order. Lossless — it moves samples without changing their values", + "function": "core.std.Turn90", + "parameters": [ + "rotation", + "flipHorizontal", + "flipVertical" + ] + } + ], + "parameters": { + "enabled": { + "type": "boolean", + "default": false, + "ui": { + "hidden": true + } + }, + "method": { + "type": "enum", + "default": "transform", + "options": [ + "transform" + ], + "ui": { + "hidden": true + } + }, + "rotation": { + "type": "enum", + "default": "none", + "options": [ + "none", + "cw90", + "rotate180", + "ccw90" + ], + "ui": { + "label": "Rotation", + "description": "A quarter turn swaps the width and height, so the output shape changes", + "widget": "dropdown" + } + }, + "flipHorizontal": { + "type": "boolean", + "default": false, + "ui": { + "label": "Mirror Left-Right", + "description": "Applied after the rotation" + } + }, + "flipVertical": { + "type": "boolean", + "default": false, + "ui": { + "label": "Mirror Top-Bottom", + "description": "Applied after the rotation" + } + } + }, + "ui": { + "sections": [ + { + "title": "Settings", + "parameters": [ + "rotation", + "flipHorizontal", + "flipVertical" + ], + "expanded": true + } + ] + } +} diff --git a/app/assets/filters/core/ghost_removal.json b/app/assets/filters/core/ghost_removal.json new file mode 100644 index 00000000..11da9ba7 --- /dev/null +++ b/app/assets/filters/core/ghost_removal.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://vapourbox.app/schemas/filter-v1.json", + "id": "ghost_removal", + "version": "1.0.0", + "name": "Ghost Removal", + "description": "Remove the displaced echo RF and cable distribution leave behind", + "longDescription": "A faint second copy of the picture, shifted sideways, is the signature of a signal that arrived by aerial or a long cable run \u2014 the same image reaching the tuner twice, a fraction of a microsecond apart.\n\nPick the preset that matches what you can see. Each one cancels an echo at a typical offset and strength; if none of them fits, the advanced editor lets you set the offset and strength directly, and add more than one.\n\nA ghost that is very strong, or that varies across the picture, will not come out cleanly \u2014 this cancels a fixed echo, it does not separate two overlapping images.", + "category": "cleanup", + "methods": [ + { + "id": "lghost", + "name": "LGhost", + "description": "Cancels a fixed horizontal echo", + "function": "core.lghost.LGhost", + "parameters": [ + "preset" + ] + } + ], + "parameters": { + "enabled": { + "type": "boolean", + "default": false, + "ui": { + "hidden": true + } + }, + "preset": { + "type": "enum", + "default": "light", + "options": [ + "light", + "medium", + "strong", + "custom" + ], + "ui": { + "label": "Ghost strength", + "description": "How pronounced the echo is. Choose Custom to set the offset and strength yourself.", + "widget": "dropdown" + } + } + }, + "ui": { + "sections": [ + { + "title": "Ghost", + "parameters": [ + "preset" + ], + "expanded": true + } + ] + } +} diff --git a/app/assets/filters/core/grain.json b/app/assets/filters/core/grain.json new file mode 100644 index 00000000..32db7d76 --- /dev/null +++ b/app/assets/filters/core/grain.json @@ -0,0 +1,224 @@ +{ + "$schema": "https://vapourbox.app/schemas/filter-v1.json", + "id": "grain", + "version": "1.0.0", + "name": "Film Grain", + "description": "Add film grain back after denoising", + "longDescription": "Adds grain to a picture that has had its own removed. Denoising strips the fine texture that made the image look photographic, and what is left often reads as plastic or waxy — a little grain puts the life back.\n\nIt also hides banding. The noise in an original recording was dithering its own gradients; take it away and skies and fades break into visible steps. Grain restores that dithering, and it survives the encoder better than the banding does.\n\nThis runs last of all the video steps, after resizing and debanding. Anything else would remove what it just added — grain applied before a resize is resampled away, and before debanding is smoothed away.", + "category": "enhancement", + "icon": "grain", + "order": 13, + "dependencies": { + "plugins": [ + "havsfunc" + ], + "vs_plugins": [ + "AddGrain.dll" + ] + }, + "methods": [ + { + "id": "add_grain", + "name": "Simple", + "description": "Best first choice. One strength control, grains colour as well as brightness, and can hold a static pattern. Very fast", + "function": "core.grain.Add", + "parameters": [ + "var", + "uvar", + "corr", + "constant" + ] + }, + { + "id": "grain_factory3", + "name": "Film stock (3-layer)", + "description": "Three grain layers chosen by brightness, so shadows are grainier than highlights — closer to real film. Brightness only: it leaves colour untouched, and it cannot hold a static pattern", + "function": "haf.GrainFactory3", + "parameters": [ + "g1str", + "g2str", + "g3str", + "tempAvg" + ] + } + ], + "parameters": { + "enabled": { + "type": "boolean", + "default": false, + "ui": { + "hidden": true + } + }, + "method": { + "type": "enum", + "default": "add_grain", + "options": [ + "add_grain", + "grain_factory3" + ], + "ui": { + "hidden": true + } + }, + "var": { + "type": "number", + "default": 4.0, + "min": 0.0, + "max": 36.0, + "step": 0.5, + "ui": { + "label": "Strength", + "description": "2 is barely visible, 4 is subtle film grain, 9 is clearly visible, 25 is heavy", + "widget": "slider", + "precision": 1, + "visibleWhen": { + "method": [ + "add_grain" + ] + } + } + }, + "uvar": { + "type": "number", + "default": 0.0, + "min": 0.0, + "max": 36.0, + "step": 0.5, + "optional": true, + "ui": { + "label": "Colour Strength", + "description": "Grain in the colour channels. Off leaves colour untouched — worth turning on when the banding is in a coloured gradient", + "widget": "slider", + "precision": 1, + "visibleWhen": { + "method": [ + "add_grain" + ] + } + } + }, + "corr": { + "type": "number", + "default": 0.0, + "min": 0.0, + "max": 0.9, + "step": 0.05, + "optional": true, + "ui": { + "label": "Grain Size", + "description": "Coarser, clumpier grain. It also weakens the grain, so raise the strength to compensate", + "widget": "slider", + "precision": 2, + "visibleWhen": { + "method": [ + "add_grain" + ] + } + } + }, + "constant": { + "type": "boolean", + "default": false, + "ui": { + "label": "Static Grain", + "description": "Hold the same pattern on every frame. Usually leave off — static grain over moving video reads as dirt on the lens", + "visibleWhen": { + "method": [ + "add_grain" + ] + } + } + }, + "g1str": { + "type": "number", + "default": 4.0, + "min": 0.0, + "max": 25.0, + "step": 0.5, + "ui": { + "label": "Shadows", + "description": "Grain in the darkest parts, where real film shows the most", + "widget": "slider", + "precision": 1, + "visibleWhen": { + "method": [ + "grain_factory3" + ] + } + } + }, + "g2str": { + "type": "number", + "default": 3.0, + "min": 0.0, + "max": 25.0, + "step": 0.5, + "ui": { + "label": "Midtones", + "description": "Grain through the middle of the range", + "widget": "slider", + "precision": 1, + "visibleWhen": { + "method": [ + "grain_factory3" + ] + } + } + }, + "g3str": { + "type": "number", + "default": 2.0, + "min": 0.0, + "max": 25.0, + "step": 0.5, + "ui": { + "label": "Highlights", + "description": "Grain in the brightest parts, where film shows the least", + "widget": "slider", + "precision": 1, + "visibleWhen": { + "method": [ + "grain_factory3" + ] + } + } + }, + "tempAvg": { + "type": "integer", + "default": 0, + "min": 0, + "max": 100, + "step": 5, + "optional": true, + "ui": { + "label": "Steadiness", + "description": "Calms the frame-to-frame movement of the grain. It cannot stop it — this method is always animated", + "widget": "slider", + "visibleWhen": { + "method": [ + "grain_factory3" + ] + } + } + } + }, + "ui": { + "sections": [ + { + "title": "Settings", + "parameters": [ + "var", + "uvar", + "corr", + "constant", + "g1str", + "g2str", + "g3str", + "tempAvg" + ], + "expanded": true + } + ] + } +} diff --git a/app/assets/filters/core/noise_reduction.json b/app/assets/filters/core/noise_reduction.json index e158e78f..32938971 100644 --- a/app/assets/filters/core/noise_reduction.json +++ b/app/assets/filters/core/noise_reduction.json @@ -15,14 +15,15 @@ ], "vs_plugins": [ "libmvtools.dll", - "DFTTest.dll" + "DFTTest.dll", + "libfluxsmooth.dll" ] }, "methods": [ { "id": "smdegrain", "name": "SMDegrain", - "description": "Motion-compensated denoising - excellent quality", + "description": "Best first choice. Motion-compensated, so it cleans noise without smearing movement. Moderate speed", "function": "haf.SMDegrain", "parameters": [ "smDegrainTr", @@ -35,7 +36,7 @@ { "id": "mc_temporal_denoise", "name": "MCTemporalDenoise", - "description": "Advanced temporal denoising - best for heavy noise", + "description": "For heavy noise SMDegrain can't settle \u2014 VHS and off-air captures. Slowest option", "function": "haf.MCTemporalDenoise", "parameters": [ "mcTemporalProfile", @@ -55,14 +56,124 @@ "mcdsSharp", "mcdsBlurSearch", "mcdsPlane" - ] + ], + "advancedOnly": true }, { "id": "qtgmc_builtin", "name": "QTGMC Built-in", - "description": "Use QTGMC's integrated denoising - fastest option", + "description": "Reuses the motion search QTGMC already ran, so it is nearly free. Only useful when deinterlacing is on", "function": "qtgmc_internal", "parameters": [] + }, + { + "id": "dfttest", + "name": "DFTTest", + "description": "Frequency-domain denoiser. The cleanest option on fine, even grain \u2014 separates it from detail better than the motion-compensated methods. Moderate speed", + "function": "core.dfttest.DFTTest", + "parameters": [ + "dfttestSigma", + "dfttestTbsize", + "dfttestSbsize" + ], + "advancedOnly": true + }, + { + "id": "fft3dfilter", + "name": "FFT3DFilter", + "description": "The traditional first stop for VHS luma noise. Fast and aggressive, with optional sharpening built in", + "function": "core.fft3dfilter.FFT3DFilter", + "parameters": [ + "fft3dSigma", + "fft3dBt", + "fft3dSharpen" + ], + "advancedOnly": true + }, + { + "id": "ttempsmooth", + "name": "TTempSmooth", + "description": "Very gentle temporal smoother \u2014 leaves anything that moves alone. A finishing pass for residual shimmer, not a primary denoiser", + "function": "core.ttmpsm.TTempSmooth", + "parameters": [ + "ttempMaxr", + "ttempThresh", + "ttempMdiff", + "ttempStrength" + ], + "advancedOnly": true + }, + { + "id": "fluxsmooth_t", + "name": "FluxSmoothT", + "description": "Averages a pixel with its neighbours in time only where they bracket it in value, so motion is left alone almost for free. Very fast \u2014 a common first pass on tape", + "function": "core.flux.SmoothT", + "parameters": [ + "fluxTemporalThreshold" + ], + "advancedOnly": true + }, + { + "id": "fluxsmooth_st", + "name": "FluxSmoothST", + "description": "As FluxSmoothT plus the eight spatial neighbours. Stronger, and a little more willing to soften fine detail", + "function": "core.flux.SmoothST", + "parameters": [ + "fluxTemporalThreshold", + "fluxSpatialThreshold" + ], + "advancedOnly": true + }, + { + "id": "stpresso", + "name": "STPresso", + "description": "Caps how far any pixel may move, so detail survives almost intact. A finishing pass for when a real denoiser is too destructive but the picture still crawls", + "function": "haf.STPresso", + "parameters": [ + "stpressoLimit", + "stpressoBias", + "stpressoTthr" + ], + "advancedOnly": true + }, + { + "id": "ctmf", + "name": "CTMF (median)", + "description": "Median filter with a large window. Good on blotches, dropouts and speckle that averaging leaves behind. Constant-time, so a wide radius costs no more than a narrow one", + "function": "core.ctmf.CTMF", + "parameters": [ + "ctmfRadius", + "ctmfPlanes" + ], + "advancedOnly": true + }, + { + "id": "mclean", + "name": "mClean", + "description": "Denoise, then put detail and grain back so the picture does not look plastic \u2014 the one to try first if you are not sure", + "function": "mclean.mClean", + "parameters": [ + "mcleanStrength", + "mcleanSharp", + "mcleanRn", + "mcleanThsad", + "mcleanChroma" + ] + }, + { + "id": "temporal_degrain2", + "name": "TemporalDegrain2", + "description": "The heavyweight \u2014 slow, and the most capable thing here for badly noisy analogue captures", + "advancedOnly": true, + "function": "temporaldegrain2.TemporalDegrain2", + "parameters": [ + "td2DegrainTr", + "td2GrainLevel", + "td2PostFft", + "td2PostSigma", + "td2PostMix", + "td2ChromaMotion" + ] } ], "parameters": { @@ -80,7 +191,16 @@ "smdegrain", "mc_temporal_denoise", "mcdegrainsharp", - "qtgmc_builtin" + "qtgmc_builtin", + "dfttest", + "fft3dfilter", + "ttempsmooth", + "fluxsmooth_t", + "fluxsmooth_st", + "stpresso", + "ctmf", + "mclean", + "temporal_degrain2" ], "ui": { "hidden": true @@ -426,6 +546,504 @@ ] } } + }, + "dfttestSigma": { + "type": "number", + "default": 8.0, + "min": 0.0, + "max": 64.0, + "step": 0.5, + "ui": { + "label": "Strength", + "description": "Higher removes more noise and more fine detail with it", + "widget": "slider", + "precision": 1, + "visibleWhen": { + "method": [ + "dfttest" + ] + } + } + }, + "dfttestTbsize": { + "type": "integer", + "default": 3, + "min": 1, + "max": 7, + "step": 2, + "ui": { + "label": "Temporal Window", + "description": "Frames considered together. 1 is purely spatial; even values are rounded down so the window stays centred", + "widget": "slider", + "visibleWhen": { + "method": [ + "dfttest" + ] + } + } + }, + "dfttestSbsize": { + "type": "integer", + "default": 16, + "min": 8, + "max": 32, + "step": 4, + "ui": { + "label": "Block Size", + "description": "Larger blocks separate noise from detail better but are slower", + "widget": "slider", + "visibleWhen": { + "method": [ + "dfttest" + ] + } + } + }, + "fft3dSigma": { + "type": "number", + "default": 2.0, + "min": 0.1, + "max": 16.0, + "step": 0.1, + "ui": { + "label": "Strength", + "description": "Higher removes more noise. FFT3D is aggressive \u2014 small changes matter", + "widget": "slider", + "precision": 1, + "visibleWhen": { + "method": [ + "fft3dfilter" + ] + } + } + }, + "fft3dBt": { + "type": "integer", + "default": 3, + "min": 1, + "max": 5, + "step": 1, + "ui": { + "label": "Temporal Window", + "description": "Frames considered together. 1 is purely spatial", + "widget": "slider", + "visibleWhen": { + "method": [ + "fft3dfilter" + ] + } + } + }, + "fft3dSharpen": { + "type": "number", + "default": 0.0, + "min": 0.0, + "max": 1.0, + "step": 0.05, + "ui": { + "label": "Sharpen", + "description": "Sharpening applied inside the same transform. 0 leaves it off", + "widget": "slider", + "precision": 2, + "visibleWhen": { + "method": [ + "fft3dfilter" + ] + } + } + }, + "ttempMaxr": { + "type": "integer", + "default": 3, + "min": 1, + "max": 7, + "step": 1, + "ui": { + "label": "Temporal Radius", + "description": "Frames on each side to average over", + "widget": "slider", + "visibleWhen": { + "method": [ + "ttempsmooth" + ] + } + } + }, + "ttempThresh": { + "type": "integer", + "default": 4, + "min": 1, + "max": 32, + "step": 1, + "ui": { + "label": "Threshold", + "description": "Pixels differing by more than this are left alone. Higher smooths more", + "widget": "slider", + "visibleWhen": { + "method": [ + "ttempsmooth" + ] + } + } + }, + "ttempMdiff": { + "type": "integer", + "default": 2, + "min": 0, + "max": 31, + "step": 1, + "ui": { + "label": "Motion Threshold", + "description": "Protects moving pixels. Held below Threshold automatically", + "widget": "slider", + "visibleWhen": { + "method": [ + "ttempsmooth" + ] + } + } + }, + "ttempStrength": { + "type": "integer", + "default": 2, + "min": 1, + "max": 8, + "step": 1, + "ui": { + "label": "Strength", + "description": "Higher weights the current frame more, so smooths less", + "widget": "slider", + "visibleWhen": { + "method": [ + "ttempsmooth" + ] + } + } + }, + "fluxTemporalThreshold": { + "type": "integer", + "default": 7, + "min": -1, + "max": 64, + "step": 1, + "ui": { + "label": "Temporal Threshold", + "description": "How different a neighbouring frame may be and still be averaged in. -1 turns the temporal half off", + "widget": "slider", + "visibleWhen": { + "method": [ + "fluxsmooth_t", + "fluxsmooth_st" + ] + } + } + }, + "fluxSpatialThreshold": { + "type": "integer", + "default": 7, + "min": -1, + "max": 64, + "step": 1, + "ui": { + "label": "Spatial Threshold", + "description": "Same, for the eight neighbouring pixels. -1 turns the spatial half off", + "widget": "slider", + "visibleWhen": { + "method": [ + "fluxsmooth_st" + ] + } + } + }, + "stpressoLimit": { + "type": "integer", + "default": 3, + "min": 1, + "max": 16, + "step": 1, + "ui": { + "label": "Limit", + "description": "Furthest any pixel may move, in 8-bit levels. The control that matters", + "widget": "slider", + "visibleWhen": { + "method": [ + "stpresso" + ] + } + } + }, + "stpressoBias": { + "type": "integer", + "default": 24, + "min": 0, + "max": 100, + "step": 1, + "ui": { + "label": "Bias", + "description": "How strongly the original pixel is favoured. Higher keeps more of it", + "widget": "slider", + "visibleWhen": { + "method": [ + "stpresso" + ] + } + } + }, + "stpressoTthr": { + "type": "integer", + "default": 12, + "min": 1, + "max": 64, + "step": 1, + "ui": { + "label": "Temporal Threshold", + "description": "Passed to the FluxSmooth it runs internally", + "widget": "slider", + "visibleWhen": { + "method": [ + "stpresso" + ] + } + } + }, + "ctmfRadius": { + "type": "integer", + "default": 2, + "min": 1, + "max": 12, + "step": 1, + "ui": { + "label": "Radius", + "description": "Half the window size. Wider removes bigger blemishes and more detail with them \u2014 and costs almost nothing extra", + "widget": "slider", + "visibleWhen": { + "method": [ + "ctmf" + ] + } + } + }, + "ctmfPlanes": { + "type": "enum", + "default": "2", + "options": [ + "0", + "1", + "2" + ], + "ui": { + "label": "Apply To", + "description": "Brightness only, colour only, or both", + "widget": "dropdown", + "visibleWhen": { + "method": [ + "ctmf" + ] + } + } + }, + "contraSharpen": { + "type": "boolean", + "default": false, + "ui": { + "label": "Restore detail after denoising", + "description": "Adds back the fine detail the denoiser removed. Unlike a sharpener it cannot invent detail that was not there, so it is safe to leave on when denoising hard.", + "widget": "checkbox" + } + }, + "contraSharpenRep": { + "type": "integer", + "default": 13, + "min": 0, + "max": 24, + "step": 1, + "ui": { + "label": "Detail restore mode", + "description": "Repair mode used when restoring detail. The default suits almost everything.", + "widget": "slider" + } + }, + "mcleanStrength": { + "type": "integer", + "default": 20, + "min": 0, + "max": 20, + "step": 1, + "visibleWhen": { + "method": [ + "mclean" + ] + }, + "ui": { + "label": "Strength", + "description": "How hard to denoise.", + "widget": "slider" + } + }, + "mcleanSharp": { + "type": "integer", + "default": 10, + "min": 0, + "max": 20, + "step": 1, + "visibleWhen": { + "method": [ + "mclean" + ] + }, + "ui": { + "label": "Detail restore", + "description": "How much fine detail to put back afterwards.", + "widget": "slider" + } + }, + "mcleanRn": { + "type": "integer", + "default": 14, + "min": 0, + "max": 20, + "step": 1, + "visibleWhen": { + "method": [ + "mclean" + ] + }, + "ui": { + "label": "Grain restore", + "description": "How much grain to put back, so the result does not look plastic.", + "widget": "slider" + } + }, + "mcleanThsad": { + "type": "integer", + "default": 400, + "min": 50, + "max": 1200, + "step": 50, + "visibleWhen": { + "method": [ + "mclean" + ] + }, + "ui": { + "label": "Motion threshold", + "description": "How much movement is tolerated when matching frames.", + "widget": "slider" + } + }, + "mcleanChroma": { + "type": "boolean", + "default": true, + "visibleWhen": { + "method": [ + "mclean" + ] + }, + "ui": { + "label": "Denoise colour too", + "description": "Leave on unless colour is already clean.", + "widget": "checkbox" + } + }, + "td2DegrainTr": { + "type": "integer", + "default": 1, + "min": 1, + "max": 3, + "step": 1, + "visibleWhen": { + "method": [ + "temporal_degrain2" + ] + }, + "ui": { + "label": "Temporal radius", + "description": "How many frames either side are used. Higher is stronger and much slower.", + "widget": "slider" + } + }, + "td2GrainLevel": { + "type": "integer", + "default": 2, + "min": -2, + "max": 3, + "step": 1, + "visibleWhen": { + "method": [ + "temporal_degrain2" + ] + }, + "ui": { + "label": "Source noise level", + "description": "How noisy the source is. Higher tunes everything for a dirtier picture.", + "widget": "slider" + } + }, + "td2PostFft": { + "type": "integer", + "default": 0, + "min": 0, + "max": 3, + "step": 1, + "visibleWhen": { + "method": [ + "temporal_degrain2" + ] + }, + "ui": { + "label": "Extra cleanup", + "description": "A second frequency-domain pass on top. 0 is off.", + "widget": "slider" + } + }, + "td2PostSigma": { + "type": "number", + "default": 1.0, + "min": 0.0, + "max": 16.0, + "step": 0.5, + "visibleWhen": { + "method": [ + "temporal_degrain2" + ] + }, + "ui": { + "label": "Cleanup strength", + "description": "How hard the extra pass works.", + "widget": "slider", + "precision": 1 + } + }, + "td2PostMix": { + "type": "integer", + "default": 0, + "min": 0, + "max": 100, + "step": 5, + "visibleWhen": { + "method": [ + "temporal_degrain2" + ] + }, + "ui": { + "label": "Blend back", + "description": "Mix some of the un-cleaned picture back in, to keep texture.", + "widget": "slider" + } + }, + "td2ChromaMotion": { + "type": "boolean", + "default": true, + "visibleWhen": { + "method": [ + "temporal_degrain2" + ] + }, + "ui": { + "label": "Use colour for motion", + "description": "Usually helps on analogue captures.", + "widget": "checkbox" + } } }, "ui": { @@ -469,6 +1087,120 @@ "mcdsPlane" ], "expanded": true + }, + { + "title": "DFTTest", + "parameters": [ + "dfttestSigma", + "dfttestTbsize", + "dfttestSbsize" + ], + "expanded": true + }, + { + "title": "FFT3DFilter", + "parameters": [ + "fft3dSigma", + "fft3dBt", + "fft3dSharpen" + ], + "expanded": true + }, + { + "title": "TTempSmooth", + "parameters": [ + "ttempMaxr", + "ttempThresh", + "ttempMdiff", + "ttempStrength" + ], + "expanded": true + }, + { + "title": "FluxSmooth", + "parameters": [ + "fluxTemporalThreshold", + "fluxSpatialThreshold" + ], + "expanded": true + }, + { + "title": "STPresso", + "parameters": [ + "stpressoLimit", + "stpressoBias", + "stpressoTthr" + ], + "expanded": true + }, + { + "title": "CTMF", + "parameters": [ + "ctmfRadius", + "ctmfPlanes" + ], + "expanded": true + }, + { + "title": "Detail restore", + "parameters": [ + "contraSharpen" + ], + "expanded": true + }, + { + "title": "Detail restore tuning", + "parameters": [ + "contraSharpenRep" + ], + "expanded": false, + "advancedOnly": true + }, + { + "title": "mClean", + "parameters": [ + "mcleanStrength", + "mcleanSharp", + "mcleanRn", + "mcleanChroma" + ], + "expanded": true, + "visibleWhen": { + "method": [ + "mclean" + ] + } + }, + { + "title": "mClean tuning", + "parameters": [ + "mcleanThsad" + ], + "expanded": false, + "advancedOnly": true, + "visibleWhen": { + "method": [ + "mclean" + ] + } + }, + { + "title": "TemporalDegrain2", + "parameters": [ + "td2DegrainTr", + "td2GrainLevel", + "td2PostFft", + "td2PostSigma", + "td2PostMix", + "td2ChromaMotion" + ], + "expanded": true, + "advancedOnly": true, + "visibleWhen": { + "method": [ + "temporal_degrain2" + ] + } } ] }, diff --git a/app/assets/filters/core/sharpen.json b/app/assets/filters/core/sharpen.json index 1bbd9b69..0eca527e 100644 --- a/app/assets/filters/core/sharpen.json +++ b/app/assets/filters/core/sharpen.json @@ -8,40 +8,68 @@ "category": "enhancement", "icon": "auto_fix_high", "order": 6, - "dependencies": { - "plugins": ["havsfunc"], - "vs_plugins": ["CAS.dll"] + "plugins": [ + "havsfunc" + ], + "vs_plugins": [ + "CAS.dll" + ] }, - "methods": [ { "id": "lsfmod", "name": "LSFmod", - "description": "LimitedSharpenFaster - quality sharpening with overshoot protection", + "description": "Best first choice. Limits overshoot, so it sharpens without adding halos. Moderate speed", "function": "haf.LSFmod", - "parameters": ["strength", "overshoot", "undershoot", "softEdge"] + "parameters": [ + "strength", + "overshoot", + "undershoot", + "softEdge" + ] }, { "id": "cas", "name": "CAS", - "description": "Contrast Adaptive Sharpening - fast GPU-optimized sharpening", + "description": "Very fast, one control, subtle by design. Good when LSFmod is more than the picture needs", "function": "core.cas.CAS", - "parameters": ["casSharpness"] + "parameters": [ + "casSharpness" + ] + }, + { + "id": "awarpsharp2", + "name": "aWarpSharp2", + "description": "Sharpens by warping pixels toward edges instead of raising contrast, so it adds no halos at all. A distinctly different look — very effective on soft or upscaled material", + "function": "core.warp.AWarpSharp2", + "parameters": [ + "warpDepth", + "warpThresh", + "warpBlur", + "warpType" + ] } ], - "parameters": { "enabled": { "type": "boolean", "default": false, - "ui": { "hidden": true } + "ui": { + "hidden": true + } }, "method": { "type": "enum", "default": "lsfmod", - "options": ["lsfmod", "cas"], - "ui": { "hidden": true } + "options": [ + "lsfmod", + "cas", + "awarpsharp2" + ], + "ui": { + "hidden": true + } }, "strength": { "type": "integer", @@ -50,12 +78,18 @@ "max": 200, "step": 5, "optional": true, - "vapoursynth": { "name": "strength" }, + "vapoursynth": { + "name": "strength" + }, "ui": { "label": "Strength", "description": "Sharpening strength (50-100 for VHS)", "widget": "slider", - "visibleWhen": { "method": ["lsfmod"] } + "visibleWhen": { + "method": [ + "lsfmod" + ] + } } }, "overshoot": { @@ -65,12 +99,18 @@ "max": 255, "step": 1, "optional": true, - "vapoursynth": { "name": "Overshoot" }, + "vapoursynth": { + "name": "Overshoot" + }, "ui": { "label": "Overshoot", "description": "Maximum overshoot allowed", "widget": "slider", - "visibleWhen": { "method": ["lsfmod"] } + "visibleWhen": { + "method": [ + "lsfmod" + ] + } } }, "undershoot": { @@ -80,12 +120,18 @@ "max": 255, "step": 1, "optional": true, - "vapoursynth": { "name": "Undershoot" }, + "vapoursynth": { + "name": "Undershoot" + }, "ui": { "label": "Undershoot", "description": "Maximum undershoot allowed", "widget": "slider", - "visibleWhen": { "method": ["lsfmod"] } + "visibleWhen": { + "method": [ + "lsfmod" + ] + } } }, "softEdge": { @@ -95,12 +141,18 @@ "max": 100, "step": 5, "optional": true, - "vapoursynth": { "name": "soft" }, + "vapoursynth": { + "name": "soft" + }, "ui": { "label": "Soft Edge", "description": "Soft edge protection percentage", "widget": "slider", - "visibleWhen": { "method": ["lsfmod"] } + "visibleWhen": { + "method": [ + "lsfmod" + ] + } } }, "casSharpness": { @@ -110,29 +162,120 @@ "max": 1.0, "step": 0.05, "optional": true, - "vapoursynth": { "name": "sharpness" }, + "vapoursynth": { + "name": "sharpness" + }, "ui": { "label": "CAS Sharpness", "description": "CAS sharpening strength (0.3-0.5 for VHS)", "widget": "slider", "precision": 2, - "visibleWhen": { "method": ["cas"] } + "visibleWhen": { + "method": [ + "cas" + ] + } + } + }, + "warpDepth": { + "type": "integer", + "default": 16, + "min": 0, + "max": 128, + "step": 1, + "ui": { + "label": "Depth", + "description": "How far pixels may be warped toward edges. The main strength control", + "widget": "slider", + "visibleWhen": { + "method": [ + "awarpsharp2" + ] + } + } + }, + "warpThresh": { + "type": "integer", + "default": 128, + "min": 0, + "max": 255, + "step": 8, + "ui": { + "label": "Edge Threshold", + "description": "Lower finds more edges to warp toward", + "widget": "slider", + "visibleWhen": { + "method": [ + "awarpsharp2" + ] + } + } + }, + "warpBlur": { + "type": "integer", + "default": 2, + "min": 0, + "max": 3, + "step": 1, + "ui": { + "label": "Mask Blur", + "description": "More blur warps more smoothly and less precisely", + "widget": "slider", + "visibleWhen": { + "method": [ + "awarpsharp2" + ] + } + } + }, + "warpType": { + "type": "enum", + "default": "0", + "options": [ + "0", + "1" + ], + "ui": { + "label": "Blur Kernel", + "description": "0 = radius 6 box per pass (smoother), 1 = radius 2 box (tighter)", + "widget": "dropdown", + "visibleWhen": { + "method": [ + "awarpsharp2" + ] + } } } }, - "ui": { "sections": [ { "title": "Settings", - "parameters": ["strength", "overshoot", "undershoot", "softEdge", "casSharpness"], + "parameters": [ + "strength", + "overshoot", + "undershoot", + "softEdge", + "casSharpness" + ], + "expanded": true + }, + { + "title": "aWarpSharp2", + "parameters": [ + "warpDepth", + "warpThresh", + "warpBlur", + "warpType" + ], "expanded": true } ] }, - "codeTemplate": { - "imports": ["import havsfunc as haf"], + "imports": [ + "import havsfunc as haf" + ], "generate": "method" } } diff --git a/app/assets/filters/core/spotless.json b/app/assets/filters/core/spotless.json index 33d76adb..63dadd38 100644 --- a/app/assets/filters/core/spotless.json +++ b/app/assets/filters/core/spotless.json @@ -4,30 +4,51 @@ "version": "1.0.0", "name": "SpotLess", "description": "Remove dust, dirt, and temporal spots from film", - "longDescription": "Removes single-frame blemishes: dust specks, hairs and emulsion flecks that flash up for one frame and vanish. It compares each frame against its neighbours and replaces anything that is present in only one of them.\n\nUse it on scanned or telecined film, after deinterlacing. Because the test is temporal, fast or erratic motion can be read as a spot — if you see smearing or ghosting on movement, ease off the strength.", + "longDescription": "Removes single-frame blemishes: dust specks, hairs and emulsion flecks that flash up for one frame and vanish. It compares each frame against its neighbours and replaces anything that is present in only one of them.\n\nUse it on scanned or telecined film, after deinterlacing. Because the test is temporal, fast or erratic motion can be read as a spot \u2014 if you see smearing or ghosting on movement, ease off the strength.", "category": "cleanup", "icon": "auto_fix_high", "order": 3, - "dependencies": { - "vs_plugins": ["libtemporalmedian.dll", "libmvtools.dll"] + "vs_plugins": [ + "libtemporalmedian.dll", + "libmvtools.dll" + ] }, - "methods": [ { "id": "spotless", "name": "SpotLess", "description": "Motion-compensated temporal median for spot/dirt removal (live-action only)", "function": "custom", - "parameters": ["chroma", "rec", "blksize", "overlap", "pel"] + "parameters": [ + "chroma", + "rec", + "blksize", + "overlap", + "pel" + ] + }, + { + "id": "removeDirt", + "name": "RemoveDirt (fast)", + "description": "About six times faster for most of the benefit \u2014 the one to use on a long capture", + "function": "removedirt.RestoreMotionBlocks", + "parameters": [ + "rdNoise", + "rdNoisy", + "rdGmthreshold", + "rdDist", + "rdPostDenoise" + ] } ], - "parameters": { "enabled": { "type": "boolean", "default": false, - "ui": { "hidden": true } + "ui": { + "hidden": true + } }, "chroma": { "type": "boolean", @@ -78,33 +99,186 @@ "pel": { "type": "enum", "default": "2", - "options": ["1", "2", "4"], + "options": [ + "1", + "2", + "4" + ], "optional": true, "ui": { "label": "Sub-pixel Accuracy", "description": "Motion estimation precision (1=pixel, 2=half-pixel, 4=quarter-pixel)", "widget": "dropdown", - "optionLabels": { "1": "1 (pixel)", "2": "2 (half-pixel)", "4": "4 (quarter-pixel)" }, + "optionLabels": { + "1": "1 (pixel)", + "2": "2 (half-pixel)", + "4": "4 (quarter-pixel)" + }, "advanced": true } + }, + "method": { + "type": "enum", + "default": "spotless", + "options": [ + "spotless", + "removeDirt" + ], + "ui": { + "label": "Method", + "description": "SpotLess is more thorough. RemoveDirt is far quicker and nearly as good.", + "widget": "dropdown" + } + }, + "rdNoise": { + "type": "integer", + "default": 50, + "min": 0, + "max": 255, + "step": 5, + "visibleWhen": { + "method": [ + "removeDirt" + ] + }, + "ui": { + "label": "Spot size", + "description": "How large a difference from its neighbours counts as dirt.", + "widget": "slider" + } + }, + "rdNoisy": { + "type": "integer", + "default": 12, + "min": 0, + "max": 64, + "step": 1, + "visibleWhen": { + "method": [ + "removeDirt" + ] + }, + "ui": { + "label": "Spot density", + "description": "How many neighbouring pixels must agree before it is treated as damage.", + "widget": "slider" + } + }, + "rdGmthreshold": { + "type": "integer", + "default": 70, + "min": 0, + "max": 255, + "step": 5, + "visibleWhen": { + "method": [ + "removeDirt" + ] + }, + "ui": { + "label": "Motion tolerance", + "description": "How much of the frame may be moving before it is treated as motion rather than damage.", + "widget": "slider" + } + }, + "rdDist": { + "type": "integer", + "default": 1, + "min": 0, + "max": 8, + "step": 1, + "visibleWhen": { + "method": [ + "removeDirt" + ] + }, + "ui": { + "label": "Spread", + "description": "How far around a detected spot to repair.", + "widget": "slider" + } + }, + "rdPostDenoise": { + "type": "boolean", + "default": false, + "visibleWhen": { + "method": [ + "removeDirt" + ] + }, + "ui": { + "label": "Extra smoothing pass", + "description": "The traditional final smoothing step. Off by default \u2014 measured, it alone triples the damage to clean parts of the picture.", + "widget": "checkbox" + } } }, - "ui": { "sections": [ { - "title": "Settings", - "parameters": ["chroma", "rec"], + "title": "Method", + "parameters": [ + "method" + ], "expanded": true }, + { + "title": "Settings", + "parameters": [ + "chroma", + "rec" + ], + "expanded": true, + "visibleWhen": { + "method": [ + "spotless" + ] + } + }, { "title": "Motion Analysis", - "parameters": ["blksize", "overlap", "pel"], - "expanded": false + "parameters": [ + "blksize", + "overlap", + "pel" + ], + "expanded": false, + "visibleWhen": { + "method": [ + "spotless" + ] + } + }, + { + "title": "RemoveDirt settings", + "parameters": [ + "rdNoise", + "rdNoisy", + "rdGmthreshold" + ], + "expanded": true, + "visibleWhen": { + "method": [ + "removeDirt" + ] + } + }, + { + "title": "RemoveDirt tuning", + "parameters": [ + "rdDist", + "rdPostDenoise" + ], + "expanded": false, + "advancedOnly": true, + "visibleWhen": { + "method": [ + "removeDirt" + ] + } } ] }, - "codeTemplate": { "imports": [], "generate": "custom" diff --git a/app/assets/filters/core/stabilize.json b/app/assets/filters/core/stabilize.json new file mode 100644 index 00000000..1be89bae --- /dev/null +++ b/app/assets/filters/core/stabilize.json @@ -0,0 +1,109 @@ +{ + "$schema": "https://vapourbox.app/schemas/filter-v1.json", + "id": "stabilize", + "version": "1.0.0", + "name": "Stabilize", + "description": "Remove shake and weave from the picture", + "longDescription": "Measures how much the whole frame moves between shots and cancels it, so a shaky capture holds still. Deliberate camera movement survives: only the difference from the recent trend is removed.\n\nUse it on telecine weave, film scans that wobble in the gate, and handheld camcorder footage. It runs last before cropping, because it shifts the picture within the frame and leaves thin empty edges — a small crop afterwards removes them.\n\nThe shift leaves thin empty edges; set Fill Exposed Edges to mirror them back in, or crop them away afterwards.", + "category": "cleanup", + "icon": "stay_current_landscape", + "order": 11, + "dependencies": { + "plugins": [ + "havsfunc" + ], + "vs_plugins": [ + "libmvtools.dll" + ] + }, + "methods": [ + { + "id": "stab", + "name": "Stab", + "description": "Motion-analysis stabilisation via MVTools. Cancels shake while leaving intended camera movement alone", + "function": "haf.Stab", + "parameters": [ + "dxmax", + "dymax", + "mirror" + ] + } + ], + "parameters": { + "enabled": { + "type": "boolean", + "default": false, + "ui": { + "hidden": true + } + }, + "method": { + "type": "enum", + "default": "stab", + "options": [ + "stab" + ], + "ui": { + "hidden": true + } + }, + "dxmax": { + "type": "integer", + "default": 4, + "min": 0, + "max": 32, + "step": 1, + "ui": { + "label": "Max Horizontal Shift", + "description": "Largest sideways correction, in pixels. 0 leaves the horizontal axis alone", + "widget": "slider" + } + }, + "dymax": { + "type": "integer", + "default": 4, + "min": 0, + "max": 32, + "step": 1, + "ui": { + "label": "Max Vertical Shift", + "description": "Largest up/down correction, in pixels. 0 leaves the vertical axis alone", + "widget": "slider" + } + }, + "mirror": { + "type": "enum", + "default": "0", + "options": [ + "0", + "1", + "2", + "3" + ], + "ui": { + "label": "Fill Exposed Edges", + "description": "Shifting the picture leaves thin empty edges. 0 leaves them black, 1 fills top and bottom, 2 left and right, 3 all four — which saves cropping afterwards", + "widget": "dropdown" + } + } + }, + "ui": { + "sections": [ + { + "title": "Settings", + "parameters": [ + "dxmax", + "dymax", + "mirror" + ], + "expanded": true + } + ] + }, + "codeTemplate": { + "imports": [ + "import havsfunc as haf" + ], + "generate": "method" + } +} diff --git a/app/assets/filters/core/subtitles.json b/app/assets/filters/core/subtitles.json index 07e53a87..e6fbbc6a 100644 --- a/app/assets/filters/core/subtitles.json +++ b/app/assets/filters/core/subtitles.json @@ -4,7 +4,7 @@ "version": "1.0.0", "name": "Subtitles", "description": "Generate subtitles from speech using Whisper AI", - "longDescription": "Transcribes the spoken audio with the Whisper speech-recognition model and writes it out as a subtitle track alongside the video.\n\nUse it to caption footage that has no subtitles of its own — home video, interviews, lectures. Larger models are more accurate but considerably slower, and accuracy falls away with heavy background noise or overlapping speakers. This pass runs after the encode and never alters the picture.", + "longDescription": "Transcribes the spoken audio with the Whisper speech-recognition model and writes it out as a subtitle track alongside the video.\n\nUse it to caption footage that has no subtitles of its own \u2014 home video, interviews, lectures. Larger models are more accurate but considerably slower, and accuracy falls away with heavy background noise or overlapping speakers. This pass runs after the encode and never alters the picture.", "category": "enhancement", "icon": "subtitles", "order": 100, @@ -14,25 +14,39 @@ "name": "Whisper", "description": "OpenAI Whisper speech-to-text", "function": "whisper", - "parameters": ["model", "output", "language"] + "parameters": [ + "model", + "output", + "language" + ] } ], "parameters": { "enabled": { "type": "boolean", "default": false, - "ui": { "hidden": true } + "ui": { + "hidden": true + } }, "method": { "type": "enum", "default": "whisper", - "options": ["whisper"], - "ui": { "hidden": true } + "options": [ + "whisper" + ], + "ui": { + "hidden": true + } }, "model": { "type": "enum", "default": "medium", - "options": ["small", "medium", "large-v3-turbo"], + "options": [ + "small", + "medium", + "large-v3-turbo" + ], "ui": { "label": "Model", "description": "Small (466 MB), Medium (1.5 GB), High (1.6 GB). Larger = more accurate but slower", @@ -42,7 +56,13 @@ "output": { "type": "enum", "default": "srt_file", - "options": ["srt_file", "embed", "both"], + "options": [ + "srt_file", + "embed", + "both", + "burn_in", + "burn_in_and_srt" + ], "ui": { "label": "Output", "description": "SRT writes alongside video; Embed muxes into container (MKV/MP4)", @@ -52,19 +72,55 @@ "language": { "type": "enum", "default": "auto", - "options": ["auto", "en", "es", "fr", "de", "it", "pt", "ja", "zh", "ko"], + "options": [ + "auto", + "en", + "es", + "fr", + "de", + "it", + "pt", + "ja", + "zh", + "ko" + ], "ui": { "label": "Language", "description": "Hint for speech recognition language", "widget": "dropdown" } + }, + "burnInPath": { + "type": "string", + "default": "", + "visibleWhen": { + "output": [ + "burn_in", + "burn_in_and_srt" + ] + }, + "ui": { + "label": "Subtitle file to burn in", + "description": "Path to a subtitle file you already have. Leave this empty to burn in what Whisper transcribes \u2014 transcription runs before the encode, so its subtitles can be drawn into the picture. Setting a file here skips transcription entirely and burns in this file instead.", + "widget": "filepicker", + "fileExtensions": [ + "srt", + "ass", + "ssa" + ] + } } }, "ui": { "sections": [ { "title": "Whisper Settings", - "parameters": ["model", "output", "language"], + "parameters": [ + "model", + "output", + "burnInPath", + "language" + ], "expanded": true } ] diff --git a/app/assets/filters/manifest.json b/app/assets/filters/manifest.json index 1d2fc401..c37d0a80 100644 --- a/app/assets/filters/manifest.json +++ b/app/assets/filters/manifest.json @@ -1 +1,23 @@ -["deinterlace.json", "descratch.json", "spotless.json", "noise_reduction.json", "chroma_denoise.json", "deblock.json", "dehalo.json", "deband.json", "sharpen.json", "color_correction.json", "chroma_fixes.json", "crop_resize.json", "subtitles.json"] +[ + "deinterlace.json", + "descratch.json", + "spotless.json", + "noise_reduction.json", + "chroma_denoise.json", + "deblock.json", + "dehalo.json", + "deband.json", + "sharpen.json", + "color_correction.json", + "chroma_fixes.json", + "crop_resize.json", + "subtitles.json", + "anti_alias.json", + "stabilize.json", + "geometry.json", + "grain.json", + "frame_rate.json", + "deflicker.json", + "edge_repair.json", + "ghost_removal.json" +] diff --git a/app/lib/main.dart b/app/lib/main.dart index dffb5eb8..8dc5ffd5 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -4,6 +4,7 @@ import 'package:rhttp/rhttp.dart'; import 'package:window_manager/window_manager.dart'; import 'models/filter_registry.dart'; +import 'services/advanced_mode_service.dart'; import 'services/dependency_manager.dart'; import 'services/hardware_encoder_detector.dart'; import 'services/preset_service.dart'; @@ -25,6 +26,10 @@ void main() async { // (the dependency download on first run is the earliest of them). await TempDirectoryService.instance.initialize(); + // Load the advanced-mode choice before the first panel builds, so the filter + // settings don't flash from simple to advanced on startup. + await AdvancedModeService.instance.initialize(); + // Initialize window manager for desktop await windowManager.ensureInitialized(); @@ -54,6 +59,21 @@ class VapourBoxApp extends StatelessWidget { @override Widget build(BuildContext context) { + // Above the MaterialApp on purpose, not inside `home`. `showDialog` pushes + // onto the MaterialApp's Navigator, so a provider placed below it is out of + // scope for every dialog route — which is how the Settings dialog's + // advanced-mode switch first came out as a "Could not find the correct + // Provider" error box. It's a dependency-free singleton, so there is no + // reason to scope it any lower; anything scoped to the main window (i.e. + // MainViewModel) still has to be re-provided per dialog, as + // MainWindow._showSettings does. + return ChangeNotifierProvider.value( + value: AdvancedModeService.instance, + child: _buildApp(), + ); + } + + Widget _buildApp() { return MaterialApp( title: 'VapourBox', debugShowCheckedModeBanner: false, @@ -220,6 +240,8 @@ class _AppStartupWrapperState extends State { .addPostFrameCallback((_) => _showDepsWarningAsync()); } + // AdvancedModeService is provided above the MaterialApp, so it is in scope + // here and in every dialog route without being repeated. return ChangeNotifierProvider( create: (_) => MainViewModel(), child: const MainWindow(), diff --git a/app/lib/models/anti_alias_parameters.dart b/app/lib/models/anti_alias_parameters.dart new file mode 100644 index 00000000..26977c7d --- /dev/null +++ b/app/lib/models/anti_alias_parameters.dart @@ -0,0 +1,80 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'anti_alias_parameters.g.dart'; + +/// Anti-aliasing method options. +/// +/// The JsonValues are the wire format to the worker and must match serde's +/// camelCase spelling of the Rust variants exactly; a mismatch falls back to the +/// default rather than erroring. +enum AntiAliasMethod { + @JsonValue('daa') + daa('daa', 'daa'), + @JsonValue('santiag') + santiag('santiag', 'santiag'); + + const AntiAliasMethod(this.value, this.displayName); + final String value; + final String displayName; +} + +/// Parameters for the anti-aliasing pass. +/// +/// Removes stair-stepping on diagonal edges, which deinterlacing and upscaling +/// both create. Runs before sharpening, because sharpening stair-stepped edges +/// makes the stepping more visible rather than less. +@JsonSerializable() +class AntiAliasParameters { + /// Whether this pass is enabled. + final bool enabled; + + /// Which method to use. + final AntiAliasMethod method; + + /// Vertical strength for santiag (0 disables the vertical pass). + final int santiagStrv; + + /// 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. + final String santiagType; + + const AntiAliasParameters({ + this.enabled = false, + this.method = AntiAliasMethod.daa, + this.santiagStrv = 1, + this.santiagStrh = 1, + this.santiagType = 'nnedi3', + }); + + AntiAliasParameters copyWith({ + bool? enabled, + AntiAliasMethod? method, + int? santiagStrv, + int? santiagStrh, + String? santiagType, + }) => + AntiAliasParameters( + enabled: enabled ?? this.enabled, + method: method ?? this.method, + santiagStrv: santiagStrv ?? this.santiagStrv, + santiagStrh: santiagStrh ?? this.santiagStrh, + santiagType: santiagType ?? this.santiagType, + ); + + /// Short summary for the pass list row. + String get summary { + if (!enabled) return 'Off'; + if (method == AntiAliasMethod.santiag) { + return 'santiag (H$santiagStrh / V$santiagStrv)'; + } + return method.displayName; + } + + factory AntiAliasParameters.fromJson(Map json) => + _$AntiAliasParametersFromJson(json); + Map toJson() => _$AntiAliasParametersToJson(this); +} diff --git a/app/lib/models/chroma_denoise_parameters.dart b/app/lib/models/chroma_denoise_parameters.dart index fbbbba27..47b489e0 100644 --- a/app/lib/models/chroma_denoise_parameters.dart +++ b/app/lib/models/chroma_denoise_parameters.dart @@ -2,6 +2,21 @@ import 'package:json_annotation/json_annotation.dart'; part 'chroma_denoise_parameters.g.dart'; +/// Which chroma denoiser to run. +/// +/// The two are complementary rather than alternatives: CCD smooths blotches +/// that sit still, Cnr4 settles colour that swims between frames. Measured on +/// the bundled plugin, neither reaches what the other does. +enum ChromaDenoiseMethod { + @JsonValue('ccd') + ccd('CCD'), + @JsonValue('cnr4') + cnr4('Cnr4'); + + const ChromaDenoiseMethod(this.displayName); + final String displayName; +} + /// Frame height CCD was designed for. Its automatic `scale` is derived from the /// source height relative to this, and the plugin rejects a scale below 1.0 — /// so a shorter source needs an explicit clamped scale or the job fails. @@ -18,6 +33,9 @@ class ChromaDenoiseParameters { /// Whether this pass is enabled. final bool enabled; + /// Which denoiser to run. + final ChromaDenoiseMethod method; + /// Euclidean RGB distance below which a neighbouring pixel joins the average. /// Higher denoises more. final double threshold; @@ -38,14 +56,37 @@ class ChromaDenoiseParameters { /// rule the plugin uses, but clamped so short sources still run. final double? scale; + // ---- Cnr4 ---------------------------------------------------------------- + /// How far chroma is pulled toward the temporal average. The plugin's own + /// default is near the top of the range, so there is more room down than up. + final int cnr4Strength; + + /// Movement tolerated before the filter stops correcting. + final int cnr4Sense; + + /// Temporal radius, 1-8. + final int cnr4Radius; + + /// Detail-retention mode, 0-3. + final int cnr4Tmode; + + /// Weighting mode, 0-2. + final int cnr4Wmode; + const ChromaDenoiseParameters({ this.enabled = false, + this.method = ChromaDenoiseMethod.ccd, this.threshold = 4.0, this.temporalRadius = 0, this.pointsLow = true, this.pointsMedium = true, this.pointsHigh = false, this.scale, + this.cnr4Strength = 192, + this.cnr4Sense = 35, + this.cnr4Radius = 2, + this.cnr4Tmode = 0, + this.cnr4Wmode = 0, }); ChromaDenoiseParameters copyWith({ @@ -56,21 +97,36 @@ class ChromaDenoiseParameters { bool? pointsMedium, bool? pointsHigh, double? scale, + ChromaDenoiseMethod? method, + int? cnr4Strength, + int? cnr4Sense, + int? cnr4Radius, + int? cnr4Tmode, + int? cnr4Wmode, }) { return ChromaDenoiseParameters( enabled: enabled ?? this.enabled, + method: method ?? this.method, threshold: threshold ?? this.threshold, temporalRadius: temporalRadius ?? this.temporalRadius, pointsLow: pointsLow ?? this.pointsLow, pointsMedium: pointsMedium ?? this.pointsMedium, pointsHigh: pointsHigh ?? this.pointsHigh, scale: scale ?? this.scale, + cnr4Strength: cnr4Strength ?? this.cnr4Strength, + cnr4Sense: cnr4Sense ?? this.cnr4Sense, + cnr4Radius: cnr4Radius ?? this.cnr4Radius, + cnr4Tmode: cnr4Tmode ?? this.cnr4Tmode, + cnr4Wmode: cnr4Wmode ?? this.cnr4Wmode, ); } /// Get a summary string for display. String get summary { if (!enabled) return 'Off'; + if (method == ChromaDenoiseMethod.cnr4) { + return 'Cnr4 $cnr4Strength · r$cnr4Radius'; + } final parts = ['CCD ${threshold.toStringAsFixed(1)}']; if (temporalRadius > 0) parts.add('TR:$temporalRadius'); return parts.join(' '); diff --git a/app/lib/models/chroma_fix_parameters.dart b/app/lib/models/chroma_fix_parameters.dart index 197e12e2..e5cb64a8 100644 --- a/app/lib/models/chroma_fix_parameters.dart +++ b/app/lib/models/chroma_fix_parameters.dart @@ -68,6 +68,55 @@ class ChromaFixParameters { /// Maximum difference allowed. final int deCrawlMaxDiff; + // --- LUTDeRainbow Parameters --- + + /// Whether to apply LUTDeRainbow (cross-luminance / rainbowing removal). + /// + /// Same 8-10 bit limit as LUTDeCrawl — havsfunc rejects anything above 10-bit + /// outright, so the worker runs the pass at 10-bit and restores the source + /// format afterwards. + final bool applyDeRainbow; + + /// DeDot — temporal dot crawl / rainbow removal on both planes. + final bool applyAutoChroma; + final int autoChromaMaxShift; + final double autoChromaAccuracy; + final int autoChromaReferenceFrame; + + final bool applyDedot; + final int dedotLuma2d; + final int dedotLumaT; + final int dedotChromaT1; + final int dedotChromaT2; + + /// Chroma difference threshold for detecting rainbowing. + final int deRainbowCThresh; + + /// Luma difference threshold. Areas moving more than this are left alone. + final int deRainbowYThresh; + + /// Use the luma difference in the decision as well as chroma. + final bool deRainbowUseLuma; + + /// Require both chroma planes to agree before treating a pixel. + final bool deRainbowLinkUv; + + // --- Bifrost (temporal rainbow removal) --- + + /// Apply Bifrost. Where LUTDeRainbow decides within a frame, this compares + /// across frames, so it catches rainbowing that shimmers rather than sits + /// still. 8-bit only — the worker converts down and restores. + final bool applyBifrost; + + /// Luma difference above which a block is treated as motion and left alone. + final double bifrostLumaThresh; + + /// How many neighbouring blocks must agree before a pixel is treated. + final int bifrostVariation; + + /// Treat the source as interlaced, comparing fields rather than frames. + final bool bifrostInterlaced; + // --- Vinverse Parameters --- /// Whether to apply Vinverse (inverted telecine/chroma fix). @@ -98,6 +147,24 @@ class ChromaFixParameters { this.deCrawlCThresh = 10, this.deCrawlMaxDiff = 50, // Vinverse defaults + this.applyDeRainbow = false, + this.applyAutoChroma = false, + this.autoChromaMaxShift = 2, + this.autoChromaAccuracy = 0.25, + this.autoChromaReferenceFrame = 0, + this.applyDedot = false, + this.dedotLuma2d = 20, + this.dedotLumaT = 20, + this.dedotChromaT1 = 15, + this.dedotChromaT2 = 5, + this.deRainbowCThresh = 10, + this.deRainbowYThresh = 10, + this.deRainbowUseLuma = true, + this.deRainbowLinkUv = true, + this.applyBifrost = false, + this.bifrostLumaThresh = 10.0, + this.bifrostVariation = 5, + this.bifrostInterlaced = true, this.applyVinverse = false, this.vinverseSstr = 2.7, this.vinverseAmnt = 255, @@ -162,6 +229,15 @@ class ChromaFixParameters { int? deCrawlYThresh, int? deCrawlCThresh, int? deCrawlMaxDiff, + bool? applyDeRainbow, + int? deRainbowCThresh, + int? deRainbowYThresh, + bool? deRainbowUseLuma, + bool? deRainbowLinkUv, + bool? applyBifrost, + double? bifrostLumaThresh, + int? bifrostVariation, + bool? bifrostInterlaced, bool? applyVinverse, double? vinverseSstr, int? vinverseAmnt, @@ -181,6 +257,15 @@ class ChromaFixParameters { deCrawlYThresh: deCrawlYThresh ?? this.deCrawlYThresh, deCrawlCThresh: deCrawlCThresh ?? this.deCrawlCThresh, deCrawlMaxDiff: deCrawlMaxDiff ?? this.deCrawlMaxDiff, + applyDeRainbow: applyDeRainbow ?? this.applyDeRainbow, + deRainbowCThresh: deRainbowCThresh ?? this.deRainbowCThresh, + deRainbowYThresh: deRainbowYThresh ?? this.deRainbowYThresh, + deRainbowUseLuma: deRainbowUseLuma ?? this.deRainbowUseLuma, + deRainbowLinkUv: deRainbowLinkUv ?? this.deRainbowLinkUv, + applyBifrost: applyBifrost ?? this.applyBifrost, + bifrostLumaThresh: bifrostLumaThresh ?? this.bifrostLumaThresh, + bifrostVariation: bifrostVariation ?? this.bifrostVariation, + bifrostInterlaced: bifrostInterlaced ?? this.bifrostInterlaced, applyVinverse: applyVinverse ?? this.applyVinverse, vinverseSstr: vinverseSstr ?? this.vinverseSstr, vinverseAmnt: vinverseAmnt ?? this.vinverseAmnt, diff --git a/app/lib/models/color_correction_parameters.dart b/app/lib/models/color_correction_parameters.dart index ffefa9a4..81d3c699 100644 --- a/app/lib/models/color_correction_parameters.dart +++ b/app/lib/models/color_correction_parameters.dart @@ -23,6 +23,20 @@ class ColorCorrectionParameters { /// Whether this pass is enabled. final bool enabled; + /// Stretch luma so the darkest and brightest parts land on the targets. + final bool applyAutoLevels; + /// Target black point, 8-bit units (scaled to the clip format in-script). + final int autoLevelsBlack; + /// Target white point, 8-bit units. + final int autoLevelsWhite; + /// 0-1 blend against the untouched picture. + final double autoLevelsStrength; + + /// Grey-world automatic white balance. + final bool applyAutoWhiteBalance; + /// 0-1 blend for the chroma shift. + final double autoWhiteBalanceStrength; + /// Preset level for simple mode. final ColorCorrectionPreset preset; @@ -48,6 +62,29 @@ class ColorCorrectionParameters { /// Whether to apply levels adjustment. final bool applyLevels; + /// Use SmoothLevels instead of plain Levels: the same curve, but dithered and + /// limited as it goes, so stretching a narrow range does not band. + /// + /// Off by default so existing presets keep the behaviour they were saved with. + final bool smoothLevels; + + /// Lift shadow detail with multi-scale retinex, on luma only. + /// + /// The plugin rejects subsampled formats, and every source this app handles + /// is one — so the luma plane is processed on its own and colour is left + /// untouched, rather than resampling chroma twice for a brightness operation. + final bool applyShadowDetail; + + /// Retinex scale in pixels. Larger lifts broad shadow areas; smaller favours + /// local texture. + final double shadowSigma; + + /// Fraction of the darkest pixels ignored when rescaling. + final double shadowLowerThr; + + /// Same at the bright end. + final double shadowUpperThr; + /// Input black level (0-255). final int inputLow; @@ -73,6 +110,12 @@ class ColorCorrectionParameters { const ColorCorrectionParameters({ this.enabled = false, + this.applyAutoLevels = false, + this.autoLevelsBlack = 16, + this.autoLevelsWhite = 235, + this.autoLevelsStrength = 1.0, + this.applyAutoWhiteBalance = false, + this.autoWhiteBalanceStrength = 1.0, this.preset = ColorCorrectionPreset.off, // Tweak defaults this.brightness = 0.0, @@ -82,6 +125,11 @@ class ColorCorrectionParameters { this.coring = false, // Levels defaults this.applyLevels = false, + this.smoothLevels = false, + this.applyShadowDetail = false, + this.shadowSigma = 100.0, + this.shadowLowerThr = 0.001, + this.shadowUpperThr = 0.001, this.inputLow = 0, this.inputHigh = 255, this.outputLow = 0, @@ -145,6 +193,11 @@ class ColorCorrectionParameters { double? saturation, bool? coring, bool? applyLevels, + bool? smoothLevels, + bool? applyShadowDetail, + double? shadowSigma, + double? shadowLowerThr, + double? shadowUpperThr, int? inputLow, int? inputHigh, int? outputLow, @@ -162,6 +215,11 @@ class ColorCorrectionParameters { saturation: saturation ?? this.saturation, coring: coring ?? this.coring, applyLevels: applyLevels ?? this.applyLevels, + smoothLevels: smoothLevels ?? this.smoothLevels, + applyShadowDetail: applyShadowDetail ?? this.applyShadowDetail, + shadowSigma: shadowSigma ?? this.shadowSigma, + shadowLowerThr: shadowLowerThr ?? this.shadowLowerThr, + shadowUpperThr: shadowUpperThr ?? this.shadowUpperThr, inputLow: inputLow ?? this.inputLow, inputHigh: inputHigh ?? this.inputHigh, outputLow: outputLow ?? this.outputLow, diff --git a/app/lib/models/deblock_parameters.dart b/app/lib/models/deblock_parameters.dart index 644d1a73..fb490f3b 100644 --- a/app/lib/models/deblock_parameters.dart +++ b/app/lib/models/deblock_parameters.dart @@ -8,7 +8,9 @@ enum DeblockMethod { @JsonValue('Deblock_QED') deblockQed('Deblock_QED', 'Deblock QED'), @JsonValue('Deblock') - deblock('Deblock', 'Deblock'); + deblock('Deblock', 'Deblock'), + @JsonValue('DCTFilter') + dctFilter('DCTFilter', 'DCTFilter'); const DeblockMethod(this.value, this.displayName); final String value; @@ -20,6 +22,9 @@ enum DeblockMethod { return 'Quality Enhanced Deblocking - good for DVDs'; case DeblockMethod.deblock: return 'Simple deblocking filter'; + case DeblockMethod.dctFilter: + return 'Attenuates high DCT frequency bands — targets ringing and ' + 'mosquito noise rather than block edges'; } } } @@ -48,6 +53,17 @@ class DeblockParameters { /// Analyze planes offset 2. final int aOffset2; + // --- DCTFilter parameters --- + + /// Lowest frequency band left untouched (0-7). Everything above is attenuated. + final int dctCutoff; + + /// How hard the bands above the cutoff are attenuated (0.0-1.0). + final double dctStrength; + + /// Planes to filter: 0 luma only, 1 chroma only, 2 both. + final int dctPlanes; + const DeblockParameters({ this.enabled = false, this.method = DeblockMethod.deblockQed, @@ -55,6 +71,9 @@ class DeblockParameters { this.quant2 = 26, this.aOffset1 = 1, this.aOffset2 = 1, + this.dctCutoff = 5, + this.dctStrength = 0.6, + this.dctPlanes = 0, }); DeblockParameters copyWith({ @@ -64,6 +83,9 @@ class DeblockParameters { int? quant2, int? aOffset1, int? aOffset2, + int? dctCutoff, + double? dctStrength, + int? dctPlanes, }) { return DeblockParameters( enabled: enabled ?? this.enabled, @@ -72,6 +94,9 @@ class DeblockParameters { quant2: quant2 ?? this.quant2, aOffset1: aOffset1 ?? this.aOffset1, aOffset2: aOffset2 ?? this.aOffset2, + dctCutoff: dctCutoff ?? this.dctCutoff, + dctStrength: dctStrength ?? this.dctStrength, + dctPlanes: dctPlanes ?? this.dctPlanes, ); } diff --git a/app/lib/models/deflicker_parameters.dart b/app/lib/models/deflicker_parameters.dart new file mode 100644 index 00000000..33b41a2e --- /dev/null +++ b/app/lib/models/deflicker_parameters.dart @@ -0,0 +1,65 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'deflicker_parameters.g.dart'; + +/// Which deflicker to run. +enum DeflickerMethod { + @JsonValue('global') + global('Whole frame'), + @JsonValue('local') + local('Across the frame'); + + const DeflickerMethod(this.displayName); + final String displayName; +} + +/// Deflicker — even out brightness pulsing between frames. +@JsonSerializable() +class DeflickerParameters { + final bool enabled; + final DeflickerMethod method; + + /// Global: how far the correction is applied, 0-1. + final double strength; + + /// Global: frames either side used for the reference average. + final int window; + + /// Local: 1-3. + final int localStrength; + + /// Local: asymmetric fold — stronger, less conservative. + final bool aggressive; + + const DeflickerParameters({ + this.enabled = false, + this.method = DeflickerMethod.global, + this.strength = 1.0, + this.window = 5, + this.localStrength = 2, + this.aggressive = false, + }); + + DeflickerParameters copyWith({ + bool? enabled, + DeflickerMethod? method, + double? strength, + int? window, + int? localStrength, + bool? aggressive, + }) => + DeflickerParameters( + enabled: enabled ?? this.enabled, + method: method ?? this.method, + strength: strength ?? this.strength, + window: window ?? this.window, + localStrength: localStrength ?? this.localStrength, + aggressive: aggressive ?? this.aggressive, + ); + + String get summary => enabled ? method.displayName : 'Off'; + + factory DeflickerParameters.fromJson(Map json) => + _$DeflickerParametersFromJson(json); + Map toJson() => _$DeflickerParametersToJson(this); +} diff --git a/app/lib/models/dehalo_parameters.dart b/app/lib/models/dehalo_parameters.dart index 8e55b4df..cdeb0af9 100644 --- a/app/lib/models/dehalo_parameters.dart +++ b/app/lib/models/dehalo_parameters.dart @@ -18,7 +18,9 @@ enum DehaloMethod { @JsonValue('Vinverse') vinverse('Vinverse', 'Vinverse (de-ghost)'), @JsonValue('Vinverse2') - vinverse2('Vinverse2', 'Vinverse 2 (de-ghost)'); + vinverse2('Vinverse2', 'Vinverse 2 (de-ghost)'), + @JsonValue('HQDeringmod') + hqDeringmod('HQDeringmod', 'HQDeringmod (de-ring)'); const DehaloMethod(this.value, this.displayName); final String value; @@ -40,6 +42,9 @@ enum DehaloMethod { return 'Removes comb/ghost residue left by deinterlacing'; case DehaloMethod.vinverse2: return 'Like Vinverse but keeps more vertical detail'; + case DehaloMethod.hqDeringmod: + return 'Masked ring removal — treats the overshoot beside an edge ' + 'while protecting the edge itself'; } } } @@ -142,6 +147,24 @@ class DehaloParameters { /// Process chroma as well as luma (havsfunc `chroma`). final bool? vinverseChroma; + // --- HQDeringmod parameters --- + + /// Ring mask radius. 1 is havsfunc's default; 2 catches wider rings. + final int? deringMrad; + + /// Mask smoothing radius, which softens where the mask takes effect. + final int? deringMsmooth; + + /// Edge-mask threshold (0-255). Lower finds more edges to protect. + final int? deringMthr; + + /// Limit on how far a pixel may be changed, in 8-bit levels. + final double? deringThr; + + /// Separate limit for the dark side of an edge; havsfunc defaults it to + /// `thr / 4`, which is usually what you want. + final double? deringDarkthr; + const DehaloParameters({ this.enabled = false, this.method = DehaloMethod.dehaloAlpha, @@ -169,6 +192,11 @@ class DehaloParameters { this.vinverseStrength, this.vinverseAmount, this.vinverseChroma, + this.deringMrad, + this.deringMsmooth, + this.deringMthr, + this.deringThr, + this.deringDarkthr, }); DehaloParameters copyWith({ @@ -198,6 +226,11 @@ class DehaloParameters { double? vinverseStrength, int? vinverseAmount, bool? vinverseChroma, + int? deringMrad, + int? deringMsmooth, + int? deringMthr, + double? deringThr, + double? deringDarkthr, }) { return DehaloParameters( enabled: enabled ?? this.enabled, @@ -226,6 +259,11 @@ class DehaloParameters { vinverseStrength: vinverseStrength ?? this.vinverseStrength, vinverseAmount: vinverseAmount ?? this.vinverseAmount, vinverseChroma: vinverseChroma ?? this.vinverseChroma, + deringMrad: deringMrad ?? this.deringMrad, + deringMsmooth: deringMsmooth ?? this.deringMsmooth, + deringMthr: deringMthr ?? this.deringMthr, + deringThr: deringThr ?? this.deringThr, + deringDarkthr: deringDarkthr ?? this.deringDarkthr, ); } diff --git a/app/lib/models/edge_repair_parameters.dart b/app/lib/models/edge_repair_parameters.dart new file mode 100644 index 00000000..e90430a9 --- /dev/null +++ b/app/lib/models/edge_repair_parameters.dart @@ -0,0 +1,61 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'edge_repair_parameters.g.dart'; + +/// Edge Repair — rebuild the dirty rows and columns at the frame border. +/// +/// Widths are always even. The bundled FillBorders is pinned to v2, which is +/// bit-identical to v4 at even widths and differs only at odd ones, where it +/// leaves subsampled chroma unrepaired. Crop already steps by 2 for the same +/// chroma-alignment reason. +@JsonSerializable() +class EdgeRepairParameters { + final bool enabled; + final int left; + final int right; + final int top; + final int bottom; + final String mode; + + const EdgeRepairParameters({ + this.enabled = false, + this.left = 0, + this.right = 0, + this.top = 0, + this.bottom = 0, + this.mode = 'fillmargins', + }); + + static int even(int value) => (value.clamp(0, 64) ~/ 2) * 2; + + /// Enabled with every edge zero is not doing anything, and the row should + /// not claim otherwise. + bool get hasEffect => + enabled && [left, right, top, bottom].any((v) => even(v) > 0); + + EdgeRepairParameters copyWith({ + bool? enabled, + int? left, + int? right, + int? top, + int? bottom, + String? mode, + }) => + EdgeRepairParameters( + enabled: enabled ?? this.enabled, + left: left ?? this.left, + right: right ?? this.right, + top: top ?? this.top, + bottom: bottom ?? this.bottom, + mode: mode ?? this.mode, + ); + + String get summary { + if (!hasEffect) return 'Off'; + return 'L${even(left)} R${even(right)} T${even(top)} B${even(bottom)}'; + } + + factory EdgeRepairParameters.fromJson(Map json) => + _$EdgeRepairParametersFromJson(json); + Map toJson() => _$EdgeRepairParametersToJson(this); +} diff --git a/app/lib/models/encoding_settings.dart b/app/lib/models/encoding_settings.dart index 00d60aeb..6895bb37 100644 --- a/app/lib/models/encoding_settings.dart +++ b/app/lib/models/encoding_settings.dart @@ -118,6 +118,10 @@ class EncodingSettings { final ChromaSubsampling chromaSubsampling; final String customFfmpegArgs; + + /// User-supplied VapourSynth, injected after every built-in pass. Same + /// footing as customFfmpegArgs, and gated behind advanced mode. + final String customVapoursynth; final ContainerFormat container; /// Output directory. If null, uses the same directory as the input file. @@ -139,6 +143,7 @@ class EncodingSettings { this.audioQuality = AudioQuality.high, this.chromaSubsampling = ChromaSubsampling.original, this.customFfmpegArgs = '', + this.customVapoursynth = '', this.container = ContainerFormat.mkv, this.outputDirectory, this.filenamePattern = '{input_filename}_processed', @@ -222,6 +227,7 @@ class EncodingSettings { AudioQuality? audioQuality, ChromaSubsampling? chromaSubsampling, String? customFfmpegArgs, + String? customVapoursynth, ContainerFormat? container, String? outputDirectory, bool clearOutputDirectory = false, @@ -245,6 +251,7 @@ class EncodingSettings { audioQuality: audioQuality ?? this.audioQuality, chromaSubsampling: chromaSubsampling ?? this.chromaSubsampling, customFfmpegArgs: customFfmpegArgs ?? this.customFfmpegArgs, + customVapoursynth: customVapoursynth ?? this.customVapoursynth, container: container ?? this.container, outputDirectory: clearOutputDirectory ? null : (outputDirectory ?? this.outputDirectory), filenamePattern: filenamePattern ?? this.filenamePattern, diff --git a/app/lib/models/filter_schema.dart b/app/lib/models/filter_schema.dart index 75e7be88..5a1b3f5f 100644 --- a/app/lib/models/filter_schema.dart +++ b/app/lib/models/filter_schema.dart @@ -28,6 +28,11 @@ enum WidgetType { textfield, @JsonValue('number') number, + + /// A path, with a Browse button beside the field. Never inferred — a string + /// parameter still defaults to [textfield], so a schema opts in explicitly. + @JsonValue('filepicker') + filepicker, } /// VapourSynth-specific parameter configuration. @@ -69,6 +74,10 @@ class ParameterUiConfig { /// Example: {"true": "Top Field First", "false": "Bottom Field First"} final Map? booleanLabels; + /// Extensions the file picker offers, without dots — `["srt", "ass"]`. + /// Only meaningful for [WidgetType.filepicker]; null means any file. + final List? fileExtensions; + const ParameterUiConfig({ this.label, this.description, @@ -77,6 +86,7 @@ class ParameterUiConfig { this.hidden, this.visibleWhen, this.booleanLabels, + this.fileExtensions, }); factory ParameterUiConfig.fromJson(Map json) => @@ -178,12 +188,23 @@ class MethodDefinition { /// List of parameter IDs that this method uses. final List parameters; + /// Whether this method only appears in advanced mode. + /// + /// Lets one filter offer a short, curated method list to everyone and the + /// full set to someone who has asked for it — the mechanism that keeps a + /// "Noise Reduction" row from becoming a dropdown of sixteen names. A method + /// the user has actually selected is always shown regardless, so a preset or + /// saved job never silently loses its method; see + /// [FilterSchema.visibleMethods]. + final bool advancedOnly; + const MethodDefinition({ required this.id, required this.name, this.description, required this.function, required this.parameters, + this.advancedOnly = false, }); factory MethodDefinition.fromJson(Map json) => @@ -426,6 +447,28 @@ class FilterSchema { } } + /// The methods to offer in the UI. + /// + /// In simple mode ([showAdvanced] false) advanced-only methods are dropped — + /// except [selectedId], which is always kept. Hiding a value the user has + /// already chosen would both lie about what the pipeline is doing and, in a + /// dropdown, crash on a value that isn't among its items. + /// + /// Order is preserved, so the schema decides what a regular user sees first. + List visibleMethods({ + required bool showAdvanced, + String? selectedId, + }) { + if (showAdvanced) return methods; + return methods + .where((m) => !m.advancedOnly || m.id == selectedId) + .toList(); + } + + /// Whether any method is advanced-only, so the UI knows there is more to + /// reveal even when every parameter is basic. + bool get hasAdvancedMethods => methods.any((m) => m.advancedOnly); + /// Get default values for all parameters. /// Optional parameters are excluded (they start as null/disabled). Map getDefaults() { diff --git a/app/lib/models/frame_rate_parameters.dart b/app/lib/models/frame_rate_parameters.dart new file mode 100644 index 00000000..d83a505e --- /dev/null +++ b/app/lib/models/frame_rate_parameters.dart @@ -0,0 +1,97 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'frame_rate_parameters.g.dart'; + +/// A target frame rate, named after the standard rather than the number. +/// +/// Named targets rather than a free-text number, deliberately: this pass exists +/// for standards conversion, and offering an arbitrary rate invites using it to +/// "smooth" a master, which invents frames that were never photographed. +enum FrameRateTarget { + @JsonValue('pal25') + pal25('PAL / SECAM (25 fps)', 25, 1), + @JsonValue('ntsc2997') + ntsc2997('NTSC (29.97 fps)', 30000, 1001), + @JsonValue('film23976') + film23976('Film (23.976 fps)', 24000, 1001), + @JsonValue('film24') + film24('Film (24 fps)', 24, 1), + @JsonValue('pal50') + pal50('PAL double rate (50 fps)', 50, 1), + @JsonValue('ntsc5994') + ntsc5994('NTSC double rate (59.94 fps)', 60000, 1001); + + const FrameRateTarget(this.displayName, this.num, this.den); + final String displayName; + final int num; + final int den; + + double get fps => num / den; +} + +/// How the new frames are produced. +enum FrameRateMethod { + @JsonValue('flowFps') + flowFps('Motion interpolation'), + @JsonValue('duplicate') + duplicate('Repeat frames'); + + const FrameRateMethod(this.displayName); + final String displayName; +} + +/// Frame rate conversion (MVTools FlowFPS). +@JsonSerializable() +class FrameRateParameters { + final bool enabled; + final FrameRateTarget target; + final FrameRateMethod method; + + /// Motion-estimation block size. + final int blockSize; + + /// Block overlap; the worker clamps it to what mvtools accepts. + final int overlap; + + /// Source rate, filled in from the detected video info. The worker needs it + /// to report a correct frame map — without it the progress total and the + /// preview index would disagree with what the encoder receives. + final int? sourceFpsNum; + final int? sourceFpsDen; + + const FrameRateParameters({ + this.enabled = false, + this.target = FrameRateTarget.pal25, + this.method = FrameRateMethod.flowFps, + this.blockSize = 16, + this.overlap = 8, + this.sourceFpsNum, + this.sourceFpsDen, + }); + + FrameRateParameters copyWith({ + bool? enabled, + FrameRateTarget? target, + FrameRateMethod? method, + int? blockSize, + int? overlap, + int? sourceFpsNum, + int? sourceFpsDen, + }) { + return FrameRateParameters( + enabled: enabled ?? this.enabled, + target: target ?? this.target, + method: method ?? this.method, + blockSize: blockSize ?? this.blockSize, + overlap: overlap ?? this.overlap, + sourceFpsNum: sourceFpsNum ?? this.sourceFpsNum, + sourceFpsDen: sourceFpsDen ?? this.sourceFpsDen, + ); + } + + String get summary => enabled ? target.displayName : 'Off'; + + factory FrameRateParameters.fromJson(Map json) => + _$FrameRateParametersFromJson(json); + Map toJson() => _$FrameRateParametersToJson(this); +} diff --git a/app/lib/models/geometry_parameters.dart b/app/lib/models/geometry_parameters.dart new file mode 100644 index 00000000..9741acd6 --- /dev/null +++ b/app/lib/models/geometry_parameters.dart @@ -0,0 +1,91 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'geometry_parameters.g.dart'; + +/// Quarter-turn rotation, clockwise. +/// +/// The JsonValues are the wire format to the worker and must match serde's +/// camelCase spelling of the Rust variants exactly. +enum Rotation { + @JsonValue('none') + none('None'), + @JsonValue('cw90') + cw90('90° clockwise'), + @JsonValue('rotate180') + rotate180('180°'), + @JsonValue('ccw90') + ccw90('90° anticlockwise'); + + const Rotation(this.displayName); + final String displayName; + + /// Whether this rotation exchanges width and height. + bool get swapsAxes => this == Rotation.cw90 || this == Rotation.ccw90; +} + +/// Parameters for the rotate/flip pass. +/// +/// Ordinary geometry the app had no way to do: sideways phone footage, mirrored +/// camcorder captures, film scans that came off the scanner rotated. All of it +/// is `core.std`, so there is no plugin dependency and no bit-depth limit. +/// +/// Runs before Crop/Resize, because a quarter turn swaps width and height and +/// every framing decision after it depends on which way round the frame is. +@JsonSerializable() +class GeometryParameters { + /// Whether this pass is enabled. + final bool enabled; + + /// Quarter-turn rotation. + final Rotation rotation; + + /// Mirror left-to-right. Applied after the rotation. + final bool flipHorizontal; + + /// Mirror top-to-bottom. Applied after the rotation. + final bool flipVertical; + + const GeometryParameters({ + this.enabled = false, + this.rotation = Rotation.none, + this.flipHorizontal = false, + this.flipVertical = false, + }); + + GeometryParameters copyWith({ + bool? enabled, + Rotation? rotation, + bool? flipHorizontal, + bool? flipVertical, + }) => + GeometryParameters( + enabled: enabled ?? this.enabled, + rotation: rotation ?? this.rotation, + flipHorizontal: flipHorizontal ?? this.flipHorizontal, + flipVertical: flipVertical ?? this.flipVertical, + ); + + /// Whether the pass would actually change anything. Enabled with nothing + /// chosen is a no-op, and the pass list should say so rather than claim a + /// pass is running. + bool get hasEffect => + enabled && (rotation != Rotation.none || flipHorizontal || flipVertical); + + /// Whether this pass exchanges the frame's width and height. + bool get swapsAxes => enabled && rotation.swapsAxes; + + /// Short summary for the pass list row. + String get summary { + if (!enabled) return 'Off'; + final parts = [ + if (rotation != Rotation.none) rotation.displayName, + if (flipHorizontal) 'flip H', + if (flipVertical) 'flip V', + ]; + return parts.isEmpty ? 'No change selected' : parts.join(', '); + } + + factory GeometryParameters.fromJson(Map json) => + _$GeometryParametersFromJson(json); + Map toJson() => _$GeometryParametersToJson(this); +} diff --git a/app/lib/models/ghost_removal_parameters.dart b/app/lib/models/ghost_removal_parameters.dart new file mode 100644 index 00000000..a3cadae9 --- /dev/null +++ b/app/lib/models/ghost_removal_parameters.dart @@ -0,0 +1,57 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'ghost_removal_parameters.g.dart'; + +/// One ghost to cancel. The plugin rejects mode 0 and intensity 0 outright. +@JsonSerializable() +class GhostSpec { + final int mode; + final int shift; + final int intensity; + + const GhostSpec({this.mode = 2, this.shift = 4, this.intensity = 20}); + + bool get isUsable => + mode >= 1 && mode <= 4 && intensity != 0 && intensity >= -128 && intensity <= 127; + + GhostSpec copyWith({int? mode, int? shift, int? intensity}) => GhostSpec( + mode: mode ?? this.mode, + shift: shift ?? this.shift, + intensity: intensity ?? this.intensity, + ); + + factory GhostSpec.fromJson(Map json) => + _$GhostSpecFromJson(json); + Map toJson() => _$GhostSpecToJson(this); +} + +/// Ghost Removal (LGhost) — cancel the displaced echo RF and cable +/// distribution leave behind. +@JsonSerializable() +class GhostRemovalParameters { + final bool enabled; + final List ghosts; + + const GhostRemovalParameters({this.enabled = false, this.ghosts = const []}); + + List get usableGhosts => + ghosts.where((g) => g.isUsable).toList(growable: false); + + bool get hasEffect => enabled && usableGhosts.isNotEmpty; + + GhostRemovalParameters copyWith({bool? enabled, List? ghosts}) => + GhostRemovalParameters( + enabled: enabled ?? this.enabled, + ghosts: ghosts ?? this.ghosts, + ); + + String get summary { + if (!hasEffect) return 'Off'; + final n = usableGhosts.length; + return n == 1 ? '1 ghost' : '$n ghosts'; + } + + factory GhostRemovalParameters.fromJson(Map json) => + _$GhostRemovalParametersFromJson(json); + Map toJson() => _$GhostRemovalParametersToJson(this); +} diff --git a/app/lib/models/grain_parameters.dart b/app/lib/models/grain_parameters.dart new file mode 100644 index 00000000..3dae0e4c --- /dev/null +++ b/app/lib/models/grain_parameters.dart @@ -0,0 +1,126 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'grain_parameters.g.dart'; + +/// Grain generation method. +enum GrainMethod { + @JsonValue('addGrain') + addGrain('AddGrain'), + @JsonValue('grainFactory3') + grainFactory3('GrainFactory3'); + + const GrainMethod(this.displayName); + final String displayName; +} + +/// Parameters for the film grain pass. +/// +/// Re-adds grain after denoising so a cleaned picture does not look plastic, +/// and masks the banding a shallow gradient shows once the noise that was +/// dithering it is gone. +/// +/// Runs last of the video passes: grain added before a resize is resampled +/// away, and before a deband is smoothed away. +@JsonSerializable() +class GrainParameters { + /// Whether this pass is enabled. + final bool enabled; + + /// Which method to use. + final GrainMethod method; + + /// Luma grain strength as a variance — the noise standard deviation is its + /// square root, so 4 gives a subtle sigma of 2. + /// + /// `var` is a Dart keyword, so the field is named `var_` and the wire name is + /// pinned explicitly; without the JsonKey the worker would never see it. + @JsonKey(name: 'var') + final double var_; + + /// Chroma grain strength, same units. 0 leaves chroma untouched. + final double uvar; + + /// Spatial correlation, which makes the grain coarser. Also reduces its + /// amplitude, so raising it usually means raising strength too. + final double corr; + + /// Hold the same pattern on every frame. Off by default: static grain over + /// moving video reads as dirt on the lens. + final bool constant; + + /// Grain strength in the shadows (GrainFactory3). + final double g1str; + + /// Grain strength in the midtones (GrainFactory3). + final double g2str; + + /// Grain strength in the highlights (GrainFactory3). + final double g3str; + + /// Damps GrainFactory3's animation. It cannot stop it — that filter is always + /// animated and offers no way to hold the pattern still. + final int tempAvg; + + const GrainParameters({ + this.enabled = false, + this.method = GrainMethod.addGrain, + this.var_ = 4.0, + this.uvar = 0.0, + this.corr = 0.0, + this.constant = false, + this.g1str = 4.0, + this.g2str = 3.0, + this.g3str = 2.0, + this.tempAvg = 0, + }); + + GrainParameters copyWith({ + bool? enabled, + GrainMethod? method, + double? var_, + double? uvar, + double? corr, + bool? constant, + double? g1str, + double? g2str, + double? g3str, + int? tempAvg, + }) => + GrainParameters( + enabled: enabled ?? this.enabled, + method: method ?? this.method, + var_: var_ ?? this.var_, + uvar: uvar ?? this.uvar, + corr: corr ?? this.corr, + constant: constant ?? this.constant, + g1str: g1str ?? this.g1str, + g2str: g2str ?? this.g2str, + g3str: g3str ?? this.g3str, + tempAvg: tempAvg ?? this.tempAvg, + ); + + /// Whether the pass would actually change the picture. Zero strength is a + /// no-op, and the row should say so rather than claim a pass is running. + bool get hasEffect { + if (!enabled) return false; + return method == GrainMethod.addGrain + ? var_ > 0 || uvar > 0 + : g1str > 0 || g2str > 0 || g3str > 0; + } + + /// Short summary for the pass list row. + String get summary { + if (!enabled) return 'Off'; + if (!hasEffect) return 'No grain selected'; + if (method == GrainMethod.grainFactory3) { + return 'Film stock (${g1str.toStringAsFixed(0)}/' + '${g2str.toStringAsFixed(0)}/${g3str.toStringAsFixed(0)})'; + } + final animated = constant ? 'static' : 'animated'; + return 'Strength ${var_.toStringAsFixed(1)}, $animated'; + } + + factory GrainParameters.fromJson(Map json) => + _$GrainParametersFromJson(json); + Map toJson() => _$GrainParametersToJson(this); +} diff --git a/app/lib/models/noise_reduction_parameters.dart b/app/lib/models/noise_reduction_parameters.dart index fbebd692..aa019f0e 100644 --- a/app/lib/models/noise_reduction_parameters.dart +++ b/app/lib/models/noise_reduction_parameters.dart @@ -11,7 +11,30 @@ enum NoiseReductionMethod { @JsonValue('mcDegrainSharp') mcDegrainSharp('MCDegrainSharp'), @JsonValue('qtgmcBuiltin') - qtgmcBuiltin('QTGMC Built-in'); + qtgmcBuiltin('QTGMC Built-in'), + // These three JsonValues are the wire format to the worker and must match + // serde's camelCase spelling of the Rust variants exactly. A mismatch does + // not error — serde falls back to the default — so the job would silently run + // SMDegrain instead. Pinned on the Rust side by + // test_method_wire_names_match_the_dart_enum. + @JsonValue('dfTtest') + dfttest('DFTTest'), + @JsonValue('fft3dFilter') + fft3dFilter('FFT3DFilter'), + @JsonValue('tTempSmooth') + tTempSmooth('TTempSmooth'), + @JsonValue('fluxSmoothT') + fluxSmoothT('FluxSmoothT'), + @JsonValue('fluxSmoothSt') + fluxSmoothSt('FluxSmoothST'), + @JsonValue('stPresso') + stPresso('STPresso'), + @JsonValue('ctmf') + ctmf('CTMF'), + @JsonValue('mClean') + mClean('mClean'), + @JsonValue('temporalDegrain2') + temporalDegrain2('TemporalDegrain2'); const NoiseReductionMethod(this.displayName); final String displayName; @@ -37,6 +60,28 @@ class NoiseReductionParameters { /// Whether this pass is enabled. final bool enabled; + /// Restore fine detail the denoiser removed. Brackets the denoise rather + /// than following it, so it is a checkbox here and not a Sharpen method. + final bool contraSharpen; + + // mClean + final int mcleanStrength; + final int mcleanSharp; + final int mcleanRn; + final int mcleanThsad; + final bool mcleanChroma; + + // TemporalDegrain2 + final int td2DegrainTr; + final int td2GrainLevel; + final int td2PostFft; + final double td2PostSigma; + final int td2PostMix; + final bool td2ChromaMotion; + + /// ContraSharpening's Repair mode; 13 is havsfunc's own default. + final int contraSharpenRep; + /// Preset level for simple mode. final NoiseReductionPreset preset; @@ -101,8 +146,85 @@ class NoiseReductionParameters { /// EZKeepGrain amount (0.0 to 1.0). final double qtgmcEzKeepGrain; + // --- DFTTest Parameters --- + + /// Denoising strength. DFTTest's own default is 8.0. + final double dfttestSigma; + + /// Temporal window in frames; forced odd by the worker. 1 is purely spatial. + final int dfttestTbsize; + + /// Spatial block size. Larger separates frequencies better but is slower. + final int dfttestSbsize; + + // --- FFT3DFilter Parameters --- + + /// Denoising strength. + final double fft3dSigma; + + /// Temporal window in frames (1-5). 1 is purely spatial. + final int fft3dBt; + + /// Post-denoise sharpening (0.0-1.0), omitted at 0. + final double fft3dSharpen; + + // --- TTempSmooth Parameters --- + + /// Temporal radius (1-7). + final int ttempMaxr; + + /// Per-pixel difference threshold, above which a pixel is left alone. + final int ttempThresh; + + /// Motion-difference threshold; the worker holds it below [ttempThresh]. + final int ttempMdiff; + + /// Weighting strength (1-8). Higher weights the current frame more. + final int ttempStrength; + + // --- FluxSmooth Parameters --- + + /// Temporal threshold: a pixel is averaged only where its neighbours in time + /// bracket it in value. Higher smooths more and risks motion. -1 disables. + final int fluxTemporalThreshold; + + /// Spatial threshold for the ST variant. -1 disables the spatial half. + final int fluxSpatialThreshold; + + // --- STPresso Parameters --- + + /// How far a pixel may move, in 8-bit levels. The whole point of the filter. + final int stpressoLimit; + + /// Bias toward the original pixel (0-100). Higher keeps more of it. + final int stpressoBias; + + /// Temporal threshold for the FluxSmoothT it runs internally. + final int stpressoTthr; + + // --- CTMF Parameters --- + + /// Median window radius. Constant-time, so this costs almost nothing. + final int ctmfRadius; + + /// Planes to filter: 0 luma only, 1 chroma only, 2 both. + final int ctmfPlanes; + const NoiseReductionParameters({ this.enabled = false, + this.contraSharpen = false, + this.mcleanStrength = 20, + this.mcleanSharp = 10, + this.mcleanRn = 14, + this.mcleanThsad = 400, + this.mcleanChroma = true, + this.td2DegrainTr = 1, + this.td2GrainLevel = 2, + this.td2PostFft = 0, + this.td2PostSigma = 1.0, + this.td2PostMix = 0, + this.td2ChromaMotion = true, + this.contraSharpenRep = 13, this.preset = NoiseReductionPreset.off, this.method = NoiseReductionMethod.smDegrain, // SMDegrain defaults @@ -125,6 +247,23 @@ class NoiseReductionParameters { // QTGMC built-in defaults this.qtgmcEzDenoise = 0.0, this.qtgmcEzKeepGrain = 0.0, + this.dfttestSigma = 8.0, + this.dfttestTbsize = 3, + this.dfttestSbsize = 16, + this.fft3dSigma = 2.0, + this.fft3dBt = 3, + this.fft3dSharpen = 0.0, + this.ttempMaxr = 3, + this.ttempThresh = 4, + this.ttempMdiff = 2, + this.ttempStrength = 2, + this.fluxTemporalThreshold = 7, + this.fluxSpatialThreshold = 7, + this.stpressoLimit = 3, + this.stpressoBias = 24, + this.stpressoTthr = 12, + this.ctmfRadius = 2, + this.ctmfPlanes = 2, }); /// Create parameters from a preset. @@ -190,6 +329,23 @@ class NoiseReductionParameters { int? mcdsPlane, double? qtgmcEzDenoise, double? qtgmcEzKeepGrain, + double? dfttestSigma, + int? dfttestTbsize, + int? dfttestSbsize, + double? fft3dSigma, + int? fft3dBt, + double? fft3dSharpen, + int? ttempMaxr, + int? ttempThresh, + int? ttempMdiff, + int? ttempStrength, + int? fluxTemporalThreshold, + int? fluxSpatialThreshold, + int? stpressoLimit, + int? stpressoBias, + int? stpressoTthr, + int? ctmfRadius, + int? ctmfPlanes, }) { return NoiseReductionParameters( enabled: enabled ?? this.enabled, @@ -211,6 +367,24 @@ class NoiseReductionParameters { mcdsPlane: mcdsPlane ?? this.mcdsPlane, qtgmcEzDenoise: qtgmcEzDenoise ?? this.qtgmcEzDenoise, qtgmcEzKeepGrain: qtgmcEzKeepGrain ?? this.qtgmcEzKeepGrain, + dfttestSigma: dfttestSigma ?? this.dfttestSigma, + dfttestTbsize: dfttestTbsize ?? this.dfttestTbsize, + dfttestSbsize: dfttestSbsize ?? this.dfttestSbsize, + fft3dSigma: fft3dSigma ?? this.fft3dSigma, + fft3dBt: fft3dBt ?? this.fft3dBt, + fft3dSharpen: fft3dSharpen ?? this.fft3dSharpen, + ttempMaxr: ttempMaxr ?? this.ttempMaxr, + ttempThresh: ttempThresh ?? this.ttempThresh, + ttempMdiff: ttempMdiff ?? this.ttempMdiff, + ttempStrength: ttempStrength ?? this.ttempStrength, + fluxTemporalThreshold: + fluxTemporalThreshold ?? this.fluxTemporalThreshold, + fluxSpatialThreshold: fluxSpatialThreshold ?? this.fluxSpatialThreshold, + stpressoLimit: stpressoLimit ?? this.stpressoLimit, + stpressoBias: stpressoBias ?? this.stpressoBias, + stpressoTthr: stpressoTthr ?? this.stpressoTthr, + ctmfRadius: ctmfRadius ?? this.ctmfRadius, + ctmfPlanes: ctmfPlanes ?? this.ctmfPlanes, ); } diff --git a/app/lib/models/parameter_converter.dart b/app/lib/models/parameter_converter.dart index 3e27963d..d23352cf 100644 --- a/app/lib/models/parameter_converter.dart +++ b/app/lib/models/parameter_converter.dart @@ -1,4 +1,11 @@ import 'chroma_denoise_parameters.dart'; +import 'anti_alias_parameters.dart'; +import 'geometry_parameters.dart'; +import 'deflicker_parameters.dart'; +import 'edge_repair_parameters.dart'; +import 'frame_rate_parameters.dart'; +import 'ghost_removal_parameters.dart'; +import 'grain_parameters.dart'; import 'chroma_fix_parameters.dart'; import 'color_correction_parameters.dart'; import 'crop_resize_parameters.dart'; @@ -6,6 +13,7 @@ import 'deband_parameters.dart'; import 'deblock_parameters.dart'; import 'descratch_parameters.dart'; import 'spotless_parameters.dart'; +import 'stabilize_parameters.dart'; import 'dehalo_parameters.dart'; import 'dynamic_parameters.dart'; import 'noise_reduction_parameters.dart'; @@ -29,6 +37,9 @@ class ParameterConverter { case DeinterlaceMethod.softTelecine: method = 'soft_telecine'; break; + case DeinterlaceMethod.bwdif: + method = 'bwdif'; + break; } return DynamicParameters( @@ -39,6 +50,7 @@ class ParameterConverter { 'preset': params.preset.displayName, 'tff': params.tff, 'fpsDivisor': params.fpsDivisor, + 'bwdifEdeint': params.bwdifEdeint, // Null means "not set" in the model; surface the effective default so // the checkbox always reflects what the worker will actually do. 'chromaUpsampleFix': params.chromaUpsampleFix ?? false, @@ -146,12 +158,52 @@ class ParameterConverter { case NoiseReductionMethod.qtgmcBuiltin: method = 'qtgmc_builtin'; break; + case NoiseReductionMethod.dfttest: + method = 'dfttest'; + break; + case NoiseReductionMethod.fft3dFilter: + method = 'fft3dfilter'; + break; + case NoiseReductionMethod.tTempSmooth: + method = 'ttempsmooth'; + break; + case NoiseReductionMethod.fluxSmoothT: + method = 'fluxsmooth_t'; + break; + case NoiseReductionMethod.fluxSmoothSt: + method = 'fluxsmooth_st'; + break; + case NoiseReductionMethod.stPresso: + method = 'stpresso'; + break; + case NoiseReductionMethod.ctmf: + method = 'ctmf'; + break; + case NoiseReductionMethod.mClean: + method = 'mclean'; + break; + case NoiseReductionMethod.temporalDegrain2: + method = 'temporal_degrain2'; + break; } return DynamicParameters( filterId: 'noise_reduction', enabled: params.enabled, values: { + 'mcleanStrength': params.mcleanStrength, + 'mcleanSharp': params.mcleanSharp, + 'mcleanRn': params.mcleanRn, + 'mcleanThsad': params.mcleanThsad, + 'mcleanChroma': params.mcleanChroma, + 'td2DegrainTr': params.td2DegrainTr, + 'td2GrainLevel': params.td2GrainLevel, + 'td2PostFft': params.td2PostFft, + 'td2PostSigma': params.td2PostSigma, + 'td2PostMix': params.td2PostMix, + 'td2ChromaMotion': params.td2ChromaMotion, + 'contraSharpen': params.contraSharpen, + 'contraSharpenRep': params.contraSharpenRep, 'method': method, 'smDegrainTr': params.smDegrainTr, 'smDegrainThSAD': params.smDegrainThSAD, @@ -169,6 +221,23 @@ class ParameterConverter { 'mcdsPlane': params.mcdsPlane, 'qtgmcEzDenoise': params.qtgmcEzDenoise, 'qtgmcEzKeepGrain': params.qtgmcEzKeepGrain, + 'dfttestSigma': params.dfttestSigma, + 'dfttestTbsize': params.dfttestTbsize, + 'dfttestSbsize': params.dfttestSbsize, + 'fft3dSigma': params.fft3dSigma, + 'fft3dBt': params.fft3dBt, + 'fft3dSharpen': params.fft3dSharpen, + 'ttempMaxr': params.ttempMaxr, + 'ttempThresh': params.ttempThresh, + 'ttempMdiff': params.ttempMdiff, + 'ttempStrength': params.ttempStrength, + 'fluxTemporalThreshold': params.fluxTemporalThreshold, + 'fluxSpatialThreshold': params.fluxSpatialThreshold, + 'stpressoLimit': params.stpressoLimit, + 'stpressoBias': params.stpressoBias, + 'stpressoTthr': params.stpressoTthr, + 'ctmfRadius': params.ctmfRadius, + 'ctmfPlanes': params.ctmfPlanes, }, ); } @@ -194,6 +263,7 @@ class ParameterConverter { DehaloMethod.edgeCleaner => 'edge_cleaner', DehaloMethod.vinverse => 'vinverse', DehaloMethod.vinverse2 => 'vinverse2', + DehaloMethod.hqDeringmod => 'hq_deringmod', }; /// Convert chroma denoise (CCD) parameters to dynamic format. @@ -202,13 +272,18 @@ class ParameterConverter { filterId: 'chroma_denoise', enabled: params.enabled, values: { - 'method': 'ccd', + 'method': params.method == ChromaDenoiseMethod.cnr4 ? 'cnr4' : 'ccd', 'threshold': params.threshold, 'temporalRadius': params.temporalRadius, 'pointsLow': params.pointsLow, 'pointsMedium': params.pointsMedium, 'pointsHigh': params.pointsHigh, if (params.scale != null) 'scale': params.scale, + 'cnr4Strength': params.cnr4Strength, + 'cnr4Sense': params.cnr4Sense, + 'cnr4Radius': params.cnr4Radius, + 'cnr4Tmode': params.cnr4Tmode, + 'cnr4Wmode': params.cnr4Wmode, }, // Left off, scale is derived from the frame height at run time — which is // both the plugin's own behaviour and the only value that works on short @@ -263,6 +338,13 @@ class ParameterConverter { optional('vinverseStrength', params.vinverseStrength, 2.7); optional('vinverseAmount', params.vinverseAmount, 255); optional('vinverseChroma', params.vinverseChroma, true); + // HQDeringmod fallbacks mirror havsfunc's own defaults, so ticking a + // checkbox starts from the value the filter would have used anyway. + optional('deringMrad', params.deringMrad, 1); + optional('deringMsmooth', params.deringMsmooth, 1); + optional('deringMthr', params.deringMthr, 60); + optional('deringThr', params.deringThr, 12.0); + optional('deringDarkthr', params.deringDarkthr, 3.0); return DynamicParameters( filterId: 'dehalo', @@ -272,6 +354,275 @@ class ParameterConverter { ); } + /// Convert anti-aliasing parameters to dynamic format. + static DynamicParameters fromAntiAlias(AntiAliasParameters params) { + return DynamicParameters( + filterId: 'anti_alias', + enabled: params.enabled, + values: { + 'method': params.method == AntiAliasMethod.santiag ? 'santiag' : 'daa', + 'santiagStrh': params.santiagStrh, + 'santiagStrv': params.santiagStrv, + }, + ); + } + + /// Convert dynamic parameters to anti-aliasing parameters. + static AntiAliasParameters toAntiAlias(DynamicParameters params) { + final v = params.values; + return AntiAliasParameters( + enabled: params.enabled, + method: v['method'] == 'santiag' + ? AntiAliasMethod.santiag + : AntiAliasMethod.daa, + santiagStrh: _asInt(v['santiagStrh']) ?? 1, + santiagStrv: _asInt(v['santiagStrv']) ?? 1, + // Not exposed: only nnedi3 is bundled, and the worker pins it there. + ); + } + + /// Convert stabilisation parameters to dynamic format. + static DynamicParameters fromStabilize(StabilizeParameters params) { + return DynamicParameters( + filterId: 'stabilize', + enabled: params.enabled, + values: { + 'method': 'stab', + 'dxmax': params.dxmax, + 'dymax': params.dymax, + 'mirror': params.mirror, + }, + ); + } + + /// Convert dynamic parameters to stabilisation parameters. + static StabilizeParameters toStabilize(DynamicParameters params) { + final v = params.values; + return StabilizeParameters( + enabled: params.enabled, + dxmax: _asInt(v['dxmax']) ?? 4, + dymax: _asInt(v['dymax']) ?? 4, + mirror: _asInt(v['mirror']) ?? 0, + ); + } + + /// Convert rotate/flip parameters to dynamic format. + static DynamicParameters fromGeometry(GeometryParameters params) { + return DynamicParameters( + filterId: 'geometry', + enabled: params.enabled, + values: { + 'method': 'transform', + 'rotation': params.rotation.name, + 'flipHorizontal': params.flipHorizontal, + 'flipVertical': params.flipVertical, + }, + ); + } + + /// Convert dynamic parameters to rotate/flip parameters. + static GeometryParameters toGeometry(DynamicParameters params) { + final v = params.values; + final name = v['rotation'] as String? ?? 'none'; + return GeometryParameters( + enabled: params.enabled, + rotation: Rotation.values.firstWhere( + (r) => r.name == name, + orElse: () => Rotation.none, + ), + flipHorizontal: v['flipHorizontal'] as bool? ?? false, + flipVertical: v['flipVertical'] as bool? ?? false, + ); + } + + /// Convert grain parameters to dynamic format. + /// Convert frame rate parameters to dynamic format. + /// Convert deflicker parameters to dynamic format. + static DynamicParameters fromDeflicker(DeflickerParameters params) { + return DynamicParameters( + filterId: 'deflicker', + enabled: params.enabled, + values: { + 'method': params.method == DeflickerMethod.local ? 'local' : 'global', + 'strength': params.strength, + 'window': params.window, + 'localStrength': params.localStrength, + 'aggressive': params.aggressive, + }, + ); + } + + /// Convert edge repair parameters to dynamic format. + static DynamicParameters fromEdgeRepair(EdgeRepairParameters params) { + return DynamicParameters( + filterId: 'edge_repair', + enabled: params.enabled, + values: { + 'left': params.left, + 'right': params.right, + 'top': params.top, + 'bottom': params.bottom, + 'mode': params.mode, + }, + ); + } + + /// Convert ghost removal parameters to dynamic format. + /// + /// The presets map to (mode, shift, intensity) triples the plugin accepts. + /// A saved job carrying its own ghost list keeps it: `custom` is resolved + /// back to whatever was stored rather than to a preset. + static DynamicParameters fromGhostRemoval(GhostRemovalParameters params) { + return DynamicParameters( + filterId: 'ghost_removal', + enabled: params.enabled, + values: {'preset': ghostPresetFor(params.ghosts)}, + ); + } + + /// Named ghost presets. Mode 2 is luminance ghosting, which is what RF and + /// cable echoes look like; the offsets are typical for SD line timing. + static const Map> ghostPresets = { + 'light': [GhostSpec(mode: 2, shift: 4, intensity: 12)], + 'medium': [GhostSpec(mode: 2, shift: 6, intensity: 24)], + 'strong': [ + GhostSpec(mode: 2, shift: 6, intensity: 36), + GhostSpec(mode: 1, shift: 12, intensity: 12), + ], + }; + + static String ghostPresetFor(List ghosts) { + for (final entry in ghostPresets.entries) { + final preset = entry.value; + if (preset.length != ghosts.length) continue; + var same = true; + for (var i = 0; i < preset.length; i++) { + if (preset[i].mode != ghosts[i].mode || + preset[i].shift != ghosts[i].shift || + preset[i].intensity != ghosts[i].intensity) { + same = false; + break; + } + } + if (same) return entry.key; + } + return ghosts.isEmpty ? 'light' : 'custom'; + } + + static DynamicParameters fromFrameRate(FrameRateParameters params) { + return DynamicParameters( + filterId: 'frame_rate', + enabled: params.enabled, + values: { + 'method': params.method == FrameRateMethod.duplicate + ? 'duplicate' + : 'flowFps', + 'target': params.target.name, + 'blockSize': params.blockSize, + 'overlap': params.overlap, + }, + ); + } + + static DynamicParameters fromGrain(GrainParameters params) { + return DynamicParameters( + filterId: 'grain', + enabled: params.enabled, + values: { + 'method': params.method == GrainMethod.grainFactory3 + ? 'grain_factory3' + : 'add_grain', + 'var': params.var_, + 'uvar': params.uvar, + 'corr': params.corr, + 'constant': params.constant, + 'g1str': params.g1str, + 'g2str': params.g2str, + 'g3str': params.g3str, + 'tempAvg': params.tempAvg, + }, + ); + } + + /// Convert dynamic parameters to grain parameters. + /// Convert dynamic parameters to frame rate parameters. + /// Convert dynamic parameters to deflicker parameters. + static DeflickerParameters toDeflicker(DynamicParameters params) { + final v = params.values; + return DeflickerParameters( + enabled: params.enabled, + method: (v['method'] as String?) == 'local' + ? DeflickerMethod.local + : DeflickerMethod.global, + strength: (v['strength'] as num?)?.toDouble() ?? 1.0, + window: _asInt(v['window']) ?? 5, + localStrength: _asInt(v['localStrength']) ?? 2, + aggressive: v['aggressive'] as bool? ?? false, + ); + } + + /// Convert dynamic parameters to edge repair parameters. + static EdgeRepairParameters toEdgeRepair(DynamicParameters params) { + final v = params.values; + return EdgeRepairParameters( + enabled: params.enabled, + left: _asInt(v['left']) ?? 0, + right: _asInt(v['right']) ?? 0, + top: _asInt(v['top']) ?? 0, + bottom: _asInt(v['bottom']) ?? 0, + mode: v['mode'] as String? ?? 'fillmargins', + ); + } + + /// Convert dynamic parameters to ghost removal parameters. + static GhostRemovalParameters toGhostRemoval( + DynamicParameters params, { + List existing = const [], + }) { + final preset = params.values['preset'] as String? ?? 'light'; + // `custom` means "keep whatever the job already carried" — resolving it to + // a preset would silently discard a hand-built ghost list. + final ghosts = preset == 'custom' + ? existing + : (ghostPresets[preset] ?? ghostPresets['light']!); + return GhostRemovalParameters(enabled: params.enabled, ghosts: ghosts); + } + + static FrameRateParameters toFrameRate(DynamicParameters params) { + final v = params.values; + final target = FrameRateTarget.values.firstWhere( + (t) => t.name == v['target'], + orElse: () => FrameRateTarget.pal25, + ); + return FrameRateParameters( + enabled: params.enabled, + method: (v['method'] as String?) == 'duplicate' + ? FrameRateMethod.duplicate + : FrameRateMethod.flowFps, + target: target, + blockSize: _asInt(v['blockSize']) ?? 16, + overlap: _asInt(v['overlap']) ?? 8, + ); + } + + static GrainParameters toGrain(DynamicParameters params) { + final v = params.values; + return GrainParameters( + enabled: params.enabled, + method: v['method'] == 'grain_factory3' + ? GrainMethod.grainFactory3 + : GrainMethod.addGrain, + var_: (v['var'] as num?)?.toDouble() ?? 4.0, + uvar: (v['uvar'] as num?)?.toDouble() ?? 0.0, + corr: (v['corr'] as num?)?.toDouble() ?? 0.0, + constant: v['constant'] as bool? ?? false, + g1str: (v['g1str'] as num?)?.toDouble() ?? 4.0, + g2str: (v['g2str'] as num?)?.toDouble() ?? 3.0, + g3str: (v['g3str'] as num?)?.toDouble() ?? 2.0, + tempAvg: _asInt(v['tempAvg']) ?? 0, + ); + } + /// Convert deblock parameters to dynamic format. static DynamicParameters fromDeblock(DeblockParameters params) { String method; @@ -282,6 +633,9 @@ class ParameterConverter { case DeblockMethod.deblock: method = 'deblock'; break; + case DeblockMethod.dctFilter: + method = 'dctfilter'; + break; } return DynamicParameters( @@ -293,6 +647,9 @@ class ParameterConverter { 'quant2': params.quant2, 'aOffset1': params.aOffset1, 'aOffset2': params.aOffset2, + 'dctCutoff': params.dctCutoff, + 'dctStrength': params.dctStrength, + 'dctPlanes': params.dctPlanes, }, ); } @@ -330,7 +687,15 @@ class ParameterConverter { return DynamicParameters( filterId: 'spotless', enabled: params.enabled, - values: {}, + values: { + 'method': params.method == SpotLessMethod.removeDirt + ? 'removeDirt' + : 'spotless', + 'rdGmthreshold': params.rdGmthreshold, + 'rdNoise': params.rdNoise, + 'rdNoisy': params.rdNoisy, + 'rdDist': params.rdDist, + 'rdPostDenoise': params.rdPostDenoise,}, lastOptionalValues: { 'chroma': params.chroma, 'rec': params.rec, @@ -370,6 +735,9 @@ class ParameterConverter { case SharpenMethod.cas: method = 'cas'; break; + case SharpenMethod.aWarpSharp2: + method = 'awarpsharp2'; + break; } return DynamicParameters( @@ -382,6 +750,10 @@ class ParameterConverter { 'undershoot': params.undershoot, 'softEdge': params.softEdge, 'casSharpness': params.casSharpness, + 'warpDepth': params.warpDepth, + 'warpThresh': params.warpThresh, + 'warpBlur': params.warpBlur, + 'warpType': params.warpType, }, ); } @@ -392,6 +764,12 @@ class ParameterConverter { filterId: 'color_correction', enabled: params.enabled, values: { + 'applyAutoLevels': params.applyAutoLevels, + 'autoLevelsBlack': params.autoLevelsBlack, + 'autoLevelsWhite': params.autoLevelsWhite, + 'autoLevelsStrength': params.autoLevelsStrength, + 'applyAutoWhiteBalance': params.applyAutoWhiteBalance, + 'autoWhiteBalanceStrength': params.autoWhiteBalanceStrength, 'method': 'tweak', 'brightness': params.brightness, 'contrast': params.contrast, @@ -399,6 +777,9 @@ class ParameterConverter { 'saturation': params.saturation, 'coring': params.coring, 'applyLevels': params.applyLevels, + 'smoothLevels': params.smoothLevels, + 'applyShadowDetail': params.applyShadowDetail, + 'shadowSigma': params.shadowSigma, 'inputLow': params.inputLow, 'inputHigh': params.inputHigh, 'outputLow': params.outputLow, @@ -416,6 +797,15 @@ class ParameterConverter { filterId: 'chroma_fixes', enabled: params.enabled, values: { + 'applyAutoChroma': params.applyAutoChroma, + 'autoChromaMaxShift': params.autoChromaMaxShift, + 'autoChromaAccuracy': params.autoChromaAccuracy, + 'autoChromaReferenceFrame': params.autoChromaReferenceFrame, + 'applyDedot': params.applyDedot, + 'dedotLuma2d': params.dedotLuma2d, + 'dedotLumaT': params.dedotLumaT, + 'dedotChromaT1': params.dedotChromaT1, + 'dedotChromaT2': params.dedotChromaT2, 'applyChromaShift': params.applyChromaShift, 'chromaShiftH': params.chromaShiftH, 'chromaShiftV': params.chromaShiftV, @@ -425,6 +815,13 @@ class ParameterConverter { 'chromaBleedCBlur': params.chromaBleedCBlur, 'chromaBleedStrength': params.chromaBleedStrength, 'applyDeCrawl': params.applyDeCrawl, + 'applyDeRainbow': params.applyDeRainbow, + 'applyBifrost': params.applyBifrost, + 'bifrostLumaThresh': params.bifrostLumaThresh, + 'bifrostVariation': params.bifrostVariation, + 'bifrostInterlaced': params.bifrostInterlaced, + 'deRainbowCThresh': params.deRainbowCThresh, + 'deRainbowYThresh': params.deRainbowYThresh, 'deCrawlYThresh': params.deCrawlYThresh, 'deCrawlCThresh': params.deCrawlCThresh, 'deCrawlMaxDiff': params.deCrawlMaxDiff, @@ -496,6 +893,7 @@ class ParameterConverter { filterId: 'subtitles', enabled: params.enabled, values: { + 'burnInPath': params.burnInPath, 'method': 'whisper', 'model': params.model.value, 'output': params.output.value, @@ -512,6 +910,14 @@ class ParameterConverter { 'descratch': fromDeScratch(pipeline.descratch), 'spotless': fromSpotLess(pipeline.spotless), 'noise_reduction': fromNoiseReduction(pipeline.noiseReduction), + 'anti_alias': fromAntiAlias(pipeline.antiAlias), + 'stabilize': fromStabilize(pipeline.stabilize), + 'geometry': fromGeometry(pipeline.geometry), + 'grain': fromGrain(pipeline.grain), + 'frame_rate': fromFrameRate(pipeline.frameRate), + 'deflicker': fromDeflicker(pipeline.deflicker), + 'edge_repair': fromEdgeRepair(pipeline.edgeRepair), + 'ghost_removal': fromGhostRemoval(pipeline.ghostRemoval), 'chroma_denoise': fromChromaDenoise(pipeline.chromaDenoise), 'dehalo': fromDehalo(pipeline.dehalo), 'deblock': fromDeblock(pipeline.deblock), @@ -542,6 +948,9 @@ class ParameterConverter { case 'soft_telecine': method = DeinterlaceMethod.softTelecine; break; + case 'bwdif': + method = DeinterlaceMethod.bwdif; + break; default: method = DeinterlaceMethod.qtgmc; } @@ -555,6 +964,7 @@ class ParameterConverter { ), tff: v['tff'] as bool?, fpsDivisor: v['fpsDivisor'] as int?, + bwdifEdeint: v['bwdifEdeint'] as bool? ?? false, chromaUpsampleFix: v['chromaUpsampleFix'] as bool?, highPrecision: v['highPrecision'] as bool?, inputType: v['inputType'] as int?, @@ -658,11 +1068,51 @@ class ParameterConverter { case 'qtgmc_builtin': method = NoiseReductionMethod.qtgmcBuiltin; break; + case 'dfttest': + method = NoiseReductionMethod.dfttest; + break; + case 'fft3dfilter': + method = NoiseReductionMethod.fft3dFilter; + break; + case 'ttempsmooth': + method = NoiseReductionMethod.tTempSmooth; + break; + case 'fluxsmooth_t': + method = NoiseReductionMethod.fluxSmoothT; + break; + case 'fluxsmooth_st': + method = NoiseReductionMethod.fluxSmoothSt; + break; + case 'stpresso': + method = NoiseReductionMethod.stPresso; + break; + case 'ctmf': + method = NoiseReductionMethod.ctmf; + break; + case 'mclean': + method = NoiseReductionMethod.mClean; + break; + case 'temporal_degrain2': + method = NoiseReductionMethod.temporalDegrain2; + break; default: method = NoiseReductionMethod.smDegrain; } return NoiseReductionParameters( + mcleanStrength: _asInt(v['mcleanStrength']) ?? 20, + mcleanSharp: _asInt(v['mcleanSharp']) ?? 10, + mcleanRn: _asInt(v['mcleanRn']) ?? 14, + mcleanThsad: _asInt(v['mcleanThsad']) ?? 400, + mcleanChroma: v['mcleanChroma'] as bool? ?? true, + td2DegrainTr: _asInt(v['td2DegrainTr']) ?? 1, + td2GrainLevel: _asInt(v['td2GrainLevel']) ?? 2, + td2PostFft: _asInt(v['td2PostFft']) ?? 0, + td2PostSigma: (v['td2PostSigma'] as num?)?.toDouble() ?? 1.0, + td2PostMix: _asInt(v['td2PostMix']) ?? 0, + td2ChromaMotion: v['td2ChromaMotion'] as bool? ?? true, + contraSharpen: v['contraSharpen'] as bool? ?? false, + contraSharpenRep: _asInt(v['contraSharpenRep']) ?? 13, enabled: params.enabled, preset: params.enabled ? NoiseReductionPreset.custom : NoiseReductionPreset.off, method: method, @@ -682,6 +1132,23 @@ class ParameterConverter { mcTemporalProfile: v['mcTemporalProfile'] as String? ?? 'medium', qtgmcEzDenoise: (v['qtgmcEzDenoise'] as num?)?.toDouble() ?? 0.0, qtgmcEzKeepGrain: (v['qtgmcEzKeepGrain'] as num?)?.toDouble() ?? 0.0, + dfttestSigma: (v['dfttestSigma'] as num?)?.toDouble() ?? 8.0, + dfttestTbsize: _asInt(v['dfttestTbsize']) ?? 3, + dfttestSbsize: _asInt(v['dfttestSbsize']) ?? 16, + fft3dSigma: (v['fft3dSigma'] as num?)?.toDouble() ?? 2.0, + fft3dBt: _asInt(v['fft3dBt']) ?? 3, + fft3dSharpen: (v['fft3dSharpen'] as num?)?.toDouble() ?? 0.0, + ttempMaxr: _asInt(v['ttempMaxr']) ?? 3, + ttempThresh: _asInt(v['ttempThresh']) ?? 4, + ttempMdiff: _asInt(v['ttempMdiff']) ?? 2, + ttempStrength: _asInt(v['ttempStrength']) ?? 2, + fluxTemporalThreshold: _asInt(v['fluxTemporalThreshold']) ?? 7, + fluxSpatialThreshold: _asInt(v['fluxSpatialThreshold']) ?? 7, + stpressoLimit: _asInt(v['stpressoLimit']) ?? 3, + stpressoBias: _asInt(v['stpressoBias']) ?? 24, + stpressoTthr: _asInt(v['stpressoTthr']) ?? 12, + ctmfRadius: _asInt(v['ctmfRadius']) ?? 2, + ctmfPlanes: _asInt(v['ctmfPlanes']) ?? 2, ); } @@ -690,6 +1157,9 @@ class ParameterConverter { final v = params.values; return ChromaDenoiseParameters( enabled: params.enabled, + method: (v['method'] as String?) == 'cnr4' + ? ChromaDenoiseMethod.cnr4 + : ChromaDenoiseMethod.ccd, threshold: (v['threshold'] as num?)?.toDouble() ?? 4.0, temporalRadius: _asInt(v['temporalRadius']) ?? 0, pointsLow: v['pointsLow'] as bool? ?? true, @@ -697,6 +1167,11 @@ class ParameterConverter { pointsHigh: v['pointsHigh'] as bool? ?? false, // Absent means "derive from the frame height". scale: (v['scale'] as num?)?.toDouble(), + cnr4Strength: _asInt(v['cnr4Strength']) ?? 192, + cnr4Sense: _asInt(v['cnr4Sense']) ?? 35, + cnr4Radius: _asInt(v['cnr4Radius']) ?? 2, + cnr4Tmode: _asInt(v['cnr4Tmode']) ?? 0, + cnr4Wmode: _asInt(v['cnr4Wmode']) ?? 0, ); } @@ -711,6 +1186,7 @@ class ParameterConverter { 'edge_cleaner' => DehaloMethod.edgeCleaner, 'vinverse' => DehaloMethod.vinverse, 'vinverse2' => DehaloMethod.vinverse2, + 'hq_deringmod' => DehaloMethod.hqDeringmod, _ => DehaloMethod.dehaloAlpha, }; @@ -742,6 +1218,11 @@ class ParameterConverter { vinverseStrength: (v['vinverseStrength'] as num?)?.toDouble(), vinverseAmount: (v['vinverseAmount'] as num?)?.toInt(), vinverseChroma: v['vinverseChroma'] as bool?, + deringMrad: _asInt(v['deringMrad']), + deringMsmooth: _asInt(v['deringMsmooth']), + deringMthr: _asInt(v['deringMthr']), + deringThr: (v['deringThr'] as num?)?.toDouble(), + deringDarkthr: (v['deringDarkthr'] as num?)?.toDouble(), ); } @@ -754,6 +1235,9 @@ class ParameterConverter { case 'deblock': method = DeblockMethod.deblock; break; + case 'dctfilter': + method = DeblockMethod.dctFilter; + break; default: method = DeblockMethod.deblockQed; } @@ -765,6 +1249,11 @@ class ParameterConverter { quant2: v['quant2'] as int? ?? 26, aOffset1: v['aOffset1'] as int? ?? 1, aOffset2: v['aOffset2'] as int? ?? 1, + dctCutoff: _asInt(v['dctCutoff']) ?? 5, + // Guarded on the Rust side too, but a NaN here would reach a plugin whose + // own range check lets it through and blackens the frame. + dctStrength: (v['dctStrength'] as num?)?.toDouble() ?? 0.6, + dctPlanes: _asInt(v['dctPlanes']) ?? 0, ); } @@ -795,6 +1284,14 @@ class ParameterConverter { static SpotLessParameters toSpotLess(DynamicParameters params) { final v = params.values; return SpotLessParameters( + method: (params.values['method'] as String?) == 'removeDirt' + ? SpotLessMethod.removeDirt + : SpotLessMethod.spotless, + rdGmthreshold: _asInt(params.values['rdGmthreshold']) ?? 70, + rdNoise: _asInt(params.values['rdNoise']) ?? 50, + rdNoisy: _asInt(params.values['rdNoisy']) ?? 12, + rdDist: _asInt(params.values['rdDist']) ?? 1, + rdPostDenoise: params.values['rdPostDenoise'] as bool? ?? false, enabled: params.enabled, chroma: v['chroma'] as bool? ?? true, rec: v['rec'] as bool? ?? false, @@ -829,6 +1326,9 @@ class ParameterConverter { case 'cas': method = SharpenMethod.cas; break; + case 'awarpsharp2': + method = SharpenMethod.aWarpSharp2; + break; default: method = SharpenMethod.lsfmod; } @@ -841,6 +1341,10 @@ class ParameterConverter { undershoot: v['undershoot'] as int? ?? 1, softEdge: v['softEdge'] as int? ?? 0, casSharpness: (v['casSharpness'] as num?)?.toDouble() ?? 0.5, + warpDepth: _asInt(v['warpDepth']) ?? 16, + warpThresh: _asInt(v['warpThresh']) ?? 128, + warpBlur: _asInt(v['warpBlur']) ?? 2, + warpType: _asInt(v['warpType']) ?? 0, ); } @@ -848,6 +1352,12 @@ class ParameterConverter { static ColorCorrectionParameters toColorCorrection(DynamicParameters params) { final v = params.values; return ColorCorrectionParameters( + applyAutoLevels: v['applyAutoLevels'] as bool? ?? false, + autoLevelsBlack: _asInt(v['autoLevelsBlack']) ?? 16, + autoLevelsWhite: _asInt(v['autoLevelsWhite']) ?? 235, + autoLevelsStrength: (v['autoLevelsStrength'] as num?)?.toDouble() ?? 1.0, + applyAutoWhiteBalance: v['applyAutoWhiteBalance'] as bool? ?? false, + autoWhiteBalanceStrength: (v['autoWhiteBalanceStrength'] as num?)?.toDouble() ?? 1.0, enabled: params.enabled, brightness: (v['brightness'] as num?)?.toDouble() ?? 0.0, contrast: (v['contrast'] as num?)?.toDouble() ?? 1.0, @@ -855,6 +1365,9 @@ class ParameterConverter { saturation: (v['saturation'] as num?)?.toDouble() ?? 1.0, coring: v['coring'] as bool? ?? false, applyLevels: v['applyLevels'] as bool? ?? false, + smoothLevels: v['smoothLevels'] as bool? ?? false, + applyShadowDetail: v['applyShadowDetail'] as bool? ?? false, + shadowSigma: (v['shadowSigma'] as num?)?.toDouble() ?? 100.0, inputLow: v['inputLow'] as int? ?? 0, inputHigh: v['inputHigh'] as int? ?? 255, outputLow: v['outputLow'] as int? ?? 0, @@ -869,6 +1382,15 @@ class ParameterConverter { static ChromaFixParameters toChromaFixes(DynamicParameters params) { final v = params.values; return ChromaFixParameters( + applyAutoChroma: v['applyAutoChroma'] as bool? ?? false, + autoChromaMaxShift: _asInt(v['autoChromaMaxShift']) ?? 2, + autoChromaAccuracy: (v['autoChromaAccuracy'] as num?)?.toDouble() ?? 0.25, + autoChromaReferenceFrame: _asInt(v['autoChromaReferenceFrame']) ?? 0, + applyDedot: v['applyDedot'] as bool? ?? false, + dedotLuma2d: _asInt(v['dedotLuma2d']) ?? 20, + dedotLumaT: _asInt(v['dedotLumaT']) ?? 20, + dedotChromaT1: _asInt(v['dedotChromaT1']) ?? 15, + dedotChromaT2: _asInt(v['dedotChromaT2']) ?? 5, enabled: params.enabled, applyChromaShift: v['applyChromaShift'] as bool? ?? false, chromaShiftH: (v['chromaShiftH'] as num?)?.toDouble() ?? 0.0, @@ -879,6 +1401,13 @@ class ParameterConverter { chromaBleedCBlur: (v['chromaBleedCBlur'] as num?)?.toDouble() ?? 0.7, chromaBleedStrength: (v['chromaBleedStrength'] as num?)?.toDouble() ?? 0.8, applyDeCrawl: v['applyDeCrawl'] as bool? ?? false, + applyDeRainbow: v['applyDeRainbow'] as bool? ?? false, + applyBifrost: v['applyBifrost'] as bool? ?? false, + bifrostLumaThresh: (v['bifrostLumaThresh'] as num?)?.toDouble() ?? 10.0, + bifrostVariation: _asInt(v['bifrostVariation']) ?? 5, + bifrostInterlaced: v['bifrostInterlaced'] as bool? ?? true, + deRainbowCThresh: _asInt(v['deRainbowCThresh']) ?? 10, + deRainbowYThresh: _asInt(v['deRainbowYThresh']) ?? 10, deCrawlYThresh: v['deCrawlYThresh'] as int? ?? 10, deCrawlCThresh: v['deCrawlCThresh'] as int? ?? 10, deCrawlMaxDiff: v['deCrawlMaxDiff'] as int? ?? 50, @@ -945,6 +1474,7 @@ class ParameterConverter { final languageStr = v['language'] as String? ?? 'auto'; return SubtitleParameters( + burnInPath: v['burnInPath'] as String? ?? '', enabled: params.enabled, model: WhisperModel.values.firstWhere( (m) => m.value == modelStr, @@ -967,6 +1497,30 @@ class ParameterConverter { noiseReduction: dynamic.get('noise_reduction') != null ? toNoiseReduction(dynamic.get('noise_reduction')!) : const NoiseReductionParameters(), + antiAlias: dynamic.get('anti_alias') != null + ? toAntiAlias(dynamic.get('anti_alias')!) + : const AntiAliasParameters(), + stabilize: dynamic.get('stabilize') != null + ? toStabilize(dynamic.get('stabilize')!) + : const StabilizeParameters(), + geometry: dynamic.get('geometry') != null + ? toGeometry(dynamic.get('geometry')!) + : const GeometryParameters(), + deflicker: dynamic.get('deflicker') != null + ? toDeflicker(dynamic.get('deflicker')!) + : const DeflickerParameters(), + edgeRepair: dynamic.get('edge_repair') != null + ? toEdgeRepair(dynamic.get('edge_repair')!) + : const EdgeRepairParameters(), + ghostRemoval: dynamic.get('ghost_removal') != null + ? toGhostRemoval(dynamic.get('ghost_removal')!) + : const GhostRemovalParameters(), + frameRate: dynamic.get('frame_rate') != null + ? toFrameRate(dynamic.get('frame_rate')!) + : const FrameRateParameters(), + grain: dynamic.get('grain') != null + ? toGrain(dynamic.get('grain')!) + : const GrainParameters(), chromaDenoise: dynamic.get('chroma_denoise') != null ? toChromaDenoise(dynamic.get('chroma_denoise')!) : const ChromaDenoiseParameters(), diff --git a/app/lib/models/pass_advice.dart b/app/lib/models/pass_advice.dart new file mode 100644 index 00000000..5c4eb91f --- /dev/null +++ b/app/lib/models/pass_advice.dart @@ -0,0 +1,141 @@ +import 'processing_pipeline.dart'; +import 'qtgmc_parameters.dart'; + +/// A note about how an enabled pass interacts with the rest of the pipeline. +class PassAdvice { + /// The pass the note is shown against. + final PassType pass; + + final String message; + + const PassAdvice(this.pass, this.message); +} + +/// Advice about combinations of passes, as opposed to the settings of any one +/// pass. +/// +/// At a dozen-odd passes the complexity that actually costs users is not the +/// length of the list, it is *interaction*: two denoisers stacked until the +/// picture is plastic, sharpening applied before the denoiser eats it again, +/// grain re-added before the deband that will smooth it away. None of that is +/// an error — every combination here produces a valid render — so none of it can +/// be caught by validation. It has to be said out loud at the point the user is +/// looking. +/// +/// Three rules, so this stays advice and not nagging: +/// +/// - **Only ever advisory.** Nothing here blocks a job, disables a control or +/// changes a value. A user who wants a stacked denoise is entitled to one; the +/// app's job is to make sure they meant it. +/// - **Only fires on enabled passes.** Advice about a pass nobody has turned on +/// is noise. +/// - **Says what to do, not just what is wrong.** "Sharpen runs before Noise +/// Reduction" is a fact; the useful half is that the denoiser will undo it. +/// +/// Pass order is fixed (see `PassListPanel.stages` and `script_generator.rs`), +/// which is what makes the ordering advice statable at all: Sharpen genuinely +/// always runs before Color Correction, so there is no case to qualify. +List adviseOn(ProcessingPipeline pipeline) { + final advice = []; + + bool on(PassType pass) => pipeline.isPassEnabled(pass); + + // --- Stacked denoisers --- + // Both are motion-compensated temporal denoisers over luma. Running both + // rarely removes more noise than turning one up, and the second pass has + // nothing left to lock its motion search onto. + if (on(PassType.noiseReduction) && on(PassType.chromaDenoise)) { + // Not a conflict: these split luma and chroma, which is a normal and good + // combination. Deliberately silent. + } + + // --- Sharpening fights the denoiser, and loses --- + // Sharpen runs *before* Noise Reduction in the pipeline, so a denoiser set + // strongly enough to matter will remove most of what was just sharpened, and + // amplified noise is what survives. + if (on(PassType.sharpen) && on(PassType.noiseReduction)) { + advice.add(const PassAdvice( + PassType.sharpen, + 'Sharpen runs before Noise Reduction, so the denoiser will soften much ' + 'of this again — and sharpened noise is what it has to work on. ' + 'Consider denoising alone first and judging the result.', + )); + } + + // --- Deband after grain --- + // Deband runs after the denoiser but the f3kdb grain it adds back is applied + // within the same pass, so this is about the sharpener that follows it in + // spirit rather than in fact: sharpening exaggerates the synthetic grain. + if (on(PassType.deband) && on(PassType.sharpen)) { + advice.add(const PassAdvice( + PassType.deband, + 'Sharpening exaggerates the grain Deband adds to hide banding. If the ' + 'result looks gritty, lower the grain here rather than the sharpening.', + )); + } + + // --- Dehalo before sharpening --- + if (on(PassType.dehalo) && on(PassType.sharpen)) { + advice.add(const PassAdvice( + PassType.sharpen, + 'Dehalo removes the bright outlines that sharpening creates, and it runs ' + 'first — so sharpening here can put back exactly what Dehalo took out.', + )); + } + + // --- IVTC plus a frame-rate assumption --- + // Both change the frame rate, in incompatible ways. + if (pipeline.deinterlace.enabled && + pipeline.deinterlace.method == DeinterlaceMethod.ivtc && + pipeline.deinterlace.fpsDivisor != null && + pipeline.deinterlace.fpsDivisor != 1) { + advice.add(const PassAdvice( + PassType.deinterlace, + 'Inverse telecine already sets the output frame rate. The FPS divisor ' + 'applies to QTGMC deinterlacing, not IVTC, and will be ignored.', + )); + } + + // --- Deinterlacing a pass that needs fields, with nothing to deinterlace --- + // Vinverse and DeCrawl exist to clean up what deinterlacing leaves behind, so + // enabling them without it is usually a mistake — though not always, since a + // source can arrive already (badly) deinterlaced. + if (on(PassType.chromaFixes) && + !pipeline.deinterlace.enabled && + (pipeline.chromaFixes.applyVinverse || pipeline.chromaFixes.applyDeCrawl)) { + advice.add(const PassAdvice( + PassType.chromaFixes, + 'Vinverse and dot-crawl removal clean up artifacts left by ' + 'deinterlacing, which is switched off. Useful on a source that was ' + 'already deinterlaced badly, otherwise it has nothing to do.', + )); + } + + // --- Rotating interlaced material destroys it --- + // Fields are stored as alternating horizontal lines. A quarter turn puts them + // in alternating COLUMNS, where no deinterlacer can find them: measured, + // std.SeparateFields on a turned clip returns two "fields" that each still + // contain both, interleaved. It is not a quality trade-off, it is + // unrecoverable — and _FieldBased still claims the clip is fine afterwards. + if (on(PassType.geometry) && + pipeline.geometry.rotation.swapsAxes && + !pipeline.deinterlace.enabled) { + advice.add(const PassAdvice( + PassType.geometry, + 'Rotating an interlaced source by a quarter turn destroys the field ' + 'structure permanently — the comb ends up in columns, where no ' + 'deinterlacer can separate it. If this source is interlaced, turn ' + 'Deinterlace on: it runs first, so the rotation then happens to ' + 'progressive frames.', + )); + } + + return advice; +} + +/// The advice for one pass, or null when there is none. +String? adviceFor(PassType pass, ProcessingPipeline pipeline) { + final matches = + adviseOn(pipeline).where((a) => a.pass == pass).map((a) => a.message); + return matches.isEmpty ? null : matches.join('\n\n'); +} diff --git a/app/lib/models/pass_relevance.dart b/app/lib/models/pass_relevance.dart new file mode 100644 index 00000000..7baeaf26 --- /dev/null +++ b/app/lib/models/pass_relevance.dart @@ -0,0 +1,159 @@ +import '../services/field_order_detector.dart'; +import 'processing_pipeline.dart'; + +/// How much a pass has to do with the file that is actually loaded. +enum PassRelevance { + /// The source shows the problem this pass fixes. Worth the user's attention. + recommended, + + /// Might help, might not — it depends on the source's condition, which + /// detection can't see. Most passes, most of the time. + neutral, + + /// The problem this pass fixes cannot be present in this source. + notApplicable, +} + +/// Whether a pass is worth looking at for a given source. +/// +/// This exists because the honest way to shorten the pass list is to shorten it +/// *for the file in hand*, and the app already detects enough to do that — scan +/// type, resolution, codec, pixel format and sample aspect all come back from +/// [FieldOrderDetector] before the list is ever shown. +/// +/// Two rules keep it useful rather than noisy: +/// +/// - **Recommend sparingly.** A badge on nine rows out of thirteen is not a +/// recommendation, it is decoration. Only claims backed by strong evidence +/// from detection are made, which is why most passes come back [neutral] — +/// film dirt, scratches, grain and over-sharpening are all invisible to +/// ffprobe, so the app has nothing to say about them and says nothing. +/// - **Never reorder, never disable.** The pass list's order is the order the +/// passes run in, and a [notApplicable] pass stays enabled and editable — +/// detection is a hint, not an authority, and it is wrong often enough +/// (`ScanType.unknown` exists for a reason) that overriding the user would +/// be worse than saying nothing. +class PassRelevanceResult { + final PassRelevance level; + + /// Short phrase for the UI, e.g. "source is progressive". Null when + /// [neutral], because there is nothing to explain. + final String? reason; + + const PassRelevanceResult(this.level, [this.reason]); + + static const neutral = PassRelevanceResult(PassRelevance.neutral); + + bool get isRecommended => level == PassRelevance.recommended; + bool get isNotApplicable => level == PassRelevance.notApplicable; +} + +/// Standard-definition height. Composite-video artefacts — dot crawl, rainbows, +/// chroma bleed — come from analogue broadcast and tape, so they effectively +/// only appear at or below SD. +const int _sdMaxHeight = 576; + +/// Decide how relevant [pass] is to [info]. +/// +/// Returns [PassRelevanceResult.neutral] for a null [info] (nothing loaded yet) +/// and for anything detection can't speak to. +PassRelevanceResult relevanceFor(PassType pass, VideoInfo? info) { + if (info == null) return PassRelevanceResult.neutral; + + final isProgressive = info.scanType == ScanType.progressive; + final isFielded = info.scanType == ScanType.interlaced || + info.scanType == ScanType.telecine || + info.scanType == ScanType.softTelecine; + final isSd = info.height <= _sdMaxHeight; + + switch (pass) { + case PassType.deinterlace: + if (isFielded) { + return PassRelevanceResult( + PassRelevance.recommended, + 'source is ${info.scanType.displayName.toLowerCase()}', + ); + } + if (isProgressive) { + return const PassRelevanceResult( + PassRelevance.notApplicable, + 'source is progressive', + ); + } + // ScanType.unknown — detection failed, so say nothing either way. + return PassRelevanceResult.neutral; + + case PassType.chromaFixes: + // Dot crawl, rainbowing and chroma bleed are composite-video faults, so + // they need an SD fielded source to be plausible. + if (isSd && isFielded) { + return const PassRelevanceResult( + PassRelevance.recommended, + 'SD interlaced source — likely an analogue capture', + ); + } + if (isProgressive && !isSd) { + return const PassRelevanceResult( + PassRelevance.notApplicable, + 'progressive HD source has no composite artifacts', + ); + } + return PassRelevanceResult.neutral; + + case PassType.deblock: + // MPEG-2 at DVD and broadcast bitrates blocks visibly; nothing else in + // the metadata is a reliable signal, since ffprobe won't tell us how hard + // the encoder was pushed. + if (info.codec.toLowerCase() == 'mpeg2video') { + return const PassRelevanceResult( + PassRelevance.recommended, + 'MPEG-2 source — blocking is common', + ); + } + return PassRelevanceResult.neutral; + + case PassType.cropResize: + // A non-square sample aspect means the output shape is a decision the + // user has to make, whether or not they want to crop or scale. + if (info.sar != null && info.sar!.isNotEmpty) { + return PassRelevanceResult( + PassRelevance.recommended, + 'anamorphic source (${info.sar}) — check pixel aspect', + ); + } + return PassRelevanceResult.neutral; + + // Everything below is invisible to detection. Film dirt, scratches, grain, + // halos, banding and colour casts need eyes on the picture, so the app has + // no opinion and offers none. + case PassType.descratch: + case PassType.spotless: + case PassType.noiseReduction: + case PassType.chromaDenoise: + case PassType.dehalo: + case PassType.deband: + case PassType.sharpen: + case PassType.colorCorrection: + case PassType.subtitles: + // 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. + // Badging every interlaced source would dilute the badge, which is the one + // thing that makes it worth having. + case PassType.antiAlias: + case PassType.stabilize: + // Orientation is a fact about how the footage was shot, not a defect + // ffprobe reports — a rotation flag in the container is not the same as + // the picture being the wrong way round. + case PassType.geometry: + // Frame rate conversion is a deliberate choice about the delivery + // format, not something detection can recommend — a 25 fps source is not + // evidence that anyone wants it at 29.97. + case PassType.deflicker: + case PassType.edgeRepair: + case PassType.ghostRemoval: + case PassType.frameRate: + case PassType.grain: + return PassRelevanceResult.neutral; + } +} diff --git a/app/lib/models/processing_pipeline.dart b/app/lib/models/processing_pipeline.dart index 18a0d0e7..f15e23ab 100644 --- a/app/lib/models/processing_pipeline.dart +++ b/app/lib/models/processing_pipeline.dart @@ -1,5 +1,12 @@ import 'package:json_annotation/json_annotation.dart'; +import 'anti_alias_parameters.dart'; +import 'geometry_parameters.dart'; +import 'deflicker_parameters.dart'; +import 'edge_repair_parameters.dart'; +import 'frame_rate_parameters.dart'; +import 'ghost_removal_parameters.dart'; +import 'grain_parameters.dart'; import 'chroma_fix_parameters.dart'; import 'color_correction_parameters.dart'; import 'crop_resize_parameters.dart'; @@ -7,6 +14,7 @@ import 'deband_parameters.dart'; import 'deblock_parameters.dart'; import 'descratch_parameters.dart'; import 'spotless_parameters.dart'; +import 'stabilize_parameters.dart'; import 'dehalo_parameters.dart'; import 'dynamic_parameters.dart'; import 'chroma_denoise_parameters.dart'; @@ -29,6 +37,14 @@ enum PassType { deblock, deband, sharpen, + antiAlias, + stabilize, + geometry, + grain, + frameRate, + deflicker, + edgeRepair, + ghostRemoval, colorCorrection, chromaFixes, cropResize, @@ -57,6 +73,22 @@ extension PassTypeExtension on PassType { return 'Deband'; case PassType.sharpen: return 'Sharpen'; + case PassType.antiAlias: + return 'Anti-Aliasing'; + case PassType.stabilize: + return 'Stabilize'; + case PassType.geometry: + return 'Rotate / Flip'; + case PassType.grain: + return 'Film Grain'; + case PassType.frameRate: + return 'Frame Rate'; + case PassType.deflicker: + return 'Deflicker'; + case PassType.edgeRepair: + return 'Edge Repair'; + case PassType.ghostRemoval: + return 'Ghost Removal'; case PassType.colorCorrection: return 'Color Correction'; case PassType.chromaFixes: @@ -88,6 +120,22 @@ extension PassTypeExtension on PassType { return 'Remove color banding from gradients'; case PassType.sharpen: return 'Sharpen edges and enhance detail'; + case PassType.antiAlias: + return 'Smooth stair-stepping on diagonal edges'; + case PassType.stabilize: + return 'Remove shake and weave from the picture'; + case PassType.geometry: + return 'Rotate or mirror the picture'; + case PassType.grain: + return 'Add film grain back after denoising'; + case PassType.frameRate: + return 'Convert between PAL and NTSC frame rates'; + case PassType.deflicker: + return 'Even out brightness pulsing between frames'; + case PassType.edgeRepair: + return 'Rebuild the dirty rows and columns at the frame border'; + case PassType.ghostRemoval: + return 'Remove the displaced echo RF and cable distribution leave behind'; case PassType.colorCorrection: return 'Adjust brightness, contrast, and colors'; case PassType.chromaFixes: @@ -130,6 +178,14 @@ class ProcessingPipeline { /// Sharpening pass parameters. final SharpenParameters sharpen; + final AntiAliasParameters antiAlias; + final StabilizeParameters stabilize; + final GeometryParameters geometry; + final GrainParameters grain; + final FrameRateParameters frameRate; + final DeflickerParameters deflicker; + final EdgeRepairParameters edgeRepair; + final GhostRemovalParameters ghostRemoval; /// Color correction pass parameters. final ColorCorrectionParameters colorCorrection; @@ -153,6 +209,14 @@ class ProcessingPipeline { this.deblock = const DeblockParameters(), this.deband = const DebandParameters(), this.sharpen = const SharpenParameters(), + this.antiAlias = const AntiAliasParameters(), + this.stabilize = const StabilizeParameters(), + this.geometry = const GeometryParameters(), + this.grain = const GrainParameters(), + this.frameRate = const FrameRateParameters(), + this.deflicker = const DeflickerParameters(), + this.edgeRepair = const EdgeRepairParameters(), + this.ghostRemoval = const GhostRemovalParameters(), this.colorCorrection = const ColorCorrectionParameters(), this.chromaFixes = const ChromaFixParameters(), this.cropResize = const CropResizeParameters(), @@ -172,6 +236,14 @@ class ProcessingPipeline { deblock: const DeblockParameters(enabled: false), deband: const DebandParameters(enabled: false), sharpen: const SharpenParameters(enabled: false), + antiAlias: const AntiAliasParameters(enabled: false), + stabilize: const StabilizeParameters(enabled: false), + geometry: const GeometryParameters(enabled: false), + grain: const GrainParameters(enabled: false), + frameRate: const FrameRateParameters(enabled: false), + deflicker: const DeflickerParameters(enabled: false), + edgeRepair: const EdgeRepairParameters(enabled: false), + ghostRemoval: const GhostRemovalParameters(enabled: false), colorCorrection: const ColorCorrectionParameters(enabled: false), chromaFixes: const ChromaFixParameters(enabled: false), cropResize: const CropResizeParameters(enabled: false), @@ -189,6 +261,21 @@ class ProcessingPipeline { if (deinterlace.enabled) { passes.add(PassType.deinterlace); } + // Edge repair precedes every spatial filter, or denoising and sharpening + // smear the bad rows inward; and precedes the resize, or resampling spreads + // them. Ghost removal follows it and precedes the denoise, so the denoiser + // does not average the echo into the picture. Deflicker follows + // deinterlacing (field-doubled frames break its temporal comparisons) and + // precedes the dirt and denoise passes, which assume a stable exposure. + if (edgeRepair.hasEffect) { + passes.add(PassType.edgeRepair); + } + if (ghostRemoval.hasEffect) { + passes.add(PassType.ghostRemoval); + } + if (deflicker.enabled) { + passes.add(PassType.deflicker); + } if (descratch.enabled) { passes.add(PassType.descratch); } @@ -212,6 +299,11 @@ class ProcessingPipeline { if (deband.enabled) { passes.add(PassType.deband); } + // Anti-aliasing before sharpening: sharpening stair-stepped edges makes + // the stepping more visible, not less. + if (antiAlias.enabled) { + passes.add(PassType.antiAlias); + } if (sharpen.enabled) { passes.add(PassType.sharpen); } @@ -221,12 +313,34 @@ class ProcessingPipeline { if (colorCorrection.enabled) { passes.add(PassType.colorCorrection); } + // Stabilisation shifts the picture within the frame, so it runs last + // before framing — a crop can then remove the edges it exposes. + if (stabilize.enabled) { + passes.add(PassType.stabilize); + } + // Rotation swaps width and height, so it settles before any framing + // decision. It also follows deinterlacing: fields run horizontally, so + // turning a still-interlaced clip shears them. + if (geometry.hasEffect) { + passes.add(PassType.geometry); + } if (cropResize.enabled && cropResize.resizeEnabled) { // Resize (post-processing) - if not already added for crop if (!passes.contains(PassType.cropResize)) { passes.add(PassType.cropResize); } } + // Grain goes last of the video passes: added before the resize it is + // resampled away, before the deband it is smoothed away. + if (grain.hasEffect) { + passes.add(PassType.grain); + } + // Genuinely last: it resamples the timeline, so anything after it would + // be working on invented frames rather than photographed ones. + if (frameRate.enabled) { + passes.add(PassType.frameRate); + } + return passes; } @@ -242,6 +356,14 @@ class ProcessingPipeline { if (deblock.enabled) count++; if (deband.enabled) count++; if (sharpen.enabled) count++; + if (antiAlias.enabled) count++; + if (stabilize.enabled) count++; + if (geometry.hasEffect) count++; + if (grain.hasEffect) count++; + if (frameRate.enabled) count++; + if (deflicker.enabled) count++; + if (edgeRepair.hasEffect) count++; + if (ghostRemoval.hasEffect) count++; if (colorCorrection.enabled) count++; if (chromaFixes.enabled) count++; if (cropResize.enabled) count++; @@ -261,6 +383,14 @@ class ProcessingPipeline { if (deblock.enabled) count++; if (deband.enabled) count++; if (sharpen.enabled) count++; + if (antiAlias.enabled) count++; + if (stabilize.enabled) count++; + if (geometry.hasEffect) count++; + if (grain.hasEffect) count++; + if (frameRate.enabled) count++; + if (deflicker.enabled) count++; + if (edgeRepair.hasEffect) count++; + if (ghostRemoval.hasEffect) count++; if (colorCorrection.enabled) count++; if (chromaFixes.enabled) count++; if (cropResize.enabled) count++; @@ -288,6 +418,22 @@ class ProcessingPipeline { return deband.enabled; case PassType.sharpen: return sharpen.enabled; + case PassType.antiAlias: + return antiAlias.enabled; + case PassType.stabilize: + return stabilize.enabled; + case PassType.geometry: + return geometry.hasEffect; + case PassType.grain: + return grain.hasEffect; + case PassType.frameRate: + return frameRate.enabled; + case PassType.deflicker: + return deflicker.enabled; + case PassType.edgeRepair: + return edgeRepair.hasEffect; + case PassType.ghostRemoval: + return ghostRemoval.hasEffect; case PassType.colorCorrection: return colorCorrection.enabled; case PassType.chromaFixes: @@ -322,6 +468,22 @@ class ProcessingPipeline { return deband.summary; case PassType.sharpen: return sharpen.summary; + case PassType.antiAlias: + return antiAlias.summary; + case PassType.stabilize: + return stabilize.summary; + case PassType.geometry: + return geometry.summary; + case PassType.grain: + return grain.summary; + case PassType.frameRate: + return frameRate.summary; + case PassType.deflicker: + return deflicker.summary; + case PassType.edgeRepair: + return edgeRepair.summary; + case PassType.ghostRemoval: + return ghostRemoval.summary; case PassType.colorCorrection: return colorCorrection.summary; case PassType.chromaFixes: @@ -343,6 +505,14 @@ class ProcessingPipeline { DeblockParameters? deblock, DebandParameters? deband, SharpenParameters? sharpen, + AntiAliasParameters? antiAlias, + StabilizeParameters? stabilize, + GeometryParameters? geometry, + GrainParameters? grain, + FrameRateParameters? frameRate, + DeflickerParameters? deflicker, + EdgeRepairParameters? edgeRepair, + GhostRemovalParameters? ghostRemoval, ColorCorrectionParameters? colorCorrection, ChromaFixParameters? chromaFixes, CropResizeParameters? cropResize, @@ -358,6 +528,14 @@ class ProcessingPipeline { deblock: deblock ?? this.deblock, deband: deband ?? this.deband, sharpen: sharpen ?? this.sharpen, + antiAlias: antiAlias ?? this.antiAlias, + stabilize: stabilize ?? this.stabilize, + geometry: geometry ?? this.geometry, + grain: grain ?? this.grain, + frameRate: frameRate ?? this.frameRate, + deflicker: deflicker ?? this.deflicker, + edgeRepair: edgeRepair ?? this.edgeRepair, + ghostRemoval: ghostRemoval ?? this.ghostRemoval, colorCorrection: colorCorrection ?? this.colorCorrection, chromaFixes: chromaFixes ?? this.chromaFixes, cropResize: cropResize ?? this.cropResize, @@ -404,6 +582,34 @@ class ProcessingPipeline { return copyWith( sharpen: sharpen.copyWith(enabled: enabled), ); + case PassType.antiAlias: + return copyWith( + antiAlias: antiAlias.copyWith(enabled: enabled), + ); + case PassType.stabilize: + return copyWith( + stabilize: stabilize.copyWith(enabled: enabled), + ); + case PassType.geometry: + return copyWith( + geometry: geometry.copyWith(enabled: enabled), + ); + case PassType.grain: + return copyWith( + grain: grain.copyWith(enabled: enabled), + ); + case PassType.frameRate: + return copyWith( + frameRate: frameRate.copyWith(enabled: enabled), + ); + case PassType.deflicker: + return copyWith(deflicker: deflicker.copyWith(enabled: enabled)); + case PassType.edgeRepair: + return copyWith(edgeRepair: edgeRepair.copyWith(enabled: enabled)); + case PassType.ghostRemoval: + return copyWith( + ghostRemoval: ghostRemoval.copyWith(enabled: enabled), + ); case PassType.colorCorrection: return copyWith( colorCorrection: colorCorrection.copyWith(enabled: enabled), diff --git a/app/lib/models/processing_preset.dart b/app/lib/models/processing_preset.dart index 44ebf772..6ca55b06 100644 --- a/app/lib/models/processing_preset.dart +++ b/app/lib/models/processing_preset.dart @@ -1,15 +1,45 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:uuid/uuid.dart'; +import 'chroma_denoise_parameters.dart'; +import 'chroma_fix_parameters.dart'; +import 'deband_parameters.dart'; +import 'deblock_parameters.dart'; import 'dehalo_parameters.dart'; +import 'descratch_parameters.dart'; +import 'deflicker_parameters.dart'; +import 'edge_repair_parameters.dart'; import 'encoding_settings.dart'; import 'noise_reduction_parameters.dart'; import 'qtgmc_parameters.dart'; +import 'sharpen_parameters.dart'; +import 'stabilize_parameters.dart'; import 'processing_pipeline.dart'; +import 'spotless_parameters.dart'; import 'video_job.dart'; part 'processing_preset.g.dart'; +/// What kind of choice a preset represents, which is how the preset menu groups +/// them. +/// +/// Mixing "how hard should it try" with "what did you capture" in one flat list +/// makes both harder to pick from — they answer different questions. +enum PresetCategory { + /// A quality/speed tier. Differs from its siblings only in effort, not in + /// which problems it fixes. + @JsonValue('quality') + quality, + + /// Shaped for a kind of source: a VHS tape, a DVD, a film scan. + @JsonValue('source') + source, + + /// Anything the user saved themselves. + @JsonValue('custom') + custom, +} + /// A saved preset containing filter and encoding settings. @JsonSerializable(explicitToJson: true) class ProcessingPreset { @@ -34,6 +64,13 @@ class ProcessingPreset { /// Whether this is a built-in preset (read-only). final bool isBuiltIn; + /// Which group this belongs to in the preset menu. Declared by each built-in + /// factory rather than looked up from a list of ids elsewhere, so a new preset + /// cannot silently land in the wrong group. Defaults to [PresetCategory.custom], + /// which is right for anything a user saves and for presets saved before this + /// field existed. + final PresetCategory category; + ProcessingPreset({ String? id, required this.name, @@ -42,6 +79,7 @@ class ProcessingPreset { required this.encodingSettings, DateTime? createdAt, this.isBuiltIn = false, + this.category = PresetCategory.custom, }) : id = id ?? const Uuid().v4(), createdAt = createdAt ?? DateTime.now(); @@ -57,6 +95,7 @@ class ProcessingPreset { EncodingSettings? encodingSettings, DateTime? createdAt, bool? isBuiltIn, + PresetCategory? category, }) { return ProcessingPreset( id: id ?? this.id, @@ -66,6 +105,7 @@ class ProcessingPreset { encodingSettings: encodingSettings ?? this.encodingSettings, createdAt: createdAt ?? this.createdAt, isBuiltIn: isBuiltIn ?? this.isBuiltIn, + category: category ?? this.category, ); } @@ -76,8 +116,13 @@ class ProcessingPreset { name: 'Fast', description: 'Quick processing with lower quality settings', pipeline: ProcessingPipeline( + // Bwdif rather than a cheaper QTGMC: measured 622 fps against QTGMC + // Fast's 150. Until now this tier was only a faster QTGMC, which is + // not what "Fast" should mean. The QTGMC preset is kept so that + // switching method in the UI lands somewhere sensible. deinterlace: QTGMCParameters( enabled: true, + method: DeinterlaceMethod.bwdif, preset: QTGMCPreset.faster, ), ), @@ -86,6 +131,7 @@ class ProcessingPreset { quality: 20, ), isBuiltIn: true, + category: PresetCategory.quality, ); } @@ -106,6 +152,7 @@ class ProcessingPreset { quality: 18, ), isBuiltIn: true, + category: PresetCategory.quality, ); } @@ -128,6 +175,7 @@ class ProcessingPreset { quality: 16, ), isBuiltIn: true, + category: PresetCategory.quality, ); } @@ -151,12 +199,47 @@ class ProcessingPreset { smDegrainRefine: true, smDegrainPrefilter: 2, ), + // CCD is the filter the restoration community reaches for first on + // tape, and it was shipping unused: chroma noise on VHS is a different + // failure from the luma grain SMDegrain handles, so one does not cover + // the other. + chromaDenoise: ChromaDenoiseParameters( + enabled: true, + threshold: 5.0, + ), + // Dot crawl along sharp colour edges is the signature composite-capture + // artefact, and nothing in this preset touched it. + chromaFixes: ChromaFixParameters( + enabled: true, + preset: ChromaFixPreset.custom, + applyDedot: true, + ), dehalo: DehaloParameters( enabled: true, method: DehaloMethod.dehaloAlpha, rx: 3.0, ry: 3.0, ), + // Composite captures are soft, and LSFmod is the sharpener that suits + // them because it is built not to add halos — which matters here more + // than usual, since the dehalo pass above has just removed some. + // Modest strength: this runs after a denoise, and sharpening a denoised + // picture hard is how tape captures end up looking artificial. + sharpen: SharpenParameters( + enabled: true, + method: SharpenMethod.lsfmod, + strength: 80, + ), + // Dirty edge rows are near-universal on tape, and cropping them away + // throws picture away with them. Two rows all round is the common case; + // the counts move in twos, which is what keeps chroma aligned. + edgeRepair: EdgeRepairParameters( + enabled: true, + left: 2, + right: 2, + top: 2, + bottom: 2, + ), ), encodingSettings: EncodingSettings( codec: VideoCodec.ffv1, @@ -165,6 +248,7 @@ class ProcessingPreset { quality: 18, ), isBuiltIn: true, + category: PresetCategory.source, ); } @@ -188,17 +272,187 @@ class ProcessingPreset { quality: 18, ), isBuiltIn: true, + category: PresetCategory.source, + ); + } + + /// Built-in preset: PAL DVD / digital broadcast (576i MPEG-2). + /// + /// Named for the source rather than the technique, which is the point: the + /// user knows what they fed in, not which denoiser it wants. MPEG-2 at DVD and + /// broadcast bitrates blocks visibly, so this pairs deinterlacing with a + /// deblock and deliberately leaves denoising off — the picture is usually + /// clean, and denoising it costs detail for nothing. + static ProcessingPreset builtInPalDvd() { + return ProcessingPreset( + id: 'builtin-pal-dvd', + name: 'PAL DVD / Broadcast', + description: 'Interlaced 576i MPEG-2: deinterlace and deblock, no denoise', + pipeline: ProcessingPipeline( + deinterlace: QTGMCParameters( + enabled: true, + preset: QTGMCPreset.slow, + ), + deblock: DeblockParameters( + enabled: true, + method: DeblockMethod.deblockQed, + ), + ), + encodingSettings: EncodingSettings( + encoderPreset: 'medium', + quality: 18, + ), + isBuiltIn: true, + category: PresetCategory.source, + ); + } + + /// Built-in preset: DV camcorder tape (DV/DVCAM, interlaced). + /// + /// DV's heavily subsampled chroma (4:1:1 on NTSC, 4:2:0 on PAL) misaligns and + /// bleeds, so the chroma work matters more here than the noise work. Noise is + /// light rather than off: DV tape is grainier than a DVD but far cleaner than + /// VHS. + static ProcessingPreset builtInDvCamcorder() { + return ProcessingPreset( + id: 'builtin-dv-camcorder', + name: 'DV Camcorder Tape', + description: 'Interlaced DV: deinterlace, fix chroma alignment, light denoise', + pipeline: ProcessingPipeline( + deinterlace: QTGMCParameters( + enabled: true, + preset: QTGMCPreset.slow, + chromaUpsampleFix: true, + ), + // fromPreset, not `preset:` — the enum on its own is only a label, and + // passing it without the matching values leaves every threshold at its + // default, i.e. "light" would denoise exactly as hard as "moderate". + noiseReduction: + NoiseReductionParameters.fromPreset(NoiseReductionPreset.light), + chromaFixes: ChromaFixParameters( + enabled: true, + preset: ChromaFixPreset.custom, + applyChromaBleedingFix: true, + // DV's problem is chroma alignment above all, and measuring it beats + // asking the user to guess it on a slider. + applyAutoChroma: true, + ), + // DV's chroma is heavily subsampled and noisy with it, and the light + // luma denoise above deliberately leaves it alone. Gentler than the VHS + // setting: DV chroma is misaligned more than it is dirty. + chromaDenoise: ChromaDenoiseParameters( + enabled: true, + threshold: 3.0, + ), + ), + encodingSettings: EncodingSettings( + encoderPreset: 'medium', + quality: 18, + ), + isBuiltIn: true, + category: PresetCategory.source, + ); + } + + /// Built-in preset: 8mm / Super 8 film scan. + /// + /// A film scan is already progressive, so deinterlacing is off — running it + /// would only soften the picture. What film has instead is physical damage, + /// which is what DeScratch and SpotLess are for. Encodes to FFV1 because a + /// scan is usually a master, not a delivery copy. + static ProcessingPreset builtInFilmScan() { + return ProcessingPreset( + id: 'builtin-film-scan', + name: '8mm / Super 8 Film Scan', + description: + 'Progressive film scan: dust, scratches and grain — no deinterlacing', + pipeline: ProcessingPipeline( + // Explicitly off, and it has to be: QTGMCParameters defaults to + // `enabled: true`, so a preset that simply omits deinterlacing gets it + // anyway — which on a progressive scan just softens the picture. + deinterlace: QTGMCParameters(enabled: false), + descratch: DeScratchParameters(enabled: true), + spotless: SpotLessParameters(enabled: true), + noiseReduction: + NoiseReductionParameters.fromPreset(NoiseReductionPreset.moderate), + // Gate weave is the first thing anyone notices on a cine scan, and this + // pass shipped for exactly this source while no preset used it. The + // pipeline already runs Stabilize last before Crop/Resize, so the thin + // empty edges it exposes can be cropped afterwards. + stabilize: StabilizeParameters(enabled: true), + // Brightness pulsing is the other half of what a cine scan suffers + // from, and the whole-frame method removes about 83% of it. + deflicker: DeflickerParameters(enabled: true), + ), + encodingSettings: EncodingSettings( + codec: VideoCodec.ffv1, + container: ContainerFormat.mkv, + encoderPreset: 'medium', + quality: 18, + ), + isBuiltIn: true, + category: PresetCategory.source, + ); + } + + /// Built-in preset: anime DVD (telecined film, drawn line art). + /// + /// Telecined, so it wants IVTC rather than deinterlacing. Line art shows the + /// two faults live action mostly hides: halos from the broadcast chain's + /// sharpening, and banding across the large flat colour areas. + static ProcessingPreset builtInAnimeDvd() { + return ProcessingPreset( + id: 'builtin-anime-dvd', + name: 'Anime DVD', + description: 'Telecined line art: inverse telecine, dehalo and deband', + pipeline: ProcessingPipeline( + deinterlace: QTGMCParameters( + enabled: true, + method: DeinterlaceMethod.ivtc, + ivtcOrder: 1, + ivtcMode: 1, + ivtcCycle: 5, + ), + dehalo: DehaloParameters( + enabled: true, + method: DehaloMethod.fineDehalo, + ), + deband: DebandParameters(enabled: true), + // Composite-mastered anime discs are notorious for rainbowing on fine + // line art. LUTDeRainbow shipped for this and was used by nothing. + chromaFixes: ChromaFixParameters( + enabled: true, + preset: ChromaFixPreset.custom, + applyDeRainbow: true, + // Complementary rather than redundant: measured, each reaches a crawl + // geometry the other leaves alone. + applyDedot: true, + ), + ), + encodingSettings: EncodingSettings( + encoderPreset: 'medium', + quality: 18, + ), + isBuiltIn: true, + category: PresetCategory.source, ); } /// Get all built-in presets. + /// + /// Ordered so the source-shaped presets come after the three quality tiers: + /// a user who knows what they captured should be able to find it by name. static List builtInPresets() { return [ builtInFast(), builtInBalanced(), builtInHighQuality(), builtInVhsCleanup(), + builtInDvCamcorder(), + builtInPalDvd(), builtInDvdIvtc(), + builtInAnimeDvd(), + builtInFilmScan(), ]; } } diff --git a/app/lib/models/qtgmc_parameters.dart b/app/lib/models/qtgmc_parameters.dart index 6ea7712c..9fb05acc 100644 --- a/app/lib/models/qtgmc_parameters.dart +++ b/app/lib/models/qtgmc_parameters.dart @@ -10,7 +10,9 @@ enum DeinterlaceMethod { @JsonValue('ivtc') ivtc, @JsonValue('softTelecine') - softTelecine; + softTelecine, + @JsonValue('bwdif') + bwdif; String get displayName { switch (this) { @@ -20,6 +22,8 @@ enum DeinterlaceMethod { return 'IVTC'; case DeinterlaceMethod.softTelecine: return 'Soft Telecine'; + case DeinterlaceMethod.bwdif: + return 'Bwdif (fast)'; } } @@ -31,6 +35,8 @@ enum DeinterlaceMethod { return 'Inverse telecine for DVD 3:2 pulldown sources'; case DeinterlaceMethod.softTelecine: return 'Fix frame rate for DVD soft telecine sources'; + case DeinterlaceMethod.bwdif: + return 'Much faster than QTGMC, at most of the quality'; } } } @@ -133,6 +139,9 @@ class QTGMCParameters { final bool? tff; final int? fpsDivisor; + /// Hand Bwdif an nnedi3 interpolator instead of its own cubic one. + final bool bwdifEdeint; + // === Working Format (issue #49) === /// Upsample 4:2:0 chroma to 4:2:2 (field-aware) before deinterlacing and /// restore the source format afterwards. Costs roughly 30% throughput, so @@ -269,6 +278,7 @@ class QTGMCParameters { this.inputType, this.tff, this.fpsDivisor, + this.bwdifEdeint = false, this.chromaUpsampleFix, this.highPrecision, this.tr0, diff --git a/app/lib/models/sharpen_parameters.dart b/app/lib/models/sharpen_parameters.dart index 56e444d8..b4eb52b3 100644 --- a/app/lib/models/sharpen_parameters.dart +++ b/app/lib/models/sharpen_parameters.dart @@ -8,7 +8,9 @@ enum SharpenMethod { @JsonValue('LSFmod') lsfmod('LSFmod', 'LSFmod'), @JsonValue('CAS') - cas('CAS', 'Contrast Adaptive Sharpening'); + cas('CAS', 'Contrast Adaptive Sharpening'), + @JsonValue('AWarpSharp2') + aWarpSharp2('AWarpSharp2', 'aWarpSharp2'); const SharpenMethod(this.value, this.displayName); final String value; @@ -20,6 +22,9 @@ enum SharpenMethod { return 'Limited sharpening with overshoot control'; case SharpenMethod.cas: return 'AMD Contrast Adaptive Sharpening'; + case SharpenMethod.aWarpSharp2: + return 'Warps pixels toward edges instead of raising contrast, so it ' + 'adds no halos'; } } } @@ -52,6 +57,20 @@ class SharpenParameters { /// CAS sharpening amount (0.0-1.0). final double casSharpness; + // --- aWarpSharp2 parameters --- + + /// How far pixels may be warped (0-255). The main strength control. + final int warpDepth; + + /// Edge mask threshold (0-255). Lower finds more edges to warp toward. + final int warpThresh; + + /// Mask blur passes (0-3). More blur warps more smoothly. + final int warpBlur; + + /// Blur kernel: 0 = radius 6 box (per-pass), 1 = radius 2 box. + final int warpType; + const SharpenParameters({ this.enabled = false, this.method = SharpenMethod.lsfmod, @@ -60,6 +79,10 @@ class SharpenParameters { this.undershoot = 1, this.softEdge = 0, this.casSharpness = 0.5, + this.warpDepth = 16, + this.warpThresh = 128, + this.warpBlur = 2, + this.warpType = 0, }); SharpenParameters copyWith({ @@ -70,6 +93,10 @@ class SharpenParameters { int? undershoot, int? softEdge, double? casSharpness, + int? warpDepth, + int? warpThresh, + int? warpBlur, + int? warpType, }) { return SharpenParameters( enabled: enabled ?? this.enabled, @@ -79,6 +106,10 @@ class SharpenParameters { undershoot: undershoot ?? this.undershoot, softEdge: softEdge ?? this.softEdge, casSharpness: casSharpness ?? this.casSharpness, + warpDepth: warpDepth ?? this.warpDepth, + warpThresh: warpThresh ?? this.warpThresh, + warpBlur: warpBlur ?? this.warpBlur, + warpType: warpType ?? this.warpType, ); } diff --git a/app/lib/models/spotless_parameters.dart b/app/lib/models/spotless_parameters.dart index 78ed9a84..af5e2439 100644 --- a/app/lib/models/spotless_parameters.dart +++ b/app/lib/models/spotless_parameters.dart @@ -2,6 +2,17 @@ import 'package:json_annotation/json_annotation.dart'; part 'spotless_parameters.g.dart'; +/// Which spot remover to run. +enum SpotLessMethod { + @JsonValue('spotless') + spotless('SpotLess'), + @JsonValue('removeDirt') + removeDirt('RemoveDirt (fast)'); + + const SpotLessMethod(this.displayName); + final String displayName; +} + /// Parameters for the SpotLess pass. /// Removes dust, dirt, and temporal spots using motion-compensated median. @JsonSerializable() @@ -9,6 +20,16 @@ class SpotLessParameters { /// Whether this pass is enabled. final bool enabled; + /// Which spot remover to run. + final SpotLessMethod method; + + /// RemoveDirt tuning. + final int rdGmthreshold; + final int rdNoise; + final int rdNoisy; + final int rdDist; + final bool rdPostDenoise; + /// Process chroma planes (default true). final bool chroma; @@ -26,6 +47,12 @@ class SpotLessParameters { const SpotLessParameters({ this.enabled = false, + this.method = SpotLessMethod.spotless, + this.rdGmthreshold = 70, + this.rdNoise = 50, + this.rdNoisy = 12, + this.rdDist = 1, + this.rdPostDenoise = false, this.chroma = true, this.rec = false, this.blksize = 16, diff --git a/app/lib/models/stabilize_parameters.dart b/app/lib/models/stabilize_parameters.dart new file mode 100644 index 00000000..076dda3f --- /dev/null +++ b/app/lib/models/stabilize_parameters.dart @@ -0,0 +1,58 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'stabilize_parameters.g.dart'; + +/// Parameters for the stabilisation pass. +/// +/// Cancels global shake — telecine weave, jittery film scans, handheld +/// camcorder footage — while leaving deliberate camera movement alone. It shifts +/// the picture within the frame, so it runs last before framing: a crop can then +/// remove the edges it exposes. +/// +/// The frame count is unchanged, which is what keeps this an ordinary pass +/// rather than one of the frame-rate-changing filters. +@JsonSerializable() +class StabilizeParameters { + /// Whether this pass is enabled. + final bool enabled; + + /// Maximum horizontal correction in pixels. + final int dxmax; + + /// Maximum vertical correction in pixels. + final int dymax; + + /// How to fill the edges the shift exposes: 0 none (left black), 1 top and + /// bottom, 2 left and right, 3 all four. + /// + /// The bundled havsfunc signature is `Stab(clp, dxmax, dymax, mirror)` — + /// there is no `range` argument, whatever other implementations take. + final int mirror; + + const StabilizeParameters({ + this.enabled = false, + this.dxmax = 4, + this.dymax = 4, + this.mirror = 0, + }); + + StabilizeParameters copyWith({ + bool? enabled, + int? dxmax, + int? dymax, + int? mirror, + }) => + StabilizeParameters( + enabled: enabled ?? this.enabled, + dxmax: dxmax ?? this.dxmax, + dymax: dymax ?? this.dymax, + mirror: mirror ?? this.mirror, + ); + + /// Short summary for the pass list row. + String get summary => enabled ? 'Up to ${dxmax}x$dymax px' : 'Off'; + + factory StabilizeParameters.fromJson(Map json) => + _$StabilizeParametersFromJson(json); + Map toJson() => _$StabilizeParametersToJson(this); +} diff --git a/app/lib/models/subtitle_parameters.dart b/app/lib/models/subtitle_parameters.dart index 75390ac3..6739d83b 100644 --- a/app/lib/models/subtitle_parameters.dart +++ b/app/lib/models/subtitle_parameters.dart @@ -25,7 +25,11 @@ enum SubtitleOutput { @JsonValue('embed') embed('embed', 'Embed in Video'), @JsonValue('both') - both('both', 'Both'); + both('both', 'Both'), + @JsonValue('burn_in') + burnIn('burn_in', 'Burn into the picture'), + @JsonValue('burn_in_and_srt') + burnInAndSrt('burn_in_and_srt', 'Burn in and keep the file'); const SubtitleOutput(this.value, this.displayName); final String value; @@ -38,6 +42,10 @@ class SubtitleParameters { /// Whether this pass is enabled. final bool enabled; + /// A subtitle file to draw into the picture. Per job, not a global setting: + /// one file's subtitles must not be applied to every video in a batch. + final String burnInPath; + /// Whisper model to use. final WhisperModel model; @@ -49,6 +57,7 @@ class SubtitleParameters { const SubtitleParameters({ this.enabled = false, + this.burnInPath = '', this.model = WhisperModel.medium, this.output = SubtitleOutput.srtFile, this.language, diff --git a/app/lib/models/video_job.dart b/app/lib/models/video_job.dart index 128823ce..1a248134 100644 --- a/app/lib/models/video_job.dart +++ b/app/lib/models/video_job.dart @@ -46,6 +46,19 @@ class VideoJob { final int? inputHeight; final String? inputPixelFormat; + /// Source colour tags, re-declared on the output by the worker. The Y4M pipe + /// strips them as it strips SAR, so without these every output is untagged + /// and read as BT.601 limited. + final String? inputColorMatrix; + final String? inputColorPrimaries; + final String? inputColorTransfer; + final String? inputColorRange; + + /// A subtitle file to burn into the picture, per job. Deliberately not an + /// encoding setting: a path baked into settings would apply one file's + /// subtitles to every video in a batch. + final String? burnInSubtitlePath; + VideoJob({ String? id, required this.inputPath, @@ -64,6 +77,11 @@ class VideoJob { this.inputWidth, this.inputHeight, this.inputPixelFormat, + this.inputColorMatrix, + this.inputColorPrimaries, + this.inputColorTransfer, + this.inputColorRange, + this.burnInSubtitlePath, }) : id = id ?? const Uuid().v4(), qtgmcParameters = qtgmcParameters ?? QTGMCParameters(), encodingSettings = encodingSettings ?? EncodingSettings(); @@ -95,6 +113,11 @@ class VideoJob { int? inputWidth, int? inputHeight, String? inputPixelFormat, + String? inputColorMatrix, + String? inputColorPrimaries, + String? inputColorTransfer, + String? inputColorRange, + String? burnInSubtitlePath, }) { return VideoJob( id: id ?? this.id, @@ -114,6 +137,11 @@ class VideoJob { inputWidth: inputWidth ?? this.inputWidth, inputHeight: inputHeight ?? this.inputHeight, inputPixelFormat: inputPixelFormat ?? this.inputPixelFormat, + inputColorMatrix: inputColorMatrix ?? this.inputColorMatrix, + inputColorPrimaries: inputColorPrimaries ?? this.inputColorPrimaries, + inputColorTransfer: inputColorTransfer ?? this.inputColorTransfer, + inputColorRange: inputColorRange ?? this.inputColorRange, + burnInSubtitlePath: burnInSubtitlePath ?? this.burnInSubtitlePath, ); } } diff --git a/app/lib/services/advanced_mode_service.dart b/app/lib/services/advanced_mode_service.dart new file mode 100644 index 00000000..b8ea86f2 --- /dev/null +++ b/app/lib/services/advanced_mode_service.dart @@ -0,0 +1,72 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Whether the filter panels show their advanced controls. +/// +/// This is one app-wide setting rather than per-panel state, and that is the +/// point: advanced mode is the lever that keeps the pass list usable as the +/// filter set grows, so it has to be worth hiding things behind. When each +/// panel kept its own flag it reset on every collapse, which meant an expert +/// re-flipped it constantly and nothing could be hidden aggressively enough to +/// matter. +/// +/// Two things sit behind it — advanced-only [UiSection]s, and now advanced-only +/// [MethodDefinition]s, so a filter can offer four methods to everyone and +/// sixteen to someone who has asked for them. +/// +/// Listeners rebuild on change, so the panels are `Consumer`/`watch` users; the +/// choice is persisted in shared_preferences under `showAdvancedOptions`. +/// Call [initialize] once at startup so the first panel build already has the +/// saved value and doesn't flash from simple to advanced. +class AdvancedModeService extends ChangeNotifier { + static final AdvancedModeService instance = AdvancedModeService._(); + AdvancedModeService._(); + + static const String _prefsKey = 'showAdvancedOptions'; + + bool _enabled = false; + bool _loaded = false; + + /// Whether advanced controls are shown. Defaults to off — the app's audience + /// arrives with a tape, not a filter preference. + bool get enabled => _enabled; + + /// Load the saved choice. Safe to call again; only the first call reads + /// storage. + Future initialize() async { + if (_loaded) return; + try { + final prefs = await SharedPreferences.getInstance(); + _enabled = prefs.getBool(_prefsKey) ?? false; + } catch (_) { + // Unreadable preferences shouldn't stop the app starting, and simple + // mode is the safe default to fall back to. + _enabled = false; + } + _loaded = true; + } + + /// Turn advanced controls on or off everywhere, and remember the choice. + Future setEnabled(bool value) async { + if (_enabled == value && _loaded) return; + + _enabled = value; + _loaded = true; + notifyListeners(); + + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefsKey, value); + } catch (_) { + // Keep the in-memory choice for this session even if it can't be saved. + } + } + + /// Reset to the unloaded default. Tests only — the singleton outlives a + /// single test case otherwise. + @visibleForTesting + void resetForTesting() { + _enabled = false; + _loaded = false; + } +} diff --git a/app/lib/services/field_order_detector.dart b/app/lib/services/field_order_detector.dart index e00061ac..e8096370 100644 --- a/app/lib/services/field_order_detector.dart +++ b/app/lib/services/field_order_detector.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart'; + import '../models/video_job.dart'; import 'tool_locator.dart'; @@ -124,6 +126,10 @@ class FieldOrderDetector { scanType: scanType, hasAudio: audioStream != null, sar: sar, + colorMatrix: colorTag(videoStream, 'color_space'), + colorPrimaries: colorTag(videoStream, 'color_primaries'), + colorTransfer: colorTag(videoStream, 'color_transfer'), + colorRange: colorTag(videoStream, 'color_range'), ); } catch (e) { return null; @@ -558,6 +564,21 @@ enum ScanType { } } +/// Read one ffprobe colour tag, treating "unknown"/"N/A" as absent. +/// +/// ffprobe reports the literal string `unknown` for an untagged stream, and +/// forwarding that as though it were a value would put nonsense on the encoder +/// command line. Anything unrecognised is dropped again worker-side, in +/// `ColorMetadata::from_raw`, so a surprising value cannot reach ffmpeg. +@visibleForTesting +String? colorTag(Map stream, String key) { + final value = (stream[key] as String?)?.trim(); + if (value == null || value.isEmpty || value == 'unknown' || value == 'N/A') { + return null; + } + return value; +} + /// Video file information. class VideoInfo { final int width; @@ -573,6 +594,15 @@ class VideoInfo { /// Sample aspect ratio from the input (e.g. "10:11"), null if 1:1 or unknown. final String? sar; + /// Colour tags as reported by ffprobe, null when the stream is untagged. + /// These are carried to the encoder and re-declared there: the Y4M pipe from + /// vspipe strips them exactly as it strips SAR, and an untagged output is + /// read as BT.601 limited by every player. + final String? colorMatrix; + final String? colorPrimaries; + final String? colorTransfer; + final String? colorRange; + const VideoInfo({ required this.width, required this.height, @@ -585,6 +615,10 @@ class VideoInfo { this.scanType = ScanType.unknown, required this.hasAudio, this.sar, + this.colorMatrix, + this.colorPrimaries, + this.colorTransfer, + this.colorRange, }); String get resolution => '${width}x$height'; diff --git a/app/lib/viewmodels/main_viewmodel.dart b/app/lib/viewmodels/main_viewmodel.dart index 0470ab8f..140a0077 100644 --- a/app/lib/viewmodels/main_viewmodel.dart +++ b/app/lib/viewmodels/main_viewmodel.dart @@ -188,6 +188,22 @@ class MainViewModel extends ChangeNotifier { return ParameterConverter.fromDeband(_processingPipeline.deband); case 'sharpen': return ParameterConverter.fromSharpen(_processingPipeline.sharpen); + case 'anti_alias': + return ParameterConverter.fromAntiAlias(_processingPipeline.antiAlias); + case 'stabilize': + return ParameterConverter.fromStabilize(_processingPipeline.stabilize); + case 'geometry': + return ParameterConverter.fromGeometry(_processingPipeline.geometry); + case 'grain': + return ParameterConverter.fromGrain(_processingPipeline.grain); + case 'frame_rate': + return ParameterConverter.fromFrameRate(_processingPipeline.frameRate); + case 'deflicker': + return ParameterConverter.fromDeflicker(_processingPipeline.deflicker); + case 'edge_repair': + return ParameterConverter.fromEdgeRepair(_processingPipeline.edgeRepair); + case 'ghost_removal': + return ParameterConverter.fromGhostRemoval(_processingPipeline.ghostRemoval); case 'color_correction': return ParameterConverter.fromColorCorrection(_processingPipeline.colorCorrection); case 'chroma_fixes': @@ -249,6 +265,57 @@ class MainViewModel extends ChangeNotifier { sharpen: ParameterConverter.toSharpen(params), ); break; + case 'anti_alias': + _processingPipeline = _processingPipeline.copyWith( + antiAlias: ParameterConverter.toAntiAlias(params), + ); + break; + case 'stabilize': + _processingPipeline = _processingPipeline.copyWith( + stabilize: ParameterConverter.toStabilize(params), + ); + break; + case 'geometry': + _processingPipeline = _processingPipeline.copyWith( + geometry: ParameterConverter.toGeometry(params), + ); + break; + case 'grain': + _processingPipeline = _processingPipeline.copyWith( + grain: ParameterConverter.toGrain(params), + ); + break; + case 'deflicker': + _processingPipeline = _processingPipeline.copyWith( + deflicker: ParameterConverter.toDeflicker(params), + ); + break; + case 'edge_repair': + _processingPipeline = _processingPipeline.copyWith( + edgeRepair: ParameterConverter.toEdgeRepair(params), + ); + break; + case 'ghost_removal': + // `custom` keeps the job's existing ghost list, so a hand-built one + // survives a round trip through the preset dropdown. + _processingPipeline = _processingPipeline.copyWith( + ghostRemoval: ParameterConverter.toGhostRemoval( + params, + existing: _processingPipeline.ghostRemoval.ghosts, + ), + ); + break; + case 'frame_rate': + // The source rate is carried through rather than round-tripped: it + // comes from detection, not from the UI, and the worker needs it to + // report a correct frame map. + _processingPipeline = _processingPipeline.copyWith( + frameRate: ParameterConverter.toFrameRate(params).copyWith( + sourceFpsNum: _processingPipeline.frameRate.sourceFpsNum, + sourceFpsDen: _processingPipeline.frameRate.sourceFpsDen, + ), + ); + break; case 'color_correction': _processingPipeline = _processingPipeline.copyWith( colorCorrection: ParameterConverter.toColorCorrection(params), @@ -1037,6 +1104,22 @@ class MainViewModel extends ChangeNotifier { return 'deband'; case PassType.sharpen: return 'sharpen'; + case PassType.antiAlias: + return 'anti_alias'; + case PassType.stabilize: + return 'stabilize'; + case PassType.geometry: + return 'geometry'; + case PassType.grain: + return 'grain'; + case PassType.frameRate: + return 'frame_rate'; + case PassType.deflicker: + return 'deflicker'; + case PassType.edgeRepair: + return 'edge_repair'; + case PassType.ghostRemoval: + return 'ghost_removal'; case PassType.colorCorrection: return 'color_correction'; case PassType.chromaFixes: @@ -1165,6 +1248,16 @@ class MainViewModel extends ChangeNotifier { : null, subtitleOnly: isSubtitleOnly, inputSar: item.videoInfo?.sar, + inputColorMatrix: item.videoInfo?.colorMatrix, + inputColorPrimaries: item.videoInfo?.colorPrimaries, + inputColorTransfer: item.videoInfo?.colorTransfer, + inputColorRange: item.videoInfo?.colorRange, + // Burn-in is per job by design: a path held in settings would apply one + // file's subtitles to every video in a batch. + burnInSubtitlePath: + _processingPipeline.subtitles.burnInPath.trim().isEmpty + ? null + : _processingPipeline.subtitles.burnInPath.trim(), ); // Record the audio config each file is actually encoded with. If a file's diff --git a/app/lib/views/about_dialog.dart b/app/lib/views/about_dialog.dart index 76d30f3f..f9b4f7a5 100644 --- a/app/lib/views/about_dialog.dart +++ b/app/lib/views/about_dialog.dart @@ -370,6 +370,41 @@ class _AboutDialogState extends State { 'Fredrik Mellbin', url: 'https://github.com/dubhater/vapoursynth-bifrost', ), + _ComponentTile( + name: 'Bwdif', + license: 'LGPL 3.0+', + copyright: 'Thomas Mundt and HolyWu, based on YADIF by ' + 'Michael Niedermayer and James Darnley, using the ' + 'BBC Weston 3-field algorithm', + url: 'https://github.com/HomeOfVapourSynthEvolution/VapourSynth-Bwdif', + ), + _ComponentTile( + name: 'FillBorders', + license: 'WTFPL', + copyright: 'dubhater — no copyright asserted upstream', + url: 'https://github.com/dubhater/vapoursynth-fillborders', + ), + _ComponentTile( + name: 'RemoveDirt', + license: 'GPL 2.0+', + copyright: 'Rainer Wittmann; additional work by ' + 'Ferenc Pintér', + url: 'https://github.com/pinterf/RemoveDirt', + ), + _ComponentTile( + name: 'DeDot', + license: 'GPL 2.0', + copyright: 'Fredrik Mellbin, after the AviSynth DeDot ' + 'filter', + url: 'https://github.com/dubhatervapoursynth/vapoursynth-dedot', + ), + _ComponentTile( + name: 'LGhost', + license: 'GPL 3.0+', + copyright: 'Port by HolyWu; original AviSynth plugin by ' + 'minamina', + url: 'https://github.com/HomeOfVapourSynthEvolution/VapourSynth-LGhost', + ), _ComponentTile( name: 'Vector Class Library', license: 'Apache 2.0', diff --git a/app/lib/views/histogram_data.dart b/app/lib/views/histogram_data.dart new file mode 100644 index 00000000..31d1f9b3 --- /dev/null +++ b/app/lib/views/histogram_data.dart @@ -0,0 +1,183 @@ +import 'dart:typed_data'; + +/// Histogram binning for the preview scope. +/// +/// Why this exists: forum advice on setting levels is literally "watch the +/// histogram", and this app ships colour controls (Levels, Tweak, white +/// balance) with no way to see what they are doing. Automatic levels and +/// automatic white balance are coming too, and a user needs to be able to +/// sanity-check what they decided. +/// +/// Why it is computed here rather than in VapourSynth: the app already holds +/// the processed preview as PNG bytes, so binning it costs no worker round +/// trip, no template change and no deps release, and it refreshes exactly when +/// the preview does. +/// +/// This file is deliberately pure — no Flutter imports, no I/O — so the +/// binning can be unit-tested without a widget. + +/// The channels a scope can display. +enum HistogramChannel { luma, red, green, blue } + +/// Bin counts for one decoded frame. +/// +/// Every channel has [binCount] bins, indexed by the 8-bit sample value. +class HistogramData { + /// Number of bins per channel — one per 8-bit level. + static const int binCount = 256; + + /// Rec.709 luma weights, pre-multiplied by 256 so luma can be derived with + /// integer math. They sum to exactly 256, so a neutral grey `v` maps back to + /// bin `v` with no drift. + static const int _lumaR = 54; // 0.2126 * 256 + static const int _lumaG = 183; // 0.7152 * 256 + static const int _lumaB = 19; // 0.0722 * 256 + + final Uint32List _luma; + final Uint32List _red; + final Uint32List _green; + final Uint32List _blue; + + /// How many pixels were counted. Zero for [HistogramData.empty]. + final int sampleCount; + + const HistogramData._( + this._luma, + this._red, + this._green, + this._blue, + this.sampleCount, + ); + + /// A histogram with no samples — the "no preview yet" state. + factory HistogramData.empty() => HistogramData._( + Uint32List(binCount), + Uint32List(binCount), + Uint32List(binCount), + Uint32List(binCount), + 0, + ); + + /// True when nothing was counted, so painters can draw an empty state + /// instead of dividing by zero. + bool get isEmpty => sampleCount == 0; + + /// The bins for [channel]. The returned list is the live backing store — + /// treat it as read-only. + Uint32List bins(HistogramChannel channel) { + switch (channel) { + case HistogramChannel.luma: + return _luma; + case HistogramChannel.red: + return _red; + case HistogramChannel.green: + return _green; + case HistogramChannel.blue: + return _blue; + } + } + + /// The tallest bin in [channel], including the clipping bins at 0 and 255. + int peak(HistogramChannel channel) { + final b = bins(channel); + var maximum = 0; + for (var i = 0; i < binCount; i++) { + if (b[i] > maximum) maximum = b[i]; + } + return maximum; + } + + /// The tallest bin in [channel] ignoring the two clipping bins. + /// + /// A letterboxed or heavily crushed frame piles an enormous count into bin 0, + /// and scaling the plot to that flattens everything else into invisibility. + /// Scaling to the interior peak instead keeps the shape of the picture + /// readable; the clipping bins are drawn clamped to the top of the plot. + int interiorPeak(HistogramChannel channel) { + final b = bins(channel); + var maximum = 0; + for (var i = 1; i < binCount - 1; i++) { + if (b[i] > maximum) maximum = b[i]; + } + return maximum; + } + + /// The scale a plot of [channel] should use: the interior peak, falling back + /// to the overall peak for a frame that is nothing but clipped values. + int plotScale(HistogramChannel channel) { + final interior = interiorPeak(channel); + return interior > 0 ? interior : peak(channel); + } + + /// Fraction of pixels sitting at luma 0 (crushed blacks), 0.0 to 1.0. + double get shadowClipFraction => + sampleCount == 0 ? 0.0 : _luma[0] / sampleCount; + + /// Fraction of pixels sitting at luma 255 (blown highlights), 0.0 to 1.0. + double get highlightClipFraction => + sampleCount == 0 ? 0.0 : _luma[binCount - 1] / sampleCount; +} + +/// Bins raw RGBA bytes into per-channel counts. +/// +/// [rgba] is tightly packed 8-bit RGBA, four bytes per pixel — exactly what +/// `ui.Image.toByteData(format: ImageByteFormat.rawRgba)` returns. Decoding +/// through `dart:ui` is what makes this safe for VapourBox's high-bit-depth +/// path: a >8-bit source produces an `rgb48be` preview PNG (16 bits per +/// channel), so the *file* has no 4-byte RGBA stride. The codec normalises it +/// to 8-bit RGBA before we ever see it. +/// +/// [pixelStride] counts every Nth pixel, for capping the cost on large frames. +/// Values below 1 are treated as 1. A trailing partial pixel is ignored, and +/// the alpha byte is ignored entirely (previews are opaque). +/// +/// An empty or sub-pixel-sized buffer yields [HistogramData.empty] rather than +/// throwing, so the "no preview yet" state needs no special case at the call +/// site. +HistogramData computeHistogramFromRgba( + Uint8List rgba, { + int pixelStride = 1, +}) { + final stride = pixelStride < 1 ? 1 : pixelStride; + final pixelCount = rgba.length ~/ 4; + if (pixelCount == 0) return HistogramData.empty(); + + final luma = Uint32List(HistogramData.binCount); + final red = Uint32List(HistogramData.binCount); + final green = Uint32List(HistogramData.binCount); + final blue = Uint32List(HistogramData.binCount); + + final byteStride = stride * 4; + var samples = 0; + for (var i = 0; i + 3 < rgba.length; i += byteStride) { + final r = rgba[i]; + final g = rgba[i + 1]; + final b = rgba[i + 2]; + red[r]++; + green[g]++; + blue[b]++; + // +128 rounds to nearest rather than truncating, so grey stays put. + luma[(HistogramData._lumaR * r + + HistogramData._lumaG * g + + HistogramData._lumaB * b + + 128) >> + 8]++; + samples++; + } + + return HistogramData._(luma, red, green, blue, samples); +} + +/// Picks a [pixelStride] that keeps binning to roughly [maxSamples] pixels. +/// +/// Binning is O(pixels) on the UI isolate, so a 4K preview is capped rather +/// than counted in full. The shape of a histogram is unchanged by sampling +/// every Nth pixel; only the absolute counts shrink, and nothing here reads +/// those as absolutes. +/// +/// The default cap is set above a full PAL/NTSC/720p frame, so this app's +/// usual sources are counted in full and only large previews subsample. +int pixelStrideFor(int pixelCount, {int maxSamples = 500000}) { + if (pixelCount <= maxSamples || maxSamples < 1) return 1; + return (pixelCount / maxSamples).ceil(); +} diff --git a/app/lib/views/histogram_scope.dart b/app/lib/views/histogram_scope.dart new file mode 100644 index 00000000..fe9b33d4 --- /dev/null +++ b/app/lib/views/histogram_scope.dart @@ -0,0 +1,425 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart' show listEquals; +import 'package:flutter/material.dart'; + +import 'histogram_data.dart'; + +/// A histogram overlay for the preview. +/// +/// Forum advice on setting levels is literally "watch the histogram", and this +/// app has colour controls with no way to see what they are doing — plus +/// automatic levels and automatic white balance on the way, which a user needs +/// to be able to sanity-check. +/// +/// It bins the preview image the app already holds, so it costs no worker round +/// trip and refreshes whenever the preview does. Binning happens once per image +/// (in [didUpdateWidget]), never per repaint. +class HistogramScope extends StatefulWidget { + /// Encoded preview image (PNG). Null or empty renders the empty state. + final Uint8List? imageBytes; + + /// Invoked by the card's close button, if provided. + final VoidCallback? onClose; + + /// Width of the scope card. + final double width; + + /// Height of the plot area (excluding header and footer). + final double plotHeight; + + const HistogramScope({ + super.key, + required this.imageBytes, + this.onClose, + this.width = 232, + this.plotHeight = 88, + }); + + @override + State createState() => _HistogramScopeState(); +} + +enum _ScopeMode { luma, rgb } + +class _HistogramScopeState extends State { + HistogramData? _data; + _ScopeMode _mode = _ScopeMode.luma; + + /// Guards against an older decode finishing after a newer one. + int _requestToken = 0; + bool _isBinning = false; + + @override + void initState() { + super.initState(); + _rebin(); + } + + @override + void didUpdateWidget(covariant HistogramScope oldWidget) { + super.didUpdateWidget(oldWidget); + // A new preview is always a new buffer, so identity is the right test — + // and it keeps a rebuild from re-decoding the same bytes. + if (!identical(oldWidget.imageBytes, widget.imageBytes)) { + _rebin(); + } + } + + Future _rebin() async { + final bytes = widget.imageBytes; + final token = ++_requestToken; + + if (bytes == null || bytes.isEmpty) { + if (mounted && _data != null) setState(() => _data = null); + return; + } + + if (mounted && !_isBinning) setState(() => _isBinning = true); + + final data = await _binEncodedImage(bytes); + + if (!mounted || token != _requestToken) return; + setState(() { + _data = data; + _isBinning = false; + }); + } + + /// Decodes [bytes] and bins the result. + /// + /// The decode goes through `instantiateImageCodec`, which normalises to 8-bit + /// RGBA. That matters here: a >8-bit source gives an `rgb48be` preview PNG + /// (16 bits per channel), so the encoded file has no 4-byte stride to assume. + static Future _binEncodedImage(Uint8List bytes) async { + ui.Codec? codec; + ui.Image? image; + try { + codec = await ui.instantiateImageCodec(bytes); + final frame = await codec.getNextFrame(); + image = frame.image; + final byteData = + await image.toByteData(format: ui.ImageByteFormat.rawRgba); + if (byteData == null) return null; + final rgba = byteData.buffer.asUint8List( + byteData.offsetInBytes, + byteData.lengthInBytes, + ); + return computeHistogramFromRgba( + rgba, + pixelStride: pixelStrideFor(image.width * image.height), + ); + } catch (_) { + // A truncated or unreadable preview must not take the panel down. + return null; + } finally { + image?.dispose(); + codec?.dispose(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final data = _data; + + return Container( + width: widget.width, + padding: const EdgeInsets.fromLTRB(10, 6, 10, 8), + decoration: BoxDecoration( + // Sits on top of the image, so it needs its own ground rather than + // borrowing whatever pixels are behind it. + color: colorScheme.surface.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.outline.withValues(alpha: 0.4)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 6, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHeader(context), + const SizedBox(height: 4), + SizedBox( + height: widget.plotHeight, + child: data == null || data.isEmpty + ? _buildEmptyPlot(context) + : CustomPaint( + painter: _HistogramPainter( + data: data, + channels: _mode == _ScopeMode.luma + ? const [HistogramChannel.luma] + : const [ + HistogramChannel.red, + HistogramChannel.green, + HistogramChannel.blue, + ], + traceColors: _mode == _ScopeMode.luma + ? [colorScheme.onSurface] + : _rgbTraceColors(theme.brightness), + gridColor: colorScheme.outline.withValues(alpha: 0.25), + borderColor: colorScheme.outline.withValues(alpha: 0.5), + ), + child: const SizedBox.expand(), + ), + ), + const SizedBox(height: 4), + _buildFooter(context, data), + ], + ), + ); + } + + Widget _buildHeader(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Row( + children: [ + Expanded( + child: Text( + 'Histogram', + style: theme.textTheme.labelMedium?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.8), + fontWeight: FontWeight.bold, + ), + ), + ), + _buildModeButton(context, _ScopeMode.luma, 'Luma'), + const SizedBox(width: 2), + _buildModeButton(context, _ScopeMode.rgb, 'RGB'), + if (widget.onClose != null) ...[ + const SizedBox(width: 2), + Tooltip( + message: 'Hide histogram', + child: InkWell( + onTap: widget.onClose, + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.all(2), + child: Icon( + Icons.close, + size: 14, + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ), + ), + ], + ], + ); + } + + Widget _buildModeButton(BuildContext context, _ScopeMode mode, String label) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final selected = _mode == mode; + + return InkWell( + onTap: selected ? null : () => setState(() => _mode = mode), + borderRadius: BorderRadius.circular(4), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: selected ? colorScheme.primaryContainer : Colors.transparent, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: selected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurface.withValues(alpha: 0.6), + fontWeight: selected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ); + } + + Widget _buildEmptyPlot(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: colorScheme.outline.withValues(alpha: 0.3)), + ), + child: Center( + child: Text( + _isBinning ? 'Reading preview...' : 'No preview yet', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ), + ); + } + + Widget _buildFooter(BuildContext context, HistogramData? data) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final style = theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.6), + ); + + final clipped = data != null && !data.isEmpty + ? 'clip ${_percent(data.shadowClipFraction)} / ' + '${_percent(data.highlightClipFraction)}' + : ''; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('0', style: style), + // Flexible + ellipsis: the readout is the only variable-width thing + // here, and a large text scale must not overflow the card. + Flexible( + child: Tooltip( + message: 'Pixels at black (0) / at white (255)', + child: Text( + clipped, + style: style, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + Text('255', style: style), + ], + ); + } + + static String _percent(double fraction) { + final value = fraction * 100; + if (value <= 0) return '0%'; + if (value < 0.1) return '<0.1%'; + final text = value.toStringAsFixed(1); + return '${text.endsWith('.0') ? text.substring(0, text.length - 2) : text}%'; + } + + /// The three channel traces are the one place a literal colour is right: the + /// channel *is* the colour, so a themed accent would be meaningless. These + /// are Material palette entries (the same source as the in/out markers in + /// the scrubber), picked per brightness so they stay legible on both themes. + static List _rgbTraceColors(Brightness brightness) { + return brightness == Brightness.dark + ? [Colors.red.shade400, Colors.green.shade400, Colors.blue.shade400] + : [Colors.red.shade700, Colors.green.shade800, Colors.blue.shade800]; + } +} + +/// Paints one or more channel traces into the plot rect. +/// +/// The painter does no binning — it reads counts that were computed once when +/// the image changed, so a resize or a theme change is a cheap repaint. +class _HistogramPainter extends CustomPainter { + final HistogramData data; + final List channels; + final List traceColors; + final Color gridColor; + final Color borderColor; + + _HistogramPainter({ + required this.data, + required this.channels, + required this.traceColors, + required this.gridColor, + required this.borderColor, + }); + + @override + void paint(Canvas canvas, Size size) { + if (size.width <= 0 || size.height <= 0) return; + + final rect = Offset.zero & size; + final rrect = RRect.fromRectAndRadius(rect, const Radius.circular(4)); + canvas.save(); + canvas.clipRRect(rrect); + + // Quarter-tone gridlines, the reference marks for judging a level move. + final gridPaint = Paint() + ..color = gridColor + ..strokeWidth = 1; + for (var i = 1; i < 4; i++) { + final x = size.width * i / 4; + canvas.drawLine(Offset(x, 0), Offset(x, size.height), gridPaint); + } + + for (var c = 0; c < channels.length; c++) { + final channel = channels[c]; + final color = traceColors[c % traceColors.length]; + final scale = data.plotScale(channel); + if (scale <= 0) continue; + _paintChannel(canvas, size, data.bins(channel), scale, color, + filled: channels.length == 1); + } + + canvas.restore(); + canvas.drawRRect( + rrect.deflate(0.5), + Paint() + ..color = borderColor + ..style = PaintingStyle.stroke + ..strokeWidth = 1, + ); + } + + void _paintChannel( + Canvas canvas, + Size size, + Uint32List bins, + int scale, + Color color, { + required bool filled, + }) { + final binWidth = size.width / HistogramData.binCount; + final path = Path()..moveTo(0, size.height); + + for (var i = 0; i < HistogramData.binCount; i++) { + // Bins 0 and 255 can tower over the rest; clamping keeps the plot's + // shape readable while still showing that they are pegged. + final normalized = (bins[i] / scale).clamp(0.0, 1.0); + final y = size.height - normalized * size.height; + final x0 = i * binWidth; + path.lineTo(x0, y); + path.lineTo(x0 + binWidth, y); + } + path.lineTo(size.width, size.height); + + if (filled) { + canvas.drawPath(path, Paint()..color = color.withValues(alpha: 0.35)); + } else { + // Overlapping channels: additive-ish fills so neutral areas read as a + // single stack rather than whichever channel was painted last. + canvas.drawPath(path, Paint()..color = color.withValues(alpha: 0.22)); + } + + canvas.drawPath( + path, + Paint() + ..color = color.withValues(alpha: 0.9) + ..style = PaintingStyle.stroke + ..strokeWidth = 1, + ); + } + + @override + bool shouldRepaint(covariant _HistogramPainter oldDelegate) { + return !identical(oldDelegate.data, data) || + !listEquals(oldDelegate.channels, channels) || + !listEquals(oldDelegate.traceColors, traceColors) || + oldDelegate.gridColor != gridColor || + oldDelegate.borderColor != borderColor; + } +} diff --git a/app/lib/views/main_window.dart b/app/lib/views/main_window.dart index 283e6ce2..5b5c8a58 100644 --- a/app/lib/views/main_window.dart +++ b/app/lib/views/main_window.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../models/encoding_settings.dart'; +import '../models/processing_preset.dart'; import '../models/progress_info.dart'; import '../models/queue_item.dart'; import '../services/audio_compatibility_service.dart'; @@ -141,23 +142,54 @@ class MainWindow extends StatelessWidget { }, itemBuilder: (context) { final presets = viewModel.availablePresets; - final builtIn = presets.where((p) => p.isBuiltIn).toList(); final user = presets.where((p) => !p.isBuiltIn).toList(); + // Split the built-ins by what question they answer. "How hard + // should it try" and "what did you capture" are different + // decisions, and one flat list of nine makes both harder to pick + // from. Anything uncategorised falls in with the source presets + // rather than disappearing. + final quality = presets + .where((p) => + p.isBuiltIn && p.category == PresetCategory.quality) + .toList(); + final bySource = presets + .where((p) => + p.isBuiltIn && p.category != PresetCategory.quality) + .toList(); + + PopupMenuItem presetItem(ProcessingPreset p) => + PopupMenuItem( + value: 'load:${p.id}', + child: ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + title: Text(p.name), + subtitle: p.description != null + ? Text(p.description!, + style: const TextStyle(fontSize: 11)) + : null, + ), + ); + return [ - const PopupMenuItem( - enabled: false, - child: Text('Built-in Presets', style: TextStyle(fontWeight: FontWeight.bold)), - ), - ...builtIn.map((p) => PopupMenuItem( - value: 'load:${p.id}', - child: ListTile( - contentPadding: EdgeInsets.zero, - dense: true, - title: Text(p.name), - subtitle: p.description != null ? Text(p.description!, style: const TextStyle(fontSize: 11)) : null, - ), - )), + if (bySource.isNotEmpty) ...[ + const PopupMenuItem( + enabled: false, + child: Text('For Your Source', + style: TextStyle(fontWeight: FontWeight.bold)), + ), + ...bySource.map(presetItem), + ], + if (quality.isNotEmpty) ...[ + const PopupMenuDivider(), + const PopupMenuItem( + enabled: false, + child: Text('Quality Only (deinterlace)', + style: TextStyle(fontWeight: FontWeight.bold)), + ), + ...quality.map(presetItem), + ], if (user.isNotEmpty) ...[ const PopupMenuDivider(), const PopupMenuItem( diff --git a/app/lib/views/pass_list/pass_list_item.dart b/app/lib/views/pass_list/pass_list_item.dart index 471688c9..d69db197 100644 --- a/app/lib/views/pass_list/pass_list_item.dart +++ b/app/lib/views/pass_list/pass_list_item.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../models/pass_relevance.dart'; import '../../models/processing_pipeline.dart'; /// A single item in the pass list showing a processing pass. @@ -16,6 +17,11 @@ class PassListItem extends StatelessWidget { final ValueChanged onToggle; final VoidCallback onTap; + /// How much this pass has to do with the loaded file. Drives a badge or a + /// muted note only — it never reorders the row or disables the control, since + /// detection is a hint and is sometimes wrong. + final PassRelevanceResult relevance; + /// Settings shown inline while expanded. Only built for the expanded item. final Widget? expandedChild; @@ -28,6 +34,7 @@ class PassListItem extends StatelessWidget { required this.isExpanded, required this.onToggle, required this.onTap, + this.relevance = PassRelevanceResult.neutral, this.expandedChild, }); @@ -121,21 +128,42 @@ class PassListItem extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - color: isEnabled - ? colorScheme.onSurface - : colorScheme.onSurface.withValues(alpha: 0.5), + Row( + children: [ + Flexible( + child: Text( + title, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + color: isEnabled + ? colorScheme.onSurface + : colorScheme.onSurface.withValues(alpha: 0.5), + ), ), + ), + if (relevance.isRecommended) ...[ + const SizedBox(width: 8), + _buildSuggestedBadge(context, colorScheme), + ], + ], ), Text( - subtitle, + // The reason a pass is suggested, or can't apply, is more + // use than a generic settings summary — but only while the + // pass is off. Once the user has turned it on they have + // made the call, and the settings matter again. + (!isEnabled && relevance.reason != null) + ? relevance.reason! + : subtitle, style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontStyle: (!isEnabled && relevance.reason != null) + ? FontStyle.italic + : FontStyle.normal, color: isEnabled ? colorScheme.onSurface.withValues(alpha: 0.7) - : colorScheme.onSurface.withValues(alpha: 0.4), + : colorScheme.onSurface.withValues( + alpha: relevance.isNotApplicable ? 0.3 : 0.4, + ), ), ), ], @@ -160,6 +188,27 @@ class PassListItem extends StatelessWidget { ); } + /// "Suggested" rather than "Recommended": it is a hint from metadata, and + /// overstating it would be misleading on the sources where detection is + /// wrong. + Widget _buildSuggestedBadge(BuildContext context, ColorScheme colorScheme) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Suggested', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + fontSize: 10, + ), + ), + ); + } + IconData _getIconForPass(PassType pass) { switch (pass) { case PassType.deinterlace: @@ -180,6 +229,22 @@ class PassListItem extends StatelessWidget { return Icons.gradient; case PassType.sharpen: return Icons.center_focus_strong; + case PassType.antiAlias: + return Icons.gesture; + case PassType.stabilize: + return Icons.stay_current_landscape; + case PassType.geometry: + return Icons.rotate_90_degrees_cw; + case PassType.grain: + return Icons.grain; + case PassType.frameRate: + return Icons.speed; + case PassType.deflicker: + return Icons.flourescent; + case PassType.edgeRepair: + return Icons.border_outer; + case PassType.ghostRemoval: + return Icons.blur_linear; case PassType.colorCorrection: return Icons.palette; case PassType.chromaFixes: diff --git a/app/lib/views/pass_list/pass_list_panel.dart b/app/lib/views/pass_list/pass_list_panel.dart index 98882604..e9dee6fd 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/pass_relevance.dart'; import '../../models/processing_pipeline.dart'; import '../../services/whisper_addon_manager.dart'; import '../../viewmodels/main_viewmodel.dart'; @@ -12,9 +13,68 @@ import 'pass_list_item.dart'; /// /// Each pass expands in place to reveal its settings — only one at a time, and /// only the expanded pass's settings are built. +/// +/// The rows are grouped into stages under headers. The order of the list is the +/// order the passes actually run in, so **the groups have to be contiguous runs +/// of that order** — they are labels over the existing sequence, not a +/// reordering. Nothing here may change which pass runs when; that lives in +/// `script_generator.rs`. Grouping is what keeps the panel scannable as passes +/// are added: five labelled stages read as shorter than thirteen ungrouped +/// rows, and the count beside each header shows where something is switched on +/// without reading every row. class PassListPanel extends StatelessWidget { const PassListPanel({super.key}); + /// The stage labels and the passes under each, in pipeline order. + /// + /// Public so `pass_list_stages_test.dart` can assert it covers every + /// [PassType] exactly once — a pass missing from here silently disappears + /// from the UI rather than failing. + static const List<({String title, List passes})> stages = [ + ( + title: 'Deinterlace & Film Damage', + passes: [ + PassType.deinterlace, + PassType.edgeRepair, + PassType.ghostRemoval, + PassType.deflicker, + PassType.descratch, + PassType.spotless, + ], + ), + ( + title: 'Noise & Artifacts', + passes: [ + PassType.noiseReduction, + PassType.chromaDenoise, + PassType.dehalo, + PassType.deblock, + PassType.deband, + ], + ), + ( + title: 'Detail & Color', + passes: [ + PassType.antiAlias, + PassType.sharpen, + PassType.chromaFixes, + PassType.colorCorrection, + ], + ), + ( + title: 'Framing', + passes: [PassType.stabilize, PassType.geometry, PassType.cropResize], + ), + ( + title: 'Finishing', + passes: [PassType.grain, PassType.frameRate], + ), + ( + title: 'Post-Processing', + passes: [PassType.subtitles], + ), + ]; + @override Widget build(BuildContext context) { return Consumer( @@ -36,12 +96,90 @@ class PassListPanel extends StatelessWidget { subtitle: subtitle, isEnabled: isEnabled, isExpanded: isExpanded, + relevance: relevanceFor(passType, viewModel.videoInfo), onToggle: onToggle ?? (enabled) => viewModel.togglePass(passType, enabled), onTap: () => viewModel.selectPass(passType), expandedChild: isExpanded ? PassSettingsInline(passType: passType) : null, ); } + /// Builds one row for a pass, looking its title and summary up so the + /// stage table stays a plain list of pass types. + Widget row(PassType passType) { + switch (passType) { + case PassType.deinterlace: + return item(passType, 'Deinterlace', + _getDeinterlaceSummary(pipeline), pipeline.deinterlace.enabled); + case PassType.descratch: + return item(passType, 'DeScratch', pipeline.descratch.summary, + pipeline.descratch.enabled); + case PassType.spotless: + return item(passType, 'SpotLess', pipeline.spotless.summary, + pipeline.spotless.enabled); + case PassType.noiseReduction: + return item(passType, 'Noise Reduction', + pipeline.noiseReduction.summary, pipeline.noiseReduction.enabled); + case PassType.chromaDenoise: + return item(passType, 'Chroma Denoise', + pipeline.chromaDenoise.summary, pipeline.chromaDenoise.enabled); + case PassType.dehalo: + return item(passType, 'Dehalo', pipeline.dehalo.summary, + pipeline.dehalo.enabled); + case PassType.deblock: + return item(passType, 'Deblock', pipeline.deblock.summary, + pipeline.deblock.enabled); + case PassType.deband: + return item(passType, 'Deband', pipeline.deband.summary, + pipeline.deband.enabled); + case PassType.sharpen: + return item(passType, 'Sharpen', pipeline.sharpen.summary, + pipeline.sharpen.enabled); + case PassType.antiAlias: + return item(passType, 'Anti-Aliasing', pipeline.antiAlias.summary, + pipeline.antiAlias.enabled); + case PassType.stabilize: + return item(passType, 'Stabilize', pipeline.stabilize.summary, + pipeline.stabilize.enabled); + case PassType.geometry: + return item(passType, 'Rotate / Flip', pipeline.geometry.summary, + pipeline.geometry.enabled); + case PassType.grain: + return item(passType, 'Film Grain', pipeline.grain.summary, + pipeline.grain.enabled); + case PassType.frameRate: + return item(passType, 'Frame Rate', pipeline.frameRate.summary, + pipeline.frameRate.enabled); + case PassType.deflicker: + return item(passType, 'Deflicker', pipeline.deflicker.summary, + pipeline.deflicker.enabled); + case PassType.edgeRepair: + return item(passType, 'Edge Repair', pipeline.edgeRepair.summary, + pipeline.edgeRepair.enabled); + case PassType.ghostRemoval: + return item(passType, 'Ghost Removal', + pipeline.ghostRemoval.summary, pipeline.ghostRemoval.enabled); + case PassType.chromaFixes: + return item(passType, 'Chroma Fixes', pipeline.chromaFixes.summary, + pipeline.chromaFixes.enabled); + case PassType.colorCorrection: + return item(passType, 'Color Correction', + pipeline.colorCorrection.summary, + pipeline.colorCorrection.enabled); + case PassType.cropResize: + return item(passType, 'Crop / Resize', pipeline.cropResize.summary, + pipeline.cropResize.enabled); + case PassType.subtitles: + return item( + passType, + 'Subtitles', + pipeline.subtitles.summary, + pipeline.subtitles.enabled, + onToggle: (enabled) => + _handleSubtitlesToggle(context, viewModel, enabled), + ); + } + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -52,112 +190,18 @@ class PassListPanel extends StatelessWidget { fontWeight: FontWeight.bold, ), ), - const SizedBox(height: 8), - - // List of passes - item( - PassType.deinterlace, - 'Deinterlace', - _getDeinterlaceSummary(pipeline), - pipeline.deinterlace.enabled, - ), - - item( - PassType.descratch, - 'DeScratch', - pipeline.descratch.summary, - pipeline.descratch.enabled, - ), - - item( - PassType.spotless, - 'SpotLess', - pipeline.spotless.summary, - pipeline.spotless.enabled, - ), - - item( - PassType.noiseReduction, - 'Noise Reduction', - pipeline.noiseReduction.summary, - pipeline.noiseReduction.enabled, - ), - - item( - PassType.chromaDenoise, - 'Chroma Denoise', - pipeline.chromaDenoise.summary, - pipeline.chromaDenoise.enabled, - ), - - item( - PassType.dehalo, - 'Dehalo', - pipeline.dehalo.summary, - pipeline.dehalo.enabled, - ), - - item( - PassType.deblock, - 'Deblock', - pipeline.deblock.summary, - pipeline.deblock.enabled, - ), - - item( - PassType.deband, - 'Deband', - pipeline.deband.summary, - pipeline.deband.enabled, - ), - item( - PassType.sharpen, - 'Sharpen', - pipeline.sharpen.summary, - pipeline.sharpen.enabled, - ), - - item( - PassType.chromaFixes, - 'Chroma Fixes', - pipeline.chromaFixes.summary, - pipeline.chromaFixes.enabled, - ), - - item( - PassType.colorCorrection, - 'Color Correction', - pipeline.colorCorrection.summary, - pipeline.colorCorrection.enabled, - ), - - item( - PassType.cropResize, - 'Crop / Resize', - pipeline.cropResize.summary, - pipeline.cropResize.enabled, - ), - - // Post-Processing section - const Divider(height: 24), - Padding( - padding: const EdgeInsets.only(left: 16, bottom: 4), - child: Text( - 'Post-Processing', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), - ), + for (final stage in stages) ...[ + _buildStageHeader( + context, + stage.title, + enabledCount: stage.passes + .where((p) => pipeline.isPassEnabled(p)) + .length, + isFirst: stage == stages.first, ), - ), - - item( - PassType.subtitles, - 'Subtitles', - pipeline.subtitles.summary, - pipeline.subtitles.enabled, - onToggle: (enabled) => _handleSubtitlesToggle(context, viewModel, enabled), - ), + for (final passType in stage.passes) row(passType), + ], const SizedBox(height: 16), @@ -174,6 +218,47 @@ class PassListPanel extends StatelessWidget { ); } + /// A stage label over the pass rows, with a count when anything in the stage + /// is switched on — so "where did I turn something on" is answerable without + /// reading every row. + Widget _buildStageHeader( + BuildContext context, + String title, { + required int enabledCount, + required bool isFirst, + }) { + final muted = Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isFirst) const Divider(height: 24) else const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.only(left: 16, bottom: 4), + child: Row( + children: [ + Text( + title, + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith(color: muted), + ), + if (enabledCount > 0) ...[ + const SizedBox(width: 6), + Text( + '· $enabledCount on', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ], + ), + ), + ], + ); + } + Future _handleSubtitlesToggle( BuildContext context, MainViewModel viewModel, diff --git a/app/lib/views/pass_settings/chroma_fix_settings_panel.dart b/app/lib/views/pass_settings/chroma_fix_settings_panel.dart deleted file mode 100644 index aba6a12c..00000000 --- a/app/lib/views/pass_settings/chroma_fix_settings_panel.dart +++ /dev/null @@ -1,255 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../models/chroma_fix_parameters.dart'; -import '../../viewmodels/main_viewmodel.dart'; - -/// Settings panel for the chroma fix pass. -class ChromaFixSettingsPanel extends StatelessWidget { - const ChromaFixSettingsPanel({super.key}); - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, viewModel, child) { - final params = viewModel.processingPipeline.chromaFixes; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Text( - 'Chroma Fix Settings', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - 'Fix chroma bleeding and crawl artifacts', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - const SizedBox(height: 16), - - // Preset - Text('Preset', style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 8), - DropdownButtonFormField( - value: params.preset, - isExpanded: true, - decoration: const InputDecoration( - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - items: ChromaFixPreset.values.map((preset) { - return DropdownMenuItem( - value: preset, - child: Text(_getPresetDisplayName(preset)), - ); - }).toList(), - onChanged: (value) { - if (value != null) { - final newParams = ChromaFixParameters.fromPreset(value); - _updateParams(viewModel, newParams); - } - }, - ), - const SizedBox(height: 4), - Text( - _getPresetDescription(params.preset), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - - if (params.enabled) ...[ - const SizedBox(height: 16), - - // Chroma Bleeding Fix - ExpansionTile( - title: const Text('Chroma Bleeding Fix'), - tilePadding: EdgeInsets.zero, - initiallyExpanded: params.applyChromaBleedingFix, - children: [ - SwitchListTile( - title: const Text('Enable'), - subtitle: const Text('Fix color bleeding at edges'), - contentPadding: EdgeInsets.zero, - value: params.applyChromaBleedingFix, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - applyChromaBleedingFix: value, - preset: ChromaFixPreset.custom, - )); - }, - ), - - if (params.applyChromaBleedingFix) ...[ - Text('Blur Strength: ${params.chromaBleedCBlur.toStringAsFixed(1)}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.chromaBleedCBlur, - min: 0.0, - max: 1.5, - divisions: 15, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - chromaBleedCBlur: value, - preset: ChromaFixPreset.custom, - )); - }, - ), - - Text('Fix Strength: ${params.chromaBleedStrength.toStringAsFixed(1)}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.chromaBleedStrength, - min: 0.0, - max: 1.0, - divisions: 10, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - chromaBleedStrength: value, - preset: ChromaFixPreset.custom, - )); - }, - ), - ], - ], - ), - - // De-Crawl - ExpansionTile( - title: const Text('De-Crawl'), - tilePadding: EdgeInsets.zero, - initiallyExpanded: params.applyDeCrawl, - children: [ - SwitchListTile( - title: const Text('Enable'), - subtitle: const Text('Fix dot crawl and chroma crawl'), - contentPadding: EdgeInsets.zero, - value: params.applyDeCrawl, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - applyDeCrawl: value, - preset: ChromaFixPreset.custom, - )); - }, - ), - - if (params.applyDeCrawl) ...[ - Text('Luma Threshold: ${params.deCrawlYThresh}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.deCrawlYThresh.toDouble(), - min: 0, - max: 50, - divisions: 50, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - deCrawlYThresh: value.round(), - preset: ChromaFixPreset.custom, - )); - }, - ), - - Text('Chroma Threshold: ${params.deCrawlCThresh}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.deCrawlCThresh.toDouble(), - min: 0, - max: 50, - divisions: 50, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - deCrawlCThresh: value.round(), - preset: ChromaFixPreset.custom, - )); - }, - ), - ], - ], - ), - - // Vinverse - ExpansionTile( - title: const Text('Vinverse'), - tilePadding: EdgeInsets.zero, - initiallyExpanded: params.applyVinverse, - children: [ - SwitchListTile( - title: const Text('Enable'), - subtitle: const Text('Remove residual combing'), - contentPadding: EdgeInsets.zero, - value: params.applyVinverse, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - applyVinverse: value, - preset: ChromaFixPreset.custom, - )); - }, - ), - - if (params.applyVinverse) ...[ - Text('Strength: ${params.vinverseSstr.toStringAsFixed(1)}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.vinverseSstr, - min: 1.0, - max: 5.0, - divisions: 40, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - vinverseSstr: value, - preset: ChromaFixPreset.custom, - )); - }, - ), - ], - ], - ), - ], - ], - ); - }, - ); - } - - String _getPresetDisplayName(ChromaFixPreset preset) { - switch (preset) { - case ChromaFixPreset.off: - return 'Off'; - case ChromaFixPreset.vhsCleanup: - return 'VHS Cleanup'; - case ChromaFixPreset.broadcastFix: - return 'Broadcast Fix'; - case ChromaFixPreset.analogRepair: - return 'Analog Repair'; - case ChromaFixPreset.custom: - return 'Custom'; - } - } - - String _getPresetDescription(ChromaFixPreset preset) { - switch (preset) { - case ChromaFixPreset.off: - return 'No chroma fixes applied'; - case ChromaFixPreset.vhsCleanup: - return 'Fix common VHS chroma issues'; - case ChromaFixPreset.broadcastFix: - return 'Fix dot crawl from composite sources'; - case ChromaFixPreset.analogRepair: - return 'Aggressive analog artifact repair'; - case ChromaFixPreset.custom: - return 'Custom chroma fix settings'; - } - } - - void _updateParams(MainViewModel viewModel, ChromaFixParameters params) { - viewModel.updateProcessingPipeline( - viewModel.processingPipeline.copyWith(chromaFixes: params), - ); - } -} diff --git a/app/lib/views/pass_settings/color_correction_settings_panel.dart b/app/lib/views/pass_settings/color_correction_settings_panel.dart deleted file mode 100644 index 65301e93..00000000 --- a/app/lib/views/pass_settings/color_correction_settings_panel.dart +++ /dev/null @@ -1,254 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../models/color_correction_parameters.dart'; -import '../../viewmodels/main_viewmodel.dart'; - -/// Settings panel for the color correction pass. -class ColorCorrectionSettingsPanel extends StatelessWidget { - const ColorCorrectionSettingsPanel({super.key}); - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, viewModel, child) { - final params = viewModel.processingPipeline.colorCorrection; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Text( - 'Color Correction Settings', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - 'Adjust brightness, contrast, and colors', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - const SizedBox(height: 16), - - // Preset - Text('Preset', style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 8), - DropdownButtonFormField( - value: params.preset, - isExpanded: true, - decoration: const InputDecoration( - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - items: ColorCorrectionPreset.values.map((preset) { - return DropdownMenuItem( - value: preset, - child: Text(_getPresetDisplayName(preset)), - ); - }).toList(), - onChanged: (value) { - if (value != null) { - final newParams = ColorCorrectionParameters.fromPreset(value); - _updateParams(viewModel, newParams); - } - }, - ), - const SizedBox(height: 4), - Text( - _getPresetDescription(params.preset), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - - if (params.enabled) ...[ - const SizedBox(height: 16), - - // Basic adjustments - Text('Brightness: ${params.brightness.toStringAsFixed(0)}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.brightness, - min: -50, - max: 50, - divisions: 100, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - brightness: value, - preset: ColorCorrectionPreset.custom, - )); - }, - ), - - Text('Contrast: ${params.contrast.toStringAsFixed(2)}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.contrast, - min: 0.5, - max: 2.0, - divisions: 30, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - contrast: value, - preset: ColorCorrectionPreset.custom, - )); - }, - ), - - Text('Saturation: ${params.saturation.toStringAsFixed(2)}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.saturation, - min: 0.0, - max: 2.0, - divisions: 40, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - saturation: value, - preset: ColorCorrectionPreset.custom, - )); - }, - ), - - Text('Hue: ${params.hue.toStringAsFixed(0)}°', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.hue, - min: -180, - max: 180, - divisions: 72, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - hue: value, - preset: ColorCorrectionPreset.custom, - )); - }, - ), - - const SizedBox(height: 8), - - SwitchListTile( - title: const Text('Clamp to TV Range'), - subtitle: const Text('Limit output to 16-235'), - contentPadding: EdgeInsets.zero, - value: params.coring, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - coring: value, - preset: ColorCorrectionPreset.custom, - )); - }, - ), - - // Advanced - Levels - ExpansionTile( - title: const Text('Levels'), - tilePadding: EdgeInsets.zero, - children: [ - const SizedBox(height: 8), - SwitchListTile( - title: const Text('Apply Levels'), - contentPadding: EdgeInsets.zero, - value: params.applyLevels, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - applyLevels: value, - preset: ColorCorrectionPreset.custom, - )); - }, - ), - - if (params.applyLevels) ...[ - Text('Input Low: ${params.inputLow}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.inputLow.toDouble(), - min: 0, - max: 255, - divisions: 255, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - inputLow: value.round(), - preset: ColorCorrectionPreset.custom, - )); - }, - ), - - Text('Input High: ${params.inputHigh}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.inputHigh.toDouble(), - min: 0, - max: 255, - divisions: 255, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - inputHigh: value.round(), - preset: ColorCorrectionPreset.custom, - )); - }, - ), - - Text('Gamma: ${params.gamma.toStringAsFixed(2)}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.gamma, - min: 0.5, - max: 2.0, - divisions: 30, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - gamma: value, - preset: ColorCorrectionPreset.custom, - )); - }, - ), - ], - ], - ), - ], - ], - ); - }, - ); - } - - String _getPresetDisplayName(ColorCorrectionPreset preset) { - switch (preset) { - case ColorCorrectionPreset.off: - return 'Off'; - case ColorCorrectionPreset.broadcastSafe: - return 'Broadcast Safe'; - case ColorCorrectionPreset.enhanceColors: - return 'Enhance Colors'; - case ColorCorrectionPreset.desaturate: - return 'Desaturate'; - case ColorCorrectionPreset.custom: - return 'Custom'; - } - } - - String _getPresetDescription(ColorCorrectionPreset preset) { - switch (preset) { - case ColorCorrectionPreset.off: - return 'No color correction applied'; - case ColorCorrectionPreset.broadcastSafe: - return 'Clamp levels to broadcast-safe range'; - case ColorCorrectionPreset.enhanceColors: - return 'Boost contrast and saturation'; - case ColorCorrectionPreset.desaturate: - return 'Convert to grayscale'; - case ColorCorrectionPreset.custom: - return 'Custom color adjustments'; - } - } - - void _updateParams(MainViewModel viewModel, ColorCorrectionParameters params) { - viewModel.updateProcessingPipeline( - viewModel.processingPipeline.copyWith(colorCorrection: params), - ); - } -} diff --git a/app/lib/views/pass_settings/crop_resize_settings_panel.dart b/app/lib/views/pass_settings/crop_resize_settings_panel.dart deleted file mode 100644 index e4f47a31..00000000 --- a/app/lib/views/pass_settings/crop_resize_settings_panel.dart +++ /dev/null @@ -1,374 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../models/crop_resize_parameters.dart'; -import '../../viewmodels/main_viewmodel.dart'; - -/// Settings panel for the crop/resize pass. -class CropResizeSettingsPanel extends StatelessWidget { - const CropResizeSettingsPanel({super.key}); - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, viewModel, child) { - final params = viewModel.processingPipeline.cropResize; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Text( - 'Crop / Resize Settings', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - 'Crop borders and resize output', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - const SizedBox(height: 16), - - // Preset - Text('Preset', style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 8), - DropdownButtonFormField( - value: params.preset, - isExpanded: true, - decoration: const InputDecoration( - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - items: CropResizePreset.values.map((preset) { - return DropdownMenuItem( - value: preset, - child: Text(_getPresetDisplayName(preset)), - ); - }).toList(), - onChanged: (value) { - if (value != null) { - final newParams = CropResizeParameters.fromPreset(value); - _updateParams(viewModel, newParams); - } - }, - ), - const SizedBox(height: 4), - Text( - _getPresetDescription(params.preset), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - - if (params.enabled) ...[ - const SizedBox(height: 16), - - // Crop settings - ExpansionTile( - title: const Text('Crop'), - tilePadding: EdgeInsets.zero, - initiallyExpanded: params.cropEnabled, - children: [ - SwitchListTile( - title: const Text('Enable Crop'), - contentPadding: EdgeInsets.zero, - value: params.cropEnabled, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - cropEnabled: value, - preset: CropResizePreset.custom, - )); - }, - ), - - if (params.cropEnabled) ...[ - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: _buildCropField(context, 'Left', params.cropLeft, (v) { - _updateParams(viewModel, params.copyWith( - cropLeft: v, - preset: CropResizePreset.custom, - )); - }), - ), - const SizedBox(width: 8), - Expanded( - child: _buildCropField(context, 'Right', params.cropRight, (v) { - _updateParams(viewModel, params.copyWith( - cropRight: v, - preset: CropResizePreset.custom, - )); - }), - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: _buildCropField(context, 'Top', params.cropTop, (v) { - _updateParams(viewModel, params.copyWith( - cropTop: v, - preset: CropResizePreset.custom, - )); - }), - ), - const SizedBox(width: 8), - Expanded( - child: _buildCropField(context, 'Bottom', params.cropBottom, (v) { - _updateParams(viewModel, params.copyWith( - cropBottom: v, - preset: CropResizePreset.custom, - )); - }), - ), - ], - ), - ], - ], - ), - - // Resize settings - ExpansionTile( - title: const Text('Resize'), - tilePadding: EdgeInsets.zero, - initiallyExpanded: params.resizeEnabled, - children: [ - SwitchListTile( - title: const Text('Enable Resize'), - contentPadding: EdgeInsets.zero, - value: params.resizeEnabled, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - resizeEnabled: value, - preset: CropResizePreset.custom, - )); - }, - ), - - if (params.resizeEnabled) ...[ - const SizedBox(height: 8), - - // Target dimensions - Row( - children: [ - Expanded( - child: TextFormField( - initialValue: params.targetWidth?.toString() ?? '', - decoration: const InputDecoration( - labelText: 'Width', - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - keyboardType: TextInputType.number, - onChanged: (value) { - final width = int.tryParse(value); - _updateParams(viewModel, params.copyWith( - targetWidth: width, - preset: CropResizePreset.custom, - )); - }, - ), - ), - const SizedBox(width: 8), - Expanded( - child: TextFormField( - initialValue: params.targetHeight?.toString() ?? '', - decoration: const InputDecoration( - labelText: 'Height', - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - keyboardType: TextInputType.number, - onChanged: (value) { - final height = int.tryParse(value); - _updateParams(viewModel, params.copyWith( - targetHeight: height, - preset: CropResizePreset.custom, - )); - }, - ), - ), - ], - ), - - const SizedBox(height: 8), - - SwitchListTile( - title: const Text('Maintain Aspect Ratio'), - contentPadding: EdgeInsets.zero, - value: params.maintainAspect, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - maintainAspect: value, - preset: CropResizePreset.custom, - )); - }, - ), - - // Resize kernel - const SizedBox(height: 8), - Text('Resize Algorithm', style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 8), - DropdownButtonFormField( - value: params.kernel, - isExpanded: true, - decoration: const InputDecoration( - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - items: const [ - DropdownMenuItem(value: ResizeKernel.spline36, child: Text('Spline36 (Recommended)')), - DropdownMenuItem(value: ResizeKernel.lanczos, child: Text('Lanczos')), - DropdownMenuItem(value: ResizeKernel.bicubic, child: Text('Bicubic')), - DropdownMenuItem(value: ResizeKernel.bilinear, child: Text('Bilinear')), - ], - onChanged: (value) { - if (value != null) { - _updateParams(viewModel, params.copyWith( - kernel: value, - preset: CropResizePreset.custom, - )); - } - }, - ), - ], - ], - ), - - // Upscale settings - ExpansionTile( - title: const Text('Integer Upscale'), - tilePadding: EdgeInsets.zero, - initiallyExpanded: params.useIntegerUpscale, - children: [ - SwitchListTile( - title: const Text('Use Integer Upscale'), - subtitle: const Text('Use NNEDI3 for 2x/4x upscaling'), - contentPadding: EdgeInsets.zero, - value: params.useIntegerUpscale, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - useIntegerUpscale: value, - preset: CropResizePreset.custom, - )); - }, - ), - - if (params.useIntegerUpscale) ...[ - const SizedBox(height: 8), - - Text('Upscale Factor', style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 8), - SegmentedButton( - segments: const [ - ButtonSegment(value: 2, label: Text('2x')), - ButtonSegment(value: 4, label: Text('4x')), - ], - selected: {params.upscaleFactor}, - onSelectionChanged: (value) { - _updateParams(viewModel, params.copyWith( - upscaleFactor: value.first, - preset: CropResizePreset.custom, - )); - }, - ), - - const SizedBox(height: 8), - - Text('Upscale Method', style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 8), - DropdownButtonFormField( - value: params.upscaleMethod, - isExpanded: true, - decoration: const InputDecoration( - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - items: const [ - DropdownMenuItem(value: UpscaleMethod.nnedi3Rpow2, child: Text('NNEDI3 (Best Quality)')), - DropdownMenuItem(value: UpscaleMethod.eedi3Rpow2, child: Text('EEDI3')), - DropdownMenuItem(value: UpscaleMethod.spline36, child: Text('Spline36 (Fastest)')), - ], - onChanged: (value) { - if (value != null) { - _updateParams(viewModel, params.copyWith( - upscaleMethod: value, - preset: CropResizePreset.custom, - )); - } - }, - ), - ], - ], - ), - ], - ], - ); - }, - ); - } - - Widget _buildCropField(BuildContext context, String label, int value, ValueChanged onChanged) { - return TextFormField( - initialValue: value.toString(), - decoration: InputDecoration( - labelText: label, - border: const OutlineInputBorder(), - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - suffixText: 'px', - ), - keyboardType: TextInputType.number, - onChanged: (text) { - final v = int.tryParse(text) ?? 0; - onChanged(v); - }, - ); - } - - String _getPresetDisplayName(CropResizePreset preset) { - switch (preset) { - case CropResizePreset.off: - return 'Off'; - case CropResizePreset.removeOverscan: - return 'Remove Overscan'; - case CropResizePreset.resize720p: - return 'Resize to 720p'; - case CropResizePreset.resize1080p: - return 'Resize to 1080p'; - case CropResizePreset.resize4k: - return 'Upscale to 4K'; - case CropResizePreset.custom: - return 'Custom'; - } - } - - String _getPresetDescription(CropResizePreset preset) { - switch (preset) { - case CropResizePreset.off: - return 'No cropping or resizing'; - case CropResizePreset.removeOverscan: - return 'Crop 8px from each edge'; - case CropResizePreset.resize720p: - return 'Resize to 1280x720'; - case CropResizePreset.resize1080p: - return 'Resize to 1920x1080'; - case CropResizePreset.resize4k: - return '2x upscale using NNEDI3'; - case CropResizePreset.custom: - return 'Custom crop/resize settings'; - } - } - - void _updateParams(MainViewModel viewModel, CropResizeParameters params) { - viewModel.updateProcessingPipeline( - viewModel.processingPipeline.copyWith(cropResize: params), - ); - } -} diff --git a/app/lib/views/pass_settings/deband_settings_panel.dart b/app/lib/views/pass_settings/deband_settings_panel.dart deleted file mode 100644 index ee9fc170..00000000 --- a/app/lib/views/pass_settings/deband_settings_panel.dart +++ /dev/null @@ -1,174 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../models/deband_parameters.dart'; -import '../../viewmodels/main_viewmodel.dart'; - -/// Settings panel for the deband pass (f3kdb). -class DebandSettingsPanel extends StatelessWidget { - const DebandSettingsPanel({super.key}); - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, viewModel, child) { - final params = viewModel.processingPipeline.deband; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Text( - 'Deband Settings', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - 'Remove color banding from gradients (f3kdb)', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - const SizedBox(height: 16), - - // Range - Text('Detection Range: ${params.range}', - style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 4), - Text( - 'Higher values detect wider bands', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - Slider( - value: params.range.toDouble(), - min: 8, - max: 128, - divisions: 24, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(range: value.round())); - }, - ), - - const SizedBox(height: 8), - - // Luma strength - Text('Luma Strength: ${params.y}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.y.toDouble(), - min: 0, - max: 64, - divisions: 64, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(y: value.round())); - }, - ), - - // Chroma strength - Text('Chroma Blue Strength: ${params.cb}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.cb.toDouble(), - min: 0, - max: 64, - divisions: 64, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(cb: value.round())); - }, - ), - - Text('Chroma Red Strength: ${params.cr}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.cr.toDouble(), - min: 0, - max: 64, - divisions: 64, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(cr: value.round())); - }, - ), - - const SizedBox(height: 16), - - // Grain settings - ExpansionTile( - title: const Text('Dither Grain'), - tilePadding: EdgeInsets.zero, - initiallyExpanded: true, - children: [ - const SizedBox(height: 8), - - Text('Luma Grain: ${params.grainY}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.grainY.toDouble(), - min: 0, - max: 64, - divisions: 64, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(grainY: value.round())); - }, - ), - - Text('Chroma Grain: ${params.grainC}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.grainC.toDouble(), - min: 0, - max: 64, - divisions: 64, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(grainC: value.round())); - }, - ), - - SwitchListTile( - title: const Text('Dynamic Grain'), - subtitle: const Text('Grain changes per frame'), - contentPadding: EdgeInsets.zero, - value: params.dynamicGrain, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(dynamicGrain: value)); - }, - ), - ], - ), - - // Output depth - ExpansionTile( - title: const Text('Advanced'), - tilePadding: EdgeInsets.zero, - children: [ - const SizedBox(height: 8), - Text('Output Bit Depth', style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 8), - SegmentedButton( - segments: const [ - ButtonSegment(value: 8, label: Text('8-bit')), - ButtonSegment(value: 10, label: Text('10-bit')), - ButtonSegment(value: 16, label: Text('16-bit')), - ], - selected: {params.outputDepth}, - onSelectionChanged: (value) { - _updateParams(viewModel, params.copyWith(outputDepth: value.first)); - }, - ), - ], - ), - ], - ); - }, - ); - } - - void _updateParams(MainViewModel viewModel, DebandParameters params) { - viewModel.updateProcessingPipeline( - viewModel.processingPipeline.copyWith(deband: params), - ); - } -} diff --git a/app/lib/views/pass_settings/deblock_settings_panel.dart b/app/lib/views/pass_settings/deblock_settings_panel.dart deleted file mode 100644 index 060c58fa..00000000 --- a/app/lib/views/pass_settings/deblock_settings_panel.dart +++ /dev/null @@ -1,169 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../models/deblock_parameters.dart'; -import '../../viewmodels/main_viewmodel.dart'; - -/// Settings panel for the deblock pass. -class DeblockSettingsPanel extends StatelessWidget { - const DeblockSettingsPanel({super.key}); - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, viewModel, child) { - final params = viewModel.processingPipeline.deblock; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Text( - 'Deblock Settings', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - 'Remove compression block artifacts', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - const SizedBox(height: 16), - - // Method selection - Text('Method', style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 8), - DropdownButtonFormField( - value: params.method, - isExpanded: true, - decoration: const InputDecoration( - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - items: DeblockMethod.values.map((method) { - return DropdownMenuItem( - value: method, - child: Text(method.displayName), - ); - }).toList(), - onChanged: (value) { - if (value != null) { - _updateParams(viewModel, params.copyWith(method: value)); - } - }, - ), - const SizedBox(height: 4), - Text( - params.method.description, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - - const SizedBox(height: 16), - - // Method-specific settings - if (params.method == DeblockMethod.deblockQed) - _buildQedSettings(context, viewModel, params) - else - _buildSimpleSettings(context, viewModel, params), - ], - ); - }, - ); - } - - Widget _buildQedSettings(BuildContext context, MainViewModel viewModel, DeblockParameters params) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Quant1 (edge strength) - Text('Edge Strength: ${params.quant1}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.quant1.toDouble(), - min: 0, - max: 60, - divisions: 60, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(quant1: value.round())); - }, - ), - - // Quant2 (non-edge strength) - Text('Non-Edge Strength: ${params.quant2}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.quant2.toDouble(), - min: 0, - max: 60, - divisions: 60, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(quant2: value.round())); - }, - ), - - // Advanced settings - ExpansionTile( - title: const Text('Advanced'), - tilePadding: EdgeInsets.zero, - children: [ - const SizedBox(height: 8), - Text('Analyze Offset 1: ${params.aOffset1}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.aOffset1.toDouble(), - min: -2, - max: 6, - divisions: 8, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(aOffset1: value.round())); - }, - ), - - Text('Analyze Offset 2: ${params.aOffset2}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.aOffset2.toDouble(), - min: -2, - max: 6, - divisions: 8, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(aOffset2: value.round())); - }, - ), - ], - ), - ], - ); - } - - Widget _buildSimpleSettings(BuildContext context, MainViewModel viewModel, DeblockParameters params) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Quant (strength) - Text('Strength: ${params.quant1}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.quant1.toDouble(), - min: 0, - max: 60, - divisions: 60, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(quant1: value.round())); - }, - ), - ], - ); - } - - void _updateParams(MainViewModel viewModel, DeblockParameters params) { - viewModel.updateProcessingPipeline( - viewModel.processingPipeline.copyWith(deblock: params), - ); - } -} diff --git a/app/lib/views/pass_settings/dehalo_settings_panel.dart b/app/lib/views/pass_settings/dehalo_settings_panel.dart deleted file mode 100644 index 14f48778..00000000 --- a/app/lib/views/pass_settings/dehalo_settings_panel.dart +++ /dev/null @@ -1,204 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../models/dehalo_parameters.dart'; -import '../../viewmodels/main_viewmodel.dart'; - -/// Settings panel for the dehalo pass. -class DehaloSettingsPanel extends StatelessWidget { - const DehaloSettingsPanel({super.key}); - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, viewModel, child) { - final params = viewModel.processingPipeline.dehalo; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Text( - 'Dehalo Settings', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - 'Remove halo artifacts around edges', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - const SizedBox(height: 16), - - // Method selection - Text('Method', style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 8), - DropdownButtonFormField( - value: params.method, - isExpanded: true, - decoration: const InputDecoration( - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - items: DehaloMethod.values.map((method) { - return DropdownMenuItem( - value: method, - child: Text(method.displayName), - ); - }).toList(), - onChanged: (value) { - if (value != null) { - _updateParams(viewModel, params.copyWith(method: value)); - } - }, - ), - const SizedBox(height: 4), - Text( - params.method.description, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - - const SizedBox(height: 16), - - // Method-specific settings - if (params.method == DehaloMethod.yahr) - _buildYahrSettings(context, viewModel, params) - else - _buildDehaloAlphaSettings(context, viewModel, params), - ], - ); - }, - ); - } - - Widget _buildDehaloAlphaSettings(BuildContext context, MainViewModel viewModel, DehaloParameters params) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Horizontal radius - Text('Horizontal Radius: ${params.rx.toStringAsFixed(1)}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.rx, - min: 1.0, - max: 3.0, - divisions: 20, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(rx: value)); - }, - ), - - // Vertical radius - Text('Vertical Radius: ${params.ry.toStringAsFixed(1)}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.ry, - min: 1.0, - max: 3.0, - divisions: 20, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(ry: value)); - }, - ), - - // Dark strength - Text('Dark Halo Strength: ${params.darkStr.toStringAsFixed(2)}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.darkStr, - min: 0.0, - max: 1.0, - divisions: 20, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(darkStr: value)); - }, - ), - - // Bright strength - Text('Bright Halo Strength: ${params.brightStr.toStringAsFixed(2)}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.brightStr, - min: 0.0, - max: 1.0, - divisions: 20, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(brightStr: value)); - }, - ), - - // FineDehalo-specific thresholds - if (params.method == DehaloMethod.fineDehalo) ...[ - const SizedBox(height: 8), - Text('Low Threshold: ${params.lowThreshold}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.lowThreshold.toDouble(), - min: 0, - max: 200, - divisions: 200, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(lowThreshold: value.round())); - }, - ), - - Text('High Threshold: ${params.highThreshold}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.highThreshold.toDouble(), - min: 0, - max: 255, - divisions: 255, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(highThreshold: value.round())); - }, - ), - ], - ], - ); - } - - Widget _buildYahrSettings(BuildContext context, MainViewModel viewModel, DehaloParameters params) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Blur amount - Text('Blur: ${params.yahrBlur}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.yahrBlur.toDouble(), - min: 1, - max: 3, - divisions: 2, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(yahrBlur: value.round())); - }, - ), - - // Depth - Text('Depth: ${params.yahrDepth}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.yahrDepth.toDouble(), - min: 8, - max: 128, - divisions: 15, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(yahrDepth: value.round())); - }, - ), - ], - ); - } - - void _updateParams(MainViewModel viewModel, DehaloParameters params) { - viewModel.updateProcessingPipeline( - viewModel.processingPipeline.copyWith(dehalo: params), - ); - } -} diff --git a/app/lib/views/pass_settings/deinterlace_settings_panel.dart b/app/lib/views/pass_settings/deinterlace_settings_panel.dart deleted file mode 100644 index bf4e7424..00000000 --- a/app/lib/views/pass_settings/deinterlace_settings_panel.dart +++ /dev/null @@ -1,171 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../models/qtgmc_parameters.dart'; -import '../../viewmodels/main_viewmodel.dart'; - -/// Settings panel for the deinterlace (QTGMC) pass. -class DeinterlaceSettingsPanel extends StatelessWidget { - const DeinterlaceSettingsPanel({super.key}); - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, viewModel, child) { - final params = viewModel.processingPipeline.deinterlace; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Text( - 'Deinterlace Settings', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - 'Remove interlacing artifacts using QTGMC', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - const SizedBox(height: 16), - - // Preset - Text('Preset', style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 8), - DropdownButtonFormField( - value: params.preset, - isExpanded: true, - decoration: const InputDecoration( - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - items: QTGMCPreset.values.map((preset) { - return DropdownMenuItem( - value: preset, - child: Text(preset.displayName), - ); - }).toList(), - onChanged: (value) { - if (value != null) { - _updateParams(viewModel, params.copyWith(preset: value)); - } - }, - ), - const SizedBox(height: 4), - Text( - params.preset.description, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - - const SizedBox(height: 16), - - // Frame Rate Output - Text('Frame Rate Output', style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 8), - SegmentedButton( - segments: const [ - ButtonSegment(value: 1, label: Text('Double Rate')), - ButtonSegment(value: 2, label: Text('Single Rate')), - ], - selected: {params.fpsDivisor ?? 1}, - onSelectionChanged: (value) { - _updateParams(viewModel, params.copyWith(fpsDivisor: value.first)); - }, - ), - const SizedBox(height: 4), - Text( - _getOutputRateDescription(viewModel, params.fpsDivisor ?? 1), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - - const SizedBox(height: 16), - - // GPU Acceleration - SwitchListTile( - title: const Text('OpenCL Acceleration'), - subtitle: const Text('Use GPU for NNEDI3'), - value: params.opencl ?? false, - contentPadding: EdgeInsets.zero, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(opencl: value)); - }, - ), - - const SizedBox(height: 8), - - // Advanced settings expansion - ExpansionTile( - title: const Text('Advanced'), - tilePadding: EdgeInsets.zero, - children: [ - const SizedBox(height: 8), - - // Source Match - Text('Source Match', style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 8), - SegmentedButton( - segments: const [ - ButtonSegment(value: 0, label: Text('Off')), - ButtonSegment(value: 1, label: Text('Basic')), - ButtonSegment(value: 2, label: Text('Refined')), - ButtonSegment(value: 3, label: Text('Full')), - ], - selected: {params.sourceMatch ?? 0}, - onSelectionChanged: (value) { - _updateParams(viewModel, params.copyWith(sourceMatch: value.first)); - }, - ), - const SizedBox(height: 16), - - // Sharpness - Text('Sharpness: ${(params.sharpness ?? 0.0).toStringAsFixed(1)}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.sharpness ?? 0.0, - min: 0.0, - max: 2.0, - divisions: 20, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(sharpness: value)); - }, - ), - const SizedBox(height: 8), - ], - ), - ], - ); - }, - ); - } - - String _getOutputRateDescription(MainViewModel viewModel, int fpsDivisor) { - final frameRate = viewModel.selectedItem?.videoInfo?.frameRate; - if (frameRate != null && frameRate > 0) { - final inputRate = frameRate.toStringAsFixed(frameRate == frameRate.roundToDouble() ? 0 : 2); - if (fpsDivisor == 1) { - return '${inputRate}i → ${inputRate}p (smooth motion)'; - } else { - final halfRate = (frameRate / 2); - final outputRate = halfRate.toStringAsFixed(halfRate == halfRate.roundToDouble() ? 0 : 2); - return '${inputRate}i → ${outputRate}p (smaller file)'; - } - } - return fpsDivisor == 1 - ? 'Double frame rate (smooth motion)' - : 'Single frame rate (smaller file)'; - } - - void _updateParams(MainViewModel viewModel, QTGMCParameters params) { - viewModel.updateProcessingPipeline( - viewModel.processingPipeline.copyWith(deinterlace: params), - ); - } -} diff --git a/app/lib/views/pass_settings/noise_reduction_settings_panel.dart b/app/lib/views/pass_settings/noise_reduction_settings_panel.dart deleted file mode 100644 index 4f1d8224..00000000 --- a/app/lib/views/pass_settings/noise_reduction_settings_panel.dart +++ /dev/null @@ -1,289 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../models/noise_reduction_parameters.dart'; -import '../../viewmodels/main_viewmodel.dart'; - -/// Settings panel for the noise reduction pass. -class NoiseReductionSettingsPanel extends StatelessWidget { - const NoiseReductionSettingsPanel({super.key}); - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, viewModel, child) { - final params = viewModel.processingPipeline.noiseReduction; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Text( - 'Noise Reduction Settings', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - 'Reduce video noise and grain', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - const SizedBox(height: 16), - - // Preset - Text('Preset', style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 8), - SegmentedButton( - segments: const [ - ButtonSegment(value: NoiseReductionPreset.off, label: Text('Off')), - ButtonSegment(value: NoiseReductionPreset.light, label: Text('Light')), - ButtonSegment(value: NoiseReductionPreset.moderate, label: Text('Moderate')), - ButtonSegment(value: NoiseReductionPreset.heavy, label: Text('Heavy')), - ], - selected: {params.preset}, - onSelectionChanged: (value) { - final preset = value.first; - final newParams = NoiseReductionParameters.fromPreset(preset); - _updateParams(viewModel, newParams); - }, - ), - const SizedBox(height: 4), - Text( - _getPresetDescription(params.preset), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - - // Show advanced options if enabled and using custom preset - if (params.enabled) ...[ - const SizedBox(height: 16), - - // Method selection - Text('Method', style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 8), - DropdownButtonFormField( - value: params.method, - isExpanded: true, - decoration: const InputDecoration( - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - items: const [ - DropdownMenuItem( - value: NoiseReductionMethod.smDegrain, - child: Text('SMDegrain (Recommended)'), - ), - DropdownMenuItem( - value: NoiseReductionMethod.mcTemporalDenoise, - child: Text('MCTemporalDenoise'), - ), - DropdownMenuItem( - value: NoiseReductionMethod.qtgmcBuiltin, - child: Text('QTGMC Built-in'), - ), - ], - onChanged: (value) { - if (value != null) { - _updateParams(viewModel, params.copyWith( - method: value, - preset: NoiseReductionPreset.custom, - )); - } - }, - ), - - const SizedBox(height: 8), - - // Advanced settings - ExpansionTile( - title: const Text('Advanced'), - tilePadding: EdgeInsets.zero, - children: [ - const SizedBox(height: 8), - _buildMethodSettings(context, viewModel, params), - ], - ), - ], - ], - ); - }, - ); - } - - Widget _buildMethodSettings(BuildContext context, MainViewModel viewModel, NoiseReductionParameters params) { - switch (params.method) { - case NoiseReductionMethod.smDegrain: - return _buildSmDegrainSettings(context, viewModel, params); - case NoiseReductionMethod.mcTemporalDenoise: - return _buildMcTemporalSettings(context, viewModel, params); - case NoiseReductionMethod.qtgmcBuiltin: - return _buildQtgmcBuiltinSettings(context, viewModel, params); - } - } - - Widget _buildSmDegrainSettings(BuildContext context, MainViewModel viewModel, NoiseReductionParameters params) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Temporal Radius - Text('Temporal Radius: ${params.smDegrainTr}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.smDegrainTr.toDouble(), - min: 1, - max: 6, - divisions: 5, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - smDegrainTr: value.round(), - preset: NoiseReductionPreset.custom, - )); - }, - ), - - // Threshold SAD (Luma) - Text('Luma Threshold: ${params.smDegrainThSAD}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.smDegrainThSAD.toDouble(), - min: 100, - max: 800, - divisions: 14, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - smDegrainThSAD: value.round(), - preset: NoiseReductionPreset.custom, - )); - }, - ), - - // Threshold SAD (Chroma) - Text('Chroma Threshold: ${params.smDegrainThSADC}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.smDegrainThSADC.toDouble(), - min: 50, - max: 400, - divisions: 7, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - smDegrainThSADC: value.round(), - preset: NoiseReductionPreset.custom, - )); - }, - ), - - SwitchListTile( - title: const Text('Refine Motion'), - contentPadding: EdgeInsets.zero, - value: params.smDegrainRefine, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - smDegrainRefine: value, - preset: NoiseReductionPreset.custom, - )); - }, - ), - ], - ); - } - - Widget _buildMcTemporalSettings(BuildContext context, MainViewModel viewModel, NoiseReductionParameters params) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Sigma: ${params.mcTemporalSigma.toStringAsFixed(1)}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.mcTemporalSigma, - min: 1.0, - max: 10.0, - divisions: 18, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - mcTemporalSigma: value, - preset: NoiseReductionPreset.custom, - )); - }, - ), - - Text('Temporal Radius: ${params.mcTemporalRadius}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.mcTemporalRadius.toDouble(), - min: 1, - max: 4, - divisions: 3, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - mcTemporalRadius: value.round(), - preset: NoiseReductionPreset.custom, - )); - }, - ), - ], - ); - } - - Widget _buildQtgmcBuiltinSettings(BuildContext context, MainViewModel viewModel, NoiseReductionParameters params) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('EZDenoise: ${params.qtgmcEzDenoise.toStringAsFixed(1)}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.qtgmcEzDenoise, - min: 0.0, - max: 5.0, - divisions: 50, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - qtgmcEzDenoise: value, - preset: NoiseReductionPreset.custom, - )); - }, - ), - - Text('Keep Grain: ${params.qtgmcEzKeepGrain.toStringAsFixed(2)}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.qtgmcEzKeepGrain, - min: 0.0, - max: 1.0, - divisions: 20, - onChanged: (value) { - _updateParams(viewModel, params.copyWith( - qtgmcEzKeepGrain: value, - preset: NoiseReductionPreset.custom, - )); - }, - ), - ], - ); - } - - String _getPresetDescription(NoiseReductionPreset preset) { - switch (preset) { - case NoiseReductionPreset.off: - return 'No noise reduction applied'; - case NoiseReductionPreset.light: - return 'Subtle denoising, preserves detail'; - case NoiseReductionPreset.moderate: - return 'Balanced noise reduction'; - case NoiseReductionPreset.heavy: - return 'Strong denoising for noisy sources'; - case NoiseReductionPreset.custom: - return 'Custom settings'; - } - } - - void _updateParams(MainViewModel viewModel, NoiseReductionParameters params) { - viewModel.updateProcessingPipeline( - viewModel.processingPipeline.copyWith(noiseReduction: params), - ); - } -} diff --git a/app/lib/views/pass_settings/pass_settings_inline.dart b/app/lib/views/pass_settings/pass_settings_inline.dart index ee04801a..181ae3b7 100644 --- a/app/lib/views/pass_settings/pass_settings_inline.dart +++ b/app/lib/views/pass_settings/pass_settings_inline.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../../models/dynamic_parameters.dart'; import '../../models/filter_registry.dart'; import '../../models/filter_schema.dart'; +import '../../models/pass_advice.dart'; import '../../models/processing_pipeline.dart'; import '../../services/whisper_addon_manager.dart'; import '../../utils/pixel_format.dart'; @@ -44,6 +45,22 @@ class PassSettingsInline extends StatelessWidget { return 'deband'; case PassType.sharpen: return 'sharpen'; + case PassType.antiAlias: + return 'anti_alias'; + case PassType.stabilize: + return 'stabilize'; + case PassType.geometry: + return 'geometry'; + case PassType.grain: + return 'grain'; + case PassType.frameRate: + return 'frame_rate'; + case PassType.deflicker: + return 'deflicker'; + case PassType.edgeRepair: + return 'edge_repair'; + case PassType.ghostRemoval: + return 'ghost_removal'; case PassType.colorCorrection: return 'color_correction'; case PassType.chromaFixes: @@ -74,6 +91,7 @@ class PassSettingsInline extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildDescription(context, schema), + _buildInteractionAdvice(context, viewModel), _buildOpenCLWarning(context, viewModel, filterId, params), _buildBitDepthWarning(context, viewModel, schema, params), DynamicFilterPanelCompact( @@ -96,6 +114,16 @@ class PassSettingsInline extends StatelessWidget { return _FilterDescription(schema: schema); } + /// How this pass interacts with the others that are switched on — a denoiser + /// that will undo the sharpening, a setting the chosen method ignores. Shown + /// in the advisory (not error) style, because every combination it comments on + /// still renders. See [adviceFor]. + Widget _buildInteractionAdvice(BuildContext context, MainViewModel viewModel) { + final message = adviceFor(passType, viewModel.processingPipeline); + if (message == null) return const SizedBox.shrink(); + return WarningBanner(message: message); + } + /// Warning shown when the deinterlace pass uses an OpenCL-only option that /// won't run on this machine: the "knlmeanscl" denoiser (gated on the /// knlmeanscl-specific probe) or the QTGMC OpenCL toggle (gated on the diff --git a/app/lib/views/pass_settings/sharpen_settings_panel.dart b/app/lib/views/pass_settings/sharpen_settings_panel.dart deleted file mode 100644 index 0aafbcb6..00000000 --- a/app/lib/views/pass_settings/sharpen_settings_panel.dart +++ /dev/null @@ -1,181 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../models/sharpen_parameters.dart'; -import '../../viewmodels/main_viewmodel.dart'; - -/// Settings panel for the sharpen pass. -class SharpenSettingsPanel extends StatelessWidget { - const SharpenSettingsPanel({super.key}); - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, viewModel, child) { - final params = viewModel.processingPipeline.sharpen; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Text( - 'Sharpen Settings', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - 'Sharpen edges and enhance detail', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - const SizedBox(height: 16), - - // Method selection - Text('Method', style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 8), - DropdownButtonFormField( - value: params.method, - isExpanded: true, - decoration: const InputDecoration( - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - items: SharpenMethod.values.map((method) { - return DropdownMenuItem( - value: method, - child: Text(method.displayName), - ); - }).toList(), - onChanged: (value) { - if (value != null) { - _updateParams(viewModel, params.copyWith(method: value)); - } - }, - ), - const SizedBox(height: 4), - Text( - params.method.description, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - - const SizedBox(height: 16), - - // Method-specific settings - if (params.method == SharpenMethod.lsfmod) - _buildLsfmodSettings(context, viewModel, params) - else - _buildCasSettings(context, viewModel, params), - ], - ); - }, - ); - } - - Widget _buildLsfmodSettings(BuildContext context, MainViewModel viewModel, SharpenParameters params) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Strength - Text('Strength: ${params.strength}%', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.strength.toDouble(), - min: 0, - max: 200, - divisions: 40, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(strength: value.round())); - }, - ), - - const SizedBox(height: 8), - - // Advanced settings - ExpansionTile( - title: const Text('Overshoot Control'), - tilePadding: EdgeInsets.zero, - children: [ - const SizedBox(height: 8), - - // Overshoot (bright edges) - Text('Bright Edge Limit: ${params.overshoot}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.overshoot.toDouble(), - min: 0, - max: 100, - divisions: 20, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(overshoot: value.round())); - }, - ), - - // Undershoot (dark edges) - Text('Dark Edge Limit: ${params.undershoot}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.undershoot.toDouble(), - min: 0, - max: 100, - divisions: 20, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(undershoot: value.round())); - }, - ), - - // Soft edge threshold - Text('Soft Edge Handling: ${params.softEdge}', - style: Theme.of(context).textTheme.labelLarge), - Slider( - value: params.softEdge.toDouble(), - min: 0, - max: 100, - divisions: 20, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(softEdge: value.round())); - }, - ), - ], - ), - ], - ); - } - - Widget _buildCasSettings(BuildContext context, MainViewModel viewModel, SharpenParameters params) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // CAS Sharpness - Text('Sharpness: ${(params.casSharpness * 100).round()}%', - style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 4), - Text( - 'Higher values increase edge contrast', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - Slider( - value: params.casSharpness, - min: 0.0, - max: 1.0, - divisions: 20, - onChanged: (value) { - _updateParams(viewModel, params.copyWith(casSharpness: value)); - }, - ), - ], - ); - } - - void _updateParams(MainViewModel viewModel, SharpenParameters params) { - viewModel.updateProcessingPipeline( - viewModel.processingPipeline.copyWith(sharpen: params), - ); - } -} diff --git a/app/lib/views/preview_panel.dart b/app/lib/views/preview_panel.dart index 7da5d0c8..30a9c8f4 100644 --- a/app/lib/views/preview_panel.dart +++ b/app/lib/views/preview_panel.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../viewmodels/main_viewmodel.dart'; import '../widgets/before_after_comparison.dart'; +import 'histogram_scope.dart'; class PreviewPanel extends StatefulWidget { const PreviewPanel({super.key}); @@ -17,6 +18,13 @@ class _PreviewPanelState extends State { double _panOffsetPixels = 0.0; bool _isDragging = false; + /// Whether the histogram overlay is shown over the processed preview. + /// + /// Deliberately available in simple mode: the scope exists to make the + /// colour controls usable, so hiding it from the people who need those + /// controls would defeat it. + bool _showHistogram = false; + @override Widget build(BuildContext context) { return Consumer( @@ -59,14 +67,68 @@ class _PreviewPanelState extends State { } } + // The scope reads the processed preview, falling back to the source frame + // before any pass has rendered — that is what the user is looking at. + final scopeImage = viewModel.processedPreview ?? viewModel.currentFrame; + return Padding( padding: const EdgeInsets.all(16), - child: BeforeAfterComparisonWidget( - beforeImage: viewModel.currentFrame, - afterImage: viewModel.processedPreview ?? viewModel.currentFrame, - isBeforeLoading: viewModel.isAnalyzing, - isAfterLoading: viewModel.isGeneratingPreview, - displayAspectRatio: displayAspectRatio, + child: Stack( + children: [ + Positioned.fill( + child: BeforeAfterComparisonWidget( + beforeImage: viewModel.currentFrame, + afterImage: scopeImage, + isBeforeLoading: viewModel.isAnalyzing, + isAfterLoading: viewModel.isGeneratingPreview, + displayAspectRatio: displayAspectRatio, + ), + ), + + // Histogram overlay and its toggle, top-right of the preview. + Positioned( + top: 8, + right: 8, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!_showHistogram) _buildHistogramToggle(context), + if (_showHistogram) + HistogramScope( + imageBytes: scopeImage, + onClose: () => setState(() => _showHistogram = false), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildHistogramToggle(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Tooltip( + message: 'Show histogram', + child: Material( + color: colorScheme.surface.withValues(alpha: 0.85), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + side: BorderSide(color: colorScheme.outline.withValues(alpha: 0.4)), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => setState(() => _showHistogram = true), + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon( + Icons.bar_chart, + size: 18, + color: colorScheme.onSurface.withValues(alpha: 0.75), + ), + ), + ), ), ); } diff --git a/app/lib/views/settings/dynamic_filter_panel.dart b/app/lib/views/settings/dynamic_filter_panel.dart index 74b2c927..50a3dd84 100644 --- a/app/lib/views/settings/dynamic_filter_panel.dart +++ b/app/lib/views/settings/dynamic_filter_panel.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../../models/dynamic_parameters.dart'; import '../../models/filter_schema.dart'; +import '../../services/advanced_mode_service.dart'; import 'widgets/parameter_widgets.dart'; /// A dynamically-generated settings panel based on a filter schema. @@ -184,7 +186,12 @@ class DynamicFilterPanel extends StatelessWidget { } /// A variant of DynamicFilterPanel that can be used inside a pass container. -class DynamicFilterPanelCompact extends StatefulWidget { +/// +/// Advanced mode is read from [AdvancedModeService], not held here: it is one +/// app-wide, persisted choice, so flipping it in any panel affects them all and +/// it survives collapsing the pass. It gates advanced-only sections, +/// preset-controlled parameters and advanced-only *methods*. +class DynamicFilterPanelCompact extends StatelessWidget { final FilterSchema schema; final DynamicParameters params; final ValueChanged onChanged; @@ -196,21 +203,21 @@ class DynamicFilterPanelCompact extends StatefulWidget { required this.onChanged, }); - @override - State createState() => _DynamicFilterPanelCompactState(); -} - -class _DynamicFilterPanelCompactState extends State { - bool _advancedMode = false; - - FilterSchema get schema => widget.schema; - DynamicParameters get params => widget.params; - ValueChanged get onChanged => widget.onChanged; - @override Widget build(BuildContext context) { - // Build method dropdown first if the filter has methods - final hasMultipleMethods = schema.methods.length > 1; + final advancedMode = context.watch().enabled; + + // The method the pipeline is actually using, which is always offered even + // when it's advanced-only. + final selectedMethod = params.method.isNotEmpty + ? params.method + : schema.methods.first.id; + final visibleMethods = schema.visibleMethods( + showAdvanced: advancedMode, + selectedId: selectedMethod, + ); + + final hasMultipleMethods = visibleMethods.length > 1; final hasAdvancedContent = _hasAdvancedContent(); return Column( @@ -221,22 +228,22 @@ class _DynamicFilterPanelCompactState extends State { Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - Text( - 'Advanced', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + Tooltip( + message: 'Show advanced options for every filter', + child: Text( + 'Advanced', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + ), ), ), const SizedBox(width: 8), SizedBox( height: 24, child: Switch( - value: _advancedMode, - onChanged: (value) { - setState(() { - _advancedMode = value; - }); - }, + value: advancedMode, + onChanged: (value) => + AdvancedModeService.instance.setEnabled(value), ), ), ], @@ -249,16 +256,18 @@ class _DynamicFilterPanelCompactState extends State { Text('Method', style: Theme.of(context).textTheme.labelLarge), const SizedBox(height: 8), DropdownButtonFormField( - value: params.method.isNotEmpty ? params.method : schema.methods.first.id, + value: selectedMethod, isExpanded: true, decoration: const InputDecoration( border: OutlineInputBorder(), contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), ), - items: schema.methods.map((method) { + items: visibleMethods.map((method) { return DropdownMenuItem( value: method.id, - child: Text(method.name), + child: Text( + method.advancedOnly ? '${method.name} (advanced)' : method.name, + ), ); }).toList(), onChanged: (value) { @@ -272,8 +281,25 @@ class _DynamicFilterPanelCompactState extends State { const SizedBox(height: 16), ], + // Sits outside the dropdown block on purpose: when simple mode filters + // a filter down to a single method there is no dropdown to hang this + // off, and that is precisely when the user most needs telling that more + // exist. + if (!advancedMode && schema.hasAdvancedMethods) ...[ + Text( + 'More methods are available in advanced mode.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), + ), + ), + const SizedBox(height: 16), + ], + // Get parameters for the current method - ..._buildMethodParameters(context), + ..._buildMethodParameters(context, advancedMode), ], ); } @@ -284,6 +310,8 @@ class _DynamicFilterPanelCompactState extends State { if (schema.parameterPresets != null && schema.parameterPresets!.isNotEmpty) { return true; } + // Has methods that only appear in advanced mode + if (schema.hasAdvancedMethods) return true; // Has advanced-only sections final sections = schema.ui?.sections; if (sections != null) { @@ -306,13 +334,13 @@ class _DynamicFilterPanelCompactState extends State { return controlled; } - List _buildMethodParameters(BuildContext context) { + List _buildMethodParameters(BuildContext context, bool advancedMode) { final widgets = []; final presetControlledParams = _getPresetControlledParams(); // In simple mode, show parameter preset selectors // In advanced mode, hide them and show the raw parameters - if (!_advancedMode) { + if (!advancedMode) { final presets = schema.parameterPresets; if (presets != null) { for (final entry in presets.entries) { @@ -341,15 +369,15 @@ class _DynamicFilterPanelCompactState extends State { if (sections != null && sections.isNotEmpty) { for (final section in sections) { // In simple mode, skip advanced-only sections - if (!_advancedMode && section.advancedOnly) continue; + if (!advancedMode && section.advancedOnly) continue; // Collect visible parameter widgets for this section first final sectionWidgets = []; for (final paramId in section.parameters) { // In simple mode, skip parameters controlled by presets - if (!_advancedMode && presetControlledParams.contains(paramId)) continue; + if (!advancedMode && presetControlledParams.contains(paramId)) continue; - final widget = _buildParameterWidget(context, paramId, showHidden: _advancedMode); + final widget = _buildParameterWidget(context, paramId, showHidden: advancedMode); if (widget != null) { sectionWidgets.add(widget); } @@ -359,7 +387,7 @@ class _DynamicFilterPanelCompactState extends State { if (sectionWidgets.isEmpty) continue; // In advanced mode with sections, show section headers - if (_advancedMode && section.advancedOnly) { + if (advancedMode && section.advancedOnly) { widgets.add( Padding( padding: const EdgeInsets.only(top: 16, bottom: 8), @@ -382,9 +410,9 @@ class _DynamicFilterPanelCompactState extends State { for (final paramId in method.parameters) { // In simple mode, skip parameters controlled by presets - if (!_advancedMode && presetControlledParams.contains(paramId)) continue; + if (!advancedMode && presetControlledParams.contains(paramId)) continue; - final widget = _buildParameterWidget(context, paramId, showHidden: _advancedMode); + final widget = _buildParameterWidget(context, paramId, showHidden: advancedMode); if (widget != null) { widgets.add(widget); } diff --git a/app/lib/views/settings/settings_dialog.dart b/app/lib/views/settings/settings_dialog.dart index eb0f931b..28fd3773 100644 --- a/app/lib/views/settings/settings_dialog.dart +++ b/app/lib/views/settings/settings_dialog.dart @@ -9,6 +9,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../about_dialog.dart' as about; import '../../models/encoding_settings.dart'; import '../../models/video_job.dart'; +import '../../services/advanced_mode_service.dart'; import '../../services/dependency_manager.dart'; import '../../services/hardware_encoder_detector.dart'; import '../../services/temp_directory_service.dart'; @@ -345,6 +346,7 @@ class _OutputSettingsTab extends StatefulWidget { class _OutputSettingsTabState extends State<_OutputSettingsTab> { late TextEditingController _filenamePatternController; late TextEditingController _customFfmpegArgsController; + late TextEditingController _customVapoursynthController; // Intel-Mac VideoToolbox uses a native target-bitrate control (no -q:v mode). final TextEditingController _vtBitrateController = TextEditingController(); @@ -367,6 +369,7 @@ class _OutputSettingsTabState extends State<_OutputSettingsTab> { super.initState(); _filenamePatternController = TextEditingController(); _customFfmpegArgsController = TextEditingController(); + _customVapoursynthController = TextEditingController(); _isIntelMac = Platform.isMacOS && DependencyManager.instance.platformId == 'macos-x64'; // Kick off (idempotent) encoder detection and rebuild as probes resolve so @@ -389,6 +392,7 @@ class _OutputSettingsTabState extends State<_OutputSettingsTab> { HardwareEncoderDetector.instance.removeListener(_onEncoderDetectionChanged); _filenamePatternController.dispose(); _customFfmpegArgsController.dispose(); + _customVapoursynthController.dispose(); _vtBitrateController.dispose(); _vtBitrateFocus.dispose(); super.dispose(); @@ -407,6 +411,9 @@ class _OutputSettingsTabState extends State<_OutputSettingsTab> { if (_customFfmpegArgsController.text != settings.customFfmpegArgs) { _customFfmpegArgsController.text = settings.customFfmpegArgs; } + if (_customVapoursynthController.text != settings.customVapoursynth) { + _customVapoursynthController.text = settings.customVapoursynth; + } return ListView( padding: const EdgeInsets.all(16), @@ -899,6 +906,48 @@ class _OutputSettingsTabState extends State<_OutputSettingsTab> { ], ), ), + + // Custom VapourSynth — the escape hatch for anyone who needs + // something the pass list does not offer. Same footing as the + // FFmpeg arguments above. + _buildSection( + context, + title: 'Custom VapourSynth', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _customVapoursynthController, + maxLines: 4, + minLines: 2, + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'clip = core.std.Crop(clip, left=2)', + ), + onChanged: (value) { + viewModel.updateEncodingSettings( + settings.copyWith(customVapoursynth: value), + ); + }, + ), + const SizedBox(height: 8), + Text( + 'Runs after every filter, with your result assigned back to ' + '"clip". It cannot change the number of frames — that would ' + 'make the progress bar and the preview disagree with the ' + 'output, so it is refused rather than allowed to fail ' + 'quietly. Use the trim controls instead.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), + ), + ), + ], + ), + ), ], ); }, @@ -1415,6 +1464,23 @@ class _GeneralSettingsTabState extends State<_GeneralSettingsTab> { return ListView( padding: const EdgeInsets.all(16), children: [ + _buildSection( + context, + title: 'Filter Options', + child: SwitchListTile( + title: const Text('Show advanced options'), + subtitle: const Text( + 'Reveal every filter method and parameter. Leave off for a ' + 'shorter, curated set of choices.', + ), + value: context.watch().enabled, + onChanged: (value) => + AdvancedModeService.instance.setEnabled(value), + ), + ), + + const SizedBox(height: 24), + _buildSection( context, title: 'Updates', diff --git a/app/lib/views/settings/widgets/parameter_widgets.dart b/app/lib/views/settings/widgets/parameter_widgets.dart index d77266a8..0614a101 100644 --- a/app/lib/views/settings/widgets/parameter_widgets.dart +++ b/app/lib/views/settings/widgets/parameter_widgets.dart @@ -1,3 +1,4 @@ +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -51,6 +52,13 @@ class ParameterWidgetFactory { value: value, onChanged: onChanged, ); + case WidgetType.filepicker: + return _FilePickerParameterWidget( + paramId: paramId, + param: param, + value: value, + onChanged: onChanged, + ); } } @@ -428,6 +436,116 @@ class _TextFieldParameterWidget extends StatelessWidget { } } +/// A path, with a Browse button beside the field. +/// +/// Stateful with a controller rather than `TextFormField(initialValue:)` like +/// the plain text field above: `initialValue` is read once, so a path written +/// back by the picker would not appear in the field. +class _FilePickerParameterWidget extends StatefulWidget { + final String paramId; + final ParameterDefinition param; + final dynamic value; + final ValueChanged onChanged; + + const _FilePickerParameterWidget({ + required this.paramId, + required this.param, + required this.value, + required this.onChanged, + }); + + @override + State<_FilePickerParameterWidget> createState() => + _FilePickerParameterWidgetState(); +} + +class _FilePickerParameterWidgetState + extends State<_FilePickerParameterWidget> { + late final TextEditingController _controller; + + String get _current => + widget.value?.toString() ?? widget.param.defaultValue?.toString() ?? ''; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: _current); + } + + @override + void didUpdateWidget(_FilePickerParameterWidget oldWidget) { + super.didUpdateWidget(oldWidget); + // Only when it actually differs — assigning unconditionally would move the + // caret to the end on every keystroke the user types. + if (_current != _controller.text) { + _controller.text = _current; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _browse() async { + final extensions = widget.param.ui?.fileExtensions; + final result = await FilePicker.platform.pickFiles( + dialogTitle: widget.param.ui?.label ?? 'Select File', + type: extensions == null || extensions.isEmpty + ? FileType.any + : FileType.custom, + allowedExtensions: + extensions == null || extensions.isEmpty ? null : extensions, + ); + + // A cancelled picker returns null; leave whatever is already there. + final path = + (result == null || result.files.isEmpty) ? null : result.files.first.path; + if (path != null) { + _controller.text = path; + widget.onChanged(path); + } + } + + @override + Widget build(BuildContext context) { + final label = widget.param.ui?.label ?? _formatParamName(widget.paramId); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + // Typing or pasting a path still works — the button is an + // addition, not a replacement. + child: TextField( + controller: _controller, + decoration: InputDecoration( + border: const OutlineInputBorder(), + isDense: true, + hintText: widget.param.ui?.description, + ), + onChanged: widget.onChanged, + ), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: _browse, + icon: const Icon(Icons.folder_open, size: 18), + label: const Text('Browse…'), + ), + ], + ), + ], + ); + } +} + /// Number input widget for numeric parameters without range. class _NumberParameterWidget extends StatelessWidget { final String paramId; diff --git a/app/macos/Runner/Info.plist b/app/macos/Runner/Info.plist index 451783a1..50aab605 100644 --- a/app/macos/Runner/Info.plist +++ b/app/macos/Runner/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.9.13 + 1.0.0 CFBundleVersion - 0.9.13 + 1.0.0 LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) NSHumanReadableCopyright diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 92c2252d..b5d33312 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -1,7 +1,7 @@ name: vapourbox description: "Video processing and cleanup powered by VapourSynth" publish_to: 'none' -version: 0.9.13+33 +version: 1.0.0+34 environment: sdk: ^3.6.2 diff --git a/app/test/advanced_mode_service_test.dart b/app/test/advanced_mode_service_test.dart new file mode 100644 index 00000000..f6edfdef --- /dev/null +++ b/app/test/advanced_mode_service_test.dart @@ -0,0 +1,87 @@ +// Tests for the app-wide advanced-mode setting: its default, that it persists, +// and that it notifies listeners so the filter panels rebuild. +// +// The default matters more than it looks — it is what a first-time user sees, +// and every advanced-only section and method in every schema hides behind it. +// +// Run with: flutter test test/advanced_mode_service_test.dart + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:vapourbox/services/advanced_mode_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final service = AdvancedModeService.instance; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + // The singleton carries state between tests. + service.resetForTesting(); + }); + + group('AdvancedModeService', () { + test('defaults to off', () async { + await service.initialize(); + expect(service.enabled, false); + }); + + test('loads a saved value', () async { + SharedPreferences.setMockInitialValues({'showAdvancedOptions': true}); + await service.initialize(); + expect(service.enabled, true); + }); + + test('persists a change', () async { + await service.initialize(); + await service.setEnabled(true); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool('showAdvancedOptions'), true); + + // A fresh load sees it — this is the whole point of the setting being + // global rather than per-panel widget state. + service.resetForTesting(); + await service.initialize(); + expect(service.enabled, true); + }); + + test('turning it back off persists too', () async { + SharedPreferences.setMockInitialValues({'showAdvancedOptions': true}); + await service.initialize(); + await service.setEnabled(false); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool('showAdvancedOptions'), false); + }); + + test('notifies listeners on change', () async { + await service.initialize(); + + var notifications = 0; + void listener() => notifications++; + service.addListener(listener); + addTearDown(() => service.removeListener(listener)); + + await service.setEnabled(true); + expect(notifications, 1); + + // Setting the same value again is a no-op, so panels don't rebuild for + // nothing. + await service.setEnabled(true); + expect(notifications, 1); + + await service.setEnabled(false); + expect(notifications, 2); + }); + + test('initialize is idempotent and does not clobber a live change', + () async { + await service.initialize(); + await service.setEnabled(true); + await service.initialize(); + expect(service.enabled, true); + }); + }); +} diff --git a/app/test/attribution_test.dart b/app/test/attribution_test.dart index d4f54168..fea86c2c 100644 --- a/app/test/attribution_test.dart +++ b/app/test/attribution_test.dart @@ -52,22 +52,27 @@ const _pluginToNotice = { 'awarpsharp2': 'AWarpSharp2', 'bifrost': 'Bifrost', 'bm3d': 'BM3D', + 'bwdif': 'Bwdif', 'cas': 'CAS', 'ctmf': 'CTMF', 'dctfilter': 'DCTFilter', 'deblock': 'Deblock', + 'dedot': 'DeDot', 'descratch': 'DeScratch', 'dfttest': 'DFTTest', 'eedi3m': 'EEDI3', 'fft3dfilter': 'FFT3DFilter', + 'fillborders': 'FillBorders', 'fluxsmooth': 'FluxSmooth', 'fmtconv': 'fmtconv', 'knlmeanscl': 'KNLMeansCL', + 'lghost': 'LGhost', 'miscfilters': 'Miscellaneous Filters', 'mvtools': 'MVTools', 'neo-f3kdb': 'neo_f3kdb', 'nnedi3': 'nnedi3', 'nnedi3cl': 'NNEDI3CL', + 'removedirt': 'RemoveDirt', 'removegrain': 'RemoveGrain', 'removegrainvs': 'RemoveGrain', 'retinex': 'Retinex', diff --git a/app/test/color_metadata_test.dart b/app/test/color_metadata_test.dart new file mode 100644 index 00000000..207c33e1 --- /dev/null +++ b/app/test/color_metadata_test.dart @@ -0,0 +1,32 @@ +// Colour tags are read from ffprobe here and re-declared on the encoder by the +// worker. ffprobe reports the literal string "unknown" for an untagged stream, +// and forwarding that as though it were a value would put nonsense on the +// ffmpeg command line — so it is dropped on the way in as well as on the way +// out (worker-side, in ColorMetadata::from_raw). + +import 'package:flutter_test/flutter_test.dart'; +import 'package:vapourbox/services/field_order_detector.dart'; + +void main() { + group('colorTag', () { + test('reads a real tag', () { + expect(colorTag({'color_space': 'bt709'}, 'color_space'), 'bt709'); + expect(colorTag({'color_range': 'pc'}, 'color_range'), 'pc'); + }); + + test('treats ffprobe\'s untagged spellings as absent', () { + for (final v in ['unknown', 'N/A', '', ' ']) { + expect(colorTag({'color_space': v}, 'color_space'), isNull, + reason: '"$v" is not a colour matrix'); + } + }); + + test('a missing key is absent, not an error', () { + expect(colorTag(const {}, 'color_space'), isNull); + }); + + test('trims surrounding whitespace', () { + expect(colorTag({'color_space': ' bt709 '}, 'color_space'), 'bt709'); + }); + }); +} diff --git a/app/test/dynamic_filter_panel_advanced_test.dart b/app/test/dynamic_filter_panel_advanced_test.dart new file mode 100644 index 00000000..22e12cb5 --- /dev/null +++ b/app/test/dynamic_filter_panel_advanced_test.dart @@ -0,0 +1,198 @@ +// Widget tests for how the generated filter panel honours advanced mode. +// +// The hazard worth a widget test rather than a unit test: the method dropdown +// takes its value from the pipeline and its items from the schema, so if simple +// mode ever filters an advanced-only method that is *currently selected*, the +// dropdown gets a value that isn't among its items and Flutter throws. That is +// reachable from an ordinary preset, so it must be pinned at the widget level — +// unit-testing `visibleMethods` alone would not catch a caller that forgot to +// pass `selectedId`. +// +// Run with: flutter test test/dynamic_filter_panel_advanced_test.dart + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:vapourbox/models/dynamic_parameters.dart'; +import 'package:vapourbox/models/filter_schema.dart'; +import 'package:vapourbox/services/advanced_mode_service.dart'; +import 'package:vapourbox/views/settings/dynamic_filter_panel.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final advanced = AdvancedModeService.instance; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + advanced.resetForTesting(); + }); + + MethodDefinition method(String id, {bool advancedOnly = false}) => + MethodDefinition( + id: id, + name: id, + function: 'module.$id', + parameters: const ['strength'], + advancedOnly: advancedOnly, + ); + + FilterSchema buildSchema() => FilterSchema( + id: 'test_filter', + version: '1.0.0', + name: 'Test Filter', + methods: [ + method('basic'), + method('exotic', advancedOnly: true), + ], + parameters: { + 'enabled': const ParameterDefinition( + type: ParameterType.boolean, + defaultValue: false, + ), + 'strength': const ParameterDefinition( + type: ParameterType.number, + defaultValue: 1.0, + min: 0.0, + max: 10.0, + ui: ParameterUiConfig(label: 'Strength'), + ), + }, + ); + + Future pumpPanel( + WidgetTester tester, { + required String selectedMethod, + }) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: advanced, + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: DynamicFilterPanelCompact( + schema: buildSchema(), + params: DynamicParameters( + filterId: 'test_filter', + enabled: true, + values: {'method': selectedMethod, 'strength': 1.0}, + ), + onChanged: (_) {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + group('DynamicFilterPanelCompact advanced mode', () { + testWidgets('simple mode hides advanced-only methods', (tester) async { + await advanced.initialize(); + await pumpPanel(tester, selectedMethod: 'basic'); + + // With only one method left to choose from, the dropdown isn't offered + // at all — one option is not a choice. + expect(find.text('Method'), findsNothing); + expect(find.text('exotic (advanced)'), findsNothing); + expect( + find.text('More methods are available in advanced mode.'), + findsOneWidget, + ); + }); + + testWidgets('advanced mode offers every method', (tester) async { + await advanced.initialize(); + await advanced.setEnabled(true); + await pumpPanel(tester, selectedMethod: 'basic'); + + expect(find.text('Method'), findsOneWidget); + expect( + find.text('More methods are available in advanced mode.'), + findsNothing, + ); + + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + expect(find.text('exotic (advanced)'), findsWidgets); + }); + + testWidgets('an advanced-only method already selected still renders in ' + 'simple mode', (tester) async { + await advanced.initialize(); + expect(advanced.enabled, false); + + // Would throw "There should be exactly one item with + // [DropdownButton]'s value" if the selected method were filtered out. + await pumpPanel(tester, selectedMethod: 'exotic'); + + expect(tester.takeException(), isNull); + expect(find.text('Method'), findsOneWidget); + expect(find.text('exotic (advanced)'), findsOneWidget); + }); + + testWidgets('toggling the switch flips the global service', (tester) async { + await advanced.initialize(); + await pumpPanel(tester, selectedMethod: 'basic'); + + // The switch shows because the schema has an advanced-only method, even + // though it has no advanced-only parameter sections. + expect(find.byType(Switch), findsOneWidget); + + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + + expect(advanced.enabled, true); + expect(find.text('Method'), findsOneWidget); + }); + }); + + group('AdvancedModeService provider scope', () { + // The Settings dialog reads this service, and `showDialog` pushes onto the + // MaterialApp's Navigator — so a provider placed inside `home` is out of + // scope for the dialog and the tab renders Provider's red error box + // instead of the switch. main.dart therefore provides it ABOVE the + // MaterialApp. This pins the shape that makes that work; the tests above + // supply their own provider and so cannot catch it. + testWidgets('a dialog route resolves it when provided above MaterialApp', + (tester) async { + await advanced.initialize(); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: advanced, + child: MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: ElevatedButton( + onPressed: () => showDialog( + context: context, + builder: (dialogContext) => Text( + 'advanced: ' + '${dialogContext.watch().enabled}', + ), + ), + child: const Text('Open'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('advanced: false'), findsOneWidget); + + // And the dialog rebuilds when it changes, so the switch inside Settings + // reflects a change made from a filter panel. + await advanced.setEnabled(true); + await tester.pumpAndSettle(); + expect(find.text('advanced: true'), findsOneWidget); + }); + }); +} diff --git a/app/test/filter_schema_curation_test.dart b/app/test/filter_schema_curation_test.dart new file mode 100644 index 00000000..c4e3d8c6 --- /dev/null +++ b/app/test/filter_schema_curation_test.dart @@ -0,0 +1,139 @@ +// Lints every built-in filter schema against the rules that keep the method +// dropdowns usable as filters are added. These are cheap assertions over the +// shipped JSON, and they exist because each rule has a silent failure mode: +// break one and the UI still renders, just wrongly. +// +// Rules and why: +// - The first method is the resolved default (`methods.first` is what an unset +// `method` falls back to), so marking it advancedOnly would default every +// user to a method most of them can't see. +// - At least one method must be visible in simple mode, or simple mode shows +// an arbitrary single choice with no dropdown at all. +// - Every method needs a description, because that is the only guidance shown +// beside it in the dropdown. A dropdown of sixteen bare names is worse than +// one of four. +// +// Run with: flutter test test/filter_schema_curation_test.dart + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:vapourbox/models/filter_schema.dart'; + +void main() { + final manifest = (jsonDecode( + File('assets/filters/manifest.json').readAsStringSync(), + ) as List) + .cast(); + + final schemas = [ + for (final filename in manifest) + FilterSchema.fromJson( + jsonDecode(File('assets/filters/core/$filename').readAsStringSync()) + as Map, + ), + ]; + + test('the manifest lists every schema file', () { + final onDisk = Directory('assets/filters/core') + .listSync() + .whereType() + .map((f) => f.uri.pathSegments.last) + .where((n) => n.endsWith('.json')) + .toSet(); + + expect(onDisk.difference(manifest.toSet()), isEmpty, + reason: 'a schema file exists but nothing loads it'); + }); + + group('method curation', () { + for (final schema in schemas) { + test('${schema.id}: first method is never advanced-only', () { + expect(schema.methods.first.advancedOnly, false, + reason: '${schema.methods.first.id} is the default method, so ' + 'hiding it in simple mode would default users to a method ' + 'they cannot see'); + }); + + test('${schema.id}: something is visible in simple mode', () { + expect(schema.visibleMethods(showAdvanced: false), isNotEmpty); + }); + + test('${schema.id}: every method has guidance', () { + for (final method in schema.methods) { + expect(method.description?.trim() ?? '', isNotEmpty, + reason: '${method.id} has no description, so the dropdown shows ' + 'a bare name with no hint of when to choose it'); + } + }); + } + }); + + group('curation actually shortens the long dropdowns', () { + // Pins the decisions made when advanced mode landed. Adding methods is + // expected; letting a simple-mode dropdown grow without curating is not. + // A count here going up means the curation was not extended along with the + // new methods. + const maxSimpleMethods = 4; + + for (final schema in schemas) { + test('${schema.id}: at most $maxSimpleMethods methods in simple mode', + () { + final visible = schema.visibleMethods(showAdvanced: false); + expect(visible.length, lessThanOrEqualTo(maxSimpleMethods), + reason: 'simple mode offers ${visible.map((m) => m.id).toList()}; ' + 'mark the specialist ones advancedOnly'); + }); + } + + test('dehalo hides its specialist and duplicated methods', () { + final dehalo = schemas.firstWhere((s) => s.id == 'dehalo'); + expect( + dehalo.visibleMethods(showAdvanced: false).map((m) => m.id), + // HQDeringmod is visible because deringing is a distinct problem from + // dehaloing, not a variant of it — this pass is named for both. + ['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); + }); + + test('the filters added from the gap analysis are behind advanced mode', () { + // Each is an alternative for when the default is not the right tool, not + // a better default — which is exactly what advanced mode is for. + // + // mClean is the one deliberate exception, and it spends the schema's last + // simple-mode slot. It is the only candidate that is a *goal* — denoise, + // restore detail, restore grain, behind one control — rather than another + // mechanism, which is the distinction this whole lint exists to enforce. + // Noise Reduction is now at the 4-method cap and cannot grow again + // without something else moving behind advanced. + final nr = schemas.firstWhere((s) => s.id == 'noise_reduction'); + expect( + nr.visibleMethods(showAdvanced: false).map((m) => m.id), + ['smdegrain', 'mc_temporal_denoise', 'qtgmc_builtin', 'mclean'], + ); + // TemporalDegrain2 is the most-requested filter in the gap analysis and + // is still advanced: 21 fps, a dozen interacting parameters, and three + // values that break it outright. An expert asks for it by name; a novice + // should never land on it by scrolling. + for (final id in [ + 'dfttest', + 'fft3dfilter', + 'ttempsmooth', + 'temporal_degrain2', + ]) { + expect(nr.getMethod(id)?.advancedOnly, true, reason: id); + } + + // aWarpSharp2 is visible: it is a genuinely different mechanism from + // LSFmod and CAS rather than another variant of them, and the pass only + // offered two options. + final sharpen = schemas.firstWhere((s) => s.id == 'sharpen'); + expect(sharpen.getMethod('awarpsharp2')?.advancedOnly, false); + expect(sharpen.visibleMethods(showAdvanced: false).length, 3); + }); + }); +} diff --git a/app/test/filter_schema_test.dart b/app/test/filter_schema_test.dart index d8003457..de99f264 100644 --- a/app/test/filter_schema_test.dart +++ b/app/test/filter_schema_test.dart @@ -236,6 +236,101 @@ void main() { expect(method.description, 'A test method'); expect(method.function, 'module.TestFunction'); expect(method.parameters, ['param1', 'param2']); + expect(method.advancedOnly, false); + }); + + test('parses advancedOnly', () { + final method = MethodDefinition.fromJson({ + 'id': 'exotic', + 'name': 'Exotic', + 'function': 'module.Exotic', + 'parameters': [], + 'advancedOnly': true, + }); + + expect(method.advancedOnly, true); + }); + }); + + group('FilterSchema.visibleMethods', () { + FilterSchema schemaWith(List methods) => FilterSchema( + id: 'test', + version: '1.0.0', + name: 'Test', + methods: methods, + parameters: const {}, + ); + + MethodDefinition method(String id, {bool advanced = false}) => + MethodDefinition( + id: id, + name: id, + function: 'module.$id', + parameters: const [], + advancedOnly: advanced, + ); + + test('simple mode drops advanced-only methods', () { + final schema = schemaWith([ + method('basic'), + method('exotic', advanced: true), + ]); + + expect( + schema.visibleMethods(showAdvanced: false).map((m) => m.id), + ['basic'], + ); + }); + + test('advanced mode shows everything', () { + final schema = schemaWith([ + method('basic'), + method('exotic', advanced: true), + ]); + + expect( + schema.visibleMethods(showAdvanced: true).map((m) => m.id), + ['basic', 'exotic'], + ); + }); + + test('keeps the selected method even when it is advanced-only', () { + // A preset or saved job can select an advanced method; hiding it would + // misreport the pipeline, and would hand a dropdown a value that isn't + // among its items. + final schema = schemaWith([ + method('basic'), + method('exotic', advanced: true), + ]); + + expect( + schema + .visibleMethods(showAdvanced: false, selectedId: 'exotic') + .map((m) => m.id), + ['basic', 'exotic'], + ); + }); + + test('preserves schema order, so the curated choice comes first', () { + final schema = schemaWith([ + method('first'), + method('exotic', advanced: true), + method('second'), + ]); + + expect( + schema.visibleMethods(showAdvanced: true).map((m) => m.id), + ['first', 'exotic', 'second'], + ); + }); + + test('hasAdvancedMethods reports whether there is more to reveal', () { + expect(schemaWith([method('basic')]).hasAdvancedMethods, false); + expect( + schemaWith([method('basic'), method('exotic', advanced: true)]) + .hasAdvancedMethods, + true, + ); }); }); diff --git a/app/test/generated_code_freshness_test.dart b/app/test/generated_code_freshness_test.dart new file mode 100644 index 00000000..28686f63 --- /dev/null +++ b/app/test/generated_code_freshness_test.dart @@ -0,0 +1,154 @@ +/// Guards against a stale `.g.dart`. +/// +/// `app/lib/**/*.g.dart` is gitignored, and every CI job runs +/// `dart run build_runner build` immediately before testing — so CI regenerates +/// unconditionally and can *never* reproduce this failure. It only ever happens +/// on a developer machine: add a field to a model, forget to rebuild, and +/// `_$XToJson` keeps emitting the old key set. +/// +/// The symptom is why this is worth a test. A dropped key isn't an error +/// anywhere — the worker's serde models carry `#[serde(default)]`, so the field +/// arrives as its default and the pass silently runs with the wrong settings. +/// You end up debugging the template. +/// +/// **Why field names and not mtimes.** The obvious check — is the `.g.dart` +/// newer than its source — is wrong, and measurably so: build_runner is +/// incremental and does not rewrite a generated file whose *output* is +/// unchanged, so eight models fail an mtime check straight after a clean build. +/// Comparing the declared fields against the generated ones tests the actual +/// hazard instead of a proxy for it. +/// +/// Unlike `WorkerHarness._warnIfWorkerIsStale()`, which warns, this **fails**. +/// That check tolerates a stale binary because a false positive would break the +/// suite over something it cannot fix; here the fix is one command, printed in +/// the failure message. +library; + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +/// Walk up from the test's CWD to the directory holding `pubspec.yaml`. +String _appRoot() { + var dir = Directory.current; + for (var i = 0; i < 6; i++) { + if (File(p.join(dir.path, 'pubspec.yaml')).existsSync()) return dir.path; + final parent = dir.parent; + if (parent.path == dir.path) break; + dir = parent; + } + fail('could not locate the app directory (no pubspec.yaml above ' + '${Directory.current.path})'); +} + +/// `final ;` — instance fields only. Getters have no `final`, and +/// `static`/`const` are excluded explicitly below. +final _field = RegExp(r'^\s*final\s+[\w<>,\s?.]+?\s+(\w+)\s*;'); + +/// A field json_serializable is told to leave out. Any of these on the field's +/// own line or the annotation above it means the generator won't reference it. +final _excluded = RegExp( + r'includeToJson:\s*false|includeFromJson:\s*false|ignore:\s*true', +); + +/// Names of the fields json_serializable should have generated code for, in +/// every `@JsonSerializable` class in [source]. +List _serializedFields(String source) { + final names = []; + final lines = source.split('\n'); + + for (var i = 0; i < lines.length; i++) { + if (!lines[i].trimLeft().startsWith('@JsonSerializable')) continue; + + // Walk to the class body, then track brace depth to find its end. + var depth = 0; + var started = false; + var previous = ''; + for (var j = i + 1; j < lines.length; j++) { + final line = lines[j]; + for (final ch in line.codeUnits) { + if (ch == 0x7B) { + depth++; + started = true; + } else if (ch == 0x7D) { + depth--; + } + } + if (started && depth <= 0) break; + + // Only top-level members of the class — depth 1 — so fields of a nested + // closure or collection literal can't be mistaken for declarations. + if (depth == 1 && !line.contains('static') && !line.contains('const ')) { + final match = _field.firstMatch(line); + if (match != null && + !_excluded.hasMatch(line) && + !_excluded.hasMatch(previous)) { + names.add(match.group(1)!); + } + } + if (line.trim().isNotEmpty) previous = line; + } + } + return names; +} + +void main() { + test('every generated .g.dart covers the fields its source declares', () { + final appRoot = _appRoot(); + final libDir = Directory(p.join(appRoot, 'lib')); + expect(libDir.existsSync(), isTrue, reason: 'app/lib should exist'); + + final missingFile = []; + final missingFields = []; + var checked = 0; + + for (final file in libDir.listSync(recursive: true).whereType()) { + final path = file.path; + if (!path.endsWith('.dart') || path.endsWith('.g.dart')) continue; + + // Only files that actually declare a part directive generate one. + final basename = p.basenameWithoutExtension(path); + final source = file.readAsStringSync(); + if (!source.contains("part '$basename.g.dart';")) continue; + + checked++; + final rel = p.relative(path, from: appRoot); + final generated = File(p.setExtension(path, '.g.dart')); + if (!generated.existsSync()) { + missingFile.add(rel); + continue; + } + + final code = generated.readAsStringSync(); + for (final field in _serializedFields(source)) { + // Every included field is read back as `instance.` in the + // toJson half, whatever `@JsonKey(name:)` renamed its JSON key to. + if (!code.contains('instance.$field')) { + missingFields.add('$rel: $field'); + } + } + } + + // A collector that matched nothing would pass vacuously; there are 28 + // generated models. + expect(checked, greaterThan(20), + reason: 'expected to find the generated models under app/lib'); + + expect( + [...missingFile, ...missingFields], + isEmpty, + reason: '\n' + '*** Generated serialization code is out of date ***\n' + '${missingFile.isEmpty ? '' : ' never generated: ${missingFile.join(', ')}\n'}' + '${missingFields.isEmpty ? '' : ' fields with no generated code:\n ${missingFields.join('\n ')}\n'}' + '\n' + 'A stale .g.dart drops the new fields from toJson() silently: the\n' + 'worker receives JSON without them, serde fills in defaults, and the\n' + 'pass runs with the wrong settings and no error anywhere.\n' + '\n' + 'Fix:\n' + ' cd app && dart run build_runner build --delete-conflicting-outputs\n', + ); + }); +} diff --git a/app/test/histogram_data_test.dart b/app/test/histogram_data_test.dart new file mode 100644 index 00000000..f1870066 --- /dev/null +++ b/app/test/histogram_data_test.dart @@ -0,0 +1,212 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:vapourbox/views/histogram_data.dart'; + +/// Builds a tightly packed RGBA buffer from a list of [r, g, b] triples. +Uint8List rgba(List> pixels, {int alpha = 255}) { + final out = Uint8List(pixels.length * 4); + for (var i = 0; i < pixels.length; i++) { + out[i * 4] = pixels[i][0]; + out[i * 4 + 1] = pixels[i][1]; + out[i * 4 + 2] = pixels[i][2]; + out[i * 4 + 3] = alpha; + } + return out; +} + +Uint8List solid(int r, int g, int b, int count) => + rgba(List.generate(count, (_) => [r, g, b])); + +void main() { + group('computeHistogramFromRgba', () { + test('empty buffer yields the empty histogram, not an exception', () { + final h = computeHistogramFromRgba(Uint8List(0)); + + expect(h.isEmpty, isTrue); + expect(h.sampleCount, 0); + expect(h.shadowClipFraction, 0.0); + expect(h.highlightClipFraction, 0.0); + expect(h.peak(HistogramChannel.luma), 0); + expect(h.plotScale(HistogramChannel.luma), 0); + for (final channel in HistogramChannel.values) { + expect(h.bins(channel).length, HistogramData.binCount); + expect(h.bins(channel).any((c) => c != 0), isFalse); + } + }); + + test('a buffer too short for one pixel is empty', () { + expect(computeHistogramFromRgba(Uint8List(3)).isEmpty, isTrue); + }); + + test('all-black image piles every channel into bin 0', () { + final h = computeHistogramFromRgba(solid(0, 0, 0, 16)); + + expect(h.sampleCount, 16); + expect(h.isEmpty, isFalse); + for (final channel in HistogramChannel.values) { + expect(h.bins(channel)[0], 16); + expect(h.bins(channel)[255], 0); + // Nothing outside bin 0. + expect(h.bins(channel).skip(1).any((c) => c != 0), isFalse); + } + expect(h.shadowClipFraction, 1.0); + expect(h.highlightClipFraction, 0.0); + // The interior is empty, so the plot falls back to the overall peak + // rather than trying to scale by zero. + expect(h.interiorPeak(HistogramChannel.luma), 0); + expect(h.peak(HistogramChannel.luma), 16); + expect(h.plotScale(HistogramChannel.luma), 16); + }); + + test('all-white image piles every channel into bin 255', () { + final h = computeHistogramFromRgba(solid(255, 255, 255, 10)); + + expect(h.sampleCount, 10); + for (final channel in HistogramChannel.values) { + expect(h.bins(channel)[255], 10); + expect(h.bins(channel)[0], 0); + } + expect(h.shadowClipFraction, 0.0); + expect(h.highlightClipFraction, 1.0); + }); + + test('known 4-pixel image bins each channel independently', () { + // Pure red, pure green, pure blue, mid grey. + final h = computeHistogramFromRgba(rgba([ + [255, 0, 0], + [0, 255, 0], + [0, 0, 255], + [128, 128, 128], + ])); + + expect(h.sampleCount, 4); + + final red = h.bins(HistogramChannel.red); + expect(red[255], 1); // the red pixel + expect(red[0], 2); // green + blue pixels + expect(red[128], 1); // the grey pixel + + final green = h.bins(HistogramChannel.green); + expect(green[255], 1); + expect(green[0], 2); + expect(green[128], 1); + + final blue = h.bins(HistogramChannel.blue); + expect(blue[255], 1); + expect(blue[0], 2); + expect(blue[128], 1); + + // Rec.709 weights, applied with 8.8 fixed point: + // red -> (54*255 + 128) >> 8 == 54 + // green -> (183*255 + 128) >> 8 == 182 + // blue -> (19*255 + 128) >> 8 == 19 + // grey -> 128 exactly + final luma = h.bins(HistogramChannel.luma); + expect(luma[54], 1); + expect(luma[182], 1); + expect(luma[19], 1); + expect(luma[128], 1); + expect(luma.fold(0, (a, b) => a + b), 4); + expect(luma[0], 0); + expect(luma[255], 0); + }); + + test('neutral grey maps to its own bin at every level', () { + // Grey must not drift: the weights sum to exactly 256 and rounding is + // to nearest, so a grey ramp lands one sample in each bin. + final ramp = rgba(List.generate(256, (v) => [v, v, v])); + final h = computeHistogramFromRgba(ramp); + + expect(h.sampleCount, 256); + final luma = h.bins(HistogramChannel.luma); + for (var v = 0; v < 256; v++) { + expect(luma[v], 1, reason: 'grey $v should land in bin $v'); + } + }); + + test('alpha is ignored', () { + final opaque = computeHistogramFromRgba(rgba([ + [10, 20, 30] + ], alpha: 255)); + final transparent = computeHistogramFromRgba(rgba([ + [10, 20, 30] + ], alpha: 0)); + + expect(transparent.sampleCount, opaque.sampleCount); + for (final channel in HistogramChannel.values) { + expect(transparent.bins(channel), opaque.bins(channel)); + } + }); + + test('a trailing partial pixel is ignored', () { + final bytes = Uint8List.fromList([ + ...solid(0, 0, 0, 2), + 1, 2, 3, // half a pixel + ]); + + expect(computeHistogramFromRgba(bytes).sampleCount, 2); + }); + + test('pixelStride samples every Nth pixel', () { + final h = computeHistogramFromRgba( + rgba([ + [0, 0, 0], + [255, 255, 255], + [0, 0, 0], + [255, 255, 255], + [0, 0, 0], + [255, 255, 255], + ]), + pixelStride: 2, + ); + + expect(h.sampleCount, 3); + expect(h.bins(HistogramChannel.luma)[0], 3); + expect(h.bins(HistogramChannel.luma)[255], 0); + }); + + test('a stride below 1 is treated as 1 rather than looping forever', () { + final h = computeHistogramFromRgba(solid(0, 0, 0, 4), pixelStride: 0); + expect(h.sampleCount, 4); + }); + + test('interiorPeak ignores the clipping bins', () { + // 100 crushed-black pixels swamping 3 mid-grey ones: scaling to the + // overall peak would flatten the picture's actual shape to nothing. + final h = computeHistogramFromRgba(rgba([ + ...List.generate(100, (_) => [0, 0, 0]), + ...List.generate(3, (_) => [128, 128, 128]), + ])); + + expect(h.peak(HistogramChannel.luma), 100); + expect(h.interiorPeak(HistogramChannel.luma), 3); + expect(h.plotScale(HistogramChannel.luma), 3); + expect(h.shadowClipFraction, closeTo(100 / 103, 1e-9)); + }); + }); + + group('pixelStrideFor', () { + test('does not subsample a frame under the cap', () { + // PAL and NTSC — this app's usual sources — count in full. + expect(pixelStrideFor(720 * 576), 1); + expect(pixelStrideFor(720 * 480), 1); + expect(pixelStrideFor(500000), 1); + expect(pixelStrideFor(0), 1); + + // Larger frames do subsample, which only scales the counts. + expect(pixelStrideFor(1280 * 720), greaterThan(1)); + }); + + test('subsamples enough to stay under the cap', () { + expect(pixelStrideFor(1000000, maxSamples: 250000), 4); + expect(pixelStrideFor(3840 * 2160, maxSamples: 250000), 34); + + for (final pixels in [1920 * 1080, 3840 * 2160, 7680 * 4320]) { + final stride = pixelStrideFor(pixels, maxSamples: 250000); + expect(pixels / stride, lessThanOrEqualTo(250000), + reason: '$pixels px at stride $stride should stay under the cap'); + } + }); + }); +} diff --git a/app/test/histogram_scope_test.dart b/app/test/histogram_scope_test.dart new file mode 100644 index 00000000..4291504e --- /dev/null +++ b/app/test/histogram_scope_test.dart @@ -0,0 +1,136 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vapourbox/views/histogram_scope.dart'; + +/// Encodes a solid-colour PNG so the scope can be pumped against real bytes +/// rather than a mock. +/// +/// Must be called inside [WidgetTester.runAsync]: image codecs are real async +/// work, which the fake-async test zone never completes. +Future solidPng(int r, int g, int b, {int size = 8}) async { + final rgba = Uint8List(size * size * 4); + for (var i = 0; i < size * size; i++) { + rgba[i * 4] = r; + rgba[i * 4 + 1] = g; + rgba[i * 4 + 2] = b; + rgba[i * 4 + 3] = 255; + } + final buffer = await ui.ImmutableBuffer.fromUint8List(rgba); + final descriptor = ui.ImageDescriptor.raw( + buffer, + width: size, + height: size, + pixelFormat: ui.PixelFormat.rgba8888, + ); + final codec = await descriptor.instantiateCodec(); + final frame = await codec.getNextFrame(); + final png = await frame.image.toByteData(format: ui.ImageByteFormat.png); + frame.image.dispose(); + codec.dispose(); + descriptor.dispose(); + return png!.buffer.asUint8List(); +} + +Widget host(Widget child, {Brightness brightness = Brightness.light}) { + return MaterialApp( + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.blue, + brightness: brightness, + ), + ), + home: Scaffold(body: Center(child: child)), + ); +} + +/// Pumps [widget] and gives the scope's decode a real moment to finish. +Future pumpScope(WidgetTester tester, Widget widget) async { + await tester.runAsync(() async { + await tester.pumpWidget(widget); + await Future.delayed(const Duration(milliseconds: 100)); + }); + await tester.pump(); +} + +void main() { + const timeout = Timeout(Duration(seconds: 30)); + + testWidgets('renders the empty state with no preview', (tester) async { + await pumpScope(tester, host(const HistogramScope(imageBytes: null))); + + expect(find.text('No preview yet'), findsOneWidget); + expect(tester.takeException(), isNull); + }, timeout: timeout); + + testWidgets('an empty buffer does not throw', (tester) async { + await pumpScope(tester, host(HistogramScope(imageBytes: Uint8List(0)))); + + expect(find.text('No preview yet'), findsOneWidget); + expect(tester.takeException(), isNull); + }, timeout: timeout); + + testWidgets('unreadable bytes fall back to the empty state', (tester) async { + await pumpScope( + tester, + host(HistogramScope(imageBytes: Uint8List.fromList([1, 2, 3, 4, 5]))), + ); + + expect(find.text('No preview yet'), findsOneWidget); + expect(tester.takeException(), isNull); + }, timeout: timeout); + + testWidgets('bins a real preview and reports clipping', (tester) async { + late Uint8List png; + await tester.runAsync(() async => png = await solidPng(0, 0, 0)); + + await pumpScope(tester, host(HistogramScope(imageBytes: png))); + + // An all-black frame is 100% shadow-clipped, 0% highlight-clipped. + expect(find.text('clip 100% / 0%'), findsOneWidget); + expect(tester.takeException(), isNull); + }, timeout: timeout); + + testWidgets('re-bins when the preview changes', (tester) async { + late Uint8List black; + late Uint8List white; + await tester.runAsync(() async { + black = await solidPng(0, 0, 0); + white = await solidPng(255, 255, 255); + }); + + await pumpScope(tester, host(HistogramScope(imageBytes: black))); + expect(find.text('clip 100% / 0%'), findsOneWidget); + + await pumpScope(tester, host(HistogramScope(imageBytes: white))); + expect(find.text('clip 0% / 100%'), findsOneWidget); + }, timeout: timeout); + + testWidgets('switches between Luma and RGB, and closes', (tester) async { + late Uint8List png; + await tester.runAsync(() async => png = await solidPng(90, 140, 200)); + var closed = false; + + await pumpScope( + tester, + host( + HistogramScope(imageBytes: png, onClose: () => closed = true), + brightness: Brightness.dark, + ), + ); + + await tester.tap(find.text('RGB')); + await tester.pump(); + expect(tester.takeException(), isNull); + + await tester.tap(find.text('Luma')); + await tester.pump(); + expect(tester.takeException(), isNull); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pump(); + expect(closed, isTrue); + }, timeout: timeout); +} diff --git a/app/test/integration_chroma_subsampling_test.dart b/app/test/integration_chroma_subsampling_test.dart index 12f282dc..d9a62631 100644 --- a/app/test/integration_chroma_subsampling_test.dart +++ b/app/test/integration_chroma_subsampling_test.dart @@ -350,6 +350,57 @@ void main() { }, timeout: const Timeout(Duration(minutes: 5))); }); + // =========================================================================== + // COLOUR METADATA + // =========================================================================== + // + // The Y4M pipe from vspipe strips colour tags exactly as it strips SAR, so + // an output that is not explicitly re-tagged comes out untagged — and an + // untagged file is read as BT.601 limited by every player. Every file this + // app wrote was untagged until the fix; measured before/after on a + // bt709 + full-range source, the output went from `tv, unknown` to + // `pc, bt709`. + // + // This asserts the round trip end to end, because nothing cheaper can: the + // tags are encoder arguments, so a generated-script test cannot see them, + // and the args-level unit tests in the worker cannot prove ffmpeg honoured + // them. + group('Colour metadata', () { + test('source colour tags are re-declared on the output', () async { + final job = createChromaTestJob( + '${TestConfig.outputDir}/test_color_tags.mkv', + ChromaSubsampling.original, + ).copyWith( + inputColorMatrix: 'bt709', + inputColorPrimaries: 'bt709', + inputColorTransfer: 'bt709', + inputColorRange: 'tv', + ); + + final result = await runFilterTest('Colour tag round trip', job); + expect(result.success, isTrue, reason: result.error); + + final out = await getVideoFormatInfo(result.outputPath!); + expect(out.colorSpace, 'bt709', + reason: 'matrix must survive the pipe, or players read it as 601'); + expect(out.colorRange, 'tv'); + }, timeout: const Timeout(Duration(minutes: 5))); + + test('an untagged source stays untagged rather than being guessed', () async { + final job = createChromaTestJob( + '${TestConfig.outputDir}/test_color_untagged.mkv', + ChromaSubsampling.original, + ); + + final result = await runFilterTest('Untagged passthrough', job); + expect(result.success, isTrue, reason: result.error); + + final out = await getVideoFormatInfo(result.outputPath!); + expect(out.colorSpace, anyOf(isNull, 'unknown'), + reason: 'we must not invent a matrix the source never declared'); + }, timeout: const Timeout(Duration(minutes: 5))); + }); + // =========================================================================== // CHROMA SUBSAMPLING: YUV420 // =========================================================================== diff --git a/app/test/integration_filter_parameters_test.dart b/app/test/integration_filter_parameters_test.dart index ffd7e057..fe3d0b5f 100644 --- a/app/test/integration_filter_parameters_test.dart +++ b/app/test/integration_filter_parameters_test.dart @@ -10,7 +10,10 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:uuid/uuid.dart'; +import 'package:vapourbox/models/anti_alias_parameters.dart'; import 'package:vapourbox/models/chroma_denoise_parameters.dart'; +import 'package:vapourbox/models/grain_parameters.dart'; +import 'package:vapourbox/models/geometry_parameters.dart'; import 'package:vapourbox/models/chroma_fix_parameters.dart'; import 'package:vapourbox/models/color_correction_parameters.dart'; import 'package:vapourbox/models/crop_resize_parameters.dart'; @@ -27,6 +30,7 @@ import 'package:vapourbox/models/processing_pipeline.dart'; import 'package:vapourbox/models/qtgmc_parameters.dart'; import 'package:vapourbox/models/sharpen_parameters.dart'; import 'package:vapourbox/models/spotless_parameters.dart'; +import 'package:vapourbox/models/stabilize_parameters.dart'; import 'package:vapourbox/models/video_job.dart'; import 'support/worker_harness.dart'; @@ -165,6 +169,10 @@ VideoJob buildJob({ SpotLessParameters? spotless, CropResizeParameters? cropResize, ChromaDenoiseParameters? chromaDenoise, + AntiAliasParameters? antiAlias, + StabilizeParameters? stabilize, + GeometryParameters? geometry, + GrainParameters? grain, }) => VideoJob( id: const Uuid().v4(), inputPath: TestConfig.inputFile, @@ -182,6 +190,10 @@ VideoJob buildJob({ colorCorrection: colorCorrection ?? const ColorCorrectionParameters(), cropResize: cropResize ?? const CropResizeParameters(), chromaDenoise: chromaDenoise ?? const ChromaDenoiseParameters(), + antiAlias: antiAlias ?? const AntiAliasParameters(), + stabilize: stabilize ?? const StabilizeParameters(), + geometry: geometry ?? const GeometryParameters(), + grain: grain ?? const GrainParameters(), ), encodingSettings: const EncodingSettings( codec: VideoCodec.h264, container: ContainerFormat.mkv, audioMode: AudioMode.none, @@ -447,6 +459,45 @@ void main() { // CCD rejects a scale below 1.0, and its own automatic value is below 1.0 // for anything shorter than 480 lines — so we derive it with a floor. expect(script, contains('max(1.0, clip.height / 480.0)')); + // The other method must not also be emitted. + expect(script, isNot(contains('core.zsmooth.Cnr4('))); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + // Cnr4 — the second Chroma Denoise method. It targets colour that swims + // between frames, where CCD targets blotches that sit still. + test('chroma_denoise: Cnr4 needs SCDetect and a 4:1:1 guard', () async { + final job = buildJob( + testName: 'chroma_denoise_cnr4', + chromaDenoise: const ChromaDenoiseParameters( + enabled: true, + method: ChromaDenoiseMethod.cnr4, + cnr4Strength: 160, + cnr4Sense: 50, + cnr4Radius: 3, + ), + ); + print(' Generating Cnr4 script...'); + final script = await generateScriptViaWorker(job); + expect(script, contains('core.zsmooth.Cnr4(')); + expect(script, isNot(contains('core.zsmooth.CCD('))); + + // scenechange defaults to True and needs frame properties this pipeline + // never sets, so without SCDetect in front this fails EVERY job on every + // platform. Measured against the bundled plugin. + final cnr4At = script.indexOf('core.zsmooth.Cnr4('); + expect(script.substring(0, cnr4At), contains('core.misc.SCDetect('), + reason: 'Cnr4 without SCDetect fails at frame request, not at parse'); + + // 4:1:1 is NTSC DV and pipe_source maps it natively; Cnr4 rejects it. + expect(script.substring(0, cnr4At), contains('subsampling_w == 2')); + + final actual = parseFilterParams(script, 'core.zsmooth.Cnr4('); + expect(actual['radius'], '3'); + // One slider drives all three planes, preserving the plugin's own ratio + // between luma and chroma rather than exposing three numbers. + expect(actual['sense'], '[50, 67, 67]'); + expect(actual['str'], '[160, 213, 213]'); print(' PASS'); }, timeout: const Timeout(Duration(minutes: 2))); @@ -471,6 +522,8 @@ void main() { expect(script, contains('core.mv.Degrain3(')); final actual = parseFilterParams(script, 'core.mv.Degrain3('); print(' Parsed ${actual.length} params'); + // mvtools spells it thsad here; mClean's call spells it thSAD. The parser + // preserves case, so the two are not interchangeable. expect(actual['thsad'], '500'); expect(actual['plane'], '3'); // Blur/sharpen references must cover the same planes as the degrain. @@ -671,6 +724,554 @@ void main() { print(' PASS'); }, timeout: const Timeout(Duration(minutes: 2))); + + // --- Filters added from the Hybrid gap analysis ----------------------- + // + // Each of these was already installed in the deps bundle and merely + // unexposed, so the risk here is not the plugin — it is the wiring. These + // assert the parameters survive the whole trip (Dart typed model -> + // converter -> job JSON -> Rust model -> generated .vpy), which is the part + // that fails silently: a name mismatch anywhere and the filter runs with + // its defaults while the UI shows the user's values. + + test('noise reduction: DFTTest params', () async { + loadSchema('noise_reduction'); + final typed = const NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.dfttest, + dfttestSigma: 12.5, + dfttestTbsize: 5, + dfttestSbsize: 12, + ); + final job = buildJob(testName: 'nr_dfttest', noiseReduction: typed); + print(' Generating DFTTest script...'); + final script = await generateScriptViaWorker(job); + expect(script, contains('core.dfttest.DFTTest(')); + final actual = parseFilterParams(script, 'core.dfttest.DFTTest('); + print(' Parsed ${actual.length} params'); + expect(actual['sigma'], '12.5'); + expect(actual['tbsize'], '5'); + expect(actual['sbsize'], '12'); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('noise reduction: DFTTest temporal window is forced odd', () async { + // An even tbsize is accepted by DFTTest but processes a window that isn't + // centred on the current frame, so the worker rounds it down. + final typed = const NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.dfttest, + dfttestTbsize: 6, + ); + final job = buildJob(testName: 'nr_dfttest_even', noiseReduction: typed); + final script = await generateScriptViaWorker(job); + final actual = parseFilterParams(script, 'core.dfttest.DFTTest('); + expect(actual['tbsize'], '5'); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + // mClean and TemporalDegrain2 both shipped with the script generator + // removing their own template block before enabling it, so the pass ran and + // emitted no denoiser at all. `test_149` in the Rust suite now enumerates + // every method, but it builds the Rust struct directly — only going through + // the worker binary proves the Dart enum's JsonValue and the Rust serde name + // still agree. + test('noise reduction: mClean reaches the script with its params', () async { + loadSchema('noise_reduction'); + final typed = const NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.mClean, + mcleanStrength: 17, + mcleanSharp: 9, + mcleanRn: 11, + mcleanThsad: 320, + ); + final job = buildJob(testName: 'nr_mclean', noiseReduction: typed); + final script = await generateScriptViaWorker(job); + expect(script, contains('from mclean import mClean')); + expect(script, contains('_mClean(')); + final actual = parseFilterParams(script, '_mClean('); + expect(actual['strength'], '17'); + expect(actual['sharp'], '9'); + expect(actual['rn'], '11'); + expect(actual['thSAD'], '320'); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('noise reduction: TemporalDegrain2 reaches the script with its params', + () async { + loadSchema('noise_reduction'); + final typed = const NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.temporalDegrain2, + td2DegrainTr: 2, + td2GrainLevel: 1, + td2PostFft: 3, + td2PostMix: 40, + ); + final job = buildJob(testName: 'nr_td2', noiseReduction: typed); + final script = await generateScriptViaWorker(job); + expect(script, contains('from temporaldegrain2 import TemporalDegrain2')); + expect(script, contains('_TemporalDegrain2(')); + final actual = parseFilterParams(script, '_TemporalDegrain2('); + expect(actual['degrainTR'], '2'); + expect(actual['grainLevel'], '1'); + // 4 and 5 abort the process rather than raising, so the clamp matters. + expect(actual['postFFT'], '3'); + expect(actual['postMix'], '40'); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('noise reduction: FFT3DFilter params', () async { + loadSchema('noise_reduction'); + final typed = const NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.fft3dFilter, + fft3dSigma: 3.5, + fft3dBt: 4, + fft3dSharpen: 0.4, + ); + final job = buildJob(testName: 'nr_fft3d', noiseReduction: typed); + print(' Generating FFT3DFilter script...'); + final script = await generateScriptViaWorker(job); + expect(script, contains('core.fft3dfilter.FFT3DFilter(')); + final actual = parseFilterParams(script, 'core.fft3dfilter.FFT3DFilter('); + print(' Parsed ${actual.length} params'); + expect(actual['sigma'], '3.5'); + expect(actual['bt'], '4'); + expect(actual['sharpen'], '0.4'); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('noise reduction: TTempSmooth params, mdiff held below thresh', + () async { + loadSchema('noise_reduction'); + // mdiff deliberately set above thresh: the plugin accepts that but it + // silently disables the motion protection the parameter exists for. + final typed = const NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.tTempSmooth, + ttempMaxr: 4, + ttempThresh: 6, + ttempMdiff: 9, + ttempStrength: 3, + ); + final job = buildJob(testName: 'nr_ttempsmooth', noiseReduction: typed); + print(' Generating TTempSmooth script...'); + final script = await generateScriptViaWorker(job); + expect(script, contains('core.ttmpsm.TTempSmooth(')); + final actual = parseFilterParams(script, 'core.ttmpsm.TTempSmooth('); + print(' Parsed ${actual.length} params'); + expect(actual['maxr'], '4'); + expect(actual['thresh'], '6'); + expect(actual['mdiff'], '5', reason: 'clamped to thresh - 1'); + expect(actual['strength'], '3'); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('sharpen: aWarpSharp2 params', () async { + loadSchema('sharpen'); + final typed = const SharpenParameters( + enabled: true, + method: SharpenMethod.aWarpSharp2, + warpDepth: 20, + warpThresh: 100, + warpBlur: 3, + warpType: 1, + ); + final job = buildJob(testName: 'sharpen_awarpsharp2', sharpen: typed); + print(' Generating aWarpSharp2 script...'); + final script = await generateScriptViaWorker(job); + expect(script, contains('core.warp.AWarpSharp2(')); + final actual = parseFilterParams(script, 'core.warp.AWarpSharp2('); + print(' Parsed ${actual.length} params'); + expect(actual['depth'], '20'); + expect(actual['thresh'], '100'); + expect(actual['blur'], '3'); + expect(actual['type'], '1'); + // `chroma` is deliberately absent: this port accepts only 0 or 1 and + // rejects anything else at script evaluation. The block first shipped with + // Avisynth's chroma=4, which killed vspipe — caught by the heavy + // end-to-end test, not by script generation, which is why this assertion + // is here as well. + expect(actual.containsKey('chroma'), isFalse); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('dehalo: HQDeringmod params', () async { + loadSchema('dehalo'); + final typed = const DehaloParameters( + enabled: true, + method: DehaloMethod.hqDeringmod, + deringMrad: 2, + deringMsmooth: 2, + deringMthr: 70, + deringThr: 16.0, + deringDarkthr: 4.0, + ); + final job = buildJob(testName: 'dehalo_hqderingmod', dehalo: typed); + print(' Generating HQDeringmod script...'); + final script = await generateScriptViaWorker(job); + expect(script, contains('haf.HQDeringmod(')); + final actual = parseFilterParams(script, 'haf.HQDeringmod('); + print(' Parsed ${actual.length} params'); + expect(actual['mrad'], '2'); + expect(actual['msmooth'], '2'); + expect(actual['mthr'], '70'); + // format_double emits a trailing .0 for whole numbers; assert the exact + // text so a change in numeric formatting is visible rather than hidden by + // a substring match. + expect(actual['thr'], '16.0'); + expect(actual['darkthr'], '4.0'); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('dehalo: HQDeringmod omits unset params so havsfunc defaults apply', + () async { + final typed = const DehaloParameters( + enabled: true, + method: DehaloMethod.hqDeringmod, + ); + final job = buildJob(testName: 'dehalo_hqdering_defaults', dehalo: typed); + final script = await generateScriptViaWorker(job); + expect(script, contains('haf.HQDeringmod(')); + final actual = parseFilterParams(script, 'haf.HQDeringmod('); + expect(actual, isEmpty, + reason: 'passing our own values would override upstream tuning'); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + + // --- Second batch: anti-aliasing, stabilisation, rainbow removal -------- + + test('anti-alias: daa', () async { + loadSchema('anti_alias'); + final job = buildJob( + testName: 'aa_daa', + antiAlias: const AntiAliasParameters( + enabled: true, + method: AntiAliasMethod.daa, + ), + ); + print(' Generating daa script...'); + final script = await generateScriptViaWorker(job); + expect(script, contains('haf.daa(')); + expect(script, isNot(contains('haf.santiag('))); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('anti-alias: santiag params, interpolator pinned to nnedi3', () async { + loadSchema('anti_alias'); + final job = buildJob( + testName: 'aa_santiag', + antiAlias: const AntiAliasParameters( + enabled: true, + method: AntiAliasMethod.santiag, + santiagStrh: 2, + santiagStrv: 3, + ), + ); + print(' Generating santiag script...'); + final script = await generateScriptViaWorker(job); + expect(script, contains('haf.santiag(')); + final actual = parseFilterParams(script, 'haf.santiag('); + print(' Parsed ${actual.length} params'); + expect(actual['strh'], '2'); + expect(actual['strv'], '3'); + // eedi2 and sangnom are not in the deps bundle; naming one would fail at + // script evaluation rather than degrade. + expect(actual['type'], '"nnedi3"'); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('stabilize: Stab params', () async { + loadSchema('stabilize'); + final job = buildJob( + testName: 'stabilize', + stabilize: const StabilizeParameters( + enabled: true, + dxmax: 6, + dymax: 8, + mirror: 3, + ), + ); + print(' Generating Stab script...'); + final script = await generateScriptViaWorker(job); + expect(script, contains('haf.Stab(')); + final actual = parseFilterParams(script, 'haf.Stab('); + print(' Parsed ${actual.length} params'); + expect(actual['dxmax'], '6'); + expect(actual['dymax'], '8'); + expect(actual['mirror'], '3'); + // Stab(clp, dxmax, dymax, mirror) — there is no `range` argument, and + // passing one is a TypeError at script evaluation. + expect(actual.containsKey('range'), isFalse); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('chroma_fixes: LUTDeRainbow with its 10-bit guard', () async { + loadSchema('chroma_fixes'); + final job = buildJob( + testName: 'derainbow', + chromaFixes: const ChromaFixParameters( + enabled: true, + applyDeRainbow: true, + deRainbowCThresh: 12, + deRainbowYThresh: 14, + ), + ); + print(' Generating LUTDeRainbow script...'); + final script = await generateScriptViaWorker(job); + expect(script, contains('haf.LUTDeRainbow(')); + final actual = parseFilterParams(script, 'haf.LUTDeRainbow('); + print(' Parsed ${actual.length} params'); + expect(actual['cthresh'], '12'); + expect(actual['ythresh'], '14'); + // Same 8-10 bit limit as LUTDeCrawl, verified by probing the bundle. + expect(script, contains('_derainbow_orig_format')); + expect(script, contains('bits_per_sample > 10')); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + + // --- Third batch: needs the fluxsmooth plugin (deps 1.9.0) -------------- + + test('noise reduction: FluxSmoothT params', () async { + loadSchema('noise_reduction'); + final job = buildJob( + testName: 'nr_flux_t', + noiseReduction: const NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.fluxSmoothT, + fluxTemporalThreshold: 9, + ), + ); + print(' Generating FluxSmoothT script...'); + final script = await generateScriptViaWorker(job); + expect(script, contains('core.flux.SmoothT(')); + expect(script, isNot(contains('core.flux.SmoothST('))); + final actual = parseFilterParams(script, 'core.flux.SmoothT('); + expect(actual['temporal_threshold'], '9'); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('noise reduction: FluxSmoothST params', () async { + final job = buildJob( + testName: 'nr_flux_st', + noiseReduction: const NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.fluxSmoothSt, + fluxTemporalThreshold: 9, + fluxSpatialThreshold: 11, + ), + ); + print(' Generating FluxSmoothST script...'); + final script = await generateScriptViaWorker(job); + expect(script, contains('core.flux.SmoothST(')); + final actual = parseFilterParams(script, 'core.flux.SmoothST('); + expect(actual['temporal_threshold'], '9'); + expect(actual['spatial_threshold'], '11'); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('noise reduction: STPresso params', () async { + final job = buildJob( + testName: 'nr_stpresso', + noiseReduction: const NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.stPresso, + stpressoLimit: 5, + stpressoBias: 30, + stpressoTthr: 16, + ), + ); + print(' Generating STPresso script...'); + final script = await generateScriptViaWorker(job); + expect(script, contains('haf.STPresso(')); + final actual = parseFilterParams(script, 'haf.STPresso('); + expect(actual['limit'], '5'); + expect(actual['bias'], '30'); + expect(actual['tthr'], '16'); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + + // --- Batch four ------------------------------------------------------- + + test('noise reduction: CTMF, with its 9-bit guard and pinned memsize', + () async { + loadSchema('noise_reduction'); + final job = buildJob( + testName: 'nr_ctmf', + noiseReduction: const NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.ctmf, + ctmfRadius: 4, + ctmfPlanes: 0, + ), + ); + print(' Generating CTMF script...'); + final script = await generateScriptViaWorker(job); + expect(script, contains('core.ctmf.CTMF(')); + final actual = parseFilterParams(script, 'core.ctmf.CTMF('); + expect(actual['radius'], '4'); + // 9-bit is rejected by the plugin and IS reachable: pixel_format.rs + // rounds an odd source depth up through 9. + expect(script, contains('bits_per_sample == 9')); + // At the plugin default, 16-bit radius 3 measures 0.79 fps against 42 + // here, for bit-identical output. + expect(actual['memsize'], '16777216'); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('deblock: DCTFilter builds exactly eight finite factors', () async { + loadSchema('deblock'); + final job = buildJob( + testName: 'deblock_dctfilter', + deblock: const DeblockParameters( + enabled: true, + method: DeblockMethod.dctFilter, + dctCutoff: 5, + dctStrength: 0.6, + ), + ); + print(' Generating DCTFilter script...'); + final script = await generateScriptViaWorker(job); + expect(script, contains('core.dctf.DCTFilter(')); + + final start = script.indexOf('factors=[') + 'factors=['.length; + final end = script.indexOf(']', start); + final factors = script.substring(start, end).split(','); + expect(factors.length, 8, reason: 'the plugin requires exactly 8'); + for (final f in factors) { + final value = double.parse(f.trim()); + expect(value, inInclusiveRange(0.0, 1.0)); + expect(value.isFinite, isTrue, + reason: 'the plugin accepts NaN and blackens the frame'); + } + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('grain: AddGrain params are not depth-scaled', () async { + loadSchema('grain'); + final job = buildJob( + testName: 'grain_add', + grain: const GrainParameters( + enabled: true, + var_: 9.0, + uvar: 2.0, + constant: true, + ), + ); + print(' Generating AddGrain script...'); + final script = await generateScriptViaWorker(job); + expect(script, contains('core.grain.Add(')); + final actual = parseFilterParams(script, 'core.grain.Add('); + // `var` is already in 8-bit units and the plugin rescales internally, so + // applying the _levels_8bit() treatment would quadruple grain at 10-bit. + // format_double emits a trailing .0; assert the exact text so a change + // in numeric formatting is visible rather than hidden. + expect(actual['var'], '9.0'); + expect(actual['uvar'], '2.0'); + expect(actual['constant'], 'True'); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('geometry: rotation restores the source pixel format', () async { + loadSchema('geometry'); + final job = buildJob( + testName: 'geometry_rotate', + geometry: const GeometryParameters( + enabled: true, + rotation: Rotation.cw90, + flipHorizontal: true, + ), + ); + print(' Generating rotate script...'); + final script = await generateScriptViaWorker(job); + expect(script, contains('core.std.Turn90(')); + expect(script, contains('core.std.FlipHorizontal(')); + expect(script, isNot(contains('core.std.FlipVertical('))); + // A quarter turn makes 4:2:2 into 4:4:0, which ffmpeg rejects outright, + // and 4:1:1 into a format with no y4m identifier at all. + expect(script, contains('_geom_src_format')); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + + test('color: SmoothLevels scales to the clip depth and pins useDB', + () async { + loadSchema('color_correction'); + final job = buildJob( + testName: 'color_smooth_levels', + colorCorrection: const ColorCorrectionParameters( + enabled: true, + applyLevels: true, + smoothLevels: true, + inputLow: 16, + inputHigh: 235, + ), + ); + print(' Generating SmoothLevels script...'); + final script = await generateScriptViaWorker(job); + expect(script, contains('haf.SmoothLevels(')); + // Same trap as std.Levels: SmoothLevels reads its levels in the clip's + // own range, so they are scaled in-script from clip.format. + expect(script, contains('input_low=_levels_8bit(16)')); + expect(script, contains('input_high=_levels_8bit(235)')); + // havsfunc calls core.f3kdb.Deband; this bundle ships neo_f3kdb, so the + // default useDB=True fails on every format. + expect(script, contains('useDB=False')); + expect(script, isNot(contains('clip = core.std.Levels('))); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + + // --- Batch five: needs bifrost + retinex (deps 1.9.0) ------------------- + + test('chroma_fixes: Bifrost with its 8-bit guard', () async { + loadSchema('chroma_fixes'); + final job = buildJob( + testName: 'chroma_bifrost', + chromaFixes: const ChromaFixParameters( + enabled: true, + applyBifrost: true, + bifrostLumaThresh: 12.0, + bifrostVariation: 3, + bifrostInterlaced: false, + ), + ); + print(' Generating Bifrost script...'); + final script = await generateScriptViaWorker(job); + expect(script, contains('core.bifrost.Bifrost(')); + final actual = parseFilterParams(script, 'core.bifrost.Bifrost('); + expect(actual['variation'], '3'); + expect(actual['interlaced'], 'False'); + // 8-bit only, verified against the bundle at 10/12/16-bit and 4:2:2. + expect(script, contains('_bifrost_src_format')); + expect(script, contains('bits_per_sample != 8')); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('color: shadow detail runs on luma only', () async { + loadSchema('color_correction'); + final job = buildJob( + testName: 'color_shadow_detail', + colorCorrection: const ColorCorrectionParameters( + enabled: true, + applyShadowDetail: true, + shadowSigma: 120.0, + ), + ); + print(' Generating shadow detail script...'); + final script = await generateScriptViaWorker(job); + expect(script, contains('core.retinex.MSRCP(')); + // MSRCP rejects subsampled formats and every source here is one, so the + // luma plane is processed alone rather than round-tripping via 4:4:4. + expect(script, contains('colorfamily=vs.GRAY')); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + // --- IVTC (core.vivtc.VFM + VDecimate) high-bit-depth guard --- // VFM only accepts 8-bit YUV/GRAY, so IVTC on a 10-bit source (e.g. ProRes // 422, yuv422p10le) must run field matching on an 8-bit metrics copy while diff --git a/app/test/integration_high_bit_depth_filters_test.dart b/app/test/integration_high_bit_depth_filters_test.dart index c7816a6e..53da1297 100644 --- a/app/test/integration_high_bit_depth_filters_test.dart +++ b/app/test/integration_high_bit_depth_filters_test.dart @@ -48,8 +48,12 @@ import 'package:vapourbox/models/crop_resize_parameters.dart'; import 'package:vapourbox/models/deband_parameters.dart'; import 'package:vapourbox/models/deblock_parameters.dart'; import 'package:vapourbox/models/dehalo_parameters.dart'; +import 'package:vapourbox/models/deflicker_parameters.dart'; import 'package:vapourbox/models/descratch_parameters.dart'; +import 'package:vapourbox/models/edge_repair_parameters.dart'; import 'package:vapourbox/models/encoding_settings.dart'; +import 'package:vapourbox/models/frame_rate_parameters.dart'; +import 'package:vapourbox/models/ghost_removal_parameters.dart'; import 'package:vapourbox/models/noise_reduction_parameters.dart'; import 'package:vapourbox/models/processing_pipeline.dart'; import 'package:vapourbox/models/qtgmc_parameters.dart'; @@ -270,6 +274,10 @@ ProcessingPipeline _only({ ColorCorrectionParameters colorCorrection = const ColorCorrectionParameters(), ChromaFixParameters chromaFixes = const ChromaFixParameters(), CropResizeParameters cropResize = const CropResizeParameters(), + DeflickerParameters deflicker = const DeflickerParameters(), + EdgeRepairParameters edgeRepair = const EdgeRepairParameters(), + GhostRemovalParameters ghostRemoval = const GhostRemovalParameters(), + FrameRateParameters frameRate = const FrameRateParameters(), }) { return ProcessingPipeline( deinterlace: deinterlace, @@ -284,6 +292,10 @@ ProcessingPipeline _only({ colorCorrection: colorCorrection, chromaFixes: chromaFixes, cropResize: cropResize, + deflicker: deflicker, + edgeRepair: edgeRepair, + ghostRemoval: ghostRemoval, + frameRate: frameRate, ); } @@ -381,6 +393,85 @@ List<_Pass> _passes() => [ ), ), + // The two vendored denoisers. Both live in worker/templates as Python + // modules rather than coming from havsfunc, so they carry their own depth + // handling and nothing upstream is scaling their thresholds for them. + _Pass( + 'noise_mclean', + _only( + noiseReduction: const NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.mClean, + preset: NoiseReductionPreset.moderate, + ), + ), + ), + _Pass( + 'noise_temporaldegrain2', + _only( + noiseReduction: const NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.temporalDegrain2, + preset: NoiseReductionPreset.moderate, + ), + ), + ), + + // Deflicker measures frame brightness against a windowed average, so its + // correction is a ratio and should be depth-independent by construction. + _Pass( + 'deflicker', + _only( + deflicker: const DeflickerParameters( + enabled: true, + strength: 1.0, + window: 5, + ), + ), + ), + + // LGhost rejects mode 0 and intensity 0 — with either, the pass is inert + // and would pass the parity check trivially while failing changesPicture. + _Pass( + 'ghost_removal', + _only( + ghostRemoval: const GhostRemovalParameters( + enabled: true, + ghosts: [GhostSpec(mode: 2, shift: 6, intensity: 60)], + ), + ), + ), + + // Widths are always even (see EdgeRepairParameters.even) — FillBorders v2 + // leaves subsampled chroma unrepaired at odd ones. + _Pass( + 'edge_repair', + _only( + edgeRepair: const EdgeRepairParameters( + enabled: true, + left: 4, + right: 4, + top: 2, + bottom: 2, + ), + ), + ), + + // The fixture is 25 fps, so 23.976 actually retimes. The source rate has + // to be supplied — the pipeline can't see the job, and without it the + // FrameMap ratio is unknown and the pass does nothing. + _Pass( + 'frame_rate', + _only( + frameRate: const FrameRateParameters( + enabled: true, + target: FrameRateTarget.film23976, + sourceFpsNum: 25, + sourceFpsDen: 1, + ), + ), + ), + // CCD rejects a scale below 1.0 and is the newest plugin in the bundle // (zsmooth), so this doubles as a check that it is actually installed. _Pass( diff --git a/app/test/integration_new_passes_test.dart b/app/test/integration_new_passes_test.dart index 26b3b355..68fe98f7 100644 --- a/app/test/integration_new_passes_test.dart +++ b/app/test/integration_new_passes_test.dart @@ -18,14 +18,22 @@ 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/chroma_denoise_parameters.dart'; import 'package:vapourbox/models/dehalo_parameters.dart'; +import 'package:vapourbox/models/chroma_fix_parameters.dart'; +import 'package:vapourbox/models/color_correction_parameters.dart'; import 'package:vapourbox/models/descratch_parameters.dart'; +import 'package:vapourbox/models/deblock_parameters.dart'; +import 'package:vapourbox/models/grain_parameters.dart'; +import 'package:vapourbox/models/geometry_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/sharpen_parameters.dart'; import 'package:vapourbox/models/spotless_parameters.dart'; +import 'package:vapourbox/models/stabilize_parameters.dart'; import 'package:vapourbox/models/subtitle_parameters.dart'; import 'package:vapourbox/models/video_job.dart'; @@ -124,6 +132,530 @@ void main() { ? false : 'whisper add-on not present (set VAPOURBOX_ADDONS_DIR or install the add-on)'); + + // --- Filters added from the Hybrid gap analysis ----------------------- + // + // Script generation for these is covered cheaply in + // integration_filter_parameters_test; what only a real encode proves is that + // the plugin actually loads and processes the source. Each of these plugins + // was already in the deps bundle but had never been called by the product, + // so "it is in deps-expected-plugins.json" was not evidence that it works. + // + // Deinterlacing is off in these: the point is to exercise the filter, and a + // QTGMC pass would dominate the runtime without testing anything new. + + test('noise reduction: DFTTest runs end-to-end', () async { + final job = _baseJob( + 'nr_dfttest', + pipeline: const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + noiseReduction: NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.dfttest, + dfttestSigma: 8.0, + dfttestTbsize: 3, + ), + ), + ); + final result = await WorkerHarness.runJob(job.toJson(), label: 'nr_dfttest'); + await _expectValidVideo(result); + }, timeout: const Timeout(Duration(minutes: 6))); + + test('noise reduction: DFTTest spatial-only (tbsize 1) runs end-to-end', + () async { + // tbsize=1 takes a different path inside DFTTest — no temporal window at + // all — so it is worth exercising separately. + final job = _baseJob( + 'nr_dfttest_spatial', + pipeline: const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + noiseReduction: NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.dfttest, + dfttestTbsize: 1, + ), + ), + ); + final result = + await WorkerHarness.runJob(job.toJson(), label: 'nr_dfttest_spatial'); + await _expectValidVideo(result); + }, timeout: const Timeout(Duration(minutes: 6))); + + test('noise reduction: FFT3DFilter runs end-to-end', () async { + final job = _baseJob( + 'nr_fft3d', + pipeline: const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + noiseReduction: NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.fft3dFilter, + fft3dSigma: 2.0, + fft3dBt: 3, + fft3dSharpen: 0.3, + ), + ), + ); + final result = await WorkerHarness.runJob(job.toJson(), label: 'nr_fft3d'); + await _expectValidVideo(result); + }, timeout: const Timeout(Duration(minutes: 6))); + + test('noise reduction: TTempSmooth runs end-to-end', () async { + final job = _baseJob( + 'nr_ttempsmooth', + pipeline: const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + noiseReduction: NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.tTempSmooth, + ttempMaxr: 3, + ttempThresh: 4, + ttempMdiff: 2, + ), + ), + ); + final result = + await WorkerHarness.runJob(job.toJson(), label: 'nr_ttempsmooth'); + await _expectValidVideo(result); + }, timeout: const Timeout(Duration(minutes: 6))); + + test('sharpen: aWarpSharp2 runs end-to-end', () async { + final job = _baseJob( + 'sharpen_awarpsharp2', + pipeline: const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + sharpen: SharpenParameters( + enabled: true, + method: SharpenMethod.aWarpSharp2, + warpDepth: 16, + ), + ), + ); + final result = + await WorkerHarness.runJob(job.toJson(), label: 'sharpen_awarpsharp2'); + await _expectValidVideo(result); + }, timeout: const Timeout(Duration(minutes: 6))); + + test('dehalo: HQDeringmod runs end-to-end', () async { + final job = _baseJob( + 'dehalo_hqderingmod', + pipeline: const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + dehalo: DehaloParameters( + enabled: true, + method: DehaloMethod.hqDeringmod, + deringMrad: 1, + deringThr: 12.0, + ), + ), + ); + final result = + await WorkerHarness.runJob(job.toJson(), label: 'dehalo_hqderingmod'); + await _expectValidVideo(result); + }, timeout: const Timeout(Duration(minutes: 6))); + + test('the added filters render in the preview path too', () async { + // Preview and encode are separate scripts AND separate ffmpeg + // invocations, so a filter can encode fine and still fail the preview. + for (final entry in { + 'dfttest': const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + noiseReduction: NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.dfttest, + ), + ), + 'fft3d': const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + noiseReduction: NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.fft3dFilter, + ), + ), + 'ttempsmooth': const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + noiseReduction: NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.tTempSmooth, + ), + ), + 'awarpsharp2': const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + sharpen: SharpenParameters( + enabled: true, + method: SharpenMethod.aWarpSharp2, + ), + ), + 'hqderingmod': const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + dehalo: DehaloParameters( + enabled: true, + method: DehaloMethod.hqDeringmod, + ), + ), + }.entries) { + final job = _baseJob('preview_${entry.key}', pipeline: entry.value); + final preview = await WorkerHarness.runPreview( + job.toJson(), + frame: 10, + label: 'preview_${entry.key}', + ); + expect(preview.success, isTrue, + reason: '${entry.key} preview failed: ${preview.errorTail}'); + print(' ${entry.key}: preview frame rendered ' + '(${preview.png!.length} bytes)'); + } + }, timeout: const Timeout(Duration(minutes: 8))); + + + // --- Second batch: whole categories the app had nothing in -------------- + + test('anti-alias: daa runs end-to-end', () async { + final job = _baseJob( + 'aa_daa', + pipeline: const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + antiAlias: AntiAliasParameters( + enabled: true, + method: AntiAliasMethod.daa, + ), + ), + ); + final result = await WorkerHarness.runJob(job.toJson(), label: 'aa_daa'); + await _expectValidVideo(result); + }, timeout: const Timeout(Duration(minutes: 6))); + + test('anti-alias: santiag runs end-to-end', () async { + final job = _baseJob( + 'aa_santiag', + pipeline: const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + antiAlias: AntiAliasParameters( + enabled: true, + method: AntiAliasMethod.santiag, + santiagStrh: 1, + santiagStrv: 1, + ), + ), + ); + final result = + await WorkerHarness.runJob(job.toJson(), label: 'aa_santiag'); + await _expectValidVideo(result); + }, timeout: const Timeout(Duration(minutes: 8))); + + test('stabilize: Stab runs end-to-end', () async { + final job = _baseJob( + 'stabilize', + pipeline: const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + stabilize: StabilizeParameters(enabled: true, mirror: 3), + ), + ); + final result = + await WorkerHarness.runJob(job.toJson(), label: 'stabilize'); + await _expectValidVideo(result); + }, timeout: const Timeout(Duration(minutes: 8))); + + test('chroma_fixes: LUTDeRainbow runs end-to-end', () async { + final job = _baseJob( + 'derainbow', + pipeline: const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + chromaFixes: ChromaFixParameters( + enabled: true, + applyDeRainbow: true, + ), + ), + ); + final result = + await WorkerHarness.runJob(job.toJson(), label: 'derainbow'); + await _expectValidVideo(result); + }, timeout: const Timeout(Duration(minutes: 6))); + + test('the second batch renders in the preview path too', () async { + for (final entry in { + 'aa_daa': const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + antiAlias: AntiAliasParameters(enabled: true), + ), + 'aa_santiag': const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + antiAlias: AntiAliasParameters( + enabled: true, + method: AntiAliasMethod.santiag, + ), + ), + 'stabilize': const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + stabilize: StabilizeParameters(enabled: true), + ), + 'derainbow': const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + chromaFixes: ChromaFixParameters( + enabled: true, + applyDeRainbow: true, + ), + ), + }.entries) { + final job = _baseJob('preview2_${entry.key}', pipeline: entry.value); + final preview = await WorkerHarness.runPreview( + job.toJson(), + frame: 10, + label: 'preview2_${entry.key}', + ); + expect(preview.success, isTrue, + reason: '${entry.key} preview failed: ${preview.errorTail}'); + print(' ${entry.key}: preview frame rendered ' + '(${preview.png!.length} bytes)'); + } + }, timeout: const Timeout(Duration(minutes: 10))); + + + // --- Third batch: proves the new fluxsmooth plugin actually loads -------- + // + // These are the first tests in the suite that depend on a plugin added by a + // DEPS change rather than one already in the bundle, so a failure here means + // the bundle, not the wiring. + + test('noise reduction: FluxSmoothT runs end-to-end', () async { + final job = _baseJob( + 'nr_flux_t', + pipeline: const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + noiseReduction: NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.fluxSmoothT, + ), + ), + ); + final result = await WorkerHarness.runJob(job.toJson(), label: 'nr_flux_t'); + await _expectValidVideo(result); + }, timeout: const Timeout(Duration(minutes: 6))); + + test('noise reduction: FluxSmoothST runs end-to-end', () async { + final job = _baseJob( + 'nr_flux_st', + pipeline: const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + noiseReduction: NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.fluxSmoothSt, + ), + ), + ); + final result = + await WorkerHarness.runJob(job.toJson(), label: 'nr_flux_st'); + await _expectValidVideo(result); + }, timeout: const Timeout(Duration(minutes: 6))); + + test('noise reduction: STPresso runs end-to-end', () async { + // STPresso calls core.flux.SmoothT internally, so this fails with + // "No attribute with the name flux exists" on a bundle without the + // plugin — which is exactly why it was dropped from the previous batch. + final job = _baseJob( + 'nr_stpresso', + pipeline: const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + noiseReduction: NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.stPresso, + ), + ), + ); + final result = + await WorkerHarness.runJob(job.toJson(), label: 'nr_stpresso'); + await _expectValidVideo(result); + }, timeout: const Timeout(Duration(minutes: 6))); + + + // --- Batch four -------------------------------------------------------- + + test('noise reduction: CTMF runs end-to-end', () async { + final job = _baseJob( + 'nr_ctmf', + pipeline: const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + noiseReduction: NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.ctmf, + ctmfRadius: 3, + ), + ), + ); + final result = await WorkerHarness.runJob(job.toJson(), label: 'nr_ctmf'); + await _expectValidVideo(result); + }, timeout: const Timeout(Duration(minutes: 6))); + + test('deblock: DCTFilter runs end-to-end', () async { + final job = _baseJob( + 'deblock_dctfilter', + pipeline: const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + deblock: DeblockParameters( + enabled: true, + method: DeblockMethod.dctFilter, + ), + ), + ); + final result = + await WorkerHarness.runJob(job.toJson(), label: 'deblock_dctfilter'); + await _expectValidVideo(result); + }, timeout: const Timeout(Duration(minutes: 6))); + + test('grain: AddGrain and GrainFactory3 both run end-to-end', () async { + for (final entry in { + 'grain_add': const GrainParameters(enabled: true, var_: 6.0, uvar: 2.0), + 'grain_gf3': const GrainParameters( + enabled: true, + method: GrainMethod.grainFactory3, + ), + }.entries) { + final job = _baseJob( + entry.key, + pipeline: ProcessingPipeline( + deinterlace: const QTGMCParameters(enabled: false), + grain: entry.value, + ), + ); + final result = + await WorkerHarness.runJob(job.toJson(), label: entry.key); + await _expectValidVideo(result); + } + }, timeout: const Timeout(Duration(minutes: 10))); + + test('geometry: every rotation and flip runs end-to-end', () async { + // The quarter turns are the ones that matter: they change the pixel + // format (4:2:2 becomes 4:4:0), which ffmpeg rejects outright unless the + // script converts back. Script generation cannot catch that. + for (final entry in { + 'geo_cw90': const GeometryParameters( + enabled: true, + rotation: Rotation.cw90, + ), + 'geo_180': const GeometryParameters( + enabled: true, + rotation: Rotation.rotate180, + ), + 'geo_ccw90': const GeometryParameters( + enabled: true, + rotation: Rotation.ccw90, + ), + 'geo_flips': const GeometryParameters( + enabled: true, + flipHorizontal: true, + flipVertical: true, + ), + }.entries) { + final job = _baseJob( + entry.key, + pipeline: ProcessingPipeline( + deinterlace: const QTGMCParameters(enabled: false), + geometry: entry.value, + ), + ); + final result = + await WorkerHarness.runJob(job.toJson(), label: entry.key); + await _expectValidVideo(result); + } + }, timeout: const Timeout(Duration(minutes: 12))); + + test('batch four renders in the preview path too', () async { + for (final entry in { + 'ctmf': const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + noiseReduction: NoiseReductionParameters( + enabled: true, + method: NoiseReductionMethod.ctmf, + ), + ), + 'dctfilter': const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + deblock: DeblockParameters( + enabled: true, + method: DeblockMethod.dctFilter, + ), + ), + 'grain': const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + grain: GrainParameters(enabled: true), + ), + 'rotate': const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + geometry: GeometryParameters( + enabled: true, + rotation: Rotation.cw90, + ), + ), + }.entries) { + final job = _baseJob('preview4_${entry.key}', pipeline: entry.value); + final preview = await WorkerHarness.runPreview( + job.toJson(), + frame: 10, + label: 'preview4_${entry.key}', + ); + expect(preview.success, isTrue, + reason: '${entry.key} preview failed: ${preview.errorTail}'); + print(' ${entry.key}: preview frame rendered ' + '(${preview.png!.length} bytes)'); + } + }, timeout: const Timeout(Duration(minutes: 10))); + + + test('color: SmoothLevels runs end-to-end', () async { + // The default useDB=True raises "no attribute named f3kdb" on every + // format against this bundle, so this proves the pin actually holds. + final job = _baseJob( + 'color_smooth_levels', + pipeline: const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + colorCorrection: ColorCorrectionParameters( + enabled: true, + applyLevels: true, + smoothLevels: true, + inputLow: 16, + inputHigh: 235, + ), + ), + ); + final result = + await WorkerHarness.runJob(job.toJson(), label: 'color_smooth_levels'); + await _expectValidVideo(result); + }, timeout: const Timeout(Duration(minutes: 6))); + + + // --- Batch five: proves bifrost and retinex actually load --------------- + + test('chroma_fixes: Bifrost runs end-to-end', () async { + final job = _baseJob( + 'chroma_bifrost', + pipeline: const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + chromaFixes: ChromaFixParameters( + enabled: true, + applyBifrost: true, + ), + ), + ); + final result = + await WorkerHarness.runJob(job.toJson(), label: 'chroma_bifrost'); + await _expectValidVideo(result); + }, timeout: const Timeout(Duration(minutes: 6))); + + test('color: shadow detail runs end-to-end', () async { + final job = _baseJob( + 'color_shadow_detail', + pipeline: const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + colorCorrection: ColorCorrectionParameters( + enabled: true, + applyShadowDetail: true, + ), + ), + ); + final result = await WorkerHarness.runJob(job.toJson(), + label: 'color_shadow_detail'); + await _expectValidVideo(result); + }, timeout: const Timeout(Duration(minutes: 6))); + // Issue #37: QTGMC with EZ Denoise + the knlmeanscl denoiser must never // crash the job. KNLMeansCL is OpenCL-only; on a headless CI runner (no // usable OpenCL device) the worker's knlm probe fails and the denoiser is diff --git a/app/test/packaging_test.dart b/app/test/packaging_test.dart new file mode 100644 index 00000000..7cc1d614 --- /dev/null +++ b/app/test/packaging_test.dart @@ -0,0 +1,75 @@ +// Packaging lint. +// +// Every packaging script used to copy VapourSynth templates by explicit +// filename. That works right up until someone adds a module: the filter runs +// perfectly in development, because the debug worker searches upward and finds +// `worker/templates/`, and then dies in a release build with a bare +// ModuleNotFoundError from inside vspipe. +// +// Six vendored modules were added in one sitting and all six would have been +// missing from every packaged build. This asserts the scripts glob instead. + +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +String _repoRoot() { + var dir = Directory.current; + while (true) { + if (Directory(p.join(dir.path, 'worker')).existsSync() && + Directory(p.join(dir.path, 'app')).existsSync()) { + return dir.path; + } + final parent = dir.parent; + if (parent.path == dir.path) { + throw StateError('could not locate the repo root'); + } + dir = parent; + } +} + +void main() { + final root = _repoRoot(); + + final modules = Directory(p.join(root, 'worker', 'templates')) + .listSync() + .whereType() + .map((f) => p.basename(f.path)) + .where((n) => n.endsWith('.py')) + .toList() + ..sort(); + + test('there are vendored modules to package', () { + // Guards against the assertions below passing vacuously. + expect(modules.length, greaterThan(2), reason: 'found: $modules'); + }); + + for (final script in [ + 'Scripts/package-macos.sh', + 'Scripts/package-linux.sh', + 'Scripts/package-windows.ps1', + ]) { + test('$script copies every template module, not a hand-written list', () { + final body = File(p.join(root, script)).readAsStringSync(); + + // A glob covers whatever exists now and whatever is added later. + final globs = RegExp(r'templates[\\/]?["\\]*\*\.py').hasMatch(body) || + body.contains('templates/"*.py') || + body.contains(r'templates\*.py'); + if (globs) return; + + // Otherwise every module must be named individually — and if that is the + // approach, adding one silently breaks the release build. + final missing = + modules.where((m) => !body.contains(m)).toList(growable: false); + expect( + missing, + isEmpty, + reason: 'these modules would be absent from the package, so the ' + 'filters that import them fail only in a release build: $missing. ' + 'Copy templates with a *.py glob instead of naming each file.', + ); + }); + } +} diff --git a/app/test/parameter_file_picker_test.dart b/app/test/parameter_file_picker_test.dart new file mode 100644 index 00000000..992abe8b --- /dev/null +++ b/app/test/parameter_file_picker_test.dart @@ -0,0 +1,148 @@ +// Widget tests for the schema-driven file picker. +// +// `WidgetType.filepicker` is the schema system's only filesystem concept, and +// two of its properties are invisible to a unit test: +// +// * the field has to repaint when the value changes underneath it. The plain +// textfield widget beside it uses `TextFormField(initialValue:)`, which is +// read once — a path written back by the picker would never appear. This one +// is stateful with a controller precisely to avoid that, so the behaviour is +// pinned here. +// * the shipped Subtitles schema has to actually reach it. Nothing else in +// `assets/filters/core/` uses the widget, so a typo in `subtitles.json` +// would silently fall back to a bare text field. +// +// Run with: flutter test test/parameter_file_picker_test.dart + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vapourbox/models/filter_schema.dart'; +import 'package:vapourbox/views/settings/widgets/parameter_widgets.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future pump( + WidgetTester tester, { + required ParameterDefinition param, + required dynamic value, + ValueChanged? onChanged, + }) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ParameterWidgetFactory.build( + paramId: 'burnInPath', + param: param, + value: value, + onChanged: onChanged ?? (_) {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + const picker = ParameterDefinition( + type: ParameterType.string, + defaultValue: '', + ui: ParameterUiConfig( + label: 'Subtitle file to burn in', + widget: WidgetType.filepicker, + fileExtensions: ['srt', 'ass', 'ssa'], + ), + ); + + group('filepicker widget', () { + testWidgets('renders a browse button beside an editable field', + (tester) async { + await pump(tester, param: picker, value: '/tmp/example.srt'); + + expect(find.text('Subtitle file to burn in'), findsOneWidget); + expect(find.widgetWithText(OutlinedButton, 'Browse…'), findsOneWidget); + + // The path is shown, and the field is not read-only — typing or pasting + // a path has to keep working; the button is an addition. + final field = tester.widget(find.byType(TextField)); + expect(field.controller?.text, '/tmp/example.srt'); + expect(field.readOnly, isFalse); + }); + + testWidgets('the field follows a value changed from outside', + (tester) async { + await pump(tester, param: picker, value: '/tmp/first.srt'); + expect( + tester.widget(find.byType(TextField)).controller?.text, + '/tmp/first.srt', + ); + + // This is what a stateless `initialValue:` field would fail: rebuilt with + // a new value, it would keep displaying the old one. + await pump(tester, param: picker, value: '/tmp/second.srt'); + expect( + tester.widget(find.byType(TextField)).controller?.text, + '/tmp/second.srt', + ); + }); + + testWidgets('typing a path reports it', (tester) async { + String? reported; + await pump( + tester, + param: picker, + value: '', + onChanged: (v) => reported = v as String, + ); + + await tester.enterText(find.byType(TextField), '/tmp/typed.ass'); + expect(reported, '/tmp/typed.ass'); + }); + + testWidgets('a plain string parameter still gets a bare text field', + (tester) async { + // The picker must be opted into explicitly — `_inferWidgetType` maps + // string to textfield, and schemas like crop_resize's customSar rely on + // that. + await pump( + tester, + param: const ParameterDefinition( + type: ParameterType.string, + defaultValue: '', + ), + value: '16:9', + ); + expect(find.widgetWithText(OutlinedButton, 'Browse…'), findsNothing); + expect(find.byType(TextFormField), findsOneWidget); + }); + }); + + group('the shipped subtitles schema', () { + late FilterSchema schema; + + setUpAll(() { + schema = FilterSchema.fromJson( + jsonDecode(File('assets/filters/core/subtitles.json').readAsStringSync()) + as Map, + ); + }); + + test('burnInPath asks for the picker, limited to subtitle files', () { + final param = schema.parameters['burnInPath']; + expect(param, isNotNull, reason: 'burnInPath should exist'); + expect(param!.ui?.widget, WidgetType.filepicker); + expect(param.ui?.fileExtensions, containsAll(['srt', 'ass'])); + }); + + test('its description no longer claims transcription runs after the encode', + () { + // The worker transcribes before the encode and feeds the result into + // burn-in (worker/src/main.rs), so the old wording was actively wrong. + final description = schema.parameters['burnInPath']!.ui!.description!; + expect(description, isNot(contains('cannot be burnt in'))); + expect(description.toLowerCase(), contains('skips transcription')); + }); + }); +} diff --git a/app/test/pass_advice_test.dart b/app/test/pass_advice_test.dart new file mode 100644 index 00000000..bd8d6a38 --- /dev/null +++ b/app/test/pass_advice_test.dart @@ -0,0 +1,290 @@ +// Tests for the advice shown about pass *combinations*. +// +// The behaviours worth pinning are the negative ones: advice about a pass that +// is switched off is noise, and an empty pipeline must be silent. A default +// pipeline that produced advice would put a warning banner in front of a user +// who has done nothing yet, which is how advisory UI gets learned-to-ignore. +// +// Run with: flutter test test/pass_advice_test.dart + +import 'package:flutter_test/flutter_test.dart'; +import 'package:vapourbox/models/chroma_denoise_parameters.dart'; +import 'package:vapourbox/models/deband_parameters.dart'; +import 'package:vapourbox/models/geometry_parameters.dart'; +import 'package:vapourbox/models/dehalo_parameters.dart'; +import 'package:vapourbox/models/chroma_fix_parameters.dart'; +import 'package:vapourbox/models/noise_reduction_parameters.dart'; +import 'package:vapourbox/models/pass_advice.dart'; +import 'package:vapourbox/models/processing_pipeline.dart'; +import 'package:vapourbox/models/qtgmc_parameters.dart'; +import 'package:vapourbox/models/sharpen_parameters.dart'; + +void main() { + group('silence by default', () { + test('an untouched pipeline says nothing', () { + expect(adviseOn(const ProcessingPipeline()), isEmpty); + }); + + test('a single enabled pass says nothing', () { + expect( + adviseOn(ProcessingPipeline( + noiseReduction: + NoiseReductionParameters.fromPreset(NoiseReductionPreset.moderate), + )), + isEmpty, + ); + }); + + test('luma and chroma denoise together is a good combination, not a ' + 'conflict', () { + // Explicitly asserted so nobody "helpfully" warns about it later: these + // two split luma and chroma and are meant to be used together. + final advice = adviseOn(ProcessingPipeline( + deinterlace: const QTGMCParameters(enabled: false), + noiseReduction: + NoiseReductionParameters.fromPreset(NoiseReductionPreset.moderate), + chromaDenoise: const ChromaDenoiseParameters(enabled: true), + )); + expect(advice, isEmpty); + }); + }); + + group('sharpening against the denoiser', () { + test('warns on the Sharpen pass, naming the consequence', () { + final advice = adviceFor( + PassType.sharpen, + ProcessingPipeline( + deinterlace: const QTGMCParameters(enabled: false), + noiseReduction: + NoiseReductionParameters.fromPreset(NoiseReductionPreset.heavy), + sharpen: const SharpenParameters(enabled: true), + ), + ); + expect(advice, isNotNull); + expect(advice, contains('denoiser')); + }); + + test('silent when only one of the two is on', () { + expect( + adviceFor( + PassType.sharpen, + ProcessingPipeline( + deinterlace: const QTGMCParameters(enabled: false), + sharpen: const SharpenParameters(enabled: true), + ), + ), + isNull, + ); + }); + }); + + group('dehalo against sharpening', () { + test('warns on Sharpen, since Dehalo runs first', () { + final advice = adviceFor( + PassType.sharpen, + ProcessingPipeline( + deinterlace: const QTGMCParameters(enabled: false), + dehalo: const DehaloParameters(enabled: true), + sharpen: const SharpenParameters(enabled: true), + ), + ); + expect(advice, isNotNull); + expect(advice, contains('Dehalo')); + }); + }); + + group('deband grain against sharpening', () { + test('warns on the Deband pass', () { + final advice = adviceFor( + PassType.deband, + ProcessingPipeline( + deinterlace: const QTGMCParameters(enabled: false), + deband: const DebandParameters(enabled: true), + sharpen: const SharpenParameters(enabled: true), + ), + ); + expect(advice, isNotNull); + expect(advice, contains('grain')); + }); + }); + + group('IVTC and the FPS divisor', () { + test('warns that the divisor is ignored', () { + final advice = adviceFor( + PassType.deinterlace, + const ProcessingPipeline( + deinterlace: QTGMCParameters( + enabled: true, + method: DeinterlaceMethod.ivtc, + fpsDivisor: 2, + ), + ), + ); + expect(advice, isNotNull); + expect(advice, contains('ignored')); + }); + + test('silent for QTGMC deinterlacing, where the divisor does apply', () { + expect( + adviceFor( + PassType.deinterlace, + const ProcessingPipeline( + deinterlace: QTGMCParameters( + enabled: true, + method: DeinterlaceMethod.qtgmc, + fpsDivisor: 2, + ), + ), + ), + isNull, + ); + }); + + test('silent for IVTC with no divisor set', () { + expect( + adviceFor( + PassType.deinterlace, + const ProcessingPipeline( + deinterlace: QTGMCParameters( + enabled: true, + method: DeinterlaceMethod.ivtc, + ), + ), + ), + isNull, + ); + }); + }); + + group('deinterlace clean-up with no deinterlacing', () { + test('warns when Vinverse is on but deinterlacing is off', () { + final advice = adviceFor( + PassType.chromaFixes, + const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + chromaFixes: ChromaFixParameters( + enabled: true, + applyVinverse: true, + ), + ), + ); + expect(advice, isNotNull); + expect(advice, contains('deinterlacing')); + }); + + test('silent when deinterlacing is on', () { + expect( + adviceFor( + PassType.chromaFixes, + const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: true), + chromaFixes: ChromaFixParameters( + enabled: true, + applyVinverse: true, + ), + ), + ), + isNull, + ); + }); + + test('silent for chroma shift, which has nothing to do with fields', () { + expect( + adviceFor( + PassType.chromaFixes, + const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + chromaFixes: ChromaFixParameters( + enabled: true, + applyChromaShift: true, + ), + ), + ), + isNull, + ); + }); + }); + + group('rotating interlaced material', () { + test('warns when a quarter turn runs with deinterlacing off', () { + final advice = adviceFor( + PassType.geometry, + const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + geometry: GeometryParameters( + enabled: true, + rotation: Rotation.cw90, + ), + ), + ); + expect(advice, isNotNull); + expect(advice, contains('destroys')); + }); + + test('silent when deinterlacing is on, since it runs first', () { + expect( + adviceFor( + PassType.geometry, + const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: true), + geometry: GeometryParameters( + enabled: true, + rotation: Rotation.cw90, + ), + ), + ), + isNull, + ); + }); + + test('silent for a half turn and for flips, which keep fields in rows', () { + for (final geometry in [ + const GeometryParameters(enabled: true, rotation: Rotation.rotate180), + const GeometryParameters(enabled: true, flipHorizontal: true), + ]) { + expect( + adviceFor( + PassType.geometry, + ProcessingPipeline( + deinterlace: const QTGMCParameters(enabled: false), + geometry: geometry, + ), + ), + isNull, + ); + } + }); + }); + + group('advice is always attached to an enabled pass', () { + test('no advice names a pass that is switched off', () { + // Otherwise a user sees a banner on a pass they are not using, which + // teaches them to ignore the banners. + final pipelines = [ + const ProcessingPipeline(), + ProcessingPipeline( + deinterlace: const QTGMCParameters(enabled: false), + sharpen: const SharpenParameters(enabled: true), + noiseReduction: + NoiseReductionParameters.fromPreset(NoiseReductionPreset.heavy), + dehalo: const DehaloParameters(enabled: true), + deband: const DebandParameters(enabled: true), + ), + const ProcessingPipeline( + deinterlace: QTGMCParameters( + enabled: true, + method: DeinterlaceMethod.ivtc, + fpsDivisor: 2, + ), + ), + ]; + + for (final pipeline in pipelines) { + for (final advice in adviseOn(pipeline)) { + expect(pipeline.isPassEnabled(advice.pass), true, + reason: 'advice attached to disabled pass ${advice.pass}'); + } + } + }); + }); +} diff --git a/app/test/pass_list_stages_test.dart b/app/test/pass_list_stages_test.dart new file mode 100644 index 00000000..247c35c9 --- /dev/null +++ b/app/test/pass_list_stages_test.dart @@ -0,0 +1,148 @@ +// The pass list is grouped into stage headers, and the grouping is a pure +// relabelling of an order that must not change: the rows appear in the order the +// passes actually run. +// +// Two silent failures live here. A PassType missing from `stages` simply does +// not render — no error, the pass is just unreachable and its settings +// uneditable. And reordering the rows would tell the user the pipeline runs in +// an order it does not, which is worse than useless when the whole point of the +// panel is to show the pipeline. +// +// Run with: flutter test test/pass_list_stages_test.dart + +import 'package:flutter_test/flutter_test.dart'; +import 'package:vapourbox/models/processing_pipeline.dart'; +import 'package:vapourbox/views/pass_list/pass_list_panel.dart'; + +void main() { + final flattened = [ + for (final stage in PassListPanel.stages) ...stage.passes, + ]; + + test('every pass appears in a stage', () { + expect(flattened.toSet(), PassType.values.toSet(), + reason: 'a PassType missing from PassListPanel.stages does not render ' + 'at all — add it to a stage when adding the pass'); + }); + + test('no pass appears twice', () { + expect(flattened.length, flattened.toSet().length); + expect(flattened.length, PassType.values.length); + }); + + test('rows stay in pipeline order', () { + // Deliberately a literal list, not derived from PassType.values — the enum + // declares colorCorrection before chromaFixes while the pipeline applies + // them the other way round, so the enum is not the authority here. If this + // fails, confirm against script_generator.rs before changing it. + expect(flattened, [ + PassType.deinterlace, + PassType.edgeRepair, + PassType.ghostRemoval, + PassType.deflicker, + PassType.descratch, + PassType.spotless, + PassType.noiseReduction, + PassType.chromaDenoise, + PassType.dehalo, + PassType.deblock, + PassType.deband, + PassType.antiAlias, + PassType.sharpen, + PassType.chromaFixes, + PassType.colorCorrection, + PassType.stabilize, + PassType.geometry, + PassType.cropResize, + PassType.grain, + PassType.frameRate, + PassType.subtitles, + ]); + }); + + group('the placements that carry meaning', () { + int indexOf(PassType p) => flattened.indexOf(p); + + test('anti-aliasing runs before sharpening', () { + // Sharpening a stair-stepped edge makes the stepping more visible, not + // less, so the order is not arbitrary. Mirrored in + // ProcessingPipeline.enabledPasses and asserted end-to-end in the Rust + // test_110. + expect(indexOf(PassType.antiAlias), + lessThan(indexOf(PassType.sharpen))); + }); + + test('rotation settles before any framing decision', () { + // A quarter turn swaps width and height, so crop, resize and the aspect + // declaration must all see the final shape. + expect(indexOf(PassType.geometry), + lessThan(indexOf(PassType.cropResize))); + // And it must follow deinterlacing: fields run horizontally, so turning + // a still-interlaced clip shears them. + expect(indexOf(PassType.geometry), + greaterThan(indexOf(PassType.deinterlace))); + }); + + test('stabilisation runs last before framing', () { + // It shifts the picture within the frame and exposes thin empty edges, so + // a crop afterwards can remove them. + expect(indexOf(PassType.stabilize), + lessThan(indexOf(PassType.cropResize))); + expect(indexOf(PassType.stabilize), + greaterThan(indexOf(PassType.colorCorrection))); + }); + }); + + 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. + expect(flattened.indexOf(PassType.grain), + greaterThan(flattened.indexOf(PassType.cropResize))); + expect(flattened.indexOf(PassType.grain), + greaterThan(flattened.indexOf(PassType.deband))); + + // Frame rate conversion is last of the video passes. It resamples the + // timeline, so any temporal pass after it would be working on invented + // frames rather than photographed ones. + // Edge repair must precede every spatial filter, or denoising and + // sharpening smear the bad rows inward, and must precede the resize, or + // resampling spreads them across the picture. + for (final later in [ + PassType.noiseReduction, + PassType.sharpen, + PassType.cropResize, + ]) { + expect(flattened.indexOf(PassType.edgeRepair), + lessThan(flattened.indexOf(later)), + reason: 'edge repair must precede $later'); + } + + // Deflicker must follow deinterlacing — field-doubled frames break every + // temporal comparison it makes — and precede the passes that assume a + // stable exposure. + expect(flattened.indexOf(PassType.deflicker), + greaterThan(flattened.indexOf(PassType.deinterlace))); + expect(flattened.indexOf(PassType.deflicker), + lessThan(flattened.indexOf(PassType.noiseReduction))); + + for (final earlier in [ + PassType.deinterlace, + PassType.noiseReduction, + PassType.stabilize, + PassType.cropResize, + PassType.grain, + ]) { + expect(flattened.indexOf(PassType.frameRate), + greaterThan(flattened.indexOf(earlier)), + reason: 'frame rate must follow $earlier'); + } + }); + + test('every stage has a title and at least one pass', () { + for (final stage in PassListPanel.stages) { + expect(stage.title.trim(), isNotEmpty); + expect(stage.passes, isNotEmpty, + reason: 'an empty stage renders a header over nothing'); + } + }); +} diff --git a/app/test/pass_relevance_test.dart b/app/test/pass_relevance_test.dart new file mode 100644 index 00000000..74ee32fa --- /dev/null +++ b/app/test/pass_relevance_test.dart @@ -0,0 +1,234 @@ +// Tests for which passes the app suggests for a given source. +// +// The property that matters most here is restraint: badging most of the list +// would make the badge meaningless, so these tests assert the *number* of +// suggestions as well as which ones, and that every pass detection can't speak +// to stays quiet. +// +// Run with: flutter test test/pass_relevance_test.dart + +import 'package:flutter_test/flutter_test.dart'; +import 'package:vapourbox/models/pass_relevance.dart'; +import 'package:vapourbox/models/processing_pipeline.dart'; +import 'package:vapourbox/services/field_order_detector.dart'; + +VideoInfo source({ + int width = 720, + int height = 576, + double frameRate = 25.0, + String codec = 'mpeg2video', + String pixelFormat = 'yuv420p', + ScanType scanType = ScanType.interlaced, + String? sar, +}) => + VideoInfo( + width: width, + height: height, + frameRate: frameRate, + duration: 60, + frameCount: 1500, + codec: codec, + pixelFormat: pixelFormat, + scanType: scanType, + hasAudio: true, + sar: sar, + ); + +Set suggestedFor(VideoInfo? info) => PassType.values + .where((p) => relevanceFor(p, info).isRecommended) + .toSet(); + +Set notApplicableFor(VideoInfo? info) => PassType.values + .where((p) => relevanceFor(p, info).isNotApplicable) + .toSet(); + +void main() { + group('nothing loaded', () { + test('says nothing at all', () { + for (final pass in PassType.values) { + expect(relevanceFor(pass, null).level, PassRelevance.neutral); + expect(relevanceFor(pass, null).reason, isNull); + } + }); + }); + + group('deinterlace', () { + test('suggested for every fielded scan type, naming it', () { + for (final scan in [ + ScanType.interlaced, + ScanType.telecine, + ScanType.softTelecine, + ]) { + final result = + relevanceFor(PassType.deinterlace, source(scanType: scan)); + expect(result.isRecommended, true, reason: '$scan should suggest it'); + expect(result.reason, contains(scan.displayName.toLowerCase())); + } + }); + + test('not applicable for progressive', () { + final result = relevanceFor( + PassType.deinterlace, source(scanType: ScanType.progressive)); + expect(result.isNotApplicable, true); + expect(result.reason, 'source is progressive'); + }); + + test('silent when detection failed', () { + // ScanType.unknown means ffprobe/idet could not decide. Claiming either + // way would be worse than saying nothing. + expect( + relevanceFor(PassType.deinterlace, source(scanType: ScanType.unknown)) + .level, + PassRelevance.neutral, + ); + }); + }); + + group('chroma fixes', () { + test('suggested for an SD fielded source', () { + expect( + relevanceFor(PassType.chromaFixes, + source(height: 576, scanType: ScanType.interlaced)) + .isRecommended, + true, + ); + }); + + test('not applicable for progressive HD', () { + expect( + relevanceFor(PassType.chromaFixes, + source(height: 1080, scanType: ScanType.progressive)) + .isNotApplicable, + true, + ); + }); + + test('silent for SD progressive — could still be a tape transfer', () { + expect( + relevanceFor(PassType.chromaFixes, + source(height: 480, scanType: ScanType.progressive)) + .level, + PassRelevance.neutral, + ); + }); + + test('silent for interlaced HD — not composite', () { + expect( + relevanceFor(PassType.chromaFixes, + source(height: 1080, scanType: ScanType.interlaced)) + .level, + PassRelevance.neutral, + ); + }); + }); + + group('deblock', () { + test('suggested for MPEG-2', () { + expect( + relevanceFor(PassType.deblock, source(codec: 'mpeg2video')) + .isRecommended, + true, + ); + }); + + test('silent for other codecs, since bitrate is invisible', () { + for (final codec in ['h264', 'prores', 'dvvideo', 'hevc']) { + expect( + relevanceFor(PassType.deblock, source(codec: codec)).level, + PassRelevance.neutral, + reason: codec, + ); + } + }); + }); + + group('crop / resize', () { + test('suggested for an anamorphic source, quoting the SAR', () { + final result = + relevanceFor(PassType.cropResize, source(sar: '10:11')); + expect(result.isRecommended, true); + expect(result.reason, contains('10:11')); + }); + + test('silent for square pixels', () { + expect(relevanceFor(PassType.cropResize, source(sar: null)).level, + PassRelevance.neutral); + expect(relevanceFor(PassType.cropResize, source(sar: '')).level, + PassRelevance.neutral); + }); + }); + + group('restraint', () { + // The badge is only worth having if it is rare. These bounds are the point + // of the feature, not incidental. + test('a PAL DVD capture suggests at most four passes', () { + final suggested = suggestedFor(source( + height: 576, + codec: 'mpeg2video', + scanType: ScanType.interlaced, + sar: '16:15', + )); + expect(suggested.length, lessThanOrEqualTo(4)); + expect( + suggested, + { + PassType.deinterlace, + PassType.chromaFixes, + PassType.deblock, + PassType.cropResize, + }, + ); + }); + + test('a modern progressive camera file suggests nothing', () { + final info = source( + width: 1920, + height: 1080, + codec: 'h264', + frameRate: 25, + scanType: ScanType.progressive, + ); + expect(suggestedFor(info), isEmpty); + // And it actively rules two out. + expect(notApplicableFor(info), + {PassType.deinterlace, PassType.chromaFixes}); + }); + + test('passes detection cannot judge are always neutral', () { + // Dirt, scratches, grain, halos, banding and colour casts need eyes on + // the picture. If one of these ever starts making claims, the badge has + // stopped meaning anything. + const invisible = [ + PassType.descratch, + PassType.spotless, + PassType.noiseReduction, + PassType.chromaDenoise, + PassType.dehalo, + PassType.deband, + PassType.sharpen, + PassType.colorCorrection, + PassType.subtitles, + ]; + + for (final scan in ScanType.values) { + for (final height in [480, 576, 720, 1080, 2160]) { + for (final codec in ['mpeg2video', 'h264', 'prores']) { + final info = + source(height: height, codec: codec, scanType: scan, sar: '4:3'); + for (final pass in invisible) { + expect(relevanceFor(pass, info).level, PassRelevance.neutral, + reason: '$pass claimed something for $scan/$height/$codec'); + } + } + } + } + }); + + test('no pass is both suggested and not applicable', () { + for (final scan in ScanType.values) { + final info = source(scanType: scan); + expect(suggestedFor(info).intersection(notApplicableFor(info)), isEmpty); + } + }); + }); +} diff --git a/app/test/processing_preset_test.dart b/app/test/processing_preset_test.dart new file mode 100644 index 00000000..2d9434e7 --- /dev/null +++ b/app/test/processing_preset_test.dart @@ -0,0 +1,287 @@ +// Tests for the built-in presets. +// +// Most of these assert that a preset does what its *name* promises. That is the +// whole contract of a source-shaped preset: someone who knows they captured a +// DV tape picks "DV Camcorder Tape" and gets deinterlacing and chroma work, and +// someone who scanned Super 8 picks the film preset and does NOT get their +// progressive scan deinterlaced. A preset that quietly stops matching its name +// is worse than no preset, because the user has no reason to check. +// +// Run with: flutter test test/processing_preset_test.dart + +import 'package:flutter_test/flutter_test.dart'; +import 'package:vapourbox/models/dehalo_parameters.dart'; +import 'package:vapourbox/models/encoding_settings.dart'; +import 'package:vapourbox/models/processing_pipeline.dart'; +import 'package:vapourbox/models/processing_preset.dart'; +import 'package:vapourbox/models/qtgmc_parameters.dart'; +import 'package:vapourbox/models/sharpen_parameters.dart'; +import 'package:vapourbox/models/video_job.dart'; + +void main() { + final presets = ProcessingPreset.builtInPresets(); + + ProcessingPreset byId(String id) => presets.firstWhere((p) => p.id == id); + + group('the built-in set', () { + test('every preset is registered', () { + // A factory that never reaches builtInPresets() is invisible — nothing + // else picks it up. See the "Adding a New Built-in Preset" checklist. + expect( + presets.map((p) => p.id).toSet(), + { + 'builtin-fast', + 'builtin-balanced', + 'builtin-high-quality', + 'builtin-vhs-cleanup', + 'builtin-dv-camcorder', + 'builtin-pal-dvd', + 'builtin-dvd-ivtc', + 'builtin-anime-dvd', + 'builtin-film-scan', + }, + ); + }); + + test('ids are unique', () { + final ids = presets.map((p) => p.id).toList(); + expect(ids.length, ids.toSet().length); + }); + + test('all are marked built-in, named and described', () { + for (final preset in presets) { + expect(preset.isBuiltIn, true, reason: preset.id); + expect(preset.name.trim(), isNotEmpty, reason: preset.id); + expect(preset.description?.trim() ?? '', isNotEmpty, reason: preset.id); + } + }); + + test('every preset actually does something', () { + for (final preset in presets) { + expect(preset.pipeline.enabledPassCount, greaterThan(0), + reason: '${preset.id} enables no passes, so it only re-encodes'); + } + }); + + test('ids survive a round trip, so saved jobs keep resolving', () { + for (final preset in presets) { + final restored = ProcessingPreset.fromJson(preset.toJson()); + expect(restored.id, preset.id); + expect(restored.name, preset.name); + expect(restored.category, preset.category); + expect(restored.pipeline.enabledPassCount, + preset.pipeline.enabledPassCount); + } + }); + }); + + group('menu grouping', () { + test('every built-in declares a category', () { + // The menu groups on this. A built-in left as `custom` would be filed + // under the source presets, which is the safe fallback but still wrong. + for (final preset in presets) { + expect(preset.category, isNot(PresetCategory.custom), reason: preset.id); + } + }); + + test('the quality tiers are the only quality-category presets', () { + expect( + presets + .where((p) => p.category == PresetCategory.quality) + .map((p) => p.id) + .toSet(), + {'builtin-fast', 'builtin-balanced', 'builtin-high-quality'}, + ); + }); + + test('every other built-in is a source preset', () { + final bySource = presets + .where((p) => p.category == PresetCategory.source) + .map((p) => p.id) + .toSet(); + expect(bySource, { + 'builtin-vhs-cleanup', + 'builtin-dv-camcorder', + 'builtin-pal-dvd', + 'builtin-dvd-ivtc', + 'builtin-anime-dvd', + 'builtin-film-scan', + }); + // Source presets should outnumber the tiers — the whole point is that + // naming the source is what a user can actually answer. + expect(bySource.length, greaterThan(3)); + }); + + test('a user-saved preset defaults to custom', () { + final saved = ProcessingPreset( + name: 'My settings', + pipeline: const ProcessingPipeline(), + encodingSettings: EncodingSettings(), + ); + expect(saved.category, PresetCategory.custom); + expect(saved.isBuiltIn, false); + }); + + test('a preset saved before the field existed loads as custom', () { + // Old user presets on disk have no `category` key. + final json = presets.first.toJson()..remove('category'); + expect(ProcessingPreset.fromJson(json).category, PresetCategory.custom); + }); + }); + + group('presets match their names', () { + test('film scan does not deinterlace a progressive source', () { + final preset = byId('builtin-film-scan'); + expect(preset.pipeline.deinterlace.enabled, false, + reason: 'a film scan is already progressive — deinterlacing it would ' + 'only soften the picture'); + // What film has instead is physical damage. + expect(preset.pipeline.descratch.enabled, true); + expect(preset.pipeline.spotless.enabled, true); + // Gate weave is the first thing anyone notices on a cine scan. This pass + // shipped for exactly this source and no preset used it for two batches. + expect(preset.pipeline.stabilize.enabled, true, + reason: 'a film scan without stabilisation is the most obviously ' + 'incomplete preset in the set'); + // And a scan is a master, so it should be archival. + expect(preset.encodingSettings.codec, VideoCodec.ffv1); + }); + + test('anime DVD inverse-telecines rather than deinterlacing', () { + final preset = byId('builtin-anime-dvd'); + expect(preset.pipeline.deinterlace.enabled, true); + expect(preset.pipeline.deinterlace.method, DeinterlaceMethod.ivtc); + // Line art shows halos and banding that live action mostly hides. + expect(preset.pipeline.dehalo.enabled, true); + expect(preset.pipeline.dehalo.method, DehaloMethod.fineDehalo); + expect(preset.pipeline.deband.enabled, true); + // Composite-mastered discs rainbow on fine line art. + expect(preset.pipeline.chromaFixes.enabled, true); + expect(preset.pipeline.chromaFixes.applyDeRainbow, true); + }); + + test('PAL DVD deblocks but does not denoise', () { + final preset = byId('builtin-pal-dvd'); + expect(preset.pipeline.deinterlace.enabled, true); + expect(preset.pipeline.deblock.enabled, true); + expect(preset.pipeline.noiseReduction.enabled, false, + reason: 'a DVD is usually clean; denoising costs detail for nothing'); + }); + + test('DV camcorder fixes chroma, which is DV\'s actual weakness', () { + final preset = byId('builtin-dv-camcorder'); + expect(preset.pipeline.deinterlace.enabled, true); + expect(preset.pipeline.chromaFixes.enabled, true); + expect(preset.pipeline.chromaFixes.applyChromaBleedingFix, true); + // 4:1:1 / 4:2:0 chroma is stored per field, so upsample before bobbing. + expect(preset.pipeline.deinterlace.chromaUpsampleFix, true); + // The light luma denoise deliberately leaves chroma alone, so chroma + // noise needs its own pass. + expect(preset.pipeline.chromaDenoise.enabled, true); + }); + + test('VHS cleanup is the heaviest denoise of the tape presets', () { + final vhs = byId('builtin-vhs-cleanup'); + final dv = byId('builtin-dv-camcorder'); + expect(vhs.pipeline.noiseReduction.enabled, true); + expect(dv.pipeline.noiseReduction.enabled, true); + expect( + vhs.pipeline.noiseReduction.smDegrainThSAD, + greaterThan(dv.pipeline.noiseReduction.smDegrainThSAD), + reason: 'VHS is far noisier than DV tape; if these ever match, one of ' + 'the two presets is miscalibrated', + ); + }); + + test('VHS cleanup uses the two filters the community prescribes most', () { + // CCD and LSFmod are what restoration regulars reach for on tape, and + // both shipped unused while this preset ran SMDegrain and DeHalo_alpha + // alone. Chroma noise is a different failure from luma grain, so the + // denoiser above does not cover it. + final vhs = byId('builtin-vhs-cleanup'); + expect(vhs.pipeline.chromaDenoise.enabled, true); + expect(vhs.pipeline.sharpen.enabled, true); + expect(vhs.pipeline.sharpen.method, SharpenMethod.lsfmod, + reason: 'LSFmod is chosen because it does not add halos, which ' + 'matters when the dehalo pass has just removed some'); + }); + + test('sharpening a tape preset stays modest, because it follows a denoise', () { + // Sharpening a denoised picture hard is how a capture ends up looking + // artificial. This bounds it rather than pinning an exact value. + final vhs = byId('builtin-vhs-cleanup'); + expect(vhs.pipeline.sharpen.strength, lessThan(100), + reason: 'default strength or above, after SMDegrain, oversharpens'); + }); + + test('Fast actually is fast, not just a cheaper QTGMC', () { + // Measured 622 fps against QTGMC Fast's 150. Before Bwdif this tier was + // only a lower QTGMC preset, which is not what the name promises. + final fast = byId('builtin-fast'); + expect(fast.pipeline.deinterlace.method, DeinterlaceMethod.bwdif); + }); + + test('VHS cleanup repairs the edges rather than cropping them', () { + final vhs = byId('builtin-vhs-cleanup'); + expect(vhs.pipeline.edgeRepair.hasEffect, true, + reason: 'dirty edge rows are near-universal on tape, and cropping ' + 'them throws picture away'); + // Even counts, which is what keeps chroma aligned on subsampled video. + for (final v in [ + vhs.pipeline.edgeRepair.left, + vhs.pipeline.edgeRepair.right, + vhs.pipeline.edgeRepair.top, + vhs.pipeline.edgeRepair.bottom, + ]) { + expect(v.isEven, true, reason: 'edge widths must be even'); + } + expect(vhs.pipeline.chromaFixes.applyDedot, true, + reason: 'dot crawl is the signature composite-capture artefact'); + }); + + test('the film scan preset fixes both faults cine film has', () { + // Gate weave and brightness pulsing. It addressed neither until now. + final film = byId('builtin-film-scan'); + expect(film.pipeline.stabilize.enabled, true); + expect(film.pipeline.deflicker.enabled, true); + }); + + test('DV camcorder measures its chroma shift rather than guessing', () { + final dv = byId('builtin-dv-camcorder'); + expect(dv.pipeline.chromaFixes.applyAutoChroma, true); + }); + + test('the quality tiers differ in quality, not in what they fix', () { + final fast = byId('builtin-fast'); + final balanced = byId('builtin-balanced'); + final high = byId('builtin-high-quality'); + + // Lower CRF is better quality. + expect(fast.encodingSettings.quality, + greaterThan(balanced.encodingSettings.quality)); + expect(balanced.encodingSettings.quality, + greaterThan(high.encodingSettings.quality)); + + for (final preset in [fast, balanced, high]) { + expect(preset.pipeline.enabledPassCount, 1, + reason: '${preset.id} is a quality tier — it should only ' + 'deinterlace, or it stops being comparable to the others'); + } + }); + }); + + group('QTGMC presets are ordered fast to slow', () { + // The source presets pick a QTGMC preset by name, so this pins the meaning + // of those names not drifting underneath them. + test('fast uses a faster QTGMC preset than high quality', () { + expect( + QTGMCPreset.values.indexOf(byId('builtin-fast').pipeline.deinterlace.preset), + greaterThan( + QTGMCPreset.values + .indexOf(byId('builtin-high-quality').pipeline.deinterlace.preset), + ), + reason: 'QTGMCPreset is declared slowest-first (placebo … draft)', + ); + }); + }); +} diff --git a/app/test/support/worker_harness.dart b/app/test/support/worker_harness.dart index fb97ee64..2a765dbb 100644 --- a/app/test/support/worker_harness.dart +++ b/app/test/support/worker_harness.dart @@ -206,6 +206,8 @@ class WorkerHarness { ); } + _warnIfWorkerIsStale(); + if (!File(inputFile).existsSync()) { throw StateError('Test input not found at $inputFile'); } @@ -803,6 +805,58 @@ class WorkerHarness { client.close(force: true); } } + + /// Warn when the worker binary predates the Rust sources or templates. + /// + /// These tests exec `worker/target/{release,debug}/vapourbox-worker`, whereas + /// `cargo test` compiles the *library* into its own test binary. So the Rust + /// suite can pass against new code while this suite silently exercises an old + /// executable — and the symptom is misleading: a generated script full of + /// unsubstituted `{{PLACEHOLDER}}` and a bare Python SyntaxError out of + /// vspipe, which reads like a template bug rather than a stale build. + /// + /// A warning rather than a throw: CI builds immediately before running, and a + /// clock-skewed checkout shouldn't fail the suite over it. + static void _warnIfWorkerIsStale() { + final worker = File(_workerPath!); + if (!worker.existsSync()) return; + final built = worker.lastModifiedSync(); + + DateTime? newest; + String? newestPath; + for (final dir in [ + p.join(repoRoot, 'worker', 'src'), + p.join(repoRoot, 'worker', 'templates'), + ]) { + final d = Directory(dir); + if (!d.existsSync()) continue; + for (final file in d.listSync(recursive: true).whereType()) { + final path = file.path; + if (!path.endsWith('.rs') && + !path.endsWith('.vpy') && + !path.endsWith('.py')) { + continue; + } + final modified = file.lastModifiedSync(); + if (newest == null || modified.isAfter(newest)) { + newest = modified; + newestPath = path; + } + } + } + + if (newest != null && newest.isAfter(built)) { + stderr.writeln( + '\n*** WARNING: the worker binary is older than the sources ***\n' + ' binary: $_workerPath\n' + ' built $built\n' + ' newer: $newestPath\n' + ' saved $newest\n' + ' These tests run the BINARY, not the library, so they are about to\n' + ' exercise stale code. Rebuild first: (cd worker && cargo build)\n', + ); + } + } } /// Result of a full encode [WorkerHarness.runJob]. diff --git a/app/test/vapoursynth_integration_test.dart b/app/test/vapoursynth_integration_test.dart index ee589844..a34666a8 100644 --- a/app/test/vapoursynth_integration_test.dart +++ b/app/test/vapoursynth_integration_test.dart @@ -121,7 +121,8 @@ required = ['std', 'resize', 'mv', 'znedi3', 'eedi3m', 'fmtc', 'dfttest', 'neo_f3kdb', 'cas', 'dctf', 'deblock', 'rgvs', 'ctmf', 'warp', 'misc', 'grain', 'tcanny', 'zsmooth', 'descratch', 'vivtc', 'ttmpsm', 'tmedian', - 'fft3dfilter'] + 'fft3dfilter', 'flux', 'bifrost', 'retinex', + 'bwdif', 'fb', 'removedirt', 'dedot', 'lghost'] # 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' diff --git a/app/windows/runner/Runner.rc b/app/windows/runner/Runner.rc index b7a4f2a3..850f55c4 100644 --- a/app/windows/runner/Runner.rc +++ b/app/windows/runner/Runner.rc @@ -73,8 +73,8 @@ IDI_APP_ICON ICON "resources\\app_icon.ico" #endif VS_VERSION_INFO VERSIONINFO - FILEVERSION 0,9,13,0 - PRODUCTVERSION 0,9,13,0 + FILEVERSION 1,0,0,0 + PRODUCTVERSION 1,0,0,0 FILEFLAGSMASK VS_FFI_FILEFLAGSMASK #ifdef _DEBUG FILEFLAGS VS_FF_DEBUG diff --git a/docs/FILTER_SCHEMA.md b/docs/FILTER_SCHEMA.md index 145c2c6e..7440f541 100644 --- a/docs/FILTER_SCHEMA.md +++ b/docs/FILTER_SCHEMA.md @@ -79,6 +79,32 @@ parameters that apply to it. | `description` | string | No | Shown beside the method in the dropdown | | `function` | string | **Yes** | VapourSynth/Python function to call | | `parameters` | array | **Yes** | Parameter ids this method uses | +| `advancedOnly` | boolean | No (`false`) | Method is only offered in advanced mode | + +### `advancedOnly` on a method + +This is how a filter offers a short list to everyone and the full set to someone +who has asked for it — the mechanism that stops a slot like Noise Reduction +becoming a dropdown of sixteen names as methods are added. It is gated by the +same app-wide switch as an `advancedOnly` **section** +(`AdvancedModeService`, **Settings → General → Show advanced options**). + +Three behaviours to know before using it: + +- **Order is the curation.** `visibleMethods()` preserves schema order, so put + the method a regular user should land on first. It is also the default, since + `methods.first` is what an unset `method` resolves to — so **never mark the + first method `advancedOnly`**. +- **A selected method is always shown**, even in simple mode. A preset or a + saved job can select an advanced-only method; hiding it would misreport what + the pipeline is doing, and would hand the dropdown a value that isn't among + its items (a Flutter assertion, not a graceful degradation). Pinned by + `dynamic_filter_panel_advanced_test.dart`. +- **Leave at least one method un-advanced.** Marking every method advanced-only + leaves simple mode showing one arbitrary choice with no dropdown. + +When a filter drops to a single visible method the dropdown is suppressed +entirely and replaced by a line telling the user more exist in advanced mode. ## Parameters @@ -154,7 +180,8 @@ parameters that apply to it. | `visibleWhen` | object | Conditional visibility | | `booleanLabels` | object | Names the two states of a boolean, e.g. `{"true": "Top Field First (TFF)", "false": "Bottom Field First (BFF)"}` | -There is **no `advancedOnly` on a parameter** — only on a section (below). +There is **no `advancedOnly` on a parameter** — only on a section (below) or a +method (above). ### Conditional visibility diff --git a/licenses/NOTICES.txt b/licenses/NOTICES.txt index aa207ca1..a391685c 100644 --- a/licenses/NOTICES.txt +++ b/licenses/NOTICES.txt @@ -266,12 +266,53 @@ Bifrost — WTFPL created by Fredrik Mellbin https://github.com/dubhater/vapoursynth-bifrost +Bwdif — LGPL-3.0-or-later + Copyright (C) 2016 Thomas Mundt + Copyright (C) 2020-2021 HolyWu + Based on YADIF: Copyright (C) 2006-2011 Michael Niedermayer + , 2010 James Darnley + With use of the Weston 3 Field Deinterlacing Filter algorithm: + Copyright (C) 2012 British Broadcasting Corporation, All Rights Reserved + (algorithm by Jim Easterbrook for BBC R&D, after the process described by + Martin Weston) + https://github.com/HomeOfVapourSynthEvolution/VapourSynth-Bwdif + Licence text: LGPL-3.0.txt + BobWeaver motion-adaptive deinterlacer, ported from FFmpeg's libavfilter. + Distributed as an unmodified binary taken from the project's published wheel, + and dynamically linked — the LGPL relinking right is satisfied by replacing + that binary in the bundle's plugin directory. + +FillBorders — WTFPL + VapourSynth plugin by dubhater. The project asserts no copyright line in its + source or repository; its readme states only "The license is WTFPL." + https://github.com/dubhater/vapoursynth-fillborders + +RemoveDirt — GPL-2.0-or-later + By Rainer Wittmann + Additional work by Ferenc Pintér + https://github.com/pinterf/RemoveDirt + Licence text: GPL-2.0.txt + +DeDot — GPL-2.0 + The project declares its author as Fredrik Mellbin (pyproject.toml) and its + licence as "GNU GPL v2, like the Avisynth plugin" (readme). A port of the + AviSynth DeDot filter; upstream names no author for that original. + https://github.com/dubhatervapoursynth/vapoursynth-dedot + Licence text: GPL-2.0.txt + +LGhost — GPL-3.0-or-later + VapourSynth port by HolyWu + LGhost.dll v0.3.01 Copyright (C) 2002, 2003 minamina (the original AviSynth + Ghost Reduction plugin) + https://github.com/HomeOfVapourSynthEvolution/VapourSynth-LGhost + Licence text: GPL-3.0.txt + Vector Class Library (VCL) — Apache-2.0 Copyright (c) Agner Fog https://github.com/vectorclass/version2 Not a plugin of its own: several of the HolyWu-maintained plugins above - (CAS, CTMF, DCTFilter, Deblock, DFTTest, TCanny, TTempSmooth, AddGrain) - compile it in for their SIMD paths. + (CAS, CTMF, DCTFilter, Deblock, DFTTest, TCanny, TTempSmooth, AddGrain, + Bwdif, LGhost) compile it in for their SIMD paths. ------------------------------------------------------------------------------- diff --git a/worker/Cargo.toml b/worker/Cargo.toml index 7830d8a8..8d6688e5 100644 --- a/worker/Cargo.toml +++ b/worker/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vapourbox-worker" -version = "0.9.13" +version = "1.0.0" edition = "2021" description = "Video processing worker using VapourSynth" authors = ["Stuart Cameron"] diff --git a/worker/src/main.rs b/worker/src/main.rs index 4f19bbfe..42e9a9e9 100644 --- a/worker/src/main.rs +++ b/worker/src/main.rs @@ -358,6 +358,58 @@ 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 @@ -447,12 +499,54 @@ fn run_worker( reporter.send_log(models::LogLevel::Info, "Encoding complete!"); - // Post-encode subtitle generation (warnings only — video already encoded) + // ------------------------------------------------------------------ + // Post-encode subtitle handling (warnings only — the video is already made) + // ------------------------------------------------------------------ if let Some(ref sub_settings) = job.subtitle_settings { if sub_settings.enabled { - let _ = run_subtitle_generation( - &job.output_path, sub_settings, reporter, &cancelled, false, None, - ); + match whisper_srt { + // Transcribed before the encode. Apply whatever the output mode + // asks for on top of what already happened during it. + Some(srt) => { + let mode = sub_settings.output; + if mode.muxes() { + reporter.send_log( + models::LogLevel::Info, + "Adding subtitle track to the output...", + ); + if let Err(e) = dependency_locator::DependencyLocator::new() + .and_then(|d| { + SubtitleGenerator::new(reporter.clone(), d).mux( + std::path::Path::new(&job.output_path), + &srt, + || cancelled.load(Ordering::SeqCst), + ) + }) + { + reporter.send_log( + models::LogLevel::Warning, + &format!("Could not add the subtitle track: {e}"), + ); + } + } + if mode.keeps_srt_file() { + reporter.send_log( + models::LogLevel::Info, + &format!("Subtitles saved to: {}", srt.display()), + ); + } else { + let _ = std::fs::remove_file(&srt); + } + } + // Nothing was transcribed — a user-supplied burn-in file, or the + // source had no audio. Fall back to the original path. + None if job.burn_in_subtitle_path.is_none() => { + let _ = run_subtitle_generation( + &job.output_path, sub_settings, reporter, &cancelled, false, None, + ); + } + None => {} + } } } diff --git a/worker/src/models/anti_alias_parameters.rs b/worker/src/models/anti_alias_parameters.rs new file mode 100644 index 00000000..3755edff --- /dev/null +++ b/worker/src/models/anti_alias_parameters.rs @@ -0,0 +1,140 @@ +//! Anti-aliasing parameters. +//! +//! Removes the stair-stepping ("jaggies") left along diagonal edges by +//! deinterlacing and by upscaling. Both methods work the same way: re-interpolate +//! 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. + +use serde::{Deserialize, Serialize}; + +/// Anti-aliasing method options. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum AntiAliasMethod { + /// Double-rate anti-aliasing: interpolates both fields with nnedi3 and + /// averages. The usual choice, and the gentler of the two. + #[default] + Daa, + /// santiag: separate horizontal and vertical strength, so it can be aimed + /// at one axis. Stronger, and slower. + Santiag, +} + +/// Parameters for the anti-aliasing pass. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AntiAliasParameters { + /// Whether this pass is enabled. + #[serde(default)] + pub enabled: bool, + + /// Which method to use. + #[serde(default)] + pub method: AntiAliasMethod, + + // --- santiag parameters --- + + /// Vertical strength (0 disables the vertical pass). + #[serde(default = "default_strength")] + pub santiag_strv: i32, + + /// Horizontal strength (0 disables the horizontal pass). + #[serde(default = "default_strength")] + pub santiag_strh: i32, + + /// Interpolator for the vertical pass: `nnedi3`, `eedi2` or `sangnom`. + /// Only `nnedi3` is bundled, so the others are not offered. + #[serde(default = "default_santiag_type")] + pub santiag_type: String, +} + +fn default_strength() -> i32 { 1 } +fn default_santiag_type() -> String { "nnedi3".to_string() } + +impl Default for AntiAliasParameters { + fn default() -> Self { + Self { + enabled: false, + method: AntiAliasMethod::default(), + santiag_strv: default_strength(), + santiag_strh: default_strength(), + santiag_type: default_santiag_type(), + } + } +} + +impl AntiAliasParameters { + /// santiag's interpolator name, restricted to what is bundled. + /// + /// 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. + pub fn effective_santiag_type(&self) -> &'static str { + match self.santiag_type.to_ascii_lowercase().as_str() { + "nnedi3" => "nnedi3", + _ => "nnedi3", + } + } + + /// Strengths clamped to the 0-3 havsfunc implements. + pub fn effective_strv(&self) -> i32 { + self.santiag_strv.clamp(0, 3) + } + + /// See [`Self::effective_strv`]. + pub fn effective_strh(&self) -> i32 { + self.santiag_strh.clamp(0, 3) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_defaults() { + let p = AntiAliasParameters::default(); + assert!(!p.enabled); + assert_eq!(p.method, AntiAliasMethod::Daa); + assert_eq!(p.santiag_strv, 1); + assert_eq!(p.santiag_strh, 1); + } + + #[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 + // script evaluation rather than degrade. + let with = |t: &str| AntiAliasParameters { + santiag_type: t.to_string(), + ..Default::default() + }; + assert_eq!(with("nnedi3").effective_santiag_type(), "nnedi3"); + assert_eq!(with("eedi2").effective_santiag_type(), "nnedi3"); + assert_eq!(with("sangnom").effective_santiag_type(), "nnedi3"); + assert_eq!(with("").effective_santiag_type(), "nnedi3"); + } + + #[test] + fn test_strengths_are_clamped() { + let with = |v: i32| AntiAliasParameters { + santiag_strv: v, + santiag_strh: v, + ..Default::default() + }; + assert_eq!(with(-2).effective_strv(), 0); + assert_eq!(with(1).effective_strv(), 1); + assert_eq!(with(9).effective_strh(), 3); + } + + #[test] + fn test_serialization() { + let json = serde_json::to_string(&AntiAliasParameters::default()).unwrap(); + assert!(json.contains("\"enabled\":false")); + assert!(json.contains("\"method\":\"daa\"")); + assert!(json.contains("\"santiagStrv\":1")); + } +} diff --git a/worker/src/models/chroma_denoise_parameters.rs b/worker/src/models/chroma_denoise_parameters.rs index 2cc75e1a..4152f6c2 100644 --- a/worker/src/models/chroma_denoise_parameters.rs +++ b/worker/src/models/chroma_denoise_parameters.rs @@ -2,6 +2,20 @@ use serde::{Deserialize, Serialize}; +/// Which chroma denoiser to run. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum ChromaDenoiseMethod { + /// CCD — spatial (optionally temporal), averages over a wide radius. + #[default] + #[serde(rename = "ccd")] + Ccd, + /// Cnr4 — temporal, luma-gated. A different failure mode from CCD: it + /// targets chroma that swims or shimmers over time rather than blotches + /// that sit still. + #[serde(rename = "cnr4")] + Cnr4, +} + /// Frame height CCD was designed for. Its automatic `scale` is derived from the /// source height relative to this, and the plugin rejects a scale below 1.0 — /// so any source shorter than this needs an explicit clamped scale or the job @@ -21,6 +35,10 @@ pub struct ChromaDenoiseParameters { #[serde(default)] pub enabled: bool, + /// Which denoiser to run. + #[serde(default)] + pub method: ChromaDenoiseMethod, + /// Euclidean RGB distance below which a neighbouring pixel joins the /// average. Higher denoises more. #[serde(default = "default_threshold")] @@ -46,10 +64,37 @@ pub struct ChromaDenoiseParameters { /// same rule the plugin uses, but clamped so short sources still run. #[serde(default)] pub scale: Option, + + // ---- Cnr4 ---------------------------------------------------------- + /// Motion sensitivity. Higher tolerates more movement before it stops + /// correcting, so higher also risks smearing moving colour. + #[serde(default = "default_cnr4_sense")] + pub cnr4_sense: i32, + + /// How far chroma is pulled toward the temporal average. The plugin's own + /// default sits near the top of the range, so there is far more headroom + /// downward than up. + #[serde(default = "default_cnr4_strength")] + pub cnr4_strength: i32, + + /// Temporal radius, 1-8. + #[serde(default = "default_cnr4_radius")] + pub cnr4_radius: i32, + + /// Detail-retention mode, 0-3. + #[serde(default)] + pub cnr4_tmode: i32, + + /// Weighting mode, 0-2. + #[serde(default)] + pub cnr4_wmode: i32, } fn default_threshold() -> f64 { 4.0 } fn default_true() -> bool { true } +fn default_cnr4_sense() -> i32 { 35 } +fn default_cnr4_strength() -> i32 { 192 } +fn default_cnr4_radius() -> i32 { 2 } impl Default for ChromaDenoiseParameters { fn default() -> Self { @@ -61,6 +106,12 @@ impl Default for ChromaDenoiseParameters { points_medium: true, points_high: false, scale: None, + method: ChromaDenoiseMethod::default(), + cnr4_sense: default_cnr4_sense(), + cnr4_strength: default_cnr4_strength(), + cnr4_radius: default_cnr4_radius(), + cnr4_tmode: 0, + cnr4_wmode: 0, } } } @@ -79,7 +130,34 @@ impl ChromaDenoiseParameters { /// Frames of temporal context this pass needs on each side. pub fn radius(&self) -> u32 { - self.temporal_radius.max(0) as u32 + match self.method { + ChromaDenoiseMethod::Ccd => self.temporal_radius.max(0) as u32, + ChromaDenoiseMethod::Cnr4 => self.effective_cnr4_radius() as u32, + } + } + + /// Cnr4's radius, clamped to what the plugin accepts. Out of range is a + /// hard error at script evaluation, not a clamp. + pub fn effective_cnr4_radius(&self) -> i32 { + self.cnr4_radius.clamp(1, 8) + } + + /// Per-plane `sense`. Chroma planes get the plugin's own higher defaults + /// scaled by the user's single control, because exposing three numbers for + /// what reads as one idea is how this pass would stop being usable. + pub fn cnr4_sense_literal(&self) -> String { + let luma = self.cnr4_sense.clamp(0, 255); + // The plugin's defaults are [35, 47, 47]: chroma is less sensitive than + // luma by a fixed ratio, preserved here as the slider moves. + let chroma = ((luma as f64) * 47.0 / 35.0).round().clamp(0.0, 255.0) as i32; + format!("[{luma}, {chroma}, {chroma}]") + } + + /// Per-plane `str`, same reasoning as `sense`. Defaults are [192, 255, 255]. + pub fn cnr4_strength_literal(&self) -> String { + let luma = self.cnr4_strength.clamp(0, 255); + let chroma = ((luma as f64) * 255.0 / 192.0).round().clamp(0.0, 255.0) as i32; + format!("[{luma}, {chroma}, {chroma}]") } } diff --git a/worker/src/models/chroma_fix_parameters.rs b/worker/src/models/chroma_fix_parameters.rs index 7f54bcd7..f81c58fd 100644 --- a/worker/src/models/chroma_fix_parameters.rs +++ b/worker/src/models/chroma_fix_parameters.rs @@ -81,6 +81,78 @@ pub struct ChromaFixParameters { #[serde(default = "default_de_crawl_max_diff")] pub de_crawl_max_diff: i32, + // --- LUTDeRainbow (cross-luminance / rainbowing) --- + + /// Apply LUTDeRainbow. + #[serde(default)] + pub apply_de_rainbow: bool, + + /// DeDot — temporal dot crawl / rainbow removal on both planes. + #[serde(default)] + pub apply_dedot: bool, + + /// Measure the chroma misalignment and correct it automatically. + #[serde(default)] + pub apply_auto_chroma: bool, + /// Largest shift to search for, in pixels. + #[serde(default = "default_acf_max_shift")] + pub auto_chroma_max_shift: i32, + /// Sub-pixel search step. + #[serde(default = "default_acf_accuracy")] + pub auto_chroma_accuracy: f64, + /// Measure once on this frame (-1 measures every frame, ~23x the cost). + #[serde(default)] + pub auto_chroma_reference_frame: i32, + /// Spatial luma threshold (0-510). + #[serde(default = "default_dedot_luma_2d")] + pub dedot_luma_2d: i32, + /// Temporal luma threshold (0-255). + #[serde(default = "default_dedot_luma_t")] + pub dedot_luma_t: i32, + /// Chroma threshold 1 (0-255). + #[serde(default = "default_dedot_chroma_t1")] + pub dedot_chroma_t1: i32, + /// Chroma threshold 2 (0-255). 255 bypasses chroma entirely. + #[serde(default = "default_dedot_chroma_t2")] + pub dedot_chroma_t2: i32, + + /// Chroma difference threshold for detecting rainbowing. + #[serde(default = "default_de_rainbow_cthresh")] + pub de_rainbow_c_thresh: i32, + + /// Luma difference threshold. Areas moving more than this are left alone. + #[serde(default = "default_de_rainbow_ythresh")] + pub de_rainbow_y_thresh: i32, + + /// Use the luma difference in the decision as well as chroma. + #[serde(default = "default_true")] + pub de_rainbow_use_luma: bool, + + /// Require both chroma planes to agree before treating a pixel. + #[serde(default = "default_true")] + pub de_rainbow_link_uv: bool, + + // --- Bifrost (temporal rainbow removal) --- + + /// Apply Bifrost. Where LUTDeRainbow works within a frame, this compares + /// across frames, so it catches rainbowing that shimmers rather than sits + /// still. + #[serde(default)] + pub apply_bifrost: bool, + + /// Luma difference above which a block is treated as motion and left alone. + #[serde(default = "default_bifrost_luma_thresh")] + pub bifrost_luma_thresh: f64, + + /// How many neighbouring blocks must agree before a pixel is treated + /// (0-3). Higher is more conservative. + #[serde(default = "default_bifrost_variation")] + pub bifrost_variation: i32, + + /// Treat the source as interlaced, comparing fields rather than frames. + #[serde(default = "default_true")] + pub bifrost_interlaced: bool, + // --- Vinverse Parameters --- /// Whether to apply Vinverse (inverted telecine/chroma fix). @@ -101,9 +173,21 @@ fn default_chroma_bleed_blur() -> f64 { 0.7 } fn default_chroma_bleed_strength() -> f64 { 0.8 } fn default_de_crawl_thresh() -> i32 { 10 } fn default_de_crawl_max_diff() -> i32 { 50 } +fn default_true() -> bool { true } +fn default_de_rainbow_cthresh() -> i32 { 10 } +fn default_de_rainbow_ythresh() -> i32 { 10 } +fn default_bifrost_luma_thresh() -> f64 { 10.0 } +fn default_bifrost_variation() -> i32 { 5 } fn default_vinverse_sstr() -> f64 { 2.7 } fn default_255() -> i32 { 255 } +fn default_acf_max_shift() -> i32 { 2 } +fn default_acf_accuracy() -> f64 { 0.25 } +fn default_dedot_luma_2d() -> i32 { 20 } +fn default_dedot_luma_t() -> i32 { 20 } +fn default_dedot_chroma_t1() -> i32 { 15 } +fn default_dedot_chroma_t2() -> i32 { 5 } + impl Default for ChromaFixParameters { fn default() -> Self { Self { @@ -118,6 +202,24 @@ impl Default for ChromaFixParameters { chroma_bleed_c_blur: default_chroma_bleed_blur(), chroma_bleed_strength: default_chroma_bleed_strength(), apply_de_crawl: false, + apply_de_rainbow: false, + apply_dedot: false, + apply_auto_chroma: false, + auto_chroma_max_shift: default_acf_max_shift(), + auto_chroma_accuracy: default_acf_accuracy(), + auto_chroma_reference_frame: 0, + dedot_luma_2d: default_dedot_luma_2d(), + dedot_luma_t: default_dedot_luma_t(), + dedot_chroma_t1: default_dedot_chroma_t1(), + dedot_chroma_t2: default_dedot_chroma_t2(), + de_rainbow_c_thresh: default_de_rainbow_cthresh(), + de_rainbow_y_thresh: default_de_rainbow_ythresh(), + de_rainbow_use_luma: true, + de_rainbow_link_uv: true, + apply_bifrost: false, + bifrost_luma_thresh: default_bifrost_luma_thresh(), + bifrost_variation: default_bifrost_variation(), + bifrost_interlaced: true, de_crawl_y_thresh: default_de_crawl_thresh(), de_crawl_c_thresh: default_de_crawl_thresh(), de_crawl_max_diff: default_de_crawl_max_diff(), @@ -153,3 +255,10 @@ mod tests { assert!(json.contains("\"chromaBleedCx\":4")); } } + +impl ChromaFixParameters { + /// Bifrost's `variation`, clamped to the range it accepts. + pub fn bifrost_effective_variation(&self) -> i32 { + self.bifrost_variation.clamp(0, 10) + } +} diff --git a/worker/src/models/color_correction_parameters.rs b/worker/src/models/color_correction_parameters.rs index 19ab6a9f..4d404e44 100644 --- a/worker/src/models/color_correction_parameters.rs +++ b/worker/src/models/color_correction_parameters.rs @@ -23,6 +23,26 @@ pub struct ColorCorrectionParameters { #[serde(default)] pub enabled: bool, + /// Stretch luma to the target black/white points, measured per frame. + #[serde(default)] + pub apply_auto_levels: bool, + /// Target black point, 8-bit UI units; scaled to the clip format in-script. + #[serde(default = "default_auto_black")] + pub auto_levels_black: i32, + /// Target white point, 8-bit UI units. + #[serde(default = "default_auto_white_point")] + pub auto_levels_white: i32, + /// 0-1 blend against the untouched clip. + #[serde(default = "default_auto_strength")] + pub auto_levels_strength: f64, + + /// Grey-world automatic white balance. + #[serde(default)] + pub apply_auto_white_balance: bool, + /// 0-1 blend for the chroma shift. + #[serde(default = "default_auto_strength")] + pub auto_white_balance_strength: f64, + /// Preset level for simple mode. #[serde(default)] pub preset: ColorCorrectionPreset, @@ -55,6 +75,40 @@ pub struct ColorCorrectionParameters { #[serde(default)] pub apply_levels: bool, + /// Use havsfunc's SmoothLevels instead of plain `std.Levels`. + /// + /// Same curve, but dithered and limited as it goes, so stretching a narrow + /// range does not band. Measured on a shallow gradient stretched to full + /// range: distinct output levels go from 47 to 135. + #[serde(default)] + pub smooth_levels: bool, + + // --- Retinex (shadow detail) --- + + /// Lift shadow detail with multi-scale retinex. + /// + /// Run on luma only. `retinex.MSRCP` rejects subsampled formats outright + /// ("sub-sampled format is not supported"), and every source this app + /// handles is 4:2:0 or 4:2:2 — so rather than round-trip the whole clip + /// through 4:4:4 and resample chroma twice, the luma plane is extracted as + /// greyscale, processed, and put back. Colour is left bit-identical. + #[serde(default)] + pub apply_shadow_detail: bool, + + /// Retinex scale, in pixels. Larger looks at a wider neighbourhood, so it + /// lifts broad shadow areas rather than local texture. + #[serde(default = "default_shadow_sigma")] + pub shadow_sigma: f64, + + /// Fraction of the darkest pixels ignored when rescaling, which stops a few + /// black pixels dragging the whole result. + #[serde(default = "default_shadow_lower")] + pub shadow_lower_thr: f64, + + /// Same at the bright end. + #[serde(default = "default_shadow_upper")] + pub shadow_upper_thr: f64, + /// Input black level (0-255). #[serde(default)] pub input_low: i32, @@ -110,13 +164,26 @@ impl ColorCorrectionParameters { } } +fn default_shadow_sigma() -> f64 { 100.0 } +fn default_shadow_lower() -> f64 { 0.001 } +fn default_shadow_upper() -> f64 { 0.001 } fn default_one_f64() -> f64 { 1.0 } fn default_255() -> i32 { 255 } +fn default_auto_black() -> i32 { 16 } +fn default_auto_white_point() -> i32 { 235 } +fn default_auto_strength() -> f64 { 1.0 } + impl Default for ColorCorrectionParameters { fn default() -> Self { Self { enabled: false, + apply_auto_levels: false, + auto_levels_black: default_auto_black(), + auto_levels_white: default_auto_white_point(), + auto_levels_strength: default_auto_strength(), + apply_auto_white_balance: false, + auto_white_balance_strength: default_auto_strength(), preset: ColorCorrectionPreset::default(), brightness: 0.0, contrast: 1.0, @@ -124,6 +191,11 @@ impl Default for ColorCorrectionParameters { saturation: 1.0, coring: false, apply_levels: false, + smooth_levels: false, + apply_shadow_detail: false, + shadow_sigma: default_shadow_sigma(), + shadow_lower_thr: default_shadow_lower(), + shadow_upper_thr: default_shadow_upper(), input_low: 0, input_high: 255, output_low: 0, @@ -185,3 +257,139 @@ mod tests { assert!(json.contains("\"contrast\":1.0")); } } + +impl ColorCorrectionParameters { + /// The black point actually passed to SmoothLevels. + /// + /// havsfunc builds its lookup table over the whole `0..peak` domain and + /// raises `x - input_low` to `1/gamma`. For `x < input_low` that base is + /// negative, and a fractional exponent on a negative base yields a Python + /// `complex` — which fails the LUT with `TypeError: must be real number, not + /// complex`. Measured: it crashes whenever `input_low > 0` and `1/gamma` is + /// not an integer, i.e. for essentially every useful gamma. + /// + /// Rather than refuse the combination, the black point is dropped for that + /// case. Losing the lift is visible; a failed job is worse, and the plain + /// Levels mode still does both together. + pub fn smooth_levels_input_low(&self) -> i32 { + if (self.gamma - 1.0).abs() > f64::EPSILON { + 0 + } else { + self.input_low + } + } + + /// Whether the gamma guard above actually dropped anything, so the UI can + /// say so rather than silently ignoring a control the user set. + pub fn smooth_levels_drops_black_point(&self) -> bool { + self.smooth_levels + && self.apply_levels + && self.input_low > 0 + && (self.gamma - 1.0).abs() > f64::EPSILON + } +} + +#[cfg(test)] +mod smooth_levels_tests { + use super::*; + + #[test] + fn test_black_point_survives_when_gamma_is_neutral() { + let p = ColorCorrectionParameters { + input_low: 16, + gamma: 1.0, + ..Default::default() + }; + assert_eq!(p.smooth_levels_input_low(), 16); + assert!(!p.smooth_levels_drops_black_point()); + } + + #[test] + fn test_black_point_is_dropped_when_gamma_would_crash_the_lut() { + // havsfunc raises a negative base to a fractional power for x below + // input_low, which yields a complex number and fails the LUT. + for gamma in [0.6, 0.8, 1.2, 2.2] { + let p = ColorCorrectionParameters { + apply_levels: true, + smooth_levels: true, + input_low: 16, + gamma, + ..Default::default() + }; + assert_eq!(p.smooth_levels_input_low(), 0, "gamma {gamma}"); + assert!(p.smooth_levels_drops_black_point(), "gamma {gamma}"); + } + } + + #[test] + fn test_nothing_is_dropped_when_the_black_point_is_already_zero() { + let p = ColorCorrectionParameters { + apply_levels: true, + smooth_levels: true, + input_low: 0, + gamma: 0.6, + ..Default::default() + }; + assert_eq!(p.smooth_levels_input_low(), 0); + assert!(!p.smooth_levels_drops_black_point()); + } + + #[test] + fn test_plain_levels_mode_never_reports_a_dropped_black_point() { + let p = ColorCorrectionParameters { + apply_levels: true, + smooth_levels: false, + apply_shadow_detail: false, + shadow_sigma: default_shadow_sigma(), + shadow_lower_thr: default_shadow_lower(), + shadow_upper_thr: default_shadow_upper(), + input_low: 16, + gamma: 0.6, + ..Default::default() + }; + assert!(!p.smooth_levels_drops_black_point()); + } +} + +impl ColorCorrectionParameters { + /// Retinex scale, clamped to something meaningful for SD and HD frames. + pub fn shadow_effective_sigma(&self) -> f64 { + self.shadow_sigma.clamp(1.0, 500.0) + } + + /// The clipping thresholds, kept inside the 0-1 fraction the plugin wants + /// and away from the degenerate ends. + pub fn shadow_effective_lower(&self) -> f64 { + self.shadow_lower_thr.clamp(0.0, 0.1) + } + + /// See [`Self::shadow_effective_lower`]. + pub fn shadow_effective_upper(&self) -> f64 { + self.shadow_upper_thr.clamp(0.0, 0.1) + } +} + +#[cfg(test)] +mod shadow_detail_tests { + use super::*; + + #[test] + fn test_defaults_are_off_and_conservative() { + let p = ColorCorrectionParameters::default(); + assert!(!p.apply_shadow_detail); + assert_eq!(p.shadow_sigma, 100.0); + } + + #[test] + fn test_sigma_and_thresholds_are_clamped() { + let p = ColorCorrectionParameters { + shadow_sigma: 9000.0, + shadow_lower_thr: 0.9, + shadow_upper_thr: -1.0, + ..Default::default() + }; + assert_eq!(p.shadow_effective_sigma(), 500.0); + assert_eq!(p.shadow_effective_lower(), 0.1); + assert_eq!(p.shadow_effective_upper(), 0.0); + } +} diff --git a/worker/src/models/color_metadata.rs b/worker/src/models/color_metadata.rs new file mode 100644 index 00000000..a111ce97 --- /dev/null +++ b/worker/src/models/color_metadata.rs @@ -0,0 +1,211 @@ +//! Colour metadata carried from the source to the encoder. +//! +//! The Y4M pipe from vspipe strips colour tags exactly as it strips the sample +//! aspect ratio — a clip carrying `_Matrix`/`_Primaries`/`_Transfer` frame +//! properties produces a Y4M header with none of them. So, like SAR, whatever +//! the pipeline did, something has to re-stamp them on the output. Without that +//! every file this app writes is untagged, and an untagged file is read as +//! BT.601 limited by every player — which silently shifts the colours of any +//! BT.709 or full-range source. +//! +//! Note this is a *metadata* fix, not a pixel one. Nothing here converts +//! anything: the samples coming off the pipe already carry whatever matrix the +//! source used, and none of the passes re-matrix them. The bug was only ever +//! that we failed to say so on the way out. +//! +//! Values are validated against what FFmpeg actually accepts rather than passed +//! through, on the same principle as `parse_ratio`: a value we do not recognise +//! is dropped, so a surprising ffprobe string falls back to "untagged" instead +//! of reaching the encoder as a broken argument and failing the whole job. + +use serde::{Deserialize, Serialize}; + +/// `-colorspace` values. ffprobe's `color_space` uses these same names. +const MATRICES: &[&str] = &[ + "rgb", "bt709", "fcc", "bt470bg", "smpte170m", "smpte240m", "ycgco", + "bt2020nc", "bt2020c", "smpte2085", "chroma-derived-nc", "chroma-derived-c", + "ictcp", +]; + +/// `-color_primaries` values, matching ffprobe's `color_primaries`. +const PRIMARIES: &[&str] = &[ + "bt709", "bt470m", "bt470bg", "smpte170m", "smpte240m", "film", "bt2020", + "smpte428", "smpte431", "smpte432", "jedec-p22", "ebu3213", +]; + +/// `-color_trc` values, matching ffprobe's `color_transfer`. +const TRANSFERS: &[&str] = &[ + "bt709", "gamma22", "gamma28", "smpte170m", "smpte240m", "linear", "log100", + "log316", "iec61966-2-4", "bt1361e", "iec61966-2-1", "bt2020-10", + "bt2020-12", "smpte2084", "smpte428", "arib-std-b67", +]; + +/// `-color_range` values. ffprobe reports `tv`/`pc`; it also emits the longer +/// `mpeg`/`jpeg` spellings in some versions, which FFmpeg accepts as synonyms. +const RANGES: &[&str] = &["tv", "pc", "mpeg", "jpeg", "limited", "full"]; + +/// Normalise one ffprobe value: trim, lowercase, and drop anything not on the +/// allowed list. `unknown`, `N/A`, `reserved` and the empty string all fall out +/// here, which is what ffprobe reports for an untagged stream. +fn clean(value: Option<&str>, allowed: &[&str]) -> Option { + let v = value?.trim().to_ascii_lowercase(); + if v.is_empty() { + return None; + } + allowed.contains(&v.as_str()).then_some(v) +} + +/// The four colour tags, as read from the source and re-declared on the output. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ColorMetadata { + pub matrix: Option, + pub primaries: Option, + pub transfer: Option, + pub range: Option, +} + +impl ColorMetadata { + /// Build from raw ffprobe strings, dropping anything unrecognised. + pub fn from_raw( + matrix: Option<&str>, + primaries: Option<&str>, + transfer: Option<&str>, + range: Option<&str>, + ) -> Self { + Self { + matrix: clean(matrix, MATRICES), + primaries: clean(primaries, PRIMARIES), + transfer: clean(transfer, TRANSFERS), + range: clean(range, RANGES), + } + } + + /// True when the source told us nothing worth re-declaring. + pub fn is_empty(&self) -> bool { + self.matrix.is_none() + && self.primaries.is_none() + && self.transfer.is_none() + && self.range.is_none() + } + + /// Output-stream flags for the encoder. Each tag is independent: a source + /// that declares only a matrix gets only `-colorspace`, rather than having + /// the other three guessed for it. + pub fn to_ffmpeg_args(&self) -> Vec { + let mut args = Vec::new(); + for (flag, value) in [ + ("-colorspace", &self.matrix), + ("-color_primaries", &self.primaries), + ("-color_trc", &self.transfer), + ("-color_range", &self.range), + ] { + if let Some(v) = value { + args.push(flag.to_string()); + args.push(v.clone()); + } + } + args + } + + /// swscale input options for the preview's YUV→RGB conversion. + /// + /// The preview's second stage reads a Y4M pipe, which carries no colour + /// information, so swscale would otherwise guess the matrix — while the + /// app's "before" thumbnail comes from a separate ffmpeg call on the + /// original file that *does* see the real tags. That mismatch showed a hue + /// shift no filter had caused. `in_range` was previously hardcoded `tv`, + /// which also stretched a full-range source a second time. + /// + /// swscale spells the matrix differently from `-colorspace` in one case: + /// it wants `bt470bg` for 601 PAL, which matches, but it has no entry for + /// the RGB identity matrix, so that is dropped. + pub fn swscale_input_opts(&self) -> Vec { + let mut opts = Vec::new(); + if let Some(m) = self.matrix.as_deref() { + if m != "rgb" { + opts.push(format!("in_color_matrix={m}")); + } + } + // Anything but an explicit full-range tag is treated as limited, which + // is what an untagged SD capture almost always is. + let full = matches!(self.range.as_deref(), Some("pc") | Some("jpeg") | Some("full")); + opts.push(format!("in_range={}", if full { "pc" } else { "tv" })); + opts + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recognised_values_survive() { + let c = ColorMetadata::from_raw(Some("bt709"), Some("bt709"), Some("bt709"), Some("tv")); + assert_eq!( + c.to_ffmpeg_args(), + vec![ + "-colorspace", "bt709", + "-color_primaries", "bt709", + "-color_trc", "bt709", + "-color_range", "tv", + ] + ); + } + + #[test] + fn untagged_streams_produce_nothing() { + // What ffprobe actually reports for a stream with no colour tags. + for junk in ["unknown", "N/A", "", " ", "reserved"] { + let c = ColorMetadata::from_raw(Some(junk), Some(junk), Some(junk), Some(junk)); + assert!(c.is_empty(), "{junk:?} should not be stamped"); + assert!(c.to_ffmpeg_args().is_empty()); + } + assert!(ColorMetadata::from_raw(None, None, None, None).is_empty()); + } + + #[test] + fn unrecognised_values_are_dropped_not_forwarded() { + // A value we do not know must never reach ffmpeg, or it fails the whole + // encode on an argument the user cannot see or fix. + let c = ColorMetadata::from_raw(Some("bt709; rm -rf"), Some("nonsense"), None, Some("wat")); + assert!(c.is_empty()); + } + + #[test] + fn each_tag_is_independent() { + // A source that declares only a matrix gets only -colorspace; the other + // three are not guessed on its behalf. + let c = ColorMetadata::from_raw(Some("smpte170m"), None, None, None); + assert_eq!(c.to_ffmpeg_args(), vec!["-colorspace", "smpte170m"]); + } + + #[test] + fn values_are_normalised() { + let c = ColorMetadata::from_raw(Some("BT709"), None, None, Some(" PC ")); + assert_eq!(c.matrix.as_deref(), Some("bt709")); + assert_eq!(c.range.as_deref(), Some("pc")); + } + + #[test] + fn preview_defaults_to_limited_range() { + // The old hardcoded behaviour, preserved for untagged sources. + let c = ColorMetadata::default(); + assert_eq!(c.swscale_input_opts(), vec!["in_range=tv"]); + } + + #[test] + fn preview_honours_a_full_range_source() { + let c = ColorMetadata::from_raw(Some("bt709"), None, None, Some("pc")); + assert_eq!( + c.swscale_input_opts(), + vec!["in_color_matrix=bt709", "in_range=pc"] + ); + } + + #[test] + fn preview_drops_the_rgb_identity_matrix() { + // swscale has no in_color_matrix entry for it. + let c = ColorMetadata::from_raw(Some("rgb"), None, None, None); + assert_eq!(c.swscale_input_opts(), vec!["in_range=tv"]); + } +} diff --git a/worker/src/models/deblock_parameters.rs b/worker/src/models/deblock_parameters.rs index 79c97e84..71a49124 100644 --- a/worker/src/models/deblock_parameters.rs +++ b/worker/src/models/deblock_parameters.rs @@ -8,6 +8,10 @@ pub enum DeblockMethod { DeblockQed, #[serde(rename = "Deblock")] Deblock, + /// DCTFilter: attenuates chosen DCT frequency bands directly. Targets + /// ringing and mosquito noise rather than block edges. + #[serde(rename = "DCTFilter")] + DctFilter, } #[allow(dead_code)] @@ -16,6 +20,7 @@ impl DeblockMethod { match self { DeblockMethod::DeblockQed => "Deblock_QED", DeblockMethod::Deblock => "Deblock", + DeblockMethod::DctFilter => "DCTFilter", } } } @@ -50,10 +55,29 @@ pub struct DeblockParameters { /// Analyze planes offset 2. #[serde(default = "default_a_offset")] pub a_offset2: i32, + + // --- DCTFilter parameters --- + + /// Lowest frequency band left untouched (0-7). Everything above it is + /// attenuated. Higher keeps more detail. + #[serde(default = "default_dct_cutoff")] + pub dct_cutoff: i32, + + /// How hard the bands above the cutoff are attenuated (0.0-1.0), where 1.0 + /// removes them entirely. + #[serde(default = "default_dct_strength")] + pub dct_strength: f64, + + /// Planes to filter: 0 luma only, 1 chroma only, 2 both. + #[serde(default = "default_dct_planes")] + pub dct_planes: i32, } fn default_quant1() -> i32 { 24 } fn default_quant2() -> i32 { 26 } +fn default_dct_cutoff() -> i32 { 5 } +fn default_dct_strength() -> f64 { 0.6 } +fn default_dct_planes() -> i32 { 0 } fn default_a_offset() -> i32 { 1 } impl Default for DeblockParameters { @@ -65,6 +89,138 @@ impl Default for DeblockParameters { quant2: default_quant2(), a_offset1: default_a_offset(), a_offset2: default_a_offset(), + dct_cutoff: default_dct_cutoff(), + dct_strength: default_dct_strength(), + dct_planes: default_dct_planes(), + } + } +} + +impl DeblockParameters { + /// The eight DCTFilter coefficients, as a Python list literal. + /// + /// DCTFilter takes exactly eight factors and applies them **separably** — + /// coefficient (u, v) is scaled by `factors[u] * factors[v]`, measured, not + /// the `max(u, v)` the Avisynth filter of the same name uses. So band k + /// affects a whole row *and* column, and the DC term is scaled by + /// `factors[0]` squared. Exposing eight raw sliders would be both + /// unusable and misleading, so the UI has a cutoff and a strength and this + /// builds the curve. + /// + /// Bands up to and including the cutoff stay at 1.0; above it they ramp down + /// linearly to `1.0 - strength`. All 1.0 is a verified exact no-op. + /// + /// Every value is clamped into [0.0, 1.0] and any non-finite value becomes + /// 1.0: **DCTFilter's own range check lets NaN through** (`nan < 0.0` and + /// `nan > 1.0` are both false) and a NaN factor silently blackens the entire + /// frame with no error anywhere. + pub fn dct_factors_literal(&self) -> String { + let cutoff = self.dct_cutoff.clamp(0, 7); + let strength = if self.dct_strength.is_finite() { + self.dct_strength.clamp(0.0, 1.0) + } else { + 0.0 + }; + + let above = 7 - cutoff; + let factors: Vec = (0..8) + .map(|band| { + let value = if band <= cutoff || above == 0 { + 1.0 + } else { + let step = (band - cutoff) as f64 / above as f64; + 1.0 - strength * step + }; + let value = if value.is_finite() { value.clamp(0.0, 1.0) } else { 1.0 }; + format!("{:.4}", value) + }) + .collect(); + format!("[{}]", factors.join(", ")) + } + + /// The `planes` list literal for DCTFilter. + pub fn dct_planes_literal(&self) -> &'static str { + match self.dct_planes { + 1 => "[1, 2]", + 2 => "[0, 1, 2]", + _ => "[0]", + } + } +} + +#[cfg(test)] +mod dct_tests { + use super::*; + + #[test] + fn test_default_factors_attenuate_only_the_top_bands() { + let p = DeblockParameters::default(); + let f = p.dct_factors_literal(); + // cutoff 5 leaves bands 0-5 alone and ramps 6 and 7 down. + assert!(f.starts_with("[1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,")); + assert!(f.ends_with("0.4000]"), "got {f}"); + } + + #[test] + fn test_zero_strength_is_an_exact_no_op() { + // All 1.0 is a verified bit-exact identity on integer formats, so a + // strength of zero must produce exactly that. + let p = DeblockParameters { dct_strength: 0.0, ..Default::default() }; + assert_eq!( + p.dct_factors_literal(), + "[1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000]" + ); + } + + #[test] + fn test_nan_strength_cannot_reach_the_plugin() { + // DCTFilter's own bounds check lets NaN through — nan < 0.0 and + // nan > 1.0 are both false — and a NaN factor silently blackens the + // whole frame with no error anywhere. + let p = DeblockParameters { dct_strength: f64::NAN, ..Default::default() }; + let f = p.dct_factors_literal(); + assert!(!f.to_lowercase().contains("nan"), "got {f}"); + assert_eq!( + f, + "[1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000]", + "a non-finite strength must fall back to the no-op curve" + ); + + for bad in [f64::INFINITY, f64::NEG_INFINITY] { + let p = DeblockParameters { dct_strength: bad, ..Default::default() }; + assert!(!p.dct_factors_literal().to_lowercase().contains("inf")); + } + } + + #[test] + fn test_every_factor_stays_inside_the_accepted_range() { + for cutoff in -3..=10 { + for strength in [-1.0, 0.0, 0.5, 1.0, 2.0] { + let p = DeblockParameters { + dct_cutoff: cutoff, + dct_strength: strength, + ..Default::default() + }; + let literal = p.dct_factors_literal(); + let values: Vec = literal + .trim_matches(|c| c == '[' || c == ']') + .split(", ") + .map(|v| v.parse().unwrap()) + .collect(); + assert_eq!(values.len(), 8, "DCTFilter requires exactly 8 factors"); + for v in values { + assert!((0.0..=1.0).contains(&v), "{v} out of range in {literal}"); + } + } } } + + #[test] + fn test_cutoff_seven_leaves_everything_alone() { + let p = DeblockParameters { dct_cutoff: 7, dct_strength: 1.0, ..Default::default() }; + assert_eq!( + p.dct_factors_literal(), + "[1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000]" + ); + } } diff --git a/worker/src/models/deflicker_parameters.rs b/worker/src/models/deflicker_parameters.rs new file mode 100644 index 00000000..09c45cb8 --- /dev/null +++ b/worker/src/models/deflicker_parameters.rs @@ -0,0 +1,129 @@ +//! Deflicker — remove brightness pulsing between frames. +//! +//! Two methods, because they address different faults and the measurements say +//! so. **Global** fits a gain and an offset per frame against a windowed +//! neighbourhood average: measured 83.5% of injected flicker removed, against +//! the local method's ~50%. **Local** damps per-region oscillation the global +//! one cannot see, because a whole-frame statistic cannot represent it. +//! +//! Global is the default. Cine-film flicker — the complaint this pass exists +//! for — is a whole-frame exposure fault. +//! +//! Note the plugin is deliberately unused. ReduceFlicker's non-SIMD path reads +//! the wrong neighbour frame, and its SIMD block is x86-only, so the ARM +//! bundles would have produced different pictures from the x86 ones. The local +//! method is a transcription validated against a numpy model of the C +//! semantics at zero levels of difference. + +use serde::{Deserialize, Serialize}; + +/// Which deflicker to run. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum DeflickerMethod { + /// Whole-frame exposure correction. Removes ~83% of global flicker. + #[default] + #[serde(rename = "global")] + Global, + /// Local oscillation damper, for flicker that varies across the frame. + #[serde(rename = "local")] + Local, +} + +/// Parameters for the deflicker pass. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeflickerParameters { + #[serde(default)] + pub enabled: bool, + + #[serde(default)] + pub method: DeflickerMethod, + + /// Global: how far the correction is applied, 0-1. + #[serde(default = "default_strength")] + pub strength: f64, + + /// Global: frames either side used for the reference average. + #[serde(default = "default_window")] + pub window: i32, + + /// Local: 1-3. Higher compares against more distant frames. + #[serde(default = "default_local_strength")] + pub local_strength: i32, + + /// Local: asymmetric fold, stronger but less conservative. + #[serde(default)] + pub aggressive: bool, +} + +fn default_strength() -> f64 { 1.0 } +fn default_window() -> i32 { 5 } +fn default_local_strength() -> i32 { 2 } + +impl Default for DeflickerParameters { + fn default() -> Self { + Self { + enabled: false, + method: DeflickerMethod::default(), + strength: default_strength(), + window: default_window(), + local_strength: default_local_strength(), + aggressive: false, + } + } +} + +impl DeflickerParameters { + /// Window clamped to what the module accepts; out of range raises. + pub fn effective_window(&self) -> i32 { + self.window.clamp(1, 12) + } + + /// Local strength clamped to 1-3. + pub fn effective_local_strength(&self) -> i32 { + self.local_strength.clamp(1, 3) + } + + /// Frames of temporal context needed either side, for preview windowing. + pub fn radius(&self) -> u32 { + match self.method { + DeflickerMethod::Global => self.effective_window() as u32, + // strength k compares against pairs up to +/-(k+1) + DeflickerMethod::Local => (self.effective_local_strength() + 1) as u32, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn global_is_the_default_because_it_removes_more() { + // 83.5% vs ~50% on injected whole-frame flicker, measured. Cine film + // flicker is a whole-frame exposure fault. + assert_eq!(DeflickerParameters::default().method, DeflickerMethod::Global); + } + + #[test] + fn out_of_range_values_are_clamped_rather_than_passed_through() { + // The module raises ValueError rather than clamping, so a saved job + // with a stale value would fail the whole encode. + let p = DeflickerParameters { window: 99, local_strength: 9, ..Default::default() }; + assert_eq!(p.effective_window(), 12); + assert_eq!(p.effective_local_strength(), 3); + let n = DeflickerParameters { window: 0, local_strength: 0, ..Default::default() }; + assert_eq!(n.effective_window(), 1); + assert_eq!(n.effective_local_strength(), 1); + } + + #[test] + fn radius_follows_the_method() { + let g = DeflickerParameters { window: 5, ..Default::default() }; + assert_eq!(g.radius(), 5); + let l = DeflickerParameters { + method: DeflickerMethod::Local, local_strength: 2, ..Default::default() + }; + assert_eq!(l.radius(), 3, "strength k reaches +/-(k+1)"); + } +} diff --git a/worker/src/models/dehalo_parameters.rs b/worker/src/models/dehalo_parameters.rs index 19cca5aa..3aa47376 100644 --- a/worker/src/models/dehalo_parameters.rs +++ b/worker/src/models/dehalo_parameters.rs @@ -22,6 +22,11 @@ pub enum DehaloMethod { /// Vinverse variant that preserves more vertical detail. #[serde(rename = "Vinverse2")] Vinverse2, + /// HQDeringmod: masked ring removal. Smooths the overshoot beside an edge + /// while protecting the edge itself, which is a different target from + /// dehalo's wider bright band. + #[serde(rename = "HQDeringmod")] + HqDeringmod, } #[allow(dead_code)] @@ -35,6 +40,7 @@ impl DehaloMethod { DehaloMethod::EdgeCleaner => "EdgeCleaner", DehaloMethod::Vinverse => "Vinverse", DehaloMethod::Vinverse2 => "Vinverse2", + DehaloMethod::HqDeringmod => "HQDeringmod", } } @@ -174,6 +180,29 @@ pub struct DehaloParameters { /// Process chroma as well as luma (havsfunc `chroma`). #[serde(default)] pub vinverse_chroma: Option, + + // --- HQDeringmod parameters --- + + /// Ring mask radius. 1 is the default; 2 catches wider rings. + #[serde(default)] + pub dering_mrad: Option, + + /// Mask smoothing radius, which softens where the mask takes effect. + #[serde(default)] + pub dering_msmooth: Option, + + /// Edge-mask threshold (0-255). Lower finds more edges to protect. + #[serde(default)] + pub dering_mthr: Option, + + /// Limit on how far a pixel may be changed, in 8-bit levels. + #[serde(default)] + pub dering_thr: Option, + + /// Separate limit for the dark side of an edge; defaults to `thr / 4` + /// inside havsfunc, which is usually what you want. + #[serde(default)] + pub dering_darkthr: Option, } fn default_rx() -> f64 { 2.0 } @@ -214,6 +243,11 @@ impl Default for DehaloParameters { vinverse_strength: None, vinverse_amount: None, vinverse_chroma: None, + dering_mrad: None, + dering_msmooth: None, + dering_mthr: None, + dering_thr: None, + dering_darkthr: None, } } } diff --git a/worker/src/models/edge_repair_parameters.rs b/worker/src/models/edge_repair_parameters.rs new file mode 100644 index 00000000..ee7a5039 --- /dev/null +++ b/worker/src/models/edge_repair_parameters.rs @@ -0,0 +1,110 @@ +//! Edge Repair — rebuild the dirty rows and columns at the frame border. +//! +//! Near-universal on tape captures, and today the only remedy is to crop them +//! away, which throws picture away with them. +//! +//! FillBorders won a measured three-way against EdgeFixer and bbmod. Its error +//! is **constant at 1.29 across three different damage models** — the signature +//! of a filter that discards the border entirely and rebuilds from the interior. +//! That is exactly the property a non-expert control needs: the result does not +//! depend on how bad the damage is, so it cannot fail badly. EdgeFixer beat it +//! in one case by 0.38/255 and lost catastrophically in another (30.81 at the +//! wrong radius), and is luma-only besides — it leaves the coloured fringe that +//! makes a tape edge obvious. bbmod lost every case and costs 19 parameters. +//! +//! **Widths are even, and that is load-bearing.** The bundle pins FillBorders +//! v2, the newest tag with published binaries. v2 and v4 are bit-identical at +//! even widths; they differ only at odd widths, where v2 leaves subsampled +//! chroma unrepaired. `crop_resize` already steps every crop control by 2 for +//! the same chroma-alignment reason, so the constraint costs nothing. + +use serde::{Deserialize, Serialize}; + +/// Parameters for the edge repair pass. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EdgeRepairParameters { + #[serde(default)] + pub enabled: bool, + + #[serde(default)] + pub left: i32, + #[serde(default)] + pub right: i32, + #[serde(default)] + pub top: i32, + #[serde(default)] + pub bottom: i32, + + /// `fillmargins` or `repeat` or `mirror`. Advanced: the measured difference + /// between the first two is 1.29 against 1.29. + #[serde(default = "default_mode")] + pub mode: String, +} + +fn default_mode() -> String { "fillmargins".to_string() } + +impl EdgeRepairParameters { + /// Each edge rounded down to an even count and bounded. + /// + /// Even is what makes v2 bit-identical to v4 on subsampled chroma; the + /// bound stops a value from a stale saved job eating the picture. + pub fn even(value: i32) -> i32 { + (value.clamp(0, 64) / 2) * 2 + } + + /// True when at least one edge would actually be repaired. + pub fn has_effect(&self) -> bool { + self.enabled + && [self.left, self.right, self.top, self.bottom] + .iter() + .any(|v| Self::even(*v) > 0) + } + + /// Only the modes the plugin accepts; anything else falls back to the + /// default rather than reaching the plugin as a broken argument. + pub fn effective_mode(&self) -> &str { + match self.mode.as_str() { + "repeat" | "mirror" | "fillmargins" => self.mode.as_str(), + _ => "fillmargins", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn widths_are_forced_even() { + // Odd widths are the only case where the pinned v2 differs from v4: + // it leaves subsampled chroma unrepaired there. + assert_eq!(EdgeRepairParameters::even(3), 2); + assert_eq!(EdgeRepairParameters::even(1), 0); + assert_eq!(EdgeRepairParameters::even(4), 4); + } + + #[test] + fn widths_are_bounded_and_never_negative() { + assert_eq!(EdgeRepairParameters::even(-8), 0); + assert_eq!(EdgeRepairParameters::even(9999), 64); + } + + #[test] + fn enabled_with_every_edge_zero_does_nothing() { + // Otherwise the pass row claims to be doing something it is not. + let p = EdgeRepairParameters { enabled: true, ..Default::default() }; + assert!(!p.has_effect()); + let p = EdgeRepairParameters { enabled: true, top: 2, ..Default::default() }; + assert!(p.has_effect()); + // An odd 1 rounds to 0, so it also has no effect. + let p = EdgeRepairParameters { enabled: true, top: 1, ..Default::default() }; + assert!(!p.has_effect()); + } + + #[test] + fn an_unknown_mode_falls_back_rather_than_reaching_the_plugin() { + let p = EdgeRepairParameters { mode: "nonsense".into(), ..Default::default() }; + assert_eq!(p.effective_mode(), "fillmargins"); + } +} diff --git a/worker/src/models/frame_rate_parameters.rs b/worker/src/models/frame_rate_parameters.rs new file mode 100644 index 00000000..051a5d92 --- /dev/null +++ b/worker/src/models/frame_rate_parameters.rs @@ -0,0 +1,211 @@ +//! Frame rate conversion (MVTools FlowFPS). +//! +//! Deliberately scoped to **standards conversion**, not smoothing. Interpolating +//! a 25p master to 60p invents frames that were never photographed and makes the +//! file a worse record than the tape it came from — squarely against what this +//! app is for. Converting an already-converted PAL/NTSC tape is the opposite +//! case: the damage is already in the source, and doing nothing is not neutral, +//! it means duplication judder or a 4% speed error. +//! +//! So this offers named target rates rather than a free number, is off by +//! default, and is never described as making motion "smooth". +//! +//! FlowFPS rather than BlockFPS: measured over 35 input-length/ratio +//! combinations, FlowFPS's output count matches `FrameMap::Retime::output_count` +//! (`n * num / den`) **exactly**, while BlockFPS is off by one in 14 of them +//! because it produces `floor((n-1) * r) + 1`. Choosing FlowFPS makes the +//! existing FrameMap variant correct as written. + +use serde::{Deserialize, Serialize}; + +/// A target frame rate, as a rational so the FrameMap ratio stays exact. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum FrameRateTarget { + /// 25 fps — PAL/SECAM. + #[default] + #[serde(rename = "pal25")] + Pal25, + /// 30000/1001 — NTSC. + #[serde(rename = "ntsc2997")] + Ntsc2997, + /// 24000/1001 — film after 3:2 removal. + #[serde(rename = "film23976")] + Film23976, + /// 24 fps — true film. + #[serde(rename = "film24")] + Film24, + /// 50 fps — PAL double rate. + #[serde(rename = "pal50")] + Pal50, + /// 60000/1001 — NTSC double rate. + #[serde(rename = "ntsc5994")] + Ntsc5994, +} + +impl FrameRateTarget { + /// Target rate as (numerator, denominator). + pub fn as_fraction(self) -> (u32, u32) { + match self { + FrameRateTarget::Pal25 => (25, 1), + FrameRateTarget::Ntsc2997 => (30000, 1001), + FrameRateTarget::Film23976 => (24000, 1001), + FrameRateTarget::Film24 => (24, 1), + FrameRateTarget::Pal50 => (50, 1), + FrameRateTarget::Ntsc5994 => (60000, 1001), + } + } +} + +/// How the new frames are produced. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum FrameRateMethod { + /// Motion-compensated interpolation. Measured 0.098 mean abs diff against + /// independently rendered ground truth on a pan, against 1.269 for + /// duplication — but it warps around occlusion boundaries when it fails. + #[default] + #[serde(rename = "flowFps")] + FlowFps, + /// Duplicate or drop whole frames. No invented pixels, visible judder. + /// The honest choice for an archival master. + #[serde(rename = "duplicate")] + Duplicate, +} + +/// Parameters for the frame rate pass. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FrameRateParameters { + #[serde(default)] + pub enabled: bool, + + #[serde(default)] + pub target: FrameRateTarget, + + #[serde(default)] + pub method: FrameRateMethod, + + /// Motion-estimation block size. Larger is faster and blockier. + #[serde(default = "default_block_size")] + pub block_size: i32, + + /// Block overlap. Must be less than block_size and even. + #[serde(default = "default_overlap")] + pub overlap: i32, + + /// The source rate, supplied by the app from ffprobe. The pipeline needs it + /// to report a correct `FrameMap::Retime`, and cannot see the job — the + /// same reason `inputSar` is carried rather than looked up. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_fps_num: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_fps_den: Option, +} + +fn default_block_size() -> i32 { 16 } +fn default_overlap() -> i32 { 8 } + +impl Default for FrameRateParameters { + fn default() -> Self { + Self { + enabled: false, + target: FrameRateTarget::default(), + method: FrameRateMethod::default(), + block_size: default_block_size(), + overlap: default_overlap(), + source_fps_num: None, + source_fps_den: None, + } + } +} + +impl FrameRateParameters { + /// Overlap clamped to what mvtools accepts: even, and strictly less than + /// half the block size. Out of range is a hard error at script evaluation, + /// not a clamp, so it has to happen here. + pub fn effective_overlap(&self) -> i32 { + let max = (self.block_size / 2).max(0); + (self.overlap.clamp(0, max) / 2) * 2 + } + + /// The retime ratio against a source rate, reduced. + /// + /// Reduced matters: 25 → 29.97 is 1200/1001, not the plugin's + /// `num=30000, den=1001`. `FrameMap::Retime` multiplies a frame count by + /// this, so an unreduced pair overflows on long clips. + pub fn ratio_against(&self, src_num: u32, src_den: u32) -> Option<(u32, u32)> { + if src_num == 0 || src_den == 0 { + return None; + } + let (t_num, t_den) = self.as_fraction(); + // (t_num/t_den) / (src_num/src_den) = (t_num*src_den) / (t_den*src_num) + let num = (t_num as u64) * (src_den as u64); + let den = (t_den as u64) * (src_num as u64); + let g = gcd(num, den); + Some(((num / g) as u32, (den / g) as u32)) + } + + fn as_fraction(&self) -> (u32, u32) { + self.target.as_fraction() + } + + /// The retime ratio using the carried source rate, if the app supplied one. + pub fn ratio(&self) -> Option<(u32, u32)> { + self.ratio_against(self.source_fps_num?, self.source_fps_den?) + } +} + +fn gcd(a: u64, b: u64) -> u64 { + if b == 0 { a.max(1) } else { gcd(b, a % b) } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn params(target: FrameRateTarget) -> FrameRateParameters { + FrameRateParameters { target, ..Default::default() } + } + + #[test] + fn ratios_are_reduced() { + // 25 -> 29.97 is 1200/1001, NOT 30000/1001. FrameMap multiplies a frame + // count by this pair, so leaving it unreduced overflows on long clips. + assert_eq!(params(FrameRateTarget::Ntsc2997).ratio_against(25, 1), Some((1200, 1001))); + // 23.976 -> 25 reduces to 25025/24000 -> 1001/960. + assert_eq!(params(FrameRateTarget::Pal25).ratio_against(24000, 1001), Some((1001, 960))); + } + + #[test] + fn converting_to_the_same_rate_is_the_identity() { + assert_eq!(params(FrameRateTarget::Pal25).ratio_against(25, 1), Some((1, 1))); + } + + #[test] + fn output_counts_match_what_flowfps_produces() { + // Measured against the real plugin: FlowFPS emits floor(n_in * r), which + // is exactly what FrameMap::Retime computes. 97 frames of 25 fps to + // 29.97 gave 116; to 23.976 from 25 gave 93. + use crate::models::FrameMap; + let r = params(FrameRateTarget::Ntsc2997).ratio_against(25, 1).unwrap(); + let map = FrameMap::Retime { num: r.0, den: r.1, synthesizes: true, radius: 1 }; + assert_eq!(map.output_count(97), 116); + } + + #[test] + fn a_zero_source_rate_is_rejected_rather_than_dividing_by_zero() { + assert_eq!(params(FrameRateTarget::Pal25).ratio_against(0, 1), None); + assert_eq!(params(FrameRateTarget::Pal25).ratio_against(25, 0), None); + } + + #[test] + fn overlap_is_clamped_to_what_mvtools_accepts() { + // mvtools errors rather than clamping, so an out-of-range value from a + // saved job would fail the whole encode. + let p = FrameRateParameters { block_size: 16, overlap: 99, ..Default::default() }; + assert_eq!(p.effective_overlap(), 8); + let odd = FrameRateParameters { block_size: 16, overlap: 5, ..Default::default() }; + assert_eq!(odd.effective_overlap(), 4, "overlap must be even"); + let neg = FrameRateParameters { block_size: 16, overlap: -4, ..Default::default() }; + assert_eq!(neg.effective_overlap(), 0); + } +} diff --git a/worker/src/models/geometry_parameters.rs b/worker/src/models/geometry_parameters.rs new file mode 100644 index 00000000..58ba9860 --- /dev/null +++ b/worker/src/models/geometry_parameters.rs @@ -0,0 +1,229 @@ +//! Rotation and flip parameters. +//! +//! Ordinary geometry the app had no way to do: sideways phone footage, mirrored +//! camcorder captures, film scans that came off the scanner rotated. +//! +//! Everything here is `core.std`, so there is no plugin dependency and no +//! bit-depth limit — the operations move samples without touching their values. +//! +//! Runs BEFORE Crop/Resize, because a quarter turn swaps width and height and +//! every later decision about framing and aspect depends on which way round the +//! frame is. + +use serde::{Deserialize, Serialize}; + +/// Quarter-turn rotation, clockwise. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum Rotation { + #[default] + None, + /// 90° clockwise. Swaps width and height. + Cw90, + /// 180°. Keeps the frame shape. + Rotate180, + /// 90° anticlockwise (270° clockwise). Swaps width and height. + Ccw90, +} + +impl Rotation { + /// The `core.std` function implementing this rotation, if any. + pub fn function(&self) -> Option<&'static str> { + match self { + Rotation::None => None, + Rotation::Cw90 => Some("Turn90"), + Rotation::Rotate180 => Some("Turn180"), + Rotation::Ccw90 => Some("Turn270"), + } + } + + /// Whether this rotation exchanges width and height. + /// + /// This is what makes the pass more than cosmetic: a quarter turn changes + /// the frame's shape, so a non-square sample aspect no longer describes it + /// and every later framing decision sees different dimensions. + pub fn swaps_axes(&self) -> bool { + matches!(self, Rotation::Cw90 | Rotation::Ccw90) + } +} + +/// Parameters for the rotate/flip pass. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GeometryParameters { + /// Whether this pass is enabled. + #[serde(default)] + pub enabled: bool, + + /// Quarter-turn rotation. + #[serde(default)] + pub rotation: Rotation, + + /// Mirror left-to-right. Applied after the rotation. + #[serde(default)] + pub flip_horizontal: bool, + + /// Mirror top-to-bottom. Applied after the rotation. + #[serde(default)] + pub flip_vertical: bool, +} + +impl Default for GeometryParameters { + fn default() -> Self { + Self { + enabled: false, + rotation: Rotation::default(), + flip_horizontal: false, + flip_vertical: false, + } + } +} + +impl GeometryParameters { + /// Whether the pass would actually change anything. + /// + /// Enabled with no rotation and no flip is a no-op, and emitting nothing for + /// it keeps the generated script honest about what runs. + pub fn has_effect(&self) -> bool { + self.enabled + && (self.rotation != Rotation::None || self.flip_horizontal || self.flip_vertical) + } + + /// Whether this pass exchanges the frame's width and height. + pub fn swaps_axes(&self) -> bool { + self.enabled && self.rotation.swaps_axes() + } + + /// The source's sample aspect as it applies *after* this pass. + /// + /// SAR is the ratio of a pixel's width to its height, so a quarter turn + /// inverts it: the 10:11 of a PAL DVD becomes 11:10 once the frame is on its + /// side. The Y4M pipe strips aspect metadata, so the worker re-declares it to + /// ffmpeg from this value — and declaring the un-inverted ratio would stretch + /// a rotated anamorphic source by the square of its own aspect. + /// + /// A half turn and the flips leave pixel shape alone. + pub fn adjusted_sar(&self, input_sar: Option<&str>) -> Option { + let sar = input_sar?.trim(); + if !self.swaps_axes() { + return Some(sar.to_string()); + } + // Anything that is not a ratio is passed through untouched rather than + // dropped: losing the declaration entirely would leave ffmpeg with no + // aspect at all, which is worse than leaving an odd one alone. + let Some((num, den)) = sar.split_once(':').or_else(|| sar.split_once('/')) else { + return Some(sar.to_string()); + }; + let (num, den) = (num.trim(), den.trim()); + // Only invert something that actually parses as a ratio; anything else + // is passed through untouched rather than turned into a broken argument. + if num.parse::().is_err() || den.parse::().is_err() { + return Some(sar.to_string()); + } + Some(format!("{}:{}", den, num)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_defaults_are_a_no_op() { + let p = GeometryParameters::default(); + assert!(!p.enabled); + assert!(!p.has_effect()); + assert!(!p.swaps_axes()); + } + + #[test] + fn test_enabled_but_unset_is_still_a_no_op() { + // Turning the pass on without choosing anything must not emit a + // rotation call — the script should say what actually runs. + let p = GeometryParameters { + enabled: true, + ..Default::default() + }; + assert!(!p.has_effect()); + } + + #[test] + fn test_quarter_turns_swap_axes_and_half_turns_do_not() { + let with = |r: Rotation| GeometryParameters { + enabled: true, + rotation: r, + ..Default::default() + }; + assert!(with(Rotation::Cw90).swaps_axes()); + assert!(with(Rotation::Ccw90).swaps_axes()); + assert!(!with(Rotation::Rotate180).swaps_axes()); + assert!(!with(Rotation::None).swaps_axes()); + } + + #[test] + fn test_a_disabled_pass_never_reports_swapped_axes() { + // The aspect logic keys off this, so a disabled pass claiming a swap + // would mis-shape the output. + let p = GeometryParameters { + enabled: false, + rotation: Rotation::Cw90, + ..Default::default() + }; + assert!(!p.swaps_axes()); + } + + #[test] + fn test_rotation_functions() { + assert_eq!(Rotation::None.function(), None); + assert_eq!(Rotation::Cw90.function(), Some("Turn90")); + assert_eq!(Rotation::Rotate180.function(), Some("Turn180")); + assert_eq!(Rotation::Ccw90.function(), Some("Turn270")); + } + + #[test] + fn test_sar_inverts_on_a_quarter_turn() { + // SAR is pixel width : height, so turning the frame on its side swaps + // them. Declaring the un-inverted ratio would stretch a rotated + // anamorphic source by the square of its own aspect. + let turned = GeometryParameters { + enabled: true, + rotation: Rotation::Cw90, + ..Default::default() + }; + assert_eq!(turned.adjusted_sar(Some("10:11")).as_deref(), Some("11:10")); + assert_eq!(turned.adjusted_sar(Some("16/11")).as_deref(), Some("11:16")); + assert_eq!(turned.adjusted_sar(None), None); + } + + #[test] + fn test_sar_is_untouched_by_half_turns_flips_and_a_disabled_pass() { + let cases = [ + GeometryParameters { enabled: true, rotation: Rotation::Rotate180, ..Default::default() }, + GeometryParameters { enabled: true, flip_horizontal: true, ..Default::default() }, + GeometryParameters { enabled: true, flip_vertical: true, ..Default::default() }, + GeometryParameters { enabled: false, rotation: Rotation::Cw90, ..Default::default() }, + ]; + for p in cases { + assert_eq!(p.adjusted_sar(Some("10:11")).as_deref(), Some("10:11")); + } + } + + #[test] + fn test_an_unparseable_sar_is_passed_through_not_mangled() { + let turned = GeometryParameters { + enabled: true, + rotation: Rotation::Ccw90, + ..Default::default() + }; + assert_eq!(turned.adjusted_sar(Some("garbage")).as_deref(), Some("garbage")); + assert_eq!(turned.adjusted_sar(Some("a:b")).as_deref(), Some("a:b")); + } + + #[test] + fn test_serialization() { + let json = serde_json::to_string(&GeometryParameters::default()).unwrap(); + assert!(json.contains("\"enabled\":false")); + assert!(json.contains("\"rotation\":\"none\"")); + assert!(json.contains("\"flipHorizontal\":false")); + } +} diff --git a/worker/src/models/ghost_removal_parameters.rs b/worker/src/models/ghost_removal_parameters.rs new file mode 100644 index 00000000..152f83cc --- /dev/null +++ b/worker/src/models/ghost_removal_parameters.rs @@ -0,0 +1,112 @@ +//! Ghost Removal (LGhost) — remove the displaced echo of the picture that RF +//! and cable distribution leave behind. +//! +//! Nothing else in the app addresses ghosting, and it is a distinct, frequently +//! reported tape complaint. +//! +//! The cleanest plugin probed for this work: **no format limits found at all** +//! across 8/10/12/16-bit, 4:2:0/4:2:2/4:4:4, GRAY and float, and a clean +//! `_FieldBased` matrix. Its natural interface is a repeating +//! `(mode, shift, intensity)` triple, which is unlike any control in this app — +//! so a short preset list is offered in simple mode and the triple editor sits +//! behind advanced. +//! +//! `opt` is deliberately not exposed: on arm64 every value produces +//! byte-identical output, so it is inert here and a footgun on x86. + +use serde::{Deserialize, Serialize}; + +/// A single ghost to cancel. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GhostSpec { + /// 1 = edge, 2 = luminance, 3 = rising edge, 4 = falling edge. + /// 0 is rejected by the plugin. + pub mode: i32, + /// Horizontal displacement in pixels; must be less than the frame width. + pub shift: i32, + /// -128..127, and never zero — the plugin rejects zero. + pub intensity: i32, +} + +/// Parameters for the ghost removal pass. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GhostRemovalParameters { + #[serde(default)] + pub enabled: bool, + + /// The ghosts to cancel. Empty means the pass does nothing. + #[serde(default)] + pub ghosts: Vec, +} + +impl GhostRemovalParameters { + /// Only the specs the plugin will accept. + /// + /// It rejects `mode == 0` and `intensity == 0` at script evaluation rather + /// than ignoring them, and requires the three arrays to be the same length + /// — so an unusable entry has to be dropped here, not passed through. + pub fn valid_ghosts(&self) -> Vec<&GhostSpec> { + self.ghosts + .iter() + .filter(|g| (1..=4).contains(&g.mode) && g.intensity != 0 + && (-128..=127).contains(&g.intensity)) + .collect() + } + + pub fn has_effect(&self) -> bool { + self.enabled && !self.valid_ghosts().is_empty() + } + + /// The three parallel array literals the plugin wants. + pub fn literals(&self) -> (String, String, String) { + let g = self.valid_ghosts(); + let join = |v: Vec| format!("[{}]", v.join(", ")); + ( + join(g.iter().map(|x| x.mode.to_string()).collect()), + join(g.iter().map(|x| x.shift.to_string()).collect()), + join(g.iter().map(|x| x.intensity.to_string()).collect()), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn g(mode: i32, shift: i32, intensity: i32) -> GhostSpec { + GhostSpec { mode, shift, intensity } + } + + #[test] + fn entries_the_plugin_rejects_are_dropped_not_forwarded() { + // mode 0 and intensity 0 are hard errors at script evaluation. + let p = GhostRemovalParameters { + enabled: true, + ghosts: vec![g(0, 4, 20), g(2, 4, 0), g(1, 4, 20), g(9, 4, 20)], + }; + assert_eq!(p.valid_ghosts().len(), 1); + assert_eq!(p.literals(), ("[1]".into(), "[4]".into(), "[20]".into())); + } + + #[test] + fn the_three_arrays_always_match_in_length() { + // The plugin errors if they do not, so filtering must be simultaneous. + let p = GhostRemovalParameters { + enabled: true, + ghosts: vec![g(1, 4, 20), g(0, 9, 30), g(3, -6, -15)], + }; + let (m, s, i) = p.literals(); + assert_eq!(m, "[1, 3]"); + assert_eq!(s, "[4, -6]"); + assert_eq!(i, "[20, -15]"); + } + + #[test] + fn enabled_with_no_usable_ghost_does_nothing() { + let p = GhostRemovalParameters { enabled: true, ghosts: vec![g(0, 0, 0)] }; + assert!(!p.has_effect()); + assert!(!GhostRemovalParameters { enabled: true, ghosts: vec![] }.has_effect()); + } +} diff --git a/worker/src/models/grain_parameters.rs b/worker/src/models/grain_parameters.rs new file mode 100644 index 00000000..f82dc82f --- /dev/null +++ b/worker/src/models/grain_parameters.rs @@ -0,0 +1,228 @@ +//! Film grain parameters. +//! +//! Re-adds grain after denoising, so a cleaned picture does not look plastic, +//! and masks the banding a shallow gradient shows once the noise that was +//! dithering it has been removed. Measured on a shallow 8-bit ramp: columns +//! showing any variation go from 0% to 100% even at the lowest useful strength. +//! +//! Runs LAST among the video passes. Grain added before a resize is resampled +//! away, and before a deband is smoothed away, so anything else would quietly +//! undo it. +//! +//! Neither method needs a bit-depth guard or 8-bit parameter scaling — verified +//! against the bundle at 8/10/12/16-bit and 4:2:2. `var` is already expressed in +//! 8-bit units and the plugin scales it internally, so applying the +//! `_levels_8bit()` treatment used elsewhere would quadruple the grain at +//! 10-bit. + +use serde::{Deserialize, Serialize}; + +/// Grain generation method. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum GrainMethod { + /// `core.grain.Add` — one strength control, grains luma and chroma, and can + /// be static or animated. + #[default] + AddGrain, + /// havsfunc `GrainFactory3` — three grain layers selected by luma level, so + /// shadows get more grain than highlights. Closer to real film stock. + /// + /// Two limitations, both measured: it is **luma-only** (chroma comes back + /// bit-identical), and it is **always animated** with no way to hold the + /// pattern still. + GrainFactory3, +} + +/// Parameters for the grain pass. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GrainParameters { + /// Whether this pass is enabled. + #[serde(default)] + pub enabled: bool, + + /// Which method to use. + #[serde(default)] + pub method: GrainMethod, + + // --- AddGrain --- + + /// Luma grain strength as a variance: the standard deviation of the noise + /// is its square root, so 4 gives σ=2. 0 disables the pass entirely. + #[serde(default = "default_var")] + pub var: f64, + + /// Chroma grain strength, same units. 0 leaves chroma bit-identical. + #[serde(default)] + pub uvar: f64, + + /// Spatial correlation (0.0-0.9), which makes the grain coarser and more + /// film-like. It also *reduces amplitude*, by + /// `sqrt((1-c)/(1+c))` per axis — measured, not assumed. + #[serde(default)] + pub corr: f64, + + /// Hold the same grain pattern on every frame. Off by default: static grain + /// over moving video reads as dirt on the lens. + #[serde(default)] + pub constant: bool, + + // --- GrainFactory3 --- + + /// Grain strength in the shadows. + #[serde(default = "default_g1")] + pub g1str: f64, + + /// Grain strength in the midtones. + #[serde(default = "default_g2")] + pub g2str: f64, + + /// Grain strength in the highlights. + #[serde(default = "default_g3")] + pub g3str: f64, + + /// Blends the grain with a 3-frame average, damping the animation. 0-100. + #[serde(default)] + pub temp_avg: i32, +} + +fn default_var() -> f64 { 4.0 } +fn default_g1() -> f64 { 4.0 } +fn default_g2() -> f64 { 3.0 } +fn default_g3() -> f64 { 2.0 } + +impl Default for GrainParameters { + fn default() -> Self { + Self { + enabled: false, + method: GrainMethod::default(), + var: default_var(), + uvar: 0.0, + corr: 0.0, + constant: false, + g1str: default_g1(), + g2str: default_g2(), + g3str: default_g3(), + temp_avg: 0, + } + } +} + +impl GrainParameters { + /// Correlation clamped below 1.0. + /// + /// The plugin rejects anything outside 0.0-1.0 outright, and 1.0 itself is + /// degenerate — it wraps back to uncorrelated noise at full amplitude, so a + /// user dragging the slider to the top would get the opposite of what the + /// control implies. Capped at 0.9, which is where it still behaves. + pub fn effective_corr(&self) -> f64 { + self.corr.clamp(0.0, 0.9) + } + + /// Temporal averaging, clamped to the 0-100 havsfunc accepts. + pub fn effective_temp_avg(&self) -> i32 { + self.temp_avg.clamp(0, 100) + } + + /// Whether the pass would actually change the picture. + /// + /// AddGrain with both strengths at zero is a measured no-op, so it emits + /// nothing rather than an identity call. + pub fn has_effect(&self) -> bool { + if !self.enabled { + return false; + } + match self.method { + GrainMethod::AddGrain => self.var > 0.0 || self.uvar > 0.0, + GrainMethod::GrainFactory3 => { + self.g1str > 0.0 || self.g2str > 0.0 || self.g3str > 0.0 + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_defaults() { + let p = GrainParameters::default(); + assert!(!p.enabled); + assert_eq!(p.method, GrainMethod::AddGrain); + // sigma = sqrt(var), so 4.0 is a subtle sigma of 2. + assert_eq!(p.var, 4.0); + assert_eq!(p.uvar, 0.0); + assert!(!p.constant, "grain should animate by default"); + } + + #[test] + fn test_correlation_is_capped_below_the_degenerate_value() { + // corr = 1.0 is accepted by the plugin but wraps back to uncorrelated + // noise at full amplitude — the opposite of what the control implies. + let with = |c: f64| GrainParameters { corr: c, ..Default::default() }; + assert_eq!(with(0.0).effective_corr(), 0.0); + assert_eq!(with(0.5).effective_corr(), 0.5); + assert_eq!(with(1.0).effective_corr(), 0.9); + assert_eq!(with(-2.0).effective_corr(), 0.0); + } + + #[test] + fn test_zero_strength_is_a_no_op_and_emits_nothing() { + let silent = GrainParameters { + enabled: true, + var: 0.0, + uvar: 0.0, + ..Default::default() + }; + assert!(!silent.has_effect()); + + // Chroma-only grain still counts as an effect. + let chroma_only = GrainParameters { + enabled: true, + var: 0.0, + uvar: 2.0, + ..Default::default() + }; + assert!(chroma_only.has_effect()); + } + + #[test] + fn test_grainfactory3_effect_keys_off_its_own_strengths() { + let p = GrainParameters { + enabled: true, + method: GrainMethod::GrainFactory3, + var: 0.0, + uvar: 0.0, + g1str: 4.0, + ..Default::default() + }; + assert!(p.has_effect(), "AddGrain's var must not gate GrainFactory3"); + + let silent = GrainParameters { + enabled: true, + method: GrainMethod::GrainFactory3, + g1str: 0.0, + g2str: 0.0, + g3str: 0.0, + ..Default::default() + }; + assert!(!silent.has_effect()); + } + + #[test] + fn test_temp_avg_is_clamped() { + let with = |t: i32| GrainParameters { temp_avg: t, ..Default::default() }; + assert_eq!(with(-5).effective_temp_avg(), 0); + assert_eq!(with(50).effective_temp_avg(), 50); + assert_eq!(with(500).effective_temp_avg(), 100); + } + + #[test] + fn test_serialization() { + let json = serde_json::to_string(&GrainParameters::default()).unwrap(); + assert!(json.contains("\"method\":\"addGrain\"")); + assert!(json.contains("\"var\":4.0")); + } +} diff --git a/worker/src/models/mod.rs b/worker/src/models/mod.rs index 2a5c1867..982f1281 100644 --- a/worker/src/models/mod.rs +++ b/worker/src/models/mod.rs @@ -2,34 +2,52 @@ //! These must serialize to/from JSON compatibly with the Dart equivalents. mod video_job; +mod color_metadata; mod qtgmc_parameters; mod progress_info; mod noise_reduction_parameters; mod color_correction_parameters; mod chroma_denoise_parameters; +mod anti_alias_parameters; mod chroma_fix_parameters; mod crop_resize_parameters; mod dehalo_parameters; +mod geometry_parameters; +mod grain_parameters; +mod frame_rate_parameters; +mod deflicker_parameters; +mod edge_repair_parameters; +mod ghost_removal_parameters; mod deblock_parameters; mod deband_parameters; mod descratch_parameters; mod spotless_parameters; +mod stabilize_parameters; mod sharpen_parameters; mod processing_pipeline; mod dvd_info; pub use video_job::*; +pub use color_metadata::*; pub use qtgmc_parameters::*; pub use progress_info::*; pub use noise_reduction_parameters::*; pub use color_correction_parameters::*; pub use chroma_denoise_parameters::*; +pub use anti_alias_parameters::*; pub use chroma_fix_parameters::*; pub use crop_resize_parameters::*; pub use dehalo_parameters::*; +pub use geometry_parameters::*; +pub use grain_parameters::*; +pub use frame_rate_parameters::*; +pub use deflicker_parameters::*; +pub use edge_repair_parameters::*; +pub use ghost_removal_parameters::*; pub use deblock_parameters::*; pub use deband_parameters::*; pub use descratch_parameters::*; +pub use stabilize_parameters::*; pub use spotless_parameters::*; pub use sharpen_parameters::*; pub use processing_pipeline::*; diff --git a/worker/src/models/noise_reduction_parameters.rs b/worker/src/models/noise_reduction_parameters.rs index e083fd4c..4df57b8f 100644 --- a/worker/src/models/noise_reduction_parameters.rs +++ b/worker/src/models/noise_reduction_parameters.rs @@ -13,6 +13,30 @@ pub enum NoiseReductionMethod { /// the motion match is good and blurs where it is poor. McDegrainSharp, QtgmcBuiltin, + /// Frequency-domain (DFT) denoiser. Very clean on fine, even grain. + DfTtest, + /// Classic 3D FFT spatio-temporal denoiser. Fast and aggressive. + Fft3dFilter, + /// Motion-adaptive temporal smoother. Very gentle — a finishing pass. + TTempSmooth, + /// FluxSmooth temporal-only: averages a pixel with its neighbours in time, + /// but only where they bracket it in value. Cheap and motion-safe. + FluxSmoothT, + /// FluxSmooth spatio-temporal: as above plus the eight spatial neighbours. + FluxSmoothSt, + /// STPresso (havsfunc): limits how far any pixel may move, so detail + /// survives almost intact. Calls FluxSmoothT internally. + StPresso, + /// Constant-time median filter. Large-radius median for blotches and + /// impulse noise, at a cost that barely moves with radius. + Ctmf, + /// mClean: an opinionated all-in-one — denoise, then restore detail and + /// grain so the result does not look plastic. The only candidate that is a + /// goal rather than a mechanism, which is why it gets the last simple slot. + MClean, + /// TemporalDegrain2: the heavyweight multi-pass motion-compensated + /// denoiser. Slow (about 21 fps) and the most capable thing here. + TemporalDegrain2, } /// Noise reduction preset levels. @@ -35,6 +59,54 @@ pub struct NoiseReductionParameters { #[serde(default)] pub enabled: bool, + /// Restore the fine detail the denoiser removed, by bracketing the pass + /// with havsfunc's ContraSharpening. Not a Sharpen method: it needs the + /// pre- and post-denoise clip, so it cannot sit in the linear chain. + #[serde(default)] + pub contra_sharpen: bool, + + // ---- mClean ----------------------------------------------------------- + /// Overall denoise strength, 0-20. + #[serde(default = "default_mclean_strength")] + pub mclean_strength: i32, + /// Detail restore, 0-20 (21-24 overboosts). + #[serde(default = "default_mclean_sharp")] + pub mclean_sharp: i32, + /// Grain restore, 0-20. + #[serde(default = "default_mclean_rn")] + pub mclean_rn: i32, + /// Motion search threshold. + #[serde(default = "default_mclean_thsad")] + pub mclean_thsad: i32, + /// Also denoise chroma. + #[serde(default = "default_true_nr")] + pub mclean_chroma: bool, + + // ---- TemporalDegrain2 ------------------------------------------------- + /// Temporal radius, 1-3. + #[serde(default = "default_td2_tr")] + pub td2_degrain_tr: i32, + /// How noisy the source is, -2..3. + #[serde(default = "default_td2_grain_level")] + pub td2_grain_level: i32, + /// Post-processing denoiser: 0 none, 1-3 progressively stronger. + /// Clamped in the module too — 4 and 5 abort the process. + #[serde(default)] + pub td2_post_fft: i32, + /// Post-processing strength. + #[serde(default = "default_td2_post_sigma")] + pub td2_post_sigma: f64, + /// Blend the post-processed result back, 0-100. + #[serde(default)] + pub td2_post_mix: i32, + /// Use chroma for motion estimation. + #[serde(default = "default_true_nr")] + pub td2_chroma_motion: bool, + + /// ContraSharpening's Repair mode. 13 is havsfunc's own default. + #[serde(default = "default_contra_sharpen_rep")] + pub contra_sharpen_rep: i32, + /// Preset level for simple mode. #[serde(default)] pub preset: NoiseReductionPreset, @@ -115,6 +187,88 @@ pub struct NoiseReductionParameters { /// EZKeepGrain amount (0.0 to 1.0). #[serde(default)] pub qtgmc_ez_keep_grain: f64, + + // --- DFTTest Parameters --- + + /// Denoising strength. DFTTest's own default is 8.0. + #[serde(default = "default_dfttest_sigma")] + pub dfttest_sigma: f64, + + /// Temporal window in frames; must be odd. 1 makes it purely spatial. + #[serde(default = "default_dfttest_tbsize")] + pub dfttest_tbsize: i32, + + /// Spatial block size. Larger separates frequencies better but is slower. + #[serde(default = "default_dfttest_sbsize")] + pub dfttest_sbsize: i32, + + // --- FFT3DFilter Parameters --- + + /// Denoising strength. + #[serde(default = "default_fft3d_sigma")] + pub fft3d_sigma: f64, + + /// Temporal window in frames (1-5). 1 makes it purely spatial. + #[serde(default = "default_fft3d_bt")] + pub fft3d_bt: i32, + + /// Post-denoise sharpening (0.0-1.0), applied inside the same transform. + #[serde(default)] + pub fft3d_sharpen: f64, + + // --- TTempSmooth Parameters --- + + /// Temporal radius (1-7). + #[serde(default = "default_ttemp_maxr")] + pub ttemp_maxr: i32, + + /// Per-pixel difference threshold, above which a pixel is left alone. + #[serde(default = "default_ttemp_thresh")] + pub ttemp_thresh: i32, + + /// Motion-difference threshold. Must stay below [`Self::ttemp_thresh`]. + #[serde(default = "default_ttemp_mdiff")] + pub ttemp_mdiff: i32, + + /// Weighting strength (1-8). Higher weights the current frame more. + #[serde(default = "default_ttemp_strength")] + pub ttemp_strength: i32, + + // --- FluxSmooth Parameters --- + + /// Temporal threshold: a pixel is averaged only when its neighbours in time + /// differ by no more than this. Higher smooths more and risks motion. + #[serde(default = "default_flux_temporal")] + pub flux_temporal_threshold: i32, + + /// Spatial threshold for the ST variant. -1 disables the spatial half. + #[serde(default = "default_flux_spatial")] + pub flux_spatial_threshold: i32, + + // --- STPresso Parameters --- + + /// How far a pixel may move, in 8-bit levels. The whole point of the filter. + #[serde(default = "default_stpresso_limit")] + pub stpresso_limit: i32, + + /// Bias toward the original pixel (0-100). Higher keeps more of it. + #[serde(default = "default_stpresso_bias")] + pub stpresso_bias: i32, + + /// Temporal threshold handed to the FluxSmoothT it runs internally. + #[serde(default = "default_stpresso_tthr")] + pub stpresso_tthr: i32, + + // --- CTMF Parameters --- + + /// Median window radius. The filter is constant-time, so this costs almost + /// nothing — measured, radius 1 to 127 is a ~20% difference. + #[serde(default = "default_ctmf_radius")] + pub ctmf_radius: i32, + + /// Planes to filter: 0 luma only, 1 chroma only, 2 both. + #[serde(default = "default_ctmf_planes")] + pub ctmf_planes: i32, } fn default_sm_degrain_tr() -> i32 { 2 } @@ -130,11 +284,50 @@ fn default_mcds_blur() -> f64 { 0.3 } fn default_mcds_sharp() -> f64 { 0.3 } fn default_mcds_th_sad() -> i32 { 400 } fn default_mcds_plane() -> i32 { 4 } +fn default_dfttest_sigma() -> f64 { 8.0 } +fn default_dfttest_tbsize() -> i32 { 3 } +fn default_dfttest_sbsize() -> i32 { 16 } +fn default_fft3d_sigma() -> f64 { 2.0 } +fn default_fft3d_bt() -> i32 { 3 } +fn default_ttemp_maxr() -> i32 { 3 } +fn default_ttemp_thresh() -> i32 { 4 } +fn default_ttemp_mdiff() -> i32 { 2 } +fn default_ttemp_strength() -> i32 { 2 } +fn default_flux_temporal() -> i32 { 7 } +fn default_flux_spatial() -> i32 { 7 } +fn default_stpresso_limit() -> i32 { 3 } +fn default_stpresso_bias() -> i32 { 24 } +fn default_stpresso_tthr() -> i32 { 12 } +fn default_ctmf_radius() -> i32 { 2 } +fn default_ctmf_planes() -> i32 { 2 } + +fn default_contra_sharpen_rep() -> i32 { 13 } +fn default_true_nr() -> bool { true } +fn default_mclean_strength() -> i32 { 20 } +fn default_mclean_sharp() -> i32 { 10 } +fn default_mclean_rn() -> i32 { 14 } +fn default_mclean_thsad() -> i32 { 400 } +fn default_td2_tr() -> i32 { 1 } +fn default_td2_grain_level() -> i32 { 2 } +fn default_td2_post_sigma() -> f64 { 1.0 } impl Default for NoiseReductionParameters { fn default() -> Self { Self { enabled: false, + contra_sharpen: false, + contra_sharpen_rep: default_contra_sharpen_rep(), + mclean_strength: default_mclean_strength(), + mclean_sharp: default_mclean_sharp(), + mclean_rn: default_mclean_rn(), + mclean_thsad: default_mclean_thsad(), + mclean_chroma: true, + td2_degrain_tr: default_td2_tr(), + td2_grain_level: default_td2_grain_level(), + td2_post_fft: 0, + td2_post_sigma: default_td2_post_sigma(), + td2_post_mix: 0, + td2_chroma_motion: true, preset: NoiseReductionPreset::default(), method: NoiseReductionMethod::default(), sm_degrain_tr: default_sm_degrain_tr(), @@ -153,6 +346,23 @@ impl Default for NoiseReductionParameters { mcds_plane: default_mcds_plane(), qtgmc_ez_denoise: 0.0, qtgmc_ez_keep_grain: 0.0, + dfttest_sigma: default_dfttest_sigma(), + dfttest_tbsize: default_dfttest_tbsize(), + dfttest_sbsize: default_dfttest_sbsize(), + fft3d_sigma: default_fft3d_sigma(), + fft3d_bt: default_fft3d_bt(), + fft3d_sharpen: 0.0, + ttemp_maxr: default_ttemp_maxr(), + ttemp_thresh: default_ttemp_thresh(), + ttemp_mdiff: default_ttemp_mdiff(), + ttemp_strength: default_ttemp_strength(), + flux_temporal_threshold: default_flux_temporal(), + flux_spatial_threshold: default_flux_spatial(), + stpresso_limit: default_stpresso_limit(), + stpresso_bias: default_stpresso_bias(), + stpresso_tthr: default_stpresso_tthr(), + ctmf_radius: default_ctmf_radius(), + ctmf_planes: default_ctmf_planes(), } } } @@ -191,6 +401,77 @@ impl NoiseReductionParameters { pub fn mcds_effective_frames(&self) -> i32 { self.mcds_frames.clamp(1, 3) } + + /// DFTTest's temporal window, forced odd. + /// + /// `tbsize` must be odd — the window is centred on the current frame. An + /// even value is not rejected, it just makes DFTTest process a window that + /// isn't centred, so the fix has to happen here rather than being left to + /// the plugin. + pub fn dfttest_effective_tbsize(&self) -> i32 { + let clamped = self.dfttest_tbsize.clamp(1, 15); + if clamped % 2 == 0 { + clamped - 1 + } else { + clamped + } + } + + /// FFT3DFilter's temporal window, clamped to the 1-5 it implements. + pub fn fft3d_effective_bt(&self) -> i32 { + self.fft3d_bt.clamp(1, 5) + } + + /// TTempSmooth's `mdiff`, kept below `thresh`. + /// + /// The plugin requires `mdiff < thresh`; equal or greater is accepted but + /// disables the motion protection the parameter exists for, so a wrong + /// pairing smooths through motion instead of erroring. + pub fn ttemp_effective_mdiff(&self) -> i32 { + let thresh = self.ttemp_effective_thresh(); + self.ttemp_mdiff.clamp(0, (thresh - 1).max(0)) + } + + /// TTempSmooth's `thresh`, clamped to the 1-256 it accepts. + pub fn ttemp_effective_thresh(&self) -> i32 { + self.ttemp_thresh.clamp(1, 256) + } + + /// TTempSmooth's temporal radius, clamped to the 1-7 it implements. + pub fn ttemp_effective_maxr(&self) -> i32 { + self.ttemp_maxr.clamp(1, 7) + } + + /// FluxSmooth's temporal threshold, clamped to the -1..255 it accepts. + /// + /// -1 disables that half of the filter; below that the plugin errors. + pub fn flux_effective_temporal(&self) -> i32 { + self.flux_temporal_threshold.clamp(-1, 255) + } + + /// FluxSmooth's spatial threshold, same range. + pub fn flux_effective_spatial(&self) -> i32 { + self.flux_spatial_threshold.clamp(-1, 255) + } + + /// CTMF's radius, clamped to what the plugin accepts. + /// + /// The plugin's own limit is 1-127, but `2*radius+1` must also fit inside + /// every processed plane — on a 4:2:0 clip that is the half-size chroma + /// plane. 12 is well inside both for any real video and past the point of + /// visual usefulness. + pub fn ctmf_effective_radius(&self) -> i32 { + self.ctmf_radius.clamp(1, 12) + } + + /// The `planes` list literal for CTMF. + pub fn ctmf_planes_literal(&self) -> &'static str { + match self.ctmf_planes { + 0 => "[0]", + 1 => "[1, 2]", + _ => "[0, 1, 2]", + } + } } /// Top of TCanny's useful sigma range for these two steps. @@ -254,5 +535,122 @@ mod tests { let json = serde_json::to_string(¶ms).unwrap(); assert!(json.contains("\"enabled\":false")); assert!(json.contains("\"smDegrainTr\":2")); + // The added methods' parameters have to reach the worker under the same + // camelCase names the Dart side writes. + assert!(json.contains("\"dfttestSigma\":8.0")); + assert!(json.contains("\"fft3dSigma\":2.0")); + assert!(json.contains("\"ttempMaxr\":3")); + } + + #[test] + fn test_method_wire_names_match_the_dart_enum() { + // These strings are the wire format between the app and the worker. A + // mismatch does not error — serde falls back to the default method — so + // the job silently runs SMDegrain instead of what the user picked. + let name = |m: NoiseReductionMethod| { + serde_json::to_string(&NoiseReductionParameters { + method: m, + ..Default::default() + }) + .unwrap() + }; + for (method, expected) in [ + (NoiseReductionMethod::SmDegrain, "smDegrain"), + (NoiseReductionMethod::McTemporalDenoise, "mcTemporalDenoise"), + (NoiseReductionMethod::McDegrainSharp, "mcDegrainSharp"), + (NoiseReductionMethod::QtgmcBuiltin, "qtgmcBuiltin"), + (NoiseReductionMethod::DfTtest, "dfTtest"), + (NoiseReductionMethod::Fft3dFilter, "fft3dFilter"), + (NoiseReductionMethod::TTempSmooth, "tTempSmooth"), + (NoiseReductionMethod::FluxSmoothT, "fluxSmoothT"), + (NoiseReductionMethod::FluxSmoothSt, "fluxSmoothSt"), + (NoiseReductionMethod::StPresso, "stPresso"), + (NoiseReductionMethod::Ctmf, "ctmf"), + ] { + let json = name(method); + assert!( + json.contains(&format!("\"method\":\"{expected}\"")), + "expected method {expected:?} in {json}" + ); + } + } + + #[test] + fn test_ctmf_radius_is_clamped_to_what_fits_a_chroma_plane() { + // The plugin's own limit is 1-127, but 2*radius+1 must also fit inside + // every processed plane, and on 4:2:0 that is the half-size chroma one. + let with = |r: i32| NoiseReductionParameters { + ctmf_radius: r, + ..Default::default() + }; + assert_eq!(with(0).ctmf_effective_radius(), 1); + assert_eq!(with(2).ctmf_effective_radius(), 2); + assert_eq!(with(99).ctmf_effective_radius(), 12); + } + + #[test] + fn test_ctmf_planes_literal() { + let with = |p: i32| NoiseReductionParameters { + ctmf_planes: p, + ..Default::default() + }; + assert_eq!(with(0).ctmf_planes_literal(), "[0]"); + assert_eq!(with(1).ctmf_planes_literal(), "[1, 2]"); + assert_eq!(with(2).ctmf_planes_literal(), "[0, 1, 2]"); + // Anything unexpected filters everything rather than nothing. + assert_eq!(with(9).ctmf_planes_literal(), "[0, 1, 2]"); + } + + #[test] + fn test_dfttest_tbsize_is_forced_odd() { + // An even window isn't rejected by DFTTest, it just isn't centred on the + // current frame — so it has to be fixed here. + let with_tbsize = |tbsize: i32| NoiseReductionParameters { + dfttest_tbsize: tbsize, + ..Default::default() + }; + assert_eq!(with_tbsize(1).dfttest_effective_tbsize(), 1); + assert_eq!(with_tbsize(3).dfttest_effective_tbsize(), 3); + assert_eq!(with_tbsize(4).dfttest_effective_tbsize(), 3); + assert_eq!(with_tbsize(6).dfttest_effective_tbsize(), 5); + assert_eq!(with_tbsize(0).dfttest_effective_tbsize(), 1); + assert_eq!(with_tbsize(99).dfttest_effective_tbsize(), 15); + } + + #[test] + fn test_fft3d_bt_is_clamped() { + let with_bt = |bt: i32| NoiseReductionParameters { + fft3d_bt: bt, + ..Default::default() + }; + assert_eq!(with_bt(0).fft3d_effective_bt(), 1); + assert_eq!(with_bt(3).fft3d_effective_bt(), 3); + assert_eq!(with_bt(9).fft3d_effective_bt(), 5); + } + + #[test] + fn test_ttempsmooth_mdiff_stays_below_thresh() { + // mdiff >= thresh is accepted by the plugin but disables the motion + // protection the parameter exists for. + let with = |thresh: i32, mdiff: i32| NoiseReductionParameters { + ttemp_thresh: thresh, + ttemp_mdiff: mdiff, + ..Default::default() + }; + assert_eq!(with(4, 2).ttemp_effective_mdiff(), 2); + assert_eq!(with(4, 4).ttemp_effective_mdiff(), 3); + assert_eq!(with(4, 9).ttemp_effective_mdiff(), 3); + assert_eq!(with(1, 5).ttemp_effective_mdiff(), 0); + } + + #[test] + fn test_ttempsmooth_maxr_is_clamped() { + let with_maxr = |maxr: i32| NoiseReductionParameters { + ttemp_maxr: maxr, + ..Default::default() + }; + assert_eq!(with_maxr(0).ttemp_effective_maxr(), 1); + assert_eq!(with_maxr(3).ttemp_effective_maxr(), 3); + assert_eq!(with_maxr(12).ttemp_effective_maxr(), 7); } } diff --git a/worker/src/models/processing_pipeline.rs b/worker/src/models/processing_pipeline.rs index d291a482..827caf5a 100644 --- a/worker/src/models/processing_pipeline.rs +++ b/worker/src/models/processing_pipeline.rs @@ -3,10 +3,10 @@ use serde::{Deserialize, Serialize}; use super::{ + AntiAliasParameters, GeometryParameters, GrainParameters, StabilizeParameters, ChromaDenoiseParameters, ChromaFixParameters, ColorCorrectionParameters, CropResizeParameters, DebandParameters, DeblockParameters, DehaloParameters, DeinterlaceMethod, DeScratchParameters, - SpotLessParameters, SharpenParameters, NoiseReductionParameters, QTGMCParameters, -}; + SpotLessParameters, SharpenParameters, NoiseReductionParameters, QTGMCParameters, FrameRateParameters, FrameRateMethod, DeflickerParameters, EdgeRepairParameters, GhostRemovalParameters,}; /// Minimum temporal context (frames on each side of the target) a windowed /// preview decodes, regardless of which filters are enabled. Motion-compensated @@ -122,6 +122,18 @@ pub enum PassType { Deblock, Deband, Sharpen, + AntiAlias, + Stabilize, + Geometry, + Grain, + /// Remove brightness pulsing between frames. + Deflicker, + /// Remove the displaced echo RF and cable distribution leave behind. + GhostRemoval, + /// Rebuild the dirty rows and columns at the frame border. + EdgeRepair, + /// Frame-rate conversion (standards conversion, not smoothing). + FrameRate, ColorCorrection, ChromaFixes, CropResize, @@ -141,6 +153,14 @@ impl PassType { PassType::Deblock => "Deblock", PassType::Deband => "Deband", PassType::Sharpen => "Sharpen", + PassType::AntiAlias => "Anti-Aliasing", + PassType::Stabilize => "Stabilize", + PassType::Geometry => "Rotate / Flip", + PassType::Grain => "Film Grain", + PassType::Deflicker => "Deflicker", + PassType::GhostRemoval => "Ghost Removal", + PassType::EdgeRepair => "Edge Repair", + PassType::FrameRate => "Frame Rate", PassType::ColorCorrection => "Color Correction", PassType::ChromaFixes => "Chroma Fixes", PassType::CropResize => "Crop / Resize", @@ -159,6 +179,14 @@ impl PassType { PassType::Deblock => "Remove compression block artifacts", PassType::Deband => "Remove color banding from gradients", PassType::Sharpen => "Sharpen edges and enhance detail", + PassType::AntiAlias => "Smooth stair-stepping on diagonal edges", + PassType::Stabilize => "Remove shake and weave from the picture", + PassType::Geometry => "Rotate or mirror the picture", + PassType::Grain => "Add film grain back after denoising", + PassType::Deflicker => "Even out brightness pulsing between frames", + PassType::GhostRemoval => "Remove the displaced echo RF and cable distribution leave behind", + PassType::EdgeRepair => "Rebuild the dirty rows and columns at the frame border", + PassType::FrameRate => "Convert between PAL and NTSC frame rates", PassType::ColorCorrection => "Adjust brightness, contrast, and colors", PassType::ChromaFixes => "Fix chroma bleeding and crawl artifacts", PassType::CropResize => "Crop borders and resize output", @@ -207,6 +235,36 @@ pub struct ProcessingPipeline { #[serde(default)] pub sharpen: SharpenParameters, + /// Anti-aliasing pass parameters. + #[serde(default)] + pub anti_alias: AntiAliasParameters, + + /// Stabilisation pass parameters. + #[serde(default)] + pub stabilize: StabilizeParameters, + + /// Rotate/flip pass parameters. + #[serde(default)] + pub geometry: GeometryParameters, + + /// Film grain pass parameters. + #[serde(default)] + pub grain: GrainParameters, + + /// Brightness flicker removal. Off by default. + #[serde(default)] + pub deflicker: DeflickerParameters, + + #[serde(default)] + pub ghost_removal: GhostRemovalParameters, + + #[serde(default)] + pub edge_repair: EdgeRepairParameters, + + /// Frame-rate conversion. Off by default. + #[serde(default)] + pub frame_rate: FrameRateParameters, + /// Color correction pass parameters. #[serde(default)] pub color_correction: ColorCorrectionParameters, @@ -232,6 +290,14 @@ impl Default for ProcessingPipeline { deblock: DeblockParameters::default(), deband: DebandParameters::default(), sharpen: SharpenParameters::default(), + anti_alias: AntiAliasParameters::default(), + stabilize: StabilizeParameters::default(), + geometry: GeometryParameters::default(), + grain: GrainParameters::default(), + deflicker: DeflickerParameters::default(), + ghost_removal: GhostRemovalParameters::default(), + edge_repair: EdgeRepairParameters::default(), + frame_rate: FrameRateParameters::default(), color_correction: ColorCorrectionParameters::default(), chroma_fixes: ChromaFixParameters::default(), crop_resize: CropResizeParameters::default(), @@ -253,6 +319,14 @@ impl ProcessingPipeline { deblock: DeblockParameters { enabled: false, ..Default::default() }, deband: DebandParameters { enabled: false, ..Default::default() }, sharpen: SharpenParameters { enabled: false, ..Default::default() }, + anti_alias: AntiAliasParameters { enabled: false, ..Default::default() }, + stabilize: StabilizeParameters { enabled: false, ..Default::default() }, + geometry: GeometryParameters { enabled: false, ..Default::default() }, + grain: GrainParameters { enabled: false, ..Default::default() }, + deflicker: DeflickerParameters { enabled: false, ..Default::default() }, + ghost_removal: GhostRemovalParameters { enabled: false, ..Default::default() }, + edge_repair: EdgeRepairParameters { enabled: false, ..Default::default() }, + frame_rate: FrameRateParameters { enabled: false, ..Default::default() }, color_correction: ColorCorrectionParameters { enabled: false, ..Default::default() }, chroma_fixes: ChromaFixParameters { enabled: false, ..Default::default() }, crop_resize: CropResizeParameters { enabled: false, ..Default::default() }, @@ -270,6 +344,24 @@ impl ProcessingPipeline { if self.deinterlace_enabled() { passes.push(PassType::Deinterlace); } + // Edge repair must precede every spatial filter, or denoising and + // sharpening smear the bad rows inward — and it must precede the resize, + // or resampling spreads them. After deinterlacing rather than first, so + // it works on progressive output when deinterlacing is on. + if self.edge_repair.has_effect() { + passes.push(PassType::EdgeRepair); + } + // Ghosting is a per-line echo in luma; removing it before the denoise + // stops the denoiser averaging the echo into the picture. + if self.ghost_removal.has_effect() { + passes.push(PassType::GhostRemoval); + } + // Deflicker must follow deinterlacing — field-doubled frames break + // every temporal comparison it makes — and precede the dirt and + // denoise passes, which all assume a stable exposure. + if self.deflicker.enabled { + passes.push(PassType::Deflicker); + } if self.descratch.enabled { passes.push(PassType::DeScratch); } @@ -293,6 +385,11 @@ impl ProcessingPipeline { if self.deband.enabled { passes.push(PassType::Deband); } + // Anti-aliasing before sharpening: sharpening stair-stepped edges + // makes the stepping more visible, not less. + if self.anti_alias.enabled { + passes.push(PassType::AntiAlias); + } if self.sharpen.enabled { passes.push(PassType::Sharpen); } @@ -302,6 +399,17 @@ impl ProcessingPipeline { if self.color_correction.enabled { passes.push(PassType::ColorCorrection); } + // Stabilisation shifts the picture within the frame, so it runs last + // before framing — that way a crop can remove the edges it exposes. + if self.stabilize.enabled { + passes.push(PassType::Stabilize); + } + // Rotation swaps width and height, so it has to settle before any + // framing decision is made. It also must follow deinterlacing: fields + // run horizontally, so turning a still-interlaced clip shears them. + if self.geometry.has_effect() { + passes.push(PassType::Geometry); + } if self.crop_resize.enabled && self.crop_resize.resize_enabled { // Resize (post-processing) - if not already added for crop if !passes.contains(&PassType::CropResize) { @@ -309,6 +417,21 @@ impl ProcessingPipeline { } } + // Grain goes last of the video passes. Added before the resize it is + // resampled away; before the deband it is smoothed away. Measured, and + // the reason this pass sits after framing rather than with the other + // enhancement steps. + if self.grain.has_effect() { + passes.push(PassType::Grain); + } + + // Frame rate conversion is genuinely last. It resamples the timeline, so + // running it before anything temporal would have every later pass work + // on invented frames rather than photographed ones. + if self.frame_rate.enabled { + passes.push(PassType::FrameRate); + } + passes } @@ -357,11 +480,41 @@ impl ProcessingPipeline { }, PassType::SpotLess => FrameMap::Identity { radius: 2 }, PassType::DeScratch => FrameMap::Identity { radius: 1 }, + PassType::Deflicker => FrameMap::Identity { radius: self.deflicker.radius() }, + PassType::EdgeRepair => FrameMap::Identity { radius: 0 }, + PassType::GhostRemoval => FrameMap::Identity { radius: 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. + PassType::FrameRate => self.frame_rate_frame_map(), // Purely spatial passes. _ => FrameMap::Identity { radius: 0 }, } } + /// Frame mapping for the frame rate pass. + /// + /// The source rate is not known to the pipeline, so this reports the + /// identity and the executor substitutes the real ratio once vspipe has + /// reported the input rate. Reporting a wrong fixed ratio here would make + /// the progress total and the preview seek disagree with the output. + fn frame_rate_frame_map(&self) -> FrameMap { + // Without a source rate we cannot know the ratio, and a wrong fixed + // ratio is worse than none: it would make the progress total and the + // preview index disagree with what the encoder actually receives. + match self.frame_rate.ratio() { + Some((num, den)) => FrameMap::Retime { + num, + den, + // Interpolated frames have no single source origin, so the + // preview's inverse mapping is a blended range, not a frame. + synthesizes: matches!(self.frame_rate.method, FrameRateMethod::FlowFps), + radius: 1, + }, + None => FrameMap::Identity { radius: 1 }, + } + } + /// Frame mapping for the deinterlace pass. QTGMC double-rate (FPSDivisor=1, /// the default when unset) doubles the frame count; IVTC/soft-telecine /// decimate on a cycle. Mirrors the count logic the progress reporter and @@ -376,6 +529,17 @@ impl ProcessingPipeline { FrameMap::Identity { radius: 3 } } } + // Bwdif's field argument decides this exactly as QTGMC's + // fps_divisor does: double rate emits one frame per field. + // Radius 1 rather than 3 — it reads one neighbour each side, not + // QTGMC's temporal window. + DeinterlaceMethod::Bwdif => { + if d.fps_divisor.unwrap_or(1) == 1 { + FrameMap::Fanout { factor: 2, radius: 1 } + } else { + FrameMap::Identity { radius: 1 } + } + } DeinterlaceMethod::Ivtc | DeinterlaceMethod::SoftTelecine => { let cycle = d.ivtc_cycle.unwrap_or(5).max(2) as u32; FrameMap::Decimate { cycle, keep: cycle - 1 } @@ -428,6 +592,14 @@ impl ProcessingPipeline { PassType::Deblock => self.deblock.enabled, PassType::Deband => self.deband.enabled, PassType::Sharpen => self.sharpen.enabled, + PassType::AntiAlias => self.anti_alias.enabled, + PassType::Stabilize => self.stabilize.enabled, + PassType::Geometry => self.geometry.has_effect(), + PassType::Grain => self.grain.has_effect(), + PassType::Deflicker => self.deflicker.enabled, + PassType::GhostRemoval => self.ghost_removal.has_effect(), + PassType::EdgeRepair => self.edge_repair.has_effect(), + PassType::FrameRate => self.frame_rate.enabled, PassType::ColorCorrection => self.color_correction.enabled, PassType::ChromaFixes => self.chroma_fixes.enabled, PassType::CropResize => self.crop_resize.enabled, diff --git a/worker/src/models/qtgmc_parameters.rs b/worker/src/models/qtgmc_parameters.rs index 4423b2a5..b3ab1a85 100644 --- a/worker/src/models/qtgmc_parameters.rs +++ b/worker/src/models/qtgmc_parameters.rs @@ -16,6 +16,10 @@ pub enum DeinterlaceMethod { Ivtc, /// Soft telecine: relabel frame rate for DVD sources with soft telecine flags. SoftTelecine, + /// Bwdif — bob-weave deinterlacer with a cubic interpolator. Measured + /// 622 fps against QTGMC Fast's 150 on the same clip, at most of the + /// quality. The speed tier, for long captures and quick proofs. + Bwdif, } /// All QTGMC parameters supported by the VapourSynth implementation. @@ -48,6 +52,12 @@ pub struct QTGMCParameters { #[serde(default, skip_serializing_if = "Option::is_none")] pub fps_divisor: Option, + /// Hand Bwdif an nnedi3 interpolator instead of its own cubic one. Measured + /// 0.524 against 0.585 plain — better than Yadifmod+nnedi3 at the same + /// cost, which is why Yadifmod is not a separate method. + #[serde(default)] + pub bwdif_edeint: bool, + // === Working Format (issue #49) === /// Upsample 4:2:0 chroma to 4:2:2 with field-aware resampling before /// deinterlacing, and restore the source format afterwards. Interlaced @@ -453,6 +463,7 @@ impl Default for QTGMCParameters { input_type: None, tff: None, fps_divisor: None, + bwdif_edeint: false, chroma_upsample_fix: None, high_precision: None, tr0: None, diff --git a/worker/src/models/sharpen_parameters.rs b/worker/src/models/sharpen_parameters.rs index 139cc1b1..e830a8e9 100644 --- a/worker/src/models/sharpen_parameters.rs +++ b/worker/src/models/sharpen_parameters.rs @@ -8,6 +8,11 @@ pub enum SharpenMethod { LSFmod, #[serde(rename = "CAS")] CAS, + /// aWarpSharp2: sharpens by warping pixels toward edges rather than raising + /// local contrast, so it adds no halos at all. A distinctly different look + /// from the other two — very effective on soft or upscaled material. + #[serde(rename = "AWarpSharp2")] + AWarpSharp2, } #[allow(dead_code)] @@ -16,6 +21,7 @@ impl SharpenMethod { match self { SharpenMethod::LSFmod => "LSFmod", SharpenMethod::CAS => "CAS", + SharpenMethod::AWarpSharp2 => "AWarpSharp2", } } } @@ -55,12 +61,33 @@ pub struct SharpenParameters { /// CAS sharpening amount (0.0-1.0). #[serde(default = "default_cas_sharpness")] pub cas_sharpness: f64, + + // --- aWarpSharp2 parameters --- + + /// How far pixels may be warped (0-255). The main strength control. + #[serde(default = "default_warp_depth")] + pub warp_depth: i32, + + /// Edge mask threshold (0-255). Lower finds more edges to warp toward. + #[serde(default = "default_warp_thresh")] + pub warp_thresh: i32, + + /// Mask blur passes (0-3). More blur warps more smoothly. + #[serde(default = "default_warp_blur")] + pub warp_blur: i32, + + /// Blur kernel: 0 = radius 6 box (per-pass), 1 = radius 2 box. + #[serde(default)] + pub warp_type: i32, } fn default_strength() -> i32 { 100 } fn default_overshoot() -> i32 { 1 } fn default_undershoot() -> i32 { 1 } fn default_cas_sharpness() -> f64 { 0.5 } +fn default_warp_depth() -> i32 { 16 } +fn default_warp_thresh() -> i32 { 128 } +fn default_warp_blur() -> i32 { 2 } impl Default for SharpenParameters { fn default() -> Self { @@ -72,6 +99,17 @@ impl Default for SharpenParameters { undershoot: default_undershoot(), soft_edge: 0, cas_sharpness: default_cas_sharpness(), + warp_depth: default_warp_depth(), + warp_thresh: default_warp_thresh(), + warp_blur: default_warp_blur(), + warp_type: 0, } } } + +impl SharpenParameters { + /// Mask blur passes, clamped to the 0-3 the plugin implements. + pub fn warp_effective_blur(&self) -> i32 { + self.warp_blur.clamp(0, 3) + } +} diff --git a/worker/src/models/spotless_parameters.rs b/worker/src/models/spotless_parameters.rs index 4b4222ab..1a4e85da 100644 --- a/worker/src/models/spotless_parameters.rs +++ b/worker/src/models/spotless_parameters.rs @@ -1,5 +1,17 @@ use serde::{Deserialize, Serialize}; +/// Which spot remover to run. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum SpotLessMethod { + /// SpotLess — motion-compensated temporal median. Higher quality, slow. + #[default] + #[serde(rename = "spotless")] + SpotLess, + /// RemoveDirt — measured 6.3x faster for about 60% of the removal. + #[serde(rename = "removeDirt")] + RemoveDirt, +} + /// Parameters for the SpotLess pass. /// Removes dust, dirt, and temporal spots using motion-compensated median. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -9,6 +21,28 @@ pub struct SpotLessParameters { #[serde(default)] pub enabled: bool, + /// Which spot remover to run. + #[serde(default)] + pub method: SpotLessMethod, + + /// RemoveDirt: global motion threshold — how much of the frame may differ + /// before it is treated as motion rather than damage. + #[serde(default = "default_rd_gmthreshold")] + pub rd_gmthreshold: i32, + /// RemoveDirt: how large a difference counts as a spot. + #[serde(default = "default_rd_noise")] + pub rd_noise: i32, + /// RemoveDirt: how many neighbouring pixels must agree. + #[serde(default = "default_rd_noisy")] + pub rd_noisy: i32, + /// RemoveDirt: dilation distance around a detected spot. + #[serde(default = "default_rd_dist")] + pub rd_dist: i32, + /// RemoveDirt: re-enable the canonical trailing RemoveGrain(17). Off by + /// default — measured, it alone triples the collateral damage. + #[serde(default)] + pub rd_post_denoise: bool, + /// Process chroma planes (default true). #[serde(default = "default_true")] pub chroma: bool, @@ -35,6 +69,11 @@ fn default_blksize() -> i32 { 16 } fn default_overlap() -> i32 { 8 } fn default_pel() -> i32 { 2 } +fn default_rd_gmthreshold() -> i32 { 70 } +fn default_rd_noise() -> i32 { 50 } +fn default_rd_noisy() -> i32 { 12 } +fn default_rd_dist() -> i32 { 1 } + impl Default for SpotLessParameters { fn default() -> Self { Self { @@ -44,6 +83,12 @@ impl Default for SpotLessParameters { blksize: default_blksize(), overlap: default_overlap(), pel: default_pel(), + method: SpotLessMethod::default(), + rd_gmthreshold: default_rd_gmthreshold(), + rd_noise: default_rd_noise(), + rd_noisy: default_rd_noisy(), + rd_dist: default_rd_dist(), + rd_post_denoise: false, } } } diff --git a/worker/src/models/stabilize_parameters.rs b/worker/src/models/stabilize_parameters.rs new file mode 100644 index 00000000..a69b561c --- /dev/null +++ b/worker/src/models/stabilize_parameters.rs @@ -0,0 +1,123 @@ +//! Image stabilisation parameters. +//! +//! havsfunc's `Stab` measures global motion with MVTools' Depan family and +//! cancels it, so a shaky capture holds still while intended camera movement +//! survives. Aimed at telecine weave, jittery film scans and handheld camcorder +//! footage. +//! +//! It does not change the frame count, which is why it is an ordinary pass and +//! not one of the frame-rate-changing filters that would touch `FrameMap`. +//! +//! No bit-depth limit — verified against the bundle at 8/10/12/16-bit and 4:2:2. + +use serde::{Deserialize, Serialize}; + +/// Parameters for the stabilisation pass. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StabilizeParameters { + /// Whether this pass is enabled. + #[serde(default)] + pub enabled: bool, + + /// Maximum horizontal correction in pixels. + #[serde(default = "default_dxmax")] + pub dxmax: i32, + + /// Maximum vertical correction in pixels. + #[serde(default = "default_dymax")] + pub dymax: i32, + + /// How to fill the edges the shift exposes: 0 none (left black), 1 top and + /// bottom, 2 left and right, 3 all four. Mirroring saves the user having to + /// crop afterwards. + #[serde(default)] + pub mirror: i32, +} + +fn default_dxmax() -> i32 { 4 } +fn default_dymax() -> i32 { 4 } + +impl Default for StabilizeParameters { + fn default() -> Self { + Self { + enabled: false, + dxmax: default_dxmax(), + dymax: default_dymax(), + mirror: 0, + } + } +} + +impl StabilizeParameters { + /// Edge-fill mode, clamped to the 0-3 DePanStabilise implements. + pub fn effective_mirror(&self) -> i32 { + self.mirror.clamp(0, 3) + } + + /// Maximum horizontal correction, clamped non-negative. A negative value is + /// accepted by DepanStabilise and disables correction on that axis, which + /// looks like the filter doing nothing. + pub fn effective_dxmax(&self) -> i32 { + self.dxmax.max(0) + } + + /// See [`Self::effective_dxmax`]. + pub fn effective_dymax(&self) -> i32 { + self.dymax.max(0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_defaults() { + let p = StabilizeParameters::default(); + assert!(!p.enabled); + assert_eq!(p.dxmax, 4); + assert_eq!(p.dymax, 4); + assert_eq!(p.mirror, 0); + } + + #[test] + fn test_parameters_match_the_bundled_havsfunc_signature() { + // Stab(clp, dxmax=4, dymax=4, mirror=0). There is no `range` argument — + // an earlier version of this model invented one, and havsfunc rejected + // it with a TypeError that only the end-to-end test caught. Introspect + // the signature; do not assume it from another implementation. + let json = serde_json::to_string(&StabilizeParameters::default()).unwrap(); + assert!(json.contains("\"dxmax\":4")); + assert!(json.contains("\"dymax\":4")); + assert!(json.contains("\"mirror\":0")); + assert!(!json.contains("range")); + } + + #[test] + fn test_mirror_is_clamped() { + let with = |m: i32| StabilizeParameters { mirror: m, ..Default::default() }; + assert_eq!(with(-1).effective_mirror(), 0); + assert_eq!(with(3).effective_mirror(), 3); + assert_eq!(with(9).effective_mirror(), 3); + } + + #[test] + fn test_negative_limits_would_silently_disable_an_axis() { + let with = |d: i32| StabilizeParameters { + dxmax: d, + dymax: d, + ..Default::default() + }; + assert_eq!(with(-1).effective_dxmax(), 0); + assert_eq!(with(-1).effective_dymax(), 0); + assert_eq!(with(6).effective_dxmax(), 6); + } + + #[test] + fn test_serialization() { + let json = serde_json::to_string(&StabilizeParameters::default()).unwrap(); + assert!(json.contains("\"enabled\":false")); + assert!(json.contains("\"dxmax\":4")); + } +} diff --git a/worker/src/models/video_job.rs b/worker/src/models/video_job.rs index 6090a65e..668950d7 100644 --- a/worker/src/models/video_job.rs +++ b/worker/src/models/video_job.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; -use super::{QTGMCParameters, ProcessingPipeline}; +use super::{QTGMCParameters, ProcessingPipeline, ColorMetadata}; /// Represents a complete video processing job. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -71,9 +71,38 @@ pub struct VideoJob { /// Input pixel format string (e.g. "yuv420p", "yuv422p"). For pipe source. #[serde(skip_serializing_if = "Option::is_none")] pub input_pixel_format: Option, + + /// Source colour tags, read from ffprobe and re-declared on the output. + /// The Y4M pipe strips them exactly as it strips SAR, so without these the + /// output is untagged and every player reads it as BT.601 limited. + #[serde(skip_serializing_if = "Option::is_none")] + pub input_color_matrix: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub input_color_primaries: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub input_color_transfer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub input_color_range: Option, + + /// A subtitle file to burn into the picture. Separate from the Whisper + /// path: transcription currently runs *after* the encode, so its output + /// cannot reach the encoder. A user-supplied file has no such problem. + #[serde(skip_serializing_if = "Option::is_none")] + pub burn_in_subtitle_path: Option, + } impl VideoJob { + /// The source's colour tags, validated. Empty when the source was untagged. + pub fn color_metadata(&self) -> ColorMetadata { + ColorMetadata::from_raw( + self.input_color_matrix.as_deref(), + self.input_color_primaries.as_deref(), + self.input_color_transfer.as_deref(), + self.input_color_range.as_deref(), + ) + } + /// Get the effective processing pipeline. /// Uses processing_pipeline if set, otherwise creates one from legacy qtgmc_parameters. pub fn effective_pipeline(&self) -> ProcessingPipeline { @@ -111,6 +140,79 @@ pub enum SubtitleOutput { SrtFile, Embed, Both, + /// Draw the subtitles into the picture itself. + BurnIn, + /// Draw them in AND keep the sidecar file. + BurnInAndSrt, +} + +impl SubtitleOutput { + /// The subtitles are drawn into the picture during the encode, so the + /// transcript has to exist before it starts. + pub fn burns_in(self) -> bool { + matches!(self, SubtitleOutput::BurnIn | SubtitleOutput::BurnInAndSrt) + } + + /// The transcript is multiplexed into the finished file as a selectable + /// track — a post-pass, because the file has to exist first. + pub fn muxes(self) -> bool { + matches!(self, SubtitleOutput::Embed | SubtitleOutput::Both) + } + + /// The `.srt` is left beside the video rather than cleaned up. + pub fn keeps_srt_file(self) -> bool { + matches!( + self, + SubtitleOutput::SrtFile | SubtitleOutput::Both | SubtitleOutput::BurnInAndSrt + ) + } +} + +#[cfg(test)] +mod subtitle_output_tests { + use super::SubtitleOutput; + + /// Every mode must do at least one of the three things, or choosing it + /// silently produces nothing. + #[test] + fn every_mode_does_something() { + for mode in [ + SubtitleOutput::SrtFile, + SubtitleOutput::Embed, + SubtitleOutput::Both, + SubtitleOutput::BurnIn, + SubtitleOutput::BurnInAndSrt, + ] { + assert!( + mode.burns_in() || mode.muxes() || mode.keeps_srt_file(), + "{mode:?} would produce no subtitles at all" + ); + } + } + + #[test] + fn burning_in_and_muxing_are_independent() { + // Burn-in draws pixels; muxing adds a track the player can switch off. + // A mode may do either, and "both" here means sidecar + track. + assert!(SubtitleOutput::BurnIn.burns_in()); + assert!(!SubtitleOutput::BurnIn.muxes()); + assert!(!SubtitleOutput::BurnIn.keeps_srt_file()); + + assert!(SubtitleOutput::Both.muxes()); + assert!(SubtitleOutput::Both.keeps_srt_file()); + assert!(!SubtitleOutput::Both.burns_in()); + + assert!(SubtitleOutput::BurnInAndSrt.burns_in()); + assert!(SubtitleOutput::BurnInAndSrt.keeps_srt_file()); + } + + /// Embed must not also leave a stray sidecar — that was the old behaviour + /// and it is why the file is deleted after muxing. + #[test] + fn embed_alone_leaves_no_sidecar() { + assert!(SubtitleOutput::Embed.muxes()); + assert!(!SubtitleOutput::Embed.keeps_srt_file()); + } } /// Video encoding settings for FFmpeg output. @@ -149,6 +251,12 @@ pub struct EncodingSettings { #[serde(default)] pub custom_ffmpeg_args: String, + /// User-supplied VapourSynth, injected after every built-in pass. Same + /// footing as custom_ffmpeg_args: an escape hatch for someone who knows + /// what they are doing, gated behind advanced mode in the UI. + #[serde(default)] + pub custom_vapoursynth: String, + /// Output container format #[serde(default)] pub container: ContainerFormat, @@ -337,6 +445,7 @@ impl Default for EncodingSettings { audio_quality: AudioQuality::default(), chroma_subsampling: ChromaSubsampling::default(), custom_ffmpeg_args: String::new(), + custom_vapoursynth: String::new(), container: ContainerFormat::default(), video_bitrate_kbps: None, } diff --git a/worker/src/pipeline_executor.rs b/worker/src/pipeline_executor.rs index e0f63cc8..878fb108 100644 --- a/worker/src/pipeline_executor.rs +++ b/worker/src/pipeline_executor.rs @@ -856,19 +856,57 @@ impl PipelineExecutor { // a resize ran, which used to drop it and leave an anamorphic source // squashed (#50). let pipeline = job.effective_pipeline(); - match pipeline.crop_resize.aspect_declaration(input_sar) { + // A quarter turn exchanges pixel width and height, so the SAR that + // describes the source no longer describes the rotated frame. Adjust it + // before declaring it, or a rotated anamorphic source is stretched by + // the square of its own aspect. + // + // Filters accumulate into one chain. ffmpeg takes the LAST -vf and + // silently drops any earlier one, so appending a second would throw + // away the aspect stamp — which is exactly how burnt-in subtitles + // would have broken issue #50's third leg. + let mut vf: Vec = Vec::new(); + + let rotated_sar = pipeline.geometry.adjusted_sar(input_sar); + match pipeline.crop_resize.aspect_declaration(rotated_sar.as_deref()) { AspectDeclaration::Sar(sar) => { // '/' as the ratio separator — ':' is ffmpeg's filter option separator. - args.extend(["-vf".to_string(), format!("setsar={}", sar.replace(':', "/"))]); + vf.push(format!("setsar={}", sar.replace(':', "/"))); } AspectDeclaration::Dar(dar) => { // ffmpeg derives the SAR from the actual frame size, which only // it knows: the dimensions are computed inside the .vpy. - args.extend(["-vf".to_string(), format!("setdar={}", dar)]); + vf.push(format!("setdar={}", dar)); } AspectDeclaration::None => {} } + // Burn subtitles into the picture, if a file was supplied. This runs + // after the whole VapourSynth graph, which for subtitles is correct + // rather than merely tolerable — they are the last thing applied. + if let Some(path) = job.burn_in_subtitle_path.as_deref() { + if !path.trim().is_empty() { + // Escape for ffmpeg's filter-argument parser: backslash, colon + // and single quote all terminate or alter the argument, and a + // Windows path contains the first two by construction. + let escaped = path + .replace('\\', "/") + .replace('\'', "\\\\'") + .replace(':', "\\\\:"); + vf.push(format!("subtitles='{}'", escaped)); + } + } + + if !vf.is_empty() { + args.extend(["-vf".to_string(), vf.join(",")]); + } + + // Re-declare the source's colour tags for exactly the same reason as the + // SAR above: the Y4M pipe strips them, so an untagged output results and + // every player then reads it as BT.601 limited. Nothing in the pipeline + // re-matrixes the samples, so the source's tags still describe them. + args.extend(job.color_metadata().to_ffmpeg_args()); + // Audio handling match settings.audio_mode { AudioMode::Passthrough => { @@ -1173,13 +1211,22 @@ impl PipelineExecutor { let vspipe_stdout = vspipe.stdout.take().context("Failed to get vspipe stdout")?; let vspipe_stderr = vspipe.stderr.take(); - // Start encoder FFmpeg — converts Y4M to PNG + // Start encoder FFmpeg — converts Y4M to PNG. + // + // The Y4M pipe carries no colour information, so swscale would guess the + // matrix here — while the app's "before" thumbnail comes from a separate + // ffmpeg call on the original file that does see the real tags. That + // mismatch showed a hue shift no filter had caused. `in_range` was also + // hardcoded to `tv`, which stretched a full-range source a second time. + let mut scale_opts = job.color_metadata().swscale_input_opts(); + scale_opts.push("out_range=pc".to_string()); + let scale_filter = format!("scale={}", scale_opts.join(":")); let ffmpeg_enc = Command::new(&ffmpeg_path) .args([ "-f", "yuv4mpegpipe", "-i", "pipe:0", "-vframes", "1", - "-vf", "scale=in_range=tv:out_range=pc", + "-vf", &scale_filter, "-f", "image2pipe", "-vcodec", "png", "pipe:1", @@ -1492,6 +1539,11 @@ mod tests { args.extend(["-movflags".to_string(), "+faststart".to_string()]); } + // Colour tags. This helper duplicates build_ffmpeg_args rather than + // calling it, so anything added there has to be added here too — the + // SAR block was missed that way and is still absent below. + args.extend(job.color_metadata().to_ffmpeg_args()); + // Audio handling match settings.audio_mode { AudioMode::Passthrough => { @@ -1540,9 +1592,62 @@ mod tests { input_width: None, input_height: None, input_pixel_format: None, + input_color_matrix: None, + input_color_primaries: None, + input_color_transfer: None, + input_color_range: None, + burn_in_subtitle_path: None, } } + /// Colour tags survive the pipe and reach the encoder. + /// + /// The Y4M pipe from vspipe strips colour metadata exactly as it strips + /// SAR, so an output that is not explicitly re-tagged is read as BT.601 + /// limited by every player. Every file this app wrote was untagged until + /// this landed, which silently shifted the colours of any BT.709 or + /// full-range source. + #[test] + fn test_color_tags_are_declared_on_the_output() { + let mut job = create_test_job("out.mkv"); + job.input_color_matrix = Some("bt709".to_string()); + job.input_color_primaries = Some("bt709".to_string()); + job.input_color_transfer = Some("bt709".to_string()); + job.input_color_range = Some("tv".to_string()); + + let args = build_ffmpeg_args_for_test(&job); + let pair = |flag: &str| { + args.iter().position(|a| a == flag).map(|i| args[i + 1].clone()) + }; + assert_eq!(pair("-colorspace").as_deref(), Some("bt709")); + assert_eq!(pair("-color_primaries").as_deref(), Some("bt709")); + assert_eq!(pair("-color_trc").as_deref(), Some("bt709")); + assert_eq!(pair("-color_range").as_deref(), Some("tv")); + } + + /// An untagged source must stay untagged rather than being guessed at. + #[test] + fn test_untagged_source_declares_nothing() { + let job = create_test_job("out.mkv"); + let args = build_ffmpeg_args_for_test(&job); + for flag in ["-colorspace", "-color_primaries", "-color_trc", "-color_range"] { + assert!(!args.iter().any(|a| a == flag), "{flag} should be absent"); + } + } + + /// ffprobe says "unknown" for an untagged stream; that must not be + /// forwarded as if it were a value, or the encode fails on an argument the + /// user can neither see nor fix. + #[test] + fn test_unknown_from_ffprobe_is_not_forwarded() { + let mut job = create_test_job("out.mkv"); + job.input_color_matrix = Some("unknown".to_string()); + job.input_color_range = Some("unknown".to_string()); + let args = build_ffmpeg_args_for_test(&job); + assert!(!args.iter().any(|a| a == "-colorspace")); + assert!(!args.iter().any(|a| a == "-color_range")); + } + #[test] fn test_is_text_subtitle_excludes_image_based() { // Image-based subtitles (DVD/Blu-ray rips) must be excluded — these are diff --git a/worker/src/script_generator.rs b/worker/src/script_generator.rs index e702562d..928a73f8 100644 --- a/worker/src/script_generator.rs +++ b/worker/src/script_generator.rs @@ -9,7 +9,9 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use crate::models::{ + AntiAliasMethod, GrainMethod, VideoJob, ProcessingPipeline, NoiseReductionMethod, UpscaleMethod, CCD_REFERENCE_HEIGHT, + ChromaDenoiseMethod, FrameRateMethod, SpotLessMethod, DeflickerMethod, EdgeRepairParameters, parse_ratio, DehaloMethod, DeblockMethod, SharpenMethod, DeinterlaceMethod, FieldOrder, @@ -168,7 +170,17 @@ impl ScriptGenerator { if path.exists() { if let Ok(content) = fs::read_to_string(path) { eprintln!("Loaded template from: {:?}", path); - return Ok(content); + // Normalize to LF. A Windows checkout gives the .vpy + // templates CRLF endings, so any substitution whose pattern + // spans more than one line stops matching there — silently, + // because a missed replace leaves valid-looking script + // behind rather than an error. That shipped once: the + // SmoothLevels path elides the plain std.Levels call with + // `replace("core.std.Levels(\n clip,\n)", "clip")`, and + // on Windows alone both calls survived into the script. + // Normalizing here fixes the whole class rather than that + // one pattern; Python does not care about the endings. + return Ok(content.replace("\r\n", "\n")); } } } @@ -340,12 +352,44 @@ impl ScriptGenerator { script = script.replace("{{/DEINTERLACE}}", ""); match params.method { + DeinterlaceMethod::Bwdif => { + script = remove_block("{{#DEINT_QTGMC}}", "{{/DEINT_QTGMC}}", script); + script = remove_block("{{#DEINT_IVTC}}", "{{/DEINT_IVTC}}", script); + script = remove_block("{{#DEINT_SOFT_TELECINE}}", "{{/DEINT_SOFT_TELECINE}}", script); + script = script.replace("{{#DEINT_BWDIF}}", ""); + script = script.replace("{{/DEINT_BWDIF}}", ""); + + // field: 0/1 keep one field per input frame (single rate), + // 2/3 emit one per field (double rate). The parity half is + // the same TFF value QTGMC uses, so the two methods cannot + // disagree about field order. + let tff = params.tff.unwrap_or(true); + let double_rate = params.fps_divisor.unwrap_or(1) == 1; + let field = match (double_rate, tff) { + (true, true) => 3, + (true, false) => 2, + (false, true) => 1, + (false, false) => 0, + }; + script = script.replace("{{BWDIF_FIELD}}", &field.to_string()); + + if params.bwdif_edeint { + script = script.replace("{{#DEINT_BWDIF_EDEINT}}", ""); + script = script.replace("{{/DEINT_BWDIF_EDEINT}}", ""); + script = remove_block("{{#DEINT_BWDIF_PLAIN}}", "{{/DEINT_BWDIF_PLAIN}}", script); + } else { + script = remove_block("{{#DEINT_BWDIF_EDEINT}}", "{{/DEINT_BWDIF_EDEINT}}", script); + script = script.replace("{{#DEINT_BWDIF_PLAIN}}", ""); + script = script.replace("{{/DEINT_BWDIF_PLAIN}}", ""); + } + } DeinterlaceMethod::Qtgmc => { // Enable QTGMC block, remove IVTC and Soft Telecine blocks script = script.replace("{{#DEINT_QTGMC}}", ""); script = script.replace("{{/DEINT_QTGMC}}", ""); script = remove_block("{{#DEINT_IVTC}}", "{{/DEINT_IVTC}}", script); script = remove_block("{{#DEINT_SOFT_TELECINE}}", "{{/DEINT_SOFT_TELECINE}}", script); + script = remove_block("{{#DEINT_BWDIF}}", "{{/DEINT_BWDIF}}", script); // Working format around the QTGMC call (issue #49): 4:2:2 // chroma and/or 16-bit, restored to the source format after. @@ -552,6 +596,7 @@ impl ScriptGenerator { script = script.replace("{{#DEINT_IVTC}}", ""); script = script.replace("{{/DEINT_IVTC}}", ""); script = remove_block("{{#DEINT_SOFT_TELECINE}}", "{{/DEINT_SOFT_TELECINE}}", script); + script = remove_block("{{#DEINT_BWDIF}}", "{{/DEINT_BWDIF}}", script); // Derive IVTC_ORDER from tff field (TFF→1, BFF→0), falling back to ivtc_order let order = match params.tff { @@ -617,6 +662,24 @@ impl ScriptGenerator { // ==================================================================== let spotless = &pipeline.spotless; if spotless.enabled { + match pipeline.spotless.method { + SpotLessMethod::SpotLess => { + script = script.replace("{{#SPOTLESS_CLASSIC}}", ""); + script = script.replace("{{/SPOTLESS_CLASSIC}}", ""); + script = remove_block("{{#SPOTLESS_REMOVEDIRT}}", "{{/SPOTLESS_REMOVEDIRT}}", script); + } + SpotLessMethod::RemoveDirt => { + script = remove_block("{{#SPOTLESS_CLASSIC}}", "{{/SPOTLESS_CLASSIC}}", script); + script = script.replace("{{#SPOTLESS_REMOVEDIRT}}", ""); + script = script.replace("{{/SPOTLESS_REMOVEDIRT}}", ""); + let sl = &pipeline.spotless; + script = script.replace("{{RD_GMTHRESHOLD}}", &sl.rd_gmthreshold.clamp(0, 255).to_string()); + script = script.replace("{{RD_NOISE}}", &sl.rd_noise.clamp(0, 255).to_string()); + script = script.replace("{{RD_NOISY}}", &sl.rd_noisy.clamp(0, 255).to_string()); + script = script.replace("{{RD_DIST}}", &sl.rd_dist.clamp(0, 8).to_string()); + script = script.replace("{{RD_POST_DENOISE}}", if sl.rd_post_denoise { "True" } else { "False" }); + } + } script = script.replace("{{#SPOTLESS}}", ""); script = script.replace("{{/SPOTLESS}}", ""); @@ -634,6 +697,18 @@ impl ScriptGenerator { // ==================================================================== let nr = &pipeline.noise_reduction; if nr.enabled { + // ContraSharpening brackets the denoise, so it is emitted as two blocks + // around it rather than as a method. + if nr.contra_sharpen { + script = script.replace("{{#NR_POST_CONTRASHARP}}", ""); + script = script.replace("{{/NR_POST_CONTRASHARP}}", ""); + script = script.replace( + "{{NR_POST_CONTRASHARP_REP}}", + &nr.contra_sharpen_rep.to_string(), + ); + } else { + script = remove_block("{{#NR_POST_CONTRASHARP}}", "{{/NR_POST_CONTRASHARP}}", script); + } script = script.replace("{{#NOISE_REDUCTION}}", ""); script = script.replace("{{/NOISE_REDUCTION}}", ""); @@ -644,6 +719,15 @@ impl ScriptGenerator { script = remove_block("{{#NR_MCTD}}", "{{/NR_MCTD}}", script); script = remove_block("{{#NR_MCDEGRAINSHARP}}", "{{/NR_MCDEGRAINSHARP}}", script); script = remove_block("{{#NR_BM3D}}", "{{/NR_BM3D}}", script); + script = remove_block("{{#NR_CTMF}}", "{{/NR_CTMF}}", script); + script = remove_block("{{#NR_FLUXSMOOTH_T}}", "{{/NR_FLUXSMOOTH_T}}", script); + script = remove_block("{{#NR_FLUXSMOOTH_ST}}", "{{/NR_FLUXSMOOTH_ST}}", script); + script = remove_block("{{#NR_STPRESSO}}", "{{/NR_STPRESSO}}", script); + script = remove_block("{{#NR_MCLEAN}}", "{{/NR_MCLEAN}}", script); + script = remove_block("{{#NR_TD2}}", "{{/NR_TD2}}", script); + script = remove_block("{{#NR_DFTTEST}}", "{{/NR_DFTTEST}}", script); + script = remove_block("{{#NR_FFT3D}}", "{{/NR_FFT3D}}", script); + script = remove_block("{{#NR_TTEMPSMOOTH}}", "{{/NR_TTEMPSMOOTH}}", script); script = process_optional_int("NR_TR", Some(nr.sm_degrain_tr), script); script = process_optional_int("NR_TH_SAD", Some(nr.sm_degrain_th_sad), script); @@ -658,6 +742,15 @@ impl ScriptGenerator { script = script.replace("{{/NR_MCTD}}", ""); script = remove_block("{{#NR_MCDEGRAINSHARP}}", "{{/NR_MCDEGRAINSHARP}}", script); script = remove_block("{{#NR_BM3D}}", "{{/NR_BM3D}}", script); + script = remove_block("{{#NR_CTMF}}", "{{/NR_CTMF}}", script); + script = remove_block("{{#NR_FLUXSMOOTH_T}}", "{{/NR_FLUXSMOOTH_T}}", script); + script = remove_block("{{#NR_FLUXSMOOTH_ST}}", "{{/NR_FLUXSMOOTH_ST}}", script); + script = remove_block("{{#NR_STPRESSO}}", "{{/NR_STPRESSO}}", script); + script = remove_block("{{#NR_MCLEAN}}", "{{/NR_MCLEAN}}", script); + script = remove_block("{{#NR_TD2}}", "{{/NR_TD2}}", script); + script = remove_block("{{#NR_DFTTEST}}", "{{/NR_DFTTEST}}", script); + script = remove_block("{{#NR_FFT3D}}", "{{/NR_FFT3D}}", script); + script = remove_block("{{#NR_TTEMPSMOOTH}}", "{{/NR_TTEMPSMOOTH}}", script); script = process_optional_string("NR_SETTINGS", Some(&nr.mc_temporal_profile), script); script = process_optional_double("NR_SIGMA", Some(nr.mc_temporal_sigma), script); @@ -669,6 +762,15 @@ impl ScriptGenerator { script = script.replace("{{#NR_MCDEGRAINSHARP}}", ""); script = script.replace("{{/NR_MCDEGRAINSHARP}}", ""); script = remove_block("{{#NR_BM3D}}", "{{/NR_BM3D}}", script); + script = remove_block("{{#NR_CTMF}}", "{{/NR_CTMF}}", script); + script = remove_block("{{#NR_FLUXSMOOTH_T}}", "{{/NR_FLUXSMOOTH_T}}", script); + script = remove_block("{{#NR_FLUXSMOOTH_ST}}", "{{/NR_FLUXSMOOTH_ST}}", script); + script = remove_block("{{#NR_STPRESSO}}", "{{/NR_STPRESSO}}", script); + script = remove_block("{{#NR_MCLEAN}}", "{{/NR_MCLEAN}}", script); + script = remove_block("{{#NR_TD2}}", "{{/NR_TD2}}", script); + script = remove_block("{{#NR_DFTTEST}}", "{{/NR_DFTTEST}}", script); + script = remove_block("{{#NR_FFT3D}}", "{{/NR_FFT3D}}", script); + script = remove_block("{{#NR_TTEMPSMOOTH}}", "{{/NR_TTEMPSMOOTH}}", script); // The blur/sharpen references must cover the same planes the // degrain does, so both derive from one selector. @@ -701,18 +803,303 @@ impl ScriptGenerator { } } } + NoiseReductionMethod::DfTtest => { + script = remove_block("{{#NR_SMDEGRAIN}}", "{{/NR_SMDEGRAIN}}", script); + script = remove_block("{{#NR_MCTD}}", "{{/NR_MCTD}}", script); + script = remove_block("{{#NR_MCDEGRAINSHARP}}", "{{/NR_MCDEGRAINSHARP}}", script); + script = remove_block("{{#NR_BM3D}}", "{{/NR_BM3D}}", script); + script = remove_block("{{#NR_CTMF}}", "{{/NR_CTMF}}", script); + script = remove_block("{{#NR_FLUXSMOOTH_T}}", "{{/NR_FLUXSMOOTH_T}}", script); + script = remove_block("{{#NR_FLUXSMOOTH_ST}}", "{{/NR_FLUXSMOOTH_ST}}", script); + script = remove_block("{{#NR_STPRESSO}}", "{{/NR_STPRESSO}}", script); + script = remove_block("{{#NR_MCLEAN}}", "{{/NR_MCLEAN}}", script); + script = remove_block("{{#NR_TD2}}", "{{/NR_TD2}}", script); + script = remove_block("{{#NR_FFT3D}}", "{{/NR_FFT3D}}", script); + script = remove_block("{{#NR_TTEMPSMOOTH}}", "{{/NR_TTEMPSMOOTH}}", script); + script = script.replace("{{#NR_DFTTEST}}", ""); + script = script.replace("{{/NR_DFTTEST}}", ""); + + script = script.replace( + "{{NR_DFTTEST_SIGMA}}", + &format_double(nr.dfttest_sigma), + ); + // Forced odd, so the temporal window stays centred on the + // current frame. + script = script.replace( + "{{NR_DFTTEST_TBSIZE}}", + &nr.dfttest_effective_tbsize().to_string(), + ); + script = script.replace( + "{{NR_DFTTEST_SBSIZE}}", + &nr.dfttest_sbsize.to_string(), + ); + } + NoiseReductionMethod::Fft3dFilter => { + script = remove_block("{{#NR_SMDEGRAIN}}", "{{/NR_SMDEGRAIN}}", script); + script = remove_block("{{#NR_MCTD}}", "{{/NR_MCTD}}", script); + script = remove_block("{{#NR_MCDEGRAINSHARP}}", "{{/NR_MCDEGRAINSHARP}}", script); + script = remove_block("{{#NR_BM3D}}", "{{/NR_BM3D}}", script); + script = remove_block("{{#NR_CTMF}}", "{{/NR_CTMF}}", script); + script = remove_block("{{#NR_FLUXSMOOTH_T}}", "{{/NR_FLUXSMOOTH_T}}", script); + script = remove_block("{{#NR_FLUXSMOOTH_ST}}", "{{/NR_FLUXSMOOTH_ST}}", script); + script = remove_block("{{#NR_STPRESSO}}", "{{/NR_STPRESSO}}", script); + script = remove_block("{{#NR_MCLEAN}}", "{{/NR_MCLEAN}}", script); + script = remove_block("{{#NR_TD2}}", "{{/NR_TD2}}", script); + script = remove_block("{{#NR_DFTTEST}}", "{{/NR_DFTTEST}}", script); + script = remove_block("{{#NR_TTEMPSMOOTH}}", "{{/NR_TTEMPSMOOTH}}", script); + script = script.replace("{{#NR_FFT3D}}", ""); + script = script.replace("{{/NR_FFT3D}}", ""); + + script = script.replace( + "{{NR_FFT3D_SIGMA}}", + &format_double(nr.fft3d_sigma), + ); + script = script.replace( + "{{NR_FFT3D_BT}}", + &nr.fft3d_effective_bt().to_string(), + ); + // Omitted at 0 so the plugin's own default applies rather + // than an explicit no-op argument. + script = process_optional_double( + "NR_FFT3D_SHARPEN", + if nr.fft3d_sharpen > 0.0 { Some(nr.fft3d_sharpen) } else { None }, + script, + ); + } + NoiseReductionMethod::TTempSmooth => { + script = remove_block("{{#NR_SMDEGRAIN}}", "{{/NR_SMDEGRAIN}}", script); + script = remove_block("{{#NR_MCTD}}", "{{/NR_MCTD}}", script); + script = remove_block("{{#NR_MCDEGRAINSHARP}}", "{{/NR_MCDEGRAINSHARP}}", script); + script = remove_block("{{#NR_BM3D}}", "{{/NR_BM3D}}", script); + script = remove_block("{{#NR_CTMF}}", "{{/NR_CTMF}}", script); + script = remove_block("{{#NR_FLUXSMOOTH_T}}", "{{/NR_FLUXSMOOTH_T}}", script); + script = remove_block("{{#NR_FLUXSMOOTH_ST}}", "{{/NR_FLUXSMOOTH_ST}}", script); + script = remove_block("{{#NR_STPRESSO}}", "{{/NR_STPRESSO}}", script); + script = remove_block("{{#NR_MCLEAN}}", "{{/NR_MCLEAN}}", script); + script = remove_block("{{#NR_TD2}}", "{{/NR_TD2}}", script); + script = remove_block("{{#NR_DFTTEST}}", "{{/NR_DFTTEST}}", script); + script = remove_block("{{#NR_FFT3D}}", "{{/NR_FFT3D}}", script); + script = script.replace("{{#NR_TTEMPSMOOTH}}", ""); + script = script.replace("{{/NR_TTEMPSMOOTH}}", ""); + + script = script.replace( + "{{NR_TTEMP_MAXR}}", + &nr.ttemp_effective_maxr().to_string(), + ); + script = script.replace( + "{{NR_TTEMP_THRESH}}", + &nr.ttemp_effective_thresh().to_string(), + ); + // Held below thresh: equal or greater is accepted by the + // plugin but silently disables its motion protection. + script = script.replace( + "{{NR_TTEMP_MDIFF}}", + &nr.ttemp_effective_mdiff().to_string(), + ); + script = script.replace( + "{{NR_TTEMP_STRENGTH}}", + &nr.ttemp_strength.to_string(), + ); + } + NoiseReductionMethod::FluxSmoothT | NoiseReductionMethod::FluxSmoothSt => { + script = remove_block("{{#NR_SMDEGRAIN}}", "{{/NR_SMDEGRAIN}}", script); + script = remove_block("{{#NR_MCTD}}", "{{/NR_MCTD}}", script); + script = remove_block("{{#NR_MCDEGRAINSHARP}}", "{{/NR_MCDEGRAINSHARP}}", script); + script = remove_block("{{#NR_BM3D}}", "{{/NR_BM3D}}", script); + script = remove_block("{{#NR_CTMF}}", "{{/NR_CTMF}}", script); + script = remove_block("{{#NR_DFTTEST}}", "{{/NR_DFTTEST}}", script); + script = remove_block("{{#NR_FFT3D}}", "{{/NR_FFT3D}}", script); + script = remove_block("{{#NR_TTEMPSMOOTH}}", "{{/NR_TTEMPSMOOTH}}", script); + script = remove_block("{{#NR_STPRESSO}}", "{{/NR_STPRESSO}}", script); + script = remove_block("{{#NR_MCLEAN}}", "{{/NR_MCLEAN}}", script); + script = remove_block("{{#NR_TD2}}", "{{/NR_TD2}}", script); + + let spatial = nr.method == NoiseReductionMethod::FluxSmoothSt; + let (keep, drop) = if spatial { + ("NR_FLUXSMOOTH_ST", "NR_FLUXSMOOTH_T") + } else { + ("NR_FLUXSMOOTH_T", "NR_FLUXSMOOTH_ST") + }; + script = script.replace(&format!("{{{{#{}}}}}", keep), ""); + script = script.replace(&format!("{{{{/{}}}}}", keep), ""); + script = remove_block( + &format!("{{{{#{}}}}}", drop), + &format!("{{{{/{}}}}}", drop), + script, + ); + + script = script.replace( + "{{NR_FLUX_TEMPORAL}}", + &nr.flux_effective_temporal().to_string(), + ); + script = script.replace( + "{{NR_FLUX_SPATIAL}}", + &nr.flux_effective_spatial().to_string(), + ); + } + NoiseReductionMethod::StPresso => { + script = remove_block("{{#NR_SMDEGRAIN}}", "{{/NR_SMDEGRAIN}}", script); + script = remove_block("{{#NR_MCTD}}", "{{/NR_MCTD}}", script); + script = remove_block("{{#NR_MCDEGRAINSHARP}}", "{{/NR_MCDEGRAINSHARP}}", script); + script = remove_block("{{#NR_BM3D}}", "{{/NR_BM3D}}", script); + script = remove_block("{{#NR_CTMF}}", "{{/NR_CTMF}}", script); + script = remove_block("{{#NR_MCLEAN}}", "{{/NR_MCLEAN}}", script); + script = remove_block("{{#NR_TD2}}", "{{/NR_TD2}}", script); + script = remove_block("{{#NR_DFTTEST}}", "{{/NR_DFTTEST}}", script); + script = remove_block("{{#NR_FFT3D}}", "{{/NR_FFT3D}}", script); + script = remove_block("{{#NR_TTEMPSMOOTH}}", "{{/NR_TTEMPSMOOTH}}", script); + script = remove_block("{{#NR_FLUXSMOOTH_T}}", "{{/NR_FLUXSMOOTH_T}}", script); + script = remove_block("{{#NR_FLUXSMOOTH_ST}}", "{{/NR_FLUXSMOOTH_ST}}", script); + script = script.replace("{{#NR_STPRESSO}}", ""); + script = script.replace("{{/NR_STPRESSO}}", ""); + + script = script.replace("{{NR_STPRESSO_LIMIT}}", &nr.stpresso_limit.to_string()); + script = script.replace("{{NR_STPRESSO_BIAS}}", &nr.stpresso_bias.to_string()); + script = script.replace("{{NR_STPRESSO_TTHR}}", &nr.stpresso_tthr.to_string()); + } + NoiseReductionMethod::MClean => { + script = remove_block("{{#NR_SMDEGRAIN}}", "{{/NR_SMDEGRAIN}}", script); + script = remove_block("{{#NR_MCTD}}", "{{/NR_MCTD}}", script); + script = remove_block("{{#NR_MCDEGRAINSHARP}}", "{{/NR_MCDEGRAINSHARP}}", script); + script = remove_block("{{#NR_BM3D}}", "{{/NR_BM3D}}", script); + script = remove_block("{{#NR_DFTTEST}}", "{{/NR_DFTTEST}}", script); + script = remove_block("{{#NR_FFT3D}}", "{{/NR_FFT3D}}", script); + script = remove_block("{{#NR_TTEMPSMOOTH}}", "{{/NR_TTEMPSMOOTH}}", script); + script = remove_block("{{#NR_FLUXSMOOTH_T}}", "{{/NR_FLUXSMOOTH_T}}", script); + script = remove_block("{{#NR_FLUXSMOOTH_ST}}", "{{/NR_FLUXSMOOTH_ST}}", script); + script = remove_block("{{#NR_STPRESSO}}", "{{/NR_STPRESSO}}", script); + script = remove_block("{{#NR_TD2}}", "{{/NR_TD2}}", script); + script = remove_block("{{#NR_CTMF}}", "{{/NR_CTMF}}", script); + script = script.replace("{{#NR_MCLEAN}}", ""); + script = script.replace("{{/NR_MCLEAN}}", ""); + script = script.replace("{{NR_MCLEAN_THSAD}}", &nr.mclean_thsad.clamp(0, 10000).to_string()); + script = script.replace("{{NR_MCLEAN_CHROMA}}", if nr.mclean_chroma { "True" } else { "False" }); + script = script.replace("{{NR_MCLEAN_SHARP}}", &nr.mclean_sharp.clamp(0, 24).to_string()); + script = script.replace("{{NR_MCLEAN_RN}}", &nr.mclean_rn.clamp(0, 20).to_string()); + script = script.replace("{{NR_MCLEAN_STRENGTH}}", &nr.mclean_strength.clamp(0, 20).to_string()); + } + NoiseReductionMethod::TemporalDegrain2 => { + script = remove_block("{{#NR_SMDEGRAIN}}", "{{/NR_SMDEGRAIN}}", script); + script = remove_block("{{#NR_MCTD}}", "{{/NR_MCTD}}", script); + script = remove_block("{{#NR_MCDEGRAINSHARP}}", "{{/NR_MCDEGRAINSHARP}}", script); + script = remove_block("{{#NR_BM3D}}", "{{/NR_BM3D}}", script); + script = remove_block("{{#NR_DFTTEST}}", "{{/NR_DFTTEST}}", script); + script = remove_block("{{#NR_FFT3D}}", "{{/NR_FFT3D}}", script); + script = remove_block("{{#NR_TTEMPSMOOTH}}", "{{/NR_TTEMPSMOOTH}}", script); + script = remove_block("{{#NR_FLUXSMOOTH_T}}", "{{/NR_FLUXSMOOTH_T}}", script); + script = remove_block("{{#NR_FLUXSMOOTH_ST}}", "{{/NR_FLUXSMOOTH_ST}}", script); + script = remove_block("{{#NR_STPRESSO}}", "{{/NR_STPRESSO}}", script); + script = remove_block("{{#NR_MCLEAN}}", "{{/NR_MCLEAN}}", script); + script = remove_block("{{#NR_CTMF}}", "{{/NR_CTMF}}", script); + script = script.replace("{{#NR_TD2}}", ""); + script = script.replace("{{/NR_TD2}}", ""); + script = script.replace("{{NR_TD2_TR}}", &nr.td2_degrain_tr.clamp(1, 3).to_string()); + script = script.replace("{{NR_TD2_GRAIN_LEVEL}}", &nr.td2_grain_level.clamp(-2, 3).to_string()); + // 4 and 5 abort the process rather than raising; the + // module clamps too, but never send them. + script = script.replace("{{NR_TD2_POST_FFT}}", &nr.td2_post_fft.clamp(0, 3).to_string()); + script = script.replace("{{NR_TD2_POST_SIGMA}}", &format_double(nr.td2_post_sigma.clamp(0.0, 16.0))); + script = script.replace("{{NR_TD2_POST_MIX}}", &nr.td2_post_mix.clamp(0, 100).to_string()); + script = script.replace("{{NR_TD2_CHROMA_MOTION}}", if nr.td2_chroma_motion { "True" } else { "False" }); + } + NoiseReductionMethod::Ctmf => { + script = remove_block("{{#NR_SMDEGRAIN}}", "{{/NR_SMDEGRAIN}}", script); + script = remove_block("{{#NR_MCTD}}", "{{/NR_MCTD}}", script); + script = remove_block("{{#NR_MCDEGRAINSHARP}}", "{{/NR_MCDEGRAINSHARP}}", script); + script = remove_block("{{#NR_BM3D}}", "{{/NR_BM3D}}", script); + script = remove_block("{{#NR_DFTTEST}}", "{{/NR_DFTTEST}}", script); + script = remove_block("{{#NR_FFT3D}}", "{{/NR_FFT3D}}", script); + script = remove_block("{{#NR_TTEMPSMOOTH}}", "{{/NR_TTEMPSMOOTH}}", script); + script = remove_block("{{#NR_FLUXSMOOTH_T}}", "{{/NR_FLUXSMOOTH_T}}", script); + script = remove_block("{{#NR_FLUXSMOOTH_ST}}", "{{/NR_FLUXSMOOTH_ST}}", script); + script = remove_block("{{#NR_STPRESSO}}", "{{/NR_STPRESSO}}", script); + script = remove_block("{{#NR_MCLEAN}}", "{{/NR_MCLEAN}}", script); + script = remove_block("{{#NR_TD2}}", "{{/NR_TD2}}", script); + script = script.replace("{{#NR_CTMF}}", ""); + script = script.replace("{{/NR_CTMF}}", ""); + + script = script.replace( + "{{NR_CTMF_RADIUS}}", + &nr.ctmf_effective_radius().to_string(), + ); + script = script.replace("{{NR_CTMF_PLANES}}", nr.ctmf_planes_literal()); + } NoiseReductionMethod::QtgmcBuiltin => { // QTGMC built-in denoising is handled in the QTGMC pass itself script = remove_block("{{#NR_SMDEGRAIN}}", "{{/NR_SMDEGRAIN}}", script); script = remove_block("{{#NR_MCTD}}", "{{/NR_MCTD}}", script); script = remove_block("{{#NR_MCDEGRAINSHARP}}", "{{/NR_MCDEGRAINSHARP}}", script); script = remove_block("{{#NR_BM3D}}", "{{/NR_BM3D}}", script); + script = remove_block("{{#NR_CTMF}}", "{{/NR_CTMF}}", script); + script = remove_block("{{#NR_FLUXSMOOTH_T}}", "{{/NR_FLUXSMOOTH_T}}", script); + script = remove_block("{{#NR_FLUXSMOOTH_ST}}", "{{/NR_FLUXSMOOTH_ST}}", script); + script = remove_block("{{#NR_STPRESSO}}", "{{/NR_STPRESSO}}", script); + script = remove_block("{{#NR_MCLEAN}}", "{{/NR_MCLEAN}}", script); + script = remove_block("{{#NR_TD2}}", "{{/NR_TD2}}", script); + script = remove_block("{{#NR_DFTTEST}}", "{{/NR_DFTTEST}}", script); + script = remove_block("{{#NR_FFT3D}}", "{{/NR_FFT3D}}", script); + script = remove_block("{{#NR_TTEMPSMOOTH}}", "{{/NR_TTEMPSMOOTH}}", script); } } } else { script = remove_block("{{#NOISE_REDUCTION}}", "{{/NOISE_REDUCTION}}", script); } + // ==================================================================== + // EDGE REPAIR / GHOST REMOVAL PASSES + // ==================================================================== + let er = &pipeline.edge_repair; + if er.has_effect() { + script = script.replace("{{#EDGE_REPAIR}}", ""); + script = script.replace("{{/EDGE_REPAIR}}", ""); + script = script.replace("{{ER_LEFT}}", &EdgeRepairParameters::even(er.left).to_string()); + script = script.replace("{{ER_RIGHT}}", &EdgeRepairParameters::even(er.right).to_string()); + script = script.replace("{{ER_TOP}}", &EdgeRepairParameters::even(er.top).to_string()); + script = script.replace("{{ER_BOTTOM}}", &EdgeRepairParameters::even(er.bottom).to_string()); + script = script.replace("{{ER_MODE}}", er.effective_mode()); + } else { + script = remove_block("{{#EDGE_REPAIR}}", "{{/EDGE_REPAIR}}", script); + } + + let gr = &pipeline.ghost_removal; + if gr.has_effect() { + let (modes, shifts, intensities) = gr.literals(); + script = script.replace("{{#GHOST_REMOVAL}}", ""); + script = script.replace("{{/GHOST_REMOVAL}}", ""); + script = script.replace("{{LG_MODE}}", &modes); + script = script.replace("{{LG_SHIFT}}", &shifts); + script = script.replace("{{LG_INTENSITY}}", &intensities); + } else { + script = remove_block("{{#GHOST_REMOVAL}}", "{{/GHOST_REMOVAL}}", script); + } + + // ==================================================================== + // DEFLICKER PASS + // ==================================================================== + let dfl = &pipeline.deflicker; + if dfl.enabled { + script = script.replace("{{#DEFLICKER}}", ""); + script = script.replace("{{/DEFLICKER}}", ""); + match dfl.method { + DeflickerMethod::Global => { + script = script.replace("{{#DEFLICKER_GLOBAL}}", ""); + script = script.replace("{{/DEFLICKER_GLOBAL}}", ""); + script = remove_block("{{#DEFLICKER_LOCAL}}", "{{/DEFLICKER_LOCAL}}", script); + script = script.replace("{{DEFLICKER_STRENGTH}}", &format_double(dfl.strength.clamp(0.0, 1.0))); + script = script.replace("{{DEFLICKER_WINDOW}}", &dfl.effective_window().to_string()); + } + DeflickerMethod::Local => { + script = remove_block("{{#DEFLICKER_GLOBAL}}", "{{/DEFLICKER_GLOBAL}}", script); + script = script.replace("{{#DEFLICKER_LOCAL}}", ""); + script = script.replace("{{/DEFLICKER_LOCAL}}", ""); + script = script.replace("{{DEFLICKER_LOCAL_STRENGTH}}", &dfl.effective_local_strength().to_string()); + script = script.replace("{{DEFLICKER_AGGRESSIVE}}", if dfl.aggressive { "True" } else { "False" }); + } + } + } else { + script = remove_block("{{#DEFLICKER}}", "{{/DEFLICKER}}", script); + } + // ==================================================================== // CHROMA DENOISE PASS (CCD) // ==================================================================== @@ -720,6 +1107,42 @@ impl ScriptGenerator { if chroma_denoise.enabled { script = script.replace("{{#CHROMA_DENOISE}}", ""); script = script.replace("{{/CHROMA_DENOISE}}", ""); + + // One method runs; the other block is removed entirely rather than + // left with unsubstituted placeholders. + match chroma_denoise.method { + ChromaDenoiseMethod::Ccd => { + script = script.replace("{{#CHROMA_DENOISE_CCD}}", ""); + script = script.replace("{{/CHROMA_DENOISE_CCD}}", ""); + script = remove_block( + "{{#CHROMA_DENOISE_CNR4}}", + "{{/CHROMA_DENOISE_CNR4}}", + script, + ); + } + ChromaDenoiseMethod::Cnr4 => { + script = remove_block( + "{{#CHROMA_DENOISE_CCD}}", + "{{/CHROMA_DENOISE_CCD}}", + script, + ); + script = script.replace("{{#CHROMA_DENOISE_CNR4}}", ""); + script = script.replace("{{/CHROMA_DENOISE_CNR4}}", ""); + script = script + .replace("{{CNR4_SENSE}}", &chroma_denoise.cnr4_sense_literal()); + script = script + .replace("{{CNR4_STRENGTH}}", &chroma_denoise.cnr4_strength_literal()); + script = script.replace( + "{{CNR4_RADIUS}}", + &chroma_denoise.effective_cnr4_radius().to_string(), + ); + script = script + .replace("{{CNR4_TMODE}}", &chroma_denoise.cnr4_tmode.clamp(0, 3).to_string()); + script = script + .replace("{{CNR4_WMODE}}", &chroma_denoise.cnr4_wmode.clamp(0, 2).to_string()); + } + } + script = script.replace("{{CCD_THRESHOLD}}", &format_double(chroma_denoise.threshold)); script = script.replace( "{{CCD_TEMPORAL_RADIUS}}", @@ -759,13 +1182,14 @@ 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; 6] = [ + const DEHALO_BLOCKS: [&str; 7] = [ "DEHALO_DEHALO_ALPHA", "DEHALO_FINE_DEHALO", "DEHALO_FINE_DEHALO2", "DEHALO_YAHR", "DEHALO_EDGE_CLEANER", "DEHALO_VINVERSE", + "DEHALO_HQDERING", ]; let selected = match dehalo.method { @@ -777,6 +1201,7 @@ impl ScriptGenerator { // Both Vinverse variants share one block; only the function // name differs. DehaloMethod::Vinverse | DehaloMethod::Vinverse2 => "DEHALO_VINVERSE", + DehaloMethod::HqDeringmod => "DEHALO_HQDERING", }; for block in DEHALO_BLOCKS { @@ -823,6 +1248,13 @@ impl ScriptGenerator { script = process_optional_int("DEHALO_VINVERSE_AMNT", dehalo.vinverse_amount, script); script = process_optional_bool("DEHALO_VINVERSE_CHROMA", dehalo.vinverse_chroma, script); } + DehaloMethod::HqDeringmod => { + script = process_optional_int("DEHALO_DERING_MRAD", dehalo.dering_mrad, script); + script = process_optional_int("DEHALO_DERING_MSMOOTH", dehalo.dering_msmooth, script); + script = process_optional_int("DEHALO_DERING_MTHR", dehalo.dering_mthr, script); + script = process_optional_double("DEHALO_DERING_THR", dehalo.dering_thr, script); + script = process_optional_double("DEHALO_DERING_DARKTHR", dehalo.dering_darkthr, script); + } } // Radius and strength are shared by DeHalo_alpha and FineDehalo only. @@ -849,6 +1281,7 @@ impl ScriptGenerator { script = script.replace("{{#DEBLOCK_QED}}", ""); script = script.replace("{{/DEBLOCK_QED}}", ""); script = remove_block("{{#DEBLOCK_SIMPLE}}", "{{/DEBLOCK_SIMPLE}}", script); + script = remove_block("{{#DEBLOCK_DCTFILTER}}", "{{/DEBLOCK_DCTFILTER}}", script); script = process_optional_int("DEBLOCK_QUANT1", Some(deblock.quant1), script); script = process_optional_int("DEBLOCK_QUANT2", Some(deblock.quant2), script); @@ -859,9 +1292,25 @@ impl ScriptGenerator { script = remove_block("{{#DEBLOCK_QED}}", "{{/DEBLOCK_QED}}", script); script = script.replace("{{#DEBLOCK_SIMPLE}}", ""); script = script.replace("{{/DEBLOCK_SIMPLE}}", ""); + script = remove_block("{{#DEBLOCK_DCTFILTER}}", "{{/DEBLOCK_DCTFILTER}}", script); script = process_optional_int("DEBLOCK_QUANT1", Some(deblock.quant1), script); } + DeblockMethod::DctFilter => { + script = remove_block("{{#DEBLOCK_QED}}", "{{/DEBLOCK_QED}}", script); + script = remove_block("{{#DEBLOCK_SIMPLE}}", "{{/DEBLOCK_SIMPLE}}", script); + script = script.replace("{{#DEBLOCK_DCTFILTER}}", ""); + script = script.replace("{{/DEBLOCK_DCTFILTER}}", ""); + + script = script.replace( + "{{DEBLOCK_DCT_FACTORS}}", + &deblock.dct_factors_literal(), + ); + script = script.replace( + "{{DEBLOCK_DCT_PLANES}}", + deblock.dct_planes_literal(), + ); + } } } else { script = remove_block("{{#DEBLOCK}}", "{{/DEBLOCK}}", script); @@ -887,6 +1336,157 @@ impl ScriptGenerator { script = remove_block("{{#DEBAND}}", "{{/DEBAND}}", script); } + // ==================================================================== + // ANTI-ALIASING PASS + // ==================================================================== + let anti_alias = &pipeline.anti_alias; + if anti_alias.enabled { + script = script.replace("{{#ANTI_ALIAS}}", ""); + script = script.replace("{{/ANTI_ALIAS}}", ""); + + // znedi3's double-rate mode REQUIRES the _FieldBased property and + // fails the job with "znedi3: _FieldBased" without it — measured + // against the bundled plugin, field=3 errors when it is absent and + // is fine at either 0 or 2. havsfunc's daa uses field=3, and the + // property is only set upstream when a field order is known, so an + // ordinary source with none killed the pass on the x86 bundles. + // + // Always mark the clip rather than depending on an upstream pass: + // 0 once deinterlacing has run (its output is progressive) or when + // nothing was detected, the detected order otherwise. Uses its own + // placeholder rather than {{FIELD_BASED}} so it does not depend on + // the outer substitution having run first. + let aa_field_based = if pipeline.deinterlace.enabled { + 0 + } else { + Self::field_based_for(job, pipeline).unwrap_or(0) + }; + script = script.replace("{{AA_FIELD_BASED_VALUE}}", &aa_field_based.to_string()); + + match anti_alias.method { + AntiAliasMethod::Daa => { + script = script.replace("{{#AA_DAA}}", ""); + script = script.replace("{{/AA_DAA}}", ""); + script = remove_block("{{#AA_SANTIAG}}", "{{/AA_SANTIAG}}", script); + } + AntiAliasMethod::Santiag => { + script = remove_block("{{#AA_DAA}}", "{{/AA_DAA}}", script); + script = script.replace("{{#AA_SANTIAG}}", ""); + script = script.replace("{{/AA_SANTIAG}}", ""); + + script = script.replace("{{AA_STRH}}", &anti_alias.effective_strh().to_string()); + script = script.replace("{{AA_STRV}}", &anti_alias.effective_strv().to_string()); + // Restricted to what the bundle actually ships. + script = script.replace("{{AA_TYPE}}", anti_alias.effective_santiag_type()); + } + } + } else { + script = remove_block("{{#ANTI_ALIAS}}", "{{/ANTI_ALIAS}}", script); + } + + // ==================================================================== + // STABILIZE PASS + // ==================================================================== + let stabilize = &pipeline.stabilize; + if stabilize.enabled { + script = script.replace("{{#STABILIZE}}", ""); + script = script.replace("{{/STABILIZE}}", ""); + script = script.replace("{{STAB_DXMAX}}", &stabilize.effective_dxmax().to_string()); + script = script.replace("{{STAB_DYMAX}}", &stabilize.effective_dymax().to_string()); + script = script.replace("{{STAB_MIRROR}}", &stabilize.effective_mirror().to_string()); + } else { + script = remove_block("{{#STABILIZE}}", "{{/STABILIZE}}", script); + } + + // ==================================================================== + // FILM GRAIN PASS + // ==================================================================== + let grain = &pipeline.grain; + if grain.has_effect() { + script = script.replace("{{#GRAIN}}", ""); + script = script.replace("{{/GRAIN}}", ""); + + match grain.method { + GrainMethod::AddGrain => { + script = script.replace("{{#GRAIN_ADD}}", ""); + script = script.replace("{{/GRAIN_ADD}}", ""); + script = remove_block("{{#GRAIN_FACTORY3}}", "{{/GRAIN_FACTORY3}}", script); + + script = script.replace("{{GRAIN_VAR}}", &format_double(grain.var)); + script = script.replace("{{GRAIN_UVAR}}", &format_double(grain.uvar)); + // Capped below 1.0, which the plugin accepts but which wraps + // back to uncorrelated noise at full amplitude. + script = script.replace( + "{{GRAIN_CORR}}", + &format_double(grain.effective_corr()), + ); + script = script.replace( + "{{GRAIN_CONSTANT}}", + if grain.constant { "True" } else { "False" }, + ); + } + GrainMethod::GrainFactory3 => { + script = remove_block("{{#GRAIN_ADD}}", "{{/GRAIN_ADD}}", script); + script = script.replace("{{#GRAIN_FACTORY3}}", ""); + script = script.replace("{{/GRAIN_FACTORY3}}", ""); + + script = script.replace("{{GRAIN_G1}}", &format_double(grain.g1str)); + script = script.replace("{{GRAIN_G2}}", &format_double(grain.g2str)); + script = script.replace("{{GRAIN_G3}}", &format_double(grain.g3str)); + // Omitted at 0 so havsfunc's own default applies. + script = process_optional_int( + "GRAIN_TEMP_AVG", + if grain.effective_temp_avg() > 0 { + Some(grain.effective_temp_avg()) + } else { + None + }, + script, + ); + } + } + } else { + script = remove_block("{{#GRAIN}}", "{{/GRAIN}}", script); + } + + // ==================================================================== + // ROTATE / FLIP PASS + // ==================================================================== + let geometry = &pipeline.geometry; + if geometry.has_effect() { + script = script.replace("{{#GEOMETRY}}", ""); + script = script.replace("{{/GEOMETRY}}", ""); + + match geometry.rotation.function() { + Some(func) => { + script = script.replace("{{#GEOM_ROTATE}}", ""); + script = script.replace("{{/GEOM_ROTATE}}", ""); + script = script.replace("{{GEOM_ROTATE_FN}}", func); + } + None => { + script = remove_block("{{#GEOM_ROTATE}}", "{{/GEOM_ROTATE}}", script); + } + } + + for (flag, block) in [ + (geometry.flip_horizontal, "GEOM_FLIP_H"), + (geometry.flip_vertical, "GEOM_FLIP_V"), + ] { + let open = format!("{{{{#{}}}}}", block); + let close = format!("{{{{/{}}}}}", block); + if flag { + script = script.replace(&open, ""); + script = script.replace(&close, ""); + } else { + script = remove_block(&open, &close, script); + } + } + } else { + // Enabled with nothing chosen emits nothing, so the script says what + // actually runs. + script = remove_block("{{#GEOMETRY}}", "{{/GEOMETRY}}", script); + } + // ==================================================================== // SHARPEN PASS // ==================================================================== @@ -900,6 +1500,7 @@ impl ScriptGenerator { script = script.replace("{{#SHARPEN_LSFMOD}}", ""); script = script.replace("{{/SHARPEN_LSFMOD}}", ""); script = remove_block("{{#SHARPEN_CAS}}", "{{/SHARPEN_CAS}}", script); + script = remove_block("{{#SHARPEN_AWARPSHARP2}}", "{{/SHARPEN_AWARPSHARP2}}", script); script = process_optional_int("SHARPEN_STRENGTH", Some(sharpen.strength), script); script = process_optional_int("SHARPEN_OVERSHOOT", Some(sharpen.overshoot), script); @@ -910,9 +1511,24 @@ impl ScriptGenerator { script = remove_block("{{#SHARPEN_LSFMOD}}", "{{/SHARPEN_LSFMOD}}", script); script = script.replace("{{#SHARPEN_CAS}}", ""); script = script.replace("{{/SHARPEN_CAS}}", ""); + script = remove_block("{{#SHARPEN_AWARPSHARP2}}", "{{/SHARPEN_AWARPSHARP2}}", script); script = process_optional_double("SHARPEN_CAS_SHARPNESS", Some(sharpen.cas_sharpness), script); } + SharpenMethod::AWarpSharp2 => { + script = remove_block("{{#SHARPEN_LSFMOD}}", "{{/SHARPEN_LSFMOD}}", script); + script = remove_block("{{#SHARPEN_CAS}}", "{{/SHARPEN_CAS}}", script); + script = script.replace("{{#SHARPEN_AWARPSHARP2}}", ""); + script = script.replace("{{/SHARPEN_AWARPSHARP2}}", ""); + + script = script.replace("{{SHARPEN_WARP_THRESH}}", &sharpen.warp_thresh.to_string()); + script = script.replace( + "{{SHARPEN_WARP_BLUR}}", + &sharpen.warp_effective_blur().to_string(), + ); + script = script.replace("{{SHARPEN_WARP_TYPE}}", &sharpen.warp_type.to_string()); + script = script.replace("{{SHARPEN_WARP_DEPTH}}", &sharpen.warp_depth.to_string()); + } } } else { script = remove_block("{{#SHARPEN}}", "{{/SHARPEN}}", script); @@ -923,6 +1539,25 @@ impl ScriptGenerator { // ==================================================================== let chroma = &pipeline.chroma_fixes; if chroma.enabled { + if chroma.apply_auto_chroma { + script = script.replace("{{#CF_AUTO_CHROMA}}", ""); + script = script.replace("{{/CF_AUTO_CHROMA}}", ""); + script = script.replace("{{ACF_MAX_SHIFT}}", &chroma.auto_chroma_max_shift.clamp(1, 16).to_string()); + script = script.replace("{{ACF_ACCURACY}}", &format_double(chroma.auto_chroma_accuracy.clamp(0.05, 1.0))); + script = script.replace("{{ACF_REFERENCE_FRAME}}", &chroma.auto_chroma_reference_frame.max(-1).to_string()); + } else { + script = remove_block("{{#CF_AUTO_CHROMA}}", "{{/CF_AUTO_CHROMA}}", script); + } + if chroma.apply_dedot { + script = script.replace("{{#CF_DEDOT}}", ""); + script = script.replace("{{/CF_DEDOT}}", ""); + script = script.replace("{{DEDOT_LUMA_2D}}", &chroma.dedot_luma_2d.clamp(0, 510).to_string()); + script = script.replace("{{DEDOT_LUMA_T}}", &chroma.dedot_luma_t.clamp(0, 255).to_string()); + script = script.replace("{{DEDOT_CHROMA_T1}}", &chroma.dedot_chroma_t1.clamp(0, 255).to_string()); + script = script.replace("{{DEDOT_CHROMA_T2}}", &chroma.dedot_chroma_t2.clamp(0, 255).to_string()); + } else { + script = remove_block("{{#CF_DEDOT}}", "{{/CF_DEDOT}}", script); + } script = script.replace("{{#CHROMA_FIXES}}", ""); script = script.replace("{{/CHROMA_FIXES}}", ""); @@ -960,6 +1595,38 @@ impl ScriptGenerator { script = remove_block("{{#CHROMA_DECRAWL}}", "{{/CHROMA_DECRAWL}}", script); } + // LUTDeRainbow + if chroma.apply_de_rainbow { + script = script.replace("{{#CHROMA_DERAINBOW}}", ""); + script = script.replace("{{/CHROMA_DERAINBOW}}", ""); + script = process_optional_int("DERAINBOW_CTHRESH", Some(chroma.de_rainbow_c_thresh), script); + script = process_optional_int("DERAINBOW_YTHRESH", Some(chroma.de_rainbow_y_thresh), script); + script = process_optional_bool("DERAINBOW_Y", Some(chroma.de_rainbow_use_luma), script); + script = process_optional_bool("DERAINBOW_LINKUV", Some(chroma.de_rainbow_link_uv), script); + } else { + script = remove_block("{{#CHROMA_DERAINBOW}}", "{{/CHROMA_DERAINBOW}}", script); + } + + // Bifrost + if chroma.apply_bifrost { + script = script.replace("{{#CHROMA_BIFROST}}", ""); + script = script.replace("{{/CHROMA_BIFROST}}", ""); + script = script.replace( + "{{BIFROST_LUMA_THRESH}}", + &format_double(chroma.bifrost_luma_thresh), + ); + script = script.replace( + "{{BIFROST_VARIATION}}", + &chroma.bifrost_effective_variation().to_string(), + ); + script = script.replace( + "{{BIFROST_INTERLACED}}", + if chroma.bifrost_interlaced { "True" } else { "False" }, + ); + } else { + script = remove_block("{{#CHROMA_BIFROST}}", "{{/CHROMA_BIFROST}}", script); + } + // Vinverse if chroma.apply_vinverse { script = script.replace("{{#CHROMA_VINVERSE}}", ""); @@ -974,11 +1641,69 @@ impl ScriptGenerator { script = remove_block("{{#CHROMA_FIXES}}", "{{/CHROMA_FIXES}}", script); } + // ==================================================================== + // CUSTOM VAPOURSYNTH + // ==================================================================== + match Some(job.encoding_settings.custom_vapoursynth.trim()) { + Some(code) if !code.is_empty() => { + script = script.replace("{{#CUSTOM_VS}}", ""); + script = script.replace("{{/CUSTOM_VS}}", ""); + script = script.replace("{{CUSTOM_VS_CODE}}", code); + } + _ => { + script = remove_block("{{#CUSTOM_VS}}", "{{/CUSTOM_VS}}", script); + } + } + + // ==================================================================== + // FRAME RATE PASS + // ==================================================================== + let fr = &pipeline.frame_rate; + if fr.enabled { + script = script.replace("{{#FRAME_RATE}}", ""); + script = script.replace("{{/FRAME_RATE}}", ""); + let (num, den) = fr.target.as_fraction(); + script = script.replace("{{FPS_TARGET_NUM}}", &num.to_string()); + script = script.replace("{{FPS_TARGET_DEN}}", &den.to_string()); + match fr.method { + FrameRateMethod::FlowFps => { + script = script.replace("{{#FRAME_RATE_FLOWFPS}}", ""); + script = script.replace("{{/FRAME_RATE_FLOWFPS}}", ""); + script = remove_block("{{#FRAME_RATE_DUPLICATE}}", "{{/FRAME_RATE_DUPLICATE}}", script); + script = script.replace("{{FPS_BLOCK_SIZE}}", &fr.block_size.to_string()); + script = script.replace("{{FPS_OVERLAP}}", &fr.effective_overlap().to_string()); + } + FrameRateMethod::Duplicate => { + script = remove_block("{{#FRAME_RATE_FLOWFPS}}", "{{/FRAME_RATE_FLOWFPS}}", script); + script = script.replace("{{#FRAME_RATE_DUPLICATE}}", ""); + script = script.replace("{{/FRAME_RATE_DUPLICATE}}", ""); + } + } + } else { + script = remove_block("{{#FRAME_RATE}}", "{{/FRAME_RATE}}", script); + } + // ==================================================================== // COLOR CORRECTION PASS // ==================================================================== let color = &pipeline.color_correction; if color.enabled { + if color.apply_auto_levels { + script = script.replace("{{#CC_AUTO_LEVELS}}", ""); + script = script.replace("{{/CC_AUTO_LEVELS}}", ""); + script = script.replace("{{CC_AUTO_LEVELS_BLACK}}", &color.auto_levels_black.clamp(0, 255).to_string()); + script = script.replace("{{CC_AUTO_LEVELS_WHITE}}", &color.auto_levels_white.clamp(0, 255).to_string()); + script = script.replace("{{CC_AUTO_LEVELS_STRENGTH}}", &format_double(color.auto_levels_strength.clamp(0.0, 1.0))); + } else { + script = remove_block("{{#CC_AUTO_LEVELS}}", "{{/CC_AUTO_LEVELS}}", script); + } + if color.apply_auto_white_balance { + script = script.replace("{{#CC_AUTO_WHITE}}", ""); + script = script.replace("{{/CC_AUTO_WHITE}}", ""); + script = script.replace("{{CC_AUTO_WHITE_STRENGTH}}", &format_double(color.auto_white_balance_strength.clamp(0.0, 1.0))); + } else { + script = remove_block("{{#CC_AUTO_WHITE}}", "{{/CC_AUTO_WHITE}}", script); + } script = script.replace("{{#COLOR_CORRECTION}}", ""); script = script.replace("{{/COLOR_CORRECTION}}", ""); @@ -1006,16 +1731,65 @@ impl ScriptGenerator { || color.output_high != 255 || (color.gamma - 1.0).abs() > 0.001; - if has_levels { + if has_levels && !color.smooth_levels { script = script.replace("{{#COLOR_LEVELS}}", ""); script = script.replace("{{/COLOR_LEVELS}}", ""); + script = remove_block("{{#COLOR_SMOOTH_LEVELS}}", "{{/COLOR_SMOOTH_LEVELS}}", script); script = process_optional_int("LEVELS_INPUT_LOW", if color.input_low != 0 { Some(color.input_low) } else { None }, script); script = process_optional_int("LEVELS_INPUT_HIGH", if color.input_high != 255 { Some(color.input_high) } else { None }, script); script = process_optional_int("LEVELS_OUTPUT_LOW", if color.output_low != 0 { Some(color.output_low) } else { None }, script); script = process_optional_int("LEVELS_OUTPUT_HIGH", if color.output_high != 255 { Some(color.output_high) } else { None }, script); script = process_optional_double("LEVELS_GAMMA", if (color.gamma - 1.0).abs() > 0.001 { Some(color.gamma) } else { None }, script); + } else if has_levels { + // SmoothLevels reuses the _levels_8bit() helper defined in the + // plain block, so that block's scaffolding stays emitted while + // its std.Levels call does not. + script = script.replace("{{#COLOR_LEVELS}}", ""); + script = script.replace("{{/COLOR_LEVELS}}", ""); + script = process_optional_int("LEVELS_INPUT_LOW", None, script); + script = process_optional_int("LEVELS_INPUT_HIGH", None, script); + script = process_optional_int("LEVELS_OUTPUT_LOW", None, script); + script = process_optional_int("LEVELS_OUTPUT_HIGH", None, script); + script = process_optional_double("LEVELS_GAMMA", None, script); + script = script.replace("core.std.Levels(\n clip,\n)", "clip"); + + script = script.replace("{{#COLOR_SMOOTH_LEVELS}}", ""); + script = script.replace("{{/COLOR_SMOOTH_LEVELS}}", ""); + // The black point is dropped when gamma is not 1.0 — havsfunc's + // LUT goes complex below input_low and fails outright. + script = script.replace( + "{{SMOOTH_INPUT_LOW}}", + &color.smooth_levels_input_low().to_string(), + ); + script = script.replace("{{SMOOTH_INPUT_HIGH}}", &color.input_high.to_string()); + script = script.replace("{{SMOOTH_OUTPUT_LOW}}", &color.output_low.to_string()); + script = script.replace("{{SMOOTH_OUTPUT_HIGH}}", &color.output_high.to_string()); + script = script.replace("{{SMOOTH_GAMMA}}", &format_double(color.gamma)); + // -2 is havsfunc's own default and the best-measured setting. + script = script.replace("{{SMOOTH_MODE}}", "-2"); } else { script = remove_block("{{#COLOR_LEVELS}}", "{{/COLOR_LEVELS}}", script); + script = remove_block("{{#COLOR_SMOOTH_LEVELS}}", "{{/COLOR_SMOOTH_LEVELS}}", script); + } + + // Shadow detail (Retinex) + if color.apply_shadow_detail { + script = script.replace("{{#COLOR_SHADOW_DETAIL}}", ""); + script = script.replace("{{/COLOR_SHADOW_DETAIL}}", ""); + script = script.replace( + "{{SHADOW_SIGMA}}", + &format_double(color.shadow_effective_sigma()), + ); + script = script.replace( + "{{SHADOW_LOWER}}", + &format_double(color.shadow_effective_lower()), + ); + script = script.replace( + "{{SHADOW_UPPER}}", + &format_double(color.shadow_effective_upper()), + ); + } else { + script = remove_block("{{#COLOR_SHADOW_DETAIL}}", "{{/COLOR_SHADOW_DETAIL}}", script); } // White balance (temperature / tint) @@ -1142,8 +1916,13 @@ impl ScriptGenerator { ); script = script.replace("{{#ASPECT_DAR_FROM_SOURCE}}", ""); script = script.replace("{{/ASPECT_DAR_FROM_SOURCE}}", ""); - let source_sar = job - .input_sar + // The rotate pass runs before this, so the source SAR no + // longer describes the frame if it turned. Same adjustment + // as the encode-side declaration, or the square-pixel + // fitting path computes the display aspect upside down. + let source_sar = pipeline + .geometry + .adjusted_sar(job.input_sar.as_deref()) .as_deref() .and_then(parse_ratio) .unwrap_or(1.0); @@ -1345,6 +2124,23 @@ fn remove_block(start_tag: &str, end_tag: &str, mut script: String) -> String { mod tests { use super::*; + #[test] + fn templates_load_with_lf_endings_only() { + // Guards the CRLF normalization in load_template_by_name. Without it, + // any substitution spanning a line break stops matching on a Windows + // checkout — and does so silently, leaving a plausible-looking script. + // This assertion is the cheap counterpart to test_132, which only + // catches it on the platform that has the problem. + for name in ["pipeline_template.vpy", "preview_template.vpy"] { + let t = ScriptGenerator::load_template_by_name(name) + .unwrap_or_else(|e| panic!("load {name}: {e}")); + assert!( + !t.contains('\r'), + "{name} kept CR characters — CRLF normalization was dropped" + ); + } + } + #[test] fn test_remove_block() { let input = "before\n{{#TEST}}content{{/TEST}}\nafter"; diff --git a/worker/src/subtitle_generator.rs b/worker/src/subtitle_generator.rs index d0ba3c49..1f432910 100644 --- a/worker/src/subtitle_generator.rs +++ b/worker/src/subtitle_generator.rs @@ -79,7 +79,7 @@ impl SubtitleGenerator { &whisper_path, &model_path, &temp_wav, - target_video, + &target.with_extension("srt"), settings, &on_cancel, ); @@ -107,6 +107,22 @@ impl SubtitleGenerator { // 4. Handle output mode match settings.output { + // Whisper runs AFTER the encode, so its output cannot be burnt in + // on this pass — the encoder has already finished. Burn-in is + // therefore only offered for a user-supplied file, handled in + // pipeline_executor; here these modes fall back to the sidecar so + // the transcription is not thrown away. + SubtitleOutput::BurnIn | SubtitleOutput::BurnInAndSrt => { + self.reporter.send_log( + LogLevel::Info, + &format!( + "Subtitles saved to: {} (burn-in applies to a supplied \ + subtitle file; transcription runs after the encode)", + srt_path.display() + ), + ); + Ok(Some(srt_path)) + } SubtitleOutput::SrtFile => { self.reporter.send_log( LogLevel::Info, @@ -133,6 +149,69 @@ impl SubtitleGenerator { } /// Check if video has an audio track using ffprobe. + /// Transcribe to an SRT and stop there — no muxing, no output-mode + /// handling. + /// + /// This is what runs *before* the encode, so burn-in has something to draw. + /// `window` is the trim applied to the encode, in seconds. + pub fn transcribe( + &self, + video_path: &str, + settings: &SubtitleSettings, + window: Option<(f64, Option)>, + srt_path: &Path, + on_cancel: F, + ) -> Result> + where + F: Fn() -> bool, + { + if !Path::new(video_path).exists() { + bail!("Video file not found: {}", video_path); + } + if !self.has_audio_track(video_path)? { + self.reporter.send_log( + LogLevel::Warning, + "No audio track found — skipping subtitle generation", + ); + return Ok(None); + } + + let whisper_path = self.deps.whisper_path()?; + let model_path = self.deps.whisper_model_path(&settings.model)?; + + self.reporter + .send_log(LogLevel::Info, "Extracting audio for subtitle generation..."); + let temp_wav = self.extract_audio_range(video_path, window, &on_cancel)?; + if on_cancel() { + let _ = std::fs::remove_file(&temp_wav); + return Ok(None); + } + + let result = self.run_whisper( + &whisper_path, + &model_path, + &temp_wav, + srt_path, + settings, + &on_cancel, + ); + let _ = std::fs::remove_file(&temp_wav); + result?; + + if !srt_path.exists() { + bail!("whisper produced no subtitle file at {}", srt_path.display()); + } + Ok(Some(srt_path.to_path_buf())) + } + + /// Multiplex an existing SRT into a finished video as a subtitle track. + pub fn mux(&self, video: &Path, srt_path: &Path, on_cancel: F) -> Result<()> + where + F: Fn() -> bool, + { + self.embed_subtitles(&video.to_string_lossy(), srt_path, video, &on_cancel) + } + fn has_audio_track(&self, video_path: &str) -> Result { let ffprobe_path = self.deps.ffprobe_path()?; let env = self.deps.build_environment(); @@ -178,6 +257,27 @@ impl SubtitleGenerator { /// Extract audio from video to 16kHz mono WAV. fn extract_audio(&self, video_path: &str, on_cancel: &F) -> Result + where + F: Fn() -> bool, + { + self.extract_audio_range(video_path, None, on_cancel) + } + + /// Extract 16 kHz mono WAV, optionally only a `(start, duration)` window. + /// + /// The window matters when transcribing the *source* rather than the + /// finished file. The encoder seeks the audio input to the trim point, so + /// the output's audio starts there — a transcript of the whole source would + /// be offset by exactly the trimmed-off head, and every cue would land + /// early. Nothing else in the pipeline retimes audio: IVTC and frame-rate + /// conversion change the video timeline only, and the audio keeps its + /// original duration, so trim is the one correction needed. + fn extract_audio_range( + &self, + video_path: &str, + window: Option<(f64, Option)>, + on_cancel: &F, + ) -> Result where F: Fn() -> bool, { @@ -187,18 +287,29 @@ impl SubtitleGenerator { let video = Path::new(video_path); let temp_wav = video.with_extension("whisper.wav"); + let mut args: Vec = Vec::new(); + // Seek before -i so ffmpeg does not decode the discarded head. + if let Some((start, _)) = window { + if start > 0.0 { + args.extend(["-ss".to_string(), format!("{start:.6}")]); + } + } + args.extend(["-i".to_string(), video_path.to_string()]); + if let Some((_, Some(duration))) = window { + if duration > 0.0 { + args.extend(["-t".to_string(), format!("{duration:.6}")]); + } + } + args.extend([ + "-ar".to_string(), "16000".to_string(), + "-ac".to_string(), "1".to_string(), + "-vn".to_string(), + "-y".to_string(), + temp_wav.to_string_lossy().to_string(), + ]); + let mut child = Command::new(&ffmpeg_path) - .args([ - "-i", - video_path, - "-ar", - "16000", - "-ac", - "1", - "-vn", - "-y", - &temp_wav.to_string_lossy(), - ]) + .args(&args) .envs(&env) .stdout(Stdio::null()) .stderr(Stdio::piped()) @@ -232,19 +343,18 @@ impl SubtitleGenerator { whisper_path: &Path, model_path: &Path, wav_path: &Path, - video_path: &str, + srt_path: &Path, settings: &SubtitleSettings, on_cancel: &F, ) -> Result where F: Fn() -> bool, { - let video = Path::new(video_path); - let srt_path = video.with_extension("srt"); + let srt_path = srt_path.to_path_buf(); - // whisper-cli outputs to .srt when --output-srt is used - // We need to specify -of (output file base) to control the output path - let output_base = video.with_extension(""); + // whisper-cli appends .srt to -of when --output-srt is used, so the + // base is the target with its extension stripped. + let output_base = srt_path.with_extension(""); let mut args = vec![ "-m".to_string(), diff --git a/worker/templates/autochromafix.py b/worker/templates/autochromafix.py new file mode 100644 index 00000000..9b60a687 --- /dev/null +++ b/worker/templates/autochromafix.py @@ -0,0 +1,376 @@ +""" +AutoChromaFix - measure and correct chroma-to-luma misalignment automatically. + +Composite captures, cheap TBCs and some DVD encoders leave the chroma planes +displaced from the luma by a fraction of a pixel to a couple of pixels, usually +horizontally (the classic Y/C delay). VapourBox already exposes a manual Chroma +Shift; this measures the shift instead of asking the user to eyeball it. + +Method: bring the chroma planes up to luma resolution once, take a directional +Prewitt edge magnitude of luma and chroma along the axis being searched, and +score how well the two agree at each whole-pixel lag with a normalised +cross-correlation. The peak of that score curve, refined to sub-pixel by fitting +a parabola through it, is the displacement; it is applied with a `resize.Bicubic` +sub-pixel shift in the same units and with the same sign convention as the +manual Chroma Shift control -- whole *luma* pixels, positive moving the chroma +planes left/up. + +Written for VapourBox. The idea of scoring chroma displacement against a luma +edge mask is not new -- `autoChromaFix.py` in Selur's +VapoursynthScriptsInHybrid does the same thing -- but that file carries no +licence of any kind, so nothing here is taken from it: the search strategy, the +scoring function, the sub-pixel handling and the chroma-siting handling below +were all worked out and measured against this project's own fixtures. +""" + +import math + +import vapoursynth as vs + +core = vs.core + + +# See the identical helper in the two .vpy templates: VapourSynth's std.Expr +# JIT is x86-only, so on ARM every expression is walked once per pixel. akarin +# has a real LLVM JIT that works on aarch64. The fallback names core.std.Expr +# explicitly -- calling _expr() would recurse forever. +_akarin_expr = getattr(getattr(core, 'akarin', None), 'Expr', None) + + +def _expr(clips, expr, **kwargs): + if _akarin_expr is not None: + return _akarin_expr(clips, expr, **kwargs) + return core.std.Expr(clips, expr, **kwargs) + + +# A search wider than this is not a misalignment, it is a different picture. +_MAX_SHIFT = 16 + + +def _peak(clip): + if clip.format.sample_type == vs.FLOAT: + return 1.0 + return float((1 << clip.format.bits_per_sample) - 1) + + +def _check(clip): + if not isinstance(clip, vs.VideoNode): + raise TypeError('auto_chroma_fix: clip must be a VideoNode') + if clip.format is None: + raise ValueError( + 'auto_chroma_fix: variable-format clips are not supported') + if clip.format.color_family != vs.YUV: + raise ValueError( + 'auto_chroma_fix: only YUV clips have chroma planes to align, ' + 'got {}'.format(clip.format.name)) + + +def _shift_chroma(clip, dx, dy): + """ + Displace both chroma planes by (dx, dy) *luma* pixels. + + Same convention as the manual Chroma Shift block in the pipeline template: + `src_left` is in the plane's own samples, so a luma-pixel shift is divided + by the subsampling factor. Positive moves the picture left / up. + """ + if dx == 0 and dy == 0: + return clip + cx = dx / float(1 << clip.format.subsampling_w) + cy = dy / float(1 << clip.format.subsampling_h) + planes = [core.std.ShufflePlanes(clip, i, vs.GRAY) for i in range(3)] + planes[1] = core.resize.Bicubic(planes[1], src_left=cx, src_top=cy) + planes[2] = core.resize.Bicubic(planes[2], src_left=cx, src_top=cy) + return core.std.ShufflePlanes(planes, [0, 0, 0], vs.YUV) + + +def _chroma_at_luma_res(clip): + """ + Both chroma planes brought up to luma resolution, sited correctly. + + The siting term is what stops a correctly aligned clip being "corrected". + Chroma in 4:2:0, 4:2:2 and 4:1:1 is co-sited with the left luma sample + horizontally and centred vertically, so a naive per-plane upscale puts it + part of a luma pixel to the right of where it belongs and the search would + dutifully measure that as a real fault. Calibrated against zimg's own 4:4:4 + conversion, which reads the clip's chroma location: the equivalent + per-plane offset is exactly `0.5 - 0.5/factor` horizontally -- 0.25 for + 4:2:0 and 4:2:2, 0.375 for 4:1:1 -- and 0 vertically, on all three. + """ + left = 0.5 - 0.5 / float(1 << clip.format.subsampling_w) + return [core.resize.Bicubic(core.std.ShufflePlanes(clip, i, vs.GRAY), + clip.width, clip.height, src_left=left) + for i in (1, 2)] + + +def _gradient(plane, axis, peak): + """ + Prewitt edge magnitude along one axis only, at float. + + One axis, not `std.Prewitt`'s combined magnitude: the horizontal search + wants to know where vertical edges are, and folding the perpendicular + gradient in adds structure that no horizontal displacement can move, + flattening the profile. + + The magnitude is taken *after* the directional difference rather than + correlating the signed gradients, because the sign of the luma-to-chroma + relationship is a property of the content, not of the alignment -- a red + object on a grey background has chroma rising where luma falls. Correlating + signed gradients produces a curve whose peak is a maximum on some sources + and a minimum on others, with no way to tell which; the magnitude has no + such ambiguity. + + Prewitt is separable into a 3-tap average across the axis and a central + difference along it. The difference is two offset crops of the same clip, + which needs no signed convolution -- `std.Convolution` clamps negative + results away -- at the cost of one pixel at each end of the axis. + """ + norm = _expr(plane, 'x {:.8f} /'.format(peak), format=vs.GRAYS) + smooth = core.std.Convolution(norm, matrix=[1, 1, 1], + mode='v' if axis == 0 else 'h') + if axis == 0: + ahead = core.std.Crop(smooth, left=2) + behind = core.std.Crop(smooth, right=2) + else: + ahead = core.std.Crop(smooth, top=2) + behind = core.std.Crop(smooth, bottom=2) + return _expr([ahead, behind], 'x y - abs') + + +def _window(grad, axis, border, lag): + """ + The scoring window, displaced by `lag` whole pixels. + + Taking the displacement as a *crop offset* is the reason this measures + whole-pixel shifts exactly. The obvious alternative -- resample the chroma + by each candidate and score the result -- puts a different amount of + interpolator softening on each candidate, since a shift that happens to + land on a whole chroma sample resamples nothing while a half-sample one is + maximally softened. That ripple rides on top of the score curve with a + period of one chroma sample and drags the peak by up to a quarter of a + pixel: measured over four real 720x576 sources it lost every 1-pixel answer + on a subsampled axis while leaving the 2-pixel ones correct. A crop + interpolates nothing, so every lag is scored on identical pixels. + """ + if axis == 0: + return core.std.Crop(grad, border + lag, border - lag, border, border) + return core.std.Crop(grad, border, border, border + lag, border - lag) + + +def _score_nodes(luma_grad, chroma_grad): + """ + The three moments a normalised cross-correlation needs, as stat nodes. + + Normalising is not optional: the chroma gradient's own energy varies with + the window, and a bare product sum tracks that rather than the alignment. + Dividing by its standard deviation and subtracting the two means leaves + only the agreement. + """ + return (core.std.PlaneStats(_expr([luma_grad, chroma_grad], 'x y *')), + core.std.PlaneStats(chroma_grad), + core.std.PlaneStats(_expr(chroma_grad, 'x x *'))) + + +# A score curve this flat carries no alignment information -- the peak is +# noise. Measured over four real 720x576 sources, a plane whose chroma still +# has edges spans 25-60% of its peak from end to end of the search, while a +# heavily low-passed VHS chroma plane spans 1.3% and its "peak" wanders with +# every injection. Below the threshold the honest answer is no shift at all. +_MIN_CONTRAST = 0.05 + +# Residual bias in the vertex estimate, treated as a whole-pixel answer. The +# five-point fit gets a correctly aligned source down to 0.06 px, so this is +# comfortably clear of it, and a genuine quarter-pixel misalignment produces a +# vertex several times larger. Swept over 81 known-shift measurements across +# seven real source formats: 0.12 loses 4:1:1 entirely, 0.15 and 0.18 score +# identically, so the value that keeps the most sub-pixel sensitivity wins. +_VERTEX_DEADZONE = 0.15 + +# Extra lags scored either side of the requested range, purely so the peak +# refinement has its full five points even when the answer sits at the edge of +# what the user asked for. +_PAD = 2 + + +def _refine(scores, lags, accuracy, limit): + """ + Peak of the score curve, to sub-pixel, quantised to `accuracy`. + + Five points, fitted by least squares, rather than the usual three-point + parabola vertex. The three-point formula is exact only for a curve that is + genuinely quadratic, and a correlation peak over real edges is not: on + correctly aligned broadcast footage it reports a displacement of 0.12 to + 0.25 px where the true answer is 0, which at the default quantisation is + the difference between the right answer and a spurious quarter-pixel + shift. Fitting five points brings the same measurements down to 0.06 px. + + `scores` and `lags` carry `_PAD` extra entries at each end, so the fit has + its full window even for a peak at the edge of the requested range; the + peak itself is only looked for inside that range. + """ + # If the curve is still climbing at the far end of the padded range, the + # real peak is outside everything that was searched and the edge value + # would be a guess. Say nothing rather than displace the chroma by the + # width of the search window -- that is what a source with no measurable + # alignment does, and it is also the honest answer for a genuine shift + # bigger than max_shift, which the user can widen. + outermost = max(range(len(scores)), key=lambda i: scores[i]) + if outermost in (0, len(scores) - 1): + return 0.0 + + inner = range(_PAD, len(scores) - _PAD) + best = max(inner, key=lambda i: scores[i]) + peak = scores[best] + span = max(scores[i] for i in inner) - min(scores[i] for i in inner) + if span <= _MIN_CONTRAST * max(abs(peak), 1e-12): + return 0.0 + + window = scores[best - 2:best + 3] + # Least-squares parabola over x = -2..2: the normal equations collapse to + # these two coefficients, and the vertex is -b / 2a. + a = (2.0 * (window[0] + window[4]) + - (window[1] + window[3]) - 2.0 * window[2]) / 14.0 + b = (2.0 * (window[4] - window[0]) + (window[3] - window[1])) / 10.0 + delta = 0.0 + if a < 0.0: + delta = -b / (2.0 * a) + delta = 0.0 if abs(delta) < _VERTEX_DEADZONE else min(1.0, max(-1.0, delta)) + + quantised = round((lags[best] + delta) / accuracy) * accuracy + return round(min(limit, max(-limit, quantised)), 6) + + +class _Search(object): + """Builds, and later reads, the score nodes for one clip.""" + + def __init__(self, clip, lags, border): + self.lags = lags + peak = _peak(clip) + luma = core.std.ShufflePlanes(clip, 0, vs.GRAY) + chroma = _chroma_at_luma_res(clip) + + # Horizontal and vertical are searched independently rather than over + # the full 2D grid: 2n windows instead of n^2, and the two axes are + # separable for the small displacements this corrects. + self.luma_stat = [] + self.nodes = [] + for axis in (0, 1): + luma_grad = _gradient(luma, axis, peak) + self.luma_stat.append( + core.std.PlaneStats(_window(luma_grad, axis, border, 0))) + chroma_grad = [_gradient(p, axis, peak) for p in chroma] + for lag in lags: + for grad in chroma_grad: + self.nodes.append(_score_nodes( + _window(luma_grad, axis, border, 0), + _window(grad, axis, border, lag))) + + def prop_src(self): + return self.luma_stat + [n for triple in self.nodes for n in triple] + + def solve(self, values, accuracy, limit): + """values: the flat list of PlaneStatsAverage in prop_src() order.""" + mean_a = values[:2] + moments = values[2:] + count = len(self.lags) + + def ncc(index, axis): + mean_ab, mean_b, mean_bb = moments[3 * index:3 * index + 3] + sd_b = max(mean_bb - mean_b * mean_b, 0.0) ** 0.5 + if sd_b <= 0.0: + return 0.0 + return (mean_ab - mean_a[axis] * mean_b) / sd_b + + result = [] + for axis in (0, 1): + base = axis * 2 * count + # One answer for both planes, from the sum of their correlations. + # A Y/C timing error displaces U and V identically, and dividing by + # each plane's own deviation has already put the two on the same + # scale -- so a plane with little colour variation contributes a + # near-zero curve rather than an answer that is pure noise. + scores = [ncc(base + 2 * i, axis) + ncc(base + 2 * i + 1, axis) + for i in range(count)] + result.append(_refine(scores, self.lags, accuracy, limit)) + return result[0], result[1] + + def read(self, frame): + return [node.get_frame(frame).props['PlaneStatsAverage'] + for node in self.prop_src()] + + +def auto_chroma_fix(clip, max_shift=2, accuracy=0.25, reference_frame=0): + """ + Measure the chroma-to-luma misalignment and correct it. + + Args: + clip: Input clip (YUV, integer or float, any subsampling). + max_shift: Largest displacement to search for, in luma pixels + (default 2, maximum 16). The score curve is sampled at + whole pixels out to this distance on both axes. + accuracy: Quantisation of the sub-pixel answer, in luma pixels + (default 0.25). Whole-pixel misalignments come out + exactly whatever this is set to; it only decides how + finely a fractional one is reported. + reference_frame: Frame to measure on (default 0). -1 measures every + frame, which is only worth it on a source whose + misalignment actually drifts: measured at 720x576 the + default costs 0.07 s once and then runs at 1020 fps, + while per-frame runs at 44 fps. + + The measured displacement is attached to every output frame as the + `_AutoChromaShiftH` / `_AutoChromaShiftV` frame properties, in luma pixels. + Nothing is drawn into the picture. + + Note on `reference_frame`: the measurement is made when the filter chain is + built, so with VapourBox's pipe source the reference frame is read out of + the pipe at script-evaluation time. 0 is the safe value; a large one would + consume the pipe ahead of the encode. + + Returns: + Clip with the chroma planes realigned to the luma. + """ + _check(clip) + + max_shift = float(max_shift) + accuracy = float(accuracy) + if accuracy <= 0.0: + raise ValueError('auto_chroma_fix: accuracy must be positive') + if max_shift < 1.0: + raise ValueError('auto_chroma_fix: max_shift must be at least 1') + reach = int(math.ceil(max_shift)) + if reach > _MAX_SHIFT: + raise ValueError( + 'auto_chroma_fix: max_shift must be at most {}'.format(_MAX_SHIFT)) + + lags = list(range(-(reach + _PAD), reach + _PAD + 1)) + # Two pixels beyond the widest lag, so no window ever reaches the columns + # or rows the gradient could not be computed on. + border = reach + _PAD + 2 + if clip.width <= 4 * border or clip.height <= 4 * border: + raise ValueError( + 'auto_chroma_fix: clip is too small to search a {} pixel shift' + .format(max_shift)) + + search = _Search(clip, lags, border) + + if reference_frame >= 0: + frame = min(int(reference_frame), clip.num_frames - 1) + dx, dy = search.solve(search.read(frame), accuracy, max_shift) + out = _shift_chroma(clip, dx, dy) + return core.std.SetFrameProps(out, _AutoChromaShiftH=float(dx), + _AutoChromaShiftV=float(dy)) + + cache = {} + + def evaluate(n, f): + dx, dy = search.solve([x.props['PlaneStatsAverage'] for x in f], + accuracy, max_shift) + node = cache.get((dx, dy)) + if node is None: + node = core.std.SetFrameProps(_shift_chroma(clip, dx, dy), + _AutoChromaShiftH=float(dx), + _AutoChromaShiftV=float(dy)) + cache[(dx, dy)] = node + return node + + return core.std.FrameEval(clip, evaluate, prop_src=search.prop_src()) diff --git a/worker/templates/deflicker.py b/worker/templates/deflicker.py new file mode 100644 index 00000000..7af9c31d --- /dev/null +++ b/worker/templates/deflicker.py @@ -0,0 +1,366 @@ +""" +Deflicker - two complementary temporal brightness stabilisers. + +`global_deflicker()` removes *global* brightness/contrast pumping: the whole +frame breathing lighter and darker, as produced by auto-exposure hunting, mains +beat on film transfers, and shutter/frame-rate mismatch. It measures each +frame's luma mean and standard deviation, compares them against the mean of a +temporal neighbourhood, and applies the affine correction (gain + offset) that +lands the frame back on the neighbourhood average. Gain alone leaves a +measurable residual on real footage, so both terms are fitted. + +`reduce_flicker()` removes *local* frame-to-frame oscillation: a pixel that +alternates between two values while its +-2 (and optionally +-3) neighbours +agree. It is a per-pixel clamp of the temporal average, gated by how much the +current frame really differs from its more distant neighbours, so genuine +motion and detail are left alone. + +Written for VapourBox. + +`reduce_flicker()` reimplements the semantics of the well-known ReduceFlicker +filter (Rainer Wittmann's Avisynth original) as a VapourSynth expression over +temporally shifted clips. The ReduceFlicker *plugin* is deliberately not used: +its scalar C paths (`proc_c` / `proc_a_c` in `vapoursynth/src/proc_filter.h`) +read `prevp[0]` / `prevp[2]` where the SIMD path correctly reads `nextp[0]` / +`nextp[2]`, and the SIMD block is guarded by `#if defined(__SSE2__)` -- so +aarch64 has only the buggy path and the ARM bundles would render differently +from the x86 ones. An expression has one implementation everywhere. + +`global_deflicker()` is not derived from any existing filter. +""" + +import math + +import vapoursynth as vs + +core = vs.core + + +# VapourSynth's own std.Expr JIT is wrapped in #ifdef VS_TARGET_CPU_X86, so on +# ARM every expression is walked once per pixel by a scalar interpreter -- +# measured ~48x slower than the JIT on this module's expressions. akarin has a +# real LLVM JIT that works on aarch64. Falls back to std.Expr wherever akarin is +# absent (notably macos-x64, whose only wheel would raise the Intel floor to +# macOS 14). Mirrors the `_expr()` helper in the two .vpy templates -- the +# fallback names core.std.Expr explicitly, because calling _expr() here would +# recurse forever. +_akarin_expr = getattr(getattr(core, 'akarin', None), 'Expr', None) +_akarin_propexpr = getattr(getattr(core, 'akarin', None), 'PropExpr', None) + + +def _expr(clips, expr, **kwargs): + if _akarin_expr is not None: + return _akarin_expr(clips, expr, **kwargs) + return core.std.Expr(clips, expr, **kwargs) + + +# std.Expr / akarin.Expr name their inputs x, y, z, then a..w -- 26 in total. +_LETTERS = ['x', 'y', 'z'] + [chr(ord('a') + i) for i in range(23)] + +# 2*window+1 shifted stat clips have to fit in one PropExpr call, and a 25-frame +# neighbourhood is already far longer than any flicker worth correcting. +_MAX_WINDOW = 12 + +# Gain is a ratio of two measured standard deviations, so a near-flat frame +# (a fade to black, a title card) can drive it arbitrarily high. Clamp it: a +# global brightness fault that needs more than +-25% is not flicker. +_GAIN_MIN = 0.8 +_GAIN_MAX = 1.25 +_TINY = 1.0 / 4096.0 + + +def _peak(clip): + """Value of full white in the clip's own sample range.""" + if clip.format.sample_type == vs.FLOAT: + return 1.0 + return float((1 << clip.format.bits_per_sample) - 1) + + +def _plane_exprs(clip, luma_expr): + """Apply `luma_expr` to plane 0 only; '' copies the plane through.""" + return [luma_expr] + [''] * (clip.format.num_planes - 1) + + +def _temporal_shift(clip, offset): + """ + Return `clip` shifted in time, with the edges clamped. + + offset < 0 gives past frames (output frame i is input frame i+offset), + offset > 0 gives future frames. Frame properties are carried along, which + is what makes the statistics clips usable as neighbours. + """ + if offset == 0: + return clip + n = abs(offset) + if offset < 0: + return core.std.Trim(core.std.DuplicateFrames(clip, [0] * n), + length=clip.num_frames) + last = clip.num_frames - 1 + return core.std.Trim(core.std.DuplicateFrames(clip, [last] * n), first=n) + + +def _check_clip(clip, name): + if not isinstance(clip, vs.VideoNode): + raise TypeError('{}: clip must be a VideoNode'.format(name)) + if clip.format is None: + raise ValueError('{}: variable-format clips are not supported'.format(name)) + if clip.format.color_family not in (vs.YUV, vs.GRAY): + raise ValueError( + '{}: only YUV and GRAY clips are supported, got {}'.format( + name, clip.format.name)) + + +# ============================================================================ +# Global (whole-frame) deflicker +# ============================================================================ + +def global_deflicker(clip, strength=1.0, window=5): + """ + Remove global brightness and contrast flicker. + + For every frame the luma mean `m` and standard deviation `s` are measured, + together with the averages `M` and `S` of the same two statistics over the + +-`window` frame neighbourhood. The frame is then remapped by the affine + transform that takes (m, s) to the blended targets, i.e. + + out = (x - m) * g + Mt, g = St / s + + with `Mt = m + strength*(M - m)` and `St = s + strength*(S - s)`. + + Fitting the offset as well as the gain matters: on a clip carrying a pure + additive brightness oscillation the gain-only fit leaves a residual + proportional to how dark the frame is, because a multiply cannot move + black. Fitting both removes it. + + Only the luma plane is touched. Chroma is passed through unchanged -- + exposure flicker is a luma phenomenon, and scaling chroma with it would + shift saturation. + + Args: + clip: Input clip (YUV or GRAY, integer or float). + strength: 0.0 = no correction, 1.0 = land exactly on the neighbourhood + average (default 1.0). Values are clamped to [0, 1]. + window: Half-width of the temporal neighbourhood in frames + (default 5, i.e. an 11 frame window). Maximum 12. + + Returns: + Clip with global brightness/contrast flicker suppressed. + """ + _check_clip(clip, 'global_deflicker') + + strength = min(1.0, max(0.0, float(strength))) + window = int(window) + if window < 1: + raise ValueError('global_deflicker: window must be >= 1') + if window > _MAX_WINDOW: + raise ValueError( + 'global_deflicker: window must be <= {}'.format(_MAX_WINDOW)) + if strength == 0.0 or clip.num_frames < 2: + return clip + + peak = _peak(clip) + + # Per-frame luma statistics. PlaneStatsAverage is normalised to [0, 1] + # whatever the bit depth, so everything below is depth independent; only + # the final bias is scaled back into the clip's own sample range. + luma = clip + if clip.format.color_family != vs.GRAY: + luma = core.std.ShufflePlanes(clip, 0, vs.GRAY) + mean_stats = core.std.PlaneStats(luma) + + # Second moment, needed because mean alone cannot separate gain from + # offset. Computed at float so an 8-bit source does not quantise it. + squared = _expr(luma, 'x {p} / x {p} / *'.format(p=peak), format=vs.GRAYS) + sq_stats = core.std.PlaneStats(squared) + + if _akarin_propexpr is not None: + coeffs = _global_props_akarin(mean_stats, sq_stats, window, strength, peak) + src = core.std.CopyFrameProps(clip, coeffs, props=['_DfGain', '_DfBias']) + out = _expr(src, _plane_exprs(clip, 'x x._DfGain * x._DfBias +')) + return core.std.RemoveFrameProps(out, props=['_DfGain', '_DfBias']) + + return _global_frameeval(clip, mean_stats, sq_stats, window, strength, peak) + + +def _gain_rpn(strength): + """St / s, clamped -- as postfix over a clip carrying _DfM/_DfS/_DfRM/_DfRS.""" + return ('x._DfS x._DfRS x._DfS - {s:.8f} * + ' + 'x._DfS {tiny:.8f} max / {lo:.6f} max {hi:.6f} min'.format( + s=strength, tiny=_TINY, lo=_GAIN_MIN, hi=_GAIN_MAX)) + + +def _global_props_akarin(mean_stats, sq_stats, window, strength, peak): + """ + Compute the per-frame gain/bias entirely inside the filter graph. + + No Python callback runs per frame on this path: the neighbourhood averages + are an akarin.PropExpr over temporally shifted copies of the statistics + clip, and the gain/bias fall out of a second PropExpr. + """ + # One clip carrying both statistics, so the shifted neighbours cost one + # input each rather than two. + merged = _akarin_propexpr( + [mean_stats, sq_stats], + lambda: {'_DfSq': 'y.PlaneStatsAverage'}) + + count = 2 * window + 1 + shifted = [_temporal_shift(merged, o) for o in range(-window, window + 1)] + centre = _LETTERS[window] + + def mean_of(term): + parts = [term(0)] + for i in range(1, count): + parts += [term(i), '+'] + parts += ['{:.1f}'.format(float(count)), '/'] + return ' '.join(parts) + + def sd_term(i): + v = _LETTERS[i] + return ('{v}._DfSq {v}.PlaneStatsAverage {v}.PlaneStatsAverage * - ' + '0 max sqrt'.format(v=v)) + + stats = _akarin_propexpr(shifted, lambda: { + '_DfM': '{}.PlaneStatsAverage'.format(centre), + '_DfS': sd_term(window), + '_DfRM': mean_of(lambda i: '{}.PlaneStatsAverage'.format(_LETTERS[i])), + '_DfRS': mean_of(sd_term), + }) + + gain = _gain_rpn(strength) + # bias = peak * (Mt - m * gain), with Mt = m + strength*(M - m). + bias = ('x._DfM x._DfRM x._DfM - {s:.8f} * + ' + 'x._DfM {g} * - {p:.8f} *'.format(s=strength, g=gain, p=peak)) + + return _akarin_propexpr(stats, lambda: {'_DfGain': gain, '_DfBias': bias}) + + +def _global_frameeval(clip, mean_stats, sq_stats, window, strength, peak): + """ + akarin-less fallback: the same arithmetic in Python, once per frame. + + std.Expr cannot read frame properties, so the coefficients have to be baked + into the expression -- which means a node per distinct correction. They are + quantised and memoised so a clip with slowly varying brightness reuses a + handful of nodes rather than building one per frame. + """ + shifted = ([_temporal_shift(mean_stats, o) for o in range(-window, window + 1)] + + [_temporal_shift(sq_stats, o) for o in range(-window, window + 1)]) + count = 2 * window + 1 + cache = {} + def exprs_for(gain, bias): + return _plane_exprs(clip, 'x {:.8f} * {:.6f} +'.format(gain, bias)) + + def evaluate(n, f): + means = [frame.props['PlaneStatsAverage'] for frame in f[:count]] + sqs = [frame.props['PlaneStatsAverage'] for frame in f[count:]] + sds = [math.sqrt(max(q - m * m, 0.0)) for m, q in zip(means, sqs)] + + m = means[window] + s = sds[window] + ref_m = sum(means) / count + ref_s = sum(sds) / count + + target_s = s + strength * (ref_s - s) + gain = min(_GAIN_MAX, max(_GAIN_MIN, target_s / max(s, _TINY))) + target_m = m + strength * (ref_m - m) + bias = peak * (target_m - m * gain) + + key = (int(round(gain * 20000.0)), int(round(bias * 2000.0 / peak))) + node = cache.get(key) + if node is None: + node = _expr(clip, exprs_for(gain, bias)) + if len(cache) < 4096: + cache[key] = node + return node + + return core.std.FrameEval(clip, evaluate, prop_src=shifted) + + +# ============================================================================ +# Local (per-pixel) flicker reduction +# ============================================================================ + +def reduce_flicker(clip, strength=2, aggressive=False): + """ + Damp per-pixel temporal oscillation while protecting motion and detail. + + For each pixel the temporal average of the immediate neighbourhood, + (prev1 + next1 + 2*cur) / 4, is clamped into a band around the current + value whose width is set by how far the current frame differs from its + more distant neighbours: + + d = min(|cur-prev2|, |cur-next2|[, |cur-prev3|, |cur-next3|, ...]) + ul = max(min(prev1, next1) - d, cur) + ll = min(max(prev1, next1) + d, cur) + out = clamp((prev1 + next1 + 2*cur) / 4, ll, ul) + + `d` is a detail guard, and it runs the opposite way to intuition: where the + current frame genuinely differs from frames +-2 and beyond (real motion) `d` + is large, the band collapses onto `cur`, and nothing is changed. Where it + agrees with them but differs from its immediate neighbours -- which is + exactly what flicker looks like -- `d` is near zero and the pixel is pulled + back towards them. `strength` therefore widens the *minimum*: each extra + frame pair can only lower `d`, so a higher strength filters more. + + With `aggressive=True` the symmetric `d` is replaced by the signed pair + + dl = max(0, min(cur-prev2, cur-next2, ...)) + dh = max(0, min(prev2-cur, next2-cur, ...)) + ul = max(min(prev1, next1) - dh, cur) + ll = min(max(prev1, next1) + dl, cur) + + At most one of `dl`/`dh` is ever non-zero, so each bound is guarded only + against an excursion in its own direction and the other side is free to + move -- a stronger correction that is more willing to touch real detail. + + All planes are processed, and the operation is a pure ratio of samples, so + it is bit-depth and subsampling independent: nothing here is expressed in + 8-bit units. + + Args: + clip: Input clip (YUV or GRAY, integer or float). + strength: 1, 2 or 3 (default 2). Frames +-1 always set the target; + strength adds the guard pairs +-2 (1), +-2/+-3 (2), and + +-2/+-3/+-4 (3), so higher values filter more. + aggressive: Use the signed guard described above (default False). + + Returns: + Clip with local temporal flicker reduced. + """ + _check_clip(clip, 'reduce_flicker') + + strength = int(strength) + if strength not in (1, 2, 3): + raise ValueError('reduce_flicker: strength must be 1, 2 or 3') + if clip.num_frames < 2: + return clip + + # x=cur, y=prev1, z=next1, then the guard pairs a/b, c/d, e/f. + offsets = [0, -1, 1] + for k in range(2, strength + 2): + offsets += [-k, k] + clips = [_temporal_shift(clip, o) for o in offsets] + names = _LETTERS[:len(offsets)] + + average = 'y z + x + x + 4 /' + distant = names[3:] # +-2 [, +-3 [, +-4]] + if aggressive: + low = _fold_min(['x {} -'.format(p) for p in distant]) + high = _fold_min(['{} x -'.format(p) for p in distant]) + upper = 'y z min {} 0 max - x max'.format(high) + lower = 'y z max {} 0 max + x min'.format(low) + else: + guard = _fold_min(['x {} - abs'.format(p) for p in distant]) + upper = 'y z min {} - x max'.format(guard) + lower = 'y z max {} + x min'.format(guard) + + expression = '{avg} {ll} max {ul} min'.format(avg=average, ll=lower, ul=upper) + return _expr(clips, expression) + + +def _fold_min(terms): + """Postfix `min` over a list of postfix sub-expressions.""" + out = terms[0] + for term in terms[1:]: + out = '{} {} min'.format(out, term) + return out diff --git a/worker/templates/hybrid_mv.py b/worker/templates/hybrid_mv.py new file mode 100644 index 00000000..588f880b --- /dev/null +++ b/worker/templates/hybrid_mv.py @@ -0,0 +1,570 @@ +""" +hybrid_mv - shared MVTools substrate and small helpers for the vendored +Selur/Hybrid script filters (temporaldegrain2.py, mclean.py). + ++++ Provenance +++ + +Derived from Selur's VapoursynthScriptsInHybrid +(https://github.com/Selur/VapoursynthScriptsInHybrid), which carries **no +LICENSE file and no per-file licence headers**. Nothing here invents one; the +attributions below are exactly what the upstream docstrings carry. + +Vendored from, as of the 2026-08-17 master snapshot: + + misc.py - ``MotionVectors`` / ``MV`` (approx. lines 740-1355), + ``MinBlur`` (518-566), ``sbr`` (569-604), + ``median_blur`` (451-516) + helpers.py - ``cround``/``m4``/``scale`` (74-84), ``Padding`` (206-216), + ``DitherLumaRebuild`` (218-236), ``BoxFilter`` (238-383), + ``DFTTest`` (534-557) + sharpen.py - ``ContraSharpening`` (795-862) + denoise.py - ``Blur``/``Sharpen`` (815-909) + ++++ Deliberate deviations from upstream +++ + +1. **mvutensils (``core.mvu``) support is dropped.** Upstream's MotionVectors + translates every call for a second, differently-spelled backend. VapourBox + does not bundle mvutensils, so every one of those branches is dead code + naming a namespace that will never exist here. The wrapper keeps the + *interface* (so the vendored call sites are unchanged) and only ever calls + ``core.mv`` / ``core.mvsf``. + +2. **Every ``std.Expr`` goes through ``_expr()``**, which prefers akarin's LLVM + JIT. VapourSynth's own Expr JIT is ``#ifdef VS_TARGET_CPU_X86``, so on ARM a + plain ``std.Expr`` is a scalar interpreter run once per pixel - the dominant + cost in a motion-compensated graph. Upstream does this inconsistently (some + sites route to akarin, ``MinBlur`` and ``ContraSharpening``'s first Expr do + not); here it is uniform. + +3. **``MinBlur``'s radius-3 / 16-bit branch is fixed.** Upstream calls + ``depth(...)`` and ``Dither.NONE``, neither of which is imported anywhere in + that module, so ``MinBlur(clp, 3)`` on a 16-bit clip raises + ``NameError: name 'depth' is not defined`` whenever ``core.ctmf`` exists - + which it does in this bundle. See ``_min_blur_median``. + +4. Unreachable backends are removed: ``cranexpr``, ``rgsf``, ``vszip``, + ``vszipcu``, ``dfttest2``, ``nlm_cuda``, ``vcm`` are not bundled. +""" + +import math +from typing import List, Optional, Sequence, Union + +import vapoursynth as vs + +core = vs.core + + +# --------------------------------------------------------------------------- +# Expression routing +# --------------------------------------------------------------------------- +# VapourSynth's std.Expr JIT is wrapped in #ifdef VS_TARGET_CPU_X86, so on ARM +# every expression is walked once per pixel by a scalar interpreter. akarin has +# a real LLVM JIT that works on aarch64. Route every expression through this +# helper rather than calling core.std.Expr directly; the fallback below must +# name core.std.Expr explicitly (calling _expr again would recurse forever). +_akarin_expr = getattr(getattr(core, 'akarin', None), 'Expr', None) + + +def _expr(clips, expr, **kwargs): + if _akarin_expr is not None: + return _akarin_expr(clips, expr, **kwargs) + return core.std.Expr(clips, expr, **kwargs) + + +# --------------------------------------------------------------------------- +# Small numeric helpers (helpers.py) +# --------------------------------------------------------------------------- + +def cround(x: float) -> int: + return math.floor(x + 0.5) if x > 0 else math.ceil(x - 0.5) + + +def m4(x: Union[float, int]) -> int: + return 16 if x < 16 else cround(x / 4) * 4 + + +def scale(value, peak): + """8-bit level -> the clip's own range (peak == 1 means float).""" + return cround(value * peak / 255) if peak != 1 else value / 255 + + +def scale_value(value: Union[int, float], input_depth: int, output_depth: int) -> float: + """Rescale a luma level between integer bit depths. + + Trimmed to upstream ``helpers.scale_value``'s defaults - limited range in + and out, luma - which is the only mode DitherLumaRebuild uses. In that mode + both peaks are ``219 << (bits - 8)``, so the ratio is a plain power of two. + Do not "fix" this to a full-range (peak / 255) rescale: it moves the search + clip's black point and changes the motion vectors found at >8 bits. + """ + if input_depth == output_depth: + return value + return value * (1 << (output_depth - 8)) / (1 << (input_depth - 8)) + + +def Padding(clip: vs.VideoNode, left: int = 0, right: int = 0, top: int = 0, bottom: int = 0) -> vs.VideoNode: + if not isinstance(clip, vs.VideoNode): + raise vs.Error('Padding: this is not a clip') + if left < 0 or right < 0 or top < 0 or bottom < 0: + raise vs.Error('Padding: border size to pad must not be negative') + width = clip.width + left + right + height = clip.height + top + bottom + return clip.resize.Point(width, height, src_left=-left, src_top=-top, + src_width=width, src_height=height) + + +def DitherLumaRebuild(src: vs.VideoNode, s0: float = 2.0, c: float = 0.0625, + chroma: bool = True) -> vs.VideoNode: + """Converts luma (and chroma) to PC levels, optionally pumping up the darks. + + Only ever used to build the clip fed to motion search. + """ + if not isinstance(src, vs.VideoNode): + raise vs.Error('DitherLumaRebuild: this is not a clip') + if src.format.color_family == vs.RGB: + raise vs.Error('DitherLumaRebuild: RGB format is not supported') + + is_gray = src.format.color_family == vs.GRAY + is_integer = src.format.sample_type == vs.INTEGER + + bits = src.format.bits_per_sample + neutral = 1 << (bits - 1) + + k = (s0 - 1) * c + if is_integer: + t = 'x {} - {} / 0 max 1 min'.format(scale_value(16, 8, bits), scale_value(219, 8, bits)) + else: + t = 'x 0 max 1 min' + e = '{} {} {} {} {} + / - * {} 1 {} - * + '.format(k, 1 + c, (1 + c) * c, t, c, t, k) + if is_integer: + e += '{} *'.format(scale_value(256, 8, bits)) + + if is_gray: + return _expr(src, expr=e) + chroma_expr = 'x {} - 128 * 112 / {} +'.format(neutral, neutral) if (chroma and is_integer) else '' + return _expr(src, expr=[e, chroma_expr]) + + +def BoxFilter(input: vs.VideoNode, radius: int = 16, radius_v: Optional[int] = None, + planes: Optional[Union[int, Sequence[int]]] = None) -> vs.VideoNode: + """Box filter - averages a (radius*2-1) square around each output pixel. + + Trimmed to the branches reachable in this bundle (upstream's fmtc.resample + and vszip routes are dropped; std.BoxBlur has existed since R39). + """ + if not isinstance(input, vs.VideoNode): + raise TypeError('BoxFilter: "input" must be a clip!') + + if planes is None: + planes = list(range(input.format.num_planes)) + elif isinstance(planes, int): + planes = [planes] + + if radius_v is None: + radius_v = radius + if radius == radius_v == 1: + return input + + if radius == radius_v in (2, 3): + return core.std.Convolution(input, [1] * ((radius * 2 - 1) ** 2), planes=planes, mode='s') + + return core.std.BoxBlur(input, hradius=radius - 1, vradius=radius_v - 1, planes=planes) + + +def Sharpen(clip: vs.VideoNode, amountH: float = 1.0, amountV: Optional[float] = None, + planes: Optional[Union[int, Sequence[int]]] = None) -> vs.VideoNode: + """Avisynth's internal Sharpen(): 3x3 kernel [(1-2^a)/2, 2^a, (1-2^a)/2].""" + if not isinstance(clip, vs.VideoNode): + raise TypeError('Sharpen: "clip" is not a clip!') + if amountH < -1.5849625 or amountH > 1: + raise ValueError("Sharpen: 'amountH' out of range [-1.58 ~ 1]") + if amountV is None: + amountV = amountH + elif amountV < -1.5849625 or amountV > 1: + raise ValueError("Sharpen: 'amountV' out of range [-1.58 ~ 1]") + + if planes is None: + planes = list(range(clip.format.num_planes)) + + center_weight_v = math.floor(2 ** (amountV - 1) * 1023 + 0.5) + outer_weight_v = math.floor((0.25 - 2 ** (amountV - 2)) * 1023 + 0.5) + center_weight_h = math.floor(2 ** (amountH - 1) * 1023 + 0.5) + outer_weight_h = math.floor((0.25 - 2 ** (amountH - 2)) * 1023 + 0.5) + + if math.fabs(amountH) >= 0.00002201361136: # log2(1 + 1/65536) + clip = core.std.Convolution(clip, [outer_weight_v, center_weight_v, outer_weight_v], + planes=planes, mode='v') + if math.fabs(amountV) >= 0.00002201361136: + clip = core.std.Convolution(clip, [outer_weight_h, center_weight_h, outer_weight_h], + planes=planes, mode='h') + return clip + + +def Blur(clip: vs.VideoNode, amountH: float = 1.0, amountV: Optional[float] = None, + planes: Optional[Union[int, Sequence[int]]] = None) -> vs.VideoNode: + """Avisynth's internal Blur(); Blur(n) is just Sharpen(-n).""" + if amountH < -1 or amountH > 1.5849625: + raise ValueError("Blur: 'amountH' out of range [-1 ~ 1.58]") + if amountV is None: + amountV = amountH + elif amountV < -1 or amountV > 1.5849625: + raise ValueError("Blur: 'amountV' out of range [-1 ~ 1.58]") + return Sharpen(clip, -amountH, -amountV, planes) + + +def DFTTest(clip: vs.VideoNode, **kwargs) -> vs.VideoNode: + """The only DFTTest backend bundled here is the CPU plugin.""" + return core.dfttest.DFTTest(clip, **kwargs) + + +# --------------------------------------------------------------------------- +# Median / MinBlur / sbr (misc.py) +# --------------------------------------------------------------------------- +# CTMF's default memsize (1 MiB) is catastrophic at high bit depth: measured +# 0.79 fps against 42 fps at 16-bit radius 3, for bit-identical output. +_CTMF_MEMSIZE = 16 << 20 + + +def median_blur(clip: vs.VideoNode, radius: Union[int, Sequence[int]] = 2, + planes: Optional[Union[int, Sequence[int]]] = None, **kwargs) -> vs.VideoNode: + """Median blur, preferring ctmf and falling back to zsmooth.Median.""" + if planes is None: + planes = list(range(clip.format.num_planes)) + elif isinstance(planes, int): + planes = [planes] + + if hasattr(core, 'ctmf'): + kwargs.setdefault('memsize', _CTMF_MEMSIZE) + return core.ctmf.CTMF(clip, radius=radius, planes=planes, **kwargs) + if hasattr(core, 'zsmooth'): + return core.zsmooth.Median(clip, radius=radius, planes=planes) + raise RuntimeError("median_blur: neither 'ctmf' nor 'zsmooth' is installed.") + + +def _min_blur_median(clp: vs.VideoNode, radius: int, planes) -> vs.VideoNode: + """MinBlur's median leg. + + Upstream's radius-3 branch reads + + if clp.format.bits_per_sample == 16 and hasattr(core, 'ctmf'): + from mvsfunc import LimitFilter + RG4 = depth(clp, 12, dither_type=Dither.NONE).ctmf.CTMF(radius=3, ...) + RG4 = LimitFilter(s16, depth(RG4, 16), thr=0.0625, elast=2, ...) + + ``depth`` and ``Dither`` are never imported in that module, so on a 16-bit + clip - and only on a 16-bit clip - this is a NameError. Since this bundle + always has ctmf, that branch is always the one taken, which is why + ``extraSharp=True`` died at exactly 16-bit. mvsfunc ships in + ``python-packages`` here, so the intended ``Depth``/``LimitFilter`` pair is + used; if that import ever goes away we drop to a plain radius-3 median + rather than raising. + """ + if radius < 3 or clp.format.bits_per_sample != 16 or not hasattr(core, 'ctmf'): + return median_blur(clp, radius=radius, planes=planes) + + try: + from mvsfunc import Depth, LimitFilter + except ImportError: + return median_blur(clp, radius=3, planes=planes) + + s16 = clp + rg4 = median_blur(Depth(clp, 12, dither='none'), radius=3, planes=planes) + return LimitFilter(s16, Depth(rg4, 16), thr=0.0625, elast=2, planes=planes) + + +def MinBlur(clp: vs.VideoNode, r: int = 1, + planes: Optional[Union[int, Sequence[int]]] = None) -> vs.VideoNode: + """Nifty Gauss/Median combination.""" + if not isinstance(clp, vs.VideoNode): + raise vs.Error('MinBlur: this is not a clip') + + plane_range = range(clp.format.num_planes) + if planes is None: + planes = list(plane_range) + elif isinstance(planes, int): + planes = [planes] + + matrix1 = [1, 2, 1, 2, 4, 2, 1, 2, 1] + matrix2 = [1, 1, 1, 1, 1, 1, 1, 1, 1] + + if r <= 0: + RG11 = sbr(clp, planes=planes) + RG4 = clp.std.Median(planes=planes) + elif r == 1: + RG11 = clp.std.Convolution(matrix=matrix1, planes=planes) + RG4 = clp.std.Median(planes=planes) + elif r == 2: + RG11 = clp.std.Convolution(matrix=matrix1, planes=planes) \ + .std.Convolution(matrix=matrix2, planes=planes) + RG4 = _min_blur_median(clp, 2, planes) + else: + RG11 = clp.std.Convolution(matrix=matrix1, planes=planes) \ + .std.Convolution(matrix=matrix2, planes=planes) \ + .std.Convolution(matrix=matrix2, planes=planes) + RG4 = _min_blur_median(clp, 3, planes) + + return _expr( + [clp, RG11, RG4], + expr=['x y - x z - * 0 < x x y - abs x z - abs < y z ? ?' if i in planes else '' + for i in plane_range], + ) + + +def sbr(c: vs.VideoNode, r: int = 1, + planes: Optional[Union[int, Sequence[int]]] = None) -> vs.VideoNode: + """Make a highpass on a blur's difference (well, kind of that).""" + if not isinstance(c, vs.VideoNode): + raise vs.Error('sbr: this is not a clip') + + neutral = 1 << (c.format.bits_per_sample - 1) if c.format.sample_type == vs.INTEGER else 0.0 + plane_range = range(c.format.num_planes) + if planes is None: + planes = list(plane_range) + elif isinstance(planes, int): + planes = [planes] + + matrix1 = [1, 2, 1, 2, 4, 2, 1, 2, 1] + matrix2 = [1, 1, 1, 1, 1, 1, 1, 1, 1] + + RG11 = c.std.Convolution(matrix=matrix1, planes=planes) + if r >= 2: + RG11 = RG11.std.Convolution(matrix=matrix2, planes=planes) + if r >= 3: + RG11 = RG11.std.Convolution(matrix=matrix2, planes=planes) + + RG11D = core.std.MakeDiff(c, RG11, planes=planes) + RG11DS = RG11D.std.Convolution(matrix=matrix1, planes=planes) + if r >= 2: + RG11DS = RG11DS.std.Convolution(matrix=matrix2, planes=planes) + if r >= 3: + RG11DS = RG11DS.std.Convolution(matrix=matrix2, planes=planes) + + RG11DD = _expr( + [RG11D, RG11DS], + expr=['x y - x {n} - * 0 < {n} x y - abs x {n} - abs < x y - {n} + x ? ?'.format(n=neutral) + if i in planes else '' for i in plane_range], + ) + return core.std.MakeDiff(c, RG11DD, planes=planes) + + +# --------------------------------------------------------------------------- +# ContraSharpening (sharpen.py) +# --------------------------------------------------------------------------- + +def ContraSharpening(denoised: vs.VideoNode, original: vs.VideoNode, + radius: Optional[int] = None, rep: int = 1, + planes: Optional[Union[int, Sequence[int]]] = None) -> vs.VideoNode: + """Sharpen the denoised clip, but never add back more than was removed.""" + if not (isinstance(denoised, vs.VideoNode) and isinstance(original, vs.VideoNode)): + raise vs.Error('ContraSharpening: this is not a clip') + if denoised.format.id != original.format.id: + raise vs.Error('ContraSharpening: clips must have the same format') + + if radius is None: + radius = 1 + + neutral = 1 << (denoised.format.bits_per_sample - 1) + plane_range = range(denoised.format.num_planes) + if planes is None: + planes = [0] if denoised.format.color_family != vs.RGB else [0, 1, 2] + elif isinstance(planes, int): + planes = [planes] + + pad = 2 if radius < 3 else 4 + denoised = Padding(denoised, pad, pad, pad, pad) + original = Padding(original, pad, pad, pad, pad) + + matrix1 = [1, 2, 1, 2, 4, 2, 1, 2, 1] + matrix2 = [1, 1, 1, 1, 1, 1, 1, 1, 1] + + # damp down remaining spots of the denoised clip + s = MinBlur(denoised, radius, planes) + # the difference achieved by the denoising + allD = core.std.MakeDiff(original, denoised, planes=planes) + + RG11 = s.std.Convolution(matrix=matrix1, planes=planes) + if radius >= 2: + RG11 = RG11.std.Convolution(matrix=matrix2, planes=planes) + if radius >= 3: + RG11 = RG11.std.Convolution(matrix=matrix2, planes=planes) + + # the difference of a simple kernel blur + ssD = core.std.MakeDiff(s, RG11, planes=planes) + # limit the difference to the max of what the denoising removed locally + repair = core.zsmooth.Repair if hasattr(core, 'zsmooth') else core.rgvs.Repair + ssDD = repair(ssD, allD, mode=[rep if i in planes else 0 for i in plane_range]) + # abs(diff) after limiting may not be bigger than before + ssDD = _expr([ssDD, ssD], + expr=['x {n} - abs y {n} - abs < x y ?'.format(n=neutral) if i in planes else '' + for i in plane_range]) + # apply the limited difference (sharpening is just inverse blurring) + last = core.std.MergeDiff(denoised, ssDD, planes=planes) + return last.std.Crop(pad, pad, pad, pad) + + +# --------------------------------------------------------------------------- +# MVTools wrapper (misc.py MotionVectors) +# --------------------------------------------------------------------------- + +class MotionVectors: + """mvtools-shaped facade over core.mv (core.mvsf for float clips). + + Upstream also translates every call for mvutensils (``core.mvu``). That + plugin is not bundled by VapourBox, so those branches are removed; what is + kept is the method/argument vocabulary, so the vendored call sites read the + same as upstream. + """ + + @staticmethod + def _ns(clip: vs.VideoNode): + if clip.format.sample_type == vs.FLOAT and hasattr(core, 'mvsf'): + return core.mvsf + return core.mv + + @staticmethod + def _analyse_func(ns): + # Some mvsf builds expose "Analyze", mv always uses "Analyse". + return getattr(ns, 'Analyse', None) or getattr(ns, 'Analyze') + + # -- Super --------------------------------------------------------------- + + def Super(self, clip: vs.VideoNode, hpad: int = 8, vpad: int = 8, pel: int = 2, + levels: int = 0, chroma: bool = True, sharp: int = 2, rfilter: int = 2, + pelclip: Optional[vs.VideoNode] = None, *, + blksize: Optional[int] = None, blksizev: Optional[int] = None, + overlap: Optional[int] = None, overlapv: Optional[int] = None) -> vs.VideoNode: + # blksize/overlap exist only so mvutensils call sites keep working; + # core.mv derives the padding itself and ignores them. + del blksize, blksizev, overlap, overlapv + return self._ns(clip).Super(clip, hpad=hpad, vpad=vpad, pel=pel, levels=levels, + chroma=chroma, sharp=sharp, rfilter=rfilter, pelclip=pelclip) + + # -- Analyse ------------------------------------------------------------- + + def _analyse(self, super: vs.VideoNode, blksize: int = 8, blksizev: Optional[int] = None, + levels: int = 0, search: int = 4, searchparam: int = 2, pelsearch: int = 0, + isb: bool = False, lambda_: Optional[int] = None, chroma: bool = True, + delta: int = 1, truemotion: bool = True, lsad: Optional[int] = None, + plevel: Optional[int] = None, global_: Optional[bool] = None, + pnew: Optional[int] = None, pzero: Optional[int] = None, pglobal: int = 0, + overlap: int = 0, overlapv: Optional[int] = None, divide: int = 0, + badsad: int = 10000, badrange: int = 24, meander: bool = True, + trymany: bool = False, fields: bool = False, tff: Optional[bool] = None, + search_coarse: int = 3, dct: int = 0) -> vs.VideoNode: + ns = self._ns(super) + return self._analyse_func(ns)( + super, blksize=blksize, blksizev=blksizev, levels=levels, search=search, + searchparam=searchparam, pelsearch=pelsearch, isb=isb, lambda_=lambda_, chroma=chroma, + delta=delta, truemotion=truemotion, lsad=lsad, plevel=plevel, global_=global_, + pnew=pnew, pzero=pzero, pglobal=pglobal, overlap=overlap, overlapv=overlapv, + divide=divide, badsad=badsad, badrange=badrange, meander=meander, trymany=trymany, + fields=fields, tff=tff, search_coarse=search_coarse, dct=dct) + + def Analyse(self, *args, **kwargs) -> vs.VideoNode: + return self._analyse(*args, **kwargs) + + def Analyze(self, *args, **kwargs) -> vs.VideoNode: + return self._analyse(*args, **kwargs) + + def AnalyseMany(self, super: vs.VideoNode, radius: int = 1, delta: int = 1, + **kwargs) -> List[vs.VideoNode]: + """Flat [bv1, fv1, bv2, fv2, ... bv, fv] - the order + Degrain()/Compensate() expect. core.mv has no batch call, so this is a + plain loop.""" + vectors = [] + for step in range(delta, delta * radius + 1, delta): + for isb in (True, False): + vectors.append(self._analyse(super, isb=isb, delta=step, **kwargs)) + return vectors + + # -- Recalculate --------------------------------------------------------- + + def Recalculate(self, super: vs.VideoNode, vectors, thsad: float = 200.0, smooth: int = 1, + blksize: int = 8, blksizev: Optional[int] = None, search: int = 4, + searchparam: int = 2, lambda_: Optional[int] = None, chroma: bool = True, + truemotion: bool = True, pnew: Optional[int] = None, overlap: int = 0, + overlapv: Optional[int] = None, divide: int = 0, meander: bool = True, + fields: bool = False, tff: Optional[bool] = None, dct: int = 0): + if isinstance(vectors, (list, tuple)): + return [self.Recalculate(super, v, thsad=thsad, smooth=smooth, blksize=blksize, + blksizev=blksizev, search=search, searchparam=searchparam, + lambda_=lambda_, chroma=chroma, truemotion=truemotion, + pnew=pnew, overlap=overlap, overlapv=overlapv, divide=divide, + meander=meander, fields=fields, tff=tff, dct=dct) + for v in vectors] + return self._ns(super).Recalculate( + super, vectors, thsad=thsad, smooth=smooth, blksize=blksize, blksizev=blksizev, + search=search, searchparam=searchparam, lambda_=lambda_, chroma=chroma, + truemotion=truemotion, pnew=pnew, overlap=overlap, overlapv=overlapv, divide=divide, + meander=meander, fields=fields, tff=tff, dct=dct) + + # -- Compensate ---------------------------------------------------------- + + def Compensate(self, clip: vs.VideoNode, super: vs.VideoNode, vectors, + scbehavior: int = 1, thsad: float = 10000.0, fields: bool = False, + time: float = 100.0, thscd1: float = 400.0, thscd2: float = 130.0, + tff: Optional[bool] = None) -> vs.VideoNode: + return self._ns(clip).Compensate(clip, super, vectors, scbehavior=scbehavior, thsad=thsad, + fields=fields, time=time, thscd1=thscd1, thscd2=thscd2, + tff=tff) + + # -- Degrain / Degrain1..N ----------------------------------------------- + + def _degrain(self, clip: vs.VideoNode, super: vs.VideoNode, *vectors, + thsad: float = 400.0, thsadc: Optional[float] = None, plane: int = 4, + limit: float = 255.0, limitc: Optional[float] = None, + thscd1: float = 400.0, thscd2: float = 130.0, opt: bool = True) -> vs.VideoNode: + vec_list = [v for v in vectors] + if not vec_list or len(vec_list) % 2 != 0 or any(v is None for v in vec_list): + raise vs.Error('MV.Degrain: expected an even, gapless list of vector clips ' + '(bw1, fw1, bw2, fw2, ...)') + ns = self._ns(clip) + func = getattr(ns, 'Degrain{}'.format(len(vec_list) // 2)) + return func(clip, super, *vec_list, thsad=thsad, + thsadc=thsadc if thsadc is not None else thsad, plane=plane, limit=limit, + limitc=limitc if limitc is not None else limit, thscd1=thscd1, thscd2=thscd2, + opt=opt) + + def Degrain(self, clip, super, *vectors, **kwargs): + return self._degrain(clip, super, *vectors, **kwargs) + + def __getattr__(self, name: str): + # Handles Degrain1..DegrainN without hand-writing each one. + suffix = name[len('Degrain'):] if name.startswith('Degrain') else None + if suffix is not None and (suffix == '' or suffix.isdigit()): + return lambda clip, super, *vectors, **kwargs: self._degrain( + clip, super, *vectors, **kwargs) + raise AttributeError('MotionVectors has no attribute {!r}'.format(name)) + + +# Ready-made singleton, matching upstream's `from misc import MV`. +MV = MotionVectors() + + +# --------------------------------------------------------------------------- +# Depth scaling +# --------------------------------------------------------------------------- + +def depth_scale(clip: vs.VideoNode) -> float: + """Multiplier taking an 8-bit level to the clip's own range. + + Every threshold and offset exposed by these filters is written in 8-bit + units (0-255), which is also how VapourBox's UI presents them. Anything + handed to a plugin that works in the clip's *own* range is therefore wrong + by 4x at 10-bit and 256x at 16-bit unless it goes through here. + """ + if clip.format.sample_type == vs.FLOAT: + return 1.0 / 255.0 + return float(1 << (clip.format.bits_per_sample - 8)) + + +def require_integer(clip: vs.VideoNode, func: str) -> None: + """Float clips need core.mvsf, which this bundle does not ship. + + Without this the failure is a bare 'Super: input clip must be integer' from + deep inside the graph, several frames of Python away from the cause. + """ + if clip.format.sample_type == vs.FLOAT and not hasattr(core, 'mvsf'): + raise vs.Error( + '{}: float (32-bit) input needs the mvsf plugin for motion search, which is not ' + 'bundled. Convert to an integer format (8-16 bit) first.'.format(func)) diff --git a/worker/templates/mclean.py b/worker/templates/mclean.py new file mode 100644 index 00000000..23c44e07 --- /dev/null +++ b/worker/templates/mclean.py @@ -0,0 +1,282 @@ +""" +mClean - spatio/temporal denoiser with optional sharpening, renoise and warp depth. + ++++ Provenance +++ + +Vendored from Selur's VapoursynthScriptsInHybrid +(https://github.com/Selur/VapoursynthScriptsInHybrid), file ``denoise.py``, +approximately lines 535-735 of the 2026-08-17 master snapshot. That repository +carries **no LICENSE file and no per-file licence headers**; nothing here +invents one. The attribution the upstream docstring itself carries is: + + From: https://forum.doom9.org/showthread.php?t=174804 by burfadel + +The MVTools substrate and the helpers this needs (``MV``, ``BoxFilter``, +``Blur``, ``scale``) live in ``hybrid_mv.py``, shared with +``temporaldegrain2.py``; that file documents its own derivation. + ++++ Deviations from upstream, all of them load-bearing +++ + +Each was measured against the plugins VapourBox actually bundles. + +1. **Deband is routed to neo_f3kdb and capped at 1.** Upstream calls + ``filt.f3kdb.Deband(...)``; this bundle ships **neo_f3kdb** under a + different namespace, so ``deband=1`` raised + ``AttributeError: no attribute named f3kdb``. Since this is our own copy the + call is simply repointed. ``deband=2`` additionally reaches + ``core.vcm.Veed``, which is bundled on no platform and is not guarded + upstream, so anything above 1 is folded down to 1. neo_f3kdb also renames + ``range`` to ``range`` (unchanged) but drops the ``preset`` string, so the + preset is expanded into its ``y``/``cb``/``cr``/``grainy``/``grainc`` + equivalents here. + +2. **icalc is pinned True.** ``icalc=False`` selects the float path, which + needs ``core.mvsf``; that plugin is not bundled, and the failure surfaces + deep inside the graph. + +3. **outbits is pinned to the source depth.** Upstream lets it silently change + the output pixel format (``outbits=16`` on an 8-bit source returned + YUV420P16), which the chroma-subsampling block downstream of this filter + does not expect. The parameter is kept in the signature for call-site + compatibility and ignored with a note. + +4. **The depth-dependent renoise thresholds are scaled from ``clip.format``** + via upstream's ``i`` multiplier, which is retained. Everything else in the + function is a ratio. + +5. Every ``std.Expr`` goes through ``hybrid_mv._expr`` (akarin's LLVM JIT where + available). Upstream's ``color.Tweak`` dependency is reduced to the + contrast-only integer path it actually uses (``_tweak_contrast``), which is + a ``std.Lut`` and involves no expression at all. + +6. The correctly-guarded ``hasattr(core, 'zsmooth')`` around + ``core.vcm.Median`` is kept as upstream wrote it - zsmooth is bundled, so + the vcm branch never fires, but the guard is right and costs nothing. + +7. **``depth`` is deliberately not exposed.** The template never passes it, so + it always runs at 0. Above 0 the function takes the difference of two + ``AWarpSharp2`` warps, and ``thresh`` is hard-capped at 255 by the plugin, + so it cannot be depth-scaled the way everything else here is: at + ``depth=2`` the 8-bit and 16-bit results diverge by **3.09/255** mean + absolute difference, against a ~0.6 rounding floor, because quantisation + noise flips the warp's edge decisions. That is inherent to the operation + rather than a scaling bug, so exposing the knob means accepting output that + changes with the source's bit depth. Don't add it to the schema without + deciding that is acceptable. +""" + +from typing import Optional + +import vapoursynth as vs + +from hybrid_mv import MV, Blur, BoxFilter, _expr, require_integer + +core = vs.core + +__all__ = ['mClean'] + + +def _tweak_contrast(clip: vs.VideoNode, cont: float) -> vs.VideoNode: + """The one thing mClean uses color.Tweak for: luma contrast on an integer clip. + + Reproduces Tweak(cont=...) with coring=True exactly - a per-level LUT over + the luma plane, clamped to the 16..235 range scaled to the clip's depth. + """ + bits = clip.format.bits_per_sample + luma_min = 16 << (bits - 8) + luma_max = 235 << (bits - 8) + lut = [min(max(int((i - luma_min) * cont + luma_min + 0.5), luma_min), luma_max) + for i in range(1 << bits)] + return clip.std.Lut(planes=0, lut=lut) + + +def mClean( + clip: vs.VideoNode, + thSAD: int = 400, + chroma: bool = True, + sharp: float = 10, + rn: float = 14, + deband: int = 0, + depth: int = 0, + strength: int = 20, + outbits: Optional[int] = None, + icalc: bool = True, + rgmode: int = 18, +) -> vs.VideoNode: + """Spatio/temporal denoiser that keeps detail, with optional enhancement. + + Typical spatial filters remove small-scale variation, which loses noise but + also sharpness and temporal stability. mClean works primarily in the + temporal domain with only light spatial limiting, and treats chroma + differently from luma. + + thSAD (400) MDegrain SAD threshold - the main denoising strength knob. + chroma (True) Denoise chroma as well as luma. + sharp (10) 0-24 modified unsharp mask on edges and detected detail. + 21-24 are "overboost", only sane on clean HD sources. + The actual amount is scaled by resolution. + rn (14) 0-20 ReNoise: adds back a spatially and temporally + modified version of the removed luma noise, which + compresses far better than the original and avoids the + flatness effective denoising leaves behind. + deband (0) 0 = off, 1 = deband (neo_f3kdb). Values above 1 are + clamped - see the module docstring. + depth (0) 0-5 modified warp sharpening; distorts the image, but 1-2 + can help line art. + strength (20) 0-20. Below 20 the result is blended back toward the + source: 0 keeps 20% of the denoising, 20 keeps all of it. + rgmode (18) RemoveGrain mode for the spatial luma pass. + + ``outbits`` and ``icalc`` are accepted for call-site compatibility and + pinned; see the module docstring. + """ + if not isinstance(clip, vs.VideoNode) or clip.format.color_family != vs.YUV: + raise vs.Error('mClean: this is not a YUV clip!') + + # Deviation 2: the float path needs core.mvsf, which is not bundled. + require_integer(clip, 'mClean') + icalc = True + + defH = max(clip.height, clip.width // 4 * 3) # for the auto blksize settings + sharp = min(max(sharp, 0), 24) + rn = min(max(rn, 0), 20) + # Deviation 1: deband 2..5 reach core.vcm.Veed, which is bundled nowhere. + deband = min(max(int(deband), 0), 1) + depth = min(max(int(depth), 0), 5) + strength = min(max(strength, 0), 20) + + bd = clip.format.bits_per_sample + zsmooth = hasattr(core, 'zsmooth') + + S = MV.Super + A = MV.Analyse + R = MV.Recalculate + + # Deviation 3: the output format must track the source. + outbits = bd + + if zsmooth: + RE = core.zsmooth.Repair + RG = core.zsmooth.RemoveGrain + else: + RE = core.rgvs.Repair + RG = core.rgvs.RemoveGrain + + sc = 8 if defH > 2880 else 4 if defH > 1440 else 2 if defH > 720 else 1 + i = 1 << (outbits - 8) # 8-bit level -> this clip's range + peak = (1 << outbits) - 1 + bs = 16 if defH / sc > 360 else 8 + ov = 6 if bs > 12 else 2 + pel = 1 if defH > 720 else 2 + truemotion = False if defH > 720 else True + lampa = 777 * (bs ** 2) // 64 + depth2 = -depth * 3 + depth = depth * 2 + + if sharp > 20: + sharp += 30 + elif defH <= 2500: + sharp = 15 + defH * sharp * 0.0007 + else: + sharp = 50 + + # ------------------------------------------------------------- preparation + if chroma: + c = core.zsmooth.Median(clip, radius=2, planes=[1, 2]) if zsmooth \ + else core.vcm.Median(clip, plane=[0, 1, 1]) + else: + c = clip + + cy = core.std.ShufflePlanes(c, [0], vs.GRAY) + + super1 = S(c if chroma else cy, hpad=bs, vpad=bs, pel=pel, rfilter=4, sharp=1, + blksize=bs, overlap=ov) + super2 = S(c if chroma else cy, hpad=bs, vpad=bs, pel=pel, rfilter=1, levels=1, + blksize=bs, overlap=ov) + analyse_args = dict(blksize=bs, overlap=ov, search=5, truemotion=truemotion) + recalculate_args = dict(blksize=bs, overlap=ov, search=5, truemotion=truemotion, + thsad=180, lambda_=lampa) + + # ---------------------------------------------------------------- analysis + bvec3 = R(super1, A(super1, isb=True, delta=3, **analyse_args), **recalculate_args) + bvec2 = R(super1, A(super1, isb=True, delta=2, badsad=1100, lsad=1120, **analyse_args), + **recalculate_args) + bvec1 = R(super1, A(super1, isb=True, delta=1, badsad=1500, lsad=980, badrange=27, + **analyse_args), **recalculate_args) + fvec1 = R(super1, A(super1, isb=False, delta=1, badsad=1500, lsad=980, badrange=27, + **analyse_args), **recalculate_args) + fvec2 = R(super1, A(super1, isb=False, delta=2, badsad=1100, lsad=1120, **analyse_args), + **recalculate_args) + fvec3 = R(super1, A(super1, isb=False, delta=3, **analyse_args), **recalculate_args) + + # mvtools' `limit` is expressed in the clip's own range and defaults to 255, + # which is a hard clamp at anything above 8-bit; "off" is the format peak. + clean = MV.Degrain3(c if chroma else cy, super2, bvec1, fvec1, bvec2, fvec2, bvec3, fvec3, + thsad=thSAD, limit=peak) + + TM = core.zsmooth.TemporalMedian if zsmooth else core.tmedian.TemporalMedian + uv = core.std.MergeDiff(clean, TM(core.std.MakeDiff(c, clean, [1, 2]), 1, [1, 2]), [1, 2]) \ + if chroma else c + clean = core.std.ShufflePlanes(clean, [0], vs.GRAY) if clean.format.num_planes != 1 else clean + + # ------------------------------------------------- post clean / pre deband + filt = core.std.ShufflePlanes([clean, uv], [0, 1, 2], vs.YUV) + + if deband: + # Deviation 1: neo_f3kdb has no `preset`, so upstream's "high"/"luma" is + # expanded. "high" = y/cb/cr 48; "luma" = y 48, chroma off. + filt = core.neo_f3kdb.Deband(filt, range=16, + y=48, cb=48 if chroma else 0, cr=48 if chroma else 0, + grainy=int(defH / 15), grainc=int(defH / 16) if chroma else 0, + output_depth=outbits) + clean = core.std.ShufflePlanes(filt, [0], vs.GRAY) + + # ------------------------------------------------------ spatial luma denoise + clean2 = RG(clean, rgmode) + + # Unsharp filter for spatial detail enhancement + clsharp = None + if sharp: + if sharp <= 50: + clsharp = core.std.MakeDiff(clean, Blur(clean2, amountH=0.08 + 0.03 * sharp)) + elif hasattr(core, 'tcanny'): + clsharp = core.std.MakeDiff(clean, clean2.tcanny.TCanny(sigma=(sharp - 46) / 4, mode=-1)) + else: + radius = max(1, round(((sharp - 46) / 4) * 1.5)) + blur = clean2 + for _ in range(3): + blur = BoxFilter(blur, radius=radius, radius_v=radius) + clsharp = core.std.MakeDiff(clean, blur) + clsharp = core.std.MergeDiff(clean2, RE(TM(clsharp), clsharp, 12)) + + # ------------------------------------------------------------------ renoise + noise_diff = core.std.MakeDiff(clean2, cy) + if rn: + # Thresholds are 8-bit levels; `i` takes them to the clip's own range. + expr = 'x {a} < 0 x {b} > {p} 0 x {c} - {p} {a} {d} - / * - ? ?'.format( + a=32 * i, b=45 * i, c=35 * i, d=65 * i, p=peak) + clean1 = core.std.Merge( + clean2, + core.std.MergeDiff(clean2, _tweak_contrast(TM(noise_diff), 1.008 + 0.00016 * rn)), + 0.3 + rn * 0.035) + clean2 = core.std.MaskedMerge( + clean2, clean1, + _expr([_expr([clean, clean.std.Invert()], 'x y min')], [expr])) + + # Combine spatial detail enhancement with spatial noise reduction + noise_diff = noise_diff.std.Binarize().std.Invert() + clean2 = core.std.MaskedMerge(clean2, clsharp if sharp else clean, + _expr([noise_diff, clean.std.Sobel()], 'x y max')) + + # ------------------------------------------------- recombine luma and chroma + output = core.std.ShufflePlanes([clean2, filt], [0, 1, 2], vs.YUV) + if strength < 20: + output = core.std.Merge(c, output, 0.2 + 0.04 * strength) + + if depth: + # warp.AWarpSharp2's `chroma` is 0 or 1 in the VapourSynth port (the + # Avisynth filter's 0-6 vocabulary is rejected at script evaluation). + s1 = output.warp.AWarpSharp2(thresh=128, blur=3, type=1, depth=depth2, chroma=1) + s2 = output.warp.AWarpSharp2(thresh=128, blur=2, type=1, depth=depth, chroma=1) + return core.std.MergeDiff(output, core.std.MakeDiff(s1, s2)) + return output diff --git a/worker/templates/pipeline_template.vpy b/worker/templates/pipeline_template.vpy index 0c0ab4a6..a3a6f8e6 100644 --- a/worker/templates/pipeline_template.vpy +++ b/worker/templates/pipeline_template.vpy @@ -5,6 +5,7 @@ Placeholders use the format: {{PARAMETER_NAME}} Conditional blocks use: {{#BLOCK_NAME}}...{{/BLOCK_NAME}} """ +import functools import vapoursynth as vs import sys @@ -358,6 +359,32 @@ if clip.format.id != _deint_src_format.id: dither_type="error_diffusion") {{/DEINT_RESTORE_FORMAT}} {{/DEINT_QTGMC}} +{{#DEINT_BWDIF}} +# Bwdif — the fast tier. Roughly 4-5x QTGMC Fast on the same clip, for long +# captures where QTGMC's hours are not justified. +# +# Two things measured against the bundled plugin, both negative results and both +# the point: +# * No format limits at all — 8/9/10/12/16-bit, 4:2:0/4:2:2/4:4:4, GRAY, RGB +# and even 4:1:1 all pass. Unique among recent additions; no guard needed. +# * No _FieldBased sensitivity. field=1 and field=3 both work with the +# property absent, 0, 1 or 2, so it does NOT repeat the znedi3 trap that +# cost two nightly cycles. +# +# `field` is required, not optional: 0/1 keep the bottom/top field (single +# rate), 2/3 double the rate. The parity half comes from the same field-order +# derivation QTGMC uses, so the two methods cannot disagree about it. +{{#DEINT_BWDIF_EDEINT}} +# An external interpolator. Bwdif accepts one directly, which is why Yadifmod +# is not a separate method: measured, Bwdif+nnedi3 (0.524) beats Yadifmod+nnedi3 +# (0.532) at the same cost. EEDI3 here is 11x slower for no gain. +_bwdif_edi = _nnedi3(clip, field={{BWDIF_FIELD}}, dh=False) +clip = core.bwdif.Bwdif(clip, field={{BWDIF_FIELD}}, edeint=_bwdif_edi) +{{/DEINT_BWDIF_EDEINT}} +{{#DEINT_BWDIF_PLAIN}} +clip = core.bwdif.Bwdif(clip, field={{BWDIF_FIELD}}) +{{/DEINT_BWDIF_PLAIN}} +{{/DEINT_BWDIF}} {{#DEINT_IVTC}} # IVTC: Inverse Telecine (VFM field matching + VDecimate) # VFM only accepts 8-bit YUV/GRAY. For higher bit-depth sources (e.g. 10-bit @@ -407,6 +434,71 @@ clip = core.vivtc.VDecimate(clip) # ============================================================================ # PASS 3: DESCRATCH (scratch removal) +# ============================================================================ +# PASS 1c: EDGE REPAIR / GHOST REMOVAL +# ============================================================================ +{{#EDGE_REPAIR}} +# Rebuild the dirty rows and columns tape captures leave at the frame border. +# Must precede every spatial filter, or denoising and sharpening smear the bad +# rows inward, and must precede the resize, or resampling spreads them. +# +# Widths are always even. The bundle pins FillBorders v2 (the newest tag with +# published binaries), and v2 is bit-identical to v4 at even widths — they +# differ only at odd widths, where v2 leaves subsampled chroma unrepaired. +# +# interlaced=-1 reads _FieldBased, so a clip whose field order this pipeline +# knows is handled per-field without a second control. +clip = core.fb.FillBorders( + clip, + left={{ER_LEFT}}, right={{ER_RIGHT}}, top={{ER_TOP}}, bottom={{ER_BOTTOM}}, + mode="{{ER_MODE}}", + interlaced=-1, +) +{{/EDGE_REPAIR}} +{{#GHOST_REMOVAL}} +# Cancel the displaced echo RF and cable distribution leave behind. Each ghost +# is a (mode, shift, intensity) triple and the three arrays must be the same +# length — the worker drops any entry the plugin would reject rather than +# letting it fail at script evaluation. +# +# No format limits were found for this plugin at any depth or subsampling, so +# there is no guard. `opt` is deliberately not passed: on arm64 every value +# produces byte-identical output, so it is inert here and a footgun on x86. +clip = core.lghost.LGhost( + clip, + mode={{LG_MODE}}, + shift={{LG_SHIFT}}, + intensity={{LG_INTENSITY}}, +) +{{/GHOST_REMOVAL}} + +# ============================================================================ +# PASS 1b: DEFLICKER +# ============================================================================ +{{#DEFLICKER}} +# Even out brightness pulsing. Runs after deinterlacing — field-doubled frames +# break every temporal comparison this makes — and before the dirt and denoise +# passes, which all assume a stable exposure. +import sys as _dfl_sys +_dfl_sys.path.insert(0, r"{{PIPE_SOURCE_DIR}}") +import deflicker as _deflicker +{{#DEFLICKER_GLOBAL}} +# Whole-frame exposure correction: fits a gain AND an offset per frame against a +# windowed neighbourhood average. Measured 83.5% of injected flicker removed. +# Both terms are fitted because neither alone works on every fault: gain-only +# manages 46% on additive flicker, offset-only 46% on multiplicative. +clip = _deflicker.global_deflicker(clip, strength={{DEFLICKER_STRENGTH}}, window={{DEFLICKER_WINDOW}}) +{{/DEFLICKER_GLOBAL}} +{{#DEFLICKER_LOCAL}} +# Local oscillation damper, for flicker that varies across the frame — which a +# whole-frame statistic cannot represent. Transcribed from the ReduceFlicker +# algorithm rather than using the plugin: its non-SIMD path reads the wrong +# neighbour frame and aarch64 has no other path, so the ARM bundles would +# render differently from x86. +clip = _deflicker.reduce_flicker(clip, strength={{DEFLICKER_LOCAL_STRENGTH}}, aggressive={{DEFLICKER_AGGRESSIVE}}) +{{/DEFLICKER_LOCAL}} +{{/DEFLICKER}} + # ============================================================================ {{#DESCRATCH}} # DeScratch - remove vertical scratches from film @@ -471,6 +563,7 @@ if _descratch_orig_format.bits_per_sample != 8: # PASS 4: SPOTLESS (temporal spot/dirt removal) # ============================================================================ {{#SPOTLESS}} +{{#SPOTLESS_CLASSIC}} import sys as _spotless_sys _spotless_sys.path.insert(0, r"{{PIPE_SOURCE_DIR}}") from spotless import SpotLess as _SpotLess @@ -492,12 +585,37 @@ clip = _SpotLess( pel={{SPOTLESS_PEL}}, {{/SPOTLESS_PEL}} ) +{{/SPOTLESS_CLASSIC}} +{{#SPOTLESS_REMOVEDIRT}} +# RemoveDirt — the fast alternative to SpotLess. Measured 908 fps against +# SpotLess's 143 for about 60% of the spot removal, which is the entire reason +# it is here: on a long capture SpotLess takes hours. The motion-compensated +# variant is deliberately NOT offered — measured, it lands on the same quality +# point as SpotLess and runs slower, so it adds nothing. +import sys as _rd_sys +_rd_sys.path.insert(0, r"{{PIPE_SOURCE_DIR}}") +from removedirt import remove_dirt as _remove_dirt +clip = _remove_dirt( + clip, + gmthreshold={{RD_GMTHRESHOLD}}, + noise={{RD_NOISE}}, + noisy={{RD_NOISY}}, + dist={{RD_DIST}}, + post_denoise={{RD_POST_DENOISE}}, +) +{{/SPOTLESS_REMOVEDIRT}} {{/SPOTLESS}} # ============================================================================ # PASS 5: NOISE REDUCTION # ============================================================================ {{#NOISE_REDUCTION}} +{{#NR_POST_CONTRASHARP}} +# ContraSharpening needs BOTH the pre- and post-denoise clip, so it cannot sit +# in the linear pass chain like a Sharpen method — it has to bracket the +# denoise. Capture the input here. +_nr_presharp_src = clip +{{/NR_POST_CONTRASHARP}} import mvsfunc as mvf {{#NR_SMDEGRAIN}} @@ -541,6 +659,105 @@ clip = haf.MCTemporalDenoise( ) {{/NR_MCTD}} +{{#NR_DFTTEST}} +# DFTTest — frequency-domain denoiser. Fine, even grain separates cleanly from +# detail in the DFT domain, which is what this does better than the +# motion-compensated options. +clip = core.dfttest.DFTTest( + clip, + sigma={{NR_DFTTEST_SIGMA}}, + tbsize={{NR_DFTTEST_TBSIZE}}, + sbsize={{NR_DFTTEST_SBSIZE}}, +) +{{/NR_DFTTEST}} + +{{#NR_FFT3D}} +# FFT3DFilter — the classic 3D FFT spatio-temporal denoiser. Fast and +# aggressive; the traditional first stop for VHS luma noise. +clip = core.fft3dfilter.FFT3DFilter( + clip, + sigma={{NR_FFT3D_SIGMA}}, + bt={{NR_FFT3D_BT}}, +{{#NR_FFT3D_SHARPEN}} + sharpen={{NR_FFT3D_SHARPEN}}, +{{/NR_FFT3D_SHARPEN}} +) +{{/NR_FFT3D}} + +{{#NR_TTEMPSMOOTH}} +# TTempSmooth — motion-adaptive temporal smoother. Very gentle: pixels differing +# by more than `thresh` are left alone entirely, so it settles residual shimmer +# without touching real motion. `mdiff` is held below `thresh` by the worker, +# because equal or greater silently disables the motion protection. +clip = core.ttmpsm.TTempSmooth( + clip, + maxr={{NR_TTEMP_MAXR}}, + thresh={{NR_TTEMP_THRESH}}, + mdiff={{NR_TTEMP_MDIFF}}, + strength={{NR_TTEMP_STRENGTH}}, +) +{{/NR_TTEMPSMOOTH}} + +{{#NR_FLUXSMOOTH_T}} +# FluxSmoothT - averages each pixel with its temporal neighbours, but ONLY where +# they bracket it in value (one above, one below). Motion is therefore left +# alone almost for free, which is why it is a common first pass on tape. +clip = core.flux.SmoothT( + clip, + temporal_threshold={{NR_FLUX_TEMPORAL}}, +) +{{/NR_FLUXSMOOTH_T}} + +{{#NR_FLUXSMOOTH_ST}} +# FluxSmoothST - as SmoothT plus the eight spatial neighbours. Stronger, and a +# little more willing to soften detail. +clip = core.flux.SmoothST( + clip, + temporal_threshold={{NR_FLUX_TEMPORAL}}, + spatial_threshold={{NR_FLUX_SPATIAL}}, +) +{{/NR_FLUXSMOOTH_ST}} + +{{#NR_STPRESSO}} +# STPresso (havsfunc) - limits how far any pixel may move from its original +# value, so detail survives almost intact. Calls core.flux.SmoothT internally, +# which is why the fluxsmooth plugin is a hard requirement for this method. +clip = haf.STPresso( + clip, + limit={{NR_STPRESSO_LIMIT}}, + bias={{NR_STPRESSO_BIAS}}, + tthr={{NR_STPRESSO_TTHR}}, +) +{{/NR_STPRESSO}} + +{{#NR_CTMF}} +# CTMF - constant-time median filter. A large-radius median for blotches and +# impulse noise, at a cost that barely moves with radius (measured: radius 1 to +# 127 is about 20% apart). +# +# Two guards, both measured against the bundle rather than assumed: +# +# - 9-BIT IS REJECTED outright ("only constant format 8, 10, 12, 14, 16 bit +# integer ... supported"), and 9-bit is reachable here: pixel_format.rs rounds +# an odd source depth up through [8, 9, 10, 12, 14, 16] and pipe_source maps +# yuv420p9le. So a 9-bit clip is lifted to 10-bit and restored after. +# - memsize is pinned to 16 MiB. At the plugin's 1 MiB default, 16-bit radius 3 +# runs at 0.79 fps against 42 fps with this value — a 40x cliff — for +# BIT-IDENTICAL output. +_ctmf_src_format = clip.format +if clip.format.bits_per_sample == 9: + clip = core.resize.Point(clip, format=clip.format.replace( + bits_per_sample=10, sample_type=vs.INTEGER).id) +clip = core.ctmf.CTMF( + clip, + radius={{NR_CTMF_RADIUS}}, + planes={{NR_CTMF_PLANES}}, + memsize=16777216, +) +if _ctmf_src_format.bits_per_sample == 9: + clip = core.resize.Point(clip, format=_ctmf_src_format.id) +{{/NR_CTMF}} + {{#NR_MCDEGRAINSHARP}} # MCDegrainSharp (Didée) — motion-compensated degrain that sharpens where the # motion match is good and blurs where it is poor. @@ -622,12 +839,59 @@ clip = mvf.BM3D( {{/NR_BM3D_RADIUS}} ) {{/NR_BM3D}} +{{#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. +import sys as _mclean_sys +_mclean_sys.path.insert(0, r"{{PIPE_SOURCE_DIR}}") +from mclean import mClean as _mClean +clip = _mClean( + clip, + thSAD={{NR_MCLEAN_THSAD}}, + chroma={{NR_MCLEAN_CHROMA}}, + sharp={{NR_MCLEAN_SHARP}}, + rn={{NR_MCLEAN_RN}}, + strength={{NR_MCLEAN_STRENGTH}}, +) +{{/NR_MCLEAN}} +{{#NR_TD2}} +# TemporalDegrain2 — the heavyweight. Multi-pass motion-compensated degraining +# with an optional frequency-domain cleanup on top. About 21 fps, and the most +# capable denoiser in the app. +# +# The vendored module clamps postFFT to 0-3 and grainLevel to -2..3, and scales +# its depth-dependent limits from clip.format. Those are not cosmetic: postFFT +# 4 and 5 abort the process rather than raising, and without the depth scaling +# the filter is a near no-op at 12-bit and above. +import sys as _td2_sys +_td2_sys.path.insert(0, r"{{PIPE_SOURCE_DIR}}") +from temporaldegrain2 import TemporalDegrain2 as _TemporalDegrain2 +clip = _TemporalDegrain2( + clip, + degrainTR={{NR_TD2_TR}}, + grainLevel={{NR_TD2_GRAIN_LEVEL}}, + postFFT={{NR_TD2_POST_FFT}}, + postSigma={{NR_TD2_POST_SIGMA}}, + postMix={{NR_TD2_POST_MIX}}, + ChromaMotion={{NR_TD2_CHROMA_MOTION}}, +) +{{/NR_TD2}} +{{#NR_POST_CONTRASHARP}} +# Give back the fine detail the denoiser removed, by adding back only the +# high-frequency difference that survives a Repair against the original. This +# is the standard companion to a strong denoise, and unlike a plain sharpener it +# cannot introduce detail that was not there before. +# +# No depth scaling needed: its only constant is derived from clip.format. +clip = haf.ContraSharpening(clip, _nr_presharp_src, rep={{NR_POST_CONTRASHARP_REP}}) +{{/NR_POST_CONTRASHARP}} {{/NOISE_REDUCTION}} # ============================================================================ # PASS 5b: CHROMA DENOISE (CCD - Camcorder Colour Denoise) # ============================================================================ {{#CHROMA_DENOISE}} +{{#CHROMA_DENOISE_CCD}} # CCD averages chroma over neighbours whose RGB distance is within `threshold`, # leaving luma untouched — the standard fix for the blotchy colour noise on VHS # captures and old camcorder footage. @@ -652,6 +916,39 @@ clip = core.zsmooth.CCD( points={{CCD_POINTS}}, scale=_ccd_scale, ) +{{/CHROMA_DENOISE_CCD}} +{{#CHROMA_DENOISE_CNR4}} +# Cnr4 — temporal chroma denoise, gated on luma motion. A different failure mode +# from CCD: it targets chroma that swims or shimmers frame to frame rather than +# blotches that sit still, so the two are complementary rather than alternatives. +# +# TWO TRAPS, both measured against the bundled plugin: +# +# 1. `scenechange` defaults to True and REQUIRES the _SceneChangePrev/Next frame +# properties, which this pipeline never sets. A bare Cnr4(clip) therefore +# fails 100% of jobs on every platform with "Scene change handling enabled, +# but input frame is missing scene change properties". misc.SCDetect supplies +# them, and is the better answer than scenechange=False because it is what +# stops the filter smearing chroma across a cut. +# +# 2. It rejects 4:1:1 outright ("must be integer YUV420, YUV422, YUV440, or +# YUV444"), and 4:1:1 is NTSC DV, which pipe_source maps natively. Convert +# up to 4:2:2 and restore afterwards, as DeScratch does for bit depth. +_cnr4_src_format = clip.format.id +if clip.format.subsampling_w == 2: + clip = core.resize.Bicubic(clip, format=vs.YUV422P8 if clip.format.bits_per_sample == 8 else vs.YUV422P16) +clip = core.misc.SCDetect(clip) +clip = core.zsmooth.Cnr4( + clip, + sense={{CNR4_SENSE}}, + str={{CNR4_STRENGTH}}, + radius={{CNR4_RADIUS}}, + tmode={{CNR4_TMODE}}, + wmode={{CNR4_WMODE}}, +) +if clip.format.id != _cnr4_src_format: + clip = core.resize.Bicubic(clip, format=_cnr4_src_format) +{{/CHROMA_DENOISE_CNR4}} {{/CHROMA_DENOISE}} # ============================================================================ @@ -784,6 +1081,30 @@ clip = haf.{{DEHALO_VINVERSE_FN}}( {{/DEHALO_VINVERSE_CHROMA}} ) {{/DEHALO_VINVERSE}} +{{#DEHALO_HQDERING}} +# HQDeringmod - masked ring removal. Targets the overshoot immediately beside an +# edge while a mask protects the edge itself, which is a narrower target than +# dehalo's wider bright band. Every argument is optional so havsfunc's own +# defaults apply where the user hasn't chosen. +clip = haf.HQDeringmod( + clip, +{{#DEHALO_DERING_MRAD}} + mrad={{DEHALO_DERING_MRAD}}, +{{/DEHALO_DERING_MRAD}} +{{#DEHALO_DERING_MSMOOTH}} + msmooth={{DEHALO_DERING_MSMOOTH}}, +{{/DEHALO_DERING_MSMOOTH}} +{{#DEHALO_DERING_MTHR}} + mthr={{DEHALO_DERING_MTHR}}, +{{/DEHALO_DERING_MTHR}} +{{#DEHALO_DERING_THR}} + thr={{DEHALO_DERING_THR}}, +{{/DEHALO_DERING_THR}} +{{#DEHALO_DERING_DARKTHR}} + darkthr={{DEHALO_DERING_DARKTHR}}, +{{/DEHALO_DERING_DARKTHR}} +) +{{/DEHALO_HQDERING}} {{/DEHALO}} # ============================================================================ @@ -819,6 +1140,22 @@ clip = core.deblock.Deblock( {{/DEBLOCK_QUANT1}} ) {{/DEBLOCK_SIMPLE}} +{{#DEBLOCK_DCTFILTER}} +# DCTFilter - attenuates chosen DCT frequency bands directly. A scalpel for +# ringing and mosquito noise, which the two block-edge deblockers do not touch. +# +# The eight factors are applied SEPARABLY: coefficient (u, v) is scaled by +# factors[u] * factors[v] — measured against the bundle, not the max(u, v) the +# Avisynth filter of the same name uses. So a band affects a whole row and +# column. The worker builds the curve from a cutoff and a strength rather than +# exposing eight raw sliders, and guarantees every value is finite: DCTFilter's +# own range check lets NaN through and a NaN factor silently blackens the frame. +clip = core.dctf.DCTFilter( + clip, + factors={{DEBLOCK_DCT_FACTORS}}, + planes={{DEBLOCK_DCT_PLANES}}, +) +{{/DEBLOCK_DCTFILTER}} {{/DEBLOCK}} # ============================================================================ @@ -855,6 +1192,47 @@ clip = core.neo_f3kdb.Deband( ) {{/DEBAND}} +# ============================================================================ +# PASS: ANTI-ALIASING +# ============================================================================ +{{#ANTI_ALIAS}} +# znedi3's DOUBLE-RATE mode requires the _FieldBased frame property and fails +# the whole job with "znedi3: _FieldBased" when it is absent. Measured against +# the bundled plugin: field=3 errors with no property and is fine with either 0 +# or 2; field=1 does not care. havsfunc's daa calls nnedi3 with field=3, and +# this pipeline only sets the property when a field order is KNOWN — so an +# ordinary source with none, which is most of them, killed daa outright. +# +# It survived on macOS arm64 (nnedi3 via patch 6) and on Windows (whose +# prebuilt znedi3 tolerates the absence), and died on macOS x64 and Linux x64: +# the same job worked or failed depending on the user's machine. +# +# So mark the clip explicitly for the pass rather than relying on an upstream +# pass having done it. 0 when deinterlacing already ran (its output IS +# progressive) or when nothing was detected; the detected order otherwise, +# which is what daa needs to pair fields correctly. +clip = core.std.SetFieldBased(clip, {{AA_FIELD_BASED_VALUE}}) +{{#AA_DAA}} +# daa - double-rate anti-aliasing. Interpolates both fields with nnedi3 and +# averages them, so a stair-stepped diagonal is rebuilt rather than blurred. +# The gentler of the two methods and the usual choice. +clip = haf.daa(clip) +{{/AA_DAA}} + +{{#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". +clip = haf.santiag( + clip, + strh={{AA_STRH}}, + strv={{AA_STRV}}, + type="{{AA_TYPE}}", +) +{{/AA_SANTIAG}} +{{/ANTI_ALIAS}} + # ============================================================================ # PASS 7: SHARPEN # ============================================================================ @@ -888,6 +1266,24 @@ clip = core.cas.CAS( {{/SHARPEN_CAS_SHARPNESS}} ) {{/SHARPEN_CAS}} +{{#SHARPEN_AWARPSHARP2}} +# aWarpSharp2 - sharpens by warping pixels toward edges rather than raising +# local contrast, so it produces no halos. +# +# `chroma` and `planes` are deliberately not passed. This VapourSynth port takes +# chroma as 0 or 1 (NOT Avisynth's 0-6, where 4 means "warp chroma with the luma +# mask") and rejects anything else outright, and by default it processes luma +# only — the conventional use as a sharpener. Passing a guessed value here is how +# this block first shipped broken; expose it deliberately, with a test, or leave +# the plugin's own default alone. +clip = core.warp.AWarpSharp2( + clip, + thresh={{SHARPEN_WARP_THRESH}}, + blur={{SHARPEN_WARP_BLUR}}, + type={{SHARPEN_WARP_TYPE}}, + depth={{SHARPEN_WARP_DEPTH}}, +) +{{/SHARPEN_AWARPSHARP2}} {{/SHARPEN}} # ============================================================================ @@ -895,6 +1291,54 @@ clip = core.cas.CAS( # ============================================================================ {{#CHROMA_FIXES}} +def _dedot_8bit_format(c): + """The clip's own layout at 8 bits — DeDot is 8-bit only.""" + return core.get_video_format(c.format.id).replace(bits_per_sample=8).id + +{{#CF_AUTO_CHROMA}} +# Measure the chroma misalignment and correct it, instead of asking the user to +# guess it on the manual sliders below. Measured on real PAL footage it recovers +# an injected whole-pixel shift exactly, at 4:2:0 and 4:2:2 and 8-16 bit, in +# about 0.07s using a single reference frame. +# +# It refuses to invent a shift when chroma carries no measurable edge structure +# — a score curve spanning under 5% of its peak reports zero rather than +# guessing. A very soft VHS chroma channel is exactly that case. +import sys as _acf_sys +_acf_sys.path.insert(0, r"{{PIPE_SOURCE_DIR}}") +from autochromafix import auto_chroma_fix as _auto_chroma_fix +clip = _auto_chroma_fix( + clip, + max_shift={{ACF_MAX_SHIFT}}, + accuracy={{ACF_ACCURACY}}, + reference_frame={{ACF_REFERENCE_FRAME}}, +) +{{/CF_AUTO_CHROMA}} +{{#CF_DEDOT}} +# DeDot — temporal dot crawl and rainbow removal, working on BOTH planes and +# skipping moving objects. Complementary to LUTDeCrawl rather than a +# replacement: measured on a line-alternating crawl pattern, LUTDeCrawl reached +# 0.00 residual where DeDot left 11.69, while on a phase-inverting checkerboard +# DeDot reached 0.24/0.00 (luma/chroma) where LUTDeCrawl left chroma untouched. +# Different geometries, so both are worth having. +# +# 8-bit only: "the input clip must be 8 bit YUV or Gray with constant format and +# dimensions", measured failing at 10/12/16-bit on both 4:2:0 and 4:2:2. Same +# convert-down-and-restore guard as DeScratch and BiFrost. +_dedot_src_format = clip.format.id +if clip.format.bits_per_sample != 8: + clip = core.resize.Bicubic(clip, format=_dedot_8bit_format(clip)) +clip = core.dedot.Dedot( + clip, + luma_2d={{DEDOT_LUMA_2D}}, + luma_t={{DEDOT_LUMA_T}}, + chroma_t1={{DEDOT_CHROMA_T1}}, + chroma_t2={{DEDOT_CHROMA_T2}}, +) +if clip.format.id != _dedot_src_format: + clip = core.resize.Bicubic(clip, format=_dedot_src_format, dither_type="error_diffusion") +{{/CF_DEDOT}} + {{#CHROMA_SHIFT}} # Chroma Shift (Y/C Delay) - shift chroma planes to correct timing misalignment _shift_h = {{CHROMA_SHIFT_H}} @@ -958,6 +1402,65 @@ if _decrawl_orig_format.bits_per_sample > 10: dither_type="error_diffusion") {{/CHROMA_DECRAWL}} +{{#CHROMA_DERAINBOW}} +# LUTDeRainbow - remove rainbowing (cross-luminance): the shimmering colour +# bands composite video puts over fine detail. The companion to LUTDeCrawl, +# which targets the dots along colour edges instead. +# +# Same 8-10 bit limit as LUTDeCrawl and the same treatment: havsfunc rejects +# anything above 10-bit outright ("LUTDeRainbow: This is not an 8-10 bit YUV or +# YCoCg clip"), verified against the bundle, so run at 10-bit and restore the +# source format after. Rainbowing is an analog artifact; 10-bit is ample for the +# decision and the 8/10-bit path is byte-for-byte unchanged. +_derainbow_orig_format = clip.format +if clip.format.bits_per_sample > 10: + clip = core.resize.Bilinear(clip, format=clip.format.replace( + bits_per_sample=10, sample_type=vs.INTEGER).id) +clip = haf.LUTDeRainbow( + clip, +{{#DERAINBOW_CTHRESH}} + cthresh={{DERAINBOW_CTHRESH}}, +{{/DERAINBOW_CTHRESH}} +{{#DERAINBOW_YTHRESH}} + ythresh={{DERAINBOW_YTHRESH}}, +{{/DERAINBOW_YTHRESH}} +{{#DERAINBOW_Y}} + y={{DERAINBOW_Y}}, +{{/DERAINBOW_Y}} +{{#DERAINBOW_LINKUV}} + linkUV={{DERAINBOW_LINKUV}}, +{{/DERAINBOW_LINKUV}} +) +if _derainbow_orig_format.bits_per_sample > 10: + clip = core.resize.Bilinear(clip, format=_derainbow_orig_format.id, + dither_type="error_diffusion") +{{/CHROMA_DERAINBOW}} + +{{#CHROMA_BIFROST}} +# Bifrost - temporal rainbow / dot-crawl removal. Where LUTDeRainbow decides +# within a frame, this compares across frames (or fields), so it catches +# rainbowing that shimmers rather than sits still. Measured on an alternating- +# chroma clip: frame-to-frame chroma swing halved. +# +# 8-BIT ONLY: "Bifrost: Only constant format 8 bit integer YUV input supported", +# verified against the bundle at 10/12/16-bit and 4:2:2. Same treatment as +# DeScratch — run the pass at 8-bit and restore the source format after. In +# practice the sources this targets (VHS, Video8, DV captures) are 8-bit anyway. +_bifrost_src_format = clip.format +if clip.format.bits_per_sample != 8: + clip = core.resize.Bilinear(clip, format=clip.format.replace( + bits_per_sample=8, sample_type=vs.INTEGER).id) +clip = core.bifrost.Bifrost( + clip, + luma_thresh={{BIFROST_LUMA_THRESH}}, + variation={{BIFROST_VARIATION}}, + interlaced={{BIFROST_INTERLACED}}, +) +if _bifrost_src_format.bits_per_sample != 8: + clip = core.resize.Bilinear(clip, format=_bifrost_src_format.id, + dither_type="error_diffusion") +{{/CHROMA_BIFROST}} + {{#CHROMA_VINVERSE}} # Vinverse - remove residual combing clip = haf.Vinverse( @@ -976,6 +1479,69 @@ clip = haf.Vinverse( # PASS 9: COLOR CORRECTION # ============================================================================ {{#COLOR_CORRECTION}} +{{#CC_AUTO_LEVELS}} +# Auto levels — stretch luma so the darkest and brightest parts of the picture +# land on the target black and white points. The common fix for a washed-out +# capture, and the one thing a non-expert can ask for by name. +# +# Stats are measured on a heavily downscaled copy, not the full frame. Min and +# max of a full frame are set by single outlier pixels — one hot specular or one +# dead pixel re-grades the whole shot, and because those outliers come and go the +# result flickers frame to frame. Averaging them away first is what makes this +# usable on real footage. +# +# PlaneStatsMin/Max come back in the CLIP's own depth, so they are used raw; +# only the user's targets, which are in 8-bit UI units, get scaled to the format. +def _auto_levels(c, black_8bit, white_8bit, strength): + peak = (1 << c.format.bits_per_sample) - 1 + to_fmt = lambda v: int(round(v * peak / 255.0)) + lo_t, hi_t = to_fmt(black_8bit), to_fmt(white_8bit) + small = core.resize.Bilinear(c, 64, 48) if c.width > 128 else c + stats = core.std.PlaneStats(small, plane=0) + + def _apply(n, f, clip_in): + lo = int(f.props['PlaneStatsMin']) + hi = int(f.props['PlaneStatsMax']) + if hi <= lo: + return clip_in # flat frame: nothing to stretch, and avoids /0 + out = core.std.Levels( + clip_in, min_in=lo, max_in=hi, min_out=lo_t, max_out=hi_t, planes=[0] + ) + if strength >= 1.0: + return out + return core.std.Merge(clip_in, out, weight=strength) + + return core.std.FrameEval(c, functools.partial(_apply, clip_in=c), prop_src=stats) + +clip = _auto_levels(clip, {{CC_AUTO_LEVELS_BLACK}}, {{CC_AUTO_LEVELS_WHITE}}, {{CC_AUTO_LEVELS_STRENGTH}}) +{{/CC_AUTO_LEVELS}} +{{#CC_AUTO_WHITE}} +# Auto white balance, grey-world: assume the scene averages to neutral, and +# shift each chroma plane's mean onto the neutral point. In YUV that is the +# whole of the grey-world assumption — there is no second thing to compute, and +# unlike Retinex it needs no 4:4:4 round trip because it never leaves the native +# plane geometry. +# +# Chroma means are averages already, so they are far steadier frame to frame +# than the luma min/max above and need no outlier handling. +def _auto_white(c, strength): + if c.format.num_planes < 3: + return c # GRAY: PlaneStats(plane=1) would error + bits = c.format.bits_per_sample + peak = (1 << bits) - 1 + neutral = 1 << (bits - 1) + su = core.std.PlaneStats(c, plane=1) + sv = core.std.PlaneStats(c, plane=2) + + def _apply(n, f, clip_in): + du = (neutral - f[0].props['PlaneStatsAverage'] * peak) * strength + dv = (neutral - f[1].props['PlaneStatsAverage'] * peak) * strength + return _expr(clip_in, ['', 'x {:.6f} +'.format(du), 'x {:.6f} +'.format(dv)]) + + return core.std.FrameEval(c, functools.partial(_apply, clip_in=c), prop_src=[su, sv]) + +clip = _auto_white(clip, {{CC_AUTO_WHITE_STRENGTH}}) +{{/CC_AUTO_WHITE}} import adjust {{#COLOR_TWEAK}} @@ -1047,6 +1613,63 @@ clip = core.std.Levels( ) {{/COLOR_LEVELS}} +{{#COLOR_SMOOTH_LEVELS}} +# SmoothLevels - the same curve as std.Levels, but dithered and limited as it +# goes, so stretching a narrow range does not band. Measured on a shallow +# gradient stretched to full range: distinct output levels 47 -> 135. +# +# Three things are load-bearing here, all measured against the bundle: +# +# 1. SmoothLevels reads its levels in the CLIP'S OWN range, not 8-bit — the +# same trap as std.Levels. Six arguments need scaling (input_low/high, +# output_low/high, Ecenter, protect) and the rest must NOT be touched: +# gamma and chroma are ratios. Unscaled, a gamma-only edit at 16-bit is off +# by a worst pixel of 249/255. +# 2. useDB is pinned False. havsfunc calls `core.f3kdb.Deband` and this bundle +# ships `neo_f3kdb` under a different namespace, so the default useDB=True +# raises "no attribute named f3kdb" on EVERY format. Fixing that needs a +# havsfunc patch, i.e. a deps release; useDB=False already gets most of the +# benefit. +# 3. The black point is dropped when gamma is not 1.0 — havsfunc raises a +# negative base to a fractional power below input_low, which yields a +# complex number and fails the LUT. The worker does that, and tells the user. +clip = haf.SmoothLevels( + clip, + input_low=_levels_8bit({{SMOOTH_INPUT_LOW}}), + input_high=_levels_8bit({{SMOOTH_INPUT_HIGH}}), + output_low=_levels_8bit({{SMOOTH_OUTPUT_LOW}}), + output_high=_levels_8bit({{SMOOTH_OUTPUT_HIGH}}), + gamma={{SMOOTH_GAMMA}}, + Smode={{SMOOTH_MODE}}, + useDB=False, +) +{{/COLOR_SMOOTH_LEVELS}} + +{{#COLOR_SHADOW_DETAIL}} +# Retinex (MSRCP) - lifts detail out of shadows by comparing each pixel to a +# wide local average, so underexposed footage opens up without simply raising +# the black level. +# +# LUMA ONLY, deliberately. retinex.MSRCP rejects subsampled formats outright +# ("sub-sampled format is not supported") and every source this app handles is +# 4:2:0 or 4:2:2. Rather than round-trip the whole clip through 4:4:4 and +# resample chroma twice for a brightness operation, the luma plane is lifted out +# as greyscale, processed, and put back — colour comes through bit-identical. +# Verified working this way at 8/10/12/16-bit and 4:2:2. +_retinex_y = core.std.ShufflePlanes(clip, planes=0, colorfamily=vs.GRAY) +_retinex_y = core.retinex.MSRCP( + _retinex_y, + sigma=[{{SHADOW_SIGMA}}], + lower_thr={{SHADOW_LOWER}}, + upper_thr={{SHADOW_UPPER}}, +) +if clip.format.color_family == vs.GRAY: + clip = _retinex_y +else: + clip = core.std.ShufflePlanes([_retinex_y, clip, clip], planes=[0, 1, 2], + colorfamily=clip.format.color_family) +{{/COLOR_SHADOW_DETAIL}} + {{#COLOR_WHITE_BALANCE}} # White balance: shift the chroma planes. U carries blue-yellow and V carries # red-cyan, so warming the image is -U/+V and a magenta tint raises both. @@ -1070,6 +1693,59 @@ if clip.format.color_family == vs.YUV: {{/COLOR_WHITE_BALANCE}} {{/COLOR_CORRECTION}} +# ============================================================================ +# PASS: STABILIZE +# ============================================================================ +{{#STABILIZE}} +# Stab - global motion stabilisation via MVTools' Depan family. Measures the +# frame-to-frame shift and subtracts it, so shake is cancelled while a +# deliberate pan survives. Frame count is unchanged, which is why this is an +# ordinary pass. +# +# The signature is Stab(clp, dxmax, dymax, mirror) — there is no `range` +# argument, whatever other Stab implementations take. `mirror` fills the edges +# the shift exposes rather than leaving them black. +clip = haf.Stab( + clip, + dxmax={{STAB_DXMAX}}, + dymax={{STAB_DYMAX}}, + mirror={{STAB_MIRROR}}, +) +{{/STABILIZE}} + +# ============================================================================ +# PASS: ROTATE / FLIP +# ============================================================================ +{{#GEOMETRY}} +# Quarter-turn rotation and mirroring, in that order. core.std only, so the ops +# themselves move samples without changing their values. +# +# A quarter turn swaps width and height, which is why this runs before any +# framing decision; the worker also inverts the declared sample aspect for it, +# since SAR is pixel width : height and the turn exchanges those. +# +# A turn ALSO swaps the chroma subsampling axes, and that is not cosmetic: +# 4:2:2 becomes 4:4:0, which vspipe emits as "C440" and ffmpeg rejects outright +# ("YUV4MPEG stream contains an unknown pixel format"), and 4:1:1 has no y4m +# identifier at all so vspipe itself fails. Both are hard job failures, and +# 4:2:2 is the common 10-bit ProRes case. So the source format is captured here +# and restored after the turn, exactly as the LUTDeCrawl and DeScratch guards +# do. It is a no-op for 4:2:0, 4:4:4 and greyscale, whose subsampling is +# symmetric. +_geom_src_format = clip.format +{{#GEOM_ROTATE}} +clip = core.std.{{GEOM_ROTATE_FN}}(clip) +{{/GEOM_ROTATE}} +{{#GEOM_FLIP_H}} +clip = core.std.FlipHorizontal(clip) +{{/GEOM_FLIP_H}} +{{#GEOM_FLIP_V}} +clip = core.std.FlipVertical(clip) +{{/GEOM_FLIP_V}} +if clip.format.id != _geom_src_format.id: + clip = core.resize.Spline36(clip, format=_geom_src_format.id) +{{/GEOMETRY}} + # ============================================================================ # PASS 10: RESIZE / UPSCALE # ============================================================================ @@ -1273,6 +1949,122 @@ if _box_w > clip.width or _box_h > clip.height: {{/RESIZE_STANDARD}} {{/RESIZE}} +# ============================================================================ +# PASS: FILM GRAIN +# ============================================================================ +{{#GRAIN}} +{{#GRAIN_ADD}} +# AddGrain - one strength control, and the only one of the two that can grain +# chroma or hold a static pattern. +# +# `var` is a VARIANCE: the noise standard deviation is its square root, so +# var=4 gives sigma=2. It is already expressed in 8-bit units and the plugin +# rescales internally, so it must NOT get the _levels_8bit() treatment used for +# std.Levels and Tweak — doing that would quadruple the grain at 10-bit. +# Verified against the bundle: identical 8-bit-equivalent output at 8/10/12/16 +# bit and 4:2:2. +clip = core.grain.Add( + clip, + var={{GRAIN_VAR}}, + uvar={{GRAIN_UVAR}}, + hcorr={{GRAIN_CORR}}, + vcorr={{GRAIN_CORR}}, + constant={{GRAIN_CONSTANT}}, +) +{{/GRAIN_ADD}} + +{{#GRAIN_FACTORY3}} +# GrainFactory3 - three grain layers chosen by luma level, so shadows get more +# grain than highlights. Closer to real film stock than a flat noise field. +# +# Two measured limitations worth knowing: it is LUMA-ONLY (chroma comes back +# bit-identical), and it is ALWAYS animated — havsfunc leaves grain.Add's +# `constant` at its default and exposes no way to reach it. `temp_avg` damps the +# animation but cannot stop it. +clip = haf.GrainFactory3( + clip, + g1str={{GRAIN_G1}}, + g2str={{GRAIN_G2}}, + g3str={{GRAIN_G3}}, +{{#GRAIN_TEMP_AVG}} + temp_avg={{GRAIN_TEMP_AVG}}, +{{/GRAIN_TEMP_AVG}} +) +{{/GRAIN_FACTORY3}} +{{/GRAIN}} + +# ============================================================================ +# PASS 11: FRAME RATE CONVERSION +# ============================================================================ +{{#FRAME_RATE}} +# Standards conversion, not smoothing. Runs genuinely last of the video passes: +# it resamples the timeline, so anything after it would be working on invented +# frames rather than photographed ones. +# +# FlowFPS rather than BlockFPS deliberately. Measured over 35 combinations, +# FlowFPS emits floor(n_in * ratio), which is exactly what FrameMap::Retime +# computes; BlockFPS emits floor((n_in-1) * ratio) + 1 and is off by one in 14 +# of them, which would desynchronise the progress total and the preview index. +# +# mvtools rejects 4:1:1 at Super ("input clip must be GRAY, 420, 422, 440, or +# 444") and 4:1:1 is NTSC DV — precisely the tape an NTSC->PAL conversion +# targets — so convert up and restore, as the chroma denoise pass does. +_fps_src_format = clip.format.id +if clip.format.subsampling_w == 2: + clip = core.resize.Bicubic( + clip, + format=vs.YUV422P8 if clip.format.bits_per_sample == 8 else vs.YUV422P16, + ) +{{#FRAME_RATE_FLOWFPS}} +_fps_super = core.mv.Super(clip, pel=2) +_fps_bw = core.mv.Analyse(_fps_super, isb=True, blksize={{FPS_BLOCK_SIZE}}, overlap={{FPS_OVERLAP}}) +_fps_fw = core.mv.Analyse(_fps_super, isb=False, blksize={{FPS_BLOCK_SIZE}}, overlap={{FPS_OVERLAP}}) +clip = core.mv.FlowFPS( + clip, _fps_super, _fps_bw, _fps_fw, + num={{FPS_TARGET_NUM}}, den={{FPS_TARGET_DEN}}, +) +{{/FRAME_RATE_FLOWFPS}} +{{#FRAME_RATE_DUPLICATE}} +# No invented pixels — whole frames are repeated or dropped. Visible judder, but +# every frame in the output was photographed. The honest choice for a master. +clip = haf.ChangeFPS(clip, {{FPS_TARGET_NUM}}, {{FPS_TARGET_DEN}}) +{{/FRAME_RATE_DUPLICATE}} +if clip.format.id != _fps_src_format: + clip = core.resize.Bicubic(clip, format=_fps_src_format) +{{/FRAME_RATE}} + +# ============================================================================ +# CUSTOM VAPOURSYNTH +# ============================================================================ +{{#CUSTOM_VS}} +# User-supplied code, on the same footing as Custom FFmpeg Arguments. Placed +# after every built-in pass, so `clip` is the only contract it has to honour. +# +# The hazard here is NOT arbitrary code — this process already loads arbitrary +# plugins. It is the frame count. A snippet calling Trim, SelectEvery or +# Interleave changes the true output length while the declared total stays put, +# which makes the progress bar lie and, worse, makes frame-accurate preview show +# a different frame than its label. Both fail silently. So the length is +# asserted rather than trusted. +_custom_vs_frames_before = len(clip) +# --- user code begins --- +{{CUSTOM_VS_CODE}} +# --- user code ends --- +if not isinstance(clip, vs.VideoNode): + raise ValueError( + "Custom VapourSynth: `clip` must still be a VideoNode when your code " + "finishes. Assign your result back to `clip`." + ) +if len(clip) != _custom_vs_frames_before: + raise ValueError( + "Custom VapourSynth: your code changed the frame count from " + f"{_custom_vs_frames_before} to {len(clip)}. That would desynchronise " + "the progress total and make the preview show a different frame than " + "its label, so it is refused rather than allowed to fail silently. " + "Use the Trim controls instead." + ) +{{/CUSTOM_VS}} + # ============================================================================ # OUTPUT FORMAT CONVERSION # ============================================================================ diff --git a/worker/templates/preview_template.vpy b/worker/templates/preview_template.vpy index 7ad311ce..90ff2088 100644 --- a/worker/templates/preview_template.vpy +++ b/worker/templates/preview_template.vpy @@ -4,6 +4,7 @@ Reads raw frames piped from FFmpeg for fast preview generation. Uses the same filter pipeline as the main template. """ +import functools import vapoursynth as vs import sys import os @@ -327,6 +328,32 @@ if clip.format.id != _deint_src_format.id: dither_type="error_diffusion") {{/DEINT_RESTORE_FORMAT}} {{/DEINT_QTGMC}} +{{#DEINT_BWDIF}} +# Bwdif — the fast tier. Roughly 4-5x QTGMC Fast on the same clip, for long +# captures where QTGMC's hours are not justified. +# +# Two things measured against the bundled plugin, both negative results and both +# the point: +# * No format limits at all — 8/9/10/12/16-bit, 4:2:0/4:2:2/4:4:4, GRAY, RGB +# and even 4:1:1 all pass. Unique among recent additions; no guard needed. +# * No _FieldBased sensitivity. field=1 and field=3 both work with the +# property absent, 0, 1 or 2, so it does NOT repeat the znedi3 trap that +# cost two nightly cycles. +# +# `field` is required, not optional: 0/1 keep the bottom/top field (single +# rate), 2/3 double the rate. The parity half comes from the same field-order +# derivation QTGMC uses, so the two methods cannot disagree about it. +{{#DEINT_BWDIF_EDEINT}} +# An external interpolator. Bwdif accepts one directly, which is why Yadifmod +# is not a separate method: measured, Bwdif+nnedi3 (0.524) beats Yadifmod+nnedi3 +# (0.532) at the same cost. EEDI3 here is 11x slower for no gain. +_bwdif_edi = _nnedi3(clip, field={{BWDIF_FIELD}}, dh=False) +clip = core.bwdif.Bwdif(clip, field={{BWDIF_FIELD}}, edeint=_bwdif_edi) +{{/DEINT_BWDIF_EDEINT}} +{{#DEINT_BWDIF_PLAIN}} +clip = core.bwdif.Bwdif(clip, field={{BWDIF_FIELD}}) +{{/DEINT_BWDIF_PLAIN}} +{{/DEINT_BWDIF}} {{#DEINT_IVTC}} # IVTC Preview: VFM field matching only (no VDecimate for single-frame preview) # VFM only accepts 8-bit YUV/GRAY. For higher bit-depth sources (e.g. 10-bit @@ -356,6 +383,71 @@ clip = core.vivtc.VFM(_ivtc_metrics, order={{IVTC_ORDER}}, # ============================================================================ # PASS 3: DESCRATCH (scratch removal) +# ============================================================================ +# PASS 1c: EDGE REPAIR / GHOST REMOVAL +# ============================================================================ +{{#EDGE_REPAIR}} +# Rebuild the dirty rows and columns tape captures leave at the frame border. +# Must precede every spatial filter, or denoising and sharpening smear the bad +# rows inward, and must precede the resize, or resampling spreads them. +# +# Widths are always even. The bundle pins FillBorders v2 (the newest tag with +# published binaries), and v2 is bit-identical to v4 at even widths — they +# differ only at odd widths, where v2 leaves subsampled chroma unrepaired. +# +# interlaced=-1 reads _FieldBased, so a clip whose field order this pipeline +# knows is handled per-field without a second control. +clip = core.fb.FillBorders( + clip, + left={{ER_LEFT}}, right={{ER_RIGHT}}, top={{ER_TOP}}, bottom={{ER_BOTTOM}}, + mode="{{ER_MODE}}", + interlaced=-1, +) +{{/EDGE_REPAIR}} +{{#GHOST_REMOVAL}} +# Cancel the displaced echo RF and cable distribution leave behind. Each ghost +# is a (mode, shift, intensity) triple and the three arrays must be the same +# length — the worker drops any entry the plugin would reject rather than +# letting it fail at script evaluation. +# +# No format limits were found for this plugin at any depth or subsampling, so +# there is no guard. `opt` is deliberately not passed: on arm64 every value +# produces byte-identical output, so it is inert here and a footgun on x86. +clip = core.lghost.LGhost( + clip, + mode={{LG_MODE}}, + shift={{LG_SHIFT}}, + intensity={{LG_INTENSITY}}, +) +{{/GHOST_REMOVAL}} + +# ============================================================================ +# PASS 1b: DEFLICKER +# ============================================================================ +{{#DEFLICKER}} +# Even out brightness pulsing. Runs after deinterlacing — field-doubled frames +# break every temporal comparison this makes — and before the dirt and denoise +# passes, which all assume a stable exposure. +import sys as _dfl_sys +_dfl_sys.path.insert(0, r"{{PIPE_SOURCE_DIR}}") +import deflicker as _deflicker +{{#DEFLICKER_GLOBAL}} +# Whole-frame exposure correction: fits a gain AND an offset per frame against a +# windowed neighbourhood average. Measured 83.5% of injected flicker removed. +# Both terms are fitted because neither alone works on every fault: gain-only +# manages 46% on additive flicker, offset-only 46% on multiplicative. +clip = _deflicker.global_deflicker(clip, strength={{DEFLICKER_STRENGTH}}, window={{DEFLICKER_WINDOW}}) +{{/DEFLICKER_GLOBAL}} +{{#DEFLICKER_LOCAL}} +# Local oscillation damper, for flicker that varies across the frame — which a +# whole-frame statistic cannot represent. Transcribed from the ReduceFlicker +# algorithm rather than using the plugin: its non-SIMD path reads the wrong +# neighbour frame and aarch64 has no other path, so the ARM bundles would +# render differently from x86. +clip = _deflicker.reduce_flicker(clip, strength={{DEFLICKER_LOCAL_STRENGTH}}, aggressive={{DEFLICKER_AGGRESSIVE}}) +{{/DEFLICKER_LOCAL}} +{{/DEFLICKER}} + # ============================================================================ {{#DESCRATCH}} # DeScratch - remove vertical scratches from film @@ -420,6 +512,7 @@ if _descratch_orig_format.bits_per_sample != 8: # PASS 4: SPOTLESS (temporal spot/dirt removal) # ============================================================================ {{#SPOTLESS}} +{{#SPOTLESS_CLASSIC}} import sys as _spotless_sys _spotless_sys.path.insert(0, r"{{PIPE_SOURCE_DIR}}") from spotless import SpotLess as _SpotLess @@ -441,12 +534,37 @@ clip = _SpotLess( pel={{SPOTLESS_PEL}}, {{/SPOTLESS_PEL}} ) +{{/SPOTLESS_CLASSIC}} +{{#SPOTLESS_REMOVEDIRT}} +# RemoveDirt — the fast alternative to SpotLess. Measured 908 fps against +# SpotLess's 143 for about 60% of the spot removal, which is the entire reason +# it is here: on a long capture SpotLess takes hours. The motion-compensated +# variant is deliberately NOT offered — measured, it lands on the same quality +# point as SpotLess and runs slower, so it adds nothing. +import sys as _rd_sys +_rd_sys.path.insert(0, r"{{PIPE_SOURCE_DIR}}") +from removedirt import remove_dirt as _remove_dirt +clip = _remove_dirt( + clip, + gmthreshold={{RD_GMTHRESHOLD}}, + noise={{RD_NOISE}}, + noisy={{RD_NOISY}}, + dist={{RD_DIST}}, + post_denoise={{RD_POST_DENOISE}}, +) +{{/SPOTLESS_REMOVEDIRT}} {{/SPOTLESS}} # ============================================================================ # PASS 5: NOISE REDUCTION # ============================================================================ {{#NOISE_REDUCTION}} +{{#NR_POST_CONTRASHARP}} +# ContraSharpening needs BOTH the pre- and post-denoise clip, so it cannot sit +# in the linear pass chain like a Sharpen method — it has to bracket the +# denoise. Capture the input here. +_nr_presharp_src = clip +{{/NR_POST_CONTRASHARP}} import mvsfunc as mvf {{#NR_SMDEGRAIN}} @@ -487,6 +605,101 @@ clip = haf.MCTemporalDenoise( ) {{/NR_MCTD}} +{{#NR_DFTTEST}} +# DFTTest — frequency-domain denoiser. See pipeline_template.vpy; the two must +# stay in step or the preview shows a different picture from the render. +clip = core.dfttest.DFTTest( + clip, + sigma={{NR_DFTTEST_SIGMA}}, + tbsize={{NR_DFTTEST_TBSIZE}}, + sbsize={{NR_DFTTEST_SBSIZE}}, +) +{{/NR_DFTTEST}} + +{{#NR_FFT3D}} +# FFT3DFilter — classic 3D FFT spatio-temporal denoiser. +clip = core.fft3dfilter.FFT3DFilter( + clip, + sigma={{NR_FFT3D_SIGMA}}, + bt={{NR_FFT3D_BT}}, +{{#NR_FFT3D_SHARPEN}} + sharpen={{NR_FFT3D_SHARPEN}}, +{{/NR_FFT3D_SHARPEN}} +) +{{/NR_FFT3D}} + +{{#NR_TTEMPSMOOTH}} +# TTempSmooth — motion-adaptive temporal smoother. `mdiff` is held below +# `thresh` by the worker. +clip = core.ttmpsm.TTempSmooth( + clip, + maxr={{NR_TTEMP_MAXR}}, + thresh={{NR_TTEMP_THRESH}}, + mdiff={{NR_TTEMP_MDIFF}}, + strength={{NR_TTEMP_STRENGTH}}, +) +{{/NR_TTEMPSMOOTH}} + +{{#NR_FLUXSMOOTH_T}} +# FluxSmoothT - averages each pixel with its temporal neighbours, but ONLY where +# they bracket it in value (one above, one below). Motion is therefore left +# alone almost for free, which is why it is a common first pass on tape. +clip = core.flux.SmoothT( + clip, + temporal_threshold={{NR_FLUX_TEMPORAL}}, +) +{{/NR_FLUXSMOOTH_T}} + +{{#NR_FLUXSMOOTH_ST}} +# FluxSmoothST - as SmoothT plus the eight spatial neighbours. Stronger, and a +# little more willing to soften detail. +clip = core.flux.SmoothST( + clip, + temporal_threshold={{NR_FLUX_TEMPORAL}}, + spatial_threshold={{NR_FLUX_SPATIAL}}, +) +{{/NR_FLUXSMOOTH_ST}} + +{{#NR_STPRESSO}} +# STPresso (havsfunc) - limits how far any pixel may move from its original +# value, so detail survives almost intact. Calls core.flux.SmoothT internally, +# which is why the fluxsmooth plugin is a hard requirement for this method. +clip = haf.STPresso( + clip, + limit={{NR_STPRESSO_LIMIT}}, + bias={{NR_STPRESSO_BIAS}}, + tthr={{NR_STPRESSO_TTHR}}, +) +{{/NR_STPRESSO}} + +{{#NR_CTMF}} +# CTMF - constant-time median filter. A large-radius median for blotches and +# impulse noise, at a cost that barely moves with radius (measured: radius 1 to +# 127 is about 20% apart). +# +# Two guards, both measured against the bundle rather than assumed: +# +# - 9-BIT IS REJECTED outright ("only constant format 8, 10, 12, 14, 16 bit +# integer ... supported"), and 9-bit is reachable here: pixel_format.rs rounds +# an odd source depth up through [8, 9, 10, 12, 14, 16] and pipe_source maps +# yuv420p9le. So a 9-bit clip is lifted to 10-bit and restored after. +# - memsize is pinned to 16 MiB. At the plugin's 1 MiB default, 16-bit radius 3 +# runs at 0.79 fps against 42 fps with this value — a 40x cliff — for +# BIT-IDENTICAL output. +_ctmf_src_format = clip.format +if clip.format.bits_per_sample == 9: + clip = core.resize.Point(clip, format=clip.format.replace( + bits_per_sample=10, sample_type=vs.INTEGER).id) +clip = core.ctmf.CTMF( + clip, + radius={{NR_CTMF_RADIUS}}, + planes={{NR_CTMF_PLANES}}, + memsize=16777216, +) +if _ctmf_src_format.bits_per_sample == 9: + clip = core.resize.Point(clip, format=_ctmf_src_format.id) +{{/NR_CTMF}} + {{#NR_MCDEGRAINSHARP}} # MCDegrainSharp (Didée) — motion-compensated degrain that sharpens where the # motion match is good and blurs where it is poor. @@ -568,12 +781,59 @@ clip = mvf.BM3D( {{/NR_BM3D_RADIUS}} ) {{/NR_BM3D}} +{{#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. +import sys as _mclean_sys +_mclean_sys.path.insert(0, r"{{PIPE_SOURCE_DIR}}") +from mclean import mClean as _mClean +clip = _mClean( + clip, + thSAD={{NR_MCLEAN_THSAD}}, + chroma={{NR_MCLEAN_CHROMA}}, + sharp={{NR_MCLEAN_SHARP}}, + rn={{NR_MCLEAN_RN}}, + strength={{NR_MCLEAN_STRENGTH}}, +) +{{/NR_MCLEAN}} +{{#NR_TD2}} +# TemporalDegrain2 — the heavyweight. Multi-pass motion-compensated degraining +# with an optional frequency-domain cleanup on top. About 21 fps, and the most +# capable denoiser in the app. +# +# The vendored module clamps postFFT to 0-3 and grainLevel to -2..3, and scales +# its depth-dependent limits from clip.format. Those are not cosmetic: postFFT +# 4 and 5 abort the process rather than raising, and without the depth scaling +# the filter is a near no-op at 12-bit and above. +import sys as _td2_sys +_td2_sys.path.insert(0, r"{{PIPE_SOURCE_DIR}}") +from temporaldegrain2 import TemporalDegrain2 as _TemporalDegrain2 +clip = _TemporalDegrain2( + clip, + degrainTR={{NR_TD2_TR}}, + grainLevel={{NR_TD2_GRAIN_LEVEL}}, + postFFT={{NR_TD2_POST_FFT}}, + postSigma={{NR_TD2_POST_SIGMA}}, + postMix={{NR_TD2_POST_MIX}}, + ChromaMotion={{NR_TD2_CHROMA_MOTION}}, +) +{{/NR_TD2}} +{{#NR_POST_CONTRASHARP}} +# Give back the fine detail the denoiser removed, by adding back only the +# high-frequency difference that survives a Repair against the original. This +# is the standard companion to a strong denoise, and unlike a plain sharpener it +# cannot introduce detail that was not there before. +# +# No depth scaling needed: its only constant is derived from clip.format. +clip = haf.ContraSharpening(clip, _nr_presharp_src, rep={{NR_POST_CONTRASHARP_REP}}) +{{/NR_POST_CONTRASHARP}} {{/NOISE_REDUCTION}} # ============================================================================ # PASS 5b: CHROMA DENOISE (CCD - Camcorder Colour Denoise) # ============================================================================ {{#CHROMA_DENOISE}} +{{#CHROMA_DENOISE_CCD}} # CCD averages chroma over neighbours whose RGB distance is within `threshold`, # leaving luma untouched — the standard fix for the blotchy colour noise on VHS # captures and old camcorder footage. @@ -598,6 +858,39 @@ clip = core.zsmooth.CCD( points={{CCD_POINTS}}, scale=_ccd_scale, ) +{{/CHROMA_DENOISE_CCD}} +{{#CHROMA_DENOISE_CNR4}} +# Cnr4 — temporal chroma denoise, gated on luma motion. A different failure mode +# from CCD: it targets chroma that swims or shimmers frame to frame rather than +# blotches that sit still, so the two are complementary rather than alternatives. +# +# TWO TRAPS, both measured against the bundled plugin: +# +# 1. `scenechange` defaults to True and REQUIRES the _SceneChangePrev/Next frame +# properties, which this pipeline never sets. A bare Cnr4(clip) therefore +# fails 100% of jobs on every platform with "Scene change handling enabled, +# but input frame is missing scene change properties". misc.SCDetect supplies +# them, and is the better answer than scenechange=False because it is what +# stops the filter smearing chroma across a cut. +# +# 2. It rejects 4:1:1 outright ("must be integer YUV420, YUV422, YUV440, or +# YUV444"), and 4:1:1 is NTSC DV, which pipe_source maps natively. Convert +# up to 4:2:2 and restore afterwards, as DeScratch does for bit depth. +_cnr4_src_format = clip.format.id +if clip.format.subsampling_w == 2: + clip = core.resize.Bicubic(clip, format=vs.YUV422P8 if clip.format.bits_per_sample == 8 else vs.YUV422P16) +clip = core.misc.SCDetect(clip) +clip = core.zsmooth.Cnr4( + clip, + sense={{CNR4_SENSE}}, + str={{CNR4_STRENGTH}}, + radius={{CNR4_RADIUS}}, + tmode={{CNR4_TMODE}}, + wmode={{CNR4_WMODE}}, +) +if clip.format.id != _cnr4_src_format: + clip = core.resize.Bicubic(clip, format=_cnr4_src_format) +{{/CHROMA_DENOISE_CNR4}} {{/CHROMA_DENOISE}} # ============================================================================ @@ -730,6 +1023,30 @@ clip = haf.{{DEHALO_VINVERSE_FN}}( {{/DEHALO_VINVERSE_CHROMA}} ) {{/DEHALO_VINVERSE}} +{{#DEHALO_HQDERING}} +# HQDeringmod - masked ring removal. Targets the overshoot immediately beside an +# edge while a mask protects the edge itself, which is a narrower target than +# dehalo's wider bright band. Every argument is optional so havsfunc's own +# defaults apply where the user hasn't chosen. +clip = haf.HQDeringmod( + clip, +{{#DEHALO_DERING_MRAD}} + mrad={{DEHALO_DERING_MRAD}}, +{{/DEHALO_DERING_MRAD}} +{{#DEHALO_DERING_MSMOOTH}} + msmooth={{DEHALO_DERING_MSMOOTH}}, +{{/DEHALO_DERING_MSMOOTH}} +{{#DEHALO_DERING_MTHR}} + mthr={{DEHALO_DERING_MTHR}}, +{{/DEHALO_DERING_MTHR}} +{{#DEHALO_DERING_THR}} + thr={{DEHALO_DERING_THR}}, +{{/DEHALO_DERING_THR}} +{{#DEHALO_DERING_DARKTHR}} + darkthr={{DEHALO_DERING_DARKTHR}}, +{{/DEHALO_DERING_DARKTHR}} +) +{{/DEHALO_HQDERING}} {{/DEHALO}} # ============================================================================ @@ -765,6 +1082,22 @@ clip = core.deblock.Deblock( {{/DEBLOCK_QUANT1}} ) {{/DEBLOCK_SIMPLE}} +{{#DEBLOCK_DCTFILTER}} +# DCTFilter - attenuates chosen DCT frequency bands directly. A scalpel for +# ringing and mosquito noise, which the two block-edge deblockers do not touch. +# +# The eight factors are applied SEPARABLY: coefficient (u, v) is scaled by +# factors[u] * factors[v] — measured against the bundle, not the max(u, v) the +# Avisynth filter of the same name uses. So a band affects a whole row and +# column. The worker builds the curve from a cutoff and a strength rather than +# exposing eight raw sliders, and guarantees every value is finite: DCTFilter's +# own range check lets NaN through and a NaN factor silently blackens the frame. +clip = core.dctf.DCTFilter( + clip, + factors={{DEBLOCK_DCT_FACTORS}}, + planes={{DEBLOCK_DCT_PLANES}}, +) +{{/DEBLOCK_DCTFILTER}} {{/DEBLOCK}} # ============================================================================ @@ -801,6 +1134,36 @@ clip = core.neo_f3kdb.Deband( ) {{/DEBAND}} +# ============================================================================ +# PASS: ANTI-ALIASING +# ============================================================================ +{{#ANTI_ALIAS}} +# znedi3's double-rate mode requires the _FieldBased frame property and fails +# with "znedi3: _FieldBased" when it is absent, which is what havsfunc's daa +# uses (field=3). See pipeline_template.vpy for the measurements; must stay in +# step with it, or a preview fails where the encode succeeds. +clip = core.std.SetFieldBased(clip, {{AA_FIELD_BASED_VALUE}}) +{{#AA_DAA}} +# daa - double-rate anti-aliasing. Interpolates both fields with nnedi3 and +# averages them, so a stair-stepped diagonal is rebuilt rather than blurred. +# The gentler of the two methods and the usual choice. +clip = haf.daa(clip) +{{/AA_DAA}} + +{{#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". +clip = haf.santiag( + clip, + strh={{AA_STRH}}, + strv={{AA_STRV}}, + type="{{AA_TYPE}}", +) +{{/AA_SANTIAG}} +{{/ANTI_ALIAS}} + # ============================================================================ # PASS 7: SHARPEN # ============================================================================ @@ -834,6 +1197,24 @@ clip = core.cas.CAS( {{/SHARPEN_CAS_SHARPNESS}} ) {{/SHARPEN_CAS}} +{{#SHARPEN_AWARPSHARP2}} +# aWarpSharp2 - sharpens by warping pixels toward edges rather than raising +# local contrast, so it produces no halos. +# +# `chroma` and `planes` are deliberately not passed. This VapourSynth port takes +# chroma as 0 or 1 (NOT Avisynth's 0-6, where 4 means "warp chroma with the luma +# mask") and rejects anything else outright, and by default it processes luma +# only — the conventional use as a sharpener. Passing a guessed value here is how +# this block first shipped broken; expose it deliberately, with a test, or leave +# the plugin's own default alone. +clip = core.warp.AWarpSharp2( + clip, + thresh={{SHARPEN_WARP_THRESH}}, + blur={{SHARPEN_WARP_BLUR}}, + type={{SHARPEN_WARP_TYPE}}, + depth={{SHARPEN_WARP_DEPTH}}, +) +{{/SHARPEN_AWARPSHARP2}} {{/SHARPEN}} # ============================================================================ @@ -841,6 +1222,54 @@ clip = core.cas.CAS( # ============================================================================ {{#CHROMA_FIXES}} +def _dedot_8bit_format(c): + """The clip's own layout at 8 bits — DeDot is 8-bit only.""" + return core.get_video_format(c.format.id).replace(bits_per_sample=8).id + +{{#CF_AUTO_CHROMA}} +# Measure the chroma misalignment and correct it, instead of asking the user to +# guess it on the manual sliders below. Measured on real PAL footage it recovers +# an injected whole-pixel shift exactly, at 4:2:0 and 4:2:2 and 8-16 bit, in +# about 0.07s using a single reference frame. +# +# It refuses to invent a shift when chroma carries no measurable edge structure +# — a score curve spanning under 5% of its peak reports zero rather than +# guessing. A very soft VHS chroma channel is exactly that case. +import sys as _acf_sys +_acf_sys.path.insert(0, r"{{PIPE_SOURCE_DIR}}") +from autochromafix import auto_chroma_fix as _auto_chroma_fix +clip = _auto_chroma_fix( + clip, + max_shift={{ACF_MAX_SHIFT}}, + accuracy={{ACF_ACCURACY}}, + reference_frame={{ACF_REFERENCE_FRAME}}, +) +{{/CF_AUTO_CHROMA}} +{{#CF_DEDOT}} +# DeDot — temporal dot crawl and rainbow removal, working on BOTH planes and +# skipping moving objects. Complementary to LUTDeCrawl rather than a +# replacement: measured on a line-alternating crawl pattern, LUTDeCrawl reached +# 0.00 residual where DeDot left 11.69, while on a phase-inverting checkerboard +# DeDot reached 0.24/0.00 (luma/chroma) where LUTDeCrawl left chroma untouched. +# Different geometries, so both are worth having. +# +# 8-bit only: "the input clip must be 8 bit YUV or Gray with constant format and +# dimensions", measured failing at 10/12/16-bit on both 4:2:0 and 4:2:2. Same +# convert-down-and-restore guard as DeScratch and BiFrost. +_dedot_src_format = clip.format.id +if clip.format.bits_per_sample != 8: + clip = core.resize.Bicubic(clip, format=_dedot_8bit_format(clip)) +clip = core.dedot.Dedot( + clip, + luma_2d={{DEDOT_LUMA_2D}}, + luma_t={{DEDOT_LUMA_T}}, + chroma_t1={{DEDOT_CHROMA_T1}}, + chroma_t2={{DEDOT_CHROMA_T2}}, +) +if clip.format.id != _dedot_src_format: + clip = core.resize.Bicubic(clip, format=_dedot_src_format, dither_type="error_diffusion") +{{/CF_DEDOT}} + {{#CHROMA_SHIFT}} # Chroma Shift (Y/C Delay) - shift chroma planes to correct timing misalignment _shift_h = {{CHROMA_SHIFT_H}} @@ -904,6 +1333,65 @@ if _decrawl_orig_format.bits_per_sample > 10: dither_type="error_diffusion") {{/CHROMA_DECRAWL}} +{{#CHROMA_DERAINBOW}} +# LUTDeRainbow - remove rainbowing (cross-luminance): the shimmering colour +# bands composite video puts over fine detail. The companion to LUTDeCrawl, +# which targets the dots along colour edges instead. +# +# Same 8-10 bit limit as LUTDeCrawl and the same treatment: havsfunc rejects +# anything above 10-bit outright ("LUTDeRainbow: This is not an 8-10 bit YUV or +# YCoCg clip"), verified against the bundle, so run at 10-bit and restore the +# source format after. Rainbowing is an analog artifact; 10-bit is ample for the +# decision and the 8/10-bit path is byte-for-byte unchanged. +_derainbow_orig_format = clip.format +if clip.format.bits_per_sample > 10: + clip = core.resize.Bilinear(clip, format=clip.format.replace( + bits_per_sample=10, sample_type=vs.INTEGER).id) +clip = haf.LUTDeRainbow( + clip, +{{#DERAINBOW_CTHRESH}} + cthresh={{DERAINBOW_CTHRESH}}, +{{/DERAINBOW_CTHRESH}} +{{#DERAINBOW_YTHRESH}} + ythresh={{DERAINBOW_YTHRESH}}, +{{/DERAINBOW_YTHRESH}} +{{#DERAINBOW_Y}} + y={{DERAINBOW_Y}}, +{{/DERAINBOW_Y}} +{{#DERAINBOW_LINKUV}} + linkUV={{DERAINBOW_LINKUV}}, +{{/DERAINBOW_LINKUV}} +) +if _derainbow_orig_format.bits_per_sample > 10: + clip = core.resize.Bilinear(clip, format=_derainbow_orig_format.id, + dither_type="error_diffusion") +{{/CHROMA_DERAINBOW}} + +{{#CHROMA_BIFROST}} +# Bifrost - temporal rainbow / dot-crawl removal. Where LUTDeRainbow decides +# within a frame, this compares across frames (or fields), so it catches +# rainbowing that shimmers rather than sits still. Measured on an alternating- +# chroma clip: frame-to-frame chroma swing halved. +# +# 8-BIT ONLY: "Bifrost: Only constant format 8 bit integer YUV input supported", +# verified against the bundle at 10/12/16-bit and 4:2:2. Same treatment as +# DeScratch — run the pass at 8-bit and restore the source format after. In +# practice the sources this targets (VHS, Video8, DV captures) are 8-bit anyway. +_bifrost_src_format = clip.format +if clip.format.bits_per_sample != 8: + clip = core.resize.Bilinear(clip, format=clip.format.replace( + bits_per_sample=8, sample_type=vs.INTEGER).id) +clip = core.bifrost.Bifrost( + clip, + luma_thresh={{BIFROST_LUMA_THRESH}}, + variation={{BIFROST_VARIATION}}, + interlaced={{BIFROST_INTERLACED}}, +) +if _bifrost_src_format.bits_per_sample != 8: + clip = core.resize.Bilinear(clip, format=_bifrost_src_format.id, + dither_type="error_diffusion") +{{/CHROMA_BIFROST}} + {{#CHROMA_VINVERSE}} # Vinverse - remove residual combing clip = haf.Vinverse( @@ -922,6 +1410,69 @@ clip = haf.Vinverse( # PASS 9: COLOR CORRECTION # ============================================================================ {{#COLOR_CORRECTION}} +{{#CC_AUTO_LEVELS}} +# Auto levels — stretch luma so the darkest and brightest parts of the picture +# land on the target black and white points. The common fix for a washed-out +# capture, and the one thing a non-expert can ask for by name. +# +# Stats are measured on a heavily downscaled copy, not the full frame. Min and +# max of a full frame are set by single outlier pixels — one hot specular or one +# dead pixel re-grades the whole shot, and because those outliers come and go the +# result flickers frame to frame. Averaging them away first is what makes this +# usable on real footage. +# +# PlaneStatsMin/Max come back in the CLIP's own depth, so they are used raw; +# only the user's targets, which are in 8-bit UI units, get scaled to the format. +def _auto_levels(c, black_8bit, white_8bit, strength): + peak = (1 << c.format.bits_per_sample) - 1 + to_fmt = lambda v: int(round(v * peak / 255.0)) + lo_t, hi_t = to_fmt(black_8bit), to_fmt(white_8bit) + small = core.resize.Bilinear(c, 64, 48) if c.width > 128 else c + stats = core.std.PlaneStats(small, plane=0) + + def _apply(n, f, clip_in): + lo = int(f.props['PlaneStatsMin']) + hi = int(f.props['PlaneStatsMax']) + if hi <= lo: + return clip_in # flat frame: nothing to stretch, and avoids /0 + out = core.std.Levels( + clip_in, min_in=lo, max_in=hi, min_out=lo_t, max_out=hi_t, planes=[0] + ) + if strength >= 1.0: + return out + return core.std.Merge(clip_in, out, weight=strength) + + return core.std.FrameEval(c, functools.partial(_apply, clip_in=c), prop_src=stats) + +clip = _auto_levels(clip, {{CC_AUTO_LEVELS_BLACK}}, {{CC_AUTO_LEVELS_WHITE}}, {{CC_AUTO_LEVELS_STRENGTH}}) +{{/CC_AUTO_LEVELS}} +{{#CC_AUTO_WHITE}} +# Auto white balance, grey-world: assume the scene averages to neutral, and +# shift each chroma plane's mean onto the neutral point. In YUV that is the +# whole of the grey-world assumption — there is no second thing to compute, and +# unlike Retinex it needs no 4:4:4 round trip because it never leaves the native +# plane geometry. +# +# Chroma means are averages already, so they are far steadier frame to frame +# than the luma min/max above and need no outlier handling. +def _auto_white(c, strength): + if c.format.num_planes < 3: + return c # GRAY: PlaneStats(plane=1) would error + bits = c.format.bits_per_sample + peak = (1 << bits) - 1 + neutral = 1 << (bits - 1) + su = core.std.PlaneStats(c, plane=1) + sv = core.std.PlaneStats(c, plane=2) + + def _apply(n, f, clip_in): + du = (neutral - f[0].props['PlaneStatsAverage'] * peak) * strength + dv = (neutral - f[1].props['PlaneStatsAverage'] * peak) * strength + return _expr(clip_in, ['', 'x {:.6f} +'.format(du), 'x {:.6f} +'.format(dv)]) + + return core.std.FrameEval(c, functools.partial(_apply, clip_in=c), prop_src=[su, sv]) + +clip = _auto_white(clip, {{CC_AUTO_WHITE_STRENGTH}}) +{{/CC_AUTO_WHITE}} import adjust {{#COLOR_TWEAK}} @@ -993,6 +1544,63 @@ clip = core.std.Levels( ) {{/COLOR_LEVELS}} +{{#COLOR_SMOOTH_LEVELS}} +# SmoothLevels - the same curve as std.Levels, but dithered and limited as it +# goes, so stretching a narrow range does not band. Measured on a shallow +# gradient stretched to full range: distinct output levels 47 -> 135. +# +# Three things are load-bearing here, all measured against the bundle: +# +# 1. SmoothLevels reads its levels in the CLIP'S OWN range, not 8-bit — the +# same trap as std.Levels. Six arguments need scaling (input_low/high, +# output_low/high, Ecenter, protect) and the rest must NOT be touched: +# gamma and chroma are ratios. Unscaled, a gamma-only edit at 16-bit is off +# by a worst pixel of 249/255. +# 2. useDB is pinned False. havsfunc calls `core.f3kdb.Deband` and this bundle +# ships `neo_f3kdb` under a different namespace, so the default useDB=True +# raises "no attribute named f3kdb" on EVERY format. Fixing that needs a +# havsfunc patch, i.e. a deps release; useDB=False already gets most of the +# benefit. +# 3. The black point is dropped when gamma is not 1.0 — havsfunc raises a +# negative base to a fractional power below input_low, which yields a +# complex number and fails the LUT. The worker does that, and tells the user. +clip = haf.SmoothLevels( + clip, + input_low=_levels_8bit({{SMOOTH_INPUT_LOW}}), + input_high=_levels_8bit({{SMOOTH_INPUT_HIGH}}), + output_low=_levels_8bit({{SMOOTH_OUTPUT_LOW}}), + output_high=_levels_8bit({{SMOOTH_OUTPUT_HIGH}}), + gamma={{SMOOTH_GAMMA}}, + Smode={{SMOOTH_MODE}}, + useDB=False, +) +{{/COLOR_SMOOTH_LEVELS}} + +{{#COLOR_SHADOW_DETAIL}} +# Retinex (MSRCP) - lifts detail out of shadows by comparing each pixel to a +# wide local average, so underexposed footage opens up without simply raising +# the black level. +# +# LUMA ONLY, deliberately. retinex.MSRCP rejects subsampled formats outright +# ("sub-sampled format is not supported") and every source this app handles is +# 4:2:0 or 4:2:2. Rather than round-trip the whole clip through 4:4:4 and +# resample chroma twice for a brightness operation, the luma plane is lifted out +# as greyscale, processed, and put back — colour comes through bit-identical. +# Verified working this way at 8/10/12/16-bit and 4:2:2. +_retinex_y = core.std.ShufflePlanes(clip, planes=0, colorfamily=vs.GRAY) +_retinex_y = core.retinex.MSRCP( + _retinex_y, + sigma=[{{SHADOW_SIGMA}}], + lower_thr={{SHADOW_LOWER}}, + upper_thr={{SHADOW_UPPER}}, +) +if clip.format.color_family == vs.GRAY: + clip = _retinex_y +else: + clip = core.std.ShufflePlanes([_retinex_y, clip, clip], planes=[0, 1, 2], + colorfamily=clip.format.color_family) +{{/COLOR_SHADOW_DETAIL}} + {{#COLOR_WHITE_BALANCE}} # White balance: shift the chroma planes. U carries blue-yellow and V carries # red-cyan, so warming the image is -U/+V and a magenta tint raises both. @@ -1016,6 +1624,59 @@ if clip.format.color_family == vs.YUV: {{/COLOR_WHITE_BALANCE}} {{/COLOR_CORRECTION}} +# ============================================================================ +# PASS: STABILIZE +# ============================================================================ +{{#STABILIZE}} +# Stab - global motion stabilisation via MVTools' Depan family. Measures the +# frame-to-frame shift and subtracts it, so shake is cancelled while a +# deliberate pan survives. Frame count is unchanged, which is why this is an +# ordinary pass. +# +# The signature is Stab(clp, dxmax, dymax, mirror) — there is no `range` +# argument, whatever other Stab implementations take. `mirror` fills the edges +# the shift exposes rather than leaving them black. +clip = haf.Stab( + clip, + dxmax={{STAB_DXMAX}}, + dymax={{STAB_DYMAX}}, + mirror={{STAB_MIRROR}}, +) +{{/STABILIZE}} + +# ============================================================================ +# PASS: ROTATE / FLIP +# ============================================================================ +{{#GEOMETRY}} +# Quarter-turn rotation and mirroring, in that order. core.std only, so the ops +# themselves move samples without changing their values. +# +# A quarter turn swaps width and height, which is why this runs before any +# framing decision; the worker also inverts the declared sample aspect for it, +# since SAR is pixel width : height and the turn exchanges those. +# +# A turn ALSO swaps the chroma subsampling axes, and that is not cosmetic: +# 4:2:2 becomes 4:4:0, which vspipe emits as "C440" and ffmpeg rejects outright +# ("YUV4MPEG stream contains an unknown pixel format"), and 4:1:1 has no y4m +# identifier at all so vspipe itself fails. Both are hard job failures, and +# 4:2:2 is the common 10-bit ProRes case. So the source format is captured here +# and restored after the turn, exactly as the LUTDeCrawl and DeScratch guards +# do. It is a no-op for 4:2:0, 4:4:4 and greyscale, whose subsampling is +# symmetric. +_geom_src_format = clip.format +{{#GEOM_ROTATE}} +clip = core.std.{{GEOM_ROTATE_FN}}(clip) +{{/GEOM_ROTATE}} +{{#GEOM_FLIP_H}} +clip = core.std.FlipHorizontal(clip) +{{/GEOM_FLIP_H}} +{{#GEOM_FLIP_V}} +clip = core.std.FlipVertical(clip) +{{/GEOM_FLIP_V}} +if clip.format.id != _geom_src_format.id: + clip = core.resize.Spline36(clip, format=_geom_src_format.id) +{{/GEOMETRY}} + # ============================================================================ # PASS 10: RESIZE / UPSCALE # ============================================================================ @@ -1219,6 +1880,122 @@ if _box_w > clip.width or _box_h > clip.height: {{/RESIZE_STANDARD}} {{/RESIZE}} +# ============================================================================ +# PASS: FILM GRAIN +# ============================================================================ +{{#GRAIN}} +{{#GRAIN_ADD}} +# AddGrain - one strength control, and the only one of the two that can grain +# chroma or hold a static pattern. +# +# `var` is a VARIANCE: the noise standard deviation is its square root, so +# var=4 gives sigma=2. It is already expressed in 8-bit units and the plugin +# rescales internally, so it must NOT get the _levels_8bit() treatment used for +# std.Levels and Tweak — doing that would quadruple the grain at 10-bit. +# Verified against the bundle: identical 8-bit-equivalent output at 8/10/12/16 +# bit and 4:2:2. +clip = core.grain.Add( + clip, + var={{GRAIN_VAR}}, + uvar={{GRAIN_UVAR}}, + hcorr={{GRAIN_CORR}}, + vcorr={{GRAIN_CORR}}, + constant={{GRAIN_CONSTANT}}, +) +{{/GRAIN_ADD}} + +{{#GRAIN_FACTORY3}} +# GrainFactory3 - three grain layers chosen by luma level, so shadows get more +# grain than highlights. Closer to real film stock than a flat noise field. +# +# Two measured limitations worth knowing: it is LUMA-ONLY (chroma comes back +# bit-identical), and it is ALWAYS animated — havsfunc leaves grain.Add's +# `constant` at its default and exposes no way to reach it. `temp_avg` damps the +# animation but cannot stop it. +clip = haf.GrainFactory3( + clip, + g1str={{GRAIN_G1}}, + g2str={{GRAIN_G2}}, + g3str={{GRAIN_G3}}, +{{#GRAIN_TEMP_AVG}} + temp_avg={{GRAIN_TEMP_AVG}}, +{{/GRAIN_TEMP_AVG}} +) +{{/GRAIN_FACTORY3}} +{{/GRAIN}} + +# ============================================================================ +# PASS 11: FRAME RATE CONVERSION +# ============================================================================ +{{#FRAME_RATE}} +# Standards conversion, not smoothing. Runs genuinely last of the video passes: +# it resamples the timeline, so anything after it would be working on invented +# frames rather than photographed ones. +# +# FlowFPS rather than BlockFPS deliberately. Measured over 35 combinations, +# FlowFPS emits floor(n_in * ratio), which is exactly what FrameMap::Retime +# computes; BlockFPS emits floor((n_in-1) * ratio) + 1 and is off by one in 14 +# of them, which would desynchronise the progress total and the preview index. +# +# mvtools rejects 4:1:1 at Super ("input clip must be GRAY, 420, 422, 440, or +# 444") and 4:1:1 is NTSC DV — precisely the tape an NTSC->PAL conversion +# targets — so convert up and restore, as the chroma denoise pass does. +_fps_src_format = clip.format.id +if clip.format.subsampling_w == 2: + clip = core.resize.Bicubic( + clip, + format=vs.YUV422P8 if clip.format.bits_per_sample == 8 else vs.YUV422P16, + ) +{{#FRAME_RATE_FLOWFPS}} +_fps_super = core.mv.Super(clip, pel=2) +_fps_bw = core.mv.Analyse(_fps_super, isb=True, blksize={{FPS_BLOCK_SIZE}}, overlap={{FPS_OVERLAP}}) +_fps_fw = core.mv.Analyse(_fps_super, isb=False, blksize={{FPS_BLOCK_SIZE}}, overlap={{FPS_OVERLAP}}) +clip = core.mv.FlowFPS( + clip, _fps_super, _fps_bw, _fps_fw, + num={{FPS_TARGET_NUM}}, den={{FPS_TARGET_DEN}}, +) +{{/FRAME_RATE_FLOWFPS}} +{{#FRAME_RATE_DUPLICATE}} +# No invented pixels — whole frames are repeated or dropped. Visible judder, but +# every frame in the output was photographed. The honest choice for a master. +clip = haf.ChangeFPS(clip, {{FPS_TARGET_NUM}}, {{FPS_TARGET_DEN}}) +{{/FRAME_RATE_DUPLICATE}} +if clip.format.id != _fps_src_format: + clip = core.resize.Bicubic(clip, format=_fps_src_format) +{{/FRAME_RATE}} + +# ============================================================================ +# CUSTOM VAPOURSYNTH +# ============================================================================ +{{#CUSTOM_VS}} +# User-supplied code, on the same footing as Custom FFmpeg Arguments. Placed +# after every built-in pass, so `clip` is the only contract it has to honour. +# +# The hazard here is NOT arbitrary code — this process already loads arbitrary +# plugins. It is the frame count. A snippet calling Trim, SelectEvery or +# Interleave changes the true output length while the declared total stays put, +# which makes the progress bar lie and, worse, makes frame-accurate preview show +# a different frame than its label. Both fail silently. So the length is +# asserted rather than trusted. +_custom_vs_frames_before = len(clip) +# --- user code begins --- +{{CUSTOM_VS_CODE}} +# --- user code ends --- +if not isinstance(clip, vs.VideoNode): + raise ValueError( + "Custom VapourSynth: `clip` must still be a VideoNode when your code " + "finishes. Assign your result back to `clip`." + ) +if len(clip) != _custom_vs_frames_before: + raise ValueError( + "Custom VapourSynth: your code changed the frame count from " + f"{_custom_vs_frames_before} to {len(clip)}. That would desynchronise " + "the progress total and make the preview show a different frame than " + "its label, so it is refused rather than allowed to fail silently. " + "Use the Trim controls instead." + ) +{{/CUSTOM_VS}} + # ============================================================================ # OUTPUT FORMAT CONVERSION # diff --git a/worker/templates/removedirt.py b/worker/templates/removedirt.py new file mode 100644 index 00000000..e9a6b244 --- /dev/null +++ b/worker/templates/removedirt.py @@ -0,0 +1,131 @@ +""" +RemoveDirt — fast dirt and dropout removal. + ++++ Why this exists alongside SpotLess +++ + +Not because it is better. Measured against the shipped ``spotless.py`` on 80 +frames of real 640x360 luma with 15 synthetic single-frame spots per frame: + + filter spot MAE clean MAE fps + SpotLess 9.21 0.222 143 + RemoveDirt (this) 16.9 0.278 908 + RemoveDirtMC 9.99 0.222 202 + +So the MC variant is *not additive* — it lands on the same point as SpotLess and +runs slower — and is deliberately not offered. The plain form is the whole case: +**6.3x the throughput for about 60% of the removal**, which is what makes it +worth having on a long capture where SpotLess would take hours. + ++++ Provenance +++ + +The plugin is ``pinterf/RemoveDirt`` v1.1 (GPL-2.0, "By Rainer Wittmann + / Additional work by Ferenc Pinter"). This wrapper reproduces the +canonical RemoveDirt chain — the Clense family, SCSelect, Repair and +RestoreMotionBlocks — and is written for VapourBox rather than copied. + ++++ Two deviations, both measured +++ + +1. **No trailing ``RemoveGrain(mode=17)``.** The canonical script ends with one, + and it is the single largest source of collateral damage in the chain: on its + own it takes clean-pixel MAE from 0.19 to 0.70 and touches 29% of pixels. + Dropping it is worth **3.2x less damage** (0.199 vs 0.630) at identical spot + removal and 36% more speed. It is exposed as an off-by-default option rather + than baked in. + +2. **The Clense family comes from ``zsmooth``, not ``rgvs``.** ``rgvs.Clense`` + raises "only 8 and 16 bit integer input supported" at 9-14 bit and float; + zsmooth's equivalents are fine at every depth this app can produce. Same + filter, wider input range. + ++++ Format limits +++ + +``RestoreMotionBlocks`` accepts 8-16 bit YUV 4:2:0/4:2:2/4:4:4 and GRAY. It +rejects 9-bit, 4:1:1, float and RGB with "Video must be grey, YUV 4:2:0, 4:2:2, +or 4:4:4 with bit depths 8-16!". Both 9-bit and 4:1:1 are reachable here — +``pixel_format.rs`` rounds odd depths up through 9, and 4:1:1 is NTSC DV — so +both are guarded below. + +Its ``noise``/``noisy`` thresholds are normalised internally: the same values at +8/10/12/16-bit produced bit-identical output (mean |diff| 0.0000), so unlike +most of this codebase they must NOT be depth-scaled. +""" + +import vapoursynth as vs + +core = vs.core + + +def _restore_format(clip, target_id): + return clip if clip.format.id == target_id else core.resize.Bicubic( + clip, format=target_id + ) + + +def remove_dirt(clip, limit=16, gmthreshold=70, noise=50, noisy=12, dist=1, + dmode=2, tolerance=12, pthreshold=4, cthreshold=4, + post_denoise=False): + """Remove dirt, spots and dropouts. + + ``limit`` is a Repair *mode enumeration* (1-24), not a strength — the + upstream docstring calls it "higher values = more aggressive" and that is + simply wrong. It is not exposed as a slider anywhere in the UI. + + ``post_denoise`` re-enables the canonical trailing RemoveGrain(17). Off by + default; see the module docstring for why. + """ + if clip.format is None or clip.format.color_family not in (vs.YUV, vs.GRAY): + raise vs.Error('RemoveDirt: only YUV and GRAY clips are supported') + + src_id = clip.format.id + bits = clip.format.bits_per_sample + + # 9-bit and 4:1:1 are both reachable and both rejected by the plugin. + needs_depth_fix = bits == 9 + needs_subsampling_fix = ( + clip.format.color_family == vs.YUV and clip.format.subsampling_w == 2 + ) + if needs_depth_fix or needs_subsampling_fix: + target_bits = 10 if needs_depth_fix else bits + if clip.format.color_family == vs.GRAY: + work_format = core.get_video_format(vs.GRAY8).replace( + bits_per_sample=target_bits, + sample_type=vs.INTEGER, + ) + else: + work_format = core.get_video_format(vs.YUV422P8).replace( + bits_per_sample=target_bits, + sample_type=vs.INTEGER, + ) + clip = core.resize.Bicubic(clip, format=work_format.id) + + # The Clense family, from zsmooth so 9-16 bit all work. + clensed = core.zsmooth.Clense(clip) + forward = core.zsmooth.ForwardClense(clip) + backward = core.zsmooth.BackwardClense(clip) + + # SCSelect picks between the three depending on whether this frame sits at + # a scene boundary — a temporal median across a cut is what produces the + # smearing these filters are notorious for. + selected = core.removedirt.SCSelect(clip, forward, backward, clensed) + + # Bound how far any pixel may move from the source before the motion gate + # even looks at it. + repaired = core.zsmooth.Repair(selected, clip, mode=[limit]) + + # The gate: restore only blocks that look like damage rather than motion. + out = core.removedirt.RestoreMotionBlocks( + repaired, clip, + gmthreshold=gmthreshold, + noise=noise, + noisy=noisy, + dist=dist, + tolerance=tolerance, + dmode=dmode, + pthreshold=pthreshold, + cthreshold=cthreshold, + ) + + if post_denoise: + out = core.zsmooth.RemoveGrain(out, mode=[17]) + + return _restore_format(out, src_id) diff --git a/worker/templates/temporaldegrain2.py b/worker/templates/temporaldegrain2.py new file mode 100644 index 00000000..4fd9981e --- /dev/null +++ b/worker/templates/temporaldegrain2.py @@ -0,0 +1,423 @@ +""" +TemporalDegrain2 - motion-compensated temporal degrainer. + ++++ Provenance +++ + +Vendored from Selur's VapoursynthScriptsInHybrid +(https://github.com/Selur/VapoursynthScriptsInHybrid), file ``degrain.py``, +approximately lines 402-736 of the 2026-08-17 master snapshot. That repository +carries **no LICENSE file and no per-file licence headers**; nothing here +invents one. The attribution the upstream docstring itself carries is: + + Temporal Degrain Updated by ErazorTT + Based on function by Sagekilla, idea + original script created by Didee + +The shared MVTools substrate and the helpers it needs (``m4``, +``DitherLumaRebuild``, ``MinBlur``, ``ContraSharpening``, ``Padding``, +``DFTTest``) live in ``hybrid_mv.py``, which documents its own derivation. +``color.LimitFilter`` is imported at module scope upstream and never called, so +it is not vendored. + ++++ Deviations from upstream, all of them load-bearing +++ + +Each was measured against the plugins VapourBox actually bundles. + +1. **postFFT is clamped to 0-3.** ``postFFT=5`` does not raise, it *aborts the + process*: ``postBlkSize = [0,48,32,12,0,0][postFFT]`` yields 0, FFT3DFilter + is handed a 0x0 block, and the run ends in "dimensions are negative (0x1)" + followed by ``libc++abi: terminating``. ``postFFT=4`` needs KNLMeansCL and a + working OpenCL device, which is not something this bundle can rely on. Both + are folded down rather than allowed to reach the plugin. + +2. **grainLevel is clamped to -2..3.** Every autotune table is length 6 and the + value is shifted by +2 before indexing, so anything outside that window is a + bare ``IndexError`` from inside the function. + +3. **extraSharp works at 16-bit.** ``extraSharp=True`` sets ``MinBlur``'s + radius to 3, whose 16-bit branch upstream calls ``depth(...)`` and + ``Dither.NONE`` without importing either - a ``NameError`` at exactly + 16 bits, and only when ``core.ctmf`` exists, which here it always does. + Fixed in ``hybrid_mv._min_blur_median``. + +4. **The depth-dependent limits are scaled from ``clip.format``.** This is the + one that decides whether the filter is usable at all above 8 bits: + + * ``limitSigma`` (and the derived sigma2/3/4) feeds FFT3DFilter, whose sigma + is in the clip's own sample range. Measured on identical 8- and 16-bit + content, an unscaled sigma diverges by **2.85/255**; scaled by + ``1 << (bits - 8)`` it is **0.45**. + * mvtools' ``limit`` is likewise in the clip's own range, and the wrapper's + "off" value of 255 means "clamp every pixel to 255/65535" at 16-bit. + Measured against the same degrain at 8-bit: ``limit=255`` diverges by + **0.77/255** and discards roughly half the degraining; at the format peak + it is **0.22**. That is what made ``outputStage=0`` a complete no-op at + >=12-bit. + + ``postSigma`` is scaled the same way for ``postFFT`` 1 and 2 (FFT3DFilter); + it is deliberately **not** scaled for ``postFFT=3``, because dfttest's sigma + was measured to be bit-depth independent (8-vs-16-bit divergence 0.0017). + +5. **Float input is rejected up front.** It fails at ``Super`` because + ``core.mvsf`` is not bundled; the native error names a clip several layers + down and reads like a corrupt graph. + +6. Every ``std.Expr`` goes through ``hybrid_mv._expr`` (akarin's LLVM JIT where + available). Unreachable backends - ``neo_fft3d``, ``cranexpr``, ``rgsf``, + ``mvsf``, ``nlm_cuda``, ``knlm`` via KNLMeansCL - are removed rather than + left behind ``hasattr`` guards that can never fire. bm3d is never reached by + this function (the only upstream mention is a TODO comment), so it needs no + deps addition. + +7. **``rec`` is deliberately not exposed.** The template never passes it, so it + always runs at False. With ``rec=True`` the vectors go through + ``MV.Recalculate`` at half the block size, and that refinement is sensitive + to quantisation: the 8-bit and 16-bit results diverge by **2.03/255** mean + absolute difference, against a ~0.6 rounding floor, where at the default + they track each other. Nothing is mis-scaled — a finer block search simply + makes different choices on a finer input — but it does mean the output + depends on the source's bit depth, which is worth a decision rather than a + slider. +""" + +from typing import Optional + +import vapoursynth as vs + +from hybrid_mv import ( + MV, + ContraSharpening, + DFTTest, + DitherLumaRebuild, + _expr, + m4, + require_integer, +) + +core = vs.core + +__all__ = ['TemporalDegrain2'] + + +def TemporalDegrain2( + clip: vs.VideoNode, + degrainTR: int = 1, + degrainPlane: int = 4, + grainLevel: int = 2, + grainLevelSetup: bool = False, + meAlg: int = 4, + meAlgPar: Optional[int] = None, + meSubpel: Optional[int] = None, + meBlksz: Optional[int] = None, + meTM: bool = False, + limitSigma: Optional[float] = None, + limitBlksz: Optional[int] = None, + fftThreads: Optional[int] = None, + postFFT: int = 0, + postTR: int = 1, + postSigma: float = 1, + postMix: int = 0, + postBlkSize: Optional[int] = None, + ppSAD1: Optional[float] = None, + ppSAD2: Optional[float] = None, + ppSCD1: Optional[float] = None, + thSCD2: int = 128, + DCT: int = 0, + SubPelInterp: int = 2, + SrchClipPP: Optional[int] = None, + GlobalMotion: bool = True, + ChromaMotion: bool = True, + rec: bool = False, + extraSharp: bool = False, + outputStage: int = 2, +) -> vs.VideoNode: + """Remove most or all grain and noise, including dancing grain. + + The knobs worth exposing, in the order they matter: + + degrainTR (1) Temporal radius of the degrain. Useful range 1 .. fps/8. + Higher cleans more but mis-identified motion vectors + start washing regions out. + grainLevel (2) -2 .. 3. How noisy the source is. Drop to 0/1 for clean + material, raise to 3 for very heavy grain. Drives every + autotuned SAD/scene-change threshold. + postFFT (0) Extra spatial pass over the degrained clip. + 0 = RemoveGrain(1), 1/2 = FFT3DFilter, 3 = DFTTest + (slower, best on banding). 4 and 5 are folded to 3 - + see the module docstring. + postSigma (1) Strength of that pass. Raising it too far bands. + postTR (1) Temporal radius of the post pass. + postMix (0) 0-100. Blends the original back in when the result is + too clean. + degrainPlane (4) 0=Y 1=U 2=V 3=UV 4=YUV. + ChromaMotion Use chroma in the motion search. + rec (False) Refine vectors with Recalculate: better motion, slower. + outputStage (2) 0 = first (limited) degrain, 1 = second, 2 = + post + pass and contra-sharpening. Lower means less filtering. + extraSharp Wider radius in the contra-sharpening step. + """ + if not isinstance(clip, vs.VideoNode) or clip.format.color_family not in (vs.GRAY, vs.YUV): + raise vs.Error('TemporalDegrain2: this is not a GRAY or YUV clip!') + + # Guard 5: float needs core.mvsf, which is not bundled. + require_integer(clip, 'TemporalDegrain2') + + # --- Guard 1: postFFT 4 (KNLMeansCL/OpenCL) and 5 (0-sized FFT blocks, which + # abort the process rather than raising) are folded onto the DFTTest path. + postFFT = int(postFFT) + if postFFT > 3: + postFFT = 3 + elif postFFT < 0: + postFFT = 0 + + # --- Guard 2: the autotune tables are length 6 and indexed by grainLevel + 2. + grainLevel = max(-2, min(3, int(grainLevel))) + + outputStage = max(0, min(2, int(outputStage))) + degrainTR = max(0, int(degrainTR)) + postTR = max(0, int(postTR)) + postMix = max(0, min(100, int(postMix))) + + w = clip.width + h = clip.height + bd = clip.format.bits_per_sample + isGRAY = clip.format.color_family == vs.GRAY + + # 8-bit level -> this clip's range. Upstream calls this `i`. + bitDepthMultiplier = 1 << (bd - 8) + mid = 1 << (bd - 1) + peak = (1 << bd) - 1 + + S = MV.Super + C = MV.Compensate + + RG = core.zsmooth.RemoveGrain if hasattr(core, 'zsmooth') else core.rgvs.RemoveGrain + + if meAlgPar is None: + # Dogway's SMDegrain values; the AVS table was based on a misreading of + # the MVTools search algorithm. + meAlgPar = 5 if rec and meTM else 2 + + longlat = max(w, h) + shortlat = min(w, h) + # Scale grainLevel from -2..3 -> 0..5 for table lookup. + grainLevel = grainLevel + 2 + + if longlat <= 1050 and shortlat <= 576: + autoTune = 0 + elif longlat <= 1280 and shortlat <= 720: + autoTune = 1 + elif longlat <= 2048 and shortlat <= 1152: + autoTune = 2 + else: + autoTune = 3 + + if meSubpel is None: + meSubpel = [4, 2, 2, 1][autoTune] + if meBlksz is None: + meBlksz = [8, 8, 16, 32][autoTune] + + limitAT = [-1, -1, 0, 0, 0, 1][grainLevel] + autoTune + 1 + + if limitSigma is None: + limitSigma = [6, 8, 12, 16, 32, 48][limitAT] + if limitBlksz is None: + limitBlksz = [12, 16, 24, 32, 64, 96][limitAT] + if SrchClipPP is None: + SrchClipPP = [0, 0, 0, 3, 3, 3][grainLevel] + + if isGRAY: + ChromaMotion = False + degrainPlane = 0 + + if degrainPlane == 0: + fPlane = [0] + elif degrainPlane == 1: + fPlane = [1] + elif degrainPlane == 2: + fPlane = [2] + elif degrainPlane == 3: + fPlane = [1, 2] + else: + fPlane = [0, 1, 2] + + if postFFT <= 0: + postTR = 0 + if postFFT == 3: + postTR = min(postTR, 7) + if postFFT in (1, 2): + postTR = min(postTR, 2) + + if postBlkSize is None: + postBlkSize = [0, 48, 32, 12][postFFT] + + if grainLevelSetup: + outputStage = 0 + degrainTR = 3 + + rad = 3 if extraSharp else None + mat = [1, 2, 1, 2, 4, 2, 1, 2, 1] + hpad = meBlksz + vpad = meBlksz + postTD = postTR * 2 + 1 + maxTR = max(degrainTR, postTR) + Overlap = int(meBlksz // 2) + Lambda = (1000 if meTM else 100) * (meBlksz ** 2) // 64 + PNew = 50 if meTM else 25 + + ppSAD1 = ppSAD1 if ppSAD1 is not None else [3, 5, 7, 9, 11, 13][grainLevel] + ppSAD2 = ppSAD2 if ppSAD2 is not None else [2, 4, 5, 6, 7, 8][grainLevel] + ppSCD1 = ppSCD1 if ppSCD1 is not None else [3, 3, 3, 4, 5, 6][grainLevel] + + if DCT == 5: + # Rescale the thresholds to match SAD values when using SATD. ppSCD1 is + # deliberately left alone: scene change detection is always SAD-based. + ppSAD1 *= 1.7 + ppSAD2 *= 1.7 + + # Per-pixel measures -> the per-8x8-block (64 px) measure MVTools uses. + thSAD1 = int(ppSAD1 * 64) + thSAD2 = int(ppSAD2 * 64) + thSCD1 = int(ppSCD1 * 64) + CMplanes = [0, 1, 2] if ChromaMotion else [0] + + if maxTR > 3: + # Upstream allows up to 6 for float clips via mvsf, which is not bundled. + raise vs.Error('TemporalDegrain2: degrainTR/postTR above 3 needs the mvsf plugin, ' + 'which is not bundled.') + + # --- Guard 4a: FFT3DFilter's sigma is in the clip's own range. + sigmaScale = float(bitDepthMultiplier) + # --- Guard 4b: so is mvtools' `limit`; the wrapper's "off" value of 255 is + # a hard clamp at anything above 8-bit, so express "off" as the format peak. + degrainLimit = peak + + # ---------------------------------------------------------------- search clip + if SrchClipPP == 1: + spatialBlur = core.resize.Bilinear(clip, m4(w / 2), m4(h / 2)) \ + .std.Convolution(matrix=mat, planes=CMplanes) \ + .resize.Bilinear(w, h) + elif SrchClipPP > 1: + if hasattr(core, 'tcanny'): + spatialBlur = core.tcanny.TCanny(clip, sigma=2, mode=-1, planes=CMplanes) + else: + spatialBlur = core.std.BoxBlur(clip, planes=CMplanes, hradius=2, hpasses=3, + vradius=2, vpasses=3) + spatialBlur = core.std.Merge(spatialBlur, clip, + [0.1] if (ChromaMotion or isGRAY) else [0.1, 0]) + else: + spatialBlur = clip + + if SrchClipPP < 3: + srchClip = spatialBlur + else: + expr = 'x {a} + y < x {b} + x {a} - y > x {b} - x y + 2 / ? ?'.format( + a=7 * bitDepthMultiplier, b=2 * bitDepthMultiplier) + srchClip = _expr([spatialBlur, clip], + [expr] if (ChromaMotion or isGRAY) else [expr, '']) + + super_args = dict(pel=meSubpel, hpad=hpad, vpad=vpad, sharp=SubPelInterp, + chroma=ChromaMotion, blksize=meBlksz, overlap=Overlap) + analyse_args = dict(blksize=meBlksz, overlap=Overlap, search=meAlg, searchparam=meAlgPar, + pelsearch=meSubpel, truemotion=meTM, lambda_=Lambda, pnew=PNew, + global_=GlobalMotion, dct=DCT, chroma=ChromaMotion) + recalculate_args = dict(thsad=thSAD1 // 2, blksize=max(meBlksz // 2, 4), + overlap=max(Overlap // 2, 2), search=meAlg, searchparam=meAlgPar, + truemotion=meTM, lambda_=Lambda // 4, pnew=PNew, dct=DCT, + chroma=ChromaMotion) + + lumaRebuild = DitherLumaRebuild(srchClip, s0=1, chroma=ChromaMotion) + + srchSuper = S(lumaRebuild, rfilter=4, **super_args) + recSuper = S(lumaRebuild, levels=1, **super_args) + + # One shared vector list [bv1, fv1, bv2, fv2, ...] covers both the degrain + # stages (first degrainTR pairs) and the post-filter compensation window. + degrainVecs = [] + postVecs = [] + if maxTR > 0: + vecs = MV.AnalyseMany(srchSuper, radius=maxTR, **analyse_args) + if rec: + vecs = MV.Recalculate(recSuper, vecs, **recalculate_args) + degrainVecs = vecs[:2 * degrainTR] + postVecs = vecs[:2 * postTR] + + # ------------------------------------------------------------------- degrain + # "spat" is a prefiltered clip used to limit the effect of the 1st MV stage. + if degrainTR > 0: + s1 = limitSigma * sigmaScale + s2 = s1 * 0.625 + s3 = s1 * 0.375 + s4 = s1 * 0.250 + ovNum = [4, 4, 4, 3, 2, 2][grainLevel] + ov = 2 * round(limitBlksz / ovNum * 0.5) + + spat = core.fft3dfilter.FFT3DFilter( + clip, planes=fPlane, sigma=s1, sigma2=s2, sigma3=s3, sigma4=s4, bt=3, + bw=limitBlksz, bh=limitBlksz, ow=ov, oh=ov, ncpu=fftThreads) + spatD = core.std.MakeDiff(clip, spat) + + # Every other Super only needs the finest level. + super_args = dict(super_args) + super_args['levels'] = 1 + + # First MV-denoising stage. + if degrainTR > 0: + supero = S(clip, **super_args) + NR1 = MV.Degrain(clip, supero, *degrainVecs, plane=degrainPlane, thsad=thSAD1, + limit=degrainLimit, thscd1=thSCD1, thscd2=thSCD2) + + # Limit NR1 to not do more than what "spat" would do. + NR1D = core.std.MakeDiff(clip, NR1) + expr = 'x {m} - abs y {m} - abs < x y ?'.format(m=mid) + DD = _expr([spatD, NR1D], [expr]) + NR1x = core.std.MakeDiff(clip, DD, [0]) + else: + NR1x = clip + + # Second MV-denoising stage. + if degrainTR > 0: + NR1x_super = S(NR1x, **super_args) + NR2 = MV.Degrain(NR1x, NR1x_super, *degrainVecs, plane=degrainPlane, thsad=thSAD2, + limit=degrainLimit, thscd1=thSCD1, thscd2=thSCD2) + else: + NR2 = clip + + if outputStage == 0: + return NR1x + if outputStage == 1: + return NR2 + + # ------------------------------------------------------------------ post FFT + if postTR > 0: + fullSuper = S(NR2, **super_args) + # postVecs is [bv1, fv1, ...]; interleave farthest-forward .. NR2 .. + # nearest .. farthest-backward. + fwdComp = [C(NR2, fullSuper, postVecs[2 * (d - 1) + 1], thsad=thSAD2, + thscd1=thSCD1, thscd2=thSCD2) for d in range(postTR, 0, -1)] + bwdComp = [C(NR2, fullSuper, postVecs[2 * (d - 1)], thsad=thSAD2, + thscd1=thSCD1, thscd2=thSCD2) for d in range(1, postTR + 1)] + noiseWindow = core.std.Interleave(fwdComp + [NR2] + bwdComp) + else: + noiseWindow = NR2 + + if postFFT == 3: + # dfttest's sigma is bit-depth independent (measured), so it is NOT scaled. + dnWindow = DFTTest(noiseWindow, sigma=postSigma * 4, tbsize=postTD, planes=fPlane, + sbsize=postBlkSize, sosize=int(postBlkSize * 9 / 12)) + elif postFFT > 0: + dnWindow = core.fft3dfilter.FFT3DFilter( + noiseWindow, sigma=postSigma * sigmaScale, planes=fPlane, bt=postTD, + ncpu=fftThreads, bw=postBlkSize, bh=postBlkSize) + else: + dnWindow = RG(noiseWindow, mode=1) + + if postTR > 0: + dnWindow = dnWindow[postTR::postTD] + + sharpened = ContraSharpening(dnWindow, clip, rad) + + if postMix > 0: + sharpened = _expr([clip, sharpened], + 'x {} * y {} * + 100 /'.format(postMix, 100 - postMix)) + + return sharpened diff --git a/worker/tests/filter_integration_test.rs b/worker/tests/filter_integration_test.rs index a79bf597..40d6c45f 100644 --- a/worker/tests/filter_integration_test.rs +++ b/worker/tests/filter_integration_test.rs @@ -60,6 +60,11 @@ fn create_base_job(output_name: &str) -> VideoJob { input_width: None, input_height: None, input_pixel_format: None, + input_color_matrix: None, + input_color_primaries: None, + input_color_transfer: None, + input_color_range: None, + burn_in_subtitle_path: None, } } @@ -620,6 +625,7 @@ fn test_20_combined_all_filters() { maintain_aspect: true, ..CropResizeParameters::default() }, + ..ProcessingPipeline::default() }); run_job(&job, "Combined - All Filters Active").unwrap(); @@ -644,6 +650,7 @@ fn test_21_sharpen_lsfmod() { undershoot: 2, soft_edge: 0, cas_sharpness: 0.5, + ..Default::default() }, ..ProcessingPipeline::default() }); @@ -670,6 +677,7 @@ fn test_22_sharpen_cas() { undershoot: 1, soft_edge: 0, cas_sharpness: 0.7, + ..Default::default() }, ..ProcessingPipeline::default() }); @@ -855,6 +863,7 @@ fn test_28_verify_sharpen_lsfmod_in_script() { undershoot: 2, soft_edge: 0, cas_sharpness: 0.5, + ..Default::default() }, ..ProcessingPipeline::default() }); @@ -886,6 +895,7 @@ fn test_29_verify_sharpen_cas_in_script() { undershoot: 1, soft_edge: 0, cas_sharpness: 0.7, + ..Default::default() }, ..ProcessingPipeline::default() }); @@ -1258,6 +1268,11 @@ fn create_ivtc_base_job(output_name: &str) -> VideoJob { input_width: None, input_height: None, input_pixel_format: None, + input_color_matrix: None, + input_color_primaries: None, + input_color_transfer: None, + input_color_range: None, + burn_in_subtitle_path: None, } } @@ -1821,6 +1836,7 @@ fn test_52_spotless() { blksize: 16, overlap: 8, pel: 2, + ..SpotLessParameters::default() }, ..ProcessingPipeline::default() }); @@ -3458,16 +3474,27 @@ fn test_92_templates_do_not_hardcode_an_nnedi3_implementation() { fn test_93_templates_do_not_call_std_expr_directly() { let templates_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("templates"); - for name in ["pipeline_template.vpy", "preview_template.vpy"] { - let path = templates_dir.join(name); - // Normalise line endings: git checks these out CRLF on Windows. - let body = std::fs::read_to_string(&path) - .unwrap_or_else(|e| panic!("read {}: {e}", path.display())) - .replace("\r\n", "\n"); - - let helper_start = body - .find("def _expr(") - .unwrap_or_else(|| panic!("{name} is missing the _expr() helper")); + /// Assert one file either has no direct `core.std.Expr` call at all, or + /// confines it to a well-formed `_expr()` helper. + fn check(name: &str, body: &str, helper_required: bool) { + let helper_start = match body.find("def _expr(") { + Some(at) => at, + None => { + assert!( + !helper_required, + "{name} is missing the _expr() helper" + ); + // No helper is fine for a module that evaluates no expressions, + // but then it must not reach for std.Expr either. + assert!( + !body.contains("core.std.Expr("), + "{name} calls core.std.Expr with no _expr() helper to route \ + it through; copy the helper from pipeline_template.vpy so \ + ARM gets akarin's JIT instead of the per-pixel interpreter" + ); + return; + } + }; let helper_end = helper_start + body[helper_start..] .find("\n\n") @@ -3498,4 +3525,1931 @@ fn test_93_templates_do_not_call_std_expr_directly() { "{name} defines _expr() but never calls it" ); } + + // Normalise line endings throughout: git checks these out CRLF on Windows. + for name in ["pipeline_template.vpy", "preview_template.vpy"] { + let path = templates_dir.join(name); + let body = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read {}: {e}", path.display())) + .replace("\r\n", "\n"); + check(name, &body, true); + } + + // The vendored Python modules the templates import are the same code path + // and the same trap: an `Expr` inside one of them is just as much a + // per-pixel interpreter on ARM as an `Expr` in the .vpy. The rule went + // uncovered for as long as it did only because spotless.py, the first + // vendored module, evaluates no expressions at all. + let mut modules: Vec<_> = std::fs::read_dir(&templates_dir) + .expect("read templates dir") + .filter_map(|entry| { + let path = entry.ok()?.path(); + (path.extension()? == "py").then_some(path) + }) + .collect(); + modules.sort(); + assert!( + !modules.is_empty(), + "no vendored .py modules found in worker/templates — the scan below \ + would pass vacuously" + ); + for path in modules { + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + let body = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read {}: {e}", path.display())) + .replace("\r\n", "\n"); + check(&name, &body, false); + } +} + +// ============================================================================ +// Filters added from the Hybrid gap analysis +// +// All five are effort-1 additions: every plugin they need was already in the +// deps bundle, unused. They were chosen because each fails *differently* from +// what the pass already offered, which is the only justification for another +// entry in a method dropdown. +// +// KNLMeansCL was in the same batch and was dropped: its OpenCL path does not +// initialise on every machine (the app's own probe reports knlm=false on the +// development Mac), CI deliberately excludes OpenCL-only plugins from the +// required-namespace list, and its `channels="YUV"` mode requires 4:4:4 — which +// none of this app's target sources are. Verified by probing the bundle, not +// from documentation. +// ============================================================================ + +/// Generate a job's script and return its text. +/// +/// `run_job_and_verify` covers "these strings are present"; this is for the +/// cases that assert something is *absent*, which is the direction that catches +/// a block the generator failed to strip. +fn script_text(job: &VideoJob) -> String { + let generator = ScriptGenerator::new().expect("Failed to create generator"); + let script_path = generator.generate(job).expect("Failed to generate script"); + std::fs::read_to_string(&script_path).expect("read generated script") +} + +#[test] +fn test_94_dfttest_noise_reduction() { + create_output_dir(); + let mut job = create_base_job("test_94_dfttest"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { + enabled: false, + ..Default::default() + }, + noise_reduction: NoiseReductionParameters { + enabled: true, + method: NoiseReductionMethod::DfTtest, + dfttest_sigma: 12.5, + dfttest_tbsize: 5, + dfttest_sbsize: 12, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "DFTTest", + &["core.dfttest.DFTTest", "sigma=12.5", "tbsize=5", "sbsize=12"], + ) + .unwrap(); +} + +#[test] +fn test_95_dfttest_temporal_window_is_forced_odd() { + // An even tbsize isn't rejected by DFTTest — it just processes a window + // that isn't centred on the current frame, so the fix has to be ours. + create_output_dir(); + let mut job = create_base_job("test_95_dfttest_even_tbsize"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { + enabled: false, + ..Default::default() + }, + noise_reduction: NoiseReductionParameters { + enabled: true, + method: NoiseReductionMethod::DfTtest, + dfttest_tbsize: 6, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify(&job, "DFTTest even tbsize", &["tbsize=5"]).unwrap(); +} + +#[test] +fn test_96_fft3dfilter_noise_reduction() { + create_output_dir(); + let mut job = create_base_job("test_96_fft3d"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { + enabled: false, + ..Default::default() + }, + noise_reduction: NoiseReductionParameters { + enabled: true, + method: NoiseReductionMethod::Fft3dFilter, + fft3d_sigma: 3.5, + fft3d_bt: 4, + fft3d_sharpen: 0.4, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "FFT3DFilter", + &[ + "core.fft3dfilter.FFT3DFilter", + "sigma=3.5", + "bt=4", + "sharpen=0.4", + ], + ) + .unwrap(); +} + +#[test] +fn test_97_fft3d_sharpen_is_omitted_when_zero() { + // Left at 0 the argument is dropped so the plugin's own default applies, + // rather than passing an explicit no-op. + create_output_dir(); + let mut job = create_base_job("test_97_fft3d_no_sharpen"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { + enabled: false, + ..Default::default() + }, + noise_reduction: NoiseReductionParameters { + enabled: true, + method: NoiseReductionMethod::Fft3dFilter, + fft3d_sharpen: 0.0, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + let script = script_text(&job); + assert!( + script.contains("core.fft3dfilter.FFT3DFilter"), + "FFT3DFilter should be called" + ); + assert!( + !script.contains("sharpen="), + "sharpen should be omitted at 0 so the plugin default applies" + ); +} + +#[test] +fn test_98_ttempsmooth_noise_reduction() { + create_output_dir(); + let mut job = create_base_job("test_98_ttempsmooth"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { + enabled: false, + ..Default::default() + }, + noise_reduction: NoiseReductionParameters { + enabled: true, + method: NoiseReductionMethod::TTempSmooth, + ttemp_maxr: 4, + ttemp_thresh: 6, + ttemp_mdiff: 3, + ttemp_strength: 3, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "TTempSmooth", + &[ + "core.ttmpsm.TTempSmooth", + "maxr=4", + "thresh=6", + "mdiff=3", + "strength=3", + ], + ) + .unwrap(); +} + +#[test] +fn test_99_ttempsmooth_mdiff_is_held_below_thresh() { + // mdiff >= thresh is accepted by the plugin but silently disables the + // motion protection the parameter exists for, so it smooths through motion. + create_output_dir(); + let mut job = create_base_job("test_99_ttempsmooth_mdiff"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { + enabled: false, + ..Default::default() + }, + noise_reduction: NoiseReductionParameters { + enabled: true, + method: NoiseReductionMethod::TTempSmooth, + ttemp_thresh: 4, + ttemp_mdiff: 9, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify(&job, "TTempSmooth mdiff clamp", &["thresh=4", "mdiff=3"]).unwrap(); +} + +#[test] +fn test_100_awarpsharp2_sharpening() { + create_output_dir(); + let mut job = create_base_job("test_100_awarpsharp2"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { + enabled: false, + ..Default::default() + }, + sharpen: SharpenParameters { + enabled: true, + method: SharpenMethod::AWarpSharp2, + warp_depth: 20, + warp_thresh: 100, + warp_blur: 3, + warp_type: 1, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "aWarpSharp2", + &[ + "core.warp.AWarpSharp2", + "depth=20", + "thresh=100", + "blur=3", + "type=1", + ], + ) + .unwrap(); + + // `chroma` is deliberately not passed. This port takes 0 or 1 and rejects + // anything else at script evaluation — the block first shipped with + // Avisynth's chroma=4 and killed vspipe outright, caught by the heavy + // end-to-end test rather than by script generation. Leave the plugin's own + // default alone unless someone exposes it with real verification. + let script = script_text(&job); + assert!( + !script.contains("chroma="), + "aWarpSharp2 should not pass chroma; this port only accepts 0 or 1" + ); +} + +#[test] +fn test_101_hqderingmod_dehalo() { + create_output_dir(); + let mut job = create_base_job("test_101_hqderingmod"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { + enabled: false, + ..Default::default() + }, + dehalo: DehaloParameters { + enabled: true, + method: DehaloMethod::HqDeringmod, + dering_mrad: Some(2), + dering_msmooth: Some(2), + dering_mthr: Some(70), + dering_thr: Some(16.0), + dering_darkthr: Some(4.0), + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "HQDeringmod", + &[ + "haf.HQDeringmod", + "mrad=2", + "msmooth=2", + "mthr=70", + // Exact text: format_double emits a trailing .0, and a bare + // "thr=16" would also match "thr=16.0" and hide a formatting change. + "thr=16.0", + "darkthr=4.0", + ], + ) + .unwrap(); +} + +#[test] +fn test_102_hqderingmod_omits_unset_parameters() { + // Every HQDeringmod argument is optional so havsfunc's own defaults apply + // where the user hasn't chosen — passing our own would silently override + // upstream tuning. + create_output_dir(); + let mut job = create_base_job("test_102_hqderingmod_defaults"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { + enabled: false, + ..Default::default() + }, + dehalo: DehaloParameters { + enabled: true, + method: DehaloMethod::HqDeringmod, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + let script = script_text(&job); + assert!(script.contains("haf.HQDeringmod"), "HQDeringmod should be called"); + for arg in ["mrad=", "msmooth=", "mthr=", "thr=", "darkthr="] { + assert!( + !script.contains(arg), + "{arg} should be omitted when unset so havsfunc's default applies" + ); + } +} + +#[test] +fn test_103_each_noise_method_emits_only_its_own_filter() { + // The template holds every method's block and the generator strips the + // others. A missed remove_block leaves two denoisers chained silently — + // valid VapourSynth, twice the runtime, and not what the user asked for. + create_output_dir(); + + let calls = [ + ("smdegrain", "haf.SMDegrain"), + ("mctd", "haf.MCTemporalDenoise"), + ("dfttest", "core.dfttest.DFTTest"), + ("fft3d", "core.fft3dfilter.FFT3DFilter"), + ("ttempsmooth", "core.ttmpsm.TTempSmooth"), + ]; + + let methods = [ + (NoiseReductionMethod::SmDegrain, "smdegrain"), + (NoiseReductionMethod::McTemporalDenoise, "mctd"), + (NoiseReductionMethod::DfTtest, "dfttest"), + (NoiseReductionMethod::Fft3dFilter, "fft3d"), + (NoiseReductionMethod::TTempSmooth, "ttempsmooth"), + ]; + + for (method, key) in methods { + let mut job = create_base_job(&format!("test_103_{key}")); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { + enabled: false, + ..Default::default() + }, + noise_reduction: NoiseReductionParameters { + enabled: true, + method, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + + let script = script_text(&job); + for (other_key, call) in calls { + let present = script.contains(call); + if other_key == key { + assert!(present, "{key} should emit {call}"); + } else { + assert!(!present, "{key} also emitted {call} — a block wasn't stripped"); + } + } + // And no unsubstituted placeholders survive for the selected method. + assert!( + !script.contains("{{NR_"), + "{key} left an unsubstituted NR_ placeholder in the script" + ); + } +} + +// ============================================================================ +// Second batch: filters whose plugins/functions were already in the bundle. +// +// Anti-aliasing and stabilisation are whole categories VapourBox had nothing +// in, which is why they are passes rather than methods. LUTDeRainbow joins the +// Chroma Fixes stack beside LUTDeCrawl, whose 8-10 bit limit it shares. +// +// STPresso was in this batch and was dropped: havsfunc's implementation calls +// `core.flux.SmoothT`, and the fluxsmooth plugin is NOT bundled (zsmooth +// provides FluxSmoothT under a different namespace). That makes it effort 2, +// not 1. Found by probing, before any wiring was written. +// ============================================================================ + +#[test] +fn test_104_anti_alias_daa() { + create_output_dir(); + let mut job = create_base_job("test_104_aa_daa"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + anti_alias: AntiAliasParameters { + enabled: true, + method: AntiAliasMethod::Daa, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify(&job, "Anti-Alias daa", &["haf.daa("]).unwrap(); + + let script = script_text(&job); + assert!( + !script.contains("haf.santiag("), + "the santiag block should have been stripped" + ); +} + +#[test] +fn test_105_anti_alias_santiag() { + create_output_dir(); + let mut job = create_base_job("test_105_aa_santiag"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + anti_alias: AntiAliasParameters { + enabled: true, + method: AntiAliasMethod::Santiag, + santiag_strh: 2, + santiag_strv: 3, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "Anti-Alias santiag", + &["haf.santiag(", "strh=2", "strv=3", "type=\"nnedi3\""], + ) + .unwrap(); +} + +#[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. + create_output_dir(); + let mut job = create_base_job("test_106_aa_santiag_type"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + anti_alias: AntiAliasParameters { + enabled: true, + method: AntiAliasMethod::Santiag, + santiag_type: "sangnom".to_string(), + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + let script = script_text(&job); + assert!(script.contains("type=\"nnedi3\""), "should fall back to nnedi3"); + // Assert on the emitted argument, not on the whole script: the template's + // own comment explains why eedi2 and sangnom are excluded, and naming them + // there is not the same as passing them. + assert!( + !script.contains("type=\"sangnom\""), + "sangnom must not reach havsfunc — it is not in the deps bundle" + ); + assert!( + !script.contains("type=\"eedi2\""), + "eedi2 must not reach havsfunc — it is not in the deps bundle" + ); +} + +#[test] +fn test_107_stabilize() { + create_output_dir(); + let mut job = create_base_job("test_107_stabilize"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + stabilize: StabilizeParameters { + enabled: true, + dxmax: 6, + dymax: 8, + mirror: 3, + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "Stabilize", + &["haf.Stab(", "dxmax=6", "dymax=8", "mirror=3"], + ) + .unwrap(); +} + +#[test] +fn test_108_stabilize_limits_are_normalized() { + // A negative dxmax/dymax is accepted by DepanStabilise and disables + // correction on that axis, which looks like the filter doing nothing. + create_output_dir(); + let mut job = create_base_job("test_108_stabilize_limits"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + stabilize: StabilizeParameters { + enabled: true, + dxmax: -3, + dymax: -3, + mirror: 9, + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "Stabilize clamps", + &["dxmax=0", "dymax=0", "mirror=3"], + ) + .unwrap(); +} + +#[test] +fn test_109_lut_derainbow_with_ten_bit_guard() { + create_output_dir(); + let mut job = create_base_job("test_109_derainbow"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + chroma_fixes: ChromaFixParameters { + enabled: true, + apply_de_rainbow: true, + de_rainbow_c_thresh: 12, + de_rainbow_y_thresh: 14, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "LUTDeRainbow", + &[ + "haf.LUTDeRainbow(", + "cthresh=12", + "ythresh=14", + // Same 8-10 bit limit as LUTDeCrawl, verified against the bundle. + "bits_per_sample > 10", + "_derainbow_orig_format", + ], + ) + .unwrap(); +} + +#[test] +fn test_110_new_passes_run_in_the_documented_order() { + // The order the passes appear in the script is the order they run, and two + // of these placements are deliberate: anti-aliasing BEFORE sharpening + // (sharpening stair-stepped edges makes the stepping worse), and + // stabilisation LAST before framing (so a crop can remove the borders it + // shifts into view). + create_output_dir(); + let mut job = create_base_job("test_110_order"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + anti_alias: AntiAliasParameters { enabled: true, ..Default::default() }, + sharpen: SharpenParameters { + enabled: true, + method: SharpenMethod::CAS, + ..Default::default() + }, + stabilize: StabilizeParameters { enabled: true, ..Default::default() }, + crop_resize: CropResizeParameters { + enabled: true, + resize_enabled: true, + target_width: Some(640), + target_height: Some(480), + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + + let script = script_text(&job); + let aa = script.find("haf.daa(").expect("daa should be present"); + let sharp = script.find("core.cas.CAS(").expect("CAS should be present"); + let stab = script.find("haf.Stab(").expect("Stab should be present"); + assert!(aa < sharp, "anti-aliasing must run before sharpening"); + assert!(sharp < stab, "stabilisation runs after the detail passes"); +} + +// ============================================================================ +// Third batch: the first filters that needed a DEPS change. +// +// `fluxsmooth` is a new plugin in the bundle (deps 1.9.0), pinned to v2 on every +// platform because that is the newest tag with a published Windows binary and +// the Windows deps script has no from-source path. It unlocks three filters: +// FluxSmoothT, FluxSmoothST, and havsfunc's STPresso — which calls +// core.flux.SmoothT internally and was dropped from the second batch precisely +// because the plugin was missing. +// ============================================================================ + +#[test] +fn test_111_fluxsmooth_temporal() { + create_output_dir(); + let mut job = create_base_job("test_111_fluxsmooth_t"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + noise_reduction: NoiseReductionParameters { + enabled: true, + method: NoiseReductionMethod::FluxSmoothT, + flux_temporal_threshold: 9, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "FluxSmoothT", + &["core.flux.SmoothT(", "temporal_threshold=9"], + ) + .unwrap(); + + // The temporal-only variant must not emit the spatial one. + let script = script_text(&job); + assert!(!script.contains("core.flux.SmoothST(")); +} + +#[test] +fn test_112_fluxsmooth_spatiotemporal() { + create_output_dir(); + let mut job = create_base_job("test_112_fluxsmooth_st"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + noise_reduction: NoiseReductionParameters { + enabled: true, + method: NoiseReductionMethod::FluxSmoothSt, + flux_temporal_threshold: 9, + flux_spatial_threshold: 11, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "FluxSmoothST", + &[ + "core.flux.SmoothST(", + "temporal_threshold=9", + "spatial_threshold=11", + ], + ) + .unwrap(); +} + +#[test] +fn test_113_fluxsmooth_thresholds_allow_minus_one_but_no_lower() { + // -1 disables that half of the filter; below that the plugin errors, so the + // worker clamps rather than letting it through. + create_output_dir(); + let mut job = create_base_job("test_113_fluxsmooth_clamp"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + noise_reduction: NoiseReductionParameters { + enabled: true, + method: NoiseReductionMethod::FluxSmoothSt, + flux_temporal_threshold: -50, + flux_spatial_threshold: 900, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "FluxSmooth clamps", + &["temporal_threshold=-1", "spatial_threshold=255"], + ) + .unwrap(); +} + +#[test] +fn test_114_stpresso() { + create_output_dir(); + let mut job = create_base_job("test_114_stpresso"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + noise_reduction: NoiseReductionParameters { + enabled: true, + method: NoiseReductionMethod::StPresso, + stpresso_limit: 5, + stpresso_bias: 30, + stpresso_tthr: 16, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "STPresso", + &["haf.STPresso(", "limit=5", "bias=30", "tthr=16"], + ) + .unwrap(); +} + +#[test] +fn test_115_every_noise_method_still_emits_only_its_own_filter() { + // Extends test_103 to the fluxsmooth-backed methods. A missed remove_block + // chains two denoisers silently — valid VapourSynth, twice the runtime, and + // not what the user asked for. + create_output_dir(); + + let calls = [ + ("smdegrain", "haf.SMDegrain"), + ("mctd", "haf.MCTemporalDenoise"), + ("dfttest", "core.dfttest.DFTTest"), + ("fft3d", "core.fft3dfilter.FFT3DFilter"), + ("ttempsmooth", "core.ttmpsm.TTempSmooth"), + ("fluxt", "core.flux.SmoothT("), + ("fluxst", "core.flux.SmoothST("), + ("stpresso", "haf.STPresso"), + ]; + + let methods = [ + (NoiseReductionMethod::SmDegrain, "smdegrain"), + (NoiseReductionMethod::McTemporalDenoise, "mctd"), + (NoiseReductionMethod::DfTtest, "dfttest"), + (NoiseReductionMethod::Fft3dFilter, "fft3d"), + (NoiseReductionMethod::TTempSmooth, "ttempsmooth"), + (NoiseReductionMethod::FluxSmoothT, "fluxt"), + (NoiseReductionMethod::FluxSmoothSt, "fluxst"), + (NoiseReductionMethod::StPresso, "stpresso"), + ]; + + for (method, key) in methods { + let mut job = create_base_job(&format!("test_115_{key}")); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + noise_reduction: NoiseReductionParameters { + enabled: true, + method, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + + let script = script_text(&job); + for (other_key, call) in calls { + // SmoothT is a substring of SmoothST, so compare on the full call + // text including the opening paren. + let present = script.contains(call); + if other_key == key { + assert!(present, "{key} should emit {call}"); + } else { + assert!(!present, "{key} also emitted {call} — a block wasn't stripped"); + } + } + assert!( + !script.contains("{{NR_"), + "{key} left an unsubstituted NR_ placeholder" + ); + } +} + +// ============================================================================ +// Batch four. +// ============================================================================ + +#[test] +fn test_116_geometry_rotation_and_flips() { + create_output_dir(); + let mut job = create_base_job("test_116_geometry"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + geometry: GeometryParameters { + enabled: true, + rotation: Rotation::Cw90, + flip_horizontal: true, + flip_vertical: false, + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "Rotate / Flip", + &["core.std.Turn90(", "core.std.FlipHorizontal("], + ) + .unwrap(); + + let script = script_text(&job); + assert!( + !script.contains("core.std.FlipVertical("), + "an unselected flip must not be emitted" + ); +} + +#[test] +fn test_117_geometry_enabled_with_nothing_selected_emits_nothing() { + // Turning the pass on without choosing anything is a no-op, and the script + // should say what actually runs rather than carry a silent identity call. + create_output_dir(); + let mut job = create_base_job("test_117_geometry_noop"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + geometry: GeometryParameters { enabled: true, ..Default::default() }, + ..ProcessingPipeline::default() + }); + let script = script_text(&job); + for call in ["core.std.Turn90(", "core.std.Turn180(", "core.std.Turn270(", + "core.std.FlipHorizontal(", "core.std.FlipVertical("] { + assert!(!script.contains(call), "{call} should not be emitted"); + } + assert!(!script.contains("{{GEOM"), "left an unsubstituted placeholder"); +} + +#[test] +fn test_118_each_rotation_emits_its_own_turn() { + create_output_dir(); + for (rotation, expected, forbidden) in [ + (Rotation::Cw90, "core.std.Turn90(", "core.std.Turn270("), + (Rotation::Rotate180, "core.std.Turn180(", "core.std.Turn90("), + (Rotation::Ccw90, "core.std.Turn270(", "core.std.Turn90("), + ] { + let mut job = create_base_job("test_118_geometry_rotations"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + geometry: GeometryParameters { + enabled: true, + rotation, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + let script = script_text(&job); + assert!(script.contains(expected), "{rotation:?} should emit {expected}"); + assert!(!script.contains(forbidden), "{rotation:?} also emitted {forbidden}"); + } +} + +#[test] +fn test_119_rotation_runs_before_framing_and_after_deinterlacing() { + // Both orderings are load-bearing. A quarter turn swaps width and height, + // so framing must see the final shape; and fields run horizontally, so + // turning a still-interlaced clip would shear them. + create_output_dir(); + let mut job = create_base_job("test_119_geometry_order"); + job.qtgmc_parameters.enabled = true; + job.qtgmc_parameters.tff = Some(true); + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { + enabled: true, + preset: QTGMCPreset::Fast, + tff: Some(true), + ..Default::default() + }, + geometry: GeometryParameters { + enabled: true, + rotation: Rotation::Cw90, + ..Default::default() + }, + crop_resize: CropResizeParameters { + enabled: true, + resize_enabled: true, + target_width: Some(480), + target_height: Some(640), + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + + let script = script_text(&job); + let deint = script.find("haf.QTGMC(").expect("QTGMC present"); + let turn = script.find("core.std.Turn90(").expect("Turn90 present"); + // Anchor on the target size, not on "core.resize." — the template also uses + // resize near the top to normalise unusual chroma formats, and matching that + // would compare against the wrong call entirely. + let framing = script + .find("target_w = 480") + .expect("the framing resize should carry the target width"); + assert!(deint < turn, "rotation must follow deinterlacing"); + assert!(turn < framing, "rotation must precede framing"); +} + +#[test] +fn test_120_grain_addgrain() { + create_output_dir(); + let mut job = create_base_job("test_120_grain_add"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + grain: GrainParameters { + enabled: true, + method: GrainMethod::AddGrain, + var: 9.0, + uvar: 2.0, + corr: 0.5, + constant: true, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "AddGrain", + &["core.grain.Add(", "var=9.0", "uvar=2.0", "hcorr=0.5", "vcorr=0.5", "constant=True"], + ) + .unwrap(); +} + +#[test] +fn test_121_grain_correlation_is_capped_below_one() { + // 1.0 is accepted by the plugin but wraps back to uncorrelated noise at + // full amplitude — the opposite of what the control implies. + create_output_dir(); + let mut job = create_base_job("test_121_grain_corr"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + grain: GrainParameters { + enabled: true, + corr: 1.0, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify(&job, "AddGrain corr cap", &["hcorr=0.9", "vcorr=0.9"]).unwrap(); +} + +#[test] +fn test_122_grain_is_not_depth_scaled() { + // `var` is already in 8-bit units and the plugin rescales internally, so it + // must NOT get the _levels_8bit() treatment applied to std.Levels and + // Tweak. Measured: identical 8-bit-equivalent output at 8/10/12/16-bit. + // This asserts the script passes the value through untouched. + create_output_dir(); + let mut job = create_base_job("test_122_grain_depth"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + grain: GrainParameters { + enabled: true, + var: 6.0, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + let script = script_text(&job); + assert!(script.contains("var=6"), "var should be passed through verbatim"); + for scaled in ["_grain_scale", "peak", "_levels_8bit"] { + assert!( + !script.contains(&format!("var={scaled}")), + "var must not be depth-scaled" + ); + } +} + +#[test] +fn test_123_grain_factory3() { + create_output_dir(); + let mut job = create_base_job("test_123_grain_gf3"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + grain: GrainParameters { + enabled: true, + method: GrainMethod::GrainFactory3, + g1str: 6.0, + g2str: 4.0, + g3str: 2.0, + temp_avg: 50, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "GrainFactory3", + &["haf.GrainFactory3(", "g1str=6", "g2str=4", "g3str=2", "temp_avg=50"], + ) + .unwrap(); + + let script = script_text(&job); + assert!(!script.contains("core.grain.Add("), "the other method must be stripped"); +} + +#[test] +fn test_124_grain_runs_last_of_the_video_passes() { + // Grain added before a resize is resampled away and before a deband is + // smoothed away, so it has to come after both — and before the output + // colour conversion, which must stay final. + create_output_dir(); + let mut job = create_base_job("test_124_grain_order"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + deband: DebandParameters { enabled: true, ..Default::default() }, + sharpen: SharpenParameters { + enabled: true, + method: SharpenMethod::CAS, + ..Default::default() + }, + grain: GrainParameters { enabled: true, ..Default::default() }, + crop_resize: CropResizeParameters { + enabled: true, + resize_enabled: true, + target_width: Some(640), + target_height: Some(480), + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + + let script = script_text(&job); + let deband = script.find("neo_f3kdb.Deband(").expect("deband present"); + let sharpen = script.find("core.cas.CAS(").expect("sharpen present"); + let framing = script.find("target_w = 640").expect("framing present"); + let grain = script.find("core.grain.Add(").expect("grain present"); + assert!(deband < grain, "grain must follow deband"); + assert!(sharpen < grain, "grain must follow sharpening"); + assert!(framing < grain, "grain must follow the framing resize"); +} + +#[test] +fn test_125_grain_with_zero_strength_emits_nothing() { + create_output_dir(); + let mut job = create_base_job("test_125_grain_silent"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + grain: GrainParameters { + enabled: true, + var: 0.0, + uvar: 0.0, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + let script = script_text(&job); + assert!(!script.contains("core.grain.Add(")); + assert!(!script.contains("{{GRAIN"), "left an unsubstituted placeholder"); +} + +#[test] +fn test_126_rotation_restores_the_source_pixel_format() { + // A quarter turn swaps the chroma subsampling axes: 4:2:2 becomes 4:4:0, + // which vspipe emits as "C440" and ffmpeg rejects outright, and 4:1:1 has no + // y4m identifier at all so vspipe itself fails. Both are hard job failures, + // and 4:2:2 is the common 10-bit ProRes case — so the script captures the + // source format before the turn and converts back after. + create_output_dir(); + let mut job = create_base_job("test_126_rotate_format"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + geometry: GeometryParameters { + enabled: true, + rotation: Rotation::Cw90, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "Rotate format guard", + &[ + "_geom_src_format = clip.format", + "clip.format.id != _geom_src_format.id", + "core.resize.Spline36(clip, format=_geom_src_format.id)", + ], + ) + .unwrap(); +} + +#[test] +fn test_127_rotation_inverts_the_declared_sample_aspect() { + // SAR is pixel width : height, so a quarter turn exchanges them. There are + // TWO consumers: the ffmpeg-side declaration on the encode path, and + // {{SOURCE_SAR}} in the script's square-pixel fitting. Both must see the + // rotated value or an anamorphic source comes out the wrong shape. + let turned = GeometryParameters { + enabled: true, + rotation: Rotation::Cw90, + ..Default::default() + }; + assert_eq!(turned.adjusted_sar(Some("64:45")).as_deref(), Some("45:64")); + + create_output_dir(); + let mut job = create_base_job("test_127_rotate_sar"); + job.qtgmc_parameters.enabled = false; + job.input_sar = Some("64:45".to_string()); + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + geometry: turned, + crop_resize: CropResizeParameters { + enabled: true, + resize_enabled: true, + pixel_aspect: PixelAspectMode::Square, + target_height: Some(720), + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + + let script = script_text(&job); + // 45/64 = 0.703125; the un-inverted 64/45 would be 1.4222. + assert!( + script.contains("0.703125") || script.contains("0.7031"), + "the square-pixel path should use the inverted SAR, not the source's" + ); + assert!( + !script.contains("1.4222"), + "the un-inverted SAR must not reach the fitting calculation" + ); +} + +#[test] +fn test_128_ctmf_with_its_nine_bit_guard_and_pinned_memsize() { + create_output_dir(); + let mut job = create_base_job("test_128_ctmf"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + noise_reduction: NoiseReductionParameters { + enabled: true, + method: NoiseReductionMethod::Ctmf, + ctmf_radius: 4, + ctmf_planes: 0, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "CTMF", + &[ + "core.ctmf.CTMF(", + "radius=4", + "planes=[0]", + // 9-bit is rejected outright by the plugin and IS reachable — + // pixel_format.rs rounds an odd source depth up through 9. + "bits_per_sample == 9", + // Pinned: at the plugin's 1 MiB default, 16-bit radius 3 measures + // 0.79 fps against 42 fps here, for bit-identical output. + "memsize=16777216", + ], + ) + .unwrap(); +} + +#[test] +fn test_129_ctmf_radius_is_clamped_in_the_script() { + create_output_dir(); + let mut job = create_base_job("test_129_ctmf_clamp"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + noise_reduction: NoiseReductionParameters { + enabled: true, + method: NoiseReductionMethod::Ctmf, + ctmf_radius: 200, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify(&job, "CTMF clamp", &["radius=12"]).unwrap(); +} + +#[test] +fn test_130_dctfilter_builds_a_valid_eight_factor_curve() { + create_output_dir(); + let mut job = create_base_job("test_130_dctfilter"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + deblock: DeblockParameters { + enabled: true, + method: DeblockMethod::DctFilter, + dct_cutoff: 5, + dct_strength: 0.6, + dct_planes: 0, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "DCTFilter", + &["core.dctf.DCTFilter(", "factors=[", "planes=[0]"], + ) + .unwrap(); + + let script = script_text(&job); + // Exactly eight factors, or the plugin errors. + let start = script.find("factors=[").unwrap() + "factors=[".len(); + let end = script[start..].find(']').unwrap() + start; + assert_eq!( + script[start..end].split(',').count(), + 8, + "DCTFilter requires exactly 8 factors" + ); + // Scope this to the factors themselves: the template's own comment + // explains the NaN trap, and matching that would assert on prose. + let factors = &script[start..end]; + assert!( + !factors.to_lowercase().contains("nan") && !factors.to_lowercase().contains("inf"), + "a NaN factor passes the plugin's range check and blackens the frame: {factors}" + ); +} + +#[test] +fn test_131_dctfilter_at_zero_strength_is_the_identity_curve() { + // All factors at 1.0 is a verified bit-exact no-op, so the pass can be + // enabled with nothing turned up and change nothing. + create_output_dir(); + let mut job = create_base_job("test_131_dctfilter_noop"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + deblock: DeblockParameters { + enabled: true, + method: DeblockMethod::DctFilter, + dct_strength: 0.0, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "DCTFilter identity", + &["factors=[1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000]"], + ) + .unwrap(); +} + +#[test] +fn test_132_smooth_levels_scales_its_values_to_the_clip_depth() { + // SmoothLevels reads levels in the CLIP'S OWN range, not 8-bit — the same + // trap as std.Levels, and the same fix: scale in the script from + // clip.format, because a preceding pass may have changed the depth. + // Measured unscaled, a gamma-only edit at 16-bit is off by a worst pixel of + // 249/255. + create_output_dir(); + let mut job = create_base_job("test_132_smooth_levels"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + color_correction: ColorCorrectionParameters { + enabled: true, + apply_levels: true, + smooth_levels: true, + input_low: 16, + input_high: 235, + output_low: 0, + output_high: 255, + gamma: 1.0, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "SmoothLevels", + &[ + "haf.SmoothLevels(", + "input_low=_levels_8bit(16)", + "input_high=_levels_8bit(235)", + "output_high=_levels_8bit(255)", + "Smode=-2", + // havsfunc calls core.f3kdb.Deband and this bundle ships + // neo_f3kdb, so the default useDB=True fails on every format. + "useDB=False", + ], + ) + .unwrap(); + + let script = script_text(&job); + assert!( + !script.contains("clip = core.std.Levels("), + "the plain Levels call must not also run" + ); +} + +#[test] +fn test_133_smooth_levels_drops_the_black_point_when_gamma_would_crash() { + // havsfunc raises a negative base to a fractional power below input_low, + // which yields a Python complex and fails the LUT outright. Measured: it + // crashes whenever input_low > 0 and 1/gamma is not an integer. + create_output_dir(); + let mut job = create_base_job("test_133_smooth_levels_gamma"); + job.qtgmc_parameters.enabled = false; + let color = ColorCorrectionParameters { + enabled: true, + apply_levels: true, + smooth_levels: true, + input_low: 16, + gamma: 0.6, + ..Default::default() + }; + assert!(color.smooth_levels_drops_black_point()); + + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + color_correction: color, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "SmoothLevels gamma guard", + &["input_low=_levels_8bit(0)", "gamma=0.6"], + ) + .unwrap(); +} + +#[test] +fn test_134_plain_levels_still_runs_when_smooth_is_off() { + // The existing behaviour must be untouched, so saved presets keep working. + create_output_dir(); + let mut job = create_base_job("test_134_plain_levels"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + color_correction: ColorCorrectionParameters { + enabled: true, + apply_levels: true, + smooth_levels: false, + input_low: 16, + gamma: 0.6, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + let script = script_text(&job); + assert!(script.contains("core.std.Levels("), "plain Levels should run"); + assert!(!script.contains("haf.SmoothLevels("), "SmoothLevels should not"); + // And the plain path keeps the black point AND the gamma together. + assert!(script.contains("min_in=_levels_8bit(16)")); +} + +// ============================================================================ +// Fifth batch: the second deps change (bifrost + retinex, joining deps 1.9.0). +// ============================================================================ + +#[test] +fn test_135_bifrost_with_its_eight_bit_guard() { + // Bifrost is 8-bit only: "Only constant format 8 bit integer YUV input + // supported", verified against the bundle at 10/12/16-bit and 4:2:2. Same + // convert-down-and-restore treatment as DeScratch. + create_output_dir(); + let mut job = create_base_job("test_135_bifrost"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + chroma_fixes: ChromaFixParameters { + enabled: true, + apply_bifrost: true, + bifrost_luma_thresh: 12.0, + bifrost_variation: 3, + bifrost_interlaced: false, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "Bifrost", + &[ + "core.bifrost.Bifrost(", + "luma_thresh=12", + "variation=3", + "interlaced=False", + "_bifrost_src_format", + "bits_per_sample != 8", + ], + ) + .unwrap(); +} + +#[test] +fn test_136_shadow_detail_runs_on_luma_only() { + // retinex.MSRCP rejects subsampled formats outright, and every source this + // app handles is 4:2:0 or 4:2:2 — so the luma plane is extracted as + // greyscale, processed, and put back, leaving chroma bit-identical rather + // than round-tripping the clip through 4:4:4. + create_output_dir(); + let mut job = create_base_job("test_136_shadow_detail"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + color_correction: ColorCorrectionParameters { + enabled: true, + apply_shadow_detail: true, + shadow_sigma: 120.0, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "Shadow detail", + &[ + "core.retinex.MSRCP(", + "sigma=[120", + // The luma-only round trip, which is what makes it usable at all. + "colorfamily=vs.GRAY", + "core.std.ShufflePlanes", + ], + ) + .unwrap(); +} + +#[test] +fn test_137_shadow_detail_parameters_are_clamped() { + create_output_dir(); + let mut job = create_base_job("test_137_shadow_clamp"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + color_correction: ColorCorrectionParameters { + enabled: true, + apply_shadow_detail: true, + shadow_sigma: 9000.0, + shadow_lower_thr: 0.9, + shadow_upper_thr: -1.0, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + run_job_and_verify( + &job, + "Shadow detail clamps", + &["sigma=[500", "lower_thr=0.1", "upper_thr=0"], + ) + .unwrap(); +} + +#[test] +fn test_138_the_two_rainbow_removers_are_independent() { + // LUTDeRainbow decides within a frame; Bifrost compares across frames. They + // are complementary, so both must be able to run together and each must be + // strippable on its own. + create_output_dir(); + for (derainbow, bifrost) in [(true, false), (false, true), (true, true)] { + let mut job = create_base_job("test_138_rainbow_pair"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + chroma_fixes: ChromaFixParameters { + enabled: true, + apply_de_rainbow: derainbow, + apply_bifrost: bifrost, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + let script = script_text(&job); + assert_eq!( + script.contains("haf.LUTDeRainbow("), + derainbow, + "LUTDeRainbow presence should follow its own toggle" + ); + assert_eq!( + script.contains("core.bifrost.Bifrost("), + bifrost, + "Bifrost presence should follow its own toggle" + ); + } +} + +#[test] +fn test_139_anti_alias_always_marks_field_based() { + // znedi3's double-rate mode REQUIRES the _FieldBased property and fails the + // whole job with "znedi3: _FieldBased" without it. Measured against the + // bundled plugin: field=3 errors when the property is absent and is fine at + // either 0 or 2; field=1 does not care. havsfunc's daa uses field=3. + // + // The property is only set upstream when a field order is KNOWN, so an + // ordinary source with none — most of them — killed the pass. It survived + // on arm64 (nnedi3 via patch 6) and Windows (prebuilt znedi3), and died on + // macOS x64 and Linux x64, so the same job worked or failed depending on + // the machine. Found by the nightly suite; script generation cannot see it, + // which is why this asserts the mark is present rather than that daa runs. + create_output_dir(); + let mut job = create_base_job("test_139_aa_field_based"); + job.qtgmc_parameters.enabled = false; + job.detected_field_order = None; // the case that failed + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + anti_alias: AntiAliasParameters { + enabled: true, + method: AntiAliasMethod::Daa, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + + let script = script_text(&job); + let marked = script + .find("core.std.SetFieldBased(clip, 0)") + .expect("an undetected field order must still be marked progressive"); + let daa = script.find("haf.daa(").expect("daa must be emitted"); + assert!(marked < daa, "the mark must precede daa, or znedi3 still errors"); + assert!( + !script.contains("{{AA_FIELD_BASED_VALUE}}"), + "the placeholder must not survive into the script" + ); +} + +#[test] +fn test_140_anti_alias_marks_progressive_after_deinterlacing() { + // With deinterlacing on, the clip reaching the pass IS progressive, so the + // mark must be 0 — stamping the source's original field order back on would + // tell every later filter the deinterlaced output is still interlaced. + create_output_dir(); + let mut job = create_base_job("test_140_aa_after_deint"); + job.detected_field_order = Some(FieldOrder::TopFieldFirst); + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: true, tff: Some(true), ..Default::default() }, + anti_alias: AntiAliasParameters { + enabled: true, + method: AntiAliasMethod::Daa, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + + let script = script_text(&job); + let daa = script.find("haf.daa(").expect("daa must be emitted"); + let mark = script[..daa] + .rfind("core.std.SetFieldBased(clip, ") + .expect("the pass must mark the clip"); + let line_end = script[mark..].find(')').unwrap() + mark; + assert_eq!( + &script[mark..=line_end], + "core.std.SetFieldBased(clip, 0)", + "after deinterlacing the clip is progressive" + ); +} + +#[test] +fn test_141_anti_alias_keeps_the_detected_order_when_not_deinterlacing() { + // Deinterlacing off and a field order known: the clip really is fielded, so + // daa needs the true parity to pair fields correctly. + create_output_dir(); + let mut job = create_base_job("test_141_aa_keeps_order"); + job.qtgmc_parameters.enabled = false; + job.detected_field_order = Some(FieldOrder::BottomFieldFirst); + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + anti_alias: AntiAliasParameters { + enabled: true, + method: AntiAliasMethod::Daa, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + + let script = script_text(&job); + let daa = script.find("haf.daa(").expect("daa must be emitted"); + assert!( + script[..daa].contains("core.std.SetFieldBased(clip, 1)"), + "BFF must reach the pass as 1, not be flattened to progressive" + ); +} + +/// Cnr4 must be preceded by SCDetect, or it fails every job on every platform. +/// +/// `scenechange` defaults to True and requires the _SceneChangePrev/Next frame +/// properties, which this pipeline never sets — measured against the bundled +/// plugin, a bare `Cnr4(clip)` errors on every format tested. SCDetect is the +/// right supplier rather than `scenechange=False`, because it is also what +/// stops the filter smearing chroma across a cut. +#[test] +fn test_142_cnr4_is_preceded_by_scene_detection() { + create_output_dir(); + let mut job = create_base_job("test_142_cnr4_scdetect"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + chroma_denoise: ChromaDenoiseParameters { + enabled: true, + method: ChromaDenoiseMethod::Cnr4, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + + let script = script_text(&job); + let cnr4 = script.find("zsmooth.Cnr4(").expect("Cnr4 must be emitted"); + let scd = script[..cnr4] + .rfind("core.misc.SCDetect(") + .expect("SCDetect must precede Cnr4 or every job fails"); + assert!(scd < cnr4); + // 4:1:1 is NTSC DV and pipe_source maps it natively; Cnr4 rejects it. + assert!( + script[..cnr4].contains("subsampling_w == 2"), + "the 4:1:1 guard must run before Cnr4" + ); + // CCD must not also be emitted — one method runs, not both. + assert!(!script.contains("zsmooth.CCD(")); +} + +/// Selecting CCD must leave no Cnr4 remnants, and vice versa. A surviving +/// placeholder is a bare Python SyntaxError from vspipe that reads like a +/// template bug. +#[test] +fn test_143_chroma_denoise_methods_are_mutually_exclusive() { + create_output_dir(); + for (method, present, absent) in [ + (ChromaDenoiseMethod::Ccd, "zsmooth.CCD(", "zsmooth.Cnr4("), + (ChromaDenoiseMethod::Cnr4, "zsmooth.Cnr4(", "zsmooth.CCD("), + ] { + let mut job = create_base_job("test_143_chroma_denoise_exclusive"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + chroma_denoise: ChromaDenoiseParameters { + enabled: true, + method, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + let script = script_text(&job); + assert!(script.contains(present), "{method:?} must emit {present}"); + assert!(!script.contains(absent), "{method:?} must not emit {absent}"); + assert!( + !script.contains("{{CNR4_") && !script.contains("{{CCD_"), + "{method:?} left an unsubstituted placeholder" + ); + } +} + +/// Every new pass emits its own call and nothing else's, and leaves no +/// unsubstituted placeholder. A surviving `{{...}}` is a bare Python +/// SyntaxError from vspipe that reads like a template bug. +#[test] +fn test_144_new_passes_emit_cleanly() { + create_output_dir(); + + // Prefix-scoped, not a bare "{{" search: the template's own docstring + // documents the placeholder syntax with {{PARAMETER_NAME}} examples. + let cases: Vec<(&str, Box, &str, &[&str])> = vec![ + ("edge_repair", Box::new(|p: &mut ProcessingPipeline| { + p.edge_repair = EdgeRepairParameters { + enabled: true, left: 2, right: 2, top: 2, bottom: 2, + ..Default::default() + }; + }), "core.fb.FillBorders(", &["{{ER_", "{{#EDGE_REPAIR", "{{/EDGE_REPAIR"]), + ("ghost_removal", Box::new(|p: &mut ProcessingPipeline| { + p.ghost_removal = GhostRemovalParameters { + enabled: true, + ghosts: vec![GhostSpec { mode: 2, shift: 6, intensity: 24 }], + }; + }), "core.lghost.LGhost(", &["{{LG_", "{{#GHOST_REMOVAL", "{{/GHOST_REMOVAL"]), + ("deflicker", Box::new(|p: &mut ProcessingPipeline| { + p.deflicker = DeflickerParameters { enabled: true, ..Default::default() }; + }), "global_deflicker(", &["{{DEFLICKER_", "{{#DEFLICKER", "{{/DEFLICKER"]), + ("frame_rate", Box::new(|p: &mut ProcessingPipeline| { + p.frame_rate = FrameRateParameters { + enabled: true, source_fps_num: Some(25), source_fps_den: Some(1), + ..Default::default() + }; + }), "core.mv.FlowFPS(", &["{{FPS_", "{{#FRAME_RATE", "{{/FRAME_RATE"]), + ]; + + for (name, apply, expected, prefixes) in cases { + let mut job = create_base_job(&format!("test_144_{name}")); + job.qtgmc_parameters.enabled = false; + let mut pipeline = ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + ..ProcessingPipeline::default() + }; + apply(&mut pipeline); + job.processing_pipeline = Some(pipeline); + + let script = script_text(&job); + assert!(script.contains(expected), "{name} must emit {expected}"); + for prefix in prefixes { + assert!( + !script.contains(prefix), + "{name} left an unsubstituted {prefix} placeholder" + ); + } + } +} + +/// Edge repair rounds every width down to an even number. +/// +/// The bundle pins FillBorders v2, which is bit-identical to v4 at even widths +/// and differs only at odd ones, where it leaves subsampled chroma unrepaired. +#[test] +fn test_145_edge_repair_widths_are_always_even() { + create_output_dir(); + let mut job = create_base_job("test_145_edge_repair_even"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + edge_repair: EdgeRepairParameters { + enabled: true, left: 3, right: 5, top: 1, bottom: 7, + ..Default::default() + }, + ..ProcessingPipeline::default() + }); + + let script = script_text(&job); + assert!(script.contains("left=2"), "3 must round to 2"); + assert!(script.contains("right=4"), "5 must round to 4"); + assert!(script.contains("top=0"), "1 must round to 0"); + assert!(script.contains("bottom=6"), "7 must round to 6"); +} + +/// Custom VapourSynth is bracketed by a frame-count assertion. +/// +/// A snippet that trims desynchronises the progress total AND makes +/// frame-accurate preview show a different frame than its label — both +/// silently. The script refuses rather than letting that happen. +#[test] +fn test_146_custom_vapoursynth_guards_the_frame_count() { + create_output_dir(); + let mut job = create_base_job("test_146_custom_vs"); + job.qtgmc_parameters.enabled = false; + job.encoding_settings.custom_vapoursynth = + "clip = core.std.Invert(clip)".to_string(); + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + ..ProcessingPipeline::default() + }); + + let script = script_text(&job); + let code = script.find("core.std.Invert").expect("snippet must be injected"); + assert!( + script[..code].contains("_custom_vs_frames_before = len(clip)"), + "the frame count must be captured before the snippet" + ); + assert!( + script[code..].contains("changed the frame count"), + "and asserted after it" + ); + + // Empty means the whole block goes, not an empty one left behind. + let mut clean = create_base_job("test_146_custom_vs_off"); + clean.qtgmc_parameters.enabled = false; + let script = script_text(&clean); + assert!(!script.contains("_custom_vs_frames_before")); +} + +/// mClean reaches the script with its own parameters, not a stale default set. +/// +/// The vendored module is the only denoiser here with no havsfunc equivalent to +/// fall back on, so a broken substitution shows up as a filter that runs and +/// does nothing rather than as an error. +#[test] +fn test_147_mclean_noise_reduction() { + create_output_dir(); + let mut job = create_base_job("test_147_mclean"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + noise_reduction: NoiseReductionParameters { + enabled: true, + method: NoiseReductionMethod::MClean, + preset: NoiseReductionPreset::Moderate, + mclean_strength: 17, + mclean_sharp: 9, + mclean_rn: 11, + mclean_thsad: 320, + mclean_chroma: false, + ..NoiseReductionParameters::default() + }, + ..ProcessingPipeline::default() + }); + + let script = script_text(&job); + assert!(script.contains("from mclean import mClean"), "module import"); + assert!(script.contains("strength=17"), "strength substituted"); + assert!(script.contains("sharp=9"), "sharp substituted"); + assert!(script.contains("rn=11"), "rn substituted"); + assert!(script.contains("thSAD=320"), "thSAD substituted"); + assert!(script.contains("chroma=False"), "chroma substituted"); + assert!( + !script.contains("{{NR_MCLEAN"), + "no placeholder may survive:\n{script}" + ); +} + +/// TemporalDegrain2 likewise, including the two values the module clamps. +#[test] +fn test_148_temporal_degrain2_noise_reduction() { + create_output_dir(); + let mut job = create_base_job("test_148_td2"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + noise_reduction: NoiseReductionParameters { + enabled: true, + method: NoiseReductionMethod::TemporalDegrain2, + preset: NoiseReductionPreset::Moderate, + td2_degrain_tr: 2, + td2_grain_level: 1, + td2_post_fft: 3, + td2_post_sigma: 1.5, + td2_post_mix: 40, + td2_chroma_motion: false, + ..NoiseReductionParameters::default() + }, + ..ProcessingPipeline::default() + }); + + let script = script_text(&job); + assert!( + script.contains("from temporaldegrain2 import TemporalDegrain2"), + "module import" + ); + assert!(script.contains("degrainTR=2"), "degrainTR substituted"); + assert!(script.contains("grainLevel=1"), "grainLevel substituted"); + assert!(script.contains("postFFT=3"), "postFFT substituted"); + assert!(script.contains("postMix=40"), "postMix substituted"); + assert!(script.contains("ChromaMotion=False"), "ChromaMotion substituted"); + assert!( + !script.contains("{{NR_TD2"), + "no placeholder may survive:\n{script}" + ); +} + +/// Every noise reduction method actually reaches the script. +/// +/// This exists because two of them didn't. The dispatch is one `match` arm per +/// method, each removing the eleven blocks it isn't and enabling the one it is +/// — so adding a method means editing every arm, and a blanket edit that also +/// hits the new arm makes it remove its *own* block. `MClean` and +/// `TemporalDegrain2` both shipped that way: the pass ran, produced a script +/// with no denoiser in it, and encoded a passthrough with no error anywhere. +/// +/// Enumerating the whole enum is the only shape that catches it. A test per +/// method catches the method you thought to test. +#[test] +fn test_149_every_noise_reduction_method_emits_its_filter() { + create_output_dir(); + + // The call that proves this method, and only this method, was emitted. + let expected: &[(NoiseReductionMethod, &str)] = &[ + (NoiseReductionMethod::SmDegrain, "haf.SMDegrain("), + (NoiseReductionMethod::McTemporalDenoise, "haf.MCTemporalDenoise("), + // Its Degrain1/2/3 call is chosen by temporal radius, so match the + // super clip it always builds instead. + (NoiseReductionMethod::McDegrainSharp, "_mcds_super_search"), + (NoiseReductionMethod::DfTtest, "core.dfttest.DFTTest("), + (NoiseReductionMethod::Fft3dFilter, "core.fft3dfilter.FFT3DFilter("), + (NoiseReductionMethod::TTempSmooth, "core.ttmpsm.TTempSmooth("), + (NoiseReductionMethod::FluxSmoothT, "core.flux.SmoothT("), + (NoiseReductionMethod::FluxSmoothSt, "core.flux.SmoothST("), + (NoiseReductionMethod::StPresso, "haf.STPresso("), + (NoiseReductionMethod::Ctmf, "core.ctmf.CTMF("), + (NoiseReductionMethod::MClean, "_mClean("), + (NoiseReductionMethod::TemporalDegrain2, "_TemporalDegrain2("), + ]; + + for (method, call) in expected { + let mut job = create_base_job("test_149_nr_methods"); + job.qtgmc_parameters.enabled = false; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + noise_reduction: NoiseReductionParameters { + enabled: true, + method: *method, + preset: NoiseReductionPreset::Moderate, + ..NoiseReductionParameters::default() + }, + ..ProcessingPipeline::default() + }); + + let script = script_text(&job); + assert!( + script.contains(call), + "{method:?} generated a script without {call} — the pass would run \ + and do nothing" + ); + assert!( + !script.contains("{{NR_"), + "{method:?} left an unsubstituted NR placeholder in the script" + ); + } + + // And the whole set is off when the pass is. + let mut off = create_base_job("test_149_nr_off"); + off.qtgmc_parameters.enabled = false; + off.processing_pipeline = Some(ProcessingPipeline { + deinterlace: QTGMCParameters { enabled: false, ..Default::default() }, + ..ProcessingPipeline::default() + }); + let script = script_text(&off); + for (_, call) in expected { + assert!(!script.contains(call), "{call} survived a disabled pass"); + } } diff --git a/worker/tests/preview_integration_test.rs b/worker/tests/preview_integration_test.rs index a615f998..e9559fdb 100644 --- a/worker/tests/preview_integration_test.rs +++ b/worker/tests/preview_integration_test.rs @@ -79,6 +79,11 @@ fn test_preview_10bit_422_matches_source_frame() { input_width: Some(WIDTH), input_height: Some(HEIGHT), input_pixel_format: Some(PIX_FMT.to_string()), + input_color_matrix: None, + input_color_primaries: None, + input_color_transfer: None, + input_color_range: None, + burn_in_subtitle_path: None, }; let cfg_path = out_dir.join("preview_pal_job.json");