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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |

Expand Down
14 changes: 11 additions & 3 deletions app/lib/models/encoding_settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,14 @@ class EncodingSettings {

Map<String, dynamic> 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;
Expand All @@ -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.
Expand Down
45 changes: 35 additions & 10 deletions app/lib/models/video_job.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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';
}
}

Expand All @@ -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 ||
Expand All @@ -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;
}
Expand All @@ -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;
}
}

Expand All @@ -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;
}

Expand All @@ -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 {
Expand Down
41 changes: 41 additions & 0 deletions app/lib/utils/pixel_format.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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_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';
}
Loading