Skip to content

Advanced mode, 30 filters across 8 new passes, deps 1.9.0, and colour tagging - #71

Merged
StuartCameronCode merged 40 commits into
mainfrom
feat/advanced-mode-and-filter-curation
Aug 17, 2026
Merged

Advanced mode, 30 filters across 8 new passes, deps 1.9.0, and colour tagging#71
StuartCameronCode merged 40 commits into
mainfrom
feat/advanced-mode-and-filter-curation

Conversation

@StuartCameronCode

@StuartCameronCode StuartCameronCode commented Aug 16, 2026

Copy link
Copy Markdown
Owner

38 commits, 137 files, +21.6k/-2.5k. The pipeline goes from 12 passes to 20, and this is the branch's whole arc: VapourBox gains most of what Hybrid can do, plus the mechanism that stops that turning it into Hybrid.

Every filter here is reachable in one click, and none of them lengthens a dropdown a beginner sees.


Pre-merge state

deps-v1.9.0 is published and deps-version.json points at it. CI is green on all four platforms against the published bundle — macOS arm64, macOS x64, Windows x64, Linux x64. v0.9.13 still holds Latest; the deps tag was published with --latest=false.


The complexity lever

The load-bearing design decision, and why 30 filters can land without the UI getting worse.

  • AdvancedModeService — advanced mode was bool _advancedMode inside one panel's state: per-panel, off by default, reset on every collapse. That made it useless as a lever, so nothing could be hidden behind it aggressively enough to matter. It is now one app-wide setting gating advancedOnly sections, preset-controlled parameters and methods.
  • filter_schema_curation_test.dart — no schema may offer more than 4 methods in simple mode. Adding methods is expected; letting a dropdown grow unchecked is what this catches. Noise Reduction now sits exactly on the cap with 13 methods behind it.

What's new

8 new passes: Anti-Aliasing, Stabilize, Rotate/Flip, Film Grain, Edge Repair, Deflicker, Ghost Removal, Frame Rate.

~30 filters, in eight batches. Most were plugins already in the bundle and unreachable:

Where Filters
Noise Reduction DFTTest, FFT3DFilter, TTempSmooth, FluxSmoothT/ST, STPresso, CTMF, mClean, TemporalDegrain2, ContraSharpening
Deinterlace Bwdif — 622 fps against QTGMC Fast's 150
Chroma CCD, Cnr4, LUTDeRainbow, BiFrost, DeDot, automatic chroma alignment
Cleanup DeScratch, SpotLess, RemoveDirt, FillBorders, LGhost, deflicker (2 methods)
Colour Retinex, SmoothLevels, Auto Gain, Auto White Balance
Other aWarpSharp2, HQDeringmod, DCTFilter, Grain, Rotate/Flip, FlowFPS, custom VapourSynth, preview histogram

9 presets, named after the source — and, crucially, they now use what ships. Eight passes were reachable by no preset at all; the film-scan preset didn't stabilise, and VHS Cleanup ran without the two filters the restoration community prescribes most.

Subtitles now run at both ends of the pipeline

transcribe the source  ->  encode (burning in if asked)  ->  mux as a post-pass

Whisper previously ran only after the encode, which made burn-in structurally
impossible — the encoder needs the subtitle file while it is running. Muxing
genuinely has to stay a post-pass, because the file it goes into does not exist
until the encode finishes, so neither end can move.

All four modes verified end to end against the real model:

mode subtitle tracks sidecar
burn_in 0 no — drawn into the picture
embed 1 no
both 1 yes
srt_file 0 yes

The part that would have broken silently: transcribing the source rather
than the finished file means honouring the trim. The encoder seeks the audio
input to the trim point, so a transcript of the whole source would put every cue
early by exactly the trimmed-off head, with no error anywhere. Proved by
trimming from frame 100 of a 25 fps source — the transcript comes back with
different words and still starts at 00:00:00, which is only true if the trimmed
window was what got transcribed.

This is safe only because nothing in the pipeline retimes audio: IVTC and
frame-rate conversion change the video timeline and leave audio at its original
duration. Recorded in CLAUDE.md, because anything added later that does
retime audio breaks it.

Burn-in confirmed to actually draw rather than just re-encode: 32,643 bytes
differ from an identical subtitle-free encode, luma MSE 9.65 against chroma
0.03 — white text.

Research behind it

47 candidates probed against the real bundle by parallel read-only agents before any wiring was designed. Full analysis, plan, curation calls and preset defaults: The Hybrid Gap.

Probing beat reading repeatedly. Two candidates were already implemented and merely hidden. Two "dead" upstreams had moved to PyPI wheels. One plugin's non-SIMD path reads the wrong neighbour frame, so ARM would have rendered differently from x86. One filter is a no-op at its own upstream default; another fails every job at its default. And TIVTC — the top-rated candidate — measured bit-identical to the vivtc path already shipping.

Of 22 Useful-tier candidates, 21 deferred. That ratio is the finding, not a disappointment.

Bugs fixed that nothing else would have caught

  • Colour metadata was dropped end to end. Never read, never carried, never stamped — so every file this app wrote was untagged and read as BT.601 limited. Measured unknown, tvbt709, pc.
  • The -vf slot was single-use. ffmpeg takes the last one and silently drops the rest, so burning in subtitles would have thrown away the aspect stamp, re-breaking issue Minor feature update suggestions #50's third leg.
  • Packaging named template files individually, so every release build would have shipped without the six new vendored modules — each filter working in development and dying with a bare ModuleNotFoundError in a packaged app.
  • daa died on two platforms only, because znedi3's double-rate mode requires _FieldBased and this pipeline sets it only when the field order is known.
  • SmoothLevels was broken on Windows alone, via CRLF defeating a two-line template substitution.

Testing

  • cargo test — 189 + 181 + 148
  • flutter test --exclude-tags heavy526 passing
  • CI green on all four platforms against published deps
  • New guards: curation lint, pass-order assertions from both sides, preset assertions, attribution lint, packaging lint, and test_93 extended to vendored .py modules

Every filter was verified end to end against the real plugins, not just as generated script — several traps are invisible at parse time and only appear when a frame is requested.

Known gaps

  • Burn-in of a user-supplied subtitle file has no browse button. It works and is reachable as a path field on the Subtitles pass; transcribed subtitles need no path and are unaffected.
  • Two heavy-test parity configurations exceed toleranceTemporalDegrain2(rec=True) at 2.03 and mClean(depth>0) at 3.09 — because quantisation noise flips a selection expression. They need excluding from the nightly parity test rather than fixing.
  • app/lib/**/*.g.dart is gitignored, so a stale local copy silently drops JSON fields. Run dart run build_runner build.

Adding filters to VapourBox mostly means adding *methods to existing passes*,
not new passes — so the method dropdown is where complexity will accumulate.
This makes advanced mode the lever that keeps it in hand.

Advanced mode was `bool _advancedMode` inside the panel's State: per-panel,
defaulting off, and reset on every collapse. That made it useless as a lever,
because an expert re-flipped it constantly and so nothing could be hidden
behind it aggressively enough to matter. It is now one persisted app-wide
setting (AdvancedModeService, `showAdvancedOptions`), surfaced both by the
switch in each panel and in Settings → General.

It is provided ABOVE the MaterialApp, not inside `home`. `showDialog` pushes
onto the MaterialApp's Navigator, so a provider below it is out of scope for
every dialog route — which is exactly how the Settings switch first rendered
as Provider's red error box instead of a control.

`advancedOnly` now also applies to a MethodDefinition, so one schema can offer
a curated list to everyone and the full set on request:

  dehalo           7 -> 3   Fine Dehalo 2 is a follow-up pass, Edge Cleaner is
                            line-art niche, and both Vinverse methods were
                            already duplicated in Chroma Fixes
  noise_reduction  4 -> 3   MCDegrainSharp is a specialist chain
  crop_resize      3 -> 2   EEDI3 upscale is several times slower than NNEDI3
                            for a marginal gain

Deinterlace, Deblock, Sharpen and Color Correction are left alone — 2-3
genuinely distinct choices each, not variant sprawl.

visibleMethods() always keeps the *selected* method, advanced or not: a preset
can select one, and hiding it would both misreport the pipeline and hand
DropdownButtonFormField a value absent from its items.

Method descriptions rewritten to say when to choose one and how fast it is,
since that is the only guidance the dropdown shows.

The pass list is grouped into five stage headers, each showing how many of its
passes are on. The stages are labels over the existing pipeline order, never a
reordering. Not collapsed by default: at 13 rows that hides enabled passes and
costs more than it buys.

Guards, because each of these fails silently rather than loudly:
- pass_list_stages_test: a PassType missing from `stages` renders nothing at
  all, and reordering rows would misrepresent the pipeline.
- filter_schema_curation_test: lints all 13 shipped schemas — first method is
  never advancedOnly (it is the resolved default), something always survives
  simple mode, every method has a description, and no schema exceeds 4 methods
  in simple mode. That last one is what keeps this strategy self-enforcing.
