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"); +}