diff --git a/Tests/TestResources/generate_prores422_10bit_test.sh b/Tests/TestResources/generate_prores422_10bit_test.sh new file mode 100755 index 0000000..af086db --- /dev/null +++ b/Tests/TestResources/generate_prores422_10bit_test.sh @@ -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 diff --git a/Tests/TestResources/prores422_10bit_telecine.mov b/Tests/TestResources/prores422_10bit_telecine.mov new file mode 100644 index 0000000..513dbff Binary files /dev/null and b/Tests/TestResources/prores422_10bit_telecine.mov differ diff --git a/app/test/integration_filter_parameters_test.dart b/app/test/integration_filter_parameters_test.dart index 569e9fd..6a0d734 100644 --- a/app/test/integration_filter_parameters_test.dart +++ b/app/test/integration_filter_parameters_test.dart @@ -151,6 +151,7 @@ void validateParams({ VideoJob buildJob({ required String testName, + QTGMCParameters? deinterlace, NoiseReductionParameters? noiseReduction, DehaloParameters? dehalo, DeblockParameters? deblock, @@ -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(), @@ -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))); }); } diff --git a/app/test/integration_high_bit_depth_test.dart b/app/test/integration_high_bit_depth_test.dart new file mode 100644 index 0000000..a8a3934 --- /dev/null +++ b/app/test/integration_high_bit_depth_test.dart @@ -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))); + }); +} diff --git a/worker/templates/pipeline_template.vpy b/worker/templates/pipeline_template.vpy index 03f58d0..3a76a81 100644 --- a/worker/templates/pipeline_template.vpy +++ b/worker/templates/pipeline_template.vpy @@ -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}} @@ -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}}, diff --git a/worker/templates/preview_template.vpy b/worker/templates/preview_template.vpy index faed109..23f9dc2 100644 --- a/worker/templates/preview_template.vpy +++ b/worker/templates/preview_template.vpy @@ -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}} @@ -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}} diff --git a/worker/tests/filter_integration_test.rs b/worker/tests/filter_integration_test.rs index ae2fb16..63026d2 100644 --- a/worker/tests/filter_integration_test.rs +++ b/worker/tests/filter_integration_test.rs @@ -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(); +}