Skip to content
Merged
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 Tests/TestResources/generate_prores422_10bit_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/bin/bash
# Generate a 10-bit ProRes 422 telecined test clip.
#
# This reproduces the source from issue: IVTC (vivtc.VFM) failing on >8-bit
# input. ProRes 422 decodes to yuv422p10le (10-bit), which VFM rejects unless
# the pipeline runs field matching on an 8-bit copy and emits full-depth pixels
# via VFM's clip2 parameter.
#
# Properties (matching the reported source):
# - Codec: Apple ProRes 422 (Standard, apcn)
# - Pixel format: yuv422p10le (10-bit)
# - Field order: top field first (interlaced / telecined)
# - Resolution: 720x480 (NTSC), 29.97fps, ~1s (30 frames = 6 3:2 cycles)
#
# Drop the resulting .mov into the app and run Deinterlace > IVTC (or a preview)
# to exercise the high-bit-depth path.
#
# Usage: ./generate_prores422_10bit_test.sh [output_dir]

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
OUTPUT_DIR="${1:-$SCRIPT_DIR}"
SOURCE="$SCRIPT_DIR/hard_telecine_test.avi"
OUTPUT="$OUTPUT_DIR/prores422_10bit_telecine.mov"

if [ ! -f "$SOURCE" ]; then
echo "Missing $SOURCE — run ./generate_telecine_test.sh first." >&2
exit 1
fi

ffmpeg -y -i "$SOURCE" \
-frames:v 30 \
-c:v prores_ks -profile:v 2 -vendor apl0 -pix_fmt yuv422p10le \
-flags +ildct+ilme -top 1 \
"$OUTPUT"

