Skip to content
96 changes: 96 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1894,8 +1894,26 @@ does, and it decides bit depth as well as chroma:
|---|---|---|
| `original` (default) | source format, depth included | archival; a 10-bit source stays 10-bit |
| `yuv420` | `vs.YUV420P8` | compatibility — the only one every player opens |
| `yuv420p10` | `vs.YUV420P10` | the only 10-bit layout NVENC/QSV/AMF encode (issue #74) |
| `yuv422` | `vs.YUV422P8` | more chroma detail, 8-bit |
| `yuv422p10` | `vs.YUV422P10` | normalize chroma without dropping to 8-bit |
| `yuv444p10` | `vs.YUV444P10` | full chroma — ProRes 4444, x264/x265; no GPU encoder |

> **This table had gone stale, and so had three tests driven from the same
> hand-written lists** — `yuv420p10` was missing from all of them, so that
> option's script substitution, its serde name and its declared depth were
> checked nowhere. A skipped row looks exactly like a passing one. Both sides
> now sweep the enum: `ChromaSubsampling::ALL` (kept complete by a
> catch-all-free match plus a count assertion) and `ChromaSubsampling.values`.
> Add a format to the enum, not to a list.

> **Verify the Y4M pipe can name a new format before wiring anything.** vspipe
> writes the header, so the question is what *it* emits — measured for 4:4:4,
> `C444p10`, which ffmpeg's demuxer accepts. Note ffmpeg's own Y4M **muxer**
> calls `yuv444p10le` "not an official yuv4mpegpipe pixel format" and says the
> same of the long-shipping `yuv422p10le`, which is the proof that the muxer's
> opinion is irrelevant here. A format the pipe cannot name is a hard job
> failure with no error — the `Turn90` 4:4:0 trap.

Three things to keep in mind:

Expand Down Expand Up @@ -2072,6 +2090,84 @@ having the last word. A deliberately distinct icon (`schema_outlined`, not a
second `info_outline`), asserted, because two identical adjacent buttons read
as one control repeated.

### ProRes: the profile decides the chroma, not ffmpeg (issue #81)

Reported as "please add ProRes", when Proxy/LT/422/HQ had shipped for months.
Most of what was wrong was that the app said otherwise, so this is mostly a
correctness change with two profiles added on the end.

> **ffmpeg's pixel-format negotiation never looks at `-profile:v`.** Measured
> against the bundled build: `prores_ks -profile:v 4` and `-profile:v 5`
> auto-select `yuv422p10le` from a `yuv420p` input, exactly as `-profile:v 2`
> does. So ProRes 4444 shipped without a pin writes a file **stamped 4444
> carrying 4:2:2** — valid, playable, and the profile's entire point discarded
> with no error anywhere. `VideoCodec::forced_pix_fmt` now decides from the
> profile (4/5 → `yuv444p10le`, 0-3 → `yuv422p10le`), joining the HuffYUV and
> AMF pins. Issue #74's lesson generalises: an encoder's declared format list
> is not a statement about what the output should be.
>
> Pinning 0-3 is a measured no-op, not an assumed one — `pal-sd-25.mov` at all
> four profiles gives identical `framemd5` with and without the flag, because
> `yuv422p10le` is the only 4:2:2 format the encoder has.
>
> **The decoder reports ProRes 4444 as 12-bit** (`yuv444p12le`) whatever 10-bit
> format the encoder was handed, so assert on the chroma part of the name. And
> `prores_ks` offers no 12-bit pixel format at all: **4444 XQ is 10-bit here**,
> whatever Apple's spec says. Don't write "12-bit" in any UI copy.

> **ProRes was showing a CRF slider wired to nothing.** It is not `isLossless`,
> so the dialog rendered one labelled "High (CRF 18)" while
> `build_encoder_quality_args` took the `prores_profile()` branch and never read
> `settings.quality`. The decision is now `VideoCodec.hasQualityControl`, a
> getter rather than a widget condition, so it can be asserted across
> `VideoCodec.values` — a hand-written list only covers the codecs someone
> thought of, which is never the broken one.

> **`prores_profile()` and `encoder_family()` lost their catch-all arms.** They
> dispatch two halves of one decision — the quality-args branch uses the first,
> its fallthrough the second — so a ProRes variant reaching the family but not
> the profile table would emit no `-profile:v` and encode as profile 2 while
> claiming otherwise. Adding the two new variants then produced four compile
> errors naming exactly the sites that mattered, which is the point.

> **`proresCodecs` in `settings_dialog.dart` was the only place the ProRes UI
> group was enumerated.** A profile missing from it exists in the model and is
> unreachable on screen, silently. Derived from `isProRes` now.

The three advanced options (`-vendor apl0`, `-bits_per_mb`, `-quant_mat`) are
behind advanced mode, ProRes-only, default off. Their copy carries measurements
rather than the linked guide's framing, because **at profile 3 the guide's four
flags produce bit-identical frames** — `quant_mat auto` already resolves to the
HQ matrix and the bitrate is already under the cap. They only do anything on
Proxy/LT (+3.6 dB for 2.8% size, +5.5 dB for 19%), and `-vendor apl0` is a
compatibility flag: four bytes per frame header, identical pixels.

`bits_per_mb` is clamped to 8192 **in the worker**, not just the UI — ffmpeg
rejects anything above it and the encode dies having written nothing, so a saved
preset can otherwise fail a job on an option nobody can see. `quant_mat` is an
enum on both sides for the same reason ("Undefined constant" kills the encode).

> **`copyWith` needed explicit clear flags, and `parameter_copy_with_test` could
> not have caught it.** That test globs `lib/models/*_parameters.dart`, so it had
> never seen `encoding_settings.dart` — now added, and confirmed live by dropping
> a field and watching it fail by name. The blast radius here is worse than in a
> pass model: **every** edit in the settings dialog goes through
> `updateEncodingSettings(settings.copyWith(...))`, so a forgotten field resets on
> the user's next click, not on a pass toggle. `x ?? this.x` can only set a
> nullable field, never clear it, so an unticked override would stick forever —
> `videoBitrateKbps` still has that defect and works around it in
> `_buildCodecRadio`.

`proresChromaPinWarning` is a **sibling** of `hardwareEncoderChromaWarning`, not
an extension. The two make opposite claims — one says the hardware cannot encode
what you asked and something is lost; the other says the profile defines what is
stored, and 4444 pads 4:2:0 *up*, costing size rather than detail. The message
says "Nothing is lost" explicitly, asserted, because a warning that reads as a
quality problem pushes people off a profile doing exactly what they asked. Both
are second implementations of the worker's decision and both are pinned
case-for-case to it. Depth is deliberately not warned about alone: ProRes is
always 10-bit, so that would fire on most ProRes jobs and become wallpaper.

## QTGMC Parameters Reference

The most important parameters:
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,12 @@ GPU-accelerated deinterlacing (NNEDI3CL) needs your GPU's OpenCL driver installe

| | |
|---|---|
| **Video** | H.264, H.265, ProRes, FFV1 (lossless), with hardware encoding via VideoToolbox, NVENC, Quick Sync or AMF where available |
| **Video** | H.264, H.265, ProRes (Proxy, LT, 422, 422 HQ, 4444, 4444 XQ), FFV1 (lossless), with hardware encoding via VideoToolbox, NVENC, Quick Sync or AMF where available |
| **Audio** | Passthrough, or re-encode to AAC, Opus or FLAC, or strip |
| **Containers** | MKV, MP4, MOV, AVI |
| **Reads** | `.dv` · `.mts` `.m2ts` (AVCHD) · `.vob` `.m2v` `.mpg` `.mpeg` · `.mxf` · `.avi` `.mov` `.mp4` `.mkv` `.ts` `.wmv` `.webm` `.flv`, plus 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 out** | Match the source, or convert to 4:2:0 8-bit (plays everywhere), 4:2:2 8-bit, or 4:2:2 10-bit. Worth setting for a 10-bit source: matching it produces a 10-bit file that some players and browsers refuse to open |
| **Colour format out** | Match the source, or convert to 4:2:0 8-bit (plays everywhere), 4:2:0 10-bit (the only 10-bit layout GPU encoders take), 4:2:2 8-bit, 4:2:2 10-bit, or 4:4:4 10-bit (ProRes 4444 and the software encoders). Worth setting for a 10-bit source: matching it produces a 10-bit file that some players and browsers refuse to open |
| **Aspect ratio** | Non-square pixels preserved through the pipeline, including through a resize; or square up anamorphic pixels, force a display aspect, or letterbox to a target size |

## The filter pipeline
Expand Down
68 changes: 67 additions & 1 deletion app/lib/models/encoding_settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,27 @@ enum AudioQuality {
/// Mirrors `ChromaSubsampling` in `worker/src/models/video_job.rs` — the [value]
/// strings must match that enum's serde names.
@JsonEnum(valueField: 'value')
/// The quantisation matrices `prores_ks` accepts by name.
///
/// Mirrors `ProResQuantMat` in `worker/src/models/video_job.rs` — the `value`
/// strings here are the wire format. An enum rather than a free string because
/// ffmpeg rejects an unknown value outright and kills the encode, so a typo in
/// a saved preset would fail the whole job on an option nobody can see.
@JsonEnum(valueField: 'value')
enum ProResQuantMat {
/// Match the profile — what the encoder does with no option at all.
auto('auto', 'Auto (match profile)'),
proxy('proxy', 'Proxy'),
lt('lt', 'LT'),
standard('standard', 'Standard'),
hq('hq', 'HQ');

const ProResQuantMat(this.value, this.label);

final String value;
final String label;
}

enum ChromaSubsampling {
/// Keep original format (no conversion), bit depth included.
original('original', 'Match source', null, null),
Expand All @@ -74,7 +95,11 @@ enum ChromaSubsampling {
yuv422('yuv422', '4:2:2 8-bit', 'more colour detail', 8),
/// Convert to 10-bit YUV422: keeps a 10-bit source's precision while
/// normalizing chroma, and gives an 8-bit source headroom for gradients.
yuv422p10('yuv422p10', '4:2:2 10-bit', 'keeps 10-bit precision', 10);
yuv422p10('yuv422p10', '4:2:2 10-bit', 'keeps 10-bit precision', 10),
/// Convert to 10-bit YUV444: full chroma resolution, no subsampling at all.
/// What ProRes 4444 stores, and what the software x264/x265 encoders can
/// take. No GPU encoder in this app can (see [hardwareEncoderChromaWarning]).
yuv444p10('yuv444p10', '4:4:4 10-bit', 'full chroma, no GPU encoders', 10);

const ChromaSubsampling(
this.value, this.label, this.blurb, this.outputBitDepth);
Expand Down Expand Up @@ -121,6 +146,20 @@ class EncodingSettings {
/// Output chroma subsampling format.
final ChromaSubsampling chromaSubsampling;

/// Write `apl0` as the ProRes vendor tag instead of ffmpeg's `Lavc`.
/// A compatibility flag, not a quality one — some Avid and Apple tooling
/// reads this field, and the decoded frames are identical either way.
/// Ignored by every non-ProRes codec.
final bool proresVendorApl0;

/// ProRes `-bits_per_mb`: the ceiling the encoder may spend per macroblock.
/// Null leaves it to the profile. Only affects Proxy and LT in practice.
final int? proresBitsPerMb;

/// ProRes `-quant_mat`. Null leaves the encoder on `auto`, which picks the
/// matrix matching the profile.
final ProResQuantMat? proresQuantMat;

final String customFfmpegArgs;

/// User-supplied VapourSynth, injected after every built-in pass. Same
Expand All @@ -146,6 +185,9 @@ class EncodingSettings {
this.audioCodec = AudioCodec.aac,
this.audioQuality = AudioQuality.high,
this.chromaSubsampling = ChromaSubsampling.original,
this.proresVendorApl0 = false,
this.proresBitsPerMb,
this.proresQuantMat,
this.customFfmpegArgs = '',
this.customVapoursynth = '',
this.container = ContainerFormat.mkv,
Expand Down Expand Up @@ -203,6 +245,14 @@ class EncodingSettings {

/// Human-readable quality description.
String get qualityDescription {
// Codecs whose quality the worker never reads must not be described in
// 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 == VideoCodec.h264Videotoolbox || codec == VideoCodec.h265Videotoolbox) {
// VideoToolbox: CRF is remapped to q:v (inverted scale) in the worker.
// Show quality in user-friendly terms based on the CRF value.
Expand Down Expand Up @@ -230,6 +280,16 @@ class EncodingSettings {
AudioCodec? audioCodec,
AudioQuality? audioQuality,
ChromaSubsampling? chromaSubsampling,
bool? proresVendorApl0,
int? proresBitsPerMb,
// Nullable settings need an explicit clear, or unticking the override in
// the UI can never put them back to null and the value sticks forever,
// silently applied to every later ProRes encode. `outputDirectory` is the
// precedent; `videoBitrateKbps` is the counter-example that has to work
// around its own absence in _buildCodecRadio.
bool clearProresBitsPerMb = false,
ProResQuantMat? proresQuantMat,
bool clearProresQuantMat = false,
String? customFfmpegArgs,
String? customVapoursynth,
ContainerFormat? container,
Expand All @@ -254,6 +314,12 @@ class EncodingSettings {
audioCodec: audioCodec ?? this.audioCodec,
audioQuality: audioQuality ?? this.audioQuality,
chromaSubsampling: chromaSubsampling ?? this.chromaSubsampling,
proresVendorApl0: proresVendorApl0 ?? this.proresVendorApl0,
proresBitsPerMb: clearProresBitsPerMb
? null
: (proresBitsPerMb ?? this.proresBitsPerMb),
proresQuantMat:
clearProresQuantMat ? null : (proresQuantMat ?? this.proresQuantMat),
customFfmpegArgs: customFfmpegArgs ?? this.customFfmpegArgs,
customVapoursynth: customVapoursynth ?? this.customVapoursynth,
container: container ?? this.container,
Expand Down
25 changes: 23 additions & 2 deletions app/lib/models/video_job.dart
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,9 @@ enum VideoCodec {
proresProxy('prores_ks -profile:v 0', 'ProRes Proxy'),
proresLT('prores_ks -profile:v 1', 'ProRes LT'),
prores422('prores_ks -profile:v 2', 'ProRes 422'),
proresHQ('prores_ks -profile:v 3', 'ProRes 422 HQ');
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');

const VideoCodec(this.value, this.displayName);

Expand Down Expand Up @@ -247,7 +249,11 @@ enum VideoCodec {
case VideoCodec.prores422:
return 'Broadcast quality';
case VideoCodec.proresHQ:
return 'Highest ProRes quality';
return 'Highest 4:2:2 ProRes quality';
case VideoCodec.prores4444:
return 'Full 4:4:4 colour, for compositing';
case VideoCodec.prores4444Xq:
return 'Full 4:4:4 colour at the highest data rate';
}
}

Expand All @@ -258,6 +264,21 @@ enum VideoCodec {
bool get isLossless =>
this == VideoCodec.ffv1 || this == VideoCodec.huffyuv || this == VideoCodec.ffvhuff;

/// Whether a quality/bitrate control means anything for this codec.
///
/// The worker decides quality per encoder family in
/// `build_encoder_quality_args`, and two families read nothing from
/// `EncodingSettings.quality`: the lossless codecs (there is no quality to
/// set) and ProRes (the profile fixes it — the ProRes branch emits
/// `-profile:v N` and returns). Showing a CRF slider for either is a control
/// that responds and changes nothing, which is worse than showing none:
/// ProRes used to present one labelled "High (CRF 18)".
///
/// 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;

/// Whether this codec produces H.264 output (software or hardware).
bool get isH264 => this == h264 || this == h264Nvenc || this == h264Qsv ||
this == h264Videotoolbox || this == h264Amf;
Expand Down
Loading
Loading