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
17 changes: 16 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ VapourBox/
| `worker/src/pixel_format.rs` | Source `pix_fmt` → pipe format (see "Source Pixel Formats") |
| `worker/templates/stabilize.py` | Stabilize pass's vertical pitch correction (see "Stabilize") |
| `worker/src/cpu.rs` | The x86 deps CPU tier (`v3`/`v2`) — the single decision; see "CPU tiers" |
| `worker/src/source_decode.rs` | Decoder ffmpeg args for encode + preview: full stored frame, size-guarded (see "Source Frame Size") |
| `worker/src/source_decode.rs` | Decoder ffmpeg args for encode + preview: full stored frame, size-guarded, invalid colour tags cleared (see "Source Frame Size", "Colour metadata") |
| `worker/src/source_timing.rs` | A variable-frame-rate source's stored frames placed on the constant-rate grid (see "Variable Frame Rate Sources") |
| `worker/src/gpu_plugin.rs` | The ncnn-on-Vulkan plugins (RIFE, AI upscaling): file layout, RIFE's model add-on, the `--probe-rife` / `--probe-upscale` probe (on stand-in models, no add-on needed), and the user-facing reason/remedy when they can't run (see "GPU Plugins") |
| `worker/src/animejanai.rs` | AnimeJaNai's add-on models: where they live, which one a job runs, the refusal when they're missing (see "Add-ons") |
Expand Down Expand Up @@ -159,6 +159,7 @@ VapourBox/
| `app/lib/models/ai_runtime_status.dart` | The worker's `--probe-ai-runtime` result |
| `app/lib/models/face_restore_parameters.dart` | Face Restoration's parameters and the add-ons each model needs (`faceRestoreAddonIds`) |
| `app/lib/models/colorize_parameters.dart` | Colourisation's parameters and the add-on each model needs (`colorizeAddonSelectedBy`) |
| `app/lib/models/color_tags.dart` | FFmpeg's accepted colour-tag lists (twin of `color_metadata.rs`) and `decodeNeutraliser`, the filter that clears invalid tags before the app's decodes |
| `app/lib/models/hdr_display.dart` | The display tone-map for the app's own source decodes (twin of `ColorMetadata::display_tonemap_filter`) and Dolby Vision detection |
| `app/lib/models/gpu_plugin_status.dart` | The worker's GPU-plugin probe results, and the panels' notice decision |
| `app/assets/filters/core/*.json` | Built-in filter schema definitions |
Expand Down Expand Up @@ -920,6 +921,20 @@ The preview path needs the same source tags fed to swscale
(`swscale_input_opts()`) or it guesses the matrix and shows a colour shift the
real encode doesn't have.