echo "Wrote $OUTPUT"
ffmpeg -i "$OUTPUT" 2>&1 | grep -E "Stream|Duration" || true
Binary file added Tests/TestResources/prores422_10bit_telecine.mov
Binary file not shown.
23 changes: 22 additions & 1 deletion app/test/integration_filter_parameters_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ void validateParams({

VideoJob buildJob({
required String testName,
QTGMCParameters? deinterlace,
NoiseReductionParameters? noiseReduction,
DehaloParameters? dehalo,
DeblockParameters? deblock,
Expand All @@ -165,7 +166,7 @@ VideoJob buildJob({
inputPath: TestConfig.inputFile,
outputPath: '${TestConfig.outputDir}/$testName.mkv',
processingPipeline: ProcessingPipeline(
deinterlace: const QTGMCParameters(enabled: false),
deinterlace: deinterlace ?? const QTGMCParameters(enabled: false),
descratch: descratch ?? const DeScratchParameters(),
spotless: spotless ?? const SpotLessParameters(),
noiseReduction: noiseReduction ?? const NoiseReductionParameters(),
Expand Down Expand Up @@ -436,5 +437,25 @@ void main() {
expect(actual['pel'], '1');
print(' PASS');
}, timeout: const Timeout(Duration(minutes: 2)));

// --- IVTC (core.vivtc.VFM + VDecimate) high-bit-depth guard ---
// VFM only accepts 8-bit YUV/GRAY, so IVTC on a 10-bit source (e.g. ProRes
// 422, yuv422p10le) must run field matching on an 8-bit metrics copy while
// emitting full-depth pixels via clip2. Assert that wiring is in the script.
test('ivtc: VFM/VDecimate 8-bit guard + clip2', () async {
final job = buildJob(
testName: 'ivtc_guard',
deinterlace: const QTGMCParameters(
enabled: true, method: DeinterlaceMethod.ivtc, tff: true,
),
);
print(' Generating IVTC script...');
final script = await generateScriptViaWorker(job);
expect(script, contains('bits_per_sample == 8'));
expect(script, contains('core.vivtc.VFM(_ivtc_metrics'));
expect(script, contains('clip2=_ivtc_src'));
expect(script, contains('core.vivtc.VDecimate(clip'));
print(' PASS');
}, timeout: const Timeout(Duration(minutes: 2)));
});
}
100 changes: 100 additions & 0 deletions app/test/integration_high_bit_depth_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/// Full-encode regression test for IVTC on a >8-bit source.
///
/// vivtc.VFM only accepts 8-bit YUV/GRAY, so IVTC on a 10-bit source such as
/// ProRes 422 (yuv422p10le) previously failed at vspipe evaluation with
/// "VFM: input clip must be constant format YUV420P8, ...". The fix runs field
/// matching on an 8-bit metrics copy while emitting the full-depth pixels via
/// VFM's clip2 parameter. This test runs the worker end-to-end on the committed
/// 10-bit ProRes fixture and asserts a valid output — it fails on the pre-fix
/// pipeline (the encode aborts) and passes with the guard in place.
///
/// The fixture (Tests/TestResources/prores422_10bit_telecine.mov) is produced
/// by Tests/TestResources/generate_prores422_10bit_test.sh.
///
/// Heavy (full-encode) — runs in the nightly workflow, not the push gate.
@Tags(['heavy'])
library;

// ignore_for_file: avoid_print — these tests print diagnostics to the test log.

import 'dart:io';

import 'package:flutter_test/flutter_test.dart';
import 'package:uuid/uuid.dart';

import 'package:vapourbox/models/encoding_settings.dart';
import 'package:vapourbox/models/processing_pipeline.dart';
import 'package:vapourbox/models/qtgmc_parameters.dart';
import 'package:vapourbox/models/video_job.dart';

import 'support/worker_harness.dart';

String get _outDir => '${WorkerHarness.outputDir}/high_bit_depth';
String get _proResFixture =>
'${WorkerHarness.repoRoot}/Tests/TestResources/prores422_10bit_telecine.mov';

void main() {
setUpAll(() async {
await WorkerHarness.ensureReady();
await Directory(_outDir).create(recursive: true);
});

group('IVTC on high-bit-depth source (full encode)', () {
test('IVTC runs end-to-end on 10-bit ProRes 422 (yuv422p10le)', () async {
expect(File(_proResFixture).existsSync(), isTrue,
reason: 'fixture missing — run generate_prores422_10bit_test.sh');

// Probe the fixture the way the app does before building a job, so the
// worker's pipe decoder gets the right dimensions/format. Confirm it is
// genuinely a >8-bit source (otherwise the test would not exercise the
// VFM 8-bit guard).
final src = await WorkerHarness.firstStream(_proResFixture,
selector: 'v:0',
entries: ['pix_fmt', 'width', 'height', 'nb_read_frames'],
countFrames: true);
expect(src, isNotNull);
final pixFmt = src!['pix_fmt'] as String;
print(' fixture: $pixFmt ${src['width']}x${src['height']} '
'${src['nb_read_frames']} frames');
expect(pixFmt, contains('10'),
reason: 'fixture must be >8-bit to exercise the VFM guard');

final frames = int.parse(src['nb_read_frames'] as String);
final job = VideoJob(
id: const Uuid().v4(),
inputPath: _proResFixture,
outputPath: '$_outDir/ivtc_10bit.mkv',
processingPipeline: const ProcessingPipeline(
deinterlace: QTGMCParameters(
enabled: true,
method: DeinterlaceMethod.ivtc,
tff: true,
),
),
encodingSettings: const EncodingSettings(
codec: VideoCodec.h264,
container: ContainerFormat.mkv,
audioMode: AudioMode.none,
),
detectedFieldOrder: FieldOrder.topFieldFirst,
totalFrames: frames,
inputFrameRate: 29.97,
inputWidth: int.parse(src['width'].toString()),
inputHeight: int.parse(src['height'].toString()),
inputPixelFormat: pixFmt,
);

final result =
await WorkerHarness.runJob(job.toJson(), label: 'ivtc_10bit');
expect(result.success, isTrue, reason: result.error);
expect(File(result.outputPath!).existsSync(), isTrue,
reason: 'output file should exist');

final out = await WorkerHarness.firstStream(result.outputPath!,
selector: 'v:0', entries: ['codec_name', 'width', 'height']);
expect(out, isNotNull, reason: 'output should contain a video stream');
print(' output video: ${out?['codec_name']} '
'${out?['width']}x${out?['height']}');
}, timeout: const Timeout(Duration(minutes: 5)));
});
}
11 changes: 9 additions & 2 deletions worker/templates/pipeline_template.vpy
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,13 @@ clip = haf.QTGMC(
{{/DEINT_QTGMC}}
{{#DEINT_IVTC}}
# IVTC: Inverse Telecine (VFM field matching + VDecimate)
clip = core.vivtc.VFM(clip, order={{IVTC_ORDER}},
# VFM only accepts 8-bit YUV/GRAY. For higher bit-depth sources (e.g. 10-bit
# ProRes 422, yuv422p10le) run the field-matching/decimation decisions on an
# 8-bit copy but take the output pixels from the full-depth clip via clip2, so
# no precision is lost.
_ivtc_src = clip
_ivtc_metrics = clip if clip.format.bits_per_sample == 8 else core.resize.Bilinear(clip, format=clip.format.replace(bits_per_sample=8, sample_type=vs.INTEGER))
clip = core.vivtc.VFM(_ivtc_metrics, order={{IVTC_ORDER}},
{{#IVTC_MODE}}
mode={{IVTC_MODE}},
{{/IVTC_MODE}}
Expand All @@ -312,7 +318,8 @@ clip = core.vivtc.VFM(clip, order={{IVTC_ORDER}},
{{#IVTC_BLOCK_Y}}
blocky={{IVTC_BLOCK_Y}},
{{/IVTC_BLOCK_Y}}
)
clip2=_ivtc_src)
# VFM output is full-depth again; VDecimate accepts 8..16 bit natively.
clip = core.vivtc.VDecimate(clip,
{{#IVTC_CYCLE}}
cycle={{IVTC_CYCLE}},
Expand Down
9 changes: 7 additions & 2 deletions worker/templates/preview_template.vpy
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,12 @@ clip = haf.QTGMC(
{{/DEINT_QTGMC}}
{{#DEINT_IVTC}}
# IVTC Preview: VFM field matching only (no VDecimate for single-frame preview)
clip = core.vivtc.VFM(clip, order={{IVTC_ORDER}},
# VFM only accepts 8-bit YUV/GRAY. For higher bit-depth sources (e.g. 10-bit
# ProRes 422, yuv422p10le) run the field matching on an 8-bit copy but take the
# output pixels from the full-depth clip via clip2, so no precision is lost.
_ivtc_src = clip
_ivtc_metrics = clip if clip.format.bits_per_sample == 8 else core.resize.Bilinear(clip, format=clip.format.replace(bits_per_sample=8, sample_type=vs.INTEGER))
clip = core.vivtc.VFM(_ivtc_metrics, order={{IVTC_ORDER}},
{{#IVTC_MODE}}
mode={{IVTC_MODE}},
{{/IVTC_MODE}}
Expand All @@ -289,7 +294,7 @@ clip = core.vivtc.VFM(clip, order={{IVTC_ORDER}},
{{#IVTC_BLOCK_Y}}
blocky={{IVTC_BLOCK_Y}},
{{/IVTC_BLOCK_Y}}
)
clip2=_ivtc_src)
{{/DEINT_IVTC}}
{{/DEINTERLACE}}

Expand Down
23 changes: 23 additions & 0 deletions worker/tests/filter_integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1977,3 +1977,26 @@ fn test_56_frame_count_mapping() {
assert_eq!(span.start, 10);
assert!(span.exact);
}

#[test]
fn test_57_ivtc_high_bit_depth_guard() {
// VFM only accepts 8-bit YUV/GRAY, so IVTC on a higher bit-depth source
// (e.g. 10-bit ProRes 422, yuv422p10le) must run field matching on an 8-bit
// metrics copy while emitting full-depth pixels via clip2. Verify the guard
// and clip2 wiring appear in the generated script. (The telecine fixture is
// 8-bit, so this also confirms the guarded path still runs end-to-end.)
create_output_dir();

let mut job = create_ivtc_base_job("test_57_ivtc_high_bit_depth_guard");
job.processing_pipeline = Some(ProcessingPipeline {
deinterlace: job.qtgmc_parameters.clone(),
..ProcessingPipeline::default()
});

run_job_and_verify(&job, "IVTC - High Bit Depth Guard", &[
"bits_per_sample == 8",
"core.vivtc.VFM(_ivtc_metrics",
"clip2=_ivtc_src",
"core.vivtc.VDecimate(clip",
]).unwrap();
}
Loading