From 9a1c677611716e79586292d91be67eedd2a435ba Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Thu, 17 Sep 2026 20:24:08 +1000 Subject: [PATCH] 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"); +}