**A tag FFmpeg rejects (ProRes's `reserved`) is cleared before any decode
converts, and only that tag.** FFmpeg 9's scaler fails a frame tagged
`reserved` outright, so every decode of the source that may convert its pixel
format puts `ColorMetadata::decode_tag_neutraliser` (worker, via
`source_decode::decoder_filter`: encode, preview and every detector) or its
byte-identical twin `ColorTags.decodeNeutraliser` (app: before frame and
thumbnails, via `PreviewGenerator.beforeFrameArgs`/`thumbnailArgs`) **first**
in its one `-vf`. Never clear a valid tag (a valid matrix moves yuv → gray16le
by up to 28 levels), which is why the job carries ffprobe's **raw** tags
(`colorTag` forwards `reserved`) and `from_raw` drops them only for the
encoder's flags. A new decode of the source must use the same helper.
`integration_reserved_colour_tags_test.dart` (heavy) checks each one against
an untagged twin; see docs/reports/2026-11-reserved-colour-tags.

**BT.601 → BT.709 on SD → HD (app 1.7.0) is the one place the script
converts colour.** Crop & Resize's `convertToBt709` (`Bt709Conversion`:
`auto` default, `on`, `off`) converts a BT.601 source (tagged
Expand Down
73 changes: 73 additions & 0 deletions Tests/TestResources/generate_reserved_colour_tags_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/bin/bash
# Generate the two ProRes clips tagged with `reserved` colour primaries and
# transfer (docs/reports/2026-11-reserved-colour-tags).
#
# FFmpeg 9's scaler refuses to convert a frame tagged `reserved`, so before the
# fix the app's "before" frame, its thumbnails, Detect borders and Descale's
# Detect all failed on such a source, and so did the encode whenever the
# decoder converts to the pipe format.
#
# The tags come from the prores_metadata bitstream filter: its `unknown`
# constant writes code 0, which is Apple's "unknown" and ISO 23091's
# `reserved` — FFmpeg's decoder reports the latter. That is exactly what the
# user's clip carries (color_primaries=reserved, color_transfer=reserved,
# color_space=unknown). Stream copy only, so the pictures are untouched.
#
# reserved_colour_tags.mov ProRes 422 yuv422p10le, 15 frames of
# prores422_10bit_telecine.mov; laid out like
# the user's clip: pcm_s24le audio as stream
# 0, video as stream 1, a 1/11988 timescale.
# reserved_colour_tags_4444a.mov ProRes 4444 with alpha (yuva444p12le when
# decoded), 320x240, 10 frames: not a pipe
# format, so the ENCODE decoder converts it.
#
# Usage: ./generate_reserved_colour_tags_test.sh [output_dir]
# FFMPEG / FFPROBE override the tools (default: ffmpeg / ffprobe on PATH).

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
OUTPUT_DIR="${1:-$SCRIPT_DIR}"
FFMPEG="${FFMPEG:-ffmpeg}"
FFPROBE="${FFPROBE:-ffprobe}"
SOURCE="$SCRIPT_DIR/prores422_10bit_telecine.mov"
OUT_422="$OUTPUT_DIR/reserved_colour_tags.mov"
OUT_4444="$OUTPUT_DIR/reserved_colour_tags_4444a.mov"
RESERVED="prores_metadata=color_primaries=unknown:color_trc=unknown"

if [ ! -f "$SOURCE" ]; then
echo "Missing $SOURCE — run ./generate_prores422_10bit_test.sh first." >&2
exit 1
fi

"$FFMPEG" -v error -y \
-i "$SOURCE" \
-f lavfi -i anullsrc=r=48000:cl=stereo \
-map 1:a -map 0:v \
-frames:v 15 -shortest \
-c:a pcm_s24le \
-c:v copy -bsf:v "$RESERVED" \
-video_track_timescale 11988 \
"$OUT_422"

TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
"$FFMPEG" -v error -y \
-f lavfi -i "testsrc2=s=320x240:r=30000/1001:d=1" \
-frames:v 10 \
-c:v prores_ks -profile:v 4 -vendor apl0 -pix_fmt yuva444p10le \
"$TMP/4444a.mov"
"$FFMPEG" -v error -y -i "$TMP/4444a.mov" -c:v copy -bsf:v "$RESERVED" "$OUT_4444"

# The fixtures are only worth anything if they reproduce the tags.
for f in "$OUT_422" "$OUT_4444"; do
tags="$("$FFPROBE" -v error -select_streams v:0 \
-show_entries stream=color_primaries,color_transfer -of csv=p=0 "$f")"
if [ "$tags" != "reserved,reserved" ]; then
echo "$f has tags '$tags', not 'reserved,reserved'" >&2
exit 1
fi
echo "Wrote $f ($(wc -c < "$f" | tr -d ' ') bytes): $("$FFPROBE" -v error \
-select_streams v:0 -show_entries stream=pix_fmt,color_primaries,color_transfer,color_space \
-of compact=p=0 "$f")"
done
Binary file added Tests/TestResources/reserved_colour_tags.mov
Binary file not shown.
Binary file not shown.
62 changes: 62 additions & 0 deletions app/lib/models/color_tags.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/// Which colour tags FFmpeg accepts, and the filter that clears the ones it
/// doesn't before the app's own decodes convert a frame.
///
/// The twin of `decode_tag_neutraliser` in
/// worker/src/models/color_metadata.rs: the lists are that file's `MATRICES`,
/// `PRIMARIES` and `TRANSFERS`, and the filter must be byte-identical for the
/// same tags. `color_tags_test.dart` reads the Rust source and checks both.
library;

class ColorTags {
ColorTags._();

/// `-colorspace` values (worker `MATRICES`).
static const matrices = [
'rgb', 'bt709', 'fcc', 'bt470bg', 'smpte170m', 'smpte240m', 'ycgco',
'bt2020nc', 'bt2020c', 'smpte2085', 'chroma-derived-nc',
'chroma-derived-c', 'ictcp',
];

/// `-color_primaries` values (worker `PRIMARIES`).
static const primaries = [
'bt709', 'bt470m', 'bt470bg', 'smpte170m', 'smpte240m', 'film', 'bt2020',
'smpte428', 'smpte431', 'smpte432', 'jedec-p22', 'ebu3213',
];

/// `-color_trc` values (worker `TRANSFERS`).
static const transfers = [
'bt709', 'gamma22', 'gamma28', 'smpte170m', 'smpte240m', 'linear',
'log100', 'log316', 'iec61966-2-4', 'bt1361e', 'iec61966-2-1',
'bt2020-10', 'bt2020-12', 'smpte2084', 'smpte428', 'arib-std-b67',
];

static bool _isInvalid(String? value, List<String> allowed,
[List<String> alsoValid = const []]) {
final v = value?.trim().toLowerCase();
if (v == null || v.isEmpty || v == 'unknown' || v == 'n/a') return false;
return !allowed.contains(v) && !alsoValid.contains(v);
}

/// The `setparams` that goes FIRST in the `-vf` of every app decode of the
/// source that converts its pixel format (the "before" frame, timeline
/// thumbnails), or null when every tag is usable. Takes ffprobe's RAW
/// values — `colorTag` forwards `reserved` for exactly this.
///
/// FFmpeg 9's scaler refuses a frame tagged `reserved` (ProRes writes code
/// 0 for "unknown", which FFmpeg reads as reserved), so without this the
/// decode fails and the Original side shows "No preview". Only an invalid
/// tag is cleared, each on its own: clearing a valid matrix changes pixels.
/// `gbr` (ffprobe's name for the RGB identity matrix) is left alone.
static String? decodeNeutraliser({
String? matrix,
String? primaries,
String? transfer,
}) {
final params = [
if (_isInvalid(primaries, ColorTags.primaries)) 'color_primaries=unknown',
if (_isInvalid(transfer, transfers)) 'color_trc=unknown',
if (_isInvalid(matrix, matrices, const ['gbr'])) 'colorspace=unknown',
];
return params.isEmpty ? null : 'setparams=${params.join(':')}';
}
}
7 changes: 7 additions & 0 deletions app/lib/services/field_order_detector.dart
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,13 @@ enum ScanType {
/// command line. Anything unrecognised is dropped again worker-side, in
/// `ColorMetadata::from_raw`, so a surprising value cannot reach ffmpeg.
/// `PreviewGenerator` reads the tags with this too.
///
/// A value FFmpeg rejects, such as ProRes's `reserved`, is **forwarded on
/// purpose**, not dropped here: FFmpeg's decoder reads the tag from the stream
/// whatever the job says, so every decode that converts the pixel format has
/// to know the tag is invalid to clear it first (`ColorTags.decodeNeutraliser`,
/// and the worker's `VideoJob::decode_tag_neutraliser` from the job's tags).
/// The encoder's tags still drop it, in `ColorMetadata::from_raw`.
String? colorTag(Map<String, dynamic> stream, String key) {
final value = (stream[key] as String?)?.trim();
if (value == null || value.isEmpty || value == 'unknown' || value == 'N/A') {
Expand Down
110 changes: 83 additions & 27 deletions app/lib/services/preview_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'dart:typed_data';
import 'package:path/path.dart' as path;
import 'package:uuid/uuid.dart';

import '../models/color_tags.dart';
import '../models/encoding_settings.dart';
import '../models/hdr_display.dart';
import '../models/preview_frame_info.dart';
Expand Down Expand Up @@ -94,6 +95,73 @@ class PreviewGenerator {
range: _colorRange,
);

/// Clears a colour tag FFmpeg's scaler refuses (`reserved`), or null when
/// every tag is usable. See [ColorTags.decodeNeutraliser].
String? get _tagFix => ColorTags.decodeNeutraliser(
matrix: _colorMatrix,
primaries: _colorPrimaries,
transfer: _colorTransfer,
);

/// The ffmpeg arguments for the "before" frame: source frame [frameIndex]
/// written to [outputPath] (JPEG in the app; the tests ask for PNG).
///
/// Seeks to the midpoint of the interval *before* the target frame
/// ((index - 0.5)/fps) so the first decoded frame is exactly [frameIndex],
/// as the worker's preview does. [tagFix] goes first in the one `-vf`: the
/// JPEG/PNG encode converts the pixel format, which fails on a `reserved`
/// tag. The integration tests run exactly these arguments.
static List<String> beforeFrameArgs({
required String videoPath,
required int frameIndex,
required double frameRate,
String? toneMap,
String? tagFix,
required String outputPath,
}) {
final frame = frameIndex < 0 ? 0 : frameIndex;
final seekTime = (frame - 0.5) / frameRate;
final ss = seekTime < 0 ? 0.0 : seekTime;
final vf = [if (tagFix != null) tagFix, if (toneMap != null) toneMap];
return [
'-y',
'-ss', ss.toStringAsFixed(6),
...sourceDecodeOptions,
'-i', videoPath,
'-frames:v', '1',
if (vf.isNotEmpty) ...['-vf', vf.join(',')],
'-q:v', '2',
outputPath,
];
}

/// The ffmpeg arguments for one timeline thumbnail at [time] seconds.
/// [tagFix] precedes `scale`, which is the step that converts.
static List<String> thumbnailArgs({
required String videoPath,
required double time,
String? toneMap,
String? tagFix,
required String outputPath,
}) {
return [
'-y',
'-ss', time.toStringAsFixed(3),
...sourceDecodeOptions,
'-i', videoPath,
'-vframes', '1',
// Scaled first: tone-mapping a 4K frame in float costs far more
// than the 160-pixel thumbnail it ends up as.
'-vf', [
if (tagFix != null) tagFix,
'scale=160:-1',
if (toneMap != null) toneMap,
].join(','),
'-q:v', '5',
outputPath,
];
}

/// Log messages from preview generation (stderr output).
final List<String> _previewLog = [];

Expand Down Expand Up @@ -229,22 +297,18 @@ class PreviewGenerator {

// Extract frame
final outputPath = '$_tempDir/frame_$frame.jpg';
final seekTime = ((frame - 0.5) / _frameRate);
final ss = seekTime < 0 ? 0.0 : seekTime;

try {
final result = await Process.run(
_ffmpegPath!,
[
'-y',
'-ss', ss.toStringAsFixed(6),
...sourceDecodeOptions,
'-i', _currentVideoPath!,
'-frames:v', '1',
if (_toneMap case final toneMap?) ...['-vf', toneMap],
'-q:v', '2',
outputPath,
],
beforeFrameArgs(
videoPath: _currentVideoPath!,
frameIndex: frame,
frameRate: _frameRate,
toneMap: _toneMap,
tagFix: _tagFix,
outputPath: outputPath,
),
);

if (result.exitCode == 0 && await File(outputPath).exists()) {
Expand Down Expand Up @@ -625,21 +689,13 @@ class PreviewGenerator {
try {
final result = await Process.run(
_ffmpegPath!,
[
'-y',
'-ss', time.toStringAsFixed(3),
...sourceDecodeOptions,
'-i', videoPath,
'-vframes', '1',
// Scaled first: tone-mapping a 4K frame in float costs far more
// than the 160-pixel thumbnail it ends up as.
'-vf', [
'scale=160:-1',
if (_toneMap case final toneMap?) toneMap,
].join(','),
'-q:v', '5',
outputPath,
],
thumbnailArgs(
videoPath: videoPath,
time: time,
toneMap: _toneMap,
tagFix: _tagFix,
outputPath: outputPath,
),
);

if (result.exitCode == 0 && await File(outputPath).exists()) {
Expand Down
8 changes: 8 additions & 0 deletions app/test/color_metadata_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ void main() {
expect(colorTag(const {}, 'color_space'), isNull);
});

test('forwards a value FFmpeg rejects, such as ProRes\'s reserved', () {
// Deliberate: the worker needs the raw tag to know the decoder must
// clear it (VideoJob::decode_tag_neutraliser). The encoder's tags drop
// it in ColorMetadata::from_raw.
expect(colorTag({'color_primaries': 'reserved'}, 'color_primaries'),
'reserved');
});

test('trims surrounding whitespace', () {
expect(colorTag({'color_space': ' bt709 '}, 'color_space'), 'bt709');
});
Expand Down
Loading
Loading