Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 48 additions & 2 deletions app/assets/filters/core/deinterlace.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@
"ivtcBlockY",
"ivtcCycle",
"ivtcDupthresh",
"ivtcScthresh"
"ivtcScthresh",
"ivtcFallbackDeinterlace",
"ivtcFallbackPreset"
]
},
{
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2095,7 +2139,9 @@
"title": "IVTC Settings",
"parameters": [
"ivtcMode",
"ivtcCycle"
"ivtcCycle",
"ivtcFallbackDeinterlace",
"ivtcFallbackPreset"
],
"expanded": true
},
Expand Down
12 changes: 12 additions & 0 deletions app/lib/models/parameter_converter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
);
}
Expand Down Expand Up @@ -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,
),
);
}

Expand Down
19 changes: 19 additions & 0 deletions app/lib/models/qtgmc_parameters.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -363,6 +376,8 @@ class QTGMCParameters {
this.ivtcCycle,
this.ivtcDupthresh,
this.ivtcScthresh,
this.ivtcFallbackDeinterlace = false,
this.ivtcFallbackPreset,
});

factory QTGMCParameters.fromJson(Map<String, dynamic> json) =>
Expand Down Expand Up @@ -460,6 +475,8 @@ class QTGMCParameters {
int? ivtcCycle,
double? ivtcDupthresh,
double? ivtcScthresh,
bool? ivtcFallbackDeinterlace,
QTGMCPreset? ivtcFallbackPreset,
bool? bwdifEdeint,
}) {
return QTGMCParameters(
Expand Down Expand Up @@ -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,
);
}
Expand Down
40 changes: 40 additions & 0 deletions app/test/integration_filter_parameters_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
});
}
26 changes: 26 additions & 0 deletions worker/src/models/qtgmc_parameters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,21 @@ pub struct QTGMCParameters {
/// VDecimate scene change threshold
#[serde(skip_serializing_if = "Option::is_none")]
pub ivtc_scthresh: Option<f64>,

/// 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<QTGMCPreset>,
}

// Default value functions
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
}
}
}
Expand Down
14 changes: 14 additions & 0 deletions worker/src/script_generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions worker/templates/pipeline_template.vpy
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand All @@ -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}}
Expand Down
19 changes: 19 additions & 0 deletions worker/templates/preview_template.vpy
Original file line number Diff line number Diff line change
Expand Up @@ -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}}

Expand Down
Loading