From cc3768f851256b1197223c9b14cb3985dea588f0 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Sat, 26 Sep 2026 19:01:38 +1000 Subject: [PATCH] Decode clean-aperture sources at their full stored size, never resampled pal-sd-25.mov is 720x576 per ffprobe but carries a QuickTime clean-aperture (clap) atom. FFmpeg 7.1+ applies container cropping by default, so it decoded at 702x576, and the decoder's `-s 720x576` (added to stop the resulting raw-stream desync) silently rescaled the crop back to 720: the source was cropped and resampled, 2.6% stretched, before any filter ran. - New worker/src/source_decode.rs builds both decoders (encode + preview): `-apply_cropping codec` before -i (drop container crop, keep the SPS crop ffprobe's width/height already include), no -s, and a size-guard crop filter that passes the probed size untouched and fails the decode on any other size, including a mid-stream resolution change. The worker reports that failure with an explanation ahead of vspipe/encoder errors. - The worker probes width/height when a job omits them, instead of the 720x480 fallback the guard would now reject (and -s used to squeeze to). - The app's before-frame and thumbnails decode the same full frame (PreviewGenerator.sourceDecodeOptions). - Tests: source_decode unit tests on the emitted args; heavy integration_clean_aperture_test.dart requires a passthrough FFV1 encode of the clap MOV (and an MKV PixelCrop remux) to be bit-identical to a full-frame decode, and a mid-stream size change to fail with the explanation. All three fail against the previous worker. - CLAUDE.md rule + dated write-up in docs/ENGINEERING_NOTES.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014GLXdGLfPwgYjW1AkonGqN --- CLAUDE.md | 26 ++ app/lib/services/preview_generator.dart | 12 + app/test/integration_clean_aperture_test.dart | 271 +++++++++++++++++ docs/ENGINEERING_NOTES.md | 99 +++++++ worker/src/dependency_locator.rs | 28 ++ worker/src/lib.rs | 1 + worker/src/main.rs | 30 +- worker/src/pipeline_executor.rs | 127 ++++---- worker/src/source_decode.rs | 274 ++++++++++++++++++ worker/tests/preview_integration_test.rs | 19 +- 10 files changed, 807 insertions(+), 80 deletions(-) create mode 100644 app/test/integration_clean_aperture_test.dart create mode 100644 worker/src/source_decode.rs diff --git a/CLAUDE.md b/CLAUDE.md index ad22d58..bccf2b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,6 +100,7 @@ VapourBox/ | `worker/src/script_generator.rs` | Template substitution for .vpy | | `worker/src/pipeline_executor.rs` | vspipe \| ffmpeg execution | | `worker/src/pixel_format.rs` | Source `pix_fmt` → pipe format (see "Source Pixel Formats") | +| `worker/src/source_decode.rs` | Decoder ffmpeg args for encode + preview: full stored frame, size-guarded (see "Source Frame Size") | | `worker/templates/pipeline_template.vpy` | VapourSynth script template | | `worker/tests/filter_integration_test.rs` | Filter integration tests | @@ -731,6 +732,31 @@ it — if they disagree, the byte stream desyncs from the frame geometry `test_native_formats_match_pipe_source` parses the Python file and fails if they drift. +### Source Frame Size: decode the stored frame, never resample to fit + +The frame size must agree the same way, and `worker/src/source_decode.rs` is +the **single** builder of both decoders' arguments (encode + preview): + +- **`-apply_cropping codec` before `-i`.** FFmpeg (7.1+) applies *container* + cropping by default — a QuickTime clean aperture (`clap`) or Matroska + `PixelCrop` — but ffprobe's `width`/`height` exclude it (it shows only as + `Frame Cropping` side data). `codec` decodes exactly what ffprobe reports; + `none` would be wrong (it undoes the H.264 SPS crop: 1080p decodes at 1088). + The user's Crop controls are the only crop. The app's own source-picture + decodes (`PreviewGenerator.sourceDecodeOptions`) use the same option so the + "before" frame matches the "after". +- **Never `-s` or scale on a decoder.** A size mismatch is an error, not a + resample: the decoder's `-vf` is a size guard (`size_guard_filter`) that + passes the probed size untouched and fails the decode on anything else, + including a mid-stream resolution change, reported via + `explain_decoder_failure` ahead of the vspipe/encoder status. A job that + omits `inputWidth`/`inputHeight` gets them probed by the worker + (`fill_frame_size`), not the old 720x480 fallback. + +`integration_clean_aperture_test.dart` (heavy) asserts a clean-aperture MOV and +MKV come through bit-exact at full size. See docs/ENGINEERING_NOTES.md, +"Clean aperture". + ### High Bit Depth Sources (10-bit ProRes 422 and deeper) A >8-bit source is read natively off the pipe. Two ways this breaks after that: diff --git a/app/lib/services/preview_generator.dart b/app/lib/services/preview_generator.dart index 9542dff..75e659e 100644 --- a/app/lib/services/preview_generator.dart +++ b/app/lib/services/preview_generator.dart @@ -32,6 +32,16 @@ class PreviewGenerator { /// Registering at spawn and discarding on exit closes that window: a process /// is reachable for cancellation from the moment it exists. final Set _livePreviews = {}; + + /// Input options (before `-i`) for every ffmpeg that shows the user the + /// source picture: decode the full stored frame, ignoring container cropping + /// (a QuickTime clean aperture, a Matroska PixelCrop). The worker decodes the + /// same way (`worker/src/source_decode.rs`), so the "before" frame is the + /// same picture — same width, same columns — as the processed "after", and + /// the size ffprobe reports is the size shown. Left at ffmpeg's default, a + /// 720x576 clean-aperture source would show 702 columns here and 720 there. + static const sourceDecodeOptions = ['-apply_cropping', 'codec']; + String? _ffmpegPath; String? _ffprobePath; String? _workerPath; @@ -194,6 +204,7 @@ class PreviewGenerator { [ '-y', '-ss', ss.toStringAsFixed(6), + ...sourceDecodeOptions, '-i', _currentVideoPath!, '-frames:v', '1', '-q:v', '2', @@ -548,6 +559,7 @@ class PreviewGenerator { [ '-y', '-ss', time.toStringAsFixed(3), + ...sourceDecodeOptions, '-i', videoPath, '-vframes', '1', '-vf', 'scale=160:-1', diff --git a/app/test/integration_clean_aperture_test.dart b/app/test/integration_clean_aperture_test.dart new file mode 100644 index 0000000..002aa71 --- /dev/null +++ b/app/test/integration_clean_aperture_test.dart @@ -0,0 +1,271 @@ +/// Clean-aperture sources come through at their full stored size, unresampled. +/// +/// `Tests/TestResources/pal-sd-25.mov` is 720x576 10-bit 4:2:2 ProRes with a +/// QuickTime `clap` (clean aperture) atom: 8 columns off the left, 9 off the +/// right. ffprobe reports it as 720x576 — the crop only shows up as +/// `side_data_list: Frame Cropping` — but FFmpeg (7.1+) applies container +/// cropping by default, so a plain decode comes out 702x576. The worker's +/// decoder used to paper over the mismatch with `-s 720x576`, which silently +/// *rescaled* the 702-wide crop back to 720: the source was cropped and +/// resampled, with no indication, before any filter ran. It now decodes the +/// full stored frame (`-apply_cropping codec`) and refuses, rather than +/// resamples, a frame of any other size — see `worker/src/source_decode.rs`. +/// +/// "Unresampled" is asserted as bit-exactness: a passthrough job encoded to +/// FFV1 must hash identically to a direct full-frame decode of the source. A +/// rescaled picture cannot pass that, however close it looks; the frame size +/// alone would (the old `-s` also produced 720x576). +/// +/// Matroska carries the same crop as `PixelCrop*` elements, exported the same +/// way, so the MOV is also remuxed to MKV at run time and checked the same way. +/// +/// Heavy (full encode + preview) — nightly, 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:path/path.dart' as p; +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 'package:vapourbox/services/preview_generator.dart'; + +import 'support/worker_harness.dart'; + +String get _outDir => '${WorkerHarness.outputDir}/clean_aperture'; + +String get _mov => + p.join(WorkerHarness.repoRoot, 'Tests', 'TestResources', 'pal-sd-25.mov'); + +const _width = 720; +const _height = 576; +const _pixFmt = 'yuv422p10le'; +const _frames = 10; + +/// The source as the app probes it: ffprobe's `width`/`height`, plus the +/// container crop (which ffprobe reports only as side data). +Future<({int width, int height, String pixFmt, int cropLeft, int cropRight})> + _probe(String path) async { + final json = await WorkerHarness.ffprobeJson([ + '-v', 'error', + '-select_streams', 'v:0', + '-show_entries', 'stream=width,height,pix_fmt:stream_side_data', + '-of', 'json', + path, + ]); + final s = (json['streams'] as List).first as Map; + final crop = ((s['side_data_list'] as List?) ?? const []) + .cast>() + .firstWhere((d) => d['side_data_type'] == 'Frame Cropping', + orElse: () => const {}); + return ( + width: s['width'] as int, + height: s['height'] as int, + pixFmt: s['pix_fmt'] as String, + cropLeft: (crop['crop_left'] as int?) ?? 0, + cropRight: (crop['crop_right'] as int?) ?? 0, + ); +} + +/// Width of the first frame ffmpeg decodes with [inputOptions] before `-i`. +Future _decodedWidth(String path, List inputOptions) async { + final r = await Process.run( + WorkerHarness.ffmpegPath, + [ + '-v', 'error', + ...inputOptions, + '-i', path, + '-map', '0:v:0', + '-frames:v', '1', + '-f', 'rawvideo', '-pix_fmt', _pixFmt, + '-', + ], + environment: WorkerHarness.ffmpegEnv, + stdoutEncoding: null, + ); + expect(r.exitCode, 0, reason: 'decode of $path failed: ${r.stderr}'); + final bytes = (r.stdout as List).length; + // yuv422p10le: 2 bytes/sample, luma + two half-width chroma planes = 4 B/px. + return bytes ~/ (_height * 4); +} + +/// MD5 of the first [_frames] frames of [path], decoded to raw [_pixFmt]. +Future _rawMd5(String path, {List inputOptions = const []}) async { + final r = await Process.run( + WorkerHarness.ffmpegPath, + [ + '-v', 'error', + ...inputOptions, + '-i', path, + '-map', '0:v:0', + '-frames:v', '$_frames', + '-c:v', 'rawvideo', '-pix_fmt', _pixFmt, + '-f', 'md5', '-', + ], + environment: WorkerHarness.ffmpegEnv, + ); + expect(r.exitCode, 0, reason: 'md5 of $path failed: ${r.stderr}'); + return (r.stdout as String).trim(); +} + +VideoJob _passthroughJob(String input, String name) => VideoJob( + id: const Uuid().v4(), + inputPath: input, + outputPath: '$_outDir/$name.mkv', + processingPipeline: const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + ), + encodingSettings: const EncodingSettings( + codec: VideoCodec.ffv1, + container: ContainerFormat.mkv, + audioMode: AudioMode.none, + ), + totalFrames: _frames, + inputFrameRate: 25.0, + inputWidth: _width, + inputHeight: _height, + inputPixelFormat: _pixFmt, + ); + +Future _expectFullFrameUnresampled(String source, String label) async { + // The fixture really is the case under test — otherwise this passes vacuously. + final probed = await _probe(source); + expect((probed.width, probed.height, probed.pixFmt), (_width, _height, _pixFmt), + reason: '$label: ffprobe must report the full stored size'); + expect(probed.cropLeft + probed.cropRight, greaterThan(0), + reason: '$label: fixture must carry a container crop'); + final defaultWidth = await _decodedWidth(source, const []); + expect(defaultWidth, lessThan(_width), + reason: "$label: ffmpeg's default decode must apply the crop, or this " + 'test no longer exercises the bug'); + print(' $label: probed ${probed.width}x${probed.height}, crop ' + 'L${probed.cropLeft}/R${probed.cropRight}, default decode ${defaultWidth}w'); + + // The app's own "before" frame decodes the same full frame the worker does. + expect(await _decodedWidth(source, PreviewGenerator.sourceDecodeOptions), _width, + reason: '$label: the before/after "before" frame must be the full frame'); + + // Encode: passthrough to lossless, bit-identical to the full stored frame. + final result = await WorkerHarness.runJob( + _passthroughJob(source, label).toJson(), + label: label); + final tail = result.logs.length > 25 + ? result.logs.sublist(result.logs.length - 25) + : result.logs; + expect(result.success, isTrue, + reason: '$label failed: ${result.error}\n${tail.join('\n')}'); + final out = await WorkerHarness.firstStream(result.outputPath!, + selector: 'v:0', entries: ['width', 'height', 'pix_fmt']); + expect((out?['width'], out?['height'], out?['pix_fmt']), + (_width, _height, _pixFmt), + reason: '$label: output must keep the full stored frame and format'); + + final expected = await _rawMd5(source, + inputOptions: PreviewGenerator.sourceDecodeOptions); + final actual = await _rawMd5(result.outputPath!); + print(' $label: source $expected / output $actual'); + expect(actual, expected, + reason: '$label: output pixels differ from the full stored frame — the ' + 'source was cropped and/or resampled on the way in'); + + // Preview: the same full frame, no desync (a size mismatch here reads as + // garbage or a length mismatch rather than a subtle difference). + final preview = await WorkerHarness.runPreview( + _passthroughJob(source, '${label}_preview').toJson(), + frame: 5); + expect(preview.png, isNotNull, + reason: '$label preview failed: ${preview.error}\n${preview.logs}'); + final previewRgb = await WorkerHarness.imageToRgb24(preview.png!, label: label); + expect(previewRgb.length, _width * _height * 3, + reason: '$label: preview must be the full ${_width}x$_height frame'); +} + +void main() { + setUpAll(() async { + await WorkerHarness.ensureReady(); + await Directory(_outDir).create(recursive: true); + }); + + test('clean-aperture MOV is processed at its full stored size, unresampled', + () async { + await _expectFullFrameUnresampled(_mov, 'clap_mov'); + }, timeout: const Timeout(Duration(minutes: 5))); + + test('Matroska PixelCrop is treated the same way', () async { + final mkv = '$_outDir/pal_pixelcrop.mkv'; + final r = await Process.run( + WorkerHarness.ffmpegPath, + [ + '-v', 'error', '-y', + '-i', _mov, + '-map', '0:v:0', + '-frames:v', '${_frames + 5}', + '-c', 'copy', + mkv, + ], + environment: WorkerHarness.ffmpegEnv, + ); + expect(r.exitCode, 0, reason: 'remux to MKV failed: ${r.stderr}'); + await _expectFullFrameUnresampled(mkv, 'pixelcrop_mkv'); + }, timeout: const Timeout(Duration(minutes: 5))); + + test('a source that changes size mid-stream fails instead of resampling', + () async { + // Two MPEG-TS segments at different widths, concatenated byte-wise: ffprobe + // (like the app) reports the first segment's size. + final a = '$_outDir/size_a.ts'; + final b = '$_outDir/size_b.ts'; + final joined = '$_outDir/size_change.ts'; + for (final (path, size, offset) in [(a, '720x576', 0), (b, '544x576', 1)]) { + final r = await Process.run( + WorkerHarness.ffmpegPath, + [ + '-v', 'error', '-y', + '-f', 'lavfi', '-i', 'testsrc2=s=$size:r=25:d=1', + '-c:v', 'mpeg2video', '-pix_fmt', 'yuv420p', + '-output_ts_offset', '$offset', + '-f', 'mpegts', path, + ], + environment: WorkerHarness.ffmpegEnv, + ); + expect(r.exitCode, 0, reason: 'segment $size failed: ${r.stderr}'); + } + await File(joined).writeAsBytes([ + ...await File(a).readAsBytes(), + ...await File(b).readAsBytes(), + ]); + final probed = await _probe(joined); + expect((probed.width, probed.height), (720, 576)); + + final job = VideoJob( + id: const Uuid().v4(), + inputPath: joined, + outputPath: '$_outDir/size_change.mkv', + processingPipeline: const ProcessingPipeline( + deinterlace: QTGMCParameters(enabled: false), + ), + encodingSettings: const EncodingSettings( + codec: VideoCodec.ffv1, + container: ContainerFormat.mkv, + audioMode: AudioMode.none, + ), + totalFrames: 50, + inputFrameRate: 25.0, + inputWidth: 720, + inputHeight: 576, + inputPixelFormat: 'yuv420p', + ); + final result = await WorkerHarness.runJob(job.toJson(), label: 'size_change'); + expect(result.success, isFalse, + reason: 'a mid-stream size change must fail, not be resampled to fit'); + expect(result.error, contains('different frame size'), + reason: 'the failure must say why: ${result.error}'); + }, timeout: const Timeout(Duration(minutes: 5))); +} diff --git a/docs/ENGINEERING_NOTES.md b/docs/ENGINEERING_NOTES.md index f11740c..6882179 100644 --- a/docs/ENGINEERING_NOTES.md +++ b/docs/ENGINEERING_NOTES.md @@ -1139,6 +1139,105 @@ close that gap by design: it generates and inspects the `.vpy` and never runs > than seeking, because an input seek lands on the nearest keyframe and would > silently compare the wrong frame. +### Clean aperture: decode the stored frame, never resample to fit (2026-09-26) + +**Found:** `Tests/TestResources/pal-sd-25.mov` (720x576 `yuv422p10le` ProRes) +carries a QuickTime `clap` (clean aperture) atom — 8 columns off the left, 9 off +the right. Since FFmpeg 7.1 the CLI applies **container** cropping by default: +`clap`, and Matroska's `PixelCrop*` elements, are exported as frame-cropping +side data and cut off at decode time. So the bundled 9.0.1 decodes that file at +**702x576** (1,617,408 bytes/frame) while ffprobe reports `width=720 +height=576` — ffprobe's `width`/`height` do **not** include a container crop; +it appears only as `side_data_list: [{side_data_type: "Frame Cropping", +crop_left: 8, crop_right: 9, …}]`. (703 columns rounds down to 702 for 4:2:2.) + +The first fix for the resulting garbage (June 2026, `8aad3f3`) forced the +decoder to `-s 720x576` on both paths. That stopped the desync by **rescaling +the 702-wide crop back to 720**: every clean-aperture source was cropped *and* +resampled before any filter ran, stretched 2.6% horizontally while the output +was still stamped with the source SAR, and the user's Crop values applied to an +already-altered picture. The regression test agreed with it because its +reference was scaled to 720x576 the same way. + +**Mechanism, measured against 9.0.1:** `-apply_cropping` (an *input* option) +takes `none`/`all`/`codec`/`container`, default `all`: + +| `-apply_cropping` | pal-sd-25.mov | H.264 1080p (1088 coded) | +|---|---|---| +| default / `all` / `container` | 702x576 | 1920x1080 | +| `codec` | **720x576** | **1920x1080** | +| `none` | 720x576 | 1920x**1088** | + +`codec` is the one that equals ffprobe's `width`/`height` in both cases — +ffprobe *does* include the bitstream's own (SPS) crop. `none` would break every +H.264/HEVC 1080p source. + +**Decision:** decode the full stored frame. `worker/src/source_decode.rs` builds +both decoders (encode + preview) with `-apply_cropping codec` before `-i`, and +no `-s`. Reasons: + +- The pipeline then sees exactly what ffprobe reported, so the job's + `input_width`/`input_height`, `pipe_source`'s frame size and the decode agree + by construction, and the **Crop controls are the only crop**. +- The clean aperture is a *display* hint (the nominal analogue active area of a + 720-wide SD frame), not damaged picture; the 17 columns are real samples, and + an archival tool shouldn't discard them unasked. +- **SAR is unchanged** by a clean aperture: it describes the pixel grid, and the + grid is the same whether or not the edges are cut. ffprobe reports 59:54 either + way, the encode stamps it as before, and the full frame at 59:54 is + geometrically correct (DAR 295:216 over 720 columns; the clean aperture alone + would be 767:576). What is lost is only the `clap` hint itself — the output + doesn't carry it — which a user who wants the clean aperture replaces with an + 8/9 Crop. + +**A size mismatch is now an error, not a resample.** Removing `-s` alone would +return a mismatch to the original silent desync, so the decoder carries a size +guard, `crop=w='if(eq(iw,W),iw,0)':h='if(eq(ih,H),ih,0)':x=0:y=0:exact=1`. At +the right size that is a full-frame, zero-copy crop (measured bit-identical to +no filter); at any other size `crop` rejects the zero width and the decode +fails. `crop` is re-configured on every input size change, so it also catches a +**mid-stream resolution change** (e.g. a DVB recording switching 720 -> 544 at an +ad break), which with `-s` was silently rescaled and with nothing would have +desynced. The worker recognises the crop filter's message +(`SIZE_GUARD_SIGNATURE`) and reports an explanation *before* the vspipe/encoder +status — `pipe_source` pads a short stream with its last frame, so without that +ordering the job would "succeed" with a frozen tail. This is a deliberate +behaviour change for mid-stream size changes: they fail with a clear message +rather than producing a distorted segment. + +The guard immediately exposed a second silent resample: a job **without** +`input_width`/`input_height` fell back to 720x480, and `-s` squeezed whatever +the source was into that. `integration_video_trimming_test.dart` had been +encoding the 720x576 `interlaced_test.avi` to 720x480 all along, and passing, +because nothing asserted the size. The app always sends ffprobe's size, but the +worker now probes it itself when a job omits it (`fill_frame_size` in +`main.rs`, `DependencyLocator::probe_frame_size`), the same way it already +probed a missing frame count. + +**Checked unchanged:** MKV/AVI/TS/MP4/DV fixtures without container crop decode +identically with and without the option (`small_clip.mp4`, +`soft_telecine_test.mkv`, `pal-dvbt-fieldcoded-25i.ts`, the two AVIs, +`prores422_10bit_telecine.mov`, DV from stdin); DVD import extracts MPEG-PS to a +file first and MPEG-2 has no container crop. The app's own picture decodes — +the "before" frame and the timeline thumbnails in `preview_generator.dart` — +use the same option (`PreviewGenerator.sourceDecodeOptions`), or the +before/after comparison would put a 702-column frame beside a 720-column one. +`field_order_detector`'s `idet` pass doesn't care about geometry and was left +alone. The option requires FFmpeg >= 7.1, which the 9.0 pin guarantees. + +**Tests:** `source_decode.rs` unit tests assert both decoders carry +`-apply_cropping codec` *before* `-i`, never `-s` or a scale, and exactly one +`-vf` (the guard). `integration_clean_aperture_test.dart` (heavy) encodes +pal-sd-25.mov — and an MKV remux of it, generated at run time, for `PixelCrop` — +as a passthrough to FFV1 and requires the output's raw MD5 to equal a +full-frame decode of the source: a resampled picture can't pass that, while the +frame size alone would (the old `-s` also gave 720x576). It also builds a +mid-stream 720->544 TS and requires the job to fail with the explanation. Against +the pre-fix worker all three fail (MD5 `848ce4a8…` vs `784b2e46…`; the size +change encodes "successfully"). `preview_integration_test.rs`'s reference now +decodes the full frame unscaled; its 20.0 threshold is too loose to separate +the two (5.8 after vs 9.0 before) — the heavy Dart test is the one that does. + ### Suggestions and advice are hints, and must stay hints Two small pure-function models sit beside the pass list, and both are diff --git a/worker/src/dependency_locator.rs b/worker/src/dependency_locator.rs index 4ad660d..190356b 100644 --- a/worker/src/dependency_locator.rs +++ b/worker/src/dependency_locator.rs @@ -425,6 +425,34 @@ impl DependencyLocator { .filter(|&n| n > 0) } + /// ffprobe's `width`x`height` for the first video stream — the size the + /// decoder produces (see `source_decode`). Used when a job omits + /// `input_width`/`input_height`, so a direct caller gets the source's real + /// size rather than the 720x480 fallback, which the decoder's size guard + /// would (rightly) reject for any other source. + pub fn probe_frame_size(&self, input: &str) -> Option<(i32, i32)> { + let ffprobe = self.ffprobe_path().ok()?; + let out = Command::new(&ffprobe) + .args([ + "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=width,height", + "-of", "csv=p=0:s=x", + input, + ]) + .envs(self.build_environment()) + .stderr(Stdio::null()) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout); + let (w, h) = text.lines().next()?.trim().split_once('x')?; + let (w, h) = (w.parse::().ok()?, h.parse::().ok()?); + (w > 0 && h > 0).then_some((w, h)) + } + /// Get the path to ffmpeg executable. pub fn ffmpeg_path(&self) -> Result { let exe_name = if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" }; diff --git a/worker/src/lib.rs b/worker/src/lib.rs index edff205..342340f 100644 --- a/worker/src/lib.rs +++ b/worker/src/lib.rs @@ -9,6 +9,7 @@ pub mod filter_registry; pub mod filter_schema; pub mod pipeline_executor; pub mod pixel_format; +pub mod source_decode; pub mod progress_reporter; pub mod schema_script_generator; pub mod script_generator; diff --git a/worker/src/main.rs b/worker/src/main.rs index 5a73020..44579c4 100644 --- a/worker/src/main.rs +++ b/worker/src/main.rs @@ -23,6 +23,7 @@ mod dependency_locator; mod dvd_reader; mod pipeline_executor; mod pixel_format; +mod source_decode; mod progress_reporter; mod script_generator; mod subtitle_generator; @@ -374,13 +375,15 @@ fn run_preview_mode(args: &Args) -> ExitCode { } }; - let job: VideoJob = match serde_json::from_str(&config_content) { + let mut job: VideoJob = match serde_json::from_str(&config_content) { Ok(j) => j, Err(e) => { eprintln!("Error parsing config: {}", e); return ExitCode::from(1); } }; + let deps = dependency_locator::DependencyLocator::new().ok(); + fill_frame_size(&mut job, deps.as_ref(), |msg| eprintln!("{}", msg)); // The frame index is passed straight through to the worker — no // time-conversion round-trip — so the rendered frame is exactly the one the @@ -405,6 +408,24 @@ fn run_preview_mode(args: &Args) -> ExitCode { } } +/// Fill a missing `input_width`/`input_height` from ffprobe. A job that carries +/// both is never touched; if either is missing and the probe succeeds, both +/// come from the probe; a failed probe leaves the old fallback in place. +fn fill_frame_size( + job: &mut VideoJob, + deps: Option<&dependency_locator::DependencyLocator>, + log: impl Fn(&str), +) { + if job.input_width.is_some() && job.input_height.is_some() { + return; + } + if let Some((w, h)) = deps.and_then(|d| d.probe_frame_size(&job.input_path)) { + log(&format!("Probed input frame size: {}x{}", w, h)); + job.input_width = Some(w); + job.input_height = Some(h); + } +} + fn run_worker( config_path: &PathBuf, reporter: &ProgressReporter, @@ -513,6 +534,13 @@ fn run_worker( } } + // Same for the frame size: the app always sends ffprobe's width/height, but + // a direct caller may not, and the old 720x480 fallback is now a decode + // error for any other source rather than a silent rescale (source_decode). + fill_frame_size(&mut job, deps.as_ref(), |msg| { + reporter.send_log(models::LogLevel::Debug, msg) + }); + // Generate VapourSynth script reporter.send_log(models::LogLevel::Info, "Generating VapourSynth script..."); // Detect OpenCL availability so QTGMC falls back to CPU NNEDI3 on machines diff --git a/worker/src/pipeline_executor.rs b/worker/src/pipeline_executor.rs index 505b6f3..e63cf5f 100644 --- a/worker/src/pipeline_executor.rs +++ b/worker/src/pipeline_executor.rs @@ -151,6 +151,7 @@ fn preview_window(target: i32, radius: i32) -> (i32, i32, i32) { use crate::dependency_locator::DependencyLocator; use crate::models::{AspectDeclaration, AudioMode, ContainerFormat, DeinterlaceMethod, EncoderFamily, LogLevel, ProgressInfo, SubtitleOutput, VideoCodec, VideoJob}; use crate::pixel_format; +use crate::source_decode; use crate::progress_reporter::ProgressReporter; use crate::script_generator::{PreviewParams, ScriptGenerator}; @@ -265,61 +266,23 @@ impl PipelineExecutor { let pix_fmt = pipe_format.name.as_str(); // Declared frame size — must match the dimensions pipe_source uses in // the template (see script_generator), so the raw stream stays aligned. + // The decoder is built to produce exactly this size without resampling + // (full stored frame, size-guarded) — see source_decode.rs. let width = job.input_width.unwrap_or(720); let height = job.input_height.unwrap_or(480); - // Build decoder FFmpeg arguments - // Frame trimming is handled here (faster than VapourSynth trimming since FFmpeg - // can seek using the container index) - let mut decoder_args: Vec = Vec::new(); - - // Seek to the start frame if specified (before -i for an accurate, - // fast decode-seek). Target the midpoint of the interval *before* - // `start` so the first kept frame (PTS >= seek time) is exactly `start`, - // not start±1 — the boundary `start/fps` is ambiguous under PTS rounding. - if let Some(start) = job.start_frame { - if start > 0 { - let fps = job.input_frame_rate.unwrap_or(29.97); - let seek_time = ((start as f64 - 0.5) / fps).max(0.0); - decoder_args.push("-ss".to_string()); - decoder_args.push(format!("{:.6}", seek_time)); - } - } - - decoder_args.extend([ - "-i".to_string(), job.input_path.clone(), - "-map".to_string(), "0:v:0".to_string(), // only first video stream - // Force the declared dimensions. Some sources decode to a different - // size than ffprobe reports (e.g. a clean aperture: 720x576 coded -> - // 702x576 decoded), which otherwise desyncs the raw frame stream from - // what pipe_source expects -> garbage output. - "-s".to_string(), format!("{}x{}", width, height), - "-f".to_string(), "rawvideo".to_string(), - "-pix_fmt".to_string(), pix_fmt.to_string(), - "-v".to_string(), "error".to_string(), - ]); - - // Limit decoder frame count to match what pipe_source expects. - // Without this, the decoder can send more frames than TOTAL_FRAMES - // (e.g. MPEG-2 telecine produces duplicate frames), causing a broken - // pipe when vspipe closes stdin before the decoder finishes. - // For trimmed exports, limit to the trimmed range instead. - if let (Some(start), Some(end)) = (job.start_frame, job.end_frame) { - let count = end - start + 1; - if count > 0 { - decoder_args.push("-frames:v".to_string()); - decoder_args.push(count.to_string()); - } - } else if let Some(end) = job.end_frame { - let count = end + 1; - decoder_args.push("-frames:v".to_string()); - decoder_args.push(count.to_string()); - } else if let Some(total) = job.total_frames { - decoder_args.push("-frames:v".to_string()); - decoder_args.push(total.to_string()); - } - - decoder_args.push("pipe:1".to_string()); // stdout via FFmpeg pipe protocol + // Frame trimming is handled by the decoder (faster than VapourSynth + // trimming since FFmpeg can seek using the container index). + let decoder_args = source_decode::encode_decoder_args( + &job.input_path, + width, + height, + pix_fmt, + job.input_frame_rate, + job.start_frame, + job.end_frame, + job.total_frames, + ); self.reporter.send_log( LogLevel::Debug, @@ -403,12 +366,17 @@ impl PipelineExecutor { let decoder_reporter = self.reporter.clone(); let decoder_stderr_thread = thread::spawn(move || { let reader = BufReader::new(decoder_stderr); - let mut last_line = String::new(); + // Keep a bounded tail: enough to recognise a size-guard failure + // (source_decode::explain_decoder_failure) and to report the cause. + let mut tail: Vec = Vec::new(); for line in reader.lines().map_while(Result::ok) { decoder_reporter.send_log(LogLevel::Debug, &format!("decoder stderr: {}", line)); - last_line = line; + tail.push(line); + if tail.len() > 40 { + tail.remove(0); + } } - last_line + tail.join("\n") }); // Parse vspipe stderr for input info (in background thread) @@ -620,7 +588,7 @@ impl PipelineExecutor { .context("Failed to wait for ffmpeg")?; // Now safe to join threads (pipes are closed, readers will hit EOF) - let _ = decoder_stderr_thread.join(); + let decoder_stderr_tail = decoder_stderr_thread.join().unwrap_or_default(); let vspipe_autoload_failed = vspipe_thread.join().unwrap_or(false); let ffmpeg_stderr_tail = ffmpeg_stderr_thread.join().unwrap_or_default(); @@ -639,6 +607,19 @@ impl PipelineExecutor { ); } + // A decoder that stopped because the source isn't the size it was + // probed at is the ROOT cause of whatever vspipe/ffmpeg then report + // (a short or empty raw stream), so it is surfaced before them. + if let Some(status) = decoder_status { + if !status.success() && !on_cancel() { + if let Some(msg) = + source_decode::explain_decoder_failure(&decoder_stderr_tail, width, height) + { + bail!("{}", msg); + } + } + } + // Check exit codes. // // ffmpeg is checked FIRST on purpose: when the encoder ffmpeg fails @@ -677,7 +658,11 @@ impl PipelineExecutor { if let Some(status) = decoder_status { let code = status.code().unwrap_or(-1); if code != 0 && code != 130 && code != 141 && code != 224 { - bail!("Decoder ffmpeg exited with {}", format_exit_status(&status)); + let tail = decoder_stderr_tail.trim(); + if tail.is_empty() { + bail!("Decoder ffmpeg exited with {}", format_exit_status(&status)); + } + bail!("Decoder ffmpeg exited with {}:\n{}", format_exit_status(&status), tail); } } @@ -1277,23 +1262,18 @@ impl PipelineExecutor { .with_context(|| format!("Failed to create temp dir: {:?}", temp_dir))?; let raw_path = temp_dir.join("frames.raw"); + // Same decode as the encode path: the full stored frame at exactly the + // probed size, never resampled — see source_decode.rs. let extract_result = Command::new(&ffmpeg_path) - .args([ - "-ss", &format!("{:.6}", seek_time), - "-i", &job.input_path, - "-map", "0:v:0", - "-frames:v", &num_frames.to_string(), - // Force the declared dimensions. Some sources decode to a - // different size than ffprobe reports (e.g. a clean aperture: - // 720x576 coded -> 702x576 decoded), which otherwise desyncs the - // raw frame stream from what pipe_source expects -> garbage. - "-s", &format!("{}x{}", width, height), - "-f", "rawvideo", - "-pix_fmt", pix_fmt, - "-v", "error", - "-y", + .args(source_decode::preview_decoder_args( + &job.input_path, + seek_time, + num_frames, + width, + height, + pix_fmt, raw_path.to_string_lossy().as_ref(), - ]) + )) .envs(&env) .stdout(Stdio::null()) .stderr(Stdio::piped()) @@ -1303,6 +1283,9 @@ impl PipelineExecutor { if !extract_result.status.success() { let stderr = String::from_utf8_lossy(&extract_result.stderr); let _ = fs::remove_dir_all(&temp_dir); + if let Some(msg) = source_decode::explain_decoder_failure(&stderr, width, height) { + bail!("{}", msg); + } bail!("Failed to decode frames: {}", stderr); } diff --git a/worker/src/source_decode.rs b/worker/src/source_decode.rs new file mode 100644 index 0000000..2e1021e --- /dev/null +++ b/worker/src/source_decode.rs @@ -0,0 +1,274 @@ +//! The ffmpeg arguments that decode the source into the raw pipe `pipe_source` +//! reads — the single place both the encode and the preview path get them from. +//! +//! `pipe_source.py` sizes every frame from the job's `input_width` x +//! `input_height` (ffprobe's `width`/`height`), so the decoder must emit frames +//! of exactly that size or the byte stream desyncs into garbage. Two rules keep +//! that true without ever resampling the picture: +//! +//! 1. **Decode the full stored frame** — `-apply_cropping codec`. Since FFmpeg +//! 7.1 the CLI applies *container* cropping by default: a QuickTime `clap` +//! (clean aperture) atom or a Matroska `PixelCrop*` element is exported as +//! frame-cropping side data and cut off at decode time. ffprobe's +//! `width`/`height` do **not** include it (they report the full 720x576 of a +//! 720x576 ProRes with a 702-wide clean aperture; the crop only appears as +//! `side_data_list: Frame Cropping`), so the default decode came out 702 wide +//! against a job that said 720. `codec` keeps the *bitstream's* own cropping +//! (the H.264/HEVC SPS crop that turns 1088 coded lines into 1080), which +//! ffprobe's `width`/`height` already include — so the decoded size is the +//! probed size by construction. The user's Crop controls are then the only +//! crop the pipeline ever applies. (`none` would be wrong: it would decode an +//! H.264 1080p stream at 1920x1088.) +//! 2. **A size mismatch is an error, never a resample.** This used to pass +//! `-s WxH`, which "fixed" the clean-aperture desync by silently rescaling the +//! 702-wide crop back to 720 — cropped *and* resampled, with a 2.6% +//! horizontal stretch. [`size_guard_filter`] instead passes frames of exactly +//! the expected size through untouched (a full-frame `crop` is a pointer +//! no-op) and fails the decode on any other size, including a mid-stream +//! resolution change, which with no guard at all would desync into garbage. +//! +//! See docs/ENGINEERING_NOTES.md, "Clean aperture: decode the stored frame". + +/// Input option placed before `-i` on every ffmpeg that decodes the source's +/// pictures for the pipeline (encode decoder, preview decoder). See the module +/// docs for why `codec` and not `none`/`all`. +pub const APPLY_CROPPING: [&str; 2] = ["-apply_cropping", "codec"]; + +/// The message ffmpeg's `crop` filter logs when [`size_guard_filter`] rejects a +/// frame. Used to recognise a guard failure in the decoder's stderr and replace +/// ffmpeg's cryptic wording with an explanation. +pub const SIZE_GUARD_SIGNATURE: &str = "Invalid too big or non positive size"; + +/// A `-vf` that passes `width`x`height` frames through unchanged and fails the +/// decode on anything else. +/// +/// `crop` re-evaluates `w`/`h` whenever its input is (re)configured — at the +/// first frame and again on any mid-stream size change — and rejects a zero +/// size. So a matching frame is a full-frame, zero-copy crop, and a mismatch is +/// a hard error instead of either a rescale or a silently desynced raw stream. +pub fn size_guard_filter(width: i32, height: i32) -> String { + format!( + "crop=w='if(eq(iw\\,{w})\\,iw\\,0)':h='if(eq(ih\\,{h})\\,ih\\,0)':x=0:y=0:exact=1", + w = width, + h = height + ) +} + +/// If a failed decoder's stderr shows the size guard tripped, the error to +/// report instead of ffmpeg's exit code. +pub fn explain_decoder_failure(stderr: &str, width: i32, height: i32) -> Option { + if !stderr.contains(SIZE_GUARD_SIGNATURE) { + return None; + } + Some(format!( + "The source decoded at a different frame size than the {}x{} it was probed at \ + (for example, its resolution changes part-way through). VapourBox does not \ + resample the source to hide this, since that would silently distort it. \ + Trim the job to a section with a single frame size, or convert the source \ + to one size first.", + width, height + )) +} + +/// Everything the encode path's decoder ffmpeg needs, in order. Writes the raw +/// frames to stdout (`pipe:1`) for vspipe. +/// +/// `pix_fmt` must be `pixel_format::decode_pixel_format`'s answer — the same one +/// the generated script declares (see "Source Pixel Formats" in CLAUDE.md). +#[allow(clippy::too_many_arguments)] +pub fn encode_decoder_args( + input_path: &str, + width: i32, + height: i32, + pix_fmt: &str, + input_frame_rate: Option, + start_frame: Option, + end_frame: Option, + total_frames: Option, +) -> Vec { + let mut args: Vec = Vec::new(); + + // Seek to the start frame if specified (before -i for an accurate, + // fast decode-seek). Target the midpoint of the interval *before* + // `start` so the first kept frame (PTS >= seek time) is exactly `start`, + // not start±1 — the boundary `start/fps` is ambiguous under PTS rounding. + if let Some(start) = start_frame { + if start > 0 { + let fps = input_frame_rate.unwrap_or(29.97); + let seek_time = ((start as f64 - 0.5) / fps).max(0.0); + args.push("-ss".to_string()); + args.push(format!("{:.6}", seek_time)); + } + } + + args.extend(APPLY_CROPPING.iter().map(|s| s.to_string())); + args.extend([ + "-i".to_string(), input_path.to_string(), + "-map".to_string(), "0:v:0".to_string(), // only first video stream + "-vf".to_string(), size_guard_filter(width, height), + "-f".to_string(), "rawvideo".to_string(), + "-pix_fmt".to_string(), pix_fmt.to_string(), + "-v".to_string(), "error".to_string(), + ]); + + // Limit decoder frame count to match what pipe_source expects. + // Without this, the decoder can send more frames than TOTAL_FRAMES + // (e.g. MPEG-2 telecine produces duplicate frames), causing a broken + // pipe when vspipe closes stdin before the decoder finishes. + // For trimmed exports, limit to the trimmed range instead. + if let (Some(start), Some(end)) = (start_frame, end_frame) { + let count = end - start + 1; + if count > 0 { + args.push("-frames:v".to_string()); + args.push(count.to_string()); + } + } else if let Some(end) = end_frame { + let count = end + 1; + args.push("-frames:v".to_string()); + args.push(count.to_string()); + } else if let Some(total) = total_frames { + args.push("-frames:v".to_string()); + args.push(total.to_string()); + } + + args.push("pipe:1".to_string()); // stdout via FFmpeg pipe protocol + args +} + +/// The preview path's decoder: `num_frames` raw frames from `seek_time` into +/// `output_path`. Same cropping and size rules as [`encode_decoder_args`]. +pub fn preview_decoder_args( + input_path: &str, + seek_time: f64, + num_frames: i32, + width: i32, + height: i32, + pix_fmt: &str, + output_path: &str, +) -> Vec { + let mut args: Vec = vec!["-ss".to_string(), format!("{:.6}", seek_time)]; + args.extend(APPLY_CROPPING.iter().map(|s| s.to_string())); + args.extend([ + "-i".to_string(), input_path.to_string(), + "-map".to_string(), "0:v:0".to_string(), + "-frames:v".to_string(), num_frames.to_string(), + "-vf".to_string(), size_guard_filter(width, height), + "-f".to_string(), "rawvideo".to_string(), + "-pix_fmt".to_string(), pix_fmt.to_string(), + "-v".to_string(), "error".to_string(), + "-y".to_string(), + output_path.to_string(), + ]); + args +} + +#[cfg(test)] +mod tests { + use super::*; + + fn encode_args() -> Vec { + encode_decoder_args("in.mov", 720, 576, "yuv422p10le", Some(25.0), None, None, Some(100)) + } + + fn preview_args() -> Vec { + preview_decoder_args("in.mov", 0.38, 11, 720, 576, "yuv422p10le", "frames.raw") + } + + fn pos(args: &[String], flag: &str) -> Option { + args.iter().position(|a| a == flag) + } + + /// Both decoders, so every assertion below covers both paths. + fn both() -> [(&'static str, Vec); 2] { + [("encode", encode_args()), ("preview", preview_args())] + } + + #[test] + fn decoders_never_rescale_the_source() { + // `-s WxH` on the decoder output silently resamples a source that + // decodes at another size (a clean aperture: 702 -> 720). Neither it nor + // a scale filter may come back. + for (path, args) in both() { + assert!(pos(&args, "-s").is_none(), "{path} decoder passes -s: {args:?}"); + assert!( + !args.iter().any(|a| a.contains("scale")), + "{path} decoder scales: {args:?}" + ); + } + } + + #[test] + fn decoders_keep_the_container_crop_off() { + // `-apply_cropping codec` is an INPUT option: it must precede `-i`, or + // ffmpeg applies it to the output and the clap crop still happens. + for (path, args) in both() { + let ac = pos(&args, "-apply_cropping") + .unwrap_or_else(|| panic!("{path} decoder has no -apply_cropping: {args:?}")); + assert_eq!(args[ac + 1], "codec", "{path}: must be `codec`, not none/all"); + assert!(ac < pos(&args, "-i").unwrap(), "{path}: -apply_cropping after -i"); + } + } + + #[test] + fn decoders_guard_the_probed_size() { + for (path, args) in both() { + let vf = pos(&args, "-vf").unwrap_or_else(|| panic!("{path}: no -vf")); + assert_eq!(args[vf + 1], size_guard_filter(720, 576), "{path}"); + // Exactly one -vf: ffmpeg honours only the last. + assert_eq!(args.iter().filter(|a| *a == "-vf").count(), 1, "{path}"); + assert!(vf > pos(&args, "-i").unwrap(), "{path}: -vf must be an output option"); + } + } + + #[test] + fn size_guard_is_a_full_frame_crop_that_rejects_other_sizes() { + let g = size_guard_filter(720, 576); + assert_eq!( + g, + "crop=w='if(eq(iw\\,720)\\,iw\\,0)':h='if(eq(ih\\,576)\\,ih\\,0)':x=0:y=0:exact=1" + ); + // Anchored at the origin — never a centred crop of a larger frame. + assert!(g.contains(":x=0:y=0")); + } + + #[test] + fn decoders_use_the_pipe_format() { + for (path, args) in both() { + let pf = pos(&args, "-pix_fmt").unwrap(); + assert_eq!(args[pf + 1], "yuv422p10le", "{path}"); + } + } + + #[test] + fn encode_decoder_trims_and_limits_frames() { + let args = encode_decoder_args("in.mov", 720, 576, "yuv420p", Some(25.0), Some(10), Some(19), None); + // Seek before -i, to the midpoint before the start frame. + let ss = pos(&args, "-ss").unwrap(); + assert!(ss < pos(&args, "-i").unwrap()); + assert_eq!(args[ss + 1], "0.380000"); + let fv = pos(&args, "-frames:v").unwrap(); + assert_eq!(args[fv + 1], "10"); + assert_eq!(args.last().unwrap(), "pipe:1"); + + let untrimmed = encode_args(); + assert!(pos(&untrimmed, "-ss").is_none()); + assert_eq!(untrimmed[pos(&untrimmed, "-frames:v").unwrap() + 1], "100"); + } + + #[test] + fn preview_decoder_writes_the_raw_file() { + let args = preview_args(); + assert_eq!(args[0], "-ss"); + assert_eq!(args[pos(&args, "-frames:v").unwrap() + 1], "11"); + assert_eq!(args.last().unwrap(), "frames.raw"); + } + + #[test] + fn guard_failures_are_explained() { + let stderr = "[Parsed_crop_0 @ 0x6000] Invalid too big or non positive size for width '0' or height '576'\n\ + [Parsed_crop_0 @ 0x6000] Failed to configure input pad on Parsed_crop_0"; + let msg = explain_decoder_failure(stderr, 720, 576).expect("guard failure recognised"); + assert!(msg.contains("720x576")); + assert!(explain_decoder_failure("Broken pipe", 720, 576).is_none()); + } +} diff --git a/worker/tests/preview_integration_test.rs b/worker/tests/preview_integration_test.rs index e9559fd..2a7acb7 100644 --- a/worker/tests/preview_integration_test.rs +++ b/worker/tests/preview_integration_test.rs @@ -1,10 +1,11 @@ //! Preview pipeline integration test. //! //! Regression test for a colour-format/frame-size desync: `pal-sd-25.mov` is a -//! 10-bit 4:2:2 ProRes file whose ffprobe-reported coded size (720x576) differs -//! from the size ffmpeg actually decodes (702x576 clean aperture). If the -//! decoder's raw output size doesn't match what `pipe_source` expects, frames -//! desync and the processed preview is garbage. +//! 10-bit 4:2:2 ProRes file whose ffprobe-reported size (720x576) differs +//! from the size ffmpeg decodes by default (702x576: the container's clean +//! aperture is applied). If the decoder's raw output size doesn't match what +//! `pipe_source` expects, frames desync and the processed preview is garbage; +//! the worker decodes the full stored frame instead (source_decode.rs). //! //! With all filters disabled the preview is a passthrough, so the processed //! frame must closely match a direct decode of the same source frame. @@ -135,14 +136,18 @@ fn test_preview_10bit_422_matches_source_frame() { // Processed preview frame -> rgb24. let preview_rgb = ffmpeg_to_rgb("preview", &["-i", preview_png.to_str().unwrap()]); - // Reference: decode the same source frame, forced to the pipeline's declared - // dimensions and the same range scaling — the "correct passthrough" result. + // Reference: decode the same source frame at its full stored size (ignoring + // the clean aperture, as the pipeline does — see source_decode.rs), with + // only the range scaling the PNG gets. Never resized: a reference scaled to + // WIDTHxHEIGHT would agree with a pipeline that crops and resamples. let time = FRAME as f64 / FPS; let reference_rgb = ffmpeg_to_rgb( "reference", &[ "-ss", &format!("{:.6}", time), + "-apply_cropping", + "codec", "-i", src.to_str().unwrap(), "-map", @@ -150,7 +155,7 @@ fn test_preview_10bit_422_matches_source_frame() { "-frames:v", "1", "-vf", - &format!("scale={}:{}:in_range=tv:out_range=pc", WIDTH, HEIGHT), + "scale=in_range=tv:out_range=pc", ], );