- dynamic_filter_panel_advanced_test: the panel behaviour, including that a
  dialog route can resolve the service.

380 tests pass, up from 317.
… on conflicts

Completes the plan for absorbing filters without adding complexity: after
curating the method dropdowns, the remaining levers were relevance, presets and
pass interaction.

Relevance (pass_relevance.dart). The honest way to shorten the pass list is to
shorten it for the file in hand, and detection already returns enough to do it —
scan type, height, codec and SAR all arrive before the list is drawn. A matching
pass gets a "Suggested" badge and the reason ("source is hard telecine (3:2
pulldown)", "anamorphic source (10:11) — check pixel aspect"); one that cannot
apply says so.

It is a hint and stays a hint: nothing is reordered (row order is pipeline
order), nothing is enabled or disabled, and ScanType.unknown stays silent
because detection failed and claiming either way is worse than saying nothing.
Restraint is the load-bearing property — a badge on nine of thirteen rows is
decoration, not a recommendation — so the nine passes detection cannot judge
(dirt, scratches, grain, halos, banding, colour) return neutral for every
combination of scan type, height and codec, and the tests bound both.

Presets named after the source, because the user knows they captured a DV tape,
not that it wants SMDegrain with a chroma-bleed fix. Four added: DV Camcorder
Tape, PAL DVD / Broadcast, Anime DVD, 8mm / Super 8 Film Scan. A preset costs no
UI complexity at all, which makes this the cheapest capability in the plan.

Two silent bugs in those presets, both caught by writing the tests first:
- ProcessingPipeline()'s default DEINTERLACES, because QTGMCParameters.enabled
  defaults to true. The film-scan preset was quietly deinterlacing a progressive
  scan, i.e. softening it for nothing.
- A `preset:` enum on its own is only a label. NoiseReductionParameters(preset:
  light) leaves every threshold at its default, so "light" denoised exactly as
  hard as "moderate". Fixed by using the fromPreset factory.
Both are now documented in the "Adding a New Built-in Preset" checklist.

Interaction advice (pass_advice.dart), which is the complexity that actually
bites once the list is long: sharpening the denoiser will undo, an FPS divisor
IVTC ignores, Vinverse with deinterlacing off. Rendered through the existing
WarningBanner. Every combination it mentions still renders, so none of it is
validation — and advice only ever attaches to an ENABLED pass, since a banner on
a pass the user is not using is how advisory UI gets learned-to-ignore.

422 tests pass, up from 380.
…stion

Two follow-ups from the UI work.

Deletes nine dead files (2,071 lines). The hand-written *_settings_panel.dart
widgets were replaced by the schema-driven PassSettingsInline +
DynamicFilterPanelCompact on 2026-08-02 and have been unreferenced since: each
declares exactly one class, nothing imports any of them, and they were last
touched in March. Removing them also clears the repo's only remaining analyzer
ERROR — a non-exhaustive switch over NoiseReductionMethod in the noise reduction
panel, which had been failing to handle mcDegrainSharp in code nothing rendered.
`flutter analyze lib test` is now error-free.

Groups the preset menu, which had grown to nine built-ins in one flat list.
"How hard should it try" and "what did you capture" are different questions, so
they are now separate sections: "For Your Source" (six) and "Quality Only"
(the three tiers, which do nothing but deinterlace). The source presets come
first, because naming the source is the question a user can actually answer.

The grouping key is a `category` on the preset, declared by each factory rather
than looked up from a list of ids elsewhere — so a new preset cannot silently
land in the wrong group. It defaults to `custom`, which is correct for
user-saved presets and for presets already on disk without the field, and a
built-in that forgets to declare one files itself under the source presets
rather than vanishing. processing_preset_test asserts every built-in declares
one, and that the pre-field JSON still loads.

427 tests pass, up from 422.
… HQDeringmod

Five filters from the Hybrid gap analysis, chosen for being both widely
regarded and genuinely low risk: every plugin was already sitting in the deps
bundle unused, so none of this needs a deps release. Each fails differently from
what its pass already offered, which is the only justification for another entry
in a method dropdown.

  Noise Reduction  DFTTest       frequency-domain; the cleanest option on fine,
                                 even grain
                   FFT3DFilter   the traditional first stop for VHS luma noise
                   TTempSmooth   very gentle temporal finish; leaves motion alone
  Sharpen          aWarpSharp2   warps pixels toward edges instead of raising
                                 contrast, so it adds no halos
  Dehalo           HQDeringmod   masked ring removal; protects the edge itself

The three denoisers are advancedOnly — SMDegrain still covers the common case,
so simple mode does not grow. aWarpSharp2 and HQDeringmod are visible: each is a
different mechanism rather than a variant, and Dehalo's own name already covers
ringing. Dehalo is now at the 4-method simple-mode cap, so the next addition
there has to displace something, which is the cap working as intended.

Constraints normalised in the worker rather than left to the plugin, because
each is accepted-but-wrong rather than an error: DFTTest's tbsize is forced odd
(an even window isn't centred on the current frame), FFT3D's bt and aWarpSharp2's
blur are clamped to their implemented ranges, and TTempSmooth's mdiff is held
below thresh (equal or greater silently disables the motion protection the
parameter exists for). HQDeringmod passes nothing it wasn't given, so havsfunc's
own tuning applies.

Two things went wrong, and both are now documented in CLAUDE.md:

KNLMeansCL was in this batch and was dropped. Probing the bundle showed its
OpenCL path does not initialise everywhere — the app's own knlm-probe.json
reports false on this 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 sources are. It looked like a one-line win and was not low risk.

aWarpSharp2 first shipped with chroma=4, Avisynth's "warp chroma with the luma
mask". This VapourSynth port accepts only 0 or 1 and rejects anything else at
script evaluation, killing vspipe. Script generation passed throughout; only the
heavy end-to-end test caught it. `chroma` is now deliberately not passed at all —
asserted from both suites — matching how every other optional plugin argument
here is treated. A port does not necessarily share its Avisynth vocabulary.

Tests, both suites as required:
- 12 numbered Rust tests (test_94..test_103), including one that asserts each
  method emits only its own filter call — a missed remove_block would chain two
  denoisers silently — and one pinning the serde wire names, since a mismatch
  falls back to the default instead of erroring.
- 7 script-generation tests in the push gate, 7 full-encode tests plus a
  preview-render test in the heavy nightly set. Preview and encode are separate
  scripts and separate ffmpeg invocations, so both are asserted.

435 push-gate tests pass (up from 427), 22 heavy, 341 Rust.
Second batch from the gap analysis. Unlike the first, these are whole
CATEGORIES the app had nothing in, so they are passes rather than methods:

  Anti-Aliasing   daa, santiag   stair-stepping left by deinterlacing and
                                 upscaling
  Stabilize       Stab           shake and weave — telecine wobble, film scans,
                                 handheld footage
  Chroma Fixes    LUTDeRainbow   rainbowing (cross-luminance), the companion to
                                 the LUTDeCrawl already there

All three come from havsfunc and MVTools, already in the bundle, so again no
deps release.

Two orderings carry meaning and are asserted from both sides (Rust test_110,
Dart pass_list_stages_test): anti-aliasing runs BEFORE sharpening, because
sharpening a stair-stepped edge makes the stepping more visible rather than
less; and stabilisation runs LAST before framing, because it shifts the picture
within the frame and exposes thin empty edges that a crop can then remove (or
that its own `mirror` argument can fill).

Normalised in the worker because each is accepted-but-wrong rather than an
error: santiag's `type` is pinned to nnedi3 (havsfunc also takes eedi2 and
sangnom, NEITHER of which is bundled — naming one fails at script evaluation);
Stab's shift limits are clamped non-negative (a negative value silently
disables that axis); and LUTDeRainbow gets LUTDeCrawl's convert-down-and-restore
guard, because it shares the same 8-10 bit limit.

Three things went wrong, all now in CLAUDE.md:

STPresso was in this batch and was dropped. havsfunc implements it with
core.flux.SmoothT and the fluxsmooth plugin is not bundled — zsmooth provides
FluxSmoothT under a different namespace. That makes it effort 2, not 1.

Stab shipped briefly with a `range` argument 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, proving
only that the function exists. inspect.signature is the check that catches this
— the same lesson as aWarpSharp2's chroma, one level deeper. `mirror` is now
exposed in its place, which is genuinely more useful.

And the heavy suite spent a full cycle testing a STALE WORKER BINARY. cargo test
compiles src/ into its own test executable, so the Rust suite passed against new
code while the Dart tests exec'd an old worker/target/debug/vapourbox-worker.
The symptom is misleading: unsubstituted {{PLACEHOLDER}} in the generated script
and a bare Python SyntaxError from vspipe, which reads like a template bug.
WorkerHarness now warns loudly when the binary predates worker/src or
worker/templates.

