From 9a1c677611716e79586292d91be67eedd2a435ba Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Thu, 17 Sep 2026 20:24:08 +1000 Subject: [PATCH 1/2] Add IVTC fallback deinterlace for leftover combing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the feature scoped for Rob's bug report (broken 3:2 cadence at scene changes, field-blended dissolves): a new opt-in ivtcFallbackDeinterlace toggle patches frames VFM couldn't cleanly field-match with a QTGMC deinterlace of that frame, instead of leaving them combed. Off by default (roughly doubles the pass's cost). Mechanism, probed against the actual bundled vivtc plugin before wiring up (not assumed from memory, per house rule): - VFM already stamps a _Combed frame property on every frame; this reads it, doesn't add it. - VDecimate accepts a clip2 kwarg — its drop decisions come from `clip`, its output frames come from `clip2`. VFM itself already uses this exact idiom for its own 8-bit-metrics/full-depth-output split. - A parallel QTGMC pass + FrameEval builds the hybrid clip that VDecimate's clip2 then draws from. The preview template has no VDecimate call (single frame), so the hybrid replaces `clip` there directly instead. ivtcFallbackPreset defaults to Fast rather than QTGMCPreset's general Slower default, since this pass runs on top of an already-slow IVTC pass. Touches: Rust model + script generator + both templates (encode and preview, so the preview shows what the real encode will produce), Dart model + converter, the deinterlace filter schema, a Rust integration test, and a Flutter script-generation test. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014GLXdGLfPwgYjW1AkonGqN --- CLAUDE.md | 39 ++++++ app/assets/filters/core/deinterlace.json | 50 ++++++- app/lib/models/parameter_converter.dart | 12 ++ app/lib/models/qtgmc_parameters.dart | 19 +++ .../integration_filter_parameters_test.dart | 40 ++++++ worker/src/models/qtgmc_parameters.rs | 26 ++++ worker/src/script_generator.rs | 14 ++ worker/templates/pipeline_template.vpy | 26 ++++ worker/templates/preview_template.vpy | 19 +++ worker/tests/filter_integration_test.rs | 127 ++++++++++++++++++ 10 files changed, 370 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 207fded..a4a440b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -979,6 +979,45 @@ block existed. `''`, `'nnedi3'` and `'bob'`; `QTGMCParameters::normalized_chroma_edi` drops anything else, since an unsupported value silently corrupts chroma. +### IVTC Fallback Deinterlace + +**`ivtcFallbackDeinterlace`** (default off, IVTC method only) patches frames +`VFM` couldn't cleanly field-match — a broken cadence at a scene change, a +blended dissolve — with a QTGMC deinterlace of that frame, instead of leaving +them combed. Off by default: it roughly doubles the pass's cost by running a +full second QTGMC deinterlace of the source in parallel. + +The mechanism relies on two `vivtc` behaviors confirmed against the actual +bundled plugin (not assumed from memory) before wiring this up: + +- `VFM` stamps a `_Combed` frame property on every frame, which is already + true regardless of this feature — it is simply unused elsewhere. +- `VDecimate` accepts a `clip2` kwarg: its *drop decisions* still come from + the clip passed as `clip`, but its *output frames* come from `clip2`. VFM + itself already uses this same idiom above it in the script, to run field + matching on an 8-bit metrics copy while emitting full-depth pixels. + +Script shape (both `pipeline_template.vpy` and `preview_template.vpy`, +`{{#IVTC_FALLBACK}}` gated in `script_generator.rs`): + +1. `VFM` runs as it always does — its `_Combed` prop is now read, not new. +2. `haf.QTGMC(_ivtc_src, Preset=..., FPSDivisor=2)` runs on the full-depth + source at single rate, matching VFM's own untouched frame count. +3. `FrameEval` swaps the QTGMC frame in wherever `_Combed` is set, producing + a hybrid clip. +4. Encode path only: `VDecimate(clip, clip2=hybrid, ...)` — decimation timing + is unaffected by the patch, since the drop decisions still come from the + plain VFM clip. The preview template has no `VDecimate` call at all + (single-frame, nothing to decimate), so the hybrid clip replaces `clip` + directly instead. + +**`ivtcFallbackPreset`** defaults to `Fast`, not the general `QTGMCPreset` +default (`Slower`) — this pass runs on top of an already-slow IVTC pass, so +inheriting the general default would make the fallback more expensive than +the pass it patches. Resolved worker-side by +`QTGMCParameters::ivtc_fallback_preset_or_default`, not by the schema alone, +since the field is optional and a saved job may predate it. + ## Testing VapourBox has **three distinct test suites**. Know which is which before adding diff --git a/app/assets/filters/core/deinterlace.json b/app/assets/filters/core/deinterlace.json index 5dbc463..27f9029 100644 --- a/app/assets/filters/core/deinterlace.json +++ b/app/assets/filters/core/deinterlace.json @@ -124,7 +124,9 @@ "ivtcBlockY", "ivtcCycle", "ivtcDupthresh", - "ivtcScthresh" + "ivtcScthresh", + "ivtcFallbackDeinterlace", + "ivtcFallbackPreset" ] }, { @@ -1948,6 +1950,48 @@ } } }, + "ivtcFallbackDeinterlace": { + "type": "boolean", + "default": false, + "ui": { + "label": "Fix Leftover Combing", + "description": "Detects frames VFM couldn't cleanly field-match — a broken cadence at a scene change, a blended dissolve — and replaces just those frames with a QTGMC deinterlace instead of leaving them combed. Roughly doubles processing time for this pass.", + "widget": "checkbox", + "visibleWhen": { + "method": [ + "ivtc" + ] + } + } + }, + "ivtcFallbackPreset": { + "type": "enum", + "default": "Fast", + "options": [ + "Placebo", + "Very Slow", + "Slower", + "Slow", + "Medium", + "Fast", + "Faster", + "Very Fast", + "Super Fast", + "Ultra Fast", + "Draft" + ], + "ui": { + "label": "Fallback Speed", + "description": "QTGMC preset used only for the frames being patched — separate from the main pipeline's deinterlace preset, since this runs on top of an already-slow IVTC pass.", + "widget": "dropdown", + "visibleWhen": { + "method": [ + "ivtc" + ], + "ivtcFallbackDeinterlace": true + } + } + }, "bwdifEdeint": { "type": "boolean", "default": false, @@ -2095,7 +2139,9 @@ "title": "IVTC Settings", "parameters": [ "ivtcMode", - "ivtcCycle" + "ivtcCycle", + "ivtcFallbackDeinterlace", + "ivtcFallbackPreset" ], "expanded": true }, diff --git a/app/lib/models/parameter_converter.dart b/app/lib/models/parameter_converter.dart index d23352c..1051899 100644 --- a/app/lib/models/parameter_converter.dart +++ b/app/lib/models/parameter_converter.dart @@ -138,6 +138,11 @@ class ParameterConverter { 'ivtcCycle': params.ivtcCycle, 'ivtcDupthresh': params.ivtcDupthresh, 'ivtcScthresh': params.ivtcScthresh, + 'ivtcFallbackDeinterlace': params.ivtcFallbackDeinterlace, + // Null means "use the worker's default" (Fast) — surface that + // default so the dropdown shows what will actually run. + 'ivtcFallbackPreset': + (params.ivtcFallbackPreset ?? QTGMCPreset.fast).displayName, }, ); } @@ -1050,6 +1055,13 @@ class ParameterConverter { ivtcCycle: v['ivtcCycle'] as int?, ivtcDupthresh: (v['ivtcDupthresh'] as num?)?.toDouble(), ivtcScthresh: (v['ivtcScthresh'] as num?)?.toDouble(), + ivtcFallbackDeinterlace: v['ivtcFallbackDeinterlace'] as bool? ?? false, + ivtcFallbackPreset: v['ivtcFallbackPreset'] == null + ? null + : QTGMCPreset.values.firstWhere( + (p) => p.displayName == v['ivtcFallbackPreset'], + orElse: () => QTGMCPreset.fast, + ), ); } diff --git a/app/lib/models/qtgmc_parameters.dart b/app/lib/models/qtgmc_parameters.dart index 9d63d0e..5fb32bc 100644 --- a/app/lib/models/qtgmc_parameters.dart +++ b/app/lib/models/qtgmc_parameters.dart @@ -271,6 +271,19 @@ class QTGMCParameters { /// VDecimate scene change threshold final double? ivtcScthresh; + /// Whether to patch frames VFM couldn't cleanly field-match (a broken + /// cadence at a scene change, a blended dissolve) with a QTGMC + /// deinterlace of that frame instead of leaving them combed. IVTC method + /// only; roughly doubles the deinterlace pass's cost, so it defaults off. + final bool ivtcFallbackDeinterlace; + + /// QTGMC preset for the fallback pass, independent of [preset] (which + /// governs the QTGMC *method*, not used while [method] is + /// [DeinterlaceMethod.ivtc]). Null resolves to [QTGMCPreset.fast] on the + /// worker side — not the general [QTGMCPreset.slower] default, since this + /// runs a full second QTGMC pass on top of an already-slow IVTC one. + final QTGMCPreset? ivtcFallbackPreset; + const QTGMCParameters({ this.enabled = true, this.method = DeinterlaceMethod.qtgmc, @@ -363,6 +376,8 @@ class QTGMCParameters { this.ivtcCycle, this.ivtcDupthresh, this.ivtcScthresh, + this.ivtcFallbackDeinterlace = false, + this.ivtcFallbackPreset, }); factory QTGMCParameters.fromJson(Map json) => @@ -460,6 +475,8 @@ class QTGMCParameters { int? ivtcCycle, double? ivtcDupthresh, double? ivtcScthresh, + bool? ivtcFallbackDeinterlace, + QTGMCPreset? ivtcFallbackPreset, bool? bwdifEdeint, }) { return QTGMCParameters( @@ -553,6 +570,8 @@ class QTGMCParameters { ivtcCycle: ivtcCycle ?? this.ivtcCycle, ivtcDupthresh: ivtcDupthresh ?? this.ivtcDupthresh, ivtcScthresh: ivtcScthresh ?? this.ivtcScthresh, + ivtcFallbackDeinterlace: ivtcFallbackDeinterlace ?? this.ivtcFallbackDeinterlace, + ivtcFallbackPreset: ivtcFallbackPreset ?? this.ivtcFallbackPreset, bwdifEdeint: bwdifEdeint ?? this.bwdifEdeint, ); } diff --git a/app/test/integration_filter_parameters_test.dart b/app/test/integration_filter_parameters_test.dart index 6b841cb..2f8b0f6 100644 --- a/app/test/integration_filter_parameters_test.dart +++ b/app/test/integration_filter_parameters_test.dart @@ -1398,5 +1398,45 @@ void main() { expect(script, contains('core.vivtc.VDecimate(clip')); print(' PASS'); }, timeout: const Timeout(Duration(minutes: 2))); + + // --- IVTC fallback deinterlace: patch frames VFM couldn't cleanly + // field-match with a QTGMC deinterlace of that frame, instead of leaving + // them combed. --- + test('ivtc: fallback deinterlace builds the hybrid clip', () async { + final job = buildJob( + testName: 'ivtc_fallback', + deinterlace: const QTGMCParameters( + enabled: true, + method: DeinterlaceMethod.ivtc, + tff: true, + ivtcFallbackDeinterlace: true, + ivtcFallbackPreset: QTGMCPreset.placebo, + ), + ); + print(' Generating IVTC fallback script...'); + final script = await generateScriptViaWorker(job); + expect(script, contains('_ivtc_fallback_deint = haf.QTGMC(_ivtc_src')); + expect(script, contains('Preset="Placebo"')); + expect(script, contains("f.props.get('_Combed')")); + expect(script, contains('_ivtc_hybrid = core.std.FrameEval(')); + expect(script, contains('clip2=_ivtc_hybrid')); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('ivtc: fallback deinterlace off by default leaves the script unchanged', + () async { + final job = buildJob( + testName: 'ivtc_fallback_off', + deinterlace: const QTGMCParameters( + enabled: true, method: DeinterlaceMethod.ivtc, tff: true, + ), + ); + final script = await generateScriptViaWorker(job); + expect(script, isNot(contains('_ivtc_fallback_deint'))); + expect(script, isNot(contains('_ivtc_hybrid'))); + expect(script, isNot(contains('clip2=_ivtc_hybrid'))); + expect(script, contains('core.vivtc.VDecimate(clip')); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); }); } diff --git a/worker/src/models/qtgmc_parameters.rs b/worker/src/models/qtgmc_parameters.rs index b3ab1a8..5bae4b9 100644 --- a/worker/src/models/qtgmc_parameters.rs +++ b/worker/src/models/qtgmc_parameters.rs @@ -411,6 +411,21 @@ pub struct QTGMCParameters { /// VDecimate scene change threshold #[serde(skip_serializing_if = "Option::is_none")] pub ivtc_scthresh: Option, + + /// Whether to patch frames VFM couldn't cleanly field-match (a broken + /// cadence at a scene change, a blended dissolve) with a QTGMC + /// deinterlace of that frame instead of leaving them combed. IVTC method + /// only; roughly doubles the deinterlace pass's cost, so it defaults off. + #[serde(default)] + pub ivtc_fallback_deinterlace: bool, + + /// QTGMC preset for the fallback pass, independent of `preset` above + /// (which governs the QTGMC *method*, not used while method=IVTC). + /// `None` resolves to `Fast` (see `ivtc_fallback_preset_or_default`) — + /// deliberately not the general `Slower` default, since this runs a full + /// second QTGMC pass on top of an already-slow IVTC one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ivtc_fallback_preset: Option, } // Default value functions @@ -452,6 +467,15 @@ impl QTGMCParameters { None } } + + /// The preset the IVTC fallback QTGMC pass runs at. `Fast` rather than + /// `QTGMCPreset::default()` (`Slower`) when unset, since this pass runs + /// on top of an already-slow IVTC pass and only patches a minority of + /// frames — the general QTGMC default would make the fallback more + /// expensive than the pass it is patching. + pub fn ivtc_fallback_preset_or_default(&self) -> QTGMCPreset { + self.ivtc_fallback_preset.unwrap_or(QTGMCPreset::Fast) + } } impl Default for QTGMCParameters { @@ -548,6 +572,8 @@ impl Default for QTGMCParameters { ivtc_cycle: None, ivtc_dupthresh: None, ivtc_scthresh: None, + ivtc_fallback_deinterlace: false, + ivtc_fallback_preset: None, } } } diff --git a/worker/src/script_generator.rs b/worker/src/script_generator.rs index 3bcbcea..ad8e965 100644 --- a/worker/src/script_generator.rs +++ b/worker/src/script_generator.rs @@ -700,6 +700,20 @@ impl ScriptGenerator { script = process_optional_int("IVTC_CYCLE", params.ivtc_cycle, script); script = process_optional_double("IVTC_DUPTHRESH", params.ivtc_dupthresh, script); script = process_optional_double("IVTC_SCTHRESH", params.ivtc_scthresh, script); + + // Fallback deinterlace for frames VFM couldn't cleanly + // field-match (see the template for the mechanism). + if params.ivtc_fallback_deinterlace { + script = script.replace("{{#IVTC_FALLBACK}}", ""); + script = script.replace("{{/IVTC_FALLBACK}}", ""); + script = script.replace( + "{{IVTC_FALLBACK_PRESET}}", + params.ivtc_fallback_preset_or_default().as_str(), + ); + script = process_optional_bool("IVTC_FALLBACK_TFF", params.tff, script); + } else { + script = remove_block("{{#IVTC_FALLBACK}}", "{{/IVTC_FALLBACK}}", script); + } } DeinterlaceMethod::SoftTelecine => { // Enable Soft Telecine block, remove QTGMC and IVTC blocks diff --git a/worker/templates/pipeline_template.vpy b/worker/templates/pipeline_template.vpy index 17a2b0d..722519f 100644 --- a/worker/templates/pipeline_template.vpy +++ b/worker/templates/pipeline_template.vpy @@ -420,6 +420,29 @@ clip = core.vivtc.VFM(_ivtc_metrics, order={{IVTC_ORDER}}, blocky={{IVTC_BLOCK_Y}}, {{/IVTC_BLOCK_Y}} clip2=_ivtc_src) +{{#IVTC_FALLBACK}} +# Fallback deinterlace: a frame VFM couldn't cleanly field-match (a broken +# cadence at a scene change, a blended dissolve) gets a QTGMC deinterlace of +# that same frame instead of being left combed. VFM already stamped _Combed +# on every frame above. QTGMC runs on the full-depth source in parallel at +# single rate (FPSDivisor=2, matching VFM's own untouched frame count), and +# FrameEval swaps its frame in wherever _Combed is set. VDecimate below takes +# its drop decisions from the plain VFM clip and its output frames from this +# hybrid via clip2 — the same clip2 idiom VFM itself used above for the +# bit-depth split — so which frames get dropped is unaffected by the patch. +_ivtc_fallback_deint = haf.QTGMC(_ivtc_src, Preset="{{IVTC_FALLBACK_PRESET}}", +{{#IVTC_FALLBACK_TFF}} + TFF={{IVTC_FALLBACK_TFF}}, +{{/IVTC_FALLBACK_TFF}} + FPSDivisor=2) +def _ivtc_fallback_apply(n, f, combed_clip, fallback_clip): + return fallback_clip if f.props.get('_Combed') else combed_clip +_ivtc_hybrid = core.std.FrameEval( + clip, + functools.partial(_ivtc_fallback_apply, combed_clip=clip, fallback_clip=_ivtc_fallback_deint), + prop_src=clip, +) +{{/IVTC_FALLBACK}} # VFM output is full-depth again; VDecimate accepts 8..16 bit natively. clip = core.vivtc.VDecimate(clip, {{#IVTC_CYCLE}} @@ -431,6 +454,9 @@ clip = core.vivtc.VDecimate(clip, {{#IVTC_SCTHRESH}} scthresh={{IVTC_SCTHRESH}}, {{/IVTC_SCTHRESH}} +{{#IVTC_FALLBACK}} + clip2=_ivtc_hybrid, +{{/IVTC_FALLBACK}} ) {{/DEINT_IVTC}} {{#DEINT_SOFT_TELECINE}} diff --git a/worker/templates/preview_template.vpy b/worker/templates/preview_template.vpy index 2642ab7..b8d9f88 100644 --- a/worker/templates/preview_template.vpy +++ b/worker/templates/preview_template.vpy @@ -385,6 +385,25 @@ clip = core.vivtc.VFM(_ivtc_metrics, order={{IVTC_ORDER}}, blocky={{IVTC_BLOCK_Y}}, {{/IVTC_BLOCK_Y}} clip2=_ivtc_src) +{{#IVTC_FALLBACK}} +# Same fallback as the encode path (see pipeline_template.vpy): a QTGMC +# deinterlace of this frame patches in wherever VFM flagged it combed, so the +# preview doesn't show combing the real encode won't have. No VDecimate here +# (preview is single-frame — nothing to decimate), so the hybrid becomes the +# clip directly rather than feeding VDecimate's clip2. +_ivtc_fallback_deint = haf.QTGMC(_ivtc_src, Preset="{{IVTC_FALLBACK_PRESET}}", +{{#IVTC_FALLBACK_TFF}} + TFF={{IVTC_FALLBACK_TFF}}, +{{/IVTC_FALLBACK_TFF}} + FPSDivisor=2) +def _ivtc_fallback_apply(n, f, combed_clip, fallback_clip): + return fallback_clip if f.props.get('_Combed') else combed_clip +clip = core.std.FrameEval( + clip, + functools.partial(_ivtc_fallback_apply, combed_clip=clip, fallback_clip=_ivtc_fallback_deint), + prop_src=clip, +) +{{/IVTC_FALLBACK}} {{/DEINT_IVTC}} {{/DEINTERLACE}} diff --git a/worker/tests/filter_integration_test.rs b/worker/tests/filter_integration_test.rs index cec5d92..b893808 100644 --- a/worker/tests/filter_integration_test.rs +++ b/worker/tests/filter_integration_test.rs @@ -5786,3 +5786,130 @@ fn test_155_a_bundle_without_the_split_still_autoloads_zsmooth() { } } } + +#[test] +fn test_156_ivtc_fallback_deinterlace_script() { + // Frames VFM couldn't cleanly field-match get patched with a QTGMC + // deinterlace of that frame instead of being left combed. Verifies the + // encode script builds the fallback QTGMC pass, the _Combed-driven + // FrameEval swap, and feeds the hybrid into VDecimate via clip2 — while + // VDecimate's own drop decisions still come from the plain VFM clip. + create_output_dir(); + + let mut job = create_ivtc_base_job("test_156_ivtc_fallback"); + job.qtgmc_parameters.ivtc_fallback_deinterlace = true; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: job.qtgmc_parameters.clone(), + ..ProcessingPipeline::default() + }); + + run_job_and_verify(&job, "IVTC - Fallback Deinterlace", &[ + "core.vivtc.VFM", + "_ivtc_fallback_deint = haf.QTGMC(_ivtc_src", + // Unset ivtc_fallback_preset resolves to Fast, not the general + // Slower default — this pass runs on top of an already-slow IVTC. + "Preset=\"Fast\"", + "f.props.get('_Combed')", + "_ivtc_hybrid = core.std.FrameEval(", + "core.vivtc.VDecimate(clip,", + "clip2=_ivtc_hybrid,", + ]).unwrap(); +} + +#[test] +fn test_157_ivtc_fallback_deinterlace_respects_explicit_preset_and_tff() { + create_output_dir(); + + let mut job = create_ivtc_base_job("test_157_ivtc_fallback_preset"); + job.qtgmc_parameters.ivtc_fallback_deinterlace = true; + job.qtgmc_parameters.ivtc_fallback_preset = Some(QTGMCPreset::Placebo); + job.qtgmc_parameters.tff = Some(false); + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: job.qtgmc_parameters.clone(), + ..ProcessingPipeline::default() + }); + + run_job_and_verify(&job, "IVTC - Fallback Deinterlace Custom Preset", &[ + "_ivtc_fallback_deint = haf.QTGMC(_ivtc_src", + "Preset=\"Placebo\"", + "TFF=False", + ]).unwrap(); +} + +#[test] +fn test_158_ivtc_fallback_deinterlace_preview_script() { + // The preview must show the same patched frame the encode would produce + // (#49-style preview/encode parity) — no VDecimate call exists in the + // preview template (single frame, nothing to decimate), so the hybrid + // clip replaces `clip` directly rather than feeding a clip2 kwarg. + create_output_dir(); + + let mut job = create_ivtc_base_job("test_158_ivtc_fallback_preview"); + job.qtgmc_parameters.ivtc_fallback_deinterlace = true; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: job.qtgmc_parameters.clone(), + ..ProcessingPipeline::default() + }); + + let generator = ScriptGenerator::new().expect("create generator"); + let params = PreviewParams { + width: 720, + height: 480, + pix_fmt: "yuv420p".to_string(), + num_frames: 11, + fps_num: 30000, + fps_den: 1001, + output_index: 3, + }; + let script_path = generator + .generate_preview(&job, ¶ms) + .expect("generate preview script"); + let script = std::fs::read_to_string(&script_path).expect("read preview script"); + + assert!( + script.contains("_ivtc_fallback_deint = haf.QTGMC(_ivtc_src"), + "preview must build the same fallback QTGMC pass as the encode, script was:\n{}", + script + ); + assert!( + script.contains("f.props.get('_Combed')"), + "preview must gate the swap on _Combed the same way the encode does" + ); + assert!( + !script.contains("core.vivtc.VDecimate"), + "preview has nothing to decimate — a VDecimate call here would be new, unintended behaviour" + ); + assert!( + !script.contains("clip2=_ivtc_hybrid"), + "no VDecimate call exists in preview to take a clip2 kwarg" + ); +} + +#[test] +fn test_159_ivtc_fallback_deinterlace_off_by_default() { + // With the toggle off (the default), the script must be exactly what it + // was before this feature existed — no leftover template markers, no + // fallback QTGMC pass, and VDecimate's call unchanged. + create_output_dir(); + + let job = create_ivtc_base_job("test_159_ivtc_fallback_off"); + let mut job = job; + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: job.qtgmc_parameters.clone(), + ..ProcessingPipeline::default() + }); + assert!(!job.qtgmc_parameters.ivtc_fallback_deinterlace, "fallback must default off"); + + let generator = ScriptGenerator::new().expect("create generator"); + let script_path = generator.generate(&job).expect("generate script"); + let script = std::fs::read_to_string(&script_path).expect("read script"); + + for leftover in ["{{#IVTC_FALLBACK}}", "{{/IVTC_FALLBACK}}", "{{IVTC_FALLBACK_PRESET}}"] { + assert!(!script.contains(leftover), "script left {leftover} unsubstituted"); + } + assert!(!script.contains("_ivtc_fallback_deint"), "fallback pass must not be built when off"); + assert!(!script.contains("_ivtc_hybrid"), "hybrid clip must not be built when off"); + assert!(!script.contains("clip2=_ivtc_hybrid"), "VDecimate must not take a clip2 kwarg when off"); + assert!(script.contains("core.vivtc.VFM"), "VFM must still be present"); + assert!(script.contains("core.vivtc.VDecimate"), "VDecimate must still be present"); +} From e27609923870264585d079d162504786fb76dee9 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sun, 20 Sep 2026 00:12:37 +1000 Subject: [PATCH 2/2] Stamp sinceAppVersion at change time, not release time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverses the policy written in the previous PR: stamping sinceAppVersion when a release is cut turned out to be exactly how a property gets missed — by release day there's no reliable way to tell which of the properties touched since the last release actually need one. Stamp it in the same change that adds or modifies the property instead, using the version that change will actually ship in (a minor bump on the latest public release, or the version already in app/pubspec.yaml if that's already ahead). Backfills this branch's own two new parameters (ivtcFallbackDeinterlace, ivtcFallbackPreset) with "1.2.0" — a minor bump on the latest public release (v1.1.0) that also matches the version already bumped to in app/pubspec.yaml on the parent branch. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014GLXdGLfPwgYjW1AkonGqN --- CLAUDE.md | 9 ++++++--- app/assets/filters/core/deinterlace.json | 2 ++ docs/FILTER_SCHEMA.md | 20 ++++++++++++++++---- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a4a440b..d81ae4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -566,9 +566,12 @@ filter actually has. when a filter or parameter shipped, purely so `WhatsNewService` can draw a "NEW" badge next to it — persisting across every launch of the current app version, and only advancing on the next real update, not clearing after one -run. Stamp it when you ship the feature, not retroactively on anything already -out — see "NEW badges" in -[docs/FILTER_SCHEMA.md](docs/FILTER_SCHEMA.md). +run. **Stamp it in the same change that adds or modifies the property, using +the version that change will actually ship in** (a minor bump on the latest +public release, or whatever's already in `app/pubspec.yaml` if that's ahead) +— not retroactively at release time, which is exactly how properties get +missed. Never touch the stamp on anything already shipped — see "NEW badges" +in [docs/FILTER_SCHEMA.md](docs/FILTER_SCHEMA.md). Several ongoing rules for schemas and the panel that renders them, each guarded by `filter_schema_curation_test.dart` unless noted — see diff --git a/app/assets/filters/core/deinterlace.json b/app/assets/filters/core/deinterlace.json index 27f9029..416a098 100644 --- a/app/assets/filters/core/deinterlace.json +++ b/app/assets/filters/core/deinterlace.json @@ -1953,6 +1953,7 @@ "ivtcFallbackDeinterlace": { "type": "boolean", "default": false, + "sinceAppVersion": "1.2.0", "ui": { "label": "Fix Leftover Combing", "description": "Detects frames VFM couldn't cleanly field-match — a broken cadence at a scene change, a blended dissolve — and replaces just those frames with a QTGMC deinterlace instead of leaving them combed. Roughly doubles processing time for this pass.", @@ -1980,6 +1981,7 @@ "Ultra Fast", "Draft" ], + "sinceAppVersion": "1.2.0", "ui": { "label": "Fallback Speed", "description": "QTGMC preset used only for the frames being patched — separate from the main pipeline's deinterlace preset, since this runs on top of an already-slow IVTC pass.", diff --git a/docs/FILTER_SCHEMA.md b/docs/FILTER_SCHEMA.md index 1e1e639..609d0fa 100644 --- a/docs/FILTER_SCHEMA.md +++ b/docs/FILTER_SCHEMA.md @@ -262,11 +262,23 @@ the settings panel, whenever `sinceAppVersion` is newer than that. - **A fresh install sees no badges at all.** There's no "since you last updated" for someone who has never had a previous version, so a first launch would otherwise badge the entire app. -- **Set it once, when the feature ships** — a bump to the app version alone +- **Stamp it in the same commit that adds or meaningfully changes the + property — not later, at release time.** A bump to the app version alone doesn't move any badge; only editing (or adding) `sinceAppVersion` on the - specific filter/parameter does. Stamp it at the same time you bump - `pubspec.yaml`'s version for the release, and leave everything already - shipped alone. + specific filter/parameter does. Deferring it to whenever a release gets cut + was tried and dropped: with dozens of parameters touched between releases, + it was too easy to forget which ones actually needed a stamp by the time + release day arrived. Stamping it at the point of change means the person + with the most context — the one who just wrote the property — is the one + who tags it. +- **Use the version this change will actually ship in**, not the currently + published one: a minor bump on top of the latest public release (check + `gh release list`), or the version already in `app/pubspec.yaml` if that's + already ahead of it (someone else on the same unreleased version already + bumped it). If the target version changes before release — a bigger bump + turns out to be needed, or this work slips a release — update every + `sinceAppVersion` stamped for it to match, in the same pass as the actual + version bump. Leave everything already shipped alone. ## Implementation readout