From 91c31b9df43d9212ed3b990dbe0cdadbea6024e3 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Tue, 10 Nov 2026 23:39:57 +1100 Subject: [PATCH 1/2] feat(output): v210 uncompressed 10-bit 4:2:2, and flag interlaced ProRes/v210 field order Adds VideoCodec::V210 ("Uncompressed 10-bit 4:2:2 (v210)") on both sides, in its own Uncompressed family: MOV (default), MKV, AVI; no quality, presets or -crf. Requested on the videohelp forum (thread 419911, #154) for Premiere/Resolve. - The script always converts to YUV422P10 for v210 (effective_chroma_subsampling), field-aware. FFmpeg's -pix_fmt conversion of interlaced 4:2:0 blends the two fields' chroma. v210ChromaPinWarning says when the Color Format is overridden, and is pinned to the worker's table. - Every still-interlaced ProRes/v210 MOV was flagged progressive (fiel 0x0100): vspipe's Y4M is always `Ip`, and -field_order is inert in FFmpeg 9. The pipe input now gets -top 1|0 from ScriptGenerator::output_field_order (fiel 0x0209/0x020e, MKV tb/bt), and ProRes gets -flags +ildct so its frame header agrees. - No +faststart for v210; restored audio beside it is PCM. - probe_frame_count takes the first line of ffprobe's output (FFmpeg 9 repeats a stream under its stream group, which made a job without totalFrames encode one frame). Report: docs/reports/2026-11-v210-field-order. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 43 ++ README.md | 8 +- app/lib/models/encoding_settings.dart | 14 +- app/lib/models/video_job.dart | 45 +- app/lib/utils/pixel_format.dart | 41 ++ app/lib/views/settings/settings_dialog.dart | 57 ++- app/test/codec_quality_control_test.dart | 22 +- app/test/integration_v210_output_test.dart | 401 ++++++++++++++++++ app/test/v210_test.dart | 172 ++++++++ docs/ENGINEERING_NOTES.md | 27 ++ .../2026-11-v210-field-order/index.html | 191 +++++++++ .../2026-11-v210-field-order/scripts/probe.sh | 86 ++++ docs/reports/README.md | 1 + worker/src/audio_restoration.rs | 28 +- worker/src/dependency_locator.rs | 28 +- worker/src/models/video_job.rs | 142 ++++++- worker/src/pipeline_executor.rs | 214 +++++++++- worker/src/script_generator.rs | 33 +- worker/tests/filter_integration_test.rs | 136 ++++++ 19 files changed, 1634 insertions(+), 55 deletions(-) create mode 100644 app/test/integration_v210_output_test.dart create mode 100644 app/test/v210_test.dart create mode 100644 docs/reports/2026-11-v210-field-order/index.html create mode 100755 docs/reports/2026-11-v210-field-order/scripts/probe.sh diff --git a/CLAUDE.md b/CLAUDE.md index 17f8fd41..95410544 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1099,6 +1099,12 @@ does, and decides bit depth as well as chroma: | `yuv422p10` | `vs.YUV422P10` | normalize chroma without dropping to 8-bit | | `yuv444p10` | `vs.YUV444P10` | full chroma — ProRes 4444, x264/x265; no GPU encoder | +**v210 overrides the selection**: the script always converts to `YUV422P10` +for it (`EncodingSettings::effective_chroma_subsampling`, Dart +`effectiveChromaSubsampling`) — see "Intermediate codecs" below. Anything that +reads the output format (the conversion block, `encoder_input_pix_fmt`, the +bit-depth warning) must use the effective one. + Tests must **sweep the enum** (`ChromaSubsampling::ALL` in Rust, kept complete by a catch-all-free match plus a count assertion; `ChromaSubsampling.values` in Dart) rather than a hand-written list — `yuv420p10` was once missing from three @@ -2065,6 +2071,43 @@ See docs/ENGINEERING_NOTES.md for the specific measurements (which profiles are bit-identical with/without the pin, the dB gain from the advanced quant-matrix options) and the `copyWith`/enum-dispatch traps this shipped with. +### Intermediate codecs: v210 and ProRes (field order, chroma) + +`VideoCodec::V210` (serde `"v210"`, app 1.8.0) is uncompressed 10-bit 4:2:2 +for editors: MOV (default), MKV, AVI; never MP4 (no tag). Its own family, +`EncoderFamily::Uncompressed` / Dart `isUncompressed` — **not** `isLossless`, +which means AVI and the "Lossless" group. No quality, presets or `-crf`. The +evidence for every rule here is docs/reports/2026-11-v210-field-order. + +- **vspipe's Y4M header always says `Ip`**, whatever `_FieldBased` is, so + ffmpeg never learns a still-interlaced picture's field order from the pipe. + For the codecs that flag it (`VideoCodec::flags_field_order`: ProRes, v210) + `pipeline_executor::field_order_args` declares it **on the pipe input**, + `-top 1|0` before `-i -`, from `ScriptGenerator::output_field_order`. Never + `-field_order` (inert in FFmpeg 9: overwritten from the frame flags) and + never `setfield` in `-vf` (a Custom FFmpeg Argument `-vf` drops it). Without + it a MOV's `fiel` atom says progressive (`0x0100`). +- **ProRes also gets `-flags +ildct`**: its frame header has its own field + flag, which stays progressive unless the frame is coded as fields. +- **A pass that makes a still-interlaced clip progressive must be taught to + `output_field_order`** (today: Stabilize pitch, Deblock mpeg2stinx, the + Frame Rate pass). `test_221` counts every `SetFieldBased(` in both templates + and fails on one it doesn't know. +- **v210's 4:2:2 10-bit comes from the script, never from ffmpeg's + `-pix_fmt`**, which blends interlaced 4:2:0 chroma across the fields; zimg + honours `_FieldBased`. `forced_pix_fmt` still pins `yuv422p10le` as a guard. + ProRes from 4:2:0 still converts in ffmpeg (open follow-up). +- `v210ChromaPinWarning` (Dart) is a second implementation of the override + and is pinned case for case to `v210_stores_422_10_whatever_is_selected`. +- **No `+faststart` for v210** (`uses_faststart`): it rewrites the whole + ~1 Gbit/s file a second time. Restored audio beside v210 is PCM + (`restored_passthrough_codec`). +- **Test field order with the `fiel` atom's bytes** (`0x0209` TFF, `0x020e` + BFF), not only ffprobe's `tb`/`bt` labels. AVI carries no field order at + all. `integration_v210_output_test.dart` (heavy) checks these, v210 bit-exact + against a 10-bit 4:2:2 source, per-field chroma, PCM in MOV and preview = + encode. + ## QTGMC Parameters Reference The most important parameters: diff --git a/README.md b/README.md index da876cdf..2cbbf52a 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ VapourBox runs [VapourSynth](https://www.vapoursynth.com/), QTGMC and FFmpeg for ## What it does -- **Converts and archives.** Reads camcorder DV and AVCHD, MPEG-2 from discs and recorders, MXF, and ordinary MP4/MKV/AVI/MOV files, including formats consumer converters mishandle (NTSC DV's 4:1:1 chroma, 10–16-bit film scans). Writes H.264, H.265, ProRes or lossless FFV1, with hardware encoding where available. DVDs and `VIDEO_TS` folders are read directly, and aspect ratio is carried through correctly. +- **Converts and archives.** Reads camcorder DV and AVCHD, MPEG-2 from discs and recorders, MXF, and ordinary MP4/MKV/AVI/MOV files, including formats consumer converters mishandle (NTSC DV's 4:1:1 chroma, 10–16-bit film scans). Writes H.264, H.265, ProRes, uncompressed 10-bit 4:2:2 (v210) or lossless FFV1, with hardware encoding where available. DVDs and `VIDEO_TS` folders are read directly, and aspect ratio is carried through correctly. - **Fixes how the picture is stored.** Detects interlaced and telecined sources, then deinterlaces with QTGMC or recovers the original film frames with inverse telecine (or by stripping soft-telecine flags). - **Cleans up old footage.** Optional filters for tape noise, smeared colour, dust and scratches, flicker, ghosting, compression blocking, banding, camera shake and dark, flat pictures. They're off until you turn them on, and the presets turn on what a given kind of source needs. - **Colours black-and-white footage.** **Colourisation** predicts colour for film scans, old broadcasts and home movies with AI (DDColor or DeOldify Video, each an optional download), steadied across frames, and keeps the brightness of every pixel exactly as it was, so the result can be graded in Color Correction. @@ -94,12 +94,12 @@ Where an AppImage can't be mounted, use the `.tar.gz` of the same build: extract | | | |---|---| -| **Video** | H.264, H.265, ProRes (Proxy to 4444 XQ), FFV1 (lossless); hardware encoding via VideoToolbox, NVENC, Quick Sync or AMF | -| **Audio** | Passthrough, AAC, Opus or FLAC, or none | +| **Video** | H.264, H.265, ProRes (Proxy to 4444 XQ), uncompressed 10-bit 4:2:2 (v210, for Premiere and Resolve: MOV is the container to choose), FFV1 (lossless); hardware encoding via VideoToolbox, NVENC, Quick Sync or AMF. ProRes and v210 of a picture left interlaced are flagged with its field order in MOV and MKV (AVI cannot carry it) | +| **Audio** | Passthrough, AAC, Opus, FLAC or uncompressed PCM, or none | | **Containers** | MKV, MP4, MOV, AVI | | **Reads** | `.dv` · `.mts` `.m2ts` · `.vob` `.m2v` `.mpg` `.mpeg` · `.mxf` · `.avi` `.mov` `.mp4` `.mkv` `.ts` `.wmv` `.webm` `.flv`, DVD discs and `VIDEO_TS` folders | | **Source formats** | 4:1:1 (NTSC DV), 4:1:0, 4:2:0/4:2:2/4:4:4 up to 16-bit, RGB, grayscale | -| **Colour format** | Match the source, or convert to 4:2:0 8-bit (plays everywhere), 4:2:0 10-bit (for GPU encoders), 4:2:2 8- or 10-bit, or 4:4:4 10-bit. Matching a 10-bit source makes a 10-bit file, which some players and browsers won't open | +| **Colour format** | Match the source, or convert to 4:2:0 8-bit (plays everywhere), 4:2:0 10-bit (for GPU encoders), 4:2:2 8- or 10-bit, or 4:4:4 10-bit. Matching a 10-bit source makes a 10-bit file, which some players and browsers won't open. v210 is always 4:2:2 10-bit, whatever is chosen | | **HDR** | HDR10, HLG and Dolby Vision (profiles 7, 8 and 9) are processed and saved as HDR, and shown tone-mapped in the preview. H.265 keeps HDR10's mastering-display and light-level metadata. Dolby Vision's own dynamic metadata isn't kept, so the output plays as standard HDR. Dolby Vision Profile 5, used by some streaming services, can't be decoded and shows pink and green | | **Aspect ratio** | Non-square pixels preserved, even through a resize; or convert to square pixels, force a display aspect, or letterbox to a target size | diff --git a/app/lib/models/encoding_settings.dart b/app/lib/models/encoding_settings.dart index 09be932e..fe992279 100644 --- a/app/lib/models/encoding_settings.dart +++ b/app/lib/models/encoding_settings.dart @@ -241,6 +241,14 @@ class EncodingSettings { Map toJson() => _$EncodingSettingsToJson(this); + /// The colour format the script actually converts to: [chromaSubsampling], + /// except for v210, which stores 10-bit 4:2:2 and nothing else, so the + /// worker converts to that whatever is selected (field-aware, in the + /// script). Mirrors `EncodingSettings::effective_chroma_subsampling`; see + /// `v210ChromaPinWarning` for what the user is told. + ChromaSubsampling get effectiveChromaSubsampling => + codec.isUncompressed ? ChromaSubsampling.yuv422p10 : chromaSubsampling; + /// Backward compatibility: alias for audioMode == passthrough. @Deprecated('Use audioMode instead') bool get audioCopy => audioMode == AudioMode.passthrough; @@ -255,9 +263,9 @@ class EncodingSettings { // terms of a number that does nothing. ProRes is set by its profile; // lossless has no quality at all. if (!codec.hasQualityControl) { - return codec.isProRes - ? 'Fixed by the ${codec.displayName} profile' - : 'Lossless'; + if (codec.isProRes) return 'Fixed by the ${codec.displayName} profile'; + if (codec.isUncompressed) return 'Uncompressed'; + return 'Lossless'; } if (codec == VideoCodec.h264Videotoolbox || codec == VideoCodec.h265Videotoolbox) { // VideoToolbox: CRF is remapped to q:v (inverted scale) in the worker. diff --git a/app/lib/models/video_job.dart b/app/lib/models/video_job.dart index 159d9b6e..c8e5df93 100644 --- a/app/lib/models/video_job.dart +++ b/app/lib/models/video_job.dart @@ -215,7 +215,10 @@ enum VideoCodec { prores422('prores_ks -profile:v 2', 'ProRes 422'), proresHQ('prores_ks -profile:v 3', 'ProRes 422 HQ'), prores4444('prores_ks -profile:v 4', 'ProRes 4444'), - prores4444Xq('prores_ks -profile:v 5', 'ProRes 4444 XQ'); + prores4444Xq('prores_ks -profile:v 5', 'ProRes 4444 XQ'), + + // Uncompressed + v210('v210', 'Uncompressed 10-bit 4:2:2 (v210)'); const VideoCodec(this.value, this.displayName); @@ -262,6 +265,9 @@ enum VideoCodec { return 'Full 4:4:4 colour, for compositing'; case VideoCodec.prores4444Xq: return 'Full 4:4:4 colour at the highest data rate'; + case VideoCodec.v210: + return 'Uncompressed 10-bit 4:2:2 for editing (Premiere, Resolve). Very ' + 'large: about 221 Mbit/s at 720x576 25p, 1.1 Gbit/s at 1080p25'; } } @@ -285,7 +291,13 @@ enum VideoCodec { /// This lives on the model rather than inline in the settings dialog so it /// can be asserted across `VideoCodec.values` — a codec added later cannot /// quietly acquire a slider the worker ignores. - bool get hasQualityControl => !isLossless && !isProRes; + bool get hasQualityControl => !isLossless && !isProRes && !isUncompressed; + + /// Whether this is an uncompressed codec (v210). Deliberately not part of + /// [isLossless]: that family defaults to AVI and is grouped as "Lossless", + /// while v210 is an editing intermediate that belongs in MOV. Mirrors + /// `VideoCodec::is_uncompressed` in the worker. + bool get isUncompressed => this == VideoCodec.v210; /// Whether this codec produces H.264 output (software or hardware). bool get isH264 => this == h264 || this == h264Nvenc || this == h264Qsv || @@ -312,11 +324,12 @@ enum VideoCodec { if (this == h264Amf || this == h265Amf) return 'AMD AMF'; if (isProRes) return 'ProRes'; if (isLossless) return 'Lossless'; + if (isUncompressed) return 'Uncompressed'; return 'Software'; } ContainerFormat get preferredContainer { - if (isProRes) return ContainerFormat.mov; + if (isProRes || isUncompressed) return ContainerFormat.mov; if (isLossless) return ContainerFormat.avi; return ContainerFormat.mp4; } @@ -328,14 +341,16 @@ enum VideoCodec { // MP4 supports H.264, H.265 (software and hardware) return isH264 || isH265; case ContainerFormat.mov: - // MOV supports H.264, H.265, ProRes - return isH264 || isH265 || isProRes; + // MOV supports H.264, H.265, ProRes, v210 + return isH264 || isH265 || isProRes || isUncompressed; case ContainerFormat.mkv: - // MKV supports H.264, H.265, and lossless (FFV1/HuffYUV/FFVHuff) - return isH264 || isH265 || isLossless; + // MKV supports H.264, H.265, lossless (FFV1/HuffYUV/FFVHuff), v210 + return isH264 || isH265 || isLossless || isUncompressed; case ContainerFormat.avi: - // AVI supports lossless (FFV1/HuffYUV/FFVHuff), H.264 (uncommon) - return isLossless || isH264; + // AVI supports lossless (FFV1/HuffYUV/FFVHuff), H.264 (uncommon) and + // v210 — though AVI cannot say whether the picture is interlaced. + // (MP4 has no tag for v210 at all: ffmpeg refuses the mux.) + return isLossless || isH264 || isUncompressed; } } @@ -353,7 +368,7 @@ enum VideoCodec { if (this == h264Amf || this == h265Amf) { return ['speed', 'balanced', 'quality']; } - // VideoToolbox, ProRes, FFV1 don't have presets + // VideoToolbox, ProRes, the lossless codecs and v210 don't have presets return null; } @@ -367,6 +382,16 @@ enum VideoCodec { } } +/// v210's data rate in bits per second for a frame size and rate. +/// +/// v210 packs six pixels into 16 bytes, and each line is padded to a multiple +/// of 48 pixels (128 bytes): 720x576 at 25 fps is 221,184,000 bit/s, which is +/// what ffmpeg itself reports for the stream. +int v210BitsPerSecond(int width, int height, double fps) { + final lineBytes = ((width + 47) ~/ 48) * 128; + return (lineBytes * height * 8 * fps).round(); +} + /// Output container formats. @JsonEnum(valueField: 'value') enum ContainerFormat { diff --git a/app/lib/utils/pixel_format.dart b/app/lib/utils/pixel_format.dart index 57df238b..9194e9d0 100644 --- a/app/lib/utils/pixel_format.dart +++ b/app/lib/utils/pixel_format.dart @@ -303,3 +303,44 @@ String? proresChromaPinWarning({ // less costly, so this stays quiet. return null; } + +/// Warning when v210 overrides the chosen output colour format, or `null` +/// when there is nothing to say. +/// +/// v210 stores uncompressed 10-bit 4:2:2 and nothing else, so the worker +/// converts to exactly that in the script, whatever Color Format is selected +/// (`EncodingSettings::effective_chroma_subsampling`). A sibling of +/// [proresChromaPinWarning], with the same silences: padding 4:2:0 up loses +/// nothing, and depth on its own is not mentioned (the bit-depth warning +/// covers a source deeper than 10 bits, from the effective format). +/// +/// **A second implementation of the worker's decision.** Pinned case for case +/// to `v210_stores_422_10_whatever_is_selected` in +/// `worker/src/models/video_job.rs` by `app/test/v210_chroma_pin_warning_test.dart`. +/// Change one, change both. +String? v210ChromaPinWarning({ + required VideoCodec codec, + required ChromaSubsampling chromaSubsampling, + String? pixelFormat, +}) { + if (!codec.isUncompressed) return null; + + if (chromaSubsampling == ChromaSubsampling.yuv422p10) return null; + + if (chromaSubsampling == ChromaSubsampling.original) { + // Nothing to say until a file is loaded and we know its format, and + // nothing to say about a 4:2:0 or 4:2:2 source: it is padded up. + if (pixelFormat == null) return null; + if (pixelFormatChromaLayout(pixelFormat) != ChromaLayout.c444) return null; + return 'Your source is $pixelFormat, and "Match source" keeps it, but ' + '${codec.displayName} stores 4:2:2, so the chroma will be resampled ' + 'down to it. Choose ProRes 4444 to keep the full chroma.'; + } + + final lost = pixelFormatChromaLayout(chromaSubsampling.value) == ChromaLayout.c444 + ? ' The extra chroma detail is resampled down.' + : ''; + return '${chromaSubsampling.label} is selected, but ${codec.displayName} ' + 'always stores 10-bit 4:2:2, so this setting is ignored: the picture ' + 'goes to 10-bit 4:2:2 directly.$lost'; +} diff --git a/app/lib/views/settings/settings_dialog.dart b/app/lib/views/settings/settings_dialog.dart index 6f91caf0..cd7362ce 100644 --- a/app/lib/views/settings/settings_dialog.dart +++ b/app/lib/views/settings/settings_dialog.dart @@ -1079,7 +1079,9 @@ class _OutputSettingsTabState extends State<_OutputSettingsTab> { ? '${settings.codec.displayName} encodes at a fixed quality ' 'set by the profile. There is no quality setting — pick a ' 'different profile for a different data rate.' - : 'This is a lossless codec. No quality setting is needed.', + : settings.codec.isUncompressed + ? _v210QualityNote(viewModel) + : 'This is a lossless codec. No quality setting is needed.', ), ), @@ -1249,7 +1251,7 @@ class _OutputSettingsTabState extends State<_OutputSettingsTab> { ), ..._buildChromaBitDepthWarning(viewModel, settings), ..._buildChromaEncoderWarning(viewModel, settings), - ..._buildProresChromaWarning(viewModel, settings), + ..._buildIntermediateChromaWarning(viewModel, settings), ], ), ), @@ -1424,7 +1426,8 @@ class _OutputSettingsTabState extends State<_OutputSettingsTab> { EncodingSettings settings, ) { final message = chromaConversionBitDepthWarning( - targetBitDepth: settings.chromaSubsampling.outputBitDepth, + // The format the worker really converts to (v210 overrides the choice). + targetBitDepth: settings.effectiveChromaSubsampling.outputBitDepth, pixelFormat: viewModel.videoInfo?.pixelFormat, ); if (message == null) return const []; @@ -1449,20 +1452,43 @@ class _OutputSettingsTabState extends State<_OutputSettingsTab> { /// Issue #81: a ProRes profile stores a chroma layout of its own, and the /// worker pins it — so say when that overrides the selected colour format, - /// in either direction. - List _buildProresChromaWarning( + /// in either direction. v210 stores 10-bit 4:2:2 whatever is selected. + List _buildIntermediateChromaWarning( MainViewModel viewModel, EncodingSettings settings, ) { final message = proresChromaPinWarning( - codec: settings.codec, - chromaSubsampling: settings.chromaSubsampling, - pixelFormat: viewModel.videoInfo?.pixelFormat, - ); + codec: settings.codec, + chromaSubsampling: settings.chromaSubsampling, + pixelFormat: viewModel.videoInfo?.pixelFormat, + ) ?? + v210ChromaPinWarning( + codec: settings.codec, + chromaSubsampling: settings.chromaSubsampling, + pixelFormat: viewModel.videoInfo?.pixelFormat, + ); if (message == null) return const []; return [const SizedBox(height: 12), WarningBanner(message: message)]; } + /// v210's fixed data rate, for the loaded video when its size is known. + String _v210QualityNote(MainViewModel viewModel) { + const base = 'Uncompressed: every frame is stored as it is, so there is no ' + 'quality setting.'; + final info = viewModel.videoInfo; + final w = info?.width, h = info?.height, fps = info?.frameRate; + if (w == null || h == null || fps == null || w <= 0 || h <= 0 || fps <= 0) { + return '$base About 221 Mbit/s at 720x576 25p, 1.1 Gbit/s at 1080p25.'; + } + final mbit = v210BitsPerSecond(w, h, fps) / 1e6; + final rate = mbit >= 1000 + ? '${(mbit / 1000).toStringAsFixed(2)} Gbit/s' + : '${mbit.round()} Mbit/s'; + final gbPerMin = v210BitsPerSecond(w, h, fps) * 60 / 8 / 1e9; + return '$base At the source frame size that is about $rate ' + '(${gbPerMin.toStringAsFixed(1)} GB a minute) before audio.'; + } + /// Whether a hardware encoder is relevant to the current platform's GPU APIs: /// VideoToolbox is macOS-only; QSV/NVENC/AMF apply to Windows and Linux. /// (Software/ProRes/lossless codecs are platform-agnostic.) @@ -1498,6 +1524,9 @@ class _OutputSettingsTabState extends State<_OutputSettingsTab> { // enum, which runs Proxy -> 4444 XQ by ascending data rate. final proresCodecs = VideoCodec.values.where((c) => c.isProRes).toList(growable: false); + // v210 sits with ProRes: both are editing intermediates. + final uncompressedCodecs = + VideoCodec.values.where((c) => c.isUncompressed).toList(growable: false); final losslessCodecs = [ VideoCodec.ffv1, VideoCodec.huffyuv, VideoCodec.ffvhuff, ]; @@ -1527,14 +1556,14 @@ class _OutputSettingsTabState extends State<_OutputSettingsTab> { } } - // ProRes section - final supportedProres = proresCodecs + // Editing intermediates: ProRes, then uncompressed v210. + final supportedIntermediates = [...proresCodecs, ...uncompressedCodecs] .where((c) => c.supportsContainer(settings.container)) .toList(); - if (supportedProres.isNotEmpty) { + if (supportedIntermediates.isNotEmpty) { children.add(const SizedBox(height: 8)); - children.add(_buildCodecGroupLabel(context, 'ProRes')); - for (final codec in supportedProres) { + children.add(_buildCodecGroupLabel(context, 'Editing Intermediates')); + for (final codec in supportedIntermediates) { children.add(_buildCodecRadio(context, viewModel, settings, codec)); } } diff --git a/app/test/codec_quality_control_test.dart b/app/test/codec_quality_control_test.dart index 40f2d664..a3ce9f84 100644 --- a/app/test/codec_quality_control_test.dart +++ b/app/test/codec_quality_control_test.dart @@ -40,9 +40,19 @@ void main() { } }); + test('is false for uncompressed v210', () { + final uncompressed = + VideoCodec.values.where((c) => c.isUncompressed).toList(); + expect(uncompressed, [VideoCodec.v210]); + for (final codec in uncompressed) { + expect(codec.hasQualityControl, isFalse, + reason: '${codec.displayName} stores every frame as it is'); + } + }); + test('is true for every other codec', () { final tunable = VideoCodec.values - .where((c) => !c.isProRes && !c.isLossless) + .where((c) => !c.isProRes && !c.isLossless && !c.isUncompressed) .toList(); expect(tunable, isNotEmpty, reason: 'sanity: tunable codecs exist'); @@ -58,9 +68,13 @@ void main() { // disagrees with the two predicates, would render two Quality sections // or none. for (final codec in VideoCodec.values) { - expect(codec.isProRes && codec.isLossless, isFalse, - reason: '${codec.displayName} cannot be both'); - expect(codec.hasQualityControl, !(codec.isProRes || codec.isLossless), + final families = [codec.isProRes, codec.isLossless, codec.isUncompressed] + .where((b) => b) + .length; + expect(families, lessThanOrEqualTo(1), + reason: '${codec.displayName} belongs to one family at most'); + expect(codec.hasQualityControl, + !(codec.isProRes || codec.isLossless || codec.isUncompressed), reason: '${codec.displayName}: getter disagrees with its parts'); } }); diff --git a/app/test/integration_v210_output_test.dart b/app/test/integration_v210_output_test.dart new file mode 100644 index 00000000..2895bc89 --- /dev/null +++ b/app/test/integration_v210_output_test.dart @@ -0,0 +1,401 @@ +/// Full-encode tests for uncompressed v210 output, and for the field order the +/// editing intermediates (v210, ProRes) declare on a still-interlaced picture. +/// +/// What only a real encode can show: +/// * v210 really is uncompressed: a 10-bit 4:2:2 source with every pass off +/// comes back bit for bit. +/// * The output is 10-bit 4:2:2 whatever Color Format is selected, and the +/// 4:2:0 -> 4:2:2 conversion keeps each field's chroma to itself. Left to +/// ffmpeg's `-pix_fmt`, the two fields' chroma was blended. +/// * A still-interlaced v210 or ProRes MOV says so: the `fiel` atom is +/// 0x0209 (TFF) / 0x020e (BFF). Before this, vspipe's Y4M (always `Ip`) +/// made every one of them 0x0100, progressive. The atom bytes are what an +/// editor reads, so they are checked directly, not only through ffprobe's +/// `tb` / `bt` labels. +/// * PCM audio is kept and offered in MOV, and the preview is the encode. +/// +/// 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 'dart:typed_data'; + +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 'support/worker_harness.dart'; + +String get _outDir => p.join(WorkerHarness.outputDir, 'v210'); +String _resource(String name) => + p.join(WorkerHarness.repoRoot, 'Tests', 'TestResources', name); + +Map _job({ + required String name, + required String input, + required String pixFmt, + required int width, + required int height, + required double fps, + VideoCodec codec = VideoCodec.v210, + ContainerFormat container = ContainerFormat.mov, + ChromaSubsampling chroma = ChromaSubsampling.original, + FieldOrder? fieldOrder, + bool deinterlace = false, + AudioMode audioMode = AudioMode.none, + AudioCodec audioCodec = AudioCodec.aac, + int? endFrame, +}) { + return VideoJob( + id: const Uuid().v4(), + inputPath: input, + outputPath: p.join(_outDir, '$name.${container.extension}'), + processingPipeline: ProcessingPipeline( + deinterlace: QTGMCParameters( + enabled: deinterlace, + preset: QTGMCPreset.fast, + tff: fieldOrder != FieldOrder.bottomFieldFirst, + ), + ), + encodingSettings: EncodingSettings( + codec: codec, + container: container, + chromaSubsampling: chroma, + audioMode: audioMode, + audioCodec: audioCodec, + ), + detectedFieldOrder: fieldOrder, + inputWidth: width, + inputHeight: height, + inputPixelFormat: pixFmt, + inputFrameRate: fps, + startFrame: endFrame == null ? null : 0, + endFrame: endFrame, + ).toJson(); +} + +Future _encode(Map job, String label) async { + final result = await WorkerHarness.runJob(job, label: label); + if (!result.success) { + print(result.logs.reversed.take(30).toList().reversed.join('\n')); + } + expect(result.success, isTrue, reason: '$label: ${result.error}'); + return job['outputPath'] as String; +} + +/// The two bytes after the MOV `fiel` atom's type, as hex, or null if absent. +String? _fiel(String movPath) { + final bytes = File(movPath).readAsBytesSync(); + const tag = [0x66, 0x69, 0x65, 0x6c]; // "fiel" + for (var i = 0; i + 6 <= bytes.length; i++) { + if (bytes[i] == tag[0] && + bytes[i + 1] == tag[1] && + bytes[i + 2] == tag[2] && + bytes[i + 3] == tag[3]) { + return bytes + .sublist(i + 4, i + 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + } + } + return null; +} + +/// Per-frame MD5s of [path] decoded to [pixFmt] (framemd5's hash column). +/// +/// Decodes the full stored frame (`-apply_cropping codec`), as the worker does: +/// pal-sd-25.mov carries a clean aperture that would otherwise crop it to 702. +Future> _frameMd5s(String path, String pixFmt, {int? frames}) async { + final r = await Process.run( + WorkerHarness.ffmpegPath, + [ + '-v', 'error', '-apply_cropping', 'codec', '-i', path, + if (frames != null) ...['-frames:v', '$frames'], + '-map', '0:v:0', '-pix_fmt', pixFmt, '-f', 'framemd5', '-', + ], + environment: WorkerHarness.ffmpegEnv, + ); + expect(r.exitCode, 0, reason: 'framemd5 $path: ${r.stderr}'); + return (r.stdout as String) + .split('\n') + .where((l) => l.isNotEmpty && !l.startsWith('#')) + .map((l) => l.split(',').last.trim()) + .toList(); +} + +/// One frame decoded to raw yuv422p10le (or [pixFmt]) bytes. +Future _rawFrame(String path, String pixFmt, {int frame = 0}) async { + final r = await Process.run( + WorkerHarness.ffmpegPath, + [ + '-v', 'error', '-i', path, + '-vf', 'select=eq(n\\,$frame)', '-frames:v', '1', + '-f', 'rawvideo', '-pix_fmt', pixFmt, '-', + ], + environment: WorkerHarness.ffmpegEnv, + stdoutEncoding: null, + ); + expect(r.exitCode, 0, reason: 'raw frame $path: ${r.stderr}'); + return Uint8List.fromList(r.stdout as List); +} + +const _fw = 720; +const _fh = 576; + +/// An interlaced 4:2:0 fixture whose two fields have different chroma: the +/// top field is red, the bottom blue, woven line by line in every plane, so +/// chroma row k of the 4:2:0 picture belongs to field k % 2. +Future _fieldChromaFixture() async { + final path = p.join(_outDir, 'src_field_chroma_420.mkv'); + final r = await Process.run( + WorkerHarness.ffmpegPath, + [ + '-hide_banner', '-loglevel', 'error', '-y', + '-f', 'lavfi', '-i', 'color=c=red:size=${_fw}x${_fh ~/ 2}:rate=50:duration=0.4', + '-f', 'lavfi', '-i', 'color=c=blue:size=${_fw}x${_fh ~/ 2}:rate=50:duration=0.4', + '-filter_complex', + '[0:v]format=yuv420p[a];[1:v]format=yuv420p[b];' + '[a][b]interleave,tinterlace=mode=merge,setfield=tff[v]', + '-map', '[v]', '-c:v', 'ffv1', '-pix_fmt', 'yuv420p', + path, + ], + environment: WorkerHarness.ffmpegEnv, + ); + expect(r.exitCode, 0, reason: 'fixture: ${r.stderr}'); + return path; +} + +void main() { + setUpAll(() async { + await WorkerHarness.ensureReady(); + await Directory(_outDir).create(recursive: true); + }); + + test('a 10-bit 4:2:2 source comes back bit for bit', () async { + final src = _resource('pal-sd-25.mov'); // ProRes 422, yuv422p10le, 25p + final out = await _encode( + _job( + name: 'bitexact', + input: src, + pixFmt: 'yuv422p10le', + width: 720, + height: 576, + fps: 25, + endFrame: 24, + ), + 'v210 bit-exact', + ); + final s = await WorkerHarness.firstStream(out, + selector: 'v:0', + entries: ['codec_name', 'pix_fmt', 'field_order', 'nb_read_frames'], + countFrames: true); + expect(s!['codec_name'], 'v210'); + expect(s['pix_fmt'], 'yuv422p10le'); + expect(s['field_order'], 'progressive'); + expect(int.parse(s['nb_read_frames'].toString()), 25); + expect(_fiel(out), '0100'); + + final expected = await _frameMd5s(src, 'yuv422p10le', frames: 25); + final actual = await _frameMd5s(out, 'yuv422p10le'); + expect(actual, expected, + reason: 'v210 is uncompressed: every frame must equal the source'); + }, timeout: const Timeout(Duration(minutes: 10))); + + test('an 8-bit source with a 4:2:0 selection is still 10-bit 4:2:2, ' + 'and the preview is the encode', () async { + final src = _resource('interlaced_test.avi'); // lagarith yuv422p + final job = _job( + name: 'from_8bit', + input: src, + pixFmt: 'yuv422p', + width: 720, + height: 576, + fps: 25, + chroma: ChromaSubsampling.yuv420, + fieldOrder: FieldOrder.topFieldFirst, + ); + final out = await _encode(job, 'v210 from 8-bit'); + final s = await WorkerHarness.firstStream(out, + selector: 'v:0', + entries: ['codec_name', 'pix_fmt', 'nb_read_frames'], + countFrames: true); + expect(s!['codec_name'], 'v210'); + expect(s['pix_fmt'], 'yuv422p10le'); + // The AVI index says 79, but four of its entries are empty: what decodes + // is the count to match. + final source = await WorkerHarness.firstStream(src, + selector: 'v:0', entries: ['nb_read_frames'], countFrames: true); + expect(int.parse(s['nb_read_frames'].toString()), + int.parse(source!['nb_read_frames'].toString())); + + final pv = await WorkerHarness.runPreview(job, frame: 10, label: 'v210 preview'); + expect(pv.png, isNotNull, reason: '${pv.error}\n${pv.logs}'); + final diff = WorkerHarness.meanAbsDiff( + await WorkerHarness.imageToRgb24(pv.png!, label: 'v210_pv'), + await WorkerHarness.frameRgb24(out, 10, label: 'v210_enc'), + ); + print(' v210 preview vs encode: meanAbsDiff ${diff.toStringAsFixed(3)}'); + expect(diff, lessThan(1.0), reason: 'the preview is not the encoded picture'); + }, timeout: const Timeout(Duration(minutes: 10))); + + group('field order of a still-interlaced picture', () { + late String fixture; + setUpAll(() async => fixture = await _fieldChromaFixture()); + + Future encode(String name, VideoCodec codec, ContainerFormat c, + FieldOrder order, {bool deinterlace = false}) => + _encode( + _job( + name: name, + input: fixture, + pixFmt: 'yuv420p', + width: _fw, + height: _fh, + fps: 25, + codec: codec, + container: c, + fieldOrder: order, + deinterlace: deinterlace, + ), + name, + ); + + Future fieldOrder(String path) async => (await WorkerHarness.firstStream( + path, + selector: 'v:0', + entries: ['field_order']))?['field_order'] as String?; + + for (final (order, label, atom) in [ + (FieldOrder.topFieldFirst, 'tb', '0209'), + (FieldOrder.bottomFieldFirst, 'bt', '020e'), + ]) { + test('v210 MOV and MKV, ${order.value}', () async { + final mov = await encode('v210_${order.value}', VideoCodec.v210, + ContainerFormat.mov, order); + expect(await fieldOrder(mov), label); + expect(_fiel(mov), atom, reason: 'the fiel atom is what an editor reads'); + + final mkv = await encode('v210_${order.value}_mkv', VideoCodec.v210, + ContainerFormat.mkv, order); + expect(await fieldOrder(mkv), label); + }, timeout: const Timeout(Duration(minutes: 5))); + + test('ProRes 422 HQ MOV, ${order.value}: container and bitstream', + () async { + final mov = await encode('prores_${order.value}', VideoCodec.proresHQ, + ContainerFormat.mov, order); + expect(await fieldOrder(mov), label); + expect(_fiel(mov), atom); + // ProRes carries its own field flag, which needs field coding. + final frames = await WorkerHarness.ffprobeJson([ + '-v', 'error', '-select_streams', 'v:0', + '-read_intervals', '%+#1', + '-show_entries', 'frame=interlaced_frame,top_field_first', + '-of', 'json', mov, + ]); + final f = (frames['frames'] as List).first as Map; + expect(f['interlaced_frame'], 1, reason: 'ProRes frame header: $f'); + expect(f['top_field_first'], order == FieldOrder.topFieldFirst ? 1 : 0); + }, timeout: const Timeout(Duration(minutes: 5))); + } + + test('AVI cannot carry it (documented limitation)', () async { + final avi = await encode('v210_tff_avi', VideoCodec.v210, + ContainerFormat.avi, FieldOrder.topFieldFirst); + final s = await WorkerHarness.firstStream(avi, + selector: 'v:0', entries: ['codec_name', 'field_order']); + expect(s!['codec_name'], 'v210'); + expect(s['field_order'] ?? 'unknown', 'unknown'); + }, timeout: const Timeout(Duration(minutes: 5))); + + test('deinterlaced output is progressive', () async { + final mov = await encode('v210_deint', VideoCodec.v210, ContainerFormat.mov, + FieldOrder.topFieldFirst, + deinterlace: true); + expect(await fieldOrder(mov), 'progressive'); + expect(_fiel(mov), '0100'); + }, timeout: const Timeout(Duration(minutes: 10))); + + test('4:2:0 -> 4:2:2 keeps each field\'s chroma to itself', () async { + final mov = await encode('v210_chroma_fields', VideoCodec.v210, + ContainerFormat.mov, FieldOrder.topFieldFirst); + + // The fixture's per-field chroma, in 8-bit units: U of chroma rows 0, 1. + final src = await _rawFrame(fixture, 'yuv420p'); + final cw = _fw ~/ 2; + final srcU = _fw * _fh; + final topU = src[srcU + 0 * cw + cw ~/ 2]; + final botU = src[srcU + 1 * cw + cw ~/ 2]; + expect((topU - botU).abs(), greaterThan(50), reason: 'fixture fields differ'); + + // The output's U plane, 4:2:2 10-bit: one chroma row per luma row, + // row r in field r % 2. + final out = await _rawFrame(mov, 'yuv422p10le'); + final y10 = _fw * _fh * 2; + int u(int row) { + final o = y10 + (row * cw + cw ~/ 2) * 2; + return out[o] | (out[o + 1] << 8); + } + + final rows = [for (var r = 100; r < 112; r++) u(r)]; + print(' output U rows (10-bit) 100..111: $rows; fields 8-bit $topU / $botU'); + for (var r = 100; r < 112; r++) { + final want = (r.isEven ? topU : botU) * 4; + expect((u(r) - want).abs(), lessThanOrEqualTo(8), + reason: 'row $r: ${u(r)} vs its field\'s $want — fields blended'); + } + }, timeout: const Timeout(Duration(minutes: 5))); + }); + + group('PCM audio in MOV', () { + test('a PCM source is passed through', () async { + final out = await _encode( + _job( + name: 'pcm_passthrough', + input: _resource('interlaced_test.avi'), // pcm_s16le + pixFmt: 'yuv422p', + width: 720, + height: 576, + fps: 25, + audioMode: AudioMode.passthrough, + endFrame: 24, + ), + 'v210 pcm passthrough', + ); + final a = await WorkerHarness.firstStream(out, + selector: 'a:0', entries: ['codec_name']); + expect(a!['codec_name'], 'pcm_s16le'); + }, timeout: const Timeout(Duration(minutes: 5))); + + test('Convert to PCM is offered and works', () async { + final out = await _encode( + _job( + name: 'pcm_convert', + input: _resource('small_clip.mp4'), // aac + pixFmt: 'yuv420p', + width: 320, + height: 240, + fps: 29.97, + audioMode: AudioMode.convert, + audioCodec: AudioCodec.pcm, + endFrame: 29, + ), + 'v210 pcm convert', + ); + final v = await WorkerHarness.firstStream(out, + selector: 'v:0', entries: ['codec_name', 'pix_fmt']); + expect(v!['codec_name'], 'v210'); + final a = await WorkerHarness.firstStream(out, + selector: 'a:0', entries: ['codec_name']); + expect(a!['codec_name'], 'pcm_s16le'); + }, timeout: const Timeout(Duration(minutes: 5))); + }); +} diff --git a/app/test/v210_test.dart b/app/test/v210_test.dart new file mode 100644 index 00000000..93201b6e --- /dev/null +++ b/app/test/v210_test.dart @@ -0,0 +1,172 @@ +// v210: uncompressed 10-bit 4:2:2 output (task 7, forum thread 419911). +// +// The model's half: containers, data rate, and the Color Format pin. The pin +// warning is a second implementation of the worker's decision +// (`EncodingSettings::effective_chroma_subsampling`), so its table is pinned +// case for case to `v210_stores_422_10_whatever_is_selected` in +// worker/src/models/video_job.rs. Change one, change both. +// +// Run with: flutter test test/v210_test.dart + +import 'package:flutter_test/flutter_test.dart'; +import 'package:vapourbox/models/encoding_settings.dart'; +import 'package:vapourbox/models/video_job.dart'; +import 'package:vapourbox/utils/pixel_format.dart'; + +void main() { + group('VideoCodec.v210', () { + test('wire value matches the worker serde name', () { + expect(VideoCodec.v210.value, 'v210'); + expect(VideoCodec.v210.displayName, 'Uncompressed 10-bit 4:2:2 (v210)'); + }); + + test('is uncompressed, alone, and not lossless or ProRes', () { + for (final codec in VideoCodec.values) { + expect(codec.isUncompressed, codec == VideoCodec.v210, + reason: codec.displayName); + } + expect(VideoCodec.v210.isLossless, isFalse); + expect(VideoCodec.v210.isProRes, isFalse); + expect(VideoCodec.v210.isHardwareEncoder, isFalse); + expect(VideoCodec.v210.availablePresets, isNull); + expect(VideoCodec.v210.encoderFamily, 'Uncompressed'); + }); + + test('MOV by default; MOV, MKV and AVI, never MP4', () { + expect(VideoCodec.v210.preferredContainer, ContainerFormat.mov); + final supported = { + for (final c in ContainerFormat.values) + if (VideoCodec.v210.supportsContainer(c)) c + }; + // MP4 has no tag for v210: ffmpeg refuses the mux outright. + expect(supported, + {ContainerFormat.mov, ContainerFormat.mkv, ContainerFormat.avi}); + expect(ContainerFormat.mov.supportedCodecs, contains(VideoCodec.v210)); + expect(ContainerFormat.mp4.supportedCodecs, isNot(contains(VideoCodec.v210))); + }); + + test('description states the data rate', () { + expect(VideoCodec.v210.description, contains('221 Mbit/s')); + expect(VideoCodec.v210.description, contains('1.1 Gbit/s')); + }); + + test('quality description names no number', () { + final s = EncodingSettings(codec: VideoCodec.v210, quality: 37); + expect(s.qualityDescription, 'Uncompressed'); + }); + }); + + group('v210BitsPerSecond', () { + test('matches the encoder (48-pixel line padding)', () { + // What ffmpeg reports for a 720x576 25p v210 stream: 221184 kb/s. + expect(v210BitsPerSecond(720, 576, 25), 221184000); + expect(v210BitsPerSecond(1920, 1080, 25), 1105920000); + // 1280 is not a multiple of 48: the line is padded to 1296 pixels. + expect(v210BitsPerSecond(1280, 720, 25), (27 * 128) * 720 * 8 * 25); + }); + }); + + group('effectiveChromaSubsampling', () { + test('v210 always converts to 10-bit 4:2:2; other codecs keep the choice', + () { + for (final cs in ChromaSubsampling.values) { + expect( + EncodingSettings(codec: VideoCodec.v210, chromaSubsampling: cs) + .effectiveChromaSubsampling, + ChromaSubsampling.yuv422p10, + reason: cs.value, + ); + expect( + EncodingSettings(codec: VideoCodec.proresHQ, chromaSubsampling: cs) + .effectiveChromaSubsampling, + cs, + ); + } + }); + }); + + group('v210ChromaPinWarning (pinned to the worker table)', () { + // (selection, source) -> (chroma lost, selection ignored). The same rows + // as `v210_stores_422_10_whatever_is_selected`. + const sources = [ + 'yuv420p', + 'yuv411p', + 'yuv422p', + 'yuv422p10le', + 'yuv444p10le', + 'yuv422p16le', + ]; + (bool, bool) expected(ChromaSubsampling cs, String src) { + switch (cs) { + case ChromaSubsampling.original: + return (src == 'yuv444p10le', false); + case ChromaSubsampling.yuv422p10: + return (false, false); + case ChromaSubsampling.yuv444p10: + return (true, true); + case ChromaSubsampling.yuv420: + case ChromaSubsampling.yuv420p10: + case ChromaSubsampling.yuv422: + return (false, true); + } + } + + test('speaks exactly where chroma is lost or the choice is ignored', () { + for (final cs in ChromaSubsampling.values) { + for (final src in sources) { + final (lost, ignored) = expected(cs, src); + final message = v210ChromaPinWarning( + codec: VideoCodec.v210, + chromaSubsampling: cs, + pixelFormat: src, + ); + final ctx = '${cs.value} from $src: $message'; + expect(message != null, lost || ignored, reason: ctx); + if (message == null) continue; + expect(message, contains('4:2:2'), reason: ctx); + expect(message.contains('ignored'), ignored, reason: ctx); + expect(message.contains('resampled down'), lost, reason: ctx); + } + } + }); + + test('silent before a file is loaded with Match source', () { + expect( + v210ChromaPinWarning( + codec: VideoCodec.v210, + chromaSubsampling: ChromaSubsampling.original, + ), + isNull, + ); + }); + + test('never fires for another codec', () { + for (final codec in VideoCodec.values.where((c) => !c.isUncompressed)) { + for (final cs in ChromaSubsampling.values) { + expect( + v210ChromaPinWarning( + codec: codec, + chromaSubsampling: cs, + pixelFormat: 'yuv444p10le', + ), + isNull, + reason: '${codec.displayName} ${cs.value}', + ); + } + } + }); + + test('the ProRes warning stays silent for v210', () { + for (final cs in ChromaSubsampling.values) { + expect( + proresChromaPinWarning( + codec: VideoCodec.v210, + chromaSubsampling: cs, + pixelFormat: 'yuv444p10le', + ), + isNull, + ); + } + }); + }); +} diff --git a/docs/ENGINEERING_NOTES.md b/docs/ENGINEERING_NOTES.md index 936018ba..acad7eff 100644 --- a/docs/ENGINEERING_NOTES.md +++ b/docs/ENGINEERING_NOTES.md @@ -3486,3 +3486,30 @@ anonymous download link for one asset returned 504 for a while (a GitHub incident; the API served the file) — re-uploading did not help, waiting did. A manifest's `url` is independent of its installed `name`, so a stuck asset could have been re-hosted under another name without touching code. + +### v210 output, and interlaced ProRes/v210 flagged progressive (2026-11-10) + +Forum request (videohelp 419911, #154): uncompressed 10-bit 4:2:2 in MOV for +Premiere/Resolve. Full evidence: +[docs/reports/2026-11-v210-field-order/](reports/2026-11-v210-field-order/index.html). + +- **Every still-interlaced ProRes/v210 MOV was flagged progressive** + (`fiel 0x0100`). vspipe writes `Ip` in the Y4M header whatever + `_FieldBased` says. `-field_order tt` on the output is inert in FFmpeg 9 + (overwritten from the frame flags); `-top 1|0` on the pipe input works + (`fiel 0x0209` / `0x020e`, MKV `tb`/`bt`; AVI has nowhere to put it). + ProRes also needs `-flags +ildct`, or its own frame header stays + progressive. The choice is `pipeline_executor::field_order_args`, from + `ScriptGenerator::output_field_order`. +- **FFmpeg's `-pix_fmt` conversion of interlaced 4:2:0 to 4:2:2 blends the + fields' chroma** (U rows 64/192 came out 172/84; zimg with `_FieldBased` + keeps 64/192). v210's 4:2:2 10-bit is therefore made in the script + (`EncodingSettings::effective_chroma_subsampling`). ProRes from interlaced + 4:2:0 still goes through FFmpeg's conversion: a follow-up, since changing it + changes existing output. +- v210 is ~221 Mbit/s at 720x576 25p and ~1.1 Gbit/s at 1080p25, so it skips + `+faststart` (a second full pass over the file) and restored audio beside it + is PCM. +- Found on the way: `DependencyLocator::probe_frame_count` failed on a MOV + whose stream FFmpeg 9 also prints under a stream group (`125\n125\n`), so a + job without `totalFrames` encoded one frame. It now takes the first line. diff --git a/docs/reports/2026-11-v210-field-order/index.html b/docs/reports/2026-11-v210-field-order/index.html new file mode 100644 index 00000000..ab45faa1 --- /dev/null +++ b/docs/reports/2026-11-v210-field-order/index.html @@ -0,0 +1,191 @@ + + + + + +v210 output and interlaced field-order flagging + + + +
+

v210 output and interlaced field-order flagging

+

2026-11-10 · forum request (videohelp thread 419911, post #154) · +measured on Apple Silicon (macOS arm64), deps 1.16.0 bundle (FFmpeg 9.0.2, VapourSynth R78), against the templates of app 1.7.0

+ +
+Outcome: built. Uncompressed 10-bit 4:2:2 (v210) is a new output codec, in MOV +(default), MKV and AVI. Probing for it found that every still-interlaced ProRes or v210 MOV +was flagged progressive (fiel atom 0x0100), because vspipe's Y4M header always +says Ip. The encoder is now told the field order on the pipe input with -top +(fiel 0x0209 TFF / 0x020e BFF), and ProRes codes the frame as fields +(-flags +ildct). The suggested -field_order output option is +inert in FFmpeg 9. A second finding: FFmpeg's -pix_fmt conversion of +interlaced 4:2:0 to 4:2:2 blends the two fields' chroma, so v210's 4:2:2 is made in the script, field-aware. +
+ +

What was asked, and what had to be checked

+

Strasse asked for MOV output with v210 uncompressed: AVI plays badly on a Mac, and FFV1/HuffYUV/FFVHuff +aren't usable directly in Premiere or Resolve, so users transcode to v210 anyway. The bundled FFmpeg's +v210 encoder takes yuv422p10le yuv422p, so no deps change is needed. An editing +intermediate is only useful if the editor treats its fields correctly, so the field-order flag was checked +for v210 and the existing ProRes output.

+ +

Findings

+

All from scripts/probe.sh. The test clip is a 720x576 25 fps 10-bit 4:2:2 blank, marked +_FieldBased=2 (TFF) in VapourSynth, piped as Y4M exactly as the worker does.

+ +

P1 · vspipe never says a clip is interlaced

+
YUV4MPEG2 C422p10 W720 H576 F25:1 Ip A0:0 XLENGTH=10
+

The header's Ip (progressive) is written whatever _FieldBased is, so FFmpeg +never learns the field order from the pipe.

+ +

P2–P5 · What reaches the file

+
+ + + + + + + + + + +
EncodeMOV fielffprobe field_orderframe header (interlaced, tff)
v210, nothing (before this change)0100progressive0, 0
v210, -field_order tt (output option)0100progressive0, 0
v210, -top 1 (input option)0209tb1, 1
v210, -top 0 (input option)020ebt1, 0
v210, -vf setfield=tff0209tb1, 1
ProRes 422 HQ, nothing (before this change)0100progressive0, 0
ProRes 422 HQ, -top 10209tb0, 0
ProRes 422 HQ, -top 1 -flags +ildct0209tb1, 1
ProRes 422 HQ, -top 0 -flags +ildct020ebt1, 0
+
    +
  • P2. Before this change every still-interlaced ProRes and v210 MOV said +progressive. That's actively wrong, not just missing: an editor reading 0x0100 treats +the woven frame as one picture.
  • +
  • P3. -field_order tt on the output does nothing. FFmpeg's encoder setup +takes the field order from the frames' own flags and overwrites the option.
  • +
  • P4. -top 1|0 before -i - (a decoder option on the Y4M input) +flags the frames. setfield in the -vf chain does the same, but a Custom FFmpeg +Argument -vf replaces the whole chain and would drop it, so the input option is used. CLAUDE.md +gives the same reasoning for declaring colour tags on the input. In QuickTime, fiel 9 = two +interleaved fields, top first; 14 = bottom first. ffprobe's tb/bt labels are +FFmpeg's names for these, and they read confusingly. The atom bytes are what an editor reads, so the tests +check the bytes.
  • +
  • P5. ProRes has a field flag in its own frame header, which stays progressive unless +the frame is coded as fields (+ildct). Apple codes interlaced ProRes that way. v210 has no +bitstream flag; its frame-level values come from the container.
  • +
+ +

P6 · Containers

+ + + + + + +
Containerv210, -top 1
MOVtb (fiel 0x0209)
MKVtb
AVIunknown: AVI has nowhere to write it
MP4refused: "Could not find tag for codec v210"
+

So v210 is offered in MOV (default), MKV and AVI. The README says AVI carries no field order.

+ +

P7 · FFmpeg's 4:2:0 → 4:2:2 conversion blends the fields' chroma

+

An interlaced 4:2:0 frame whose field 0 has chroma U = 64 and field 1 U = 192 (in a woven 4:2:0 +picture, chroma row k belongs to field k mod 2). The U rows of the 4:2:2 result, 8-bit +units:

+ + + + +
Converted byU rows 2…11
FFmpeg -pix_fmt yuv422p10le (frames flagged interlaced)172, 172, 84, 84, 172, 172, 84, 84, 172, 172
VapourSynth resize with _FieldBased=264, 192, 64, 192, 64, 192, 64, 192, 64, 192
+

FFmpeg's auto-inserted scaler treats the woven frame as progressive, mixing the fields' colour on +every moving edge. zimg honours _FieldBased and keeps each field's chroma exact. So for v210 the +script always converts to YUV422P10 itself +(EncodingSettings::effective_chroma_subsampling), whatever Color Format is selected. FFmpeg's +-pix_fmt yuv422p10le stays only as a guard and converts nothing. This is a synthetic worst case +(fully saturated, opposite colours per field). On real footage the error is confined to chroma that differs +between fields: moving edges.

+

ProRes has the same latent problem for interlaced 4:2:0 sources. It is unchanged here, +because changing it changes existing ProRes output. Left as a follow-up.

+ +

P8 · Data rate

+

FFmpeg reports 221,184 kb/s for 720x576 25p v210. That matches v210's packing (6 pixels +per 16 bytes, lines padded to 48 pixels): ceil(720/48)·128 · 576 · 8 · 25 = 221,184,000 bit/s. +1080p25 is 1,105,920,000 bit/s, about 8.3 GB a minute. For the same reason, MP4/MOV's +faststart +(a second full pass that rewrites the file to move its index to the front) is skipped for v210.

+ +

Decisions

+
    +
  • Field order from one decision. ScriptGenerator::output_field_order starts +from "deinterlacing off and a field order detected". It drops to progressive where a pass makes the frames +progressive: Stabilize's pitch correction, Deblock's mpeg2stinx, and a Frame Rate conversion (a retimed +woven frame is not a field pair). A test counts every SetFieldBased( in both templates, so a +new pass that clears the marking fails until it is accounted for.
  • +
  • Only v210 and ProRes are flagged. FFV1/HuffYUV in MKV could take the flag as +metadata, and x264/x265 would need real interlaced coding. Both are separate decisions, left as +follow-ups.
  • +
  • v210's colour format is not the user's choice. Any other selection is converted to +10-bit 4:2:2 directly, and the settings dialog says so (v210ChromaPinWarning, pinned to the +worker's table).
  • +
  • Restored audio beside v210 is PCM, not the container's usual lossy codec.
  • +
+ +

Verification

+

app/test/integration_v210_output_test.dart (heavy) encodes real jobs and checks:

+
    +
  • a 10-bit 4:2:2 ProRes source comes back bit for bit in v210 (framemd5 of all 25 frames)
  • +
  • the fiel bytes and field_order for v210 MOV/MKV and ProRes MOV at TFF and BFF, +plus ProRes's frame header
  • +
  • AVI stays unknown, and a deinterlaced job stays 0x0100
  • +
  • each field's chroma survives the 4:2:0 → 4:2:2 conversion on a red/blue field fixture +(10-bit rows exactly 360/960)
  • +
  • PCM passthrough and conversion in MOV
  • +
  • preview = encode (meanAbsDiff 0.000)
  • +
+

The Rust tests cover the argument matrix (which codecs and jobs get -top and ++ildct) and the script's conversion block.

+ +

Not tested

+
    +
  • Windows and Linux FFmpeg builds. -top is a generic decoder option, so the nightly heavy +run on all four platforms is the check there.
  • +
  • Opening the files in Premiere Pro or DaVinci Resolve. The fiel values are Apple's +documented meanings, but no NLE was run.
  • +
+ +

Reproduce

+
SCRATCH=/path/to/scratch docs/reports/2026-11-v210-field-order/scripts/probe.sh
+

It needs the deps bundle (deps/macos-arm64, or DEPS=…) and writes only under +$SCRATCH.

+
+ + diff --git a/docs/reports/2026-11-v210-field-order/scripts/probe.sh b/docs/reports/2026-11-v210-field-order/scripts/probe.sh new file mode 100755 index 00000000..f8f7507f --- /dev/null +++ b/docs/reports/2026-11-v210-field-order/scripts/probe.sh @@ -0,0 +1,86 @@ +#!/bin/bash +# Reproduces every number in ../index.html: what field order a still-interlaced +# v210 / ProRes encode declares (P1-P6), whether FFmpeg's 4:2:0 -> 4:2:2 +# conversion keeps each field's chroma (P7), and v210's containers and data +# rate (P8). +# +# SCRATCH=/some/scratch/dir ./probe.sh +# +# Uses the repo's deps bundle (deps/, or $DEPS). Writes only under +# $SCRATCH. +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$HERE/../../../.." && pwd)" +S="${SCRATCH:?set SCRATCH to your scratch directory}" +DEPS="${DEPS:-$REPO/deps/macos-arm64}" +FF="$DEPS/ffmpeg/ffmpeg"; FP="$DEPS/ffmpeg/ffprobe"; VSPIPE="$DEPS/vapoursynth/vspipe" +mkdir -p "$S"; cd "$S" + +fiel() { python3 -c "import sys;d=open(sys.argv[1],'rb').read();i=d.find(b'fiel');print(d[i+4:i+6].hex() if i>0 else 'absent')" "$1"; } +fo() { "$FP" -v error -select_streams v:0 -show_entries stream=field_order -of csv=p=0 "$1"; } +hdr() { "$FP" -v error -select_streams v:0 -read_intervals %+#1 \ + -show_entries frame=interlaced_frame,top_field_first -of csv=p=0 "$1"; } + +echo "== P1: vspipe's Y4M header for a clip marked _FieldBased=2 (TFF)" +cat > fb.vpy <<'PY' +import vapoursynth as vs +c = vs.core.std.BlankClip(width=720, height=576, format=vs.YUV422P10, length=10, fpsnum=25) +vs.core.std.SetFieldBased(c, 2).set_output() +PY +"$VSPIPE" -c y4m fb.vpy in.y4m >/dev/null 2>&1 +head -c 60 in.y4m | head -1 + +echo "== P2-P5: MOV fiel atom / ffprobe field_order / ProRes frame header" +printf '%-34s %-6s %-12s %s\n' case fiel field_order "frame(interlaced,tff)" +run() { # label outfile args... + local label="$1" out="$2"; shift 2 + "$FF" -v error -y "$@" "$out" + printf '%-34s %-6s %-12s %s\n' "$label" "$(fiel "$out")" "$(fo "$out")" "$(hdr "$out")" +} +run "v210, nothing" a.mov -f yuv4mpegpipe -i in.y4m -c:v v210 +run "v210, -field_order tt (output)" b.mov -f yuv4mpegpipe -i in.y4m -c:v v210 -field_order tt +run "v210, -top 1 (input)" c.mov -top 1 -f yuv4mpegpipe -i in.y4m -c:v v210 +run "v210, -top 0 (input)" c0.mov -top 0 -f yuv4mpegpipe -i in.y4m -c:v v210 +run "v210, -vf setfield=tff" d.mov -f yuv4mpegpipe -i in.y4m -vf setfield=tff -c:v v210 +run "ProRes HQ, nothing" e.mov -f yuv4mpegpipe -i in.y4m -c:v prores_ks -profile:v 3 +run "ProRes HQ, -top 1" f.mov -top 1 -f yuv4mpegpipe -i in.y4m -c:v prores_ks -profile:v 3 +run "ProRes HQ, -top 1 +ildct" g.mov -top 1 -f yuv4mpegpipe -i in.y4m -flags +ildct -c:v prores_ks -profile:v 3 +run "ProRes HQ, -top 0 +ildct" g0.mov -top 0 -f yuv4mpegpipe -i in.y4m -flags +ildct -c:v prores_ks -profile:v 3 + +echo "== P4/P6: other containers with -top 1" +for ext in mkv avi; do + "$FF" -v error -y -top 1 -f yuv4mpegpipe -i in.y4m -c:v v210 "top.$ext" + echo "v210 .$ext field_order: $(fo "top.$ext")" +done +"$FF" -v error -y -f yuv4mpegpipe -i in.y4m -c:v v210 x.mp4 2>&1 | head -1 || true + +echo "== P7: interlaced 4:2:0 -> 4:2:2. Field 0 chroma U=64, field 1 U=192." +cat > chroma.vpy <<'PY' +import os, vapoursynth as vs +core = vs.core +y = core.std.BlankClip(width=64, height=64, format=vs.GRAY8, color=128, length=4, fpsnum=25) +ua = core.std.BlankClip(width=32, height=16, format=vs.GRAY8, color=64, length=4, fpsnum=25) +ub = core.std.BlankClip(width=32, height=16, format=vs.GRAY8, color=192, length=4, fpsnum=25) +u = core.std.DoubleWeave(core.std.Interleave([ua, ub]), tff=True)[::2] # row k = field k%2 +v = core.std.BlankClip(u, color=128) +c = core.std.SetFieldBased(core.std.ShufflePlanes([y, u, v], [0, 0, 0], vs.YUV), 2) +if os.environ.get("FIELDAWARE"): + c = core.resize.Bicubic(c, format=vs.YUV422P10) # what the worker now does +c.set_output() +PY +"$VSPIPE" -c y4m chroma.vpy c420.y4m >/dev/null 2>&1 +FIELDAWARE=1 "$VSPIPE" -c y4m chroma.vpy c422.y4m >/dev/null 2>&1 +"$FF" -v error -y -top 1 -f yuv4mpegpipe -i c420.y4m -c:v v210 -pix_fmt yuv422p10le by_ffmpeg.mov +"$FF" -v error -y -f yuv4mpegpipe -i c422.y4m -c:v v210 by_script.mov +for f in by_ffmpeg.mov by_script.mov; do + "$FF" -v error -i "$f" -frames:v 1 -f rawvideo -pix_fmt yuv422p10le - | python3 -c " +import sys, struct +d = sys.stdin.buffer.read(); W = 64; H = 64 +u = d[W*H*2:] +rows = [struct.unpack_from('> 2 for r in range(2, 12)] +print('$f U rows 2..11 (8-bit):', rows)" +done + +echo "== P8: v210 data rate at 720x576 25p (ffmpeg's own figure)" +"$FF" -y -f yuv4mpegpipe -i in.y4m -c:v v210 rate.mov 2>&1 | grep 'Stream.*v210' | grep -o '[0-9]* kb/s' +python3 -c "print('formula: ceil(720/48)*128 bytes/line * 576 * 8 * 25 =', -(-720//48)*128*576*8*25, 'bit/s')" diff --git a/docs/reports/README.md b/docs/reports/README.md index 533e59c8..e3afb6cc 100644 --- a/docs/reports/README.md +++ b/docs/reports/README.md @@ -66,6 +66,7 @@ to it. | Date | Report | Outcome | |---|---|---| +| 2026-11-10 | [v210 output and interlaced field-order flagging](2026-11-v210-field-order/index.html) | Built: v210 (uncompressed 10-bit 4:2:2) in MOV/MKV/AVI. Every still-interlaced ProRes/v210 MOV was flagged progressive (`fiel 0x0100`, vspipe's Y4M is always `Ip`); `-field_order` is inert in FFmpeg 9, so the pipe input gets `-top` (`fiel 0x0209`/`0x020e`) and ProRes `+ildct`. FFmpeg's 4:2:0 → 4:2:2 conversion blends the fields' chroma, so v210's is done in the script, field-aware | | 2026-11-10 | [Issue #148: Frame Rate combined with Noise Reduction and Sharpen](issue-148-frame-rate-combined/index.html) | Not reproduced on macOS arm64 across 5 denoisers x 4 sharpeners x FlowFPS/RIFE x 8/10-bit; no pipeline change; nightly regression test added to cover Windows/MVTools v24 and other GPUs; reporter asked for a clip | | 2026-11-09 | [Noise and halo scores for the pass list](2026-11-noise-halo-scores/index.html) | Built: `--detect-noise-halo` badges Noise Reduction at a motion-compensated noise sigma ≥ 3.5 and Dehalo at a two-sided edge overshoot ≥ 6% of the step; no false positives on 154 fixtures (clean max 2.06 / 1.48), catches white noise from sigma 4 and tape/grain noise from sigma 6, misses sharpening of already-soft edges. VCG's signalstats TOUT and halo ratio did not separate the fixtures; a spatial (Immerkær) noise score was rejected as blind to tape and grain noise | | 2026-11-08 | [Crop & Resize: BT.601 → BT.709 on SD → HD, and Detect borders](2026-11-crop-detect-709/index.html) | Built: matrix-only 601 → 709 conversion when the output is HD (default Auto; patches within 0.67 levels of the source; a gamut conversion rejected: it moved 75% green by 55 levels on players without colour management); `--detect-crop` finds blanking, bars and the head-switching band exactly on real-picture fixtures, and matches a by-eye measurement of a DVB-T broadcast | diff --git a/worker/src/audio_restoration.rs b/worker/src/audio_restoration.rs index c674bb3a..f1524acd 100644 --- a/worker/src/audio_restoration.rs +++ b/worker/src/audio_restoration.rs @@ -66,7 +66,7 @@ use anyhow::{bail, Context, Result}; use crate::dependency_locator::DependencyLocator; use crate::models::{ AudioCodec, AudioMode, AudioRestorationParameters, ChannelFix, ContainerFormat, - EncodingSettings, HumFrequency, LogLevel, ProgressInfo, VideoJob, + EncodingSettings, HumFrequency, LogLevel, ProgressInfo, VideoCodec, VideoJob, }; use crate::progress_reporter::ProgressReporter; @@ -731,14 +731,15 @@ pub fn encode_map_args(settings: &EncodingSettings, track: Option<&RestoredTrack /// The encoder's audio codec arguments. Passthrough copies — except the /// rendered track, which is new audio and must be encoded: FLAC in MKV, AAC -/// at the chosen quality in MP4/MOV, PCM in AVI. +/// at the chosen quality in MP4/MOV, PCM in AVI — and PCM whatever the +/// container beside uncompressed v210 video. pub fn encode_codec_args(settings: &EncodingSettings, track: Option<&RestoredTrack>) -> Vec { let mut args = Vec::new(); match settings.audio_mode { AudioMode::Passthrough => { args.extend(["-c:a".into(), "copy".into()]); if track.is_some() { - let codec = restored_passthrough_codec(settings.container); + let codec = restored_passthrough_codec(settings.container, settings.codec); args.extend(["-c:a:0".into(), codec.ffmpeg_name().into()]); if !codec.is_lossless() { args.extend(["-b:a:0".into(), format!("{}k", settings.audio_quality.bitrate())]); @@ -757,7 +758,14 @@ pub fn encode_codec_args(settings: &EncodingSettings, track: Option<&RestoredTra } /// What a restored track is encoded as when the audio setting is Passthrough. -pub fn restored_passthrough_codec(container: ContainerFormat) -> AudioCodec { +/// +/// An uncompressed (v210) output is an editing intermediate: its restored +/// track is PCM in every container rather than a lossy codec beside +/// uncompressed picture. +pub fn restored_passthrough_codec(container: ContainerFormat, video: VideoCodec) -> AudioCodec { + if video.is_uncompressed() { + return AudioCodec::Pcm; + } match container { ContainerFormat::Mkv => AudioCodec::Flac, ContainerFormat::Mp4 | ContainerFormat::Mov => AudioCodec::Aac, @@ -1617,4 +1625,16 @@ mod tests { assert_eq!(num(-0.0), "0"); assert_eq!(num_precise(1.000999), "1.000999"); } + + /// An uncompressed (v210) output's restored track is PCM in every + /// container; every other codec keeps the container's choice. + #[test] + fn restored_passthrough_is_pcm_beside_v210() { + for container in [ContainerFormat::Mp4, ContainerFormat::Mov, ContainerFormat::Mkv, ContainerFormat::Avi] { + assert_eq!(restored_passthrough_codec(container, VideoCodec::V210), AudioCodec::Pcm); + } + assert_eq!(restored_passthrough_codec(ContainerFormat::Mov, VideoCodec::ProResHQ), AudioCodec::Aac); + assert_eq!(restored_passthrough_codec(ContainerFormat::Mkv, VideoCodec::FFV1), AudioCodec::Flac); + assert_eq!(restored_passthrough_codec(ContainerFormat::Avi, VideoCodec::H264), AudioCodec::Pcm); + } } diff --git a/worker/src/dependency_locator.rs b/worker/src/dependency_locator.rs index e5b55c07..b8a2c6ea 100644 --- a/worker/src/dependency_locator.rs +++ b/worker/src/dependency_locator.rs @@ -576,11 +576,7 @@ impl DependencyLocator { if !out.status.success() { return None; } - String::from_utf8_lossy(&out.stdout) - .trim() - .parse::() - .ok() - .filter(|&n| n > 0) + first_count(&String::from_utf8_lossy(&out.stdout)) } /// ffprobe's `width`x`height` for the first video stream — the size the @@ -1118,6 +1114,19 @@ impl DependencyLocator { } } +/// The first line of ffprobe's `nb_read_frames` output as a positive count. +/// +/// FFmpeg 9 prints a stream a second time under each stream group it belongs +/// to (a QuickTime file with a timecode track, e.g. `pal-sd-25.mov`), so the +/// output is `125\n125\n`. Parsing the whole of it failed, and a job without +/// `totalFrames` then encoded a single frame. +fn first_count(stdout: &str) -> Option { + stdout + .lines() + .find_map(|l| l.trim().parse::().ok()) + .filter(|&n| n > 0) +} + #[cfg(test)] mod tests { use super::*; @@ -1130,4 +1139,13 @@ mod tests { }; assert_eq!(locator.platform_suffix(), "windows-x64"); } + + #[test] + fn frame_count_takes_the_first_line() { + assert_eq!(super::first_count("125\n125\n"), Some(125)); + assert_eq!(super::first_count("79\n"), Some(79)); + assert_eq!(super::first_count("N/A\n"), None); + assert_eq!(super::first_count("0\n"), None); + assert_eq!(super::first_count(""), None); + } } diff --git a/worker/src/models/video_job.rs b/worker/src/models/video_job.rs index e32264c9..5d6e0043 100644 --- a/worker/src/models/video_job.rs +++ b/worker/src/models/video_job.rs @@ -172,7 +172,7 @@ impl VideoJob { /// like everything else about custom code, it is the user's to get right. pub fn encoder_input_pix_fmt(&self) -> String { self.encoding_settings - .chroma_subsampling + .effective_chroma_subsampling() .ffmpeg_pix_fmt() .map(str::to_string) .unwrap_or_else(|| { @@ -691,6 +691,25 @@ impl EncodingSettings { pub fn effective_max_cache_size_mb(&self) -> Option { self.max_cache_size_mb.filter(|mb| *mb > 0) } + + /// The output colour format the script actually converts to: the user's + /// choice, except where the codec stores one format and only one. + /// + /// v210 is always 10-bit 4:2:2, so the conversion is done here, in the + /// script, where `_FieldBased` makes zimg field-aware. Left to ffmpeg's + /// `-pix_fmt`, an interlaced 4:2:0 source would have its two fields' + /// chroma blended together (measured: rows of 64/192 came out 84/172), + /// and a user-selected 8-bit format would be reduced first and padded + /// back up, losing depth for nothing. `v210ChromaPinWarning` in + /// `app/lib/utils/pixel_format.dart` tells the user, pinned to + /// `v210_stores_422_10_whatever_is_selected`. + pub fn effective_chroma_subsampling(&self) -> ChromaSubsampling { + if self.codec.is_uncompressed() { + ChromaSubsampling::Yuv422P10 + } else { + self.chroma_subsampling + } + } } impl Default for EncodingSettings { @@ -781,6 +800,13 @@ pub enum VideoCodec { #[serde(rename = "prores_ks -profile:v 5")] ProRes4444Xq, + + // Uncompressed + /// Uncompressed 10-bit 4:2:2 (`v210`), the editing intermediate Premiere + /// and Resolve take natively. Always stored as `yuv422p10le`, which the + /// script produces itself (see `EncodingSettings::effective_chroma_subsampling`). + #[serde(rename = "v210")] + V210, } /// Encoder family for grouping quality/preset logic. @@ -793,6 +819,8 @@ pub enum EncoderFamily { Amf, ProRes, Lossless, + /// v210: no quality, no preset, a data rate fixed by the frame size. + Uncompressed, } #[allow(dead_code)] @@ -819,6 +847,7 @@ impl VideoCodec { VideoCodec::ProResHQ => "prores_ks", VideoCodec::ProRes4444 => "prores_ks", VideoCodec::ProRes4444Xq => "prores_ks", + VideoCodec::V210 => "v210", } } @@ -844,6 +873,7 @@ impl VideoCodec { VideoCodec::ProResHQ, VideoCodec::ProRes4444, VideoCodec::ProRes4444Xq, + VideoCodec::V210, ]; /// Get the ProRes profile value, if applicable. @@ -874,7 +904,8 @@ impl VideoCodec { | VideoCodec::H265Amf | VideoCodec::FFV1 | VideoCodec::Huffyuv - | VideoCodec::Ffvhuff => None, + | VideoCodec::Ffvhuff + | VideoCodec::V210 => None, } } @@ -893,6 +924,21 @@ impl VideoCodec { matches!(self.encoder_family(), EncoderFamily::Lossless) } + /// Whether this is an uncompressed codec (v210). Deliberately not part of + /// `is_lossless`: that family defaults to AVI and is grouped as "Lossless" + /// in the UI, while v210 is an editing intermediate that belongs in MOV. + pub fn is_uncompressed(&self) -> bool { + matches!(self.encoder_family(), EncoderFamily::Uncompressed) + } + + /// Whether the output is flagged with the picture's field order when it is + /// still interlaced (see `ScriptGenerator::output_field_order`). The + /// editing intermediates only: an NLE reads the flag to decide how to + /// treat the fields. Other codecs are unchanged. + pub fn flags_field_order(&self) -> bool { + self.is_prores() || self.is_uncompressed() + } + /// FFmpeg output pixel format to force for this codec, given the format the /// pipeline will actually hand it (`encoder_input`, from /// [`VideoJob::encoder_input_pix_fmt`]). `None` leaves the choice to @@ -943,6 +989,11 @@ impl VideoCodec { // Classic HuffYUV only supports yuv422p (not yuv420p); ffvhuff and // the others accept yuv420p. VideoCodec::Huffyuv => return Some("yuv422p"), + // v210 stores 10-bit 4:2:2 and nothing else. The script already + // delivers exactly that (`effective_chroma_subsampling`), field- + // aware, so this is a guard rather than a conversion: ffmpeg's own + // 4:2:0 -> 4:2:2 conversion blends the two fields' chroma. + VideoCodec::V210 => return Some("yuv422p10le"), // AMF takes nv12 natively. Left to negotiate, ffmpeg will hand a // >8-bit source to the encoder as p010, and 10-bit HEVC encode is // only supported on some AMD ASICs — where it isn't, the AMF @@ -1006,7 +1057,7 @@ impl VideoCodec { "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow", ], EncoderFamily::Amf => &["speed", "balanced", "quality"], - // VideoToolbox, ProRes and the lossless codecs take no preset. + // VideoToolbox, ProRes, the lossless codecs and v210 take no preset. _ => &[], } } @@ -1050,7 +1101,13 @@ impl VideoCodec { /// Whether this is a hardware-accelerated encoder. pub fn is_hardware(&self) -> bool { - !matches!(self.encoder_family(), EncoderFamily::Software | EncoderFamily::ProRes | EncoderFamily::Lossless) + !matches!( + self.encoder_family(), + EncoderFamily::Software + | EncoderFamily::ProRes + | EncoderFamily::Lossless + | EncoderFamily::Uncompressed + ) } /// Get the encoder family for this codec. @@ -1062,6 +1119,7 @@ impl VideoCodec { VideoCodec::H264Videotoolbox | VideoCodec::H265Videotoolbox => EncoderFamily::Videotoolbox, VideoCodec::H264Amf | VideoCodec::H265Amf => EncoderFamily::Amf, VideoCodec::FFV1 | VideoCodec::Huffyuv | VideoCodec::Ffvhuff => EncoderFamily::Lossless, + VideoCodec::V210 => EncoderFamily::Uncompressed, // Exhaustive, not a catch-all: a non-ProRes variant added later // would otherwise be silently classified as ProRes and take the // `-profile:v` branch in `build_encoder_quality_args`. @@ -1076,7 +1134,7 @@ impl VideoCodec { /// Get the preferred container format for this codec. pub fn preferred_container(&self) -> ContainerFormat { - if self.is_prores() { + if self.is_prores() || self.is_uncompressed() { ContainerFormat::Mov } else if self.is_lossless() { ContainerFormat::Avi @@ -1107,6 +1165,7 @@ impl VideoCodec { VideoCodec::ProResHQ => "ProRes 422 HQ", VideoCodec::ProRes4444 => "ProRes 4444", VideoCodec::ProRes4444Xq => "ProRes 4444 XQ", + VideoCodec::V210 => "Uncompressed 10-bit 4:2:2 (v210)", } } } @@ -1439,6 +1498,77 @@ mod tests { /// file stamped 4444 carrying 4:2:2. /// /// Like the HuffYUV and AMF pins, this holds whatever the pipeline produced + /// v210 is the uncompressed family, alone: no quality, no preset, MOV by + /// default, never hardware, and its serde name is the wire format. + #[test] + fn v210_is_uncompressed() { + let c = VideoCodec::V210; + assert_eq!(serde_json::to_string(&c).unwrap(), "\"v210\""); + assert_eq!(serde_json::from_str::("\"v210\"").unwrap(), c); + assert_eq!(c.ffmpeg_codec(), "v210"); + assert_eq!(c.encoder_family(), EncoderFamily::Uncompressed); + assert!(c.is_uncompressed() && !c.is_lossless() && !c.is_prores()); + assert!(!c.is_hardware()); + assert!(c.available_presets().is_empty()); + assert_eq!(c.preferred_container(), ContainerFormat::Mov); + assert!(c.flags_field_order()); + for codec in VideoCodec::ALL.iter().copied() { + assert_eq!(codec.is_uncompressed(), codec == VideoCodec::V210, "{codec:?}"); + assert_eq!(codec.flags_field_order(), codec.is_prores() || codec == VideoCodec::V210); + } + for fmt in ["yuv420p", "yuv422p", "yuv420p10le", "yuv422p10le", "yuv444p10le", "yuv422p16le"] { + assert_eq!(c.forced_pix_fmt(fmt), Some("yuv422p10le"), "{fmt}"); + } + } + + /// The table `v210ChromaPinWarning` (app/lib/utils/pixel_format.dart) is + /// pinned to, in `app/test/v210_chroma_pin_warning_test.dart`. Change one, + /// change both. + /// + /// Whatever Color Format is selected, the script converts to 10-bit 4:2:2 + /// for v210. Each row: (selection, source, chroma lost, selection ignored). + #[test] + fn v210_stores_422_10_whatever_is_selected() { + use crate::pixel_format::{chroma_and_depth, ChromaClass}; + use ChromaSubsampling as C; + let sources = ["yuv420p", "yuv411p", "yuv422p", "yuv422p10le", "yuv444p10le", "yuv422p16le"]; + for &cs in ChromaSubsampling::ALL { + for src in sources { + let mut job: VideoJob = serde_json::from_value(serde_json::json!({ + "id": "00000000-0000-0000-0000-000000000000", + "inputPath": "in.mov", + "outputPath": "out.mov", + "qtgmcParameters": {}, + "encodingSettings": {}, + })) + .unwrap(); + job.input_pixel_format = Some(src.to_string()); + job.encoding_settings.codec = VideoCodec::V210; + job.encoding_settings.chroma_subsampling = cs; + assert_eq!(job.encoding_settings.effective_chroma_subsampling(), C::Yuv422P10); + assert_eq!(job.encoder_input_pix_fmt(), "yuv422p10le", "{cs:?} {src}"); + + // What the pin warning reports, derived from the same facts. + let chosen = cs.ffmpeg_pix_fmt().unwrap_or(src); + let (chosen_chroma, _) = chroma_and_depth(chosen); + let chroma_lost = chosen_chroma == ChromaClass::C444; + let ignored = !matches!(cs, C::Original | C::Yuv422P10); + let expected: (bool, bool) = match (cs, src) { + (C::Original, "yuv444p10le") => (true, false), + (C::Original, _) => (false, false), + (C::Yuv422P10, _) => (false, false), + (C::Yuv444P10, _) => (true, true), + (C::Yuv420 | C::Yuv420P10 | C::Yuv422, _) => (false, true), + }; + assert_eq!((chroma_lost, ignored), expected, "{cs:?} from {src}"); + } + } + // Any other codec keeps the selection. + let mut s = EncodingSettings::default(); + s.chroma_subsampling = C::Yuv420; + assert_eq!(s.effective_chroma_subsampling(), C::Yuv420); + } + /// — the profile is the whole input to the decision. #[test] fn prores_profile_decides_the_chroma() { @@ -1541,7 +1671,7 @@ mod tests { ); assert_eq!( VideoCodec::ALL.len(), - 19, + 20, "a VideoCodec variant was added without listing it in ALL" ); } diff --git a/worker/src/pipeline_executor.rs b/worker/src/pipeline_executor.rs index 96971246..e2b7a83a 100644 --- a/worker/src/pipeline_executor.rs +++ b/worker/src/pipeline_executor.rs @@ -196,6 +196,58 @@ use crate::progress_reporter::ProgressReporter; use crate::script_generator::{PreviewParams, ScriptGenerator}; use crate::source_timing::SourceTimeline; +/// The encoder arguments that declare a still-interlaced picture's field order. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct FieldOrderArgs { + /// Before `-i -` on the Y4M input: `-top 1` (TFF) or `-top 0` (BFF). + pub input: Vec, + /// After the codec's quality args: `-flags +ildct` for ProRes. + pub encoder: Vec, +} + +/// The one decision about how the encode declares field order, used by every +/// builder of the encoder's arguments. +/// +/// vspipe writes `Ip` (progressive) in every Y4M header, whatever +/// `_FieldBased` says, so without this an interlaced ProRes or v210 MOV is +/// written with a `fiel` atom of `0x0100`, i.e. flagged progressive. Measured +/// against FFmpeg 9.0 (docs/reports/2026-11-v210-field-order): +/// +/// - `-field_order tt` as an output option is inert: the encoder's field +/// order is overwritten from the frames' flags. +/// - `-top 1|0` on the Y4M input flags the frames, which gives `fiel 0x0209` +/// (TFF) / `0x020e` (BFF) in MOV and `tb` / `bt` in MKV. AVI has no field +/// order to write. `setfield` in `-vf` works too, but a Custom FFmpeg +/// Argument `-vf` replaces the chain and would drop it. +/// - ProRes also carries a field flag in its own frame header, which stays +/// progressive unless the frame is coded as fields (`-flags +ildct`). +pub fn field_order_args(job: &VideoJob) -> FieldOrderArgs { + let codec = job.encoding_settings.codec; + if !codec.flags_field_order() { + return FieldOrderArgs::default(); + } + let pipeline = job.effective_pipeline(); + let Some(tff) = ScriptGenerator::output_field_order(job, &pipeline) else { + return FieldOrderArgs::default(); + }; + FieldOrderArgs { + input: vec!["-top".to_string(), if tff { "1" } else { "0" }.to_string()], + encoder: if codec.is_prores() { + vec!["-flags".to_string(), "+ildct".to_string()] + } else { + Vec::new() + }, + } +} + +/// Whether an MP4/MOV output gets `-movflags +faststart`. Not for v210: +/// faststart rewrites the whole file in a second pass to move the index to +/// the front, which at ~1 Gbit/s (an hour of 1080p is ~500 GB) doubles the +/// disk I/O and needs the space twice, for a streaming feature no editor uses. +pub fn uses_faststart(codec: VideoCodec) -> bool { + !codec.is_uncompressed() +} + /// Executes the ffmpeg (decode) | vspipe | ffmpeg (encode) pipeline. pub struct PipelineExecutor { reporter: ProgressReporter, @@ -886,6 +938,11 @@ impl PipelineExecutor { // Declaring the same tags here leaves it nothing to convert. Must match // the output flags exactly — see ColorMetadata. args.extend(job.output_color_metadata().to_ffmpeg_input_args()); + // Field order of a still-interlaced picture, for the codecs that flag + // it: the Y4M header always says progressive, so it is declared here, + // on the input, where a Custom FFmpeg Argument `-vf` cannot drop it. + let field_order = field_order_args(job); + args.extend(field_order.input.iter().cloned()); args.extend(["-i".to_string(), "-".to_string()]); // Input 1: Original file for audio stream @@ -955,6 +1012,8 @@ impl PipelineExecutor { // Encoder-family-specific quality and preset args Self::build_encoder_quality_args(&mut args, job); + // ProRes codes a still-interlaced picture as fields (`+ildct`). + args.extend(field_order.encoder.iter().cloned()); // HDR10 mastering display / content light level (issue #133). The pipe // drops them with the rest of the side data; x265 is the one encoder @@ -986,7 +1045,9 @@ impl PipelineExecutor { if settings.codec.is_h265() { args.extend(["-tag:v".to_string(), "hvc1".to_string()]); } - args.extend(["-movflags".to_string(), "+faststart".to_string()]); + if uses_faststart(settings.codec) { + args.extend(["-movflags".to_string(), "+faststart".to_string()]); + } } // Declare the output's aspect ratio. The Y4M pipe from vspipe strips it, @@ -1130,7 +1191,7 @@ impl PipelineExecutor { let (from_chroma, from_depth) = pixel_format::chroma_and_depth(from); let (to_chroma, to_depth) = pixel_format::chroma_and_depth(to); - if codec.is_prores() { + if codec.is_prores() || codec.is_uncompressed() { // Nothing is lost when both chroma and depth are kept or widened, // which is the common case for a ProRes job: say nothing rather // than report a conversion as if it were a cost. @@ -1255,7 +1316,7 @@ impl PipelineExecutor { args.extend(["-qp_b".to_string(), settings.quality.to_string()]); args.extend(["-quality".to_string(), preset.clone()]); } - EncoderFamily::Lossless | EncoderFamily::ProRes => { + EncoderFamily::Lossless | EncoderFamily::ProRes | EncoderFamily::Uncompressed => { // No quality/preset args needed } } @@ -1911,6 +1972,8 @@ mod tests { // colour tags declared on it (mirrors build_ffmpeg_args). args.extend(["-f".to_string(), "yuv4mpegpipe".to_string()]); args.extend(job.output_color_metadata().to_ffmpeg_input_args()); + let field_order = field_order_args(job); + args.extend(field_order.input.iter().cloned()); args.extend(["-i".to_string(), "-".to_string()]); // Input 1: Original file for audio stream @@ -1928,6 +1991,7 @@ mod tests { // Encoder-family-specific quality and preset args PipelineExecutor::build_encoder_quality_args(&mut args, job); + args.extend(field_order.encoder.iter().cloned()); // HDR10 static metadata (mirrors build_ffmpeg_args); the probe needs a // real file, so tests set `TEST_HDR` through `with_hdr` instead. @@ -1947,7 +2011,9 @@ mod tests { if settings.codec.is_h265() { args.extend(["-tag:v".to_string(), "hvc1".to_string()]); } - args.extend(["-movflags".to_string(), "+faststart".to_string()]); + if uses_faststart(settings.codec) { + args.extend(["-movflags".to_string(), "+faststart".to_string()]); + } } // Colour tags. This helper duplicates build_ffmpeg_args rather than @@ -2788,6 +2854,146 @@ mod tests { assert!(!args.contains(&"-crf".to_string()), "ffvhuff should not have -crf"); } + /// v210: the codec, its one pixel format, and nothing a quality family + /// would add — the data rate is fixed by the frame size. + #[test] + fn test_ffmpeg_args_v210() { + for container in [ContainerFormat::Mov, ContainerFormat::Mkv, ContainerFormat::Avi] { + let mut job = create_test_job(&format!("output.{}", container.extension())); + job.encoding_settings.codec = VideoCodec::V210; + job.encoding_settings.container = container; + job.input_pixel_format = Some("yuv420p".to_string()); + let args = build_ffmpeg_args_for_test(&job); + + let at = |flag: &str| args.iter().position(|a| a == flag); + assert_eq!(args[at("-c:v").unwrap() + 1], "v210"); + assert_eq!(args[at("-pix_fmt").unwrap() + 1], "yuv422p10le"); + for absent in ["-crf", "-preset", "-profile:v", "-q:v", "-b:v", "-cq", "-qp_i", "-global_quality"] { + assert!(at(absent).is_none(), "{container:?}: v210 must not get {absent}: {args:?}"); + } + assert!( + !args.iter().any(|a| a.contains("faststart")), + "{container:?}: no faststart for v210 (a second full pass over a huge file)" + ); + } + } + + /// Faststart stays for every other codec in MP4/MOV. + #[test] + fn test_faststart_only_skipped_for_uncompressed() { + for codec in VideoCodec::ALL.iter().copied() { + assert_eq!(uses_faststart(codec), !codec.is_uncompressed(), "{codec:?}"); + } + let mut job = create_test_job("output.mov"); + job.encoding_settings.codec = VideoCodec::ProResHQ; + job.encoding_settings.container = ContainerFormat::Mov; + assert!(build_ffmpeg_args_for_test(&job).contains(&"+faststart".to_string())); + } + + /// The pipe already delivers 10-bit 4:2:2 for v210 (the script converts, + /// field-aware), so the job log says nothing about a conversion. + #[test] + fn test_v210_pix_fmt_note() { + assert_eq!( + PipelineExecutor::pix_fmt_change_note(VideoCodec::V210, "yuv422p10le", "yuv422p10le"), + None + ); + assert_eq!( + PipelineExecutor::pix_fmt_change_note(VideoCodec::V210, "yuv420p", "yuv422p10le"), + None, + "padding up loses nothing" + ); + let note = PipelineExecutor::pix_fmt_change_note(VideoCodec::V210, "yuv444p10le", "yuv422p10le") + .expect("4:4:4 down to 4:2:2 is said"); + assert!(note.contains("stores"), "{note}"); + assert!(!note.contains("cannot encode"), "{note}"); + } + + fn field_order_job(codec: VideoCodec, container: ContainerFormat) -> VideoJob { + let mut job = create_test_job(&format!("output.{}", container.extension())); + job.encoding_settings.codec = codec; + job.encoding_settings.container = container; + let mut pipeline = crate::models::ProcessingPipeline::default(); + pipeline.deinterlace.enabled = false; + job.processing_pipeline = Some(pipeline); + job + } + + /// Field order reaches the encoder only for the intermediates, only when + /// the picture is still interlaced, on the pipe input (`-top`, ahead of + /// `-i -`), and ProRes alone codes fields (`+ildct`). + #[test] + fn test_field_order_args_matrix() { + use crate::models::FieldOrder; + let codecs = [ + (VideoCodec::V210, ContainerFormat::Mov), + (VideoCodec::V210, ContainerFormat::Mkv), + (VideoCodec::ProResHQ, ContainerFormat::Mov), + (VideoCodec::FFV1, ContainerFormat::Mkv), + (VideoCodec::H264, ContainerFormat::Mp4), + ]; + // (deinterlace on, detected order, stabilize pitch) -> expected top + let cases: [(bool, Option, bool, Option<&str>); 6] = [ + (true, Some(FieldOrder::TopFieldFirst), false, None), + (false, Some(FieldOrder::TopFieldFirst), false, Some("1")), + (false, Some(FieldOrder::BottomFieldFirst), false, Some("0")), + (false, Some(FieldOrder::Progressive), false, None), + (false, None, false, None), + (false, Some(FieldOrder::TopFieldFirst), true, None), + ]; + for (codec, container) in codecs { + for (deint, order, pitch, expected) in cases { + let mut job = field_order_job(codec, container); + job.detected_field_order = order; + let p = job.processing_pipeline.as_mut().unwrap(); + p.deinterlace.enabled = deint; + if pitch { + p.stabilize.enabled = true; + p.stabilize.pitch_mode = crate::models::PitchMode::Auto; + } + let args = build_ffmpeg_args_for_test(&job); + let ctx = format!("{codec:?} {container:?} deint={deint} {order:?} pitch={pitch}: {args:?}"); + let expected = if codec.flags_field_order() { expected } else { None }; + match expected { + Some(v) => { + let top = args.iter().position(|a| a == "-top").expect(&ctx); + let pipe = args.windows(2).position(|w| w[0] == "-i" && w[1] == "-").unwrap(); + assert_eq!(args[top + 1], v, "{ctx}"); + assert!(top < pipe, "-top must be an input option of the pipe: {ctx}"); + assert_eq!( + args.iter().any(|a| a == "+ildct"), + codec.is_prores(), + "{ctx}" + ); + } + None => { + assert!(!args.contains(&"-top".to_string()), "{ctx}"); + assert!(!args.contains(&"+ildct".to_string()), "{ctx}"); + } + } + // Never the inert output option. + assert!(!args.contains(&"-field_order".to_string()), "{ctx}"); + } + } + } + + /// The real builder takes field order from the same helper, on the pipe. + #[test] + fn test_real_ffmpeg_builder_declares_field_order_on_the_pipe() { + let src = include_str!("pipeline_executor.rs").replace("\r\n", "\n"); + let start = src.find("fn build_ffmpeg_args(\n").expect("real builder"); + let body = &src[start..]; + let body = &body[..body.find("\n }\n").expect("end of builder")]; + let helper = body.find("field_order_args(job)").expect("field order helper"); + let input = body.find("field_order.input").expect("input args"); + let pipe = body + .find(r#"args.extend(["-i".to_string(), "-".to_string()]);"#) + .expect("pipe input"); + assert!(helper < input && input < pipe, "-top belongs before -i -"); + assert!(body.contains("field_order.encoder"), "ProRes +ildct"); + assert!(body.contains("uses_faststart(settings.codec)")); + } + #[test] fn test_ffmpeg_args_nvenc_h264_quality() { let mut job = create_test_job("output.mp4"); diff --git a/worker/src/script_generator.rs b/worker/src/script_generator.rs index 703423b4..785b3181 100644 --- a/worker/src/script_generator.rs +++ b/worker/src/script_generator.rs @@ -492,6 +492,37 @@ impl ScriptGenerator { Self::field_based_for(job, pipeline).map(|fb| fb == 2) } + /// The field order the encoded picture actually has: `Some(true)` for top + /// field first, `Some(false)` for bottom, `None` for progressive. + /// + /// The Y4M pipe cannot say (vspipe writes `Ip` whatever `_FieldBased` is), + /// so the encoder is told on its input (`-top`) for codecs that flag it + /// (`VideoCodec::flags_field_order`). Starts from [`still_interlaced_tff`] + /// and drops to progressive wherever a pass after Deinterlace makes the + /// frames progressive. Every `SetFieldBased(…, 0)` in the templates is + /// accounted for by `template_field_based_sites_are_all_accounted_for`, so + /// a new pass that does this fails until it is taught here. + /// + /// [`still_interlaced_tff`]: Self::still_interlaced_tff + pub fn output_field_order(job: &VideoJob, pipeline: &ProcessingPipeline) -> Option { + let tff = Self::still_interlaced_tff(job, pipeline)?; + // Stabilize's pitch correction resamples vertically and clears + // _FieldBased first ({{#STAB_CLEAR_FIELD_BASED}}). + if pipeline.stabilize.pitch_active() { + return None; + } + // mpeg2stinx is a cross-field filter for film DVDs encoded as + // interlaced MPEG-2, and marks its output progressive. + if pipeline.deblock.enabled && pipeline.deblock.method == DeblockMethod::Mpeg2Stinx { + return None; + } + // A retimed woven frame is an interpolated picture, not a field pair. + if pipeline.frame_rate.enabled { + return None; + } + Some(tff) + } + /// Validate and normalise the job's pitch factor file when the Stabilize /// pass needs it, writing a clean one-factor-per-line copy to the temp /// directory for the script to read. @@ -2650,7 +2681,7 @@ impl ScriptGenerator { // vapoursynth_format), so adding an output format is one line there // rather than another arm here. `None` means "keep the source format", // bit depth included. - match job.encoding_settings.chroma_subsampling.vapoursynth_format() { + match job.encoding_settings.effective_chroma_subsampling().vapoursynth_format() { None => { script = remove_block("{{#CHROMA_CONVERT}}", "{{/CHROMA_CONVERT}}", script); } diff --git a/worker/tests/filter_integration_test.rs b/worker/tests/filter_integration_test.rs index ee021492..57fae9f5 100644 --- a/worker/tests/filter_integration_test.rs +++ b/worker/tests/filter_integration_test.rs @@ -8196,3 +8196,139 @@ fn test_218_stabilize_pitch_without_deinterlace_drops_field_marking() { assert!(!encode.contains("core.std.SetFieldBased(clip, 0)")); assert!(!preview.contains("core.std.SetFieldBased(clip, 0)")); } + +// ============================================================================ +// v210: uncompressed 10-bit 4:2:2 (task 7, forum thread 419911) +// ============================================================================ + +#[test] +fn test_219_v210_forces_422p10_in_both_scripts() { + // v210 stores 10-bit 4:2:2 and nothing else. The script converts to it + // whatever Color Format is selected, so the conversion is field-aware + // (zimg honours _FieldBased); ffmpeg's -pix_fmt would blend the two + // fields' chroma of an interlaced 4:2:0 source. + create_output_dir(); + for cs in ChromaSubsampling::ALL.iter().copied() { + let mut job = create_base_job("test_219_v210"); + job.encoding_settings.codec = VideoCodec::V210; + job.encoding_settings.container = ContainerFormat::Mov; + job.encoding_settings.chroma_subsampling = cs; + job.input_pixel_format = Some("yuv420p".to_string()); + let mut pipeline = ProcessingPipeline::default(); + pipeline.deinterlace.enabled = false; + job.processing_pipeline = Some(pipeline); + let (encode, preview) = generate_both_scripts(&job); + for (label, script) in [("encode", &encode), ("preview", &preview)] { + assert!( + script.contains("target_format = vs.YUV422P10"), + "{cs:?} {label}: v210 must convert to YUV422P10 in the script" + ); + // Still interlaced: the conversion happens after the clip is + // marked field-based, so zimg resamples chroma per field. + let mark = script.find("core.std.SetFieldBased(clip, 2)").expect("marked TFF"); + let conv = script.find("target_format = vs.YUV422P10").unwrap(); + assert!(mark < conv, "{cs:?} {label}: conversion must follow the field marking"); + } + } + + // Any other codec with Original keeps the source format: no block. + let mut job = create_base_job("test_219_ffv1"); + job.encoding_settings.chroma_subsampling = ChromaSubsampling::Original; + let (encode, preview) = generate_both_scripts(&job); + assert!(!encode.contains("target_format = ")); + assert!(!preview.contains("target_format = ")); +} + +#[test] +fn test_220_output_field_order() { + // What the encode declares as the picture's field order (`-top`), for the + // codecs that flag it. Still interlaced unless a pass makes it progressive. + fn job_with(detected: Option, edit: impl FnOnce(&mut ProcessingPipeline)) -> (VideoJob, ProcessingPipeline) { + let mut job = create_base_job("test_220"); + job.detected_field_order = detected; + let mut p = ProcessingPipeline::default(); + p.deinterlace.enabled = false; + edit(&mut p); + job.processing_pipeline = Some(p.clone()); + (job, p) + } + let order = |detected, edit: &dyn Fn(&mut ProcessingPipeline)| { + let (job, p) = job_with(detected, |p| edit(p)); + ScriptGenerator::output_field_order(&job, &p) + }; + let tff = Some(FieldOrder::TopFieldFirst); + let bff = Some(FieldOrder::BottomFieldFirst); + assert_eq!(order(tff, &|_| {}), Some(true)); + assert_eq!(order(bff, &|_| {}), Some(false)); + assert_eq!(order(Some(FieldOrder::Progressive), &|_| {}), None); + assert_eq!(order(None, &|_| {}), None); + assert_eq!(order(tff, &|p| p.deinterlace.enabled = true), None, "deinterlaced"); + assert_eq!( + order(tff, &|p| { + p.stabilize.enabled = true; + p.stabilize.pitch_mode = PitchMode::Auto; + }), + None, + "pitch correction clears _FieldBased" + ); + assert_eq!( + order(tff, &|p| { + p.stabilize.enabled = true; // weave only keeps the marking + }), + Some(true) + ); + assert_eq!( + order(tff, &|p| { + p.deblock.enabled = true; + p.deblock.method = DeblockMethod::Mpeg2Stinx; + }), + None, + "mpeg2stinx marks its output progressive" + ); + assert_eq!( + order(tff, &|p| { + p.deblock.enabled = true; // per-field deblock keeps it interlaced + }), + Some(true) + ); + assert_eq!(order(tff, &|p| p.frame_rate.enabled = true), None, "retimed"); +} + +#[test] +fn test_221_template_field_based_sites_are_all_accounted_for() { + // `ScriptGenerator::output_field_order` must know every place a template + // makes a still-interlaced clip progressive, or an interlaced flag is + // written over progressive frames. Each `SetFieldBased(` site is listed + // here with the reason it is (or is not) a progressive-making step. A new + // one fails this test until it is added — and, if it clears the marking + // on a still-interlaced clip, taught to `output_field_order`. + let expected: &[(&str, &str)] = &[ + ("core.std.SetFieldBased(clip, {{FIELD_BASED}})", "the source marking itself"), + ("core.std.SetFieldBased(_fade_f, 0), tff=True)", "IVTC fade fix, internal; IVTC is deinterlacing"), + ("clip = core.std.SetFieldBased(clip, 0)\n", "IVTC fade fix (deinterlacing), Deblock mpeg2stinx, Stabilize pitch"), + ("core.std.SetFieldBased(c, 0), tff=_stx_tff)", "mpeg2stinx internal"), + ("core.std.SetFieldBased(c, 2 if _stx_tff else 1)", "mpeg2stinx internal"), + ("core.std.SetFieldBased(core.std.Merge(_stx_1, _stx_2), 0)", "mpeg2stinx output: progressive"), + ("core.std.SetFieldBased(clip, {{AA_FIELD_BASED_VALUE}})", "SangNom restores the source order"), + ]; + // How many of each: the bare `clip, 0` line appears three times (fade fix, + // mpeg2stinx, Stabilize pitch), AA twice (two SangNom blocks). + let counts = [1usize, 1, 3, 1, 1, 1, 2]; + for name in ["pipeline_template.vpy", "preview_template.vpy"] { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("templates").join(name); + let src = std::fs::read_to_string(&path).unwrap().replace("\r\n", "\n"); + let total = src.matches("SetFieldBased(").count(); + let mut accounted = 0; + for ((pattern, why), n) in expected.iter().zip(counts) { + let found = src.matches(pattern).count(); + assert_eq!(found, n, "{name}: `{pattern}` ({why}) expected {n}, found {found}"); + accounted += found; + } + assert_eq!( + total, accounted, + "{name}: a SetFieldBased call this test does not know about. If it \ + makes a still-interlaced clip progressive, teach \ + ScriptGenerator::output_field_order, then list it here." + ); + } +} From 0d6f7a6850d93dfd7b8a55c7b1361e7476a6a351 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Tue, 10 Nov 2026 23:42:04 +1100 Subject: [PATCH 2/2] fix(review): restore the ProRes chroma test's doc comment; name the real v210 warning test Co-Authored-By: Claude Opus 5.5 --- app/lib/utils/pixel_format.dart | 2 +- worker/src/models/video_job.rs | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/lib/utils/pixel_format.dart b/app/lib/utils/pixel_format.dart index 9194e9d0..5923e6ec 100644 --- a/app/lib/utils/pixel_format.dart +++ b/app/lib/utils/pixel_format.dart @@ -316,7 +316,7 @@ String? proresChromaPinWarning({ /// /// **A second implementation of the worker's decision.** Pinned case for case /// to `v210_stores_422_10_whatever_is_selected` in -/// `worker/src/models/video_job.rs` by `app/test/v210_chroma_pin_warning_test.dart`. +/// `worker/src/models/video_job.rs` by `app/test/v210_test.dart`. /// Change one, change both. String? v210ChromaPinWarning({ required VideoCodec codec, diff --git a/worker/src/models/video_job.rs b/worker/src/models/video_job.rs index 5d6e0043..152ed4f0 100644 --- a/worker/src/models/video_job.rs +++ b/worker/src/models/video_job.rs @@ -1491,13 +1491,6 @@ mod tests { } } - /// ProRes stores a chroma layout fixed by its profile, and ffmpeg's format - /// negotiation does not know that — measured against the bundled build, - /// `prores_ks -profile:v 4` auto-selects `yuv422p10le` from a yuv420p input - /// exactly as `-profile:v 2` does. Unpinned, ProRes 4444 therefore writes a - /// file stamped 4444 carrying 4:2:2. - /// - /// Like the HuffYUV and AMF pins, this holds whatever the pipeline produced /// v210 is the uncompressed family, alone: no quality, no preset, MOV by /// default, never hardware, and its serde name is the wire format. #[test] @@ -1522,7 +1515,7 @@ mod tests { } /// The table `v210ChromaPinWarning` (app/lib/utils/pixel_format.dart) is - /// pinned to, in `app/test/v210_chroma_pin_warning_test.dart`. Change one, + /// pinned to, in `app/test/v210_test.dart`. Change one, /// change both. /// /// Whatever Color Format is selected, the script converts to 10-bit 4:2:2 @@ -1569,6 +1562,13 @@ mod tests { assert_eq!(s.effective_chroma_subsampling(), C::Yuv420); } + /// ProRes stores a chroma layout fixed by its profile, and ffmpeg's format + /// negotiation does not know that — measured against the bundled build, + /// `prores_ks -profile:v 4` auto-selects `yuv422p10le` from a yuv420p input + /// exactly as `-profile:v 2` does. Unpinned, ProRes 4444 therefore writes a + /// file stamped 4444 carrying 4:2:2. + /// + /// Like the HuffYUV and AMF pins, this holds whatever the pipeline produced /// — the profile is the whole input to the decision. #[test] fn prores_profile_decides_the_chroma() {