From ab1f880ee3f9fa7315942dcd2a1cc0ef0a4493a0 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Wed, 19 Aug 2026 17:00:35 +1000 Subject: [PATCH] feat(ui): show the VapourSynth calls each pass makes, in advanced mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Experts coming from Hybrid, StaxRip or AviSynth recognise FixChromaBleedingMod or MSRCP instantly, and could not tell what a VapourBox pass was doing from its labels. Each pass now prints its calls. Advanced mode only, and only there: a plugin name is not actionable for someone who has not asked for that level of detail, and advanced mode is the app-wide lever for exactly that judgement. Most of this was a display gap rather than a data one. Every method already declared its own `function` and the Dart model already parsed it — it had never been rendered anywhere. For those passes the readout simply names the selected method. Composite passes are the real work. Colour Correction was the reported case and is the sharpest one: it has no method dropdown at all, so nothing in the UI named any of the six things it can run. Such a pass now declares an `implementation` list of its whole repertoire, each entry optionally gated by an `activeWhen`, and the readout emphasises what is running while still showing what is available. Seeing the inactive calls is the point — it says what the pass could do, not only what it is doing now. activeWhen reuses the visibleWhen matcher rather than adding a second one, so the two cannot drift. It takes more than one key, and that is load-bearing: applyLevels chooses *between* std.Levels and haf.SmoothLevels, so each is gated on the pair {applyLevels, smoothLevels} and precisely one is ever emphasised. Gating both on applyLevels alone would claim the pass runs two levels operations. Chroma Fixes has the same shape, where automatic and manual chroma alignment are both core.resize.Spline36 and automatic supersedes manual. Four placeholder function names are fixed rather than displayed: spotless -> spotless.SpotLess, qtgmc_internal -> haf.QTGMC (NoiseProcess), whisper -> whisper-cli, and havsfunc.ChangeFPS -> haf.ChangeFPS, a prefix used nowhere else. "custom" stays as bookkeeping on the three composite passes but is never shown, asserted. Crop & Resize's scaling entry reads core.resize.* rather than a concrete kernel: the kernel is a seven-way enum already visible as its own dropdown directly above, so listing all seven greyed out would be noise. It is the one entry that is not a literal callable name. Four schema lints per filter, all of which fail silently otherwise: a blank function, a "custom" method with no implementation list, an activeWhen naming a parameter that does not exist (the call would read as permanently inactive, the same failure as a visibleWhen naming a missing parameter), and a list where nothing can ever be active. --- CLAUDE.md | 42 ++++ app/assets/filters/core/chroma_fixes.json | 71 +++++- app/assets/filters/core/color_correction.json | 55 ++++- app/assets/filters/core/crop_resize.json | 44 +++- app/assets/filters/core/frame_rate.json | 4 +- app/assets/filters/core/noise_reduction.json | 22 +- app/assets/filters/core/spotless.json | 8 +- app/assets/filters/core/subtitles.json | 6 +- app/lib/models/filter_schema.dart | 42 ++++ .../views/settings/dynamic_filter_panel.dart | 99 +++++++++ .../filter_implementation_readout_test.dart | 207 ++++++++++++++++++ app/test/filter_schema_curation_test.dart | 68 ++++++ docs/FILTER_SCHEMA.md | 62 ++++++ 13 files changed, 696 insertions(+), 34 deletions(-) create mode 100644 app/test/filter_implementation_readout_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index ed5a833..b00333d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1242,6 +1242,48 @@ Title Case / sentence case of parameter labels across schemas — now more visib side by side under headings, but a cosmetic sweep of several hundred strings that should be its own change. +### Every pass says which VapourSynth calls it makes + +Added 2026-08-19 on request: experts coming from Hybrid, StaxRip or AviSynth +recognise `FixChromaBleedingMod` or `MSRCP` instantly and could not tell what a +VapourBox pass was doing from its labels. Each pass now prints its calls in +**advanced mode**, and only there — a plugin name is not actionable for someone +who has not asked for that level, and advanced mode is the lever for exactly +that judgement. Field reference: **[docs/FILTER_SCHEMA.md](docs/FILTER_SCHEMA.md)**. + +Two shapes, because filters come in two shapes: + +- **One call per method** — the readout prints the selected method's own + `function`. That data already existed on every method and had **never been + rendered anywhere**; this was mostly a display gap, not a data one. +- **Composite passes** — Colour Correction, Chroma Fixes and Crop & Resize are + not one call. They declare an `implementation` list of everything they can + invoke, each entry optionally gated by an `activeWhen` that reuses the + `visibleWhen` matcher, and the readout shows the **whole repertoire with the + running calls emphasised**. Seeing the inactive ones is the point: it says + what the pass could do, not only what it is doing. + +> **Colour Correction was the reported case and is the sharpest one.** It has no +> method dropdown at all (the inert one was removed in the panel audit), so +> before this there was nothing anywhere in the UI naming any of the six things +> it can run — `adjust.Tweak`, `std.Levels`, `haf.SmoothLevels`, +> `retinex.MSRCP`, and the two `PlaneStats`-driven automatic passes. + +> **`activeWhen` takes more than one key, and that is load-bearing.** +> `applyLevels` chooses *between* `std.Levels` and `haf.SmoothLevels`, so each is +> gated on the pair `{applyLevels, smoothLevels}` and precisely one is ever +> emphasised. Gating both on `applyLevels` alone would claim the pass runs two +> levels operations. The same shape covers Chroma Fixes' automatic-supersedes- +> manual alignment, where both entries are `core.resize.Spline36` and only the +> `role` text tells them apart. + +Three rules linted by `filter_schema_curation_test.dart`: no method leaves +`function` blank; a method declaring `"function": "custom"` must be explained by +an `implementation` list (`custom` is bookkeeping and is never displayed); and +every `activeWhen` key must name a real parameter, or the call reads as +permanently inactive — the same silent failure as a `visibleWhen` naming a +missing parameter. + ### Presets are the other way a hidden setting arrives A preset is the main route by which settings appear without anyone touching a diff --git a/app/assets/filters/core/chroma_fixes.json b/app/assets/filters/core/chroma_fixes.json index 543b17b..cae75a9 100644 --- a/app/assets/filters/core/chroma_fixes.json +++ b/app/assets/filters/core/chroma_fixes.json @@ -4,7 +4,7 @@ "version": "1.1.0", "name": "Chroma Fixes", "description": "Fix colour alignment, bleeding, rainbows and dot crawl", - "longDescription": "Repairs colour-specific damage from analog and composite video. It covers five separate problems, each with its own switch, in the order they appear below:\n\n**Alignment** \u2014 the colour sits sideways or up/down from the picture it belongs to. Correct it automatically or by hand; these are alternatives, not a pair.\n\n**Bleeding** \u2014 colour smears past the edge it belongs to, most obviously on strong reds.\n\n**Dot crawl** \u2014 dots crawling along sharp colour edges.\n\n**Rainbowing** \u2014 shimmering colour bands over fine detail.\n\n**Chroma combing** \u2014 leftover comb teeth in the colour planes.\n\nDot crawl and rainbowing each offer a second removal that compares neighbouring frames rather than working inside one. They reach different patterns, so they are worth combining rather than choosing between.\n\nTurn on only what you can actually see in the preview \u2014 every one of these costs some colour detail.", + "longDescription": "Repairs colour-specific damage from analog and composite video. It covers five separate problems, each with its own switch, in the order they appear below:\n\n**Alignment** — the colour sits sideways or up/down from the picture it belongs to. Correct it automatically or by hand; these are alternatives, not a pair.\n\n**Bleeding** — colour smears past the edge it belongs to, most obviously on strong reds.\n\n**Dot crawl** — dots crawling along sharp colour edges.\n\n**Rainbowing** — shimmering colour bands over fine detail.\n\n**Chroma combing** — leftover comb teeth in the colour planes.\n\nDot crawl and rainbowing each offer a second removal that compares neighbouring frames rather than working inside one. They reach different patterns, so they are worth combining rather than choosing between.\n\nTurn on only what you can actually see in the preview — every one of these costs some colour detail.", "category": "cleanup", "icon": "palette", "order": 8, @@ -69,7 +69,7 @@ "default": false, "ui": { "label": "Correct colour alignment automatically", - "description": "Measures how far the colour has slipped from the picture and shifts it back, so you do not have to guess it on a slider. If it cannot measure the source reliably it leaves the picture alone rather than guessing. This replaces the manual sliders \u2014 with it on, they are not used.", + "description": "Measures how far the colour has slipped from the picture and shifts it back, so you do not have to guess it on a slider. If it cannot measure the source reliably it leaves the picture alone rather than guessing. This replaces the manual sliders — with it on, they are not used.", "widget": "checkbox" } }, @@ -124,7 +124,7 @@ "default": false, "ui": { "label": "Correct colour alignment by hand (Y/C delay)", - "description": "Shift the colour back into place yourself. Use this when the automatic measurement finds nothing \u2014 very soft VHS colour is sometimes not measurable.", + "description": "Shift the colour back into place yourself. Use this when the automatic measurement finds nothing — very soft VHS colour is sometimes not measurable.", "widget": "checkbox", "visibleWhen": { "applyAutoChroma": false @@ -229,7 +229,7 @@ }, "ui": { "label": "Horizontal offset", - "description": "Whole-pixel colour offset the fix works from. Leave it alone unless the colour is misaligned as well as bleeding \u2014 the alignment controls above are the better tool for that.", + "description": "Whole-pixel colour offset the fix works from. Leave it alone unless the colour is misaligned as well as bleeding — the alignment controls above are the better tool for that.", "widget": "slider", "visibleWhen": { "applyChromaBleedingFix": true @@ -391,7 +391,7 @@ "default": false, "ui": { "label": "Remove rainbow shimmer", - "description": "Shimmering colour bands over fine detail on composite captures \u2014 the companion to dot crawl, which sits along edges instead. Decided within each frame.", + "description": "Shimmering colour bands over fine detail on composite captures — the companion to dot crawl, which sits along edges instead. Decided within each frame.", "widget": "checkbox" } }, @@ -630,5 +630,64 @@ "import havsfunc as haf" ], "generate": "custom" - } + }, + "implementation": [ + { + "function": "core.resize.Spline36", + "role": "chroma alignment, by a measured shift", + "activeWhen": { + "applyAutoChroma": true + } + }, + { + "function": "core.resize.Spline36", + "role": "chroma alignment, by hand", + "activeWhen": { + "applyChromaShift": true, + "applyAutoChroma": false + } + }, + { + "function": "haf.FixChromaBleedingMod", + "role": "colour bleeding past edges", + "activeWhen": { + "applyChromaBleedingFix": true + } + }, + { + "function": "haf.LUTDeCrawl", + "role": "dot crawl", + "activeWhen": { + "applyDeCrawl": true + } + }, + { + "function": "core.dedot.Dedot", + "role": "dot crawl, temporal", + "activeWhen": { + "applyDedot": true + } + }, + { + "function": "haf.LUTDeRainbow", + "role": "rainbowing", + "activeWhen": { + "applyDeRainbow": true + } + }, + { + "function": "core.bifrost.Bifrost", + "role": "rainbowing that only shows in motion", + "activeWhen": { + "applyBifrost": true + } + }, + { + "function": "haf.Vinverse", + "role": "residual chroma combing", + "activeWhen": { + "applyVinverse": true + } + } + ] } diff --git a/app/assets/filters/core/color_correction.json b/app/assets/filters/core/color_correction.json index 20b2cd6..df88c97 100644 --- a/app/assets/filters/core/color_correction.json +++ b/app/assets/filters/core/color_correction.json @@ -4,7 +4,7 @@ "version": "1.1.0", "name": "Color Correction", "description": "Adjust brightness, contrast, saturation, levels and white balance", - "longDescription": "Adjusts brightness, contrast, saturation, the black and white levels, and the colour cast.\n\nUse it to rescue washed-out or crushed transfers, to fix a capture made at the wrong levels (limited 16-235 read as full 0-255, or the reverse), or to lift colour from faded film. Small moves go a long way \u2014 check the before/after preview rather than judging by numbers.\n\nLevels and white balance can each be set **automatically** or **by hand**, and the two sit together in the same group. The automatic pass measures the picture and runs first; anything you then set by hand is applied on top of the corrected picture, not the original.", + "longDescription": "Adjusts brightness, contrast, saturation, the black and white levels, and the colour cast.\n\nUse it to rescue washed-out or crushed transfers, to fix a capture made at the wrong levels (limited 16-235 read as full 0-255, or the reverse), or to lift colour from faded film. Small moves go a long way — check the before/after preview rather than judging by numbers.\n\nLevels and white balance can each be set **automatically** or **by hand**, and the two sit together in the same group. The automatic pass measures the picture and runs first; anything you then set by hand is applied on top of the corrected picture, not the original.", "category": "color", "icon": "tune", "order": 7, @@ -191,7 +191,7 @@ "default": false, "ui": { "label": "Set levels by hand", - "description": "Map the input range onto the output range yourself, and adjust gamma. With automatic levels on, only gamma is left to set \u2014 the automatic pass has already placed black and white." + "description": "Map the input range onto the output range yourself, and adjust gamma. With automatic levels on, only gamma is left to set — the automatic pass has already placed black and white." } }, "gamma": { @@ -317,7 +317,7 @@ "step": 0.05, "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.", + "description": "Lower this to keep some of the original cast — useful when the cast is meant to be there, like firelight.", "widget": "slider", "precision": 2, "visibleWhen": { @@ -352,7 +352,7 @@ }, "ui": { "label": "Tint", - "description": "Green/magenta. Negative shifts toward green, positive toward magenta \u2014 the axis VHS and telecine casts usually sit on.", + "description": "Green/magenta. Negative shifts toward green, positive toward magenta — the axis VHS and telecine casts usually sit on.", "widget": "slider", "precision": 0 } @@ -362,7 +362,7 @@ "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." + "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 — colour is untouched." } }, "shadowSigma": { @@ -451,5 +451,48 @@ "import havsfunc as haf" ], "generate": "custom" - } + }, + "implementation": [ + { + "function": "core.std.Levels", + "role": "automatic levels, measured per frame with core.std.PlaneStats", + "activeWhen": { + "applyAutoLevels": true + } + }, + { + "function": "core.std.Expr", + "role": "automatic white balance, measured per frame with core.std.PlaneStats", + "activeWhen": { + "applyAutoWhiteBalance": true + } + }, + { + "function": "adjust.Tweak", + "role": "brightness, contrast, saturation, hue" + }, + { + "function": "core.std.Levels", + "role": "levels", + "activeWhen": { + "applyLevels": true, + "smoothLevels": false + } + }, + { + "function": "haf.SmoothLevels", + "role": "levels, dithered and limited as it maps", + "activeWhen": { + "applyLevels": true, + "smoothLevels": true + } + }, + { + "function": "core.retinex.MSRCP", + "role": "shadow detail, on the luma plane only", + "activeWhen": { + "applyShadowDetail": true + } + } + ] } diff --git a/app/assets/filters/core/crop_resize.json b/app/assets/filters/core/crop_resize.json index 355aa1e..6d71007 100644 --- a/app/assets/filters/core/crop_resize.json +++ b/app/assets/filters/core/crop_resize.json @@ -4,7 +4,7 @@ "version": "1.2.0", "name": "Crop & Resize", "description": "Crop borders and resize or upscale video", - "longDescription": "Trims unwanted borders and changes the output resolution. Cropping happens first, then resizing.\n\nUse crop to cut the head-switching noise along the bottom of VHS captures and the black overscan edges of broadcast material \u2014 otherwise the encoder spends bitrate on them. Use resize for a target resolution, or the NNEDI3 upscaler for a much better 2x/4x enlargement than a plain kernel. Keep crop values even so they stay aligned with chroma subsampling.", + "longDescription": "Trims unwanted borders and changes the output resolution. Cropping happens first, then resizing.\n\nUse crop to cut the head-switching noise along the bottom of VHS captures and the black overscan edges of broadcast material — otherwise the encoder spends bitrate on them. Use resize for a target resolution, or the NNEDI3 upscaler for a much better 2x/4x enlargement than a plain kernel. Keep crop values even so they stay aligned with chroma subsampling.", "category": "transform", "icon": "crop", "order": 9, @@ -752,5 +752,45 @@ "codeTemplate": { "imports": [], "generate": "custom" - } + }, + "implementation": [ + { + "function": "core.std.Crop", + "role": "cropping", + "activeWhen": { + "cropEnabled": true + } + }, + { + "function": "core.resize.*", + "role": "scaling, with the kernel selected above", + "activeWhen": { + "resizeEnabled": true + } + }, + { + "function": "core.znedi3.nnedi3", + "role": "edge-directed doubling (core.nnedi3.nnedi3 on ARM, which has NEON kernels)", + "activeWhen": { + "useIntegerUpscale": true, + "upscaleMethod": "nnedi3Rpow2" + } + }, + { + "function": "core.eedi3m.EEDI3", + "role": "edge-directed doubling", + "activeWhen": { + "useIntegerUpscale": true, + "upscaleMethod": "eedi3Rpow2" + } + }, + { + "function": "core.resize.Spline36", + "role": "integer doubling, without edge direction", + "activeWhen": { + "useIntegerUpscale": true, + "upscaleMethod": "spline36" + } + } + ] } diff --git a/app/assets/filters/core/frame_rate.json b/app/assets/filters/core/frame_rate.json index d8cf3af..feb0c25 100644 --- a/app/assets/filters/core/frame_rate.json +++ b/app/assets/filters/core/frame_rate.json @@ -4,7 +4,7 @@ "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.", + "longDescription": "Changes how many frames per second the output runs at. This is for standards conversion — 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 — the honest choice for an archival master.\n\nThis pass runs last, after everything else.", "category": "enhancement", "methods": [ { @@ -21,7 +21,7 @@ "id": "duplicate", "name": "Repeat frames", "description": "Repeats or drops whole frames. Invents nothing, but motion judders.", - "function": "havsfunc.ChangeFPS", + "function": "haf.ChangeFPS", "parameters": [] } ], diff --git a/app/assets/filters/core/noise_reduction.json b/app/assets/filters/core/noise_reduction.json index b3d7400..a994225 100644 --- a/app/assets/filters/core/noise_reduction.json +++ b/app/assets/filters/core/noise_reduction.json @@ -4,7 +4,7 @@ "version": "1.1.0", "name": "Noise Reduction", "description": "Remove video noise and grain", - "longDescription": "Reduces grain, analog noise and colour speckle while trying to keep real detail. Temporal denoisers average across neighbouring frames and are best for steady grain; spatial ones smooth within a single frame and cope better with motion.\n\nUse it on VHS, Hi8 and other noisy captures, and before sharpening or encoding \u2014 clean footage compresses far better at the same bitrate. Too much strength smears motion and flattens texture into plastic.", + "longDescription": "Reduces grain, analog noise and colour speckle while trying to keep real detail. Temporal denoisers average across neighbouring frames and are best for steady grain; spatial ones smooth within a single frame and cope better with motion.\n\nUse it on VHS, Hi8 and other noisy captures, and before sharpening or encoding — clean footage compresses far better at the same bitrate. Too much strength smears motion and flattens texture into plastic.", "category": "cleanup", "icon": "grain", "order": 2, @@ -36,7 +36,7 @@ { "id": "mc_temporal_denoise", "name": "MCTemporalDenoise", - "description": "For heavy noise SMDegrain can't settle \u2014 VHS and off-air captures. Slowest option", + "description": "For heavy noise SMDegrain can't settle — VHS and off-air captures. Slowest option", "function": "haf.MCTemporalDenoise", "parameters": [ "mcTemporalProfile", @@ -47,7 +47,7 @@ { "id": "mcdegrainsharp", "name": "MCDegrainSharp", - "description": "Did\u00e9e's motion-compensated degrain: sharpens where the motion match is good, blurs where it is poor. Also written MDegrainSharpen", + "description": "Didée's motion-compensated degrain: sharpens where the motion match is good, blurs where it is poor. Also written MDegrainSharpen", "function": "core.mv.Degrain", "parameters": [ "mcdsFrames", @@ -63,13 +63,13 @@ "id": "qtgmc_builtin", "name": "QTGMC Built-in", "description": "Reuses the motion search QTGMC already ran, so it is nearly free. Only useful when deinterlacing is on", - "function": "qtgmc_internal", + "function": "haf.QTGMC (NoiseProcess)", "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", + "description": "Frequency-domain denoiser. The cleanest option on fine, even grain — separates it from detail better than the motion-compensated methods. Moderate speed", "function": "core.dfttest.DFTTest", "parameters": [ "dfttestSigma", @@ -93,7 +93,7 @@ { "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", + "description": "Very gentle temporal smoother — leaves anything that moves alone. A finishing pass for residual shimmer, not a primary denoiser", "function": "core.ttmpsm.TTempSmooth", "parameters": [ "ttempMaxr", @@ -106,7 +106,7 @@ { "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", + "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 — a common first pass on tape", "function": "core.flux.SmoothT", "parameters": [ "fluxTemporalThreshold" @@ -150,7 +150,7 @@ { "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", + "description": "Denoise, then put detail and grain back so the picture does not look plastic — the one to try first if you are not sure", "function": "mclean.mClean", "parameters": [ "mcleanStrength", @@ -163,7 +163,7 @@ { "id": "temporal_degrain2", "name": "TemporalDegrain2", - "description": "The heavyweight \u2014 slow, and the most capable thing here for badly noisy analogue captures", + "description": "The heavyweight — slow, and the most capable thing here for badly noisy analogue captures", "advancedOnly": true, "function": "temporaldegrain2.TemporalDegrain2", "parameters": [ @@ -607,7 +607,7 @@ "step": 0.1, "ui": { "label": "Strength", - "description": "Higher removes more noise. FFT3D is aggressive \u2014 small changes matter", + "description": "Higher removes more noise. FFT3D is aggressive — small changes matter", "widget": "slider", "precision": 1, "visibleWhen": { @@ -814,7 +814,7 @@ "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", + "description": "Half the window size. Wider removes bigger blemishes and more detail with them — and costs almost nothing extra", "widget": "slider", "visibleWhen": { "method": [ diff --git a/app/assets/filters/core/spotless.json b/app/assets/filters/core/spotless.json index 57efdb7..0377589 100644 --- a/app/assets/filters/core/spotless.json +++ b/app/assets/filters/core/spotless.json @@ -4,7 +4,7 @@ "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 \u2014 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 — if you see smearing or ghosting on movement, ease off the strength.", "category": "cleanup", "icon": "auto_fix_high", "order": 3, @@ -19,7 +19,7 @@ "id": "spotless", "name": "SpotLess", "description": "Motion-compensated temporal median for spot/dirt removal (live-action only)", - "function": "custom", + "function": "spotless.SpotLess", "parameters": [ "chroma", "rec", @@ -31,7 +31,7 @@ { "id": "removeDirt", "name": "RemoveDirt (fast)", - "description": "About six times faster for most of the benefit \u2014 the one to use on a long capture", + "description": "About six times faster for most of the benefit — the one to use on a long capture", "function": "removedirt.RestoreMotionBlocks", "parameters": [ "rdNoise", @@ -228,7 +228,7 @@ "default": false, "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.", + "description": "The traditional final smoothing step. Off by default — measured, it alone triples the damage to clean parts of the picture.", "widget": "checkbox", "visibleWhen": { "method": [ diff --git a/app/assets/filters/core/subtitles.json b/app/assets/filters/core/subtitles.json index 51180ad..08f6659 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 \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.", + "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.", "category": "enhancement", "icon": "subtitles", "order": 100, @@ -13,7 +13,7 @@ "id": "whisper", "name": "Whisper", "description": "OpenAI Whisper speech-to-text", - "function": "whisper", + "function": "whisper-cli", "parameters": [ "model", "output", @@ -95,7 +95,7 @@ "default": "", "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.", + "description": "Path to a subtitle file you already have. Leave this empty to burn in what Whisper transcribes — 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", diff --git a/app/lib/models/filter_schema.dart b/app/lib/models/filter_schema.dart index 5a1b3f5..0cbe379 100644 --- a/app/lib/models/filter_schema.dart +++ b/app/lib/models/filter_schema.dart @@ -212,6 +212,42 @@ class MethodDefinition { Map toJson() => _$MethodDefinitionToJson(this); } +/// One VapourSynth call a filter makes, for the "what is this actually doing" +/// readout in advanced mode. +/// +/// Most filters need none of this: a method already names its own [function], +/// and the readout uses that. This exists for the passes that are not a single +/// call — Colour Correction alone can invoke six different things depending on +/// which of its switches are on — where naming one function would be a lie and +/// naming none is what prompted the request. +/// +/// [activeWhen] is evaluated against the filter's current parameter values with +/// exactly the same matcher as `visibleWhen`, so the readout can distinguish +/// what is running now from what the pass is merely capable of. An entry with +/// no condition is always active. +@JsonSerializable() +class ImplementationEntry { + /// The call as it appears in the generated script, e.g. `core.retinex.MSRCP`. + final String function; + + /// What this call contributes, in the user's vocabulary rather than the + /// plugin's — "shadow detail", not "multi-scale retinex". + final String? role; + + /// Condition under which this call is actually made. Null means always. + final Map? activeWhen; + + const ImplementationEntry({ + required this.function, + this.role, + this.activeWhen, + }); + + factory ImplementationEntry.fromJson(Map json) => + _$ImplementationEntryFromJson(json); + Map toJson() => _$ImplementationEntryToJson(this); +} + /// UI section grouping parameters together. @JsonSerializable() class UiSection { @@ -410,6 +446,11 @@ class FilterSchema { /// Code generation configuration. final CodeTemplate? codeTemplate; + /// The VapourSynth calls this filter makes, when it is not simply the + /// selected method's own `function`. Declared only by the composite passes — + /// see [ImplementationEntry]. + final List? implementation; + /// Source of this schema: "builtin", "user", "community". @JsonKey(includeFromJson: false, includeToJson: false) String source; @@ -432,6 +473,7 @@ class FilterSchema { this.presets, this.ui, this.codeTemplate, + this.implementation, this.source = 'builtin', }); diff --git a/app/lib/views/settings/dynamic_filter_panel.dart b/app/lib/views/settings/dynamic_filter_panel.dart index b7a6933..e0070c0 100644 --- a/app/lib/views/settings/dynamic_filter_panel.dart +++ b/app/lib/views/settings/dynamic_filter_panel.dart @@ -298,6 +298,11 @@ class DynamicFilterPanelCompact extends StatelessWidget { const SizedBox(height: 16), ], + // What this pass actually calls. Advanced mode only: a plugin name is + // not actionable for someone who has not asked for that level, and + // advanced mode is the app-wide lever for exactly that judgement. + if (advancedMode) ..._buildImplementationReadout(context), + // Get parameters for the current method ..._buildMethodParameters(context, advancedMode), ], @@ -484,6 +489,100 @@ class DynamicFilterPanelCompact extends StatelessWidget { ); } + /// The VapourSynth calls this pass makes, for identifying it against other + /// tools ("this is FixChromaBleedingMod", "that is MSRCP"). + /// + /// Two shapes, because filters come in two shapes. A pass that is one call + /// per method just shows the selected method's own `function`. A composite + /// pass — Colour Correction can invoke six things depending on its switches — + /// declares an `implementation` list instead, and the whole repertoire is + /// shown with the calls currently running emphasised. Seeing the inactive + /// ones is the point: it says what the pass could do, not only what it is + /// doing. + List _buildImplementationReadout(BuildContext context) { + final entries = _implementationEntries(); + if (entries.isEmpty) return const []; + + final theme = Theme.of(context); + final dim = theme.colorScheme.onSurface.withValues(alpha: 0.45); + final bright = theme.colorScheme.onSurface.withValues(alpha: 0.85); + + return [ + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'VapourSynth', + style: theme.textTheme.labelSmall?.copyWith( + color: dim, + letterSpacing: 0.6, + ), + ), + const SizedBox(height: 4), + for (final e in entries) + Padding( + padding: const EdgeInsets.only(top: 2), + child: RichText( + text: TextSpan( + style: theme.textTheme.bodySmall, + children: [ + TextSpan( + text: e.entry.function, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 11.5, + color: e.active ? bright : dim, + fontWeight: + e.active ? FontWeight.w600 : FontWeight.normal, + ), + ), + if (e.entry.role != null) + TextSpan( + text: ' — ${e.entry.role}', + style: TextStyle(fontSize: 11.5, color: dim), + ), + ], + ), + ), + ), + ], + ), + ), + ]; + } + + /// The readout's rows, each flagged with whether it is running right now. + List<({ImplementationEntry entry, bool active})> _implementationEntries() { + final declared = schema.implementation; + if (declared != null && declared.isNotEmpty) { + return [ + for (final e in declared) + ( + entry: e, + active: e.activeWhen == null || _checkVisibleWhen(e.activeWhen!), + ), + ]; + } + + // Method-based pass: the selected method is the only thing that runs, so + // there is nothing to grey out. Fall back to the sole method when the + // filter has exactly one, which is how the single-method passes read. + final method = schema.getMethod(params.method) ?? + (schema.methods.length == 1 ? schema.methods.first : null); + if (method == null || method.function.trim().isEmpty) return const []; + // `custom` is a placeholder meaning "declared in `implementation` + // instead"; showing it would be worse than showing nothing. + if (method.function == 'custom') return const []; + return [ + ( + entry: ImplementationEntry(function: method.function), + active: true, + ), + ]; + } + bool _isVisible(String paramId) { final param = schema.parameters[paramId]; if (param?.ui?.visibleWhen == null) return true; diff --git a/app/test/filter_implementation_readout_test.dart b/app/test/filter_implementation_readout_test.dart new file mode 100644 index 0000000..c86a41b --- /dev/null +++ b/app/test/filter_implementation_readout_test.dart @@ -0,0 +1,207 @@ +// The "what is this actually doing" readout: the VapourSynth calls a pass +// makes, shown so someone who knows the plugins from other tools can identify +// the pipeline rather than infer it from label wording. +// +// Two things make this worth a widget test. The readout is assembled from +// schema data at build time, so a schema that declares nothing renders nothing +// with no error; and it is gated on advanced mode, which is the app-wide +// complexity lever — leaking plugin names into simple mode would undo the +// curation the rest of the panel is built around. +// +// Run with: flutter test test/filter_implementation_readout_test.dart + +import 'dart:convert'; +import 'dart:io'; + +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(); + }); + + FilterSchema shipped(String filename) => FilterSchema.fromJson( + jsonDecode(File('assets/filters/core/$filename').readAsStringSync()) + as Map, + ); + + Future pump( + WidgetTester tester, + FilterSchema schema, + Map values, + ) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: advanced, + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: DynamicFilterPanelCompact( + schema: schema, + params: DynamicParameters( + filterId: schema.id, + enabled: true, + values: values, + ), + onChanged: (_) {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + /// The rendered function names, in order, with whether each is emphasised. + List<(String, bool)> readout(WidgetTester tester) { + final out = <(String, bool)>[]; + for (final rt in tester.widgetList(find.byType(RichText))) { + final span = rt.text; + if (span is! TextSpan) continue; + final children = span.children; + if (children == null || children.isEmpty) continue; + final first = children.first; + if (first is! TextSpan) continue; + if (first.style?.fontFamily != 'monospace') continue; + out.add((first.text ?? '', first.style?.fontWeight == FontWeight.w600)); + } + return out; + } + + group('advanced mode gating', () { + testWidgets('simple mode shows no plugin names at all', (tester) async { + await advanced.setEnabled(false); + await pump(tester, shipped('chroma_denoise.json'), { + 'method': 'ccd', + 'enabled': true, + }); + expect(readout(tester), isEmpty); + expect(find.textContaining('zsmooth'), findsNothing); + }); + + testWidgets('advanced mode names the selected method', (tester) async { + await advanced.setEnabled(true); + await pump(tester, shipped('chroma_denoise.json'), { + 'method': 'ccd', + 'enabled': true, + }); + expect(readout(tester), [('core.zsmooth.CCD', true)]); + }); + + testWidgets('it follows the method, not the filter', (tester) async { + await advanced.setEnabled(true); + await pump(tester, shipped('chroma_denoise.json'), { + 'method': 'cnr4', + 'enabled': true, + }); + expect(readout(tester), [('core.zsmooth.Cnr4', true)]); + }); + }); + + group('a composite pass shows its whole repertoire', () { + // Colour Correction is the pass that prompted this: it has no method + // dropdown, so before `implementation` existed there was nothing anywhere + // in the UI naming what it ran. + testWidgets('everything is listed, and nothing is active when nothing is ' + 'switched on', (tester) async { + await advanced.setEnabled(true); + await pump(tester, shipped('color_correction.json'), {'enabled': true}); + + final rows = readout(tester); + final names = rows.map((r) => r.$1).toList(); + expect(names, contains('core.retinex.MSRCP')); + expect(names, contains('haf.SmoothLevels')); + expect(names, contains('adjust.Tweak')); + + // Tweak is unconditional; every gated call is inactive with all the + // switches off. + expect(rows.firstWhere((r) => r.$1 == 'adjust.Tweak').$2, isTrue); + expect(rows.firstWhere((r) => r.$1 == 'core.retinex.MSRCP').$2, isFalse); + }); + + testWidgets('switching a repair on emphasises its call', (tester) async { + await advanced.setEnabled(true); + await pump(tester, shipped('color_correction.json'), { + 'enabled': true, + 'applyShadowDetail': true, + }); + + final rows = readout(tester); + expect(rows.firstWhere((r) => r.$1 == 'core.retinex.MSRCP').$2, isTrue, + reason: 'shadow detail is on, so MSRCP is running'); + expect(rows.firstWhere((r) => r.$1 == 'haf.SmoothLevels').$2, isFalse, + reason: 'levels are still off'); + }); + + testWidgets('two calls behind one switch pick the right one', + (tester) async { + // applyLevels chooses between std.Levels and SmoothLevels, so exactly + // one of the pair must be emphasised — showing both would misreport what + // runs, and that pair is the reason activeWhen takes more than one key. + await advanced.setEnabled(true); + await pump(tester, shipped('color_correction.json'), { + 'enabled': true, + 'applyLevels': true, + 'smoothLevels': true, + }); + + var rows = readout(tester); + expect(rows.firstWhere((r) => r.$1 == 'haf.SmoothLevels').$2, isTrue); + expect(rows.where((r) => r.$1 == 'core.std.Levels').every((r) => !r.$2), + isTrue, reason: 'SmoothLevels replaces std.Levels, not joins it'); + + await pump(tester, shipped('color_correction.json'), { + 'enabled': true, + 'applyLevels': true, + 'smoothLevels': false, + }); + rows = readout(tester); + expect(rows.firstWhere((r) => r.$1 == 'haf.SmoothLevels').$2, isFalse); + expect(rows.where((r) => r.$1 == 'core.std.Levels').any((r) => r.$2), + isTrue); + }); + + testWidgets('chroma fixes distinguishes automatic from manual alignment', + (tester) async { + // Both are core.resize.Spline36, told apart only by their role text, and + // automatic suppresses manual — the same precedence the generator uses. + await advanced.setEnabled(true); + await pump(tester, shipped('chroma_fixes.json'), { + 'enabled': true, + 'applyAutoChroma': true, + 'applyChromaShift': true, + }); + + final active = readout(tester).where((r) => r.$2).toList(); + expect(active.length, 1, + reason: 'automatic alignment supersedes the manual shift, so only ' + 'one of the two Spline36 calls runs'); + expect(active.single.$1, 'core.resize.Spline36'); + }); + }); + + group('placeholders never reach the screen', () { + testWidgets('a "custom" function is not shown as a plugin name', + (tester) async { + // `custom` is schema bookkeeping meaning "declared in implementation + // instead". Rendering it would be worse than rendering nothing. + await advanced.setEnabled(true); + await pump(tester, shipped('crop_resize.json'), {'enabled': true}); + expect(readout(tester).map((r) => r.$1), isNot(contains('custom'))); + expect(find.text('custom'), findsNothing); + }); + }); +} diff --git a/app/test/filter_schema_curation_test.dart b/app/test/filter_schema_curation_test.dart index c23e17d..7ec177f 100644 --- a/app/test/filter_schema_curation_test.dart +++ b/app/test/filter_schema_curation_test.dart @@ -150,6 +150,74 @@ void main() { }); }); + // The "what is this actually doing" readout in advanced mode is driven + // entirely by schema data, so it fails the same silent way conditional + // visibility does: a placeholder function name, or a condition naming a + // parameter that does not exist, produces a readout that is confidently + // wrong rather than absent. + group('every pass says what it calls', () { + rawSchemas.forEach((filename, raw) { + final id = raw['id'] as String; + final parameters = (raw['parameters'] as Map).cast(); + final methods = (raw['methods'] as List).cast>(); + final implementation = + (raw['implementation'] as List?)?.cast>(); + + test('$id: no method leaves its function blank', () { + for (final m in methods) { + expect(m['function'], isA(), + reason: '${m['id']} declares no function at all'); + expect((m['function'] as String).trim(), isNotEmpty, + reason: '${m['id']} declares an empty function'); + } + }); + + test('$id: a "custom" method is explained by an implementation list', () { + // `custom` means "not one call" — which is fine, but only if the pass + // then says what it *is*. Otherwise the readout shows nothing and the + // expert is back to guessing, which is the whole complaint. + final custom = [ + for (final m in methods) + if (m['function'] == 'custom') m['id'] as String, + ]; + if (custom.isEmpty) return; + expect(implementation, isNotNull, + reason: '$custom declare function "custom" but $id declares no ' + '`implementation`, so the readout would be empty'); + expect(implementation, isNotEmpty); + }); + + if (implementation != null) { + test('$id: every declared call names a real condition', () { + for (final entry in implementation) { + expect((entry['function'] as String).trim(), isNotEmpty); + final activeWhen = + (entry['activeWhen'] as Map?)?.cast(); + if (activeWhen == null) continue; + for (final key in activeWhen.keys) { + expect(parameters.containsKey(key), isTrue, + reason: '${entry['function']} is gated on "$key", which is ' + 'not a parameter of $id — the condition can never be ' + 'satisfied, so the call would always read as inactive'); + } + } + }); + + test('$id: at least one call is reachable', () { + // A list where every entry is gated on something impossible would + // render entirely greyed out, which reads as "this pass does + // nothing". + expect(implementation.any((e) => e['activeWhen'] == null), isTrue, + reason: 'no unconditional call, so with every switch off the ' + 'readout is entirely inactive — acceptable only if that is ' + 'genuinely true of $id'); + }, skip: id == 'chroma_fixes' || id == 'crop_resize' + ? 'every repair is opt-in, so an all-off pass really does nothing' + : false); + } + }); + }); + group('conditional visibility is where the model can see it', () { // Both halves matter: a condition in the wrong place never hides anything, // and a condition naming something that does not exist never shows diff --git a/docs/FILTER_SCHEMA.md b/docs/FILTER_SCHEMA.md index 46131a8..39688b1 100644 --- a/docs/FILTER_SCHEMA.md +++ b/docs/FILTER_SCHEMA.md @@ -35,6 +35,7 @@ app means writing one of these. This is the field reference. | `presets` | object | No | Named value sets, `{"fast": {"tr0": 1}}` | | `ui` | object | No | Section layout | | `codeTemplate` | object | No | Code generation hints | +| `implementation` | array | No | The VapourSynth calls this pass makes, for the advanced-mode readout. Only for passes that are not one call per method — see below | ## Dependencies @@ -219,6 +220,67 @@ works the same way as one on a checkbox. > offer, can *never* be satisfied — so the control it guards is invisible > forever, which is the worse of the two silent failures. +## Implementation readout + +In advanced mode each pass shows the VapourSynth calls it makes, so someone who +knows the plugins from other tools can identify the pipeline instead of +inferring it from label wording. + +**Most filters need nothing here.** A method already declares its own +`function`, and the readout uses that — it names whichever method is selected, +and nothing else runs. + +`implementation` is for the passes that are *not* one call per method. Colour +Correction has no method dropdown at all and can invoke six different things +depending on which switches are on; naming one of them would be a lie, and +naming none is what prompted this feature. Such a pass declares the whole +repertoire, and the readout emphasises the calls currently running: + +```json +"implementation": [ + { + "function": "core.std.Levels", + "role": "automatic levels, measured per frame with core.std.PlaneStats", + "activeWhen": { "applyAutoLevels": true } + }, + { "function": "adjust.Tweak", "role": "brightness, contrast, saturation, hue" }, + { + "function": "haf.SmoothLevels", + "role": "levels, dithered and limited as it maps", + "activeWhen": { "applyLevels": true, "smoothLevels": true } + } +] +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `function` | string | **Yes** | The call as it appears in the generated script | +| `role` | string | No | What it contributes, in the user's vocabulary rather than the plugin's — "shadow detail", not "multi-scale retinex" | +| `activeWhen` | object | No | Condition under which the call is actually made. Omit for a call that always runs | + +`activeWhen` uses **exactly the same matcher as `visibleWhen`** — keys are +parameter ids, values are matched by equality or list membership, and multiple +keys are ANDed. That is what lets one switch choose between two calls: `Levels` +is `{"applyLevels": true, "smoothLevels": false}` and `SmoothLevels` is the same +with `true`, so precisely one is ever emphasised. + +Three rules, all linted by `filter_schema_curation_test.dart`: + +- **No method may leave `function` blank.** +- **A method declaring `"function": "custom"` requires an `implementation` + list.** `custom` means "not one call", which is fine — but a pass that then + says nothing renders an empty readout, which is the original complaint. + `custom` is never displayed. +- **Every `activeWhen` key must name a real parameter.** A condition naming + something that does not exist can never be satisfied, so the call would read + as permanently inactive — the same silent failure as a `visibleWhen` naming a + missing parameter. + +> **The readout is advanced-mode only**, deliberately. A plugin name is not +> actionable for someone who has not asked for that level of detail, and +> advanced mode is the app-wide lever for exactly that judgement. Don't promote +> it to simple mode. + ## Parameter Presets A dropdown that writes several parameters at once — distinct from the top-level