449 push-gate tests pass (up from 435), 27 heavy, 243 Rust. The one Rust failure
in `cargo test` is pre-existing: subtitle_integration_test needs the whisper
model, which CI provisions and this machine does not have.
The first filters in this series that need a DEPS CHANGE rather than something
already sitting in the bundle. fluxsmooth adds three Noise Reduction methods:

  FluxSmoothT    averages a pixel with its neighbours in time only where they
                 bracket it in value, so motion is left alone almost for free
  FluxSmoothST   as above plus the eight spatial neighbours
  STPresso       caps how far any pixel may move, so detail survives intact —
                 dropped from the previous batch precisely because it calls
                 core.flux.SmoothT internally and the plugin was missing

Deps 1.8.0 -> 1.9.0. Beyond the usual filter wiring this needed: a build block
in all three download-deps-* scripts, an entry per platform in
deps-expected-plugins.json, the namespace in vapoursynth_integration_test's
required list, and the version/tag bump.

Two constraints shaped the choice of plugin, both now in CLAUDE.md.

Windows has no from-source build path — download-deps-windows.ps1 only fetches
published release archives — so a plugin is addable only 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 exactly
this: 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 exist only in the newer source. fluxsmooth's
newest release v2 ships win32/win64, so there is no skew.

And it is built by invoking the compiler directly on its single C file rather
than through its autotools build. Adding autoconf/automake/libtool to three CI
deps workflows for one plugin is a poor trade; one line of cc gives identical
output with no new toolchain.

Verified on macos-arm64 by building the plugin, dropping it into the bundle and
running the filters end-to-end — all three encode and preview correctly, and
STPresso works, which confirms the earlier diagnosis of why it had to be
dropped. Windows and Linux are covered only when CI builds them.

*** THIS BRANCH NOW POINTS AT A DEPS RELEASE THAT DOES NOT EXIST YET. ***
deps-version.json names deps-v1.9.0. Before merge, follow the rc flow in
CLAUDE.md: cut deps-v1.9.0-rc1 as a prerelease, run the three build-deps-*
workflows against it, point deps-version.json at the rc on this branch, verify,
then publish the final deps-v1.9.0 and repoint. Scripts/release.sh already
refuses to cut an app release while deps-version.json names an -rc tag.

452 push-gate tests pass (up from 449), 30 heavy, 248 Rust.
The code for fluxsmooth landed in 35c63fe; this records the judgement behind it,
which was only in the review discussion.

zsmooth already provides FluxSmoothT/ST, so those two methods never needed the
new plugin — they call the canonical flux namespace only because it is there.
STPresso is the only filter that actually required it, since havsfunc hardcodes
core.flux.SmoothT and cannot see zsmooth's equivalent.

So the deps release buys exactly one filter. The alternative was considered and
rejected: repoint the FluxSmooth methods at zsmooth, drop STPresso, revert
deps-version.json to 1.8.0, and the whole branch would need no deps release at
all. Keeping it was the call — STPresso is well regarded, the plugin is 34 KB,
and the release is a one-time cost.

Written down so the next person finding a 34 KB plugin behind a version bump
does not have to work this out again, and knows that removing it means removing
STPresso.
…othLevels

Batch four, all effort 1 — every plugin was already in the bundle, so no deps
change:

  Noise Reduction   CTMF          large-window median for blotches and dropouts
  Deblock           DCTFilter     ringing and mosquito noise, which the two
                                  block-edge deblockers do not touch
  NEW pass          Film Grain    AddGrain + GrainFactory3, to stop a denoised
                                  picture reading as plastic and to hide banding
  NEW pass          Rotate / Flip sideways phone footage, mirrored captures
  Color Correction  SmoothLevels  the same curve as Levels but dithered, so
                                  stretching a narrow range does not band

Four parallel read-only agents probed every candidate against the real bundle
before any wiring was written. That is the only reason this batch works — they
found seven traps, none of which documentation would have shown, and three of
them were already live in code I had written:

A QUARTER TURN CHANGES THE PIXEL FORMAT. std.Turn90 swaps the chroma subsampling
axes, so 4:2:2 becomes 4:4:0, which vspipe emits as C440 and ffmpeg rejects
outright; 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 now captures the source format and converts back.

SAR MUST BE INVERTED ON A TURN, IN TWO PLACES — the ffmpeg-side declaration and
{{SOURCE_SAR}} in the square-pixel fitting path. I had done the first and missed
the second.

TURNING INTERLACED MATERIAL IS UNRECOVERABLE. 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. Now warned about in pass_advice.

CTMF REJECTS 9-BIT, and 9-bit is reachable — pixel_format.rs rounds odd depths
up through it. Guarded. Its memsize is also pinned to 16 MiB: at the plugin
default, 16-bit radius 3 measures 0.79 fps against 42, for identical output.

DCTFILTER ACCEPTS NaN AND BLACKENS THE FRAME — its range check is
`f < 0.0 || f > 1.0`, both false for NaN. The worker builds its eight factors
and guarantees they are finite. Its mapping is also separable
(factors[u] * factors[v]), not the max(u,v) of the Avisynth filter of that name.

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.

SMOOTHLEVELS' DEFAULT CANNOT RUN: havsfunc calls core.f3kdb.Deband and this
bundle ships neo_f3kdb. useDB is pinned False. Its levels are also read in the
clip's own range (six arguments scaled in-script), and it crashes outright when
input_low > 0 and 1/gamma is not an integer, so the worker drops the black point
for that combination and the schema says so.

TemporalDegrain2 was requested and is NOT included. Its upstream repo declares
NO LICENCE, so vendoring ~4,300 lines of it is a legal decision rather than a
technical one and needs your call. It is also not effort 1: five modules not
one, postFFT=5 aborts the process rather than raising, postFFT=4 is broken two
ways, extraSharp is a NameError at exactly 16-bit, and depth-dependent limits
make outputStage=0 a complete no-op at >=12-bit while 73% of the degraining is
silently lost at 16-bit. bm3d is never reached, so it needs no deps addition.
Full findings in the task list and CLAUDE.md.

470 push-gate tests pass (up from 452), 36 heavy, 293 Rust.
…ow detail

Two more effort-2 filters, joining the ALREADY-PENDING deps 1.9.0 rather than
forcing another bump — that tag is still unpublished, so it was free to grow.

  Chroma Fixes      Bifrost   temporal rainbow / dot-crawl removal. Where the
                              LUTDeRainbow added earlier decides within a frame,
                              this compares across frames, so it catches
                              rainbowing that shimmers as the picture moves
  Color Correction  Retinex   lifts detail out of the shadows of underexposed
                              footage, by comparing each area to its
                              surroundings rather than raising the black level

Screening candidates against the Windows-binary rule is now the first step of
any effort-2 work, and it disqualifies about half of them. Of the ten checked,
Bifrost v3.0, Retinex r4, MiniDeen v2, MSmooth v1.1 and Descale r8 ship a
Windows binary on their newest release; DeDot v3, FillBorders v4, EEDI2 r7.1,
EdgeFixer r3 and TDeintMod r10.1 do not, and pinning every platform back to an
older tag to match would cost features that only exist in the newer source.

Both chosen plugins link nothing beyond system libraries, so they are safe on
all five bundles.

Two integration constraints, both measured against the bundle rather than read:

BIFROST IS 8-BIT ONLY — "Only constant format 8 bit integer YUV input
supported", confirmed failing at 10/12/16-bit and 4:2:2. It gets DeScratch's
convert-down-and-restore guard. Low impact in practice, since 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, 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 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.

One build note worth keeping: bifrost includes <vapoursynth/VapourSynth4.h>
rather than <VapourSynth4.h>, so -I"$VS_INC_DIR" is not enough. The scripts
stage a small include root with a vapoursynth/ subdirectory and pass its parent.
Like fluxsmooth it is a single C file compiled directly, so no autotools are
added to CI; retinex is an ordinary meson build resolving headers via
pkg-config, which build_plugin already points at the from-source install.

deps-v1.9.0 still needs cutting before merge — now with three plugins rather
than one. The rc flow in CLAUDE.md is unchanged.

472 push-gate tests pass (up from 470), 38 heavy, 299 Rust.
ci-test.yml and nightly.yml could only ever download the bundle named by
deps-version.json from a published release, so a deps change could not be
tested until it was published -- and publishing an untested bundle is the
thing you were trying to avoid. The documented way out was a prerelease rc
tag, which works but is publicly visible.

Both workflows now take an optional deps_run_id workflow_dispatch input.
When set, .github/scripts/fetch-deps-bundle.sh pulls the zip from that
build-deps-* run's artifact instead of from a release: private to the repo,
no tag, and nothing to clean up afterwards.

