color: non-sRGB preview, per-monitor display ICC, pipeline hardening
Preview now follows the project output colorspace end to end: the display chain derives its content space from the project's OutputColorSpec instead of a hardcoded sRGB name, self-managed ICC transforms go through an XYZ D65 interchange stage (OCIO cie_xyz_d65_interchange) for non-sRGB targets, and the platform layer declares the content colorspace (gpui submodule bump). macOS defaults to OS-managed (fixes wide-gamut UI oversaturation); Windows ACM warns once on non-sRGB targets. Multi-monitor: the display ICC is looked up per the window's current screen (macOS display id, Windows per-monitor DC, X11 RandR output profile) with a throttled poll that invalidates frame caches on moves. Pipeline precision: 10-bit+ sources fall back to YUV444P16LE + a Rust matrix conversion when swscale lacks F32 output (no more 8-bit truncation); BT.709/2020 SDR decodes with BT.1886 gamma 2.4 instead of the sRGB EOTF; working-space compositing no longer clamps RGB to [0,1] (alpha still clamped); the output node clamps to the target gamut; frames without colorimetry metadata convert with BT.709 defaults (warned once) instead of passing through; scopes read the output-colorspace signal on both F32 paths. Also: only emit rerun-if-changed for .env when it exists (a missing file made every build fully dirty).
This commit is contained in:
@@ -145,6 +145,21 @@ pub struct RetrieveVideoParams {
|
||||
pub target_size: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// The colorimetry of a decoded frame, as carried out of the bitstream
|
||||
/// (raw ISO/IEC 23001-8 / H.273 code points — the same numbering FFmpeg's
|
||||
/// `AVCodecParameters` uses). The render layer maps these to its input
|
||||
/// transform (source colorspace → the pipeline working space).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub struct DecodedColorMeta {
|
||||
/// Color primaries code point (`AVCOL_PRI_*`; 0/2 = unknown).
|
||||
pub color_primaries: i32,
|
||||
/// Transfer characteristic code point (`AVCOL_TRC_*`; 0/2 = unknown).
|
||||
pub color_trc: i32,
|
||||
/// True when the decoded RGB is full range (the YUV→RGB used the
|
||||
/// full-range coefficients).
|
||||
pub full_range: bool,
|
||||
}
|
||||
|
||||
/// `Decoder::RetrieveAudioStatus` — outcome of an audio retrieve.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RetrieveAudioStatus {
|
||||
|
||||
@@ -121,6 +121,20 @@ pub struct EncodingParams {
|
||||
pub custom_range_out_num: i64,
|
||||
/// Custom range out denominator.
|
||||
pub custom_range_out_den: i64,
|
||||
|
||||
/// Delivery color metadata written into the output container
|
||||
/// (ISO/IEC 23001-8 / H.273 code points — the same numbering FFmpeg's
|
||||
/// `AVCodecContext` uses; 0 = leave unset). The export sets these from
|
||||
/// the project's output colorspace so the file declares its
|
||||
/// colorimetry (the mov `colr` atom / H.264-HEVC VUI) instead of
|
||||
/// leaving players to guess.
|
||||
pub color_primaries: i32,
|
||||
/// Delivery transfer characteristic code point (`AVCOL_TRC_*`).
|
||||
pub color_trc: i32,
|
||||
/// Delivery matrix coefficients code point (`AVCOL_SPC_*`; 0 = RGB).
|
||||
pub color_space: i32,
|
||||
/// Delivery color range (`AVCOL_RANGE_*`: 1 = limited, 2 = full).
|
||||
pub color_range: i32,
|
||||
}
|
||||
|
||||
impl Default for EncodingParams {
|
||||
@@ -170,6 +184,11 @@ impl Default for EncodingParams {
|
||||
custom_range_in_den: 0,
|
||||
custom_range_out_num: 0,
|
||||
custom_range_out_den: 0,
|
||||
|
||||
color_primaries: 0,
|
||||
color_trc: 0,
|
||||
color_space: 0,
|
||||
color_range: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1069,15 +1088,16 @@ mod tests {
|
||||
]
|
||||
}
|
||||
|
||||
/// `oakcodec_encoding_params` byte-level layout lock, verified against
|
||||
/// the real header with a C++ `offsetof` probe (see the crate notes):
|
||||
/// every field offset and the total size must match `include/codec/
|
||||
/// encoder.h` exactly so a C caller's POD is read in place.
|
||||
/// `oakcodec_encoding_params` byte-level layout lock. The original C
|
||||
/// ABI (`include/codec/encoder.h`) has been retired, but the offsets of
|
||||
/// the pre-existing fields stay frozen so any on-disk/IPC copy of the
|
||||
/// POD still reads in place; the delivery color-metadata fields are
|
||||
/// appended at the end.
|
||||
#[test]
|
||||
fn encoding_params_c_abi_layout() {
|
||||
use std::mem::{offset_of, size_of};
|
||||
|
||||
assert_eq!(size_of::<EncodingParams>(), 1536);
|
||||
assert_eq!(size_of::<EncodingParams>(), 1552);
|
||||
assert_eq!(offset_of!(EncodingParams, filename), 0);
|
||||
assert_eq!(offset_of!(EncodingParams, format), 1024);
|
||||
assert_eq!(offset_of!(EncodingParams, video_enabled), 1028);
|
||||
@@ -1091,5 +1111,10 @@ mod tests {
|
||||
assert_eq!(offset_of!(EncodingParams, has_custom_range), 1496);
|
||||
assert_eq!(offset_of!(EncodingParams, custom_range_in_num), 1504);
|
||||
assert_eq!(offset_of!(EncodingParams, custom_range_out_den), 1528);
|
||||
// Appended delivery color metadata (H.273 code points).
|
||||
assert_eq!(offset_of!(EncodingParams, color_primaries), 1536);
|
||||
assert_eq!(offset_of!(EncodingParams, color_trc), 1540);
|
||||
assert_eq!(offset_of!(EncodingParams, color_space), 1544);
|
||||
assert_eq!(offset_of!(EncodingParams, color_range), 1548);
|
||||
}
|
||||
}
|
||||
|
||||
+394
-15
@@ -57,6 +57,7 @@ use ffmpeg::{ChannelLayout, Dictionary, Error as FfmpegError, Rational as FfRati
|
||||
use ffmpeg_next as ffmpeg;
|
||||
|
||||
use oak_common::cancelatom::CancelAtom;
|
||||
use oak_common::colormath::YuvMatrix;
|
||||
use oak_common::ocioutils::PixelFormat as OakPixelFormat;
|
||||
use oak_common::videoparams::{Interlacing, VideoParams, VideoType};
|
||||
use oak_core::{PixelFormat, Rational, SampleFormat, TimeRange};
|
||||
@@ -70,6 +71,22 @@ use crate::frame::Frame;
|
||||
|
||||
/// `OAKCOMMON_COLOR_RANGE_FULL`.
|
||||
const OAKCOMMON_COLOR_RANGE_FULL: i32 = 1;
|
||||
/// `OAKCOMMON_COLOR_RANGE_LIMITED`.
|
||||
const OAKCOMMON_COLOR_RANGE_LIMITED: i32 = 0;
|
||||
/// `AVCOL_RANGE_JPEG` (full range; AVCOL_RANGE_MPEG = 1 is limited).
|
||||
const AVCOL_RANGE_JPEG: i32 = 2;
|
||||
/// swscale colorspace ids (`SWS_CS_*`, libswscale/swscale.h).
|
||||
const SWS_CS_ITU709: i32 = 1;
|
||||
const SWS_CS_ITU601: i32 = 5;
|
||||
const SWS_CS_SMPTE240M: i32 = 7;
|
||||
const SWS_CS_BT2020: i32 = 9;
|
||||
/// AVCOL_SPC_* code points that map onto each swscale colorspace.
|
||||
const AVCOL_SPC_BT709: i32 = 1;
|
||||
const AVCOL_SPC_BT470BG: i32 = 5;
|
||||
const AVCOL_SPC_SMPTE170M: i32 = 6;
|
||||
const AVCOL_SPC_SMPTE240M: i32 = 7;
|
||||
const AVCOL_SPC_BT2020_NCL: i32 = 9;
|
||||
const AVCOL_SPC_BT2020_CL: i32 = 10;
|
||||
/// The format-level time base (microseconds), `FB_TIME_BASE` in the bridge.
|
||||
const FB_TIME_BASE: i64 = 1_000_000;
|
||||
/// `AV_NOPTS_VALUE`.
|
||||
@@ -332,8 +349,20 @@ impl Decoder for FFmpegDecoder {
|
||||
let f = decoded?
|
||||
.ok_or_else(|| fail("no video frame available at the requested time"))?;
|
||||
|
||||
let (w, h, bytes) = state.scale_video_to_f32(f, p.force_range, p.target_size)?;
|
||||
let frame = copy_rgba_f32_to_frame(w, h, &bytes, p.time)?;
|
||||
let (w, h, bytes, color_meta) =
|
||||
state.scale_video_to_f32(f, p.force_range, p.target_size)?;
|
||||
let mut frame = copy_rgba_f32_to_frame(w, h, &bytes, p.time)?;
|
||||
// Carry the source colorimetry on the frame params: the render
|
||||
// layer maps it to its input transform (source → working space).
|
||||
if let Some(params) = frame.params.as_mut() {
|
||||
params.set_color_primaries(color_meta.color_primaries);
|
||||
params.set_color_transfer(color_meta.color_trc);
|
||||
params.set_color_range(if color_meta.full_range {
|
||||
oak_common::videoparams::ColorRange::Full
|
||||
} else {
|
||||
oak_common::videoparams::ColorRange::Limited
|
||||
});
|
||||
}
|
||||
Ok(Arc::new(frame))
|
||||
}
|
||||
|
||||
@@ -949,8 +978,10 @@ impl DecoderState {
|
||||
}
|
||||
|
||||
/// Scale a decoded frame to float RGBA (F32, 4 channels), returning the
|
||||
/// raw pixel bytes plus dimensions. Mirrors `pre_process_frame` +
|
||||
/// `retrieve_video_frame_internal` scaling with the color-range forcing.
|
||||
/// raw pixel bytes, dimensions, and the source colorimetry. Mirrors
|
||||
/// `pre_process_frame` + `retrieve_video_frame_internal` scaling; unlike
|
||||
/// the old bridge path the YUV→RGB honors the frame's own colorspace
|
||||
/// (BT.601/709/2020) and range instead of assuming BT.601 limited.
|
||||
/// `target_size` resizes in the same swscale pass (native → RGBA/F32 at
|
||||
/// the target size) instead of converting at native size first — the
|
||||
/// caller's downscale then degenerates to a plain copy, and no
|
||||
@@ -960,18 +991,45 @@ impl DecoderState {
|
||||
f: ffmpeg::frame::Video,
|
||||
force_range: i32,
|
||||
target_size: Option<(u32, u32)>,
|
||||
) -> crate::error::Result<(u32, u32, Vec<u8>)> {
|
||||
) -> crate::error::Result<(u32, u32, Vec<u8>, crate::decoder::DecodedColorMeta)> {
|
||||
let video = self
|
||||
.video
|
||||
.as_mut()
|
||||
.expect("scale_video_to_f32 requires a video session");
|
||||
|
||||
// The frame's own colorimetry (set by the decoder from the
|
||||
// bitstream); raw code points pass through to the render layer.
|
||||
let (raw_primaries, raw_trc, raw_space, raw_range) = unsafe {
|
||||
let av = f.as_ptr();
|
||||
(
|
||||
(*av).color_primaries as i32,
|
||||
(*av).color_trc as i32,
|
||||
(*av).colorspace as i32,
|
||||
(*av).color_range as i32,
|
||||
)
|
||||
};
|
||||
|
||||
// # CPP-PARITY ffmpegdecoder.cpp:376: disregard "JPEG" pixel formats
|
||||
// and force the color range to whatever the caller requested.
|
||||
let src_format = convert_jpeg_space_to_regular_space(f.format());
|
||||
// — but a YUVJ source is full range by definition, so remember it
|
||||
// for the range decision below.
|
||||
let orig_format = f.format();
|
||||
let src_format = convert_jpeg_space_to_regular_space(orig_format);
|
||||
let yuvj_full = orig_format != src_format;
|
||||
let mut f = f;
|
||||
f.set_format(src_format);
|
||||
f.set_color_range(if force_range == OAKCOMMON_COLOR_RANGE_FULL {
|
||||
|
||||
// The effective color range: the caller's force wins; otherwise the
|
||||
// frame's own metadata (YUVJ sources are full range). The old path
|
||||
// forced MPEG/limited for everything, crushing full-range screen
|
||||
// captures and JPEG-derived footage.
|
||||
let full_range = if force_range == OAKCOMMON_COLOR_RANGE_FULL {
|
||||
true
|
||||
} else if force_range == OAKCOMMON_COLOR_RANGE_LIMITED {
|
||||
false
|
||||
} else {
|
||||
yuvj_full || raw_range == AVCOL_RANGE_JPEG
|
||||
};
|
||||
f.set_color_range(if full_range {
|
||||
ffmpeg::color::Range::JPEG
|
||||
} else {
|
||||
ffmpeg::color::Range::MPEG
|
||||
@@ -990,24 +1048,67 @@ impl DecoderState {
|
||||
// float context there can abort instead of erroring — RGBA64 is
|
||||
// REPORTED supported but still aborts, so only RGBAF32LE is
|
||||
// probed (on the builds that have it, e.g. the system FFmpeg,
|
||||
// it works); everything else takes the universal 8-bit RGBA
|
||||
// path converted in Rust.
|
||||
// it works). High-bit-depth YUV sources fall back to 16-bit
|
||||
// planar YUV 4:4:4 (converted to F32 RGBA in Rust) so their
|
||||
// precision survives; 8-bit and RGB sources take the universal
|
||||
// 8-bit RGBA path.
|
||||
let supported = ffmpeg::software::scaling::support::output(Pixel::RGBAF32LE);
|
||||
let (depth, is_yuv) = pix_fmt_depth_and_yuv(src_format);
|
||||
let (out_fmt, f32_ok) = if supported {
|
||||
(Pixel::RGBAF32LE, true)
|
||||
} else if depth > 8 && is_yuv {
|
||||
(Pixel::YUV444P16LE, false)
|
||||
} else {
|
||||
(Pixel::RGBA, false)
|
||||
};
|
||||
let ctx = get_or_create_scaler(&mut video.scaler, src_format, src_w, src_h, out_fmt, w, h)?;
|
||||
if out_fmt == Pixel::YUV444P16LE {
|
||||
// YUV→YUV pass-through: the 16-bit code values must reach the
|
||||
// Rust matrix conversion bit-exact. sws_setColorspaceDetails
|
||||
// has to see the SAME coefficient table for source and
|
||||
// destination — differing tables would insert a cascaded
|
||||
// YUV→RGB→YUV round trip — and both ranges are set full so
|
||||
// the YUV→YUV range recompression is skipped entirely (it
|
||||
// only runs when src_range != dst_range). The matrix and
|
||||
// full/limited expansion happen later, in
|
||||
// convert_yuv444p16_to_rgba_f32.
|
||||
unsafe {
|
||||
let table = sys::sws_getCoefficients(sws_colorspace_for(raw_space, src_w, src_h));
|
||||
sys::sws_setColorspaceDetails(
|
||||
ctx.as_mut_ptr(),
|
||||
table,
|
||||
1, // src full range (no recompression)
|
||||
table,
|
||||
1, // dst full range (no recompression)
|
||||
0,
|
||||
1 << 16,
|
||||
1 << 16,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// The YUV→RGB matrix: BT.601/709/2020 per the frame's
|
||||
// colorspace tag, with the full/limited range decided above.
|
||||
// RGB sources are untouched by the colorspace tables (swscale
|
||||
// ignores them there).
|
||||
apply_sws_colorspace(ctx, raw_space, full_range, src_w, src_h);
|
||||
}
|
||||
let mut out = ffmpeg::frame::Video::empty();
|
||||
ctx.run(&f, &mut out).map_err(ffmpeg_err)?;
|
||||
let stride = out.stride(0);
|
||||
let bytes = if f32_ok {
|
||||
let stride = out.stride(0);
|
||||
convert_rgba_f32_le(&out.data(0), w, h, stride)
|
||||
} else if out_fmt == Pixel::YUV444P16LE {
|
||||
convert_yuv444p16_to_rgba_f32(&out, w, h, yuv_matrix_for(raw_space, src_w, src_h), full_range)
|
||||
} else {
|
||||
let stride = out.stride(0);
|
||||
convert_rgba8_to_f32(&out.data(0), w, h, stride)
|
||||
};
|
||||
Ok((w, h, bytes))
|
||||
let meta = crate::decoder::DecodedColorMeta {
|
||||
color_primaries: raw_primaries,
|
||||
color_trc: raw_trc,
|
||||
full_range,
|
||||
};
|
||||
Ok((w, h, bytes, meta))
|
||||
}
|
||||
|
||||
/// Fill `dest` (interleaved f32) with the decoded audio covering
|
||||
@@ -1467,6 +1568,90 @@ fn get_or_create_scaler(
|
||||
Ok(&mut cache.as_mut().expect("set above").ctx)
|
||||
}
|
||||
|
||||
/// Map a frame's `AVCOL_SPC_*` tag to a swscale colorspace id (the YUV→RGB
|
||||
/// coefficient set). Untagged frames fall back by size (HD material is
|
||||
/// overwhelmingly BT.709, SD is BT.601 — the old code used BT.601 for
|
||||
/// everything, tinting every HD source).
|
||||
fn sws_colorspace_for(av_colorspace: i32, src_w: u32, src_h: u32) -> i32 {
|
||||
match av_colorspace {
|
||||
AVCOL_SPC_BT709 => SWS_CS_ITU709,
|
||||
AVCOL_SPC_BT470BG | AVCOL_SPC_SMPTE170M => SWS_CS_ITU601,
|
||||
AVCOL_SPC_SMPTE240M => SWS_CS_SMPTE240M,
|
||||
AVCOL_SPC_BT2020_NCL | AVCOL_SPC_BT2020_CL => SWS_CS_BT2020,
|
||||
// Untagged: HD → BT.709, SD → BT.601.
|
||||
_ => {
|
||||
if src_w >= 1280 || src_h > 576 {
|
||||
SWS_CS_ITU709
|
||||
} else {
|
||||
SWS_CS_ITU601
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Rust-side YUV→RGB matrix for a frame's `AVCOL_SPC_*` tag. Unlike
|
||||
/// [`sws_colorspace_for`] only tags with an exact matrix in [`YuvMatrix`]
|
||||
/// are honored; everything else (including SMPTE 240M and BT.2020 CL) falls
|
||||
/// back by size.
|
||||
fn yuv_matrix_for(av_colorspace: i32, src_w: u32, src_h: u32) -> YuvMatrix {
|
||||
match av_colorspace {
|
||||
AVCOL_SPC_BT709 => YuvMatrix::Bt709,
|
||||
AVCOL_SPC_BT470BG | AVCOL_SPC_SMPTE170M => YuvMatrix::Bt601,
|
||||
AVCOL_SPC_BT2020_NCL => YuvMatrix::Bt2020,
|
||||
_ => {
|
||||
if src_w >= 1280 || src_h > 576 {
|
||||
YuvMatrix::Bt709
|
||||
} else {
|
||||
YuvMatrix::Bt601
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bit depth (bits per component) and YUV-ness of a pixel format, from its
|
||||
/// `AVPixFmtDescriptor` (8 and false for formats without one — none in
|
||||
/// practice for decoder output).
|
||||
fn pix_fmt_depth_and_yuv(fmt: Pixel) -> (i32, bool) {
|
||||
unsafe {
|
||||
let desc = sys::av_pix_fmt_desc_get(fmt.into());
|
||||
if desc.is_null() {
|
||||
(8, false)
|
||||
} else {
|
||||
((*desc).comp[0].depth, (*desc).flags & sys::AV_PIX_FMT_FLAG_RGB as u64 == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure a swscale context's YUV→RGB matrix and range.
|
||||
///
|
||||
/// The range flag selects full/limited input coefficients; the RGB output is
|
||||
/// always full range. `sws_setColorspaceDetails` ignores the tables for
|
||||
/// non-YUV sources, so RGB footage passes through unchanged.
|
||||
fn apply_sws_colorspace(
|
||||
ctx: &mut scaling::Context,
|
||||
av_colorspace: i32,
|
||||
full_range: bool,
|
||||
src_w: u32,
|
||||
src_h: u32,
|
||||
) {
|
||||
let sws_cs = sws_colorspace_for(av_colorspace, src_w, src_h);
|
||||
unsafe {
|
||||
let inv_table = sys::sws_getCoefficients(sws_cs);
|
||||
let dst_table = sys::sws_getCoefficients(SWS_CS_ITU601);
|
||||
// brightness 0, contrast/saturation unity (16.16 fixed point).
|
||||
sys::sws_setColorspaceDetails(
|
||||
ctx.as_mut_ptr(),
|
||||
inv_table,
|
||||
full_range as i32,
|
||||
dst_table,
|
||||
1, // RGB out is full range
|
||||
0,
|
||||
1 << 16,
|
||||
1 << 16,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame's presentation timestamp (NOPTS when unset).
|
||||
fn pts_of(f: Option<&ffmpeg::frame::Video>) -> Option<i64> {
|
||||
f.and_then(|f| f.pts())
|
||||
@@ -1569,6 +1754,43 @@ fn convert_rgba8_to_f32(data: &[u8], w: u32, h: u32, stride: usize) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Convert a 16-bit planar YUV 4:4:4 frame (YUV444P16LE, as emitted by the
|
||||
/// high-bit-depth swscale fallback) to interleaved F32 RGBA little-endian
|
||||
/// bytes. The YUV→RGB matrix and full/limited expansion run here instead of
|
||||
/// inside swscale so the 16-bit code values survive intact: swscale only
|
||||
/// converted the format (and resized), with identical source/destination
|
||||
/// colorspace tables and full ranges on both sides, so no matrix and no
|
||||
/// range recompression was applied. 10/12-bit sources arrive left-shifted
|
||||
/// to 16-bit (code << 6 / code << 4) — exactly the code-value scale
|
||||
/// [`oak_common::colormath::yuv444p16_to_rgb_f32`] expects.
|
||||
fn convert_yuv444p16_to_rgba_f32(
|
||||
out: &ffmpeg::frame::Video,
|
||||
w: u32,
|
||||
h: u32,
|
||||
matrix: YuvMatrix,
|
||||
full_range: bool,
|
||||
) -> Vec<u8> {
|
||||
let mut rgba = vec![0.0f32; (w as usize) * (h as usize) * 4];
|
||||
oak_common::colormath::yuv444p16_to_rgb_f32(
|
||||
out.data(0),
|
||||
out.stride(0),
|
||||
out.data(1),
|
||||
out.stride(1),
|
||||
out.data(2),
|
||||
out.stride(2),
|
||||
w as usize,
|
||||
h as usize,
|
||||
matrix,
|
||||
full_range,
|
||||
&mut rgba,
|
||||
);
|
||||
let mut bytes = vec![0u8; rgba.len() * 4];
|
||||
for (dst, v) in bytes.chunks_exact_mut(4).zip(&rgba) {
|
||||
dst.copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Build an allocated [`Frame`] (F32, RGBA) from raw pixel bytes.
|
||||
///
|
||||
/// # CPP-PARITY
|
||||
@@ -1696,6 +1918,17 @@ fn probe_file(filename: &str, cancelled: Option<&CancelAtom>) -> Option<FootageD
|
||||
vp.set_time_base(tb.0 as i32, tb.1 as i32);
|
||||
vp.set_duration(stream.duration());
|
||||
vp.set_premultiplied_alpha(false);
|
||||
// Stream colorimetry (drives the input→working transform
|
||||
// and lets the UI show what the footage is).
|
||||
unsafe {
|
||||
vp.set_color_primaries((*raw).color_primaries as i32);
|
||||
vp.set_color_transfer((*raw).color_trc as i32);
|
||||
vp.set_color_range(if (*raw).color_range as i32 == AVCOL_RANGE_JPEG {
|
||||
oak_common::videoparams::ColorRange::Full
|
||||
} else {
|
||||
oak_common::videoparams::ColorRange::Limited
|
||||
});
|
||||
}
|
||||
desc.push_stream(StreamEntry::Video(vp));
|
||||
}
|
||||
MediaType::Audio => {
|
||||
@@ -1980,11 +2213,68 @@ impl Encoder for FFmpegEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the export's delivery color metadata (H.273 code points, carried
|
||||
/// in [`EncodingParams`]) to the video encoder before it opens. The values
|
||||
/// are FFmpeg's own enum numbering, so each is re-interpreted into the
|
||||
/// matching sys enum and handed to the typed setter; 0 (unset) fields keep
|
||||
/// the codec default.
|
||||
fn set_encoder_color_metadata(
|
||||
encoder: &mut ffmpeg::codec::encoder::video::Video,
|
||||
params: &EncodingParams,
|
||||
) {
|
||||
if params.color_primaries != 0 {
|
||||
let v: sys::AVColorPrimaries =
|
||||
unsafe { std::mem::transmute(params.color_primaries) };
|
||||
encoder.set_color_primaries(v.into());
|
||||
}
|
||||
if params.color_trc != 0 {
|
||||
let v: sys::AVColorTransferCharacteristic =
|
||||
unsafe { std::mem::transmute(params.color_trc) };
|
||||
encoder.set_color_transfer_characteristic(v.into());
|
||||
}
|
||||
if params.color_space != 0 {
|
||||
let v: sys::AVColorSpace = unsafe { std::mem::transmute(params.color_space) };
|
||||
encoder.set_colorspace(v.into());
|
||||
}
|
||||
if params.color_range != 0 {
|
||||
let v: sys::AVColorRange = unsafe { std::mem::transmute(params.color_range) };
|
||||
encoder.set_color_range(v.into());
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure the encoder's RGB→YUV scaler so the produced YUV matches the
|
||||
/// delivery tag written by [`set_encoder_color_metadata`] (otherwise swscale
|
||||
/// defaults to BT.601/limited regardless of the tag, and players decode with
|
||||
/// the wrong matrix). `params.color_space` is the `AVCOL_SPC_*` value; the
|
||||
/// range follows `params.color_range` (1 = limited, 2 = full; 0 → limited).
|
||||
fn apply_sws_output_colorspace(scaler: &mut scaling::Context, params: &EncodingParams) {
|
||||
let sws_cs = match params.color_space {
|
||||
1 => SWS_CS_ITU709, // AVCOL_SPC_BT709
|
||||
9 | 10 => SWS_CS_BT2020, // AVCOL_SPC_BT2020_NCL / _CL
|
||||
_ => SWS_CS_ITU601,
|
||||
};
|
||||
let full_range = params.color_range == 2; // AVCOL_RANGE_JPEG
|
||||
unsafe {
|
||||
let table = sys::sws_getCoefficients(sws_cs);
|
||||
// src is RGB (always full range); dst is YUV with the delivery
|
||||
// matrix and range.
|
||||
sys::sws_setColorspaceDetails(
|
||||
scaler.as_mut_ptr(),
|
||||
table, // inv_table unused for an RGB source
|
||||
1,
|
||||
table,
|
||||
full_range as i32,
|
||||
0,
|
||||
1 << 16,
|
||||
1 << 16,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl EncoderState {
|
||||
/// Open the output file, create the streams and encoders and write the
|
||||
/// header.
|
||||
fn open(&mut self, params: &EncodingParams) -> crate::error::Result<()> {
|
||||
if self.output.is_some() {
|
||||
fn open(&mut self, params: &EncodingParams) -> crate::error::Result<()> { if self.output.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let filename = c_string_1024(¶ms.filename);
|
||||
@@ -2055,6 +2345,12 @@ impl EncoderState {
|
||||
.unwrap_or_else(|| default_pixel_format_for_codec(codec_id));
|
||||
encoder.set_format(pix_fmt);
|
||||
|
||||
// Delivery color metadata (H.273 code points) → the container's
|
||||
// colr atom / H.264-HEVC VUI, so the exported file declares its
|
||||
// colorimetry instead of leaving players to guess. Only set when
|
||||
// the export populated them (0 = leave the codec default).
|
||||
set_encoder_color_metadata(&mut encoder, params);
|
||||
|
||||
let opened = encoder.open().map_err(|e| { eprintln!("DBG-AUD: audio open failed: {e:?}"); ffmpeg_err(e) })?;
|
||||
stream.set_parameters(&opened);
|
||||
// The encoder may adjust the time base during `open` (x264
|
||||
@@ -2077,7 +2373,7 @@ impl EncoderState {
|
||||
let frame_duration = (time_base.1 as i64 * i64::from(frame_rate.1))
|
||||
/ (i64::from(time_base.0) * i64::from(frame_rate.0)).max(1);
|
||||
|
||||
let scaler = scaling::Context::get(
|
||||
let mut scaler = scaling::Context::get(
|
||||
Pixel::RGBA,
|
||||
width,
|
||||
height,
|
||||
@@ -2087,6 +2383,9 @@ impl EncoderState {
|
||||
scaling::Flags::BILINEAR,
|
||||
)
|
||||
.map_err(ffmpeg_err)?;
|
||||
// Match the RGB→YUV conversion to the delivery color tag so
|
||||
// players decode with the matrix/range the container declares.
|
||||
apply_sws_output_colorspace(&mut scaler, params);
|
||||
|
||||
video = Some(VideoEncoderState {
|
||||
encoder: opened,
|
||||
@@ -2665,4 +2964,84 @@ mod tests {
|
||||
let e = FFmpegEncoder::with_params(p);
|
||||
assert!(e.open().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pix_fmt_depth_and_yuv_detects_depth_and_kind() {
|
||||
// YUV luma depths (on the YUVJ→regular-normalized format).
|
||||
assert_eq!(pix_fmt_depth_and_yuv(Pixel::YUV420P), (8, true));
|
||||
assert_eq!(pix_fmt_depth_and_yuv(Pixel::YUV420P10LE), (10, true));
|
||||
assert_eq!(pix_fmt_depth_and_yuv(Pixel::YUV444P16LE), (16, true));
|
||||
// RGB formats never take the high-bit-depth YUV fallback.
|
||||
assert_eq!(pix_fmt_depth_and_yuv(Pixel::RGBA), (8, false));
|
||||
assert_eq!(pix_fmt_depth_and_yuv(Pixel::RGB48LE), (16, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yuv_matrix_mapping_is_strict() {
|
||||
use oak_common::colormath::YuvMatrix;
|
||||
assert_eq!(yuv_matrix_for(AVCOL_SPC_BT709, 1920, 1080), YuvMatrix::Bt709);
|
||||
assert_eq!(yuv_matrix_for(AVCOL_SPC_BT470BG, 640, 480), YuvMatrix::Bt601);
|
||||
assert_eq!(yuv_matrix_for(AVCOL_SPC_SMPTE170M, 1920, 1080), YuvMatrix::Bt601);
|
||||
assert_eq!(yuv_matrix_for(AVCOL_SPC_BT2020_NCL, 1920, 1080), YuvMatrix::Bt2020);
|
||||
// SMPTE 240M / BT.2020 CL / unknown tags are NOT mapped directly —
|
||||
// they fall back by size (HD → BT.709, SD → BT.601).
|
||||
assert_eq!(yuv_matrix_for(AVCOL_SPC_SMPTE240M, 1920, 1080), YuvMatrix::Bt709);
|
||||
assert_eq!(yuv_matrix_for(AVCOL_SPC_BT2020_CL, 640, 480), YuvMatrix::Bt601);
|
||||
assert_eq!(yuv_matrix_for(0, 1920, 1080), YuvMatrix::Bt709);
|
||||
assert_eq!(yuv_matrix_for(0, 640, 480), YuvMatrix::Bt601);
|
||||
assert_eq!(yuv_matrix_for(0, 1000, 600), YuvMatrix::Bt709); // h > 576
|
||||
assert_eq!(yuv_matrix_for(0, 720, 576), YuvMatrix::Bt601); // 576 is SD
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sws_colorspace_mapping_keeps_legacy_behavior() {
|
||||
assert_eq!(sws_colorspace_for(AVCOL_SPC_BT709, 0, 0), SWS_CS_ITU709);
|
||||
assert_eq!(sws_colorspace_for(AVCOL_SPC_BT470BG, 0, 0), SWS_CS_ITU601);
|
||||
assert_eq!(sws_colorspace_for(AVCOL_SPC_SMPTE170M, 0, 0), SWS_CS_ITU601);
|
||||
assert_eq!(sws_colorspace_for(AVCOL_SPC_SMPTE240M, 0, 0), SWS_CS_SMPTE240M);
|
||||
assert_eq!(sws_colorspace_for(AVCOL_SPC_BT2020_NCL, 0, 0), SWS_CS_BT2020);
|
||||
assert_eq!(sws_colorspace_for(AVCOL_SPC_BT2020_CL, 0, 0), SWS_CS_BT2020);
|
||||
assert_eq!(sws_colorspace_for(0, 1920, 1080), SWS_CS_ITU709);
|
||||
assert_eq!(sws_colorspace_for(0, 640, 480), SWS_CS_ITU601);
|
||||
}
|
||||
|
||||
/// A 2×1 YUV444P16LE frame: full-range white (Y=65535, neutral C) left,
|
||||
/// full-range black (Y=0, neutral C) right. Each sample is a u16.
|
||||
fn synthetic_yuv444p16_frame() -> ffmpeg::frame::Video {
|
||||
let mut f = ffmpeg::frame::Video::new(Pixel::YUV444P16LE, 2, 1);
|
||||
for plane in 0..3 {
|
||||
let data = f.data_mut(plane);
|
||||
for (px, v) in data.chunks_exact_mut(2).take(2).enumerate() {
|
||||
let code = match plane {
|
||||
0 => [65535u16, 0u16][px], // luma: white, black
|
||||
_ => 32768u16, // chroma: neutral
|
||||
};
|
||||
v[..2].copy_from_slice(&code.to_le_bytes());
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yuv444p16_fallback_round_trips_full_range_white_and_black() {
|
||||
let f = synthetic_yuv444p16_frame();
|
||||
let bytes = convert_yuv444p16_to_rgba_f32(&f, 2, 1, YuvMatrix::Bt709, true);
|
||||
let px = |i: usize| -> [f32; 4] {
|
||||
let b = &bytes[i * 16..i * 16 + 16];
|
||||
[
|
||||
f32::from_le_bytes(b[0..4].try_into().unwrap()),
|
||||
f32::from_le_bytes(b[4..8].try_into().unwrap()),
|
||||
f32::from_le_bytes(b[8..12].try_into().unwrap()),
|
||||
f32::from_le_bytes(b[12..16].try_into().unwrap()),
|
||||
]
|
||||
};
|
||||
let white = px(0);
|
||||
let black = px(1);
|
||||
for c in 0..3 {
|
||||
assert!((white[c] - 1.0).abs() < 1e-6, "white[{c}] = {}", white[c]);
|
||||
assert!(black[c].abs() < 1e-6, "black[{c}] = {}", black[c]);
|
||||
}
|
||||
assert_eq!(white[3], 1.0);
|
||||
assert_eq!(black[3], 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user