Draft releases were the obvious alternative and are the wrong one -- not
for the reason CLAUDE.md gave (CI does authenticate, so the "neither CI nor
the app can fetch them" claim was only ever true of the app), but because
draft assets need push access, and getting there means widening the token
to contents: write on a workflow that runs against pull requests. The
artifact route buys the same privacy for a read-only scope, so both
workflows declare an explicit read-only permissions block -- the repo's
restricted default grants contents+packages read and nothing else, so
actions: read has to be asked for or the fetch 404s.

The rc flow is kept and documented for the one case artifacts cannot
cover: an installed app has no token, so testing the real first-run
download path still needs a published prerelease.

The script prints only the zip path on stdout and hard-fails with the
artifact listing when nothing matches, rather than letting an empty path
surface later as a confusing unzip error.
One CI dispatch runs all four platform jobs, but macOS, Windows and Linux
are three separate build-deps-* workflows with three separate run IDs, so a
single run ID left three of the four jobs unable to find an artifact. The
input now takes a comma-separated list and each job takes the first ID that
holds an artifact for its own platform, which also means order doesn't
matter and the same list works for every job.

A run that built a different platform has no matching artifact and gh exits
non-zero for it -- expected here, so the loop keeps going rather than
treating the first miss as fatal.
All three were wired from the macOS script's vocabulary, and Linux does not
share it, so every one of them failed there -- and retinex failed on macOS
too. Caught by the deps-expected-plugins packaging guard, which is exactly
what it is for: three missing .so files rather than a bundle that installs
and then dies at job time with "No attribute with the name flux exists".

Linux:

  - fluxsmooth and bifrost referenced $VS_INC_DIR, which is the macOS
    script's name for it; Linux calls it $VS_INCLUDE_DIR and the unset
    variable reached cc as a bare -I ("missing path after -I"). Both now
    assert the variable is set, so a future rename fails saying which
    plugin and which variable rather than emitting a confusing cc error.

  - retinex was copied over without $PLUGIN_BUILD_ENV. macOS has no such
    prefix, but on Linux it carries PKG_CONFIG_PATH, without which meson
    cannot see VapourSynth at all.

macOS:

  - retinex includes <vapoursynth/VapourSynth.h>, so pkg-config finding
    VapourSynth is not sufficient -- the include root needs a CHILD
    directory named vapoursynth. Linux has kept a symlink farm for this for
    years; macOS had none, so bifrost staged a private include tree and
    retinex, which resolves headers through pkg-config and cannot be handed
    one, had no way to work at all.

    macOS now mirrors Linux's farm, so a single -I"$VS_INC_DIR" satisfies
    both include styles. bifrost drops its private staging, and retinex
    needs no build-command change. The farm also warns if no API3 header
    turns up to link: R78 installs only the API4 set and both scripts top
    up VapourSynth.h from the source tree, so retinex silently depends on
    that top-up having happened.
They were written into the arm64 side of the plugin arch split, so the x64
job never reached them and the packaging guard failed on all three missing
dylibs -- the same symptom as the Linux failure, a different cause, and
invisible on arm64 where everything passed.

x64 takes pre-built binaries for most plugins, but Stefan-Olt ships none of
these three, so they have to come from source on both arches. They now sit
after the split, which is where zsmooth already lives for the same reason.

Nothing else is needed to make that work on x64: MACOSX_DEPLOYMENT_TARGET is
exported near the top of the script, so the two direct cc calls inherit the
12.0 floor and pass the minos guard (issue #39), and build_plugin sets
PKG_CONFIG_PATH from VS_PC_DIR itself, which is resolved before the split.

arm64 is unaffected -- it built all three successfully at the previous
commit and the move only changes which branch they are reached from.
…Windows

The SmoothLevels path suppresses the plain std.Levels call with

    replace("core.std.Levels(\n    clip,\n)", "clip")

which is a literal two-line pattern. A Windows checkout gives the .vpy
templates CRLF endings, so the pattern never matched there and BOTH the
plain Levels call and SmoothLevels survived into the generated script --
silently, because a missed replace leaves valid-looking Python rather than
an error. Caught by test_132's negative assertion, on Windows only.

Fixed at the root: load_template_by_name normalizes CRLF to LF on read, so
the whole class goes away rather than this one pattern. It is currently the
only multi-line pattern in the generator, but it was not obviously the last.
Python does not care about the endings.

templates_load_with_lf_endings_only asserts both templates load without CR
on every platform, so the guard fails everywhere rather than only on the
platform that has the problem.

Also records in CLAUDE.md what these three plugins actually cost: four red
deps builds, each a different cause, all from copying a block between
download scripts that do not share a vocabulary.
znedi3 refuses a clip carrying _FieldBased and fails the whole job with
"Failed to retrieve frame 0 with error: znedi3: _FieldBased". This pipeline
sets that property whenever a field order is known -- including with
deinterlacing OFF, where it is deliberately kept so a later resize resamples
chroma field-aware -- so "anti-alias with deinterlacing off" was a hard
failure on every bundle whose interpolator is znedi3.

It passed on macOS arm64 (nnedi3 via havsfunc patch 6) and on Windows
(whose prebuilt znedi3 accepts it), and failed on macOS x64 and Linux x64.
That is the worst shape for a bug: the same job either worked or died
depending on the user's machine.

Anti-aliasing operates on whole frames, so the pass now clears the mark and
restores it immediately afterwards -- restoring matters, because a later
resize still needs it, which is the entire reason it is set when
deinterlacing is off. Both templates, kept in step, or a preview would fail
where the encode succeeded.

Found by the nightly heavy suite. The script-only gate cannot see this
class at all: it asserted haf.daa( appears, which it did, in a script that
could not run. test_139/test_140 assert the guard's shape and that a source
with no detected field order still generates exactly what it did before.
…not rejects it

My previous commit had the cause backwards and did not fix anything: it
cleared _FieldBased only when a field order was KNOWN, while the failing
case is the opposite one. Measured against the bundled znedi3 rather than
inferred this time:

  field=1  no property: OK     =0: OK   =2: OK
  field=3  no property: ERROR  =0: OK   =2: OK

havsfunc's daa calls nnedi3 with field=3, and the pipeline only sets
_FieldBased when a field order is known — so an ordinary source with none
(the heavy suite's fixture, and most real sources) reached znedi3 with the
property absent and failed the whole job with "znedi3: _FieldBased".

macOS arm64 uses nnedi3 (patch 6) and Windows ships a prebuilt znedi3;
both tolerate the absence. macOS x64 and Linux x64 build znedi3 from source
and do not. Hence the same job working or dying by machine.

The pass now always marks the clip instead of depending on an upstream pass
having done it: 0 once deinterlacing has run (that output IS progressive)
or when nothing was detected, and the detected order otherwise, which daa
needs to pair fields correctly. Stamping the source's original order back
on after deinterlacing would tell every later filter the progressive output
is still interlaced, so that distinction is asserted rather than assumed.

test_139 covers the case that actually failed (no detected order), test_140
that deinterlacing yields 0, test_141 that BFF is not flattened.
A terse plugin error naming a property says the property is involved, not
in which direction -- reading "znedi3: _FieldBased" as a rejection produced
a fix that changed nothing, when znedi3's double-rate mode in fact requires
the property. Records the probe table, the fix, and that two platforms
passing was not evidence: arm64 (nnedi3) and Windows (prebuilt znedi3) both
tolerated the absence while the two from-source bundles did not.
@StuartCameronCode
StuartCameronCode force-pushed the feat/advanced-mode-and-filter-curation branch from 1b15aab to 4ee577a Compare August 17, 2026 01:33
… found

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. The
plan itself, the simple-vs-advanced calls and the preset defaults live in the
artifact; this commit records what the probing invalidated in here.

Probing changed the verdict on nine of the twenty-five. The four durable
lessons, each of which would have cost a cycle:

- A forum consensus about AviSynth is not evidence about this pipeline. TIVTC
  was top-rated on "TIVTC is definitely better for complex DVDs", repeated
  across VideoHelp. Against the repo's own hard_telecine_test.avi it is
  bit-identical to the vivtc path already shipping — 0.0000/255 after matching
  and after decimation. Measure the premise before pricing the work.
- Two candidates were already implemented. EDI upscaling is complete in
  pipeline_template.vpy and measured within 0.055 px of a reference resample; it
  is invisible only because its schema section is advancedOnly/expanded:false.
  GrayWorld is not a second filter — in YUV it reduces exactly to AutoWhite.
- Upstream defaults can be no-ops or hard failures. zsmooth.Cnr4 defaults
  scenechange=True and needs frame props this pipeline never sets, so a naive
  call fails 100% of jobs on every platform. Checkmate is the mirror image: at
  its own default tthr2=0 it measured 0.000 difference on every pattern tested.
- ReduceFlicker's non-SIMD path reads the wrong neighbour frame and aarch64 has
  no other path, so the ARM bundles would have rendered differently from x86 —
  the znedi3 _FieldBased shape again.

Corrections to rules this file states:

- The Windows-binary rule is intact but three of its conclusions were stale,
  because upstream moved distribution channel rather than stopping. Bwdif r5 and
  DeDot v3 both publish PyPI wheels for all five targets, and EdgeFixer r3 does
  ship a Windows asset. The check is now two commands, and the PyPI one was
  missing; the akarin block in download-deps-windows.ps1 is already a working
  wheel fetcher to copy. A wheel's macOS tag is a floor, not a promise —
  dedot's macosx_15_0_x86_64 still fails the 12.0 STRICT_MIN_OS guard.
- test_93 scans only the two .vpy templates, not vendored .py modules. It has
  never mattered because spotless.py uses no Expr, but TemporalDegrain2 and
  mClean both do, so vendoring either would silently take the 21x scalar
  interpreter path on ARM.
- 8 of the 16 shipped passes are used by no built-in preset at all. Stabilize
  shipped specifically for film scans and the 8mm preset does not use it; CCD is
  the filter VideoHelp prescribes most for tape and is in no preset, including
  VHS Cleanup. Audit the presets whenever a pass ships.

New section: colour metadata is dropped end to end and is NOT yet fixed. Matrix,
primaries, transfer and range are never read (ffprobe returns them; the parser
discards them), never carried on either VideoJob, and never stamped —
build_ffmpeg_args emits no -color* flag at all, so every output is untagged and
read as BT.601 limited. Same shape and same fix site as the SAR bug. A
SetFrameProps pass would be inert: the Y4M pipe strips frame properties too.
The preview has a companion bug — it hardcodes in_range=tv with no
in_color_matrix while the "before" thumbnail does see the real tags, so a
709-tagged source shows a hue shift no filter caused.
Second seven-agent probe round, this time over every remaining Useful-tier
candidate from the gap analysis. One promotion out of twenty-two — MVTools
FlowFPS as a Frame Rate pass — which is the expected shape for a tier defined as
"second answers", not a disappointment. The plan, curation calls and preset
defaults are in the artifact; this records the transferable lessons.

- 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 after lostfunc.DeSpot and GrayWorld.
  Those arguments are better: spot MAE 10.49 -> 9.33 at 318 -> 424 fps, so a
  three-line change to shipped code is worth more than the filter was.
- An automatic filter must be tested on the material it should ignore.
  AutoDeblock's detection is inverted: on blocked MPEG-2 it altered the picture
  less than it altered clean footage, and on grainy-but-unblocked content it
  fired strong on 99% of frames. Any "auto" filter gets a three-way test, and
  the clean cases matter more than the damaged one.
- Check candidates against the content this app's own presets create. FillDrops
  cannot distinguish a dropped frame from a held animation cel — both are
  bit-exact — so it destroyed 39 of 80 held frames at every threshold. We ship
  Anime DVD and DVD IVTC presets where duplicates are normal.
- Prefer a crash you can catch. vs-placebo constructs its node with no exception
  when Vulkan is absent, then segfaults on the first frame, taking vspipe down
  for both job and preview with nothing to detect. Worse than KNLMeansCL, which
  at least raises.
- FrameMap::Retime already exists and no pass emits one. FlowFPS's output count
  matches it exactly across 35 combinations where BlockFPS is off by one in 14,
  so choosing FlowFPS makes existing code correct as written.

Also records a probe finding that did NOT survive verification, because the
correction is the useful part: an agent reported SMDegrain as a near no-op at
the app's defaults. Its 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 is well-behaved across 27 combinations. No bug —
but bare SMDegrain on uniform synthetic noise finds perfect motion matches
everywhere and gates everything out, so validate MC denoisers on real footage.
Colour metadata was dropped at all three stages. It was never read — ffprobe
returns color_space/color_transfer/color_primaries/color_range and the parser
took width, height, pix_fmt, SAR and field order and discarded them. It was
never carried on either VideoJob. And it was never stamped: build_ffmpeg_args
emitted no -color* flag of any kind.

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 end to end through the worker on a bt709 +
full-range clip:

  source          bt709, pc
  output before   unknown, tv
  output after    bt709, pc

This is the same bug as the SAR one, with the same cause and the same fix site.
A SetFrameProps pass in the script would be inert — the Y4M pipe strips frame
properties exactly as it strips SAR, verified — so the tags are output-stream
flags on the encoder, emitted immediately after the setsar block.

Values are validated rather than forwarded. ColorMetadata::from_raw drops
anything not on FFmpeg's accepted list, on the same principle as parse_ratio:
ffprobe reports the literal string "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, so a source declaring only a matrix gets
only -colorspace, and an untagged source stays untagged rather than being
guessed at.

Also fixes 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 showed a hue shift no filter had caused, and a full-range
source was range-stretched twice.

build_ffmpeg_args_for_test duplicates build_ffmpeg_args rather than calling it,
which is how the SAR block came to be missing from it. The colour block is
mirrored there with a comment saying why.

Tests: 8 unit tests on the mapping, 3 on the emitted arguments, 4 Dart tests on
the ffprobe side, and a heavy end-to-end round trip in
integration_chroma_subsampling_test.dart — the only level that can prove ffmpeg
honoured the flags, since these are encoder arguments a generated-script test
cannot see.
@StuartCameronCode StuartCameronCode changed the title Advanced mode, curated method dropdowns, and 18 new filters Advanced mode, 18 new filters across 4 new passes, deps 1.9.0, and a colour-tagging fix Aug 17, 2026
Eight of the sixteen shipped passes were used by no built-in preset at all.
Every batch added capability that nothing enabled, so a user picking a preset
named after their source got none of the work done for that source.

The four that matter, all existing passes and no new code:

- 8mm / Super 8 Film Scan gains Stabilize. This pass shipped specifically for
  film scans two batches ago and no preset used it, while gate weave is the
  first thing anyone notices on a cine scan. The most obviously incomplete
  preset in the set until now.
- VHS Cleanup gains Chroma Denoise (CCD) and Sharpen (LSFmod) — the two filters
  the restoration community prescribes most for tape, both shipping unused while
  this preset ran SMDegrain and DeHalo_alpha alone. Chroma noise on VHS is a
  different failure from luma grain, so the denoiser did not cover it. LSFmod at
  80 rather than the default 100: it runs after a denoise, and sharpening a
  denoised capture hard is how tape ends up looking artificial. LSFmod is the
  right sharpener here because it is built not to add halos, which matters when
  the dehalo pass has just removed some.
- DV Camcorder Tape gains Chroma Denoise, gentler than VHS. DV chroma is
  misaligned more than it is dirty, and the light luma denoise deliberately
  leaves chroma alone.
- Anime DVD gains LUTDeRainbow, which shipped for composite-mastered discs
  rainbowing on fine line art and was used by nothing.

Restraint is the point: no preset gains everything available to it, and the
three quality tiers are unchanged, because a preset is a claim about a source.

processing_preset_test.dart asserts each of these against what the preset's
name promises — that file is where two silent preset bugs were already caught.
Also bounds the tape sharpening strength rather than pinning it, since the
failure mode is oversharpening after a denoise.

Verified end to end: the rewired VHS Cleanup pipeline (QTGMC + SMDegrain + CCD +
DeHalo_alpha + LSFmod) encodes cleanly.
…e EDI upscaling

Two Phase 1 items from the plan, both already in the bundle.

Cnr4 (zsmooth) joins CCD in the Chroma Denoise pass. They are complementary
rather than alternatives: CCD smooths blotches that sit still, Cnr4 settles
colour that swims or shimmers between frames. Measured at 1015 fps, so it is
effectively free. Visible rather than advanced — two methods is not a crowded
dropdown, and the distinction is one a user can act on.

Two traps, both measured against the bundled plugin and both fatal:

- 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. misc.SCDetect now runs in front, which is the
  better fix than scenechange=False because it is also what stops the filter
  smearing chroma across a cut.
- It rejects 4:1:1, and 4:1:1 is NTSC DV, which pipe_source maps natively. The
  template converts up to 4:2:2 and restores the source format afterwards, the
  same shape as the DeScratch bit-depth guard.

The plugin takes sense and str per plane. Exposing three numbers for what reads
as one idea is how a pass stops being usable, so one slider drives all three and
preserves the plugin's own luma:chroma ratio.

EDI upscaling was already fully implemented — rpow2 doubling with per-plane
centroid correction, measured within 0.055 px of a reference resample — and
invisible, because the whole section was advancedOnly with expanded:false. Split
into a visible "Upscale" section with the three controls needed to reach the
feature, and an advanced "Upscale tuning" section for the ten network knobs.
This is the highest-value curation change in the plan and cost no new code.

Not touched: crop_resize declares methods[] that nothing reads, the real choice
being the upscaleMethod parameter. Removing it means teaching the curation lint
to tolerate a schema with no methods, which delivers nothing to users.

Verified end to end against the real plugin, both methods, plus generation tests
from Rust and Dart. 482 Dart tests, 145 Rust integration tests.
… white balance

Three more Phase 1 items, none of which needs a plugin.

ContraSharpening is a checkbox inside Noise Reduction rather than a Sharpen
method, and it has to be: it takes the pre- AND post-denoise clip, so it cannot
sit in the linear pass chain. The template captures the input at the top of the
pass and applies the restore at the bottom. Phrased in the UI as the outcome —
"restore detail after denoising" — because unlike a sharpener it cannot invent
detail that was not there, so it is safe to leave on when denoising hard.

Note the placeholder is NR_POST_CONTRASHARP, not NR_CONTRASHARP: SMDegrain
already has its own contrasharp argument using that name.

Auto levels and auto white balance are ~40 lines of PlaneStats and need no
plugin at all. GrayWorld is not a third thing — in YUV the grey-world assumption
reduces exactly to shifting the chroma plane means onto neutral, which is what
auto white balance does, so it ships as one control.

Two things the probing changed about the naive implementation:

- Auto levels measures its stats on a heavily downscaled copy, not the full
  frame. Min and max of a full frame are set by single outlier pixels, so one
  specular highlight 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 it usable on real footage.
- PlaneStatsMin/Max come back in the clip's own depth and are used raw; only the
  user's targets, which are 8-bit UI units, are scaled to the format. Getting
  that backwards is the bug class that already shipped twice. PlaneStatsAverage
  is normalised at every depth and must not be scaled at all — the auto white
  balance path relies on that.

Auto white balance guards num_planes < 3: PlaneStats(plane=1) errors on GRAY.
Its Expr goes through the _expr() helper, so ARM takes the akarin JIT rather
than the per-pixel interpreter.

Verified end to end against the real plugins: ContraSharpening over SMDegrain,
and auto levels + auto white balance together.
Two independent items. Both were promoted on measurement rather than on the
strength of the idea.

FRAME RATE (MVTools FlowFPS) — the one promotion out of 22 Useful-tier
candidates. Scoped deliberately to standards conversion, not smoothing:
interpolating a master to a higher rate invents frames that were never
photographed and makes the file a worse record than the tape. Converting an
already-converted PAL/NTSC tape is the opposite case, where the damage is in the
source and doing nothing means judder or a 4% speed error. So it offers named
target rates rather than a free number, ships off by default, and is never
described as making motion "smooth".

FlowFPS rather than BlockFPS, and the reason is arithmetic rather than quality.
FrameMap::Retime already existed in the worker and no pass emitted one. Measured
over 35 combinations, FlowFPS produces floor(n_in * ratio) — exactly what
Retime::output_count computes — while BlockFPS produces floor((n_in-1)*r)+1 and
is off by one in 14 of them, which would desynchronise the progress total and
the preview index. Choosing FlowFPS makes existing code correct as written.

Three things worth knowing:

- The ratio is reduced before it reaches FrameMap. 25 -> 29.97 is 1200/1001, not
  the plugin's 30000/1001; Retime multiplies a frame count by that pair, so an
  unreduced ratio overflows on a long clip.
- The source rate is carried on the parameters, because the pipeline cannot see
  the job and a wrong fixed ratio is worse than none — same reason inputSar is
  carried rather than looked up. Absent, it reports Identity.
- mvtools rejects 4:1:1 at Super, and 4:1:1 is NTSC DV, precisely the tape an
  NTSC->PAL conversion targets. Guarded up to 4:2:2 and restored.

The pass runs genuinely last of the video passes, asserted from both sides.
pass_relevance stays silent on it: a 25 fps source is not evidence anyone wants
29.97, so this is a delivery decision detection cannot make.

HISTOGRAM — app-side, computed from the preview PNG the app already holds. No
plugin, no deps release, no worker round trip, and it updates as a slider moves
rather than costing a re-render. Chosen over the VapourSynth histogram plugin,
which has no LICENSE file at all.

Forum advice on levels is literally "watch the histogram", and the app had
colour controls with no way to see what they were doing — now more so, having
just gained automatic levels and automatic white balance that a user needs to be
able to sanity-check.

Binning is a pure function over RGBA bytes so it can be tested without a widget:
13 tests including all-black, all-white, a known 4-pixel image asserting exact
luma bins, and a 256-level ramp. Rec.709 luma in 8.8 fixed point with weights
summing to exactly 256, so neutral grey lands in its own bin with no drift.
Scaling uses the interior peak, ignoring bins 0 and 255 — a letterboxed frame
piles enormous counts into bin 0 and scaling to that flattens the real shape to
nothing.
…he bundle

Five plugins into the still-unpublished deps 1.9.0, unblocking the Phase 3
filters. All five verified locally on macOS arm64 — built or downloaded through
the actual new script text, loading, and producing a frame on a real clip.

Two of these were previously rejected and the rejections had expired, which is
the finding worth keeping: the Windows-binary rule is intact but upstream
projects have been changing distribution channel rather than stopping.

- Bwdif: GitHub assets stop at r4.1 (2021), but r5 moved to PyPI and
  vapoursynth-bwdif 5.1 ships wheels for all five targets at minos 10.15/11.0 —
  under both macOS floors, so unlike akarin it costs the Intel bundle nothing.
- DeDot: v2/v3 ship no GitHub assets; vapoursynth_dedot 3.0 ships wheels for all
  five. BUT both macOS wheels are minos 15.0 against the Intel floor of 12.0
  under STRICT_MIN_OS, so macos-x64 builds from source instead. A wheel's macOS
  tag is a floor, not a promise.
- FillBorders stays pinned to v2, the last tag with binaries, because the v2/v4
  difference vanishes at even border widths — which is the only thing the UI
  will offer.
- RemoveDirt and LGhost take Stefan-Olt prebuilts where they exist and build
  from source on linux-aarch64.

Three platform-specific traps handled, all from the four-check table:

- macOS: the whole section sits after `fi  # end plugin arch split`, so x64
  reaches it. A block inside the arm64 branch is invisible on Apple Silicon and
  simply never runs on Intel.
- Linux: $VS_INCLUDE_DIR, not macOS's $VS_INC_DIR, and meson needs the
  $PLUGIN_BUILD_ENV prefix. Both asserted with ${VAR:?} so a rename fails naming
  the plugin.
- Windows: RemoveDirt-1.1.7z ships x64\ and x64_Clang\ copies of the same DLL
  name, and the existing filter matched both, making the winner copy-order
  dependent. The extraction loops now take an optional Prefer regex, pinned to
  x64_Clang.

Attribution taken from upstream and quoted, per issue #72. FillBorders has no
copyright line anywhere in its repo and DeDot names no author for the AviSynth
original — NOTICES records both gaps rather than inventing a name. Bwdif and
LGhost vendor VCL2, so both join the existing Vector Class Library entry.

Not verifiable here: the Windows script (no pwsh on this machine, reviewed by
hand), Linux x86_64 wheel load, and the macOS x64 minos guard on a real Intel
runner. Those are CI's to confirm.
"Is there a faster way to deinterlace than QTGMC?" is a perennial question and
the app had no answer: every tier was a cheaper QTGMC. Bwdif measures 622 fps
against QTGMC Fast's 150 on the same clip — 4-5x — at most of the quality.

Visible rather than advanced, and it takes the deinterlace schema to exactly the
4-method simple-mode cap. A speed tier is a goal a user can act on, not a
variant of something already offered.

Three measured facts shaped the wiring:

- 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, so unlike almost every recent addition it needs no guard
  in either template.
- No _FieldBased sensitivity: field=1 and field=3 both work with the property
  absent, 0, 1 or 2. It does not repeat the znedi3 double-rate trap that cost
  two nightly cycles.
- It accepts an external interpolator itself, which is why Yadifmod is not a
  separate method. Measured, Bwdif+nnedi3 (0.524) beats Yadifmod+nnedi3 (0.532)
  at the same cost, so the whole of that candidate collapses into one advanced
  checkbox here. EEDI3 as the interpolator is 11x slower for no gain and is
  deliberately not offered.

`field` is required, not optional, and its parity half comes from the same TFF
value QTGMC uses — deriving it separately is how the preview and the render come
to disagree about field order. Its FrameMap mirrors QTGMC's: Fanout{2} at double
rate, Identity otherwise, but radius 1 rather than 3, because it reads one
neighbour each side rather than a temporal window.

Verified end to end on a real interlaced fixture: both interpolators, and double
rate correctly producing 42 frames from 21 at twice the rate.
…r dot crawl

Two Phase 3 filters, both using plugins added to deps 1.9.0 in the previous
commit.

REMOVEDIRT ships as a second SpotLess method, and the case for it is speed
rather than quality. Measured against the shipped SpotLess on 80 frames with
synthetic single-frame spots:

  SpotLess           spot MAE  9.21   clean 0.222   143 fps
  RemoveDirt         spot MAE 16.9    clean 0.278   908 fps
  RemoveDirtMC       spot MAE  9.99   clean 0.222   202 fps

So the motion-compensated variant is deliberately NOT offered: it lands on the
same quality point as SpotLess and runs slower, adding nothing. The plain form
gives 6.3x the throughput for about 60% of the removal, which is what makes it
usable on a long capture where SpotLess would take hours.

Two deviations from the canonical chain, both measured:

- No trailing RemoveGrain(17). 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 at identical
  spot removal and 36% more speed. Exposed as an off-by-default option instead.
- The Clense family comes from zsmooth, not rgvs: rgvs.Clense raises at 9-14 bit
  and float, zsmooth's equivalents work at every depth this app can produce.

The plugin rejects 9-bit and 4:1:1, and both are reachable here — pixel_format
rounds odd depths up through 9, and 4:1:1 is NTSC DV — so both are guarded and
the source format restored. Verified at 8/9/10/16-bit, 4:2:0/4:2:2/4:1:1 and
GRAY. Its noise thresholds are normalised internally (bit-identical output at
8/10/12/16-bit), so unlike most of this codebase they must not be depth-scaled.

DEDOT joins Chroma Fixes as a toggle beside LUTDeCrawl and LUTDeRainbow, and it
is complementary rather than a replacement — measured on a line-alternating
crawl LUTDeCrawl reached 0.00 residual where DeDot left 11.69, while on a
phase-inverting checkerboard DeDot reached 0.24/0.00 on both planes where
LUTDeCrawl left chroma untouched. Different geometries, so both earn their
place. It is 8-bit only, so it gets the DeScratch convert-and-restore guard with
error diffusion on the way back up.

Both verified end to end, and the classic SpotLess path re-verified unchanged.
…Degrain2

Five new modules in worker/templates/, not yet wired into the pipeline. Also
extends test_93 to scan them, and fixes a struct literal I broke in 2062db9.

DEFLICKER — two methods, and the plugin is deliberately not used. ReduceFlicker's
non-SIMD path reads prevp[0]/[2] where its SIMD path correctly reads
nextp[0]/[2], and the SIMD block is #if defined(__SSE2__), so aarch64 has only
the buggy path — the ARM bundles would have rendered differently from x86, the
same shape as the znedi3 trap. Transcribed to Expr instead and validated against
a numpy model of the C semantics: max |diff| 0 levels across all six
strength/aggressive combinations.

The global method is the more valuable one and it beat its own brief: 83.5% of
injected flicker removed against a 75% target. Both a gain and an offset term
are fitted because neither alone is good on all fault types — gain-only manages
46% on additive flicker, offset-only 46% on multiplicative, the affine fit is
1.45-1.62 levels residual on all three.

AUTOCHROMAFIX — reimplemented rather than copied; the reference is unlicensed.
Recovers an injected whole-pixel chroma shift exactly: 42/42 at ±1px and 24/24
at ±2px across 4:2:0 and 4:2:2 at 8/10/12/16-bit. 0.07s once with a reference
frame, then 1020 fps.

Two findings from getting there. Resampling each candidate shift is what breaks
whole-pixel accuracy — a shift landing on a whole chroma sample resamples
nothing while a half-sample one is maximally softened, and that ripple drags the
peak 0.25px. Scoring integer lags as crop offsets, with no interpolation
anywhere, fixed it. And signed gradient correlation has a content-dependent
sign, so the directional gradient is rectified; std.Prewitt's combined magnitude
is too flat to use.

It also 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. The VHS-ish fixture is exactly that case and correctly gets no
correction.

MCLEAN and TEMPORALDEGRAIN2 are vendored from Selur/VapoursynthScriptsInHybrid,
which has no LICENSE file and no headers. Each vendored file records the
upstream repo, the file and the line range it came from, states that plainly,
and carries the upstream docstring attributions verbatim. No licence or
copyright holder is invented anywhere.

Four TemporalDegrain2 guards, all measured against the bundled plugins:
postFFT is clamped to 0-3 because postFFT=5 aborts the process rather than
raising (a zero block size reaches FFT3DFilter); grainLevel is clamped because
the autotune tables are length 6; extraSharp's NameError at exactly 16-bit is
fixed at its source in MinBlur; and the depth-dependent limits are scaled from
clip.format, which takes 8-vs-16-bit parity from 2.85 to 0.45 on the FFT sigma
and from 0.77 to 0.22 on the mvtools limit. Without that last one the filter
lands at 2.005 against a 2.0 test tolerance.

mClean pins three parameters: deband is repointed to neo_f3kdb and clamped to
0-1 (the level-2 path reaches an unbundled plugin), icalc is forced True (the
float path needs absent mvsf), and outbits is forced to the source depth so the
downstream chroma conversion is not handed a format it did not expect.

test_93 now scans every worker/templates/*.py, not just the two .vpy templates.
That gap never mattered because spotless.py uses no Expr — these modules do, and
without the check they would silently take the 21x scalar interpreter path on
ARM. Verified it fails on all three violation shapes.
Three new passes, taking the pipeline from 17 to 20. Each fills a category the
app had nothing in.

DEFLICKER answers the complaint every cine-film thread is about, and which the
shipped "8mm / Super 8 Film Scan" preset could not touch. Two methods because
they address different faults: the global one fits a gain and an offset per
frame against a windowed neighbourhood average and removes 83.5% of injected
flicker; the local one damps per-region oscillation a whole-frame statistic
cannot represent. Global is the default — cine flicker is a whole-frame
exposure fault.

Both terms are fitted in the global method because neither alone works
everywhere: gain-only manages 46% on additive flicker, offset-only 46% on
multiplicative, the affine fit 1.45-1.62 levels residual on all three.

EDGE REPAIR rebuilds the dirty rows tape captures leave at the frame border,
which until now could only be cropped away — throwing picture away with them.
FillBorders won a measured three-way: its error is constant at 1.29 across three
damage models, the signature of a filter that discards the border and rebuilds
from the interior, so the result cannot depend on how bad the damage is.
EdgeFixer is luma-only and would leave 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, not cosmetic: the bundle pins
FillBorders v2 as the newest tag with binaries, and v2 is bit-identical to v4 at
even widths, differing only at odd ones where it leaves subsampled chroma
unrepaired. Crop already steps by 2 for the same reason. interlaced=-1 reads
_FieldBased so a known field order is handled without a second control.

GHOST REMOVAL cancels the displaced echo RF and cable distribution leave behind
— a distinct, frequently reported tape complaint with nothing addressing it.
LGhost was the cleanest plugin probed for this work: no format limits found at
any depth or subsampling, and a clean _FieldBased matrix. Its natural interface
is a repeating (mode, shift, intensity) triple, unlike any control here, so
simple mode gets a short strength preset and the triple editor sits behind
advanced. The worker drops any entry the plugin would reject — mode 0 and
intensity 0 are hard errors at script evaluation, and the three arrays must stay
the same length, so filtering has to be simultaneous.

Pipeline order is asserted from both sides. Edge repair precedes every spatial
filter and the resize; ghost removal precedes the denoise so the echo is not
averaged into the picture; deflicker follows deinterlacing, because field-doubled
frames break every temporal comparison it makes.

`opt` is deliberately not exposed on LGhost: measured on arm64 every value gives
byte-identical output, so it is inert here and a footgun on x86.

All three verified end to end, including an invalid ghost being dropped and odd
edge widths rounding down.
…gnment

The last of the Phase 2 filters, now connected to their vendored modules.

mCLEAN takes Noise Reduction's final simple-mode slot, and that is a deliberate
call the curation lint now pins. It is the only candidate in the whole gap
analysis that is a *goal* — denoise, restore detail, restore grain, behind one
control — rather than another mechanism, which is exactly the distinction the
lint exists to enforce. The schema is now at the 4-method cap and cannot grow
again without something moving behind advanced.

TEMPORALDEGRAIN2 is the most-requested filter in the analysis and stays behind
advanced mode anyway: 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. The worker clamps postFFT to 0-3 in addition to the
module's own clamp — 4 and 5 abort the process rather than raising, so neither
layer should be the only one.

AUTOMATIC CHROMA ALIGNMENT joins Chroma Fixes as the counterpart to the manual
shift sliders that were already there. It measures the misalignment instead of
asking the user to guess it, recovers an injected whole-pixel shift exactly
across 4:2:0/4:2:2 at 8-16 bit, and costs 0.07s with a reference frame. It
refuses to invent a shift when chroma carries no measurable edge structure,
which matters because a soft VHS chroma channel is exactly that case.

One bug caught by test_115, worth recording because the shape recurs. I added
the two new remove_block calls by appending them to an existing line — but that
line does not appear in the arm where its own block is the active one, so
STPresso alone never stripped the new blocks and left unsubstituted
placeholders. A missed remove_block chains two denoisers silently: valid
VapourSynth, twice the runtime, not what was asked for. Every arm is now
checked programmatically rather than by pattern-matching on a sibling.
The second half of the preset work. Phase 0 wired the passes that already
shipped; this wires the ones added since.

- Fast now uses Bwdif. Measured 622 fps against QTGMC Fast's 150 — until now
  this tier was only a lower QTGMC preset, which is not what the name promises.
  The QTGMC preset is left in place so switching method in the UI lands
  somewhere sensible.
- VHS Cleanup gains Edge Repair at two rows all round, and DeDot. Dirty edge
  rows are near-universal on tape and cropping them throws picture away; dot
  crawl along sharp colour edges is the signature composite artefact and
  nothing in the preset touched it.
- 8mm / Super 8 Film Scan gains Deflicker. With Stabilize from the earlier
  commit it now addresses both faults cine film actually has — gate weave and
  brightness pulsing — where before it addressed neither.
- DV Camcorder Tape gains automatic chroma alignment. DV's problem is chroma
  above all, and measuring the shift beats asking the user to guess it.
- Anime DVD gains DeDot alongside LUTDeRainbow. Complementary rather than
  redundant: measured, each reaches a crawl geometry the other leaves alone.

Every one asserted in processing_preset_test.dart against what the preset's
name promises, including that the edge-repair widths are even — odd widths are
the only case where the pinned FillBorders v2 differs from v4.
Phase 4, and with it every filter in the plan is implemented.

BURN-IN draws a supplied .srt or .ass into the picture using libass, which is
compiled into all four bundled ffmpeg builds. Deliberately scoped to a
user-supplied file: Whisper transcription runs AFTER the encode, so its output
does not exist when the encoder needs it. Moving transcription before the encode
is a larger change and a separate piece of work — the two burn-in subtitle modes
fall back to writing the sidecar rather than silently doing nothing.

This required fixing a latent bug first. The -vf slot was single-use: the code
appended either setsar or setdar, and ffmpeg takes the LAST -vf and silently
drops earlier ones. Appending a subtitles filter would therefore have thrown
away the aspect stamp — breaking the third leg of issue #50 in a way nothing
would have caught. Filters now accumulate into one chain, verified: a 16:11
anamorphic source with burnt-in subtitles comes out still tagged 16:11.

The path is per job, not an encoding setting. A path held in settings would
apply one file's subtitles to every video in a batch.

CUSTOM VAPOURSYNTH is the escape hatch, on the same footing as the Custom FFmpeg
Arguments that already existed — arbitrary local execution is not a new risk in
a process that already loads arbitrary plugins.

The real hazard is the frame count, and it is guarded rather than trusted. A
snippet calling Trim or SelectEvery changes the true 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 now captures len(clip) before the snippet and refuses
afterwards if it changed, naming both numbers and pointing at the trim controls.
It also asserts clip is still a VideoNode, since the commonest mistake is
forgetting to assign the result back.

Verified: a benign snippet runs; core.std.Trim is refused with "changed the
frame count from 11 to 3".

Also caught by schema_converter_integration_test, which asserts enum values
match schema options — the two new subtitle modes had to be added to the schema,
not just the enum. That test earned its place.
Six lessons from implementing the plan, each of which cost time or would have
shipped a silent bug:

- The -vf slot was single-use and ffmpeg silently drops all but the last one,
  so adding subtitle burn-in would have thrown away the aspect stamp.
- Adding a remove_block by pattern-matching on a sibling line misses the arm
  where that block is the active one.
- Whisper burn-in is a different feature, not a harder one: transcription runs
  after the encode.
- The hazard in custom user code is the frame count, not the arbitrary
  execution, because it corrupts progress and preview silently.
- FrameMap::Retime already existed unused; check before adding a variant, and
  reduce the ratio before handing it over.
- A wheel's macOS tag is a floor, not a promise.
test_144 asserts each new pass emits its own call and leaves no unsubstituted
placeholder of its own — prefix-scoped rather than a bare {{ search, because
the template's docstring documents the placeholder syntax with literal
{{PARAMETER_NAME}} examples.

test_145 pins that edge repair rounds every width down to even. That is not
cosmetic: 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_146 pins the custom-VapourSynth frame-count bracket, in both directions —
present when a snippet is supplied, and the whole block removed when it is not.
Seventeen filters became twenty-one. Adds the four new passes — Edge Repair,
Ghost Removal, Deflicker and Frame Rate — and notes Bwdif as the fast option on
the Deinterlace row.

Also rewrites the cleanup summary, which listed what the filters address rather
than what a user has: it now names dirty capture edges, aerial ghosting and
cine-film flicker, and says the presets turn on what a source needs, since that
is now true where before it largely was not.
…en list

All three packaging scripts copied VapourSynth templates by explicit filename —
pipe_source.py and spotless.py — so the six modules added in this branch would
have been absent from every packaged build on every platform.

The failure mode is the bad one. In development the debug worker searches
upward, finds worker/templates/, and every new filter works perfectly. In a
release build the same filter dies inside vspipe with a bare ModuleNotFoundError
and no indication that packaging is the cause. That is the class of bug
CLAUDE.md already warns about for release bundles, arriving by a new route.

Now a *.py glob on all three, so it covers whatever exists and whatever is added
later. packaging_test.dart asserts each script either globs or names every
module present, and I verified it fails when the glob is reverted rather than
passing vacuously.
@StuartCameronCode StuartCameronCode changed the title Advanced mode, 18 new filters across 4 new passes, deps 1.9.0, and a colour-tagging fix Advanced mode, 30 filters across 8 new passes, deps 1.9.0, and colour tagging Aug 17, 2026
Whisper ran only after the encode, which made burn-in structurally 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. Burn-in therefore only
worked for a file the user already had.

Transcription now runs before the encode, and the output mode is applied
afterwards:

  1. transcribe the source  ->  .srt
  2. encode, drawing it into the picture if the mode burns in
  3. post-pass: multiplex the .srt into the finished file if the mode asks for
     a subtitle track, and keep or delete the sidecar accordingly

Muxing has to be a post-pass because the file it goes into does not exist until
the encode finishes. It reuses the existing embed path.

THE THING THAT WOULD HAVE BROKEN SILENTLY: transcribing the source rather than
the output means the trim has to be honoured. 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, and
nothing would report an error. extract_audio_range now takes the same window
the encode uses.

Nothing else in the pipeline retimes audio, which is what makes this safe: IVTC
and frame-rate conversion change the video timeline only and leave the audio at
its original duration, so trim is the sole correction needed. That is worth
knowing before adding any pass that does retime audio.

Verified end to end against the real Whisper model, all four modes:

  burn_in    0 subtitle tracks, no sidecar   (drawn into the picture)
  embed      1 subtitle track,  no sidecar
  both       1 subtitle track,  sidecar kept
  srt_file   0 subtitle tracks, sidecar kept

Burn-in confirmed to actually draw: 32,643 bytes differ from the same encode
without subtitles, luma MSE 9.65 against chroma ~0.03 — white text, not a
re-encode artefact.

Sync confirmed by trimming from frame 100 of a 25 fps source: the transcript
contains different words ("door slams" rather than "dramatic music") and still
starts at 00:00:00, which is only true if the trimmed window was transcribed.

SubtitleOutput gains burns_in/muxes/keeps_srt_file rather than matching on the
enum in three places, with a test asserting every mode does at least one of
them — a mode that does none would silently produce nothing at all.
The transcribe -> encode -> mux order is load-bearing: burn-in needs the
transcript to exist before the encode starts, and muxing needs the file the
encode produces. Neither end can move.

The trap worth recording is that transcribing the source means honouring the
trim, or every cue lands early by the trimmed-off head with no error anywhere.
That is only safe because nothing in this pipeline retimes audio — noted, so
that anything which later does knows it breaks this.
@StuartCameronCode
StuartCameronCode force-pushed the feat/advanced-mode-and-filter-curation branch from 8873a79 to a3f3e85 Compare August 17, 2026 11:10
…d gaps

Four things, one of them a real bug found by the second.

**mClean and TemporalDegrain2 never ran.** The noise-reduction dispatch is one
match arm per method, each removing the eleven blocks it isn't. A blanket edit
that added the two new remove_block calls to every arm also added them to the
two new arms, so each deleted its own template block before enabling it. The
pass was on, the script contained no denoiser, and the encode produced a
passthrough — no error anywhere, and neither method had a script-generation
test to notice.

test_149 now walks the whole enum and asserts each method's own call appears;
a test per method only ever covers the method you thought to write one for.
The two also get push-gate coverage in integration_filter_parameters_test,
which goes through the worker binary and so catches a Dart JsonValue drifting
from the Rust serde name — something the Rust test cannot.

**Parity coverage for this branch's new surface.** The high-bit-depth suite
predated the branch: no mClean, no TemporalDegrain2, and none of Deflicker,
Ghost Removal, Edge Repair or Frame Rate. All six are added, and all six land
at 0.28-0.74 mean abs diff against the ~0.6 rounding floor, so none needs a
raised tolerance. They also clear the 12/16-bit depth-rejection matrix, so no
template guards were needed.

This corrects the flagged issue rather than implementing it: TemporalDegrain2's
`rec` and mClean's `depth` — the two configurations measured at 2.03 and 3.09 —
are not exposed by either template, so nothing could reach them. Both figures
are recorded in the vendored modules instead, so the knobs aren't added later
without that being a decision.

**A browse button for burn-in subtitles**, as a reusable WidgetType.filepicker
with ui.fileExtensions rather than a special case: it's the schema system's
first filesystem concept. Stateful with a controller, because the plain
textfield's initialValue is read once and would never show a picked path.

**A stale .g.dart guard.** The generated files are gitignored and CI
regenerates before every test run, so CI can never reproduce the failure that
bites locally: a dropped key reaches the worker as a serde default and the pass
silently runs with the wrong settings. The check compares declared fields
against generated code, not mtimes — build_runner is incremental and leaves an
unchanged output alone, which fails an mtime check on eight models straight
after a clean build.

Also corrects the burnInPath description, which still said transcribed
subtitles cannot be burnt in; they can, since transcription moved ahead of the
encode.
@StuartCameronCode
StuartCameronCode merged commit d9b3af9 into main Aug 17, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant