M8.8: colour management — sequence working space (Rec. 709 / Rec. 2100 PQ / HLG, wide gamut), Interpret Footage colour space, log/HDR decode with BT.2390 tone mapping, HDR export signalling, HDR scopes

- model: SequenceSettings::color (ColorPipeline: working space, wide gamut, Auto Tone Map Media;
  serde default Rec. 709) and Interpretation::color_space (override; None = metadata)
- render::colorman: source colour space from the override or VUI/colr/MKV metadata, decode tables
  (camera log with super-whites, PQ, HLG + OOTF), gamut matrix, tone mapping into SDR working
  spaces, gamut mapping; working → SDR monitor transform at the top level
  (RenderOptions::working_output keeps working values for HDR export and scopes). Plain Rec. 709
  media in a Rec. 709 sequence keeps the old fast path and stays on the GPU; managed layers and
  HDR/wide-gamut sequences composite on the CPU
- export: H.264 and ProRes of a PQ/HLG sequence encode BT.2020 PQ/HLG and signal it: VUI / ProRes
  header, colr (nclx/nclc), and for PQ mdcv + clli boxes and H.264 SEI (payloadTypes 137/144);
  `sdr: true` (and render previews) export tone-mapped SDR. isobmff reads/writes mdcv and clli
- commands: sequence.colorSettings (Sequence ▸ Color Management…), clip.interpretFootage
  (Clip ▸ Modify ▸ Interpret Footage…, Project panel context menu), color.spaces,
  media.colorInfo; dialogs with automation ids colorDialog.*
- scopes: HDR sequences show a cd/m² waveform on a PQ scale (203 marked) and a BT.2020
  vectorscope from the working-space render (scopes.hdrWaveform)
- display-referred wide-gamut input is desaturated into gamut keeping in-gamut colours exactly;
  the soft compression is reserved for camera gamuts
- tests: colorman unit tests, engine (interpret/undo, PQ working space values and display),
  goldens log_to_rec709 and hdr_tone_map (own renders + sidecars), export PQ/HLG × ProRes/H.264
  read back by our demuxer and ffprobe (color_transfer/primaries/space, mastering display and
  content light side data), headless UI (dialogs, HDR scopes)
- docs: architecture §5.2 (pipeline, HDR export, display colour management: no macOS EDR yet)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 08:56:47 -04:00
co-authored by Claude Opus 5.5
parent a7c1cfd956
commit 1653430c42
36 changed files with 1145 additions and 46 deletions
+2
View File
@@ -38,6 +38,8 @@ procedural demo footage (`crates/media/src/generators.rs`); no external media. R
| `crates/golden/goldens/crop.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `crop`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/gaussian_blur.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `gaussian_blur`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/graphic_shapes.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `graphic_shapes`) from procedurally generated media and bundled OFL fonts | MIT OR Apache-2.0 |
| `crates/golden/goldens/log_to_rec709.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `log_to_rec709`) from procedurally generated media re-encoded as S-Log3 | MIT OR Apache-2.0 |
| `crates/golden/goldens/hdr_tone_map.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `hdr_tone_map`) from procedurally generated media re-encoded as Rec. 2100 PQ | MIT OR Apache-2.0 |
| `crates/golden/goldens/graphic_title.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `graphic_title`) from procedurally generated media and bundled OFL fonts | MIT OR Apache-2.0 |
| `crates/golden/goldens/lumetri_basic.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `lumetri_basic`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/text_burnin.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `text_burnin`) from procedurally generated media | MIT OR Apache-2.0 |
Generated
+3
View File
@@ -1455,6 +1455,7 @@ version = "0.1.0"
dependencies = [
"filmcraft-aac",
"filmcraft-codecs",
"filmcraft-color",
"filmcraft-frame",
"filmcraft-h264enc",
"filmcraft-isobmff",
@@ -1505,6 +1506,8 @@ dependencies = [
name = "filmcraft-golden"
version = "0.1.0"
dependencies = [
"filmcraft-color",
"filmcraft-frame",
"filmcraft-geom",
"filmcraft-gpu",
"filmcraft-media",
+2
View File
@@ -90,6 +90,8 @@ on the sRGB curve).
the achromatic axis, after the ACES Reference Gamut Compression: for each channel
d = (max − c)/max; distances below 0.8 are unchanged, distances from 0.8 to 1.25 are compressed
into 0.8–1.0 with a power-1.2 curve. Colours within 80 % of the boundary are untouched.
* **Display-referred wide-gamut input** (BT.2020 / P3 / PQ / HLG media into a narrower working
gamut) and
* **Output** (BT.2020 working → BT.709 monitor/export): colours with a negative component are
desaturated towards their BT.709 luminance just enough to fit (`desaturate_into_gamut`); in-gamut
colours, such as BT.709 media in a wide-gamut sequence, come back exactly.
+14 -3
View File
@@ -195,6 +195,9 @@ pub struct InputTransform {
hlg: Option<([f32; 3], f32)>,
tone: Option<ToneMap>,
compress: bool,
/// Display-referred wide-gamut media (BT.2020 / P3 / HDR): desaturate into the working gamut
/// with these luma weights instead of the camera-gamut compression.
desaturate: Option<[f32; 3]>,
}
impl InputTransform {
@@ -215,13 +218,18 @@ impl InputTransform {
_ => REFERENCE_WHITE_NITS,
};
let tone = (src.is_hdr() && !pipe.working.is_hdr() && pipe.auto_tone_map).then(|| ToneMap::new(peak, REFERENCE_WHITE_NITS));
let compress = !identity_matrix && !gamut_within(src.gamut(), wg);
InputTransform { source: src, table: DecodeTable::for_curve(curve, range), matrix: to_f32(&m), identity_matrix, hlg, tone, compress }
let wider = !identity_matrix && !gamut_within(src.gamut(), wg);
// camera gamuts (log media) get the soft compression; display gamuts keep their in-gamut
// colours exactly (BT.709 content in a BT.2020 file comes back unchanged)
let compress = wider && src.is_log();
let l = wg.luma();
let desaturate = (wider && !src.is_log()).then(|| [l[0] as f32, l[1] as f32, l[2] as f32]);
InputTransform { source: src, table: DecodeTable::for_curve(curve, range), matrix: to_f32(&m), identity_matrix, hlg, tone, compress, desaturate }
}
/// Whether anything happens after the per-channel table.
pub fn has_pixel_stage(&self) -> bool {
!self.identity_matrix || self.hlg.is_some() || self.tone.is_some() || self.compress
!self.identity_matrix || self.hlg.is_some() || self.tone.is_some() || self.compress || self.desaturate.is_some()
}
/// The per-pixel stage (input: the table's output).
@@ -241,6 +249,9 @@ impl InputTransform {
if self.compress {
c = gamut_compress(c);
}
if let Some(l) = self.desaturate {
c = desaturate_into_gamut(c, l);
}
c
}
+127 -5
View File
@@ -1,14 +1,15 @@
//! Colour commands: the project LUT library (`lut.*`), Lumetri LUT slots and section switches
//! (`lumetri.*`).
//! Colour commands: colour management (`sequence.colorSettings`, `clip.interpretFootage`,
//! `color.spaces`, `media.colorInfo`), the project LUT library (`lut.*`), Lumetri LUT slots and
//! section switches (`lumetri.*`).
//!
//! Lumetri commands address a clip by `clip` (default: the first selected video clip with
//! Lumetri Color, else the first selected video clip — Lumetri is added if missing).
use filmcraft_color::{Lut, LutFormat};
use filmcraft_project::{ClipId, ParamValue, ProjectLut, TrackKind};
use filmcraft_color::{ColorSpace, Lut, LutFormat, WorkingSpace};
use filmcraft_project::{ClipId, ItemId, ItemKind, ParamValue, ProjectLut, TrackKind};
use serde_json::{Value, json};
use crate::commands::{CommandSpec, always, bad, bool_p, clip_p, has_seq, str_p};
use crate::commands::{CommandSpec, always, bad, bool_p, clip_p, has_seq, item_p, str_p};
use crate::{Result, Session};
type Run = fn(&mut Session, &Value) -> Result<Value>;
@@ -23,6 +24,24 @@ fn query(id: &'static str, label: &'static str, params: &'static str, run: Run)
pub(crate) fn commands() -> Vec<CommandSpec> {
vec![
spec(
"sequence.colorSettings",
"Color Management…",
&["Sequence"],
r#"{"workingSpace":"rec709"|"rec2100-pq"|"rec2100-hlg"?,"wideGamut":bool?,"autoToneMap":bool?}"#,
has_seq,
color_settings,
),
spec(
"clip.interpretFootage",
"Interpret Footage…",
&["Clip", "Modify"],
r#"{"items":[id]?,"colorSpace":"auto"|"<color space id>"}"#,
has_footage,
interpret,
),
query("color.spaces", "List Colour Spaces", "{}", spaces),
query("media.colorInfo", "Media Colour Info", r#"{"item":id}"#, color_info),
spec("lut.import", "Import LUT…", &[], r#"{"path":str,"name":str?}"#, always, import),
query("lut.list", "List LUTs", "{}", list),
spec("lut.remove", "Remove LUT", &[], r#"{"id":str}"#, always, remove),
@@ -195,3 +214,106 @@ fn set_section(s: &mut Session, p: &Value) -> Result<Value> {
set_lumetri_param(s, clip, idx, param, ParamValue::Bool(v), "Lumetri Section")?;
Ok(json!({"clip": clip.0, "param": param, "on": v}))
}
fn color_settings(s: &mut Session, p: &Value) -> Result<Value> {
let id = s.state.active_sequence.ok_or_else(|| bad("sequence.colorSettings", "no active sequence"))?;
let ws = match str_p(p, "workingSpace") {
Some(w) => Some(WorkingSpace::parse(w).ok_or_else(|| bad("sequence.colorSettings", format!("unknown working space `{w}`")))?),
None => None,
};
let (wide, tone) = (bool_p(p, "wideGamut"), bool_p(p, "autoToneMap"));
let cur = s.project.sequence(id).map(|q| q.settings.color).unwrap_or_default();
let changed = ws.is_some_and(|w| w != cur.working) || wide.is_some_and(|v| v != cur.wide_gamut) || tone.is_some_and(|v| v != cur.auto_tone_map);
if changed {
s.edit("Sequence Color Settings", |pr, _| {
let q = pr.sequence_mut(id).ok_or_else(|| bad("sequence.colorSettings", "no sequence"))?;
let c = &mut q.settings.color;
if let Some(w) = ws {
c.working = w;
}
if let Some(v) = wide {
c.wide_gamut = v;
}
if let Some(v) = tone {
c.auto_tone_map = v;
}
q.settings.working_space = c.working.label().into();
Ok(())
})?;
}
let c = s.project.sequence(id).map(|q| q.settings.color).unwrap_or_default();
Ok(json!({"workingSpace": c.working.id(), "label": c.working.label(), "wideGamut": c.wide_gamut, "autoToneMap": c.auto_tone_map}))
}
fn has_footage(s: &Session) -> std::result::Result<(), String> {
if footage_targets(s, &Value::Null).is_empty() { Err("select footage in the Project panel or clips in the timeline".into()) } else { Ok(()) }
}
/// Media items addressed by `items`, else the Project panel selection, else the media of the
/// selected timeline clips.
pub(crate) fn footage_targets(s: &Session, p: &Value) -> Vec<ItemId> {
let media = |i: &ItemId| s.project.item(*i).is_some_and(|it| matches!(it.kind, ItemKind::Media(_)));
if let Some(a) = p.get("items").and_then(Value::as_array) {
return a.iter().filter_map(Value::as_u64).map(ItemId).filter(media).collect();
}
let mut v: Vec<ItemId> = s.state.project_selection.iter().copied().filter(media).collect();
if v.is_empty()
&& let Some(q) = s.active_sequence()
{
for c in &s.state.selection {
if let Some((_, it)) = q.find_item(*c)
&& media(&it.item)
&& !v.contains(&it.item)
{
v.push(it.item);
}
}
}
v
}
fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
let items = footage_targets(s, p);
if items.is_empty() {
return Err(bad("clip.interpretFootage", "no footage selected"));
}
let cs = match str_p(p, "colorSpace") {
None => return Err(bad("clip.interpretFootage", "need `colorSpace` (auto or a colour space id)")),
Some("auto") | Some("") => None,
Some(c) => Some(ColorSpace::parse(c).ok_or_else(|| bad("clip.interpretFootage", format!("unknown colour space `{c}`")))?),
};
s.edit("Interpret Footage", |pr, _| {
for i in &items {
if let Some(ItemKind::Media(m)) = pr.item_mut(*i).map(|it| &mut it.kind) {
m.interpret.color_space = cs;
}
}
Ok(())
})?;
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>(), "colorSpace": cs.map(|c| c.id()).unwrap_or("auto")}))
}
fn spaces(_: &mut Session, _: &Value) -> Result<Value> {
let cs: Vec<Value> = ColorSpace::ALL
.iter()
.map(|c| json!({"id": c.id(), "label": c.label(), "hdr": c.is_hdr(), "log": c.is_log(), "gamut": format!("{:?}", c.gamut())}))
.collect();
let ws: Vec<Value> = WorkingSpace::ALL.iter().map(|w| json!({"id": w.id(), "label": w.label(), "hdr": w.is_hdr()})).collect();
Ok(json!({"colorSpaces": cs, "workingSpaces": ws}))
}
fn color_info(s: &mut Session, p: &Value) -> Result<Value> {
let id = item_p(p, "item").ok_or_else(|| bad("media.colorInfo", "need `item`"))?;
let Some(ItemKind::Media(m)) = s.project.item(id).map(|i| &i.kind) else { return Err(bad("media.colorInfo", "not a media item")) };
let detected = m.info.video.as_ref().map(|v| ColorSpace::from_info(&v.color));
let effective = m.interpret.color_space.or(detected);
Ok(json!({
"detected": detected.map(|c| c.id()),
"detectedLabel": detected.map(|c| c.label()),
"metadata": m.info.video.as_ref().map(|v| json!({"transfer": format!("{:?}", v.color.transfer), "primaries": format!("{:?}", v.color.primaries), "matrix": format!("{:?}", v.color.matrix), "range": format!("{:?}", v.color.range)})),
"override": m.interpret.color_space.map(|c| c.id()),
"effective": effective.map(|c| c.id()),
"effectiveLabel": effective.map(|c| c.label()),
"hdr": effective.is_some_and(|c| c.is_hdr()),
}))
}
+39 -3
View File
@@ -14,9 +14,8 @@ fn demo() -> Session {
fn pick_clip(s: &mut Session) -> ClipId {
let q = s.active_sequence().unwrap();
let t = s.playhead();
let c = q.video_tracks[0].item_at(t).or(q.video_tracks[0].items.first()).unwrap();
let id = c.id;
s.set_playhead(c.start + filmcraft_time::Tick(1000));
// the demo opens with the playhead inside the first V1 clip (its first frames fade from black)
let id = q.video_tracks[0].item_at(t).unwrap().id;
s.state.selection = vec![id];
id
}
@@ -32,6 +31,7 @@ fn lumetri_text(s: &Session, clip: ClipId, p: &str) -> String {
}
fn mean(img: &filmcraft_render::Image) -> [f32; 3] {
assert!(img.px.iter().any(|v| *v > 0.01), "blank render");
let mut m = [0f64; 3];
for p in img.px.chunks_exact(4) {
for k in 0..3 {
@@ -130,3 +130,39 @@ fn set_param_fills_in_parameters_missing_from_old_instances() {
.unwrap();
s.execute("effects.setParam", json!({"clip": clip.0, "effect": "lumetri", "param": "vignette_on", "value": false})).unwrap();
}
#[test]
fn interpret_footage_and_sequence_colour_settings() {
let mut s = demo();
let clip = pick_clip(&mut s);
let item = s.active_sequence().unwrap().find_item(clip).unwrap().1.item;
let sdr = mean(&s.render_program(0.125).unwrap());
// metadata: the demo footage is SDR
let info = s.execute("media.colorInfo", json!({"item": item.0})).unwrap();
assert_eq!(info["override"], serde_json::Value::Null);
assert_eq!(info["hdr"], false);
// interpret it as S-Log3: decoded as log (lifted signal → brighter scene light), tone mapped
let r = s.execute("clip.interpretFootage", json!({"colorSpace": "slog3-sgamut3cine"})).unwrap();
assert_eq!(r["items"][0], item.0);
let info = s.execute("media.colorInfo", json!({"item": item.0})).unwrap();
assert_eq!(info["effective"], "slog3-sgamut3cine");
assert_eq!(info["hdr"], true);
let log = mean(&s.render_program(0.125).unwrap());
assert!((log[0] - sdr[0]).abs() + (log[1] - sdr[1]).abs() > 0.02, "{sdr:?} vs {log:?}");
assert!(log.iter().all(|v| *v <= 1.0 + 1e-3), "tone mapped into SDR: {log:?}");
assert!(s.execute("clip.interpretFootage", json!({"colorSpace": "nope"})).is_err());
s.execute("edit.undo", json!({})).unwrap();
let back = mean(&s.render_program(0.125).unwrap());
assert!((back[0] - sdr[0]).abs() < 1e-4, "undo restores the metadata interpretation");
// a PQ sequence: SDR media keeps its values in working space; the monitor shows the same SDR
let r = s.execute("sequence.colorSettings", json!({"workingSpace": "Rec. 2100 PQ"})).unwrap();
assert_eq!(r["workingSpace"], "rec2100-pq");
assert_eq!(s.active_sequence().unwrap().settings.working_space, "Rec. 2100 PQ");
let work = mean(&s.render_program_working(0.125).unwrap());
let disp = mean(&s.render_program(0.125).unwrap());
assert!(work.iter().zip(&sdr).all(|(a, b)| (a - b).abs() < 0.08), "BT.2020 working values ≈ BT.709 SDR values: {work:?} vs {sdr:?}");
assert!(disp.iter().zip(&sdr).all(|(a, b)| (a - b).abs() < 0.08), "display transform: {disp:?} vs {sdr:?}");
let spaces = s.execute("color.spaces", json!({})).unwrap();
assert_eq!(spaces["colorSpaces"].as_array().unwrap().len(), filmcraft_color::ColorSpace::ALL.len());
assert!(s.execute("sequence.colorSettings", json!({"workingSpace": "xyz"})).is_err());
}
+3 -1
View File
@@ -171,7 +171,7 @@ fn track_p(s: &Session, p: &Value, k: &str) -> Option<TrackId> {
let tracks = if kind.eq_ignore_ascii_case("v") { &seq.video_tracks } else { &seq.audio_tracks };
tracks.get(i.checked_sub(1)?).map(|t| t.id)
}
fn item_p(p: &Value, k: &str) -> Option<ItemId> {
pub(crate) fn item_p(p: &Value, k: &str) -> Option<ItemId> {
u64_p(p, k).map(ItemId)
}
@@ -1883,6 +1883,8 @@ fn export_media(s: &mut Session, p: &Value) -> Result<Value> {
bitrate_kbps: u64_p(p, "bitrateKbps").unwrap_or(20_000) as u32,
burn_captions: bool_p(p, "burnCaptions").unwrap_or(false),
part_of_batch: false,
sdr: bool_p(p, "sdr").unwrap_or(false),
..Default::default()
};
let id = s.jobs.len() as u64 + 1;
let job = crate::Job {
+9
View File
@@ -530,6 +530,15 @@ impl Session {
self.media.source_for(&self.project, item, &*self.services)
}
/// Render the active sequence at the playhead in its working colour space (HDR values kept;
/// for scopes and analysis).
pub fn render_program_working(&self, scale: f32) -> Option<filmcraft_render::Image> {
let seq = self.state.active_sequence?;
let provider = self.media.provider(self.project.clone(), self.services.clone());
let opts = filmcraft_render::RenderOptions { scale, working_output: true, ..Default::default() };
Some(filmcraft_render::render_sequence(&self.project, seq, self.playhead(), opts, &provider))
}
/// Render the active sequence at the playhead (CPU reference path).
pub fn render_program(&self, scale: f32) -> Option<filmcraft_render::Image> {
let seq = self.state.active_sequence?;
+3
View File
@@ -459,6 +459,9 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result<Value> {
// Captions stay live over previews and are not part of the preview hash.
burn_captions: false,
part_of_batch: true,
// previews stand in for the monitor picture: display-referred SDR
sdr: true,
..Default::default()
};
match filmcraft_export::export(&project, seq, &settings, &provider, &prog) {
Ok(r) => {
+1
View File
@@ -12,6 +12,7 @@ workspace = true
[dependencies]
filmcraft-time = { workspace = true }
filmcraft-frame = { workspace = true }
filmcraft-color = { workspace = true }
filmcraft-media = { workspace = true }
filmcraft-project = { workspace = true }
filmcraft-render = { workspace = true }
+199 -16
View File
@@ -101,6 +101,59 @@ pub struct ExportSettings {
/// `progress.total`, `finished` and the final status; this call only adds to `done`.
#[serde(default)]
pub part_of_batch: bool,
/// Encode display-referred SDR (Rec. 709, tone mapped) even when the sequence works in
/// Rec. 2100 PQ/HLG. Otherwise H.264 and ProRes exports of an HDR sequence are encoded in the
/// sequence's HDR space and signal it (VUI / `colr` / `mdcv` / `clli` / SEI).
#[serde(default)]
pub sdr: bool,
/// Colour signalling chosen by [`export`] for the encoders (not set by callers).
#[serde(skip)]
pub signal: ColorSignal,
}
/// Colour description of the encoded stream (ITU-T H.273 code points).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ColorSignal {
pub primaries: u8,
pub transfer: u8,
pub matrix: u8,
}
impl Default for ColorSignal {
fn default() -> Self {
ColorSignal { primaries: 1, transfer: 1, matrix: 1 }
}
}
impl ColorSignal {
pub const PQ: ColorSignal = ColorSignal { primaries: 9, transfer: 16, matrix: 9 };
pub const HLG: ColorSignal = ColorSignal { primaries: 9, transfer: 18, matrix: 9 };
pub fn is_hdr(&self) -> bool {
matches!(self.transfer, 16 | 18)
}
/// (Kr, Kb) of the matrix.
pub fn kr_kb(&self) -> (f32, f32) {
if self.matrix == 9 { (0.2627, 0.0593) } else { (0.2126, 0.0722) }
}
/// Sample-entry boxes: `colr` (nclx for MP4, nclc for MOV) plus `mdcv`/`clli` for PQ
/// (mastering display BT.2020/D65, 1000/0.0001 cd/m²; MaxCLL/MaxFALL 0 = unknown).
pub fn apply_to(&self, e: &mut SampleEntry, mov: bool) {
if !self.is_hdr() {
return;
}
if let Some(v) = e.video.as_mut() {
let (p, t, m) = (self.primaries as u16, self.transfer as u16, self.matrix as u16);
v.color = Some(if mov {
filmcraft_isobmff::ColorInfo::Nclc { primaries: p, transfer: t, matrix: m }
} else {
filmcraft_isobmff::ColorInfo::Nclx { primaries: p, transfer: t, matrix: m, full_range: false }
});
if self.transfer == 16 {
v.mastering_display = Some(filmcraft_isobmff::MasteringDisplay::bt2020(1000.0, 0.0001));
v.content_light = Some((0, 0));
}
}
}
}
impl Default for ExportSettings {
@@ -115,6 +168,8 @@ impl Default for ExportSettings {
bitrate_kbps: 20_000,
burn_captions: false,
part_of_batch: false,
sdr: false,
signal: ColorSignal::default(),
}
}
}
@@ -159,11 +214,13 @@ pub struct EncodedPacket {
pub composition_offset: i32,
}
/// Input picture for encoders: straight sRGB RGBA8 (encoders convert to their own YUV).
/// Input picture for encoders: straight sRGB RGBA8 (encoders convert to their own YUV), or for
/// HDR exports the encoded R'G'B' (PQ/HLG, BT.2020; 3 floats per pixel, 0..1) in `hdr`.
pub struct EncoderFrame<'a> {
pub width: u32,
pub height: u32,
pub rgba: &'a [u8],
pub hdr: Option<&'a [f32]>,
pub index: u64,
}
@@ -285,18 +342,27 @@ struct ProResEncoder {
w: u32,
h: u32,
rate: FrameRate,
signal: ColorSignal,
}
impl VideoEncoder for ProResEncoder {
fn sample_entry(&self) -> SampleEntry {
SampleEntry::prores(filmcraft_isobmff::FourCc(*b"apch"), self.w as u16, self.h as u16)
let mut e = SampleEntry::prores(filmcraft_isobmff::FourCc(*b"apch"), self.w as u16, self.h as u16);
self.signal.apply_to(&mut e, true);
e
}
fn timescale(&self) -> u32 {
self.rate.num as u32
}
fn encode(&mut self, f: &EncoderFrame) -> Result<Vec<EncodedPacket>> {
let mut fr = filmcraft_prores::Frame::new(f.width, f.height, filmcraft_prores::ChromaFormat::Yuv422, 10, false);
rgba_to_yuv422_10(f.rgba, f.width as usize, f.height as usize, &mut fr.y, &mut fr.cb, &mut fr.cr);
match f.hdr {
Some(rgb) => {
let (kr, kb) = self.signal.kr_kb();
rgbf_to_yuv422_10(rgb, f.width as usize, f.height as usize, kr, kb, &mut fr.y, &mut fr.cb, &mut fr.cr)
}
None => rgba_to_yuv422_10(f.rgba, f.width as usize, f.height as usize, &mut fr.y, &mut fr.cb, &mut fr.cr),
}
let data = self.enc.encode(&fr).map_err(|e| ExportError::Encode(e.to_string()))?;
Ok(vec![EncodedPacket { data, key: true, duration: self.rate.den as u32, composition_offset: 0 }])
}
@@ -331,9 +397,41 @@ pub fn rgba_to_yuv422_10(rgba: &[u8], w: usize, h: usize, y: &mut [u16], cb: &mu
});
}
fn prores_factory(format: Format, w: u32, h: u32, rate: FrameRate, _s: &ExportSettings) -> Option<Result<Box<dyn VideoEncoder>>> {
(format == Format::ProRes)
.then(|| Ok(Box::new(ProResEncoder { enc: filmcraft_prores::Encoder::new(filmcraft_prores::Profile::Hq, w, h), w, h, rate }) as Box<dyn VideoEncoder>))
/// Limited-range 10-bit 4:2:2 from encoded R'G'B' floats with matrix (Kr, Kb).
#[allow(clippy::too_many_arguments)]
pub fn rgbf_to_yuv422_10(rgb: &[f32], w: usize, h: usize, kr: f32, kb: f32, y: &mut [u16], cb: &mut [u16], cr: &mut [u16]) {
let cw = w.div_ceil(2);
debug_assert!(rgb.len() >= w * h * 3 && y.len() >= w * h);
let kg = 1.0 - kr - kb;
let (sb, sr) = (2.0 * (1.0 - kb), 2.0 * (1.0 - kr));
y.par_chunks_mut(w).zip(cb.par_chunks_mut(cw).zip(cr.par_chunks_mut(cw))).enumerate().for_each(|(row, (yr, (cbr, crr)))| {
let src = &rgb[row * w * 3..(row + 1) * w * 3];
let mut us = vec![0f32; w];
let mut vs = vec![0f32; w];
for x in 0..w {
let (r, g, b) = (src[x * 3], src[x * 3 + 1], src[x * 3 + 2]);
let yy = kr * r + kg * g + kb * b;
yr[x] = (64.0 + 876.0 * yy).round().clamp(4.0, 1019.0) as u16;
us[x] = (b - yy) / sb;
vs[x] = (r - yy) / sr;
}
for cx in 0..cw {
let a = cx * 2;
let b2 = (a + 1).min(w - 1);
cbr[cx] = (512.0 + 896.0 * (us[a] + us[b2]) * 0.5).round().clamp(4.0, 1019.0) as u16;
crr[cx] = (512.0 + 896.0 * (vs[a] + vs[b2]) * 0.5).round().clamp(4.0, 1019.0) as u16;
}
});
}
fn prores_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option<Result<Box<dyn VideoEncoder>>> {
(format == Format::ProRes).then(|| {
let mut cfg = filmcraft_prores::EncoderConfig::new(filmcraft_prores::Profile::Hq, w, h);
if s.signal.is_hdr() {
cfg.color = filmcraft_prores::ColorInfo { primaries: s.signal.primaries, transfer: s.signal.transfer, matrix: s.signal.matrix };
}
Ok(Box::new(ProResEncoder { enc: filmcraft_prores::Encoder::with_config(cfg), w, h, rate, signal: s.signal }) as Box<dyn VideoEncoder>)
})
}
/// H.264 High (our encoder): sRGB/709 RGBA8 → 8-bit limited-range BT.709 4:2:0, VBR at the
@@ -346,6 +444,7 @@ struct H264Encoder {
y: Vec<u8>,
u: Vec<u8>,
v: Vec<u8>,
signal: ColorSignal,
}
impl H264Encoder {
@@ -362,13 +461,21 @@ impl VideoEncoder for H264Encoder {
let (sps, pps) = self.enc.sps_pps();
filmcraft_isobmff::AvcConfig::new(vec![sps], vec![pps], 4)
});
SampleEntry::avc(cfg, self.w as u16, self.h as u16)
let mut e = SampleEntry::avc(cfg, self.w as u16, self.h as u16);
self.signal.apply_to(&mut e, false);
e
}
fn timescale(&self) -> u32 {
self.rate.num as u32
}
fn encode(&mut self, f: &EncoderFrame) -> Result<Vec<EncodedPacket>> {
rgba_to_yuv420_8(f.rgba, f.width as usize, f.height as usize, &mut self.y, &mut self.u, &mut self.v);
match f.hdr {
Some(rgb) => {
let (kr, kb) = self.signal.kr_kb();
rgbf_to_yuv420_8(rgb, f.width as usize, f.height as usize, kr, kb, &mut self.y, &mut self.u, &mut self.v)
}
None => rgba_to_yuv420_8(f.rgba, f.width as usize, f.height as usize, &mut self.y, &mut self.u, &mut self.v),
}
let cw = (f.width as usize).div_ceil(2);
let frame = filmcraft_h264enc::YuvFrame { y: &self.y, u: &self.u, v: &self.v, y_stride: f.width as usize, uv_stride: cw };
let ps = self.enc.try_encode(&frame, f.index as i64 * self.rate.den).map_err(|e| ExportError::Encode(e.to_string()))?;
@@ -414,6 +521,39 @@ pub fn rgba_to_yuv420_8(rgba: &[u8], w: usize, h: usize, y: &mut Vec<u8>, u: &mu
});
}
/// Limited-range 8-bit 4:2:0 from encoded R'G'B' floats with matrix (Kr, Kb).
#[allow(clippy::too_many_arguments)]
pub fn rgbf_to_yuv420_8(rgb: &[f32], w: usize, h: usize, kr: f32, kb: f32, y: &mut Vec<u8>, u: &mut Vec<u8>, v: &mut Vec<u8>) {
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let kg = 1.0 - kr - kb;
let (sb, sr) = (2.0 * (1.0 - kb), 2.0 * (1.0 - kr));
y.resize(w * h, 0);
u.resize(cw * ch, 0);
v.resize(cw * ch, 0);
y.par_chunks_mut(w * 2).zip(u.par_chunks_mut(cw).zip(v.par_chunks_mut(cw))).enumerate().for_each(|(cy, (yr, (ur, vr)))| {
let rows = yr.len() / w;
let mut us = vec![0f32; cw];
let mut vs = vec![0f32; cw];
let mut cnt = vec![0f32; cw];
for dy in 0..rows {
let row = cy * 2 + dy;
let src = &rgb[row * w * 3..(row + 1) * w * 3];
for x in 0..w {
let (r, g, b) = (src[x * 3], src[x * 3 + 1], src[x * 3 + 2]);
let yy = kr * r + kg * g + kb * b;
yr[dy * w + x] = (16.0 + 219.0 * yy).round().clamp(1.0, 254.0) as u8;
us[x / 2] += (b - yy) / sb;
vs[x / 2] += (r - yy) / sr;
cnt[x / 2] += 1.0;
}
}
for cx in 0..cw {
ur[cx] = (128.0 + 224.0 * us[cx] / cnt[cx]).round().clamp(1.0, 254.0) as u8;
vr[cx] = (128.0 + 224.0 * vs[cx] / cnt[cx]).round().clamp(1.0, 254.0) as u8;
}
});
}
fn h264_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option<Result<Box<dyn VideoEncoder>>> {
if format != Format::H264 {
return None;
@@ -424,9 +564,19 @@ fn h264_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSetti
cfg.keyint = (rate.num as f64 / rate.den as f64 * 2.0).round().max(1.0) as u32;
let kbps = s.bitrate_kbps.max(100);
cfg.rate = filmcraft_h264enc::RateControl::Vbr { target_kbps: kbps, max_kbps: kbps * 3 / 2 };
if s.signal.is_hdr() {
cfg.color = filmcraft_h264enc::ColorConfig { primaries: s.signal.primaries, transfer: s.signal.transfer, matrix: s.signal.matrix, full_range: false };
if s.signal.transfer == 16 {
let md = filmcraft_isobmff::MasteringDisplay::bt2020(1000.0, 0.0001).to_bytes();
let mut b = [0u8; 24];
b.copy_from_slice(&md);
cfg.mastering_display = Some(b);
cfg.content_light = Some((0, 0));
}
}
Some(
filmcraft_h264enc::Encoder::new(cfg)
.map(|enc| Box::new(H264Encoder { enc, w, h, rate, y: Vec::new(), u: Vec::new(), v: Vec::new() }) as Box<dyn VideoEncoder>)
.map(|enc| Box::new(H264Encoder { enc, w, h, rate, y: Vec::new(), u: Vec::new(), v: Vec::new(), signal: s.signal }) as Box<dyn VideoEncoder>)
.map_err(|e| ExportError::Encode(e.to_string())),
)
}
@@ -447,6 +597,17 @@ pub fn export_range(project: &Project, seq: ItemId, settings: &ExportSettings) -
pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, sources: &dyn SourceProvider, progress: &Progress) -> Result<Report> {
let t0 = std::time::Instant::now();
let q = project.sequence(seq).ok_or(ExportError::NoSequence)?;
// HDR sequences export HDR (H.264 / ProRes) unless SDR is asked for
let pipe = q.settings.color;
let hdr_out = pipe.working.is_hdr() && !settings.sdr && matches!(settings.format, Format::H264 | Format::ProRes);
let mut settings = settings.clone();
settings.signal = match (hdr_out, pipe.working) {
(true, filmcraft_color::WorkingSpace::Rec2100Pq) => ColorSignal::PQ,
(true, _) => ColorSignal::HLG,
_ => ColorSignal::default(),
};
let settings = &settings;
let out_tf = hdr_out.then(|| filmcraft_color::OutputTransform::new(&pipe, pipe.working.output_space()));
let rate = q.settings.frame_rate;
let range = export_range(project, seq, settings)?;
let f0 = rate.frame_at(range.start);
@@ -458,7 +619,26 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
progress.total.store(if settings.format == Format::Wav { 1 } else { nframes }, Ordering::Relaxed);
progress.set_status(format!("Exporting {} frames ({})", nframes, settings.format.label()));
}
let opts = RenderOptions { scale: w as f32 / q.settings.width as f32, captions: settings.burn_captions, ..Default::default() };
let opts = RenderOptions { scale: w as f32 / q.settings.width as f32, captions: settings.burn_captions, working_output: hdr_out, ..Default::default() };
// HDR: encoded R'G'B' floats over black, padded/cropped to the even output size
let render_hdr = |f: i64| -> Vec<f32> {
let img = filmcraft_render::render_sequence(project, seq, rate.tick_of(f), opts, sources);
let tf = out_tf.as_ref().expect("hdr transform");
let mut out = vec![0f32; (w * h * 3) as usize];
let black = tf.encode([0.0; 3]);
for (y, row) in out.chunks_exact_mut(w as usize * 3).enumerate() {
for (x, o) in row.chunks_exact_mut(3).enumerate() {
let c = if x < img.w && y < img.h {
let i = (y * img.w + x) * 4;
tf.encode([img.px[i], img.px[i + 1], img.px[i + 2]])
} else {
black
};
o.copy_from_slice(&c);
}
}
out
};
let render = |f: i64| -> Vec<u8> {
let img = filmcraft_render::render_sequence(project, seq, rate.tick_of(f), opts, sources);
let mut rgba = img.over_black_rgba8();
@@ -544,9 +724,10 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
// Encode the first batch before creating tracks (encoders may finalise codec config then).
let mut pending: Vec<EncodedPacket> = Vec::new();
let first_end = (f0 + batch).min(f1);
let first: Vec<Vec<u8>> = (f0..first_end).into_par_iter().map(render).collect();
for (k, rgba) in first.iter().enumerate() {
pending.extend(venc.encode(&EncoderFrame { width: w, height: h, rgba, index: k as u64 })?);
let frame = |fi: i64| -> (Vec<u8>, Vec<f32>) { if hdr_out { (Vec::new(), render_hdr(fi)) } else { (render(fi), Vec::new()) } };
let first: Vec<(Vec<u8>, Vec<f32>)> = (f0..first_end).into_par_iter().map(frame).collect();
for (k, (rgba, hdr)) in first.iter().enumerate() {
pending.extend(venc.encode(&EncoderFrame { width: w, height: h, rgba, hdr: hdr_out.then_some(hdr.as_slice()), index: k as u64 })?);
}
progress.done.fetch_add((first_end - f0) as u64, Ordering::Relaxed);
let mut mux = Mp4Writer::new(std::io::BufWriter::new(file), WriterOptions::new(brand)).map_err(|e| ExportError::Io(e.to_string()))?;
@@ -614,9 +795,11 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
return Err(ExportError::Cancelled);
}
let end = (f + batch).min(f1);
let frames: Vec<Vec<u8>> = (f..end).into_par_iter().map(render).collect();
for (k, rgba) in frames.iter().enumerate() {
for p in venc.encode(&EncoderFrame { width: w, height: h, rgba, index: (f - f0) as u64 + k as u64 })? {
let frames: Vec<(Vec<u8>, Vec<f32>)> = (f..end).into_par_iter().map(frame).collect();
for (k, (rgba, hdr)) in frames.iter().enumerate() {
for p in
venc.encode(&EncoderFrame { width: w, height: h, rgba, hdr: hdr_out.then_some(hdr.as_slice()), index: (f - f0) as u64 + k as u64 })?
{
mux.write_sample(vt, WriteSample { data: &p.data, duration: p.duration, composition_offset: p.composition_offset, is_sync: p.key })
.map_err(|e| ExportError::Io(e.to_string()))?;
}
+59
View File
@@ -175,3 +175,62 @@ fn caption_burn_in_h264_ffmpeg_oracle() {
let (left, right) = (bright(&with, h * 2 / 3, h, 0, w / 2), bright(&with, h * 2 / 3, h, w / 2, w));
assert!(left > 100 && right > 100, "{left} / {right}");
}
/// HDR sequences export PQ / HLG with colour signalling in the bitstream (VUI, ProRes header) and
/// the container (`colr`, `mdcv`, `clli`); our demuxer and ffprobe both read it back, and the
/// picture survives the round trip.
#[test]
fn hdr_exports_signal_pq_and_hlg() {
use filmcraft_color::{ColorPipeline, Transfer, WorkingSpace};
for (ws, fmt, ext, transfer, ff_transfer) in [
(WorkingSpace::Rec2100Pq, Format::ProRes, "mov", Transfer::Pq, "smpte2084"),
(WorkingSpace::Rec2100Pq, Format::H264, "mp4", Transfer::Pq, "smpte2084"),
(WorkingSpace::Rec2100Hlg, Format::ProRes, "mov", Transfer::Hlg, "arib-std-b67"),
(WorkingSpace::Rec2100Hlg, Format::H264, "mp4", Transfer::Hlg, "arib-std-b67"),
] {
let (p, seq, m) = project();
let mut p = (*p).clone();
p.sequence_mut(seq).unwrap().settings.color = ColorPipeline { working: ws, ..ColorPipeline::REC709 };
let p = Arc::new(p);
let path = tmp(&format!("hdr-{}.{ext}", ws.id()));
let s = ExportSettings { format: fmt, path: path.clone(), include_audio: false, ..Default::default() };
export(&p, seq, &s, &m, &Progress::default()).unwrap();
// our demuxer/decoder sees the signalling
let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into();
let src = filmcraft_codecs::open_bytes(&path, bytes).unwrap();
let v = src.info().video.clone().unwrap();
assert_eq!(v.color.transfer, transfer, "{path}");
assert_eq!(v.color.primaries, filmcraft_color::Primaries::Bt2020, "{path}");
assert_eq!(v.color.matrix, filmcraft_color::Matrix::Bt2020Ncl, "{path}");
// 709 red matte → BT.2020 HDR → decoded back into Rec. 709 (tone mapped) stays red
let f = src.video_frame(FrameRequest::full(Tick::ZERO)).unwrap();
let back = filmcraft_render::colorman::decode(&Project::new("x"), ItemId(0), &f, 1, &ColorPipeline::REC709);
let c = filmcraft_render::Image::unpremul(back.get(160, 90));
assert!(c[0] > 0.6 && c[1] < 0.05 && c[2] < 0.05, "{path}: {c:?}");
// ffprobe agrees
let Some(ffprobe) = filmcraft_testkit::ffprobe_or_skip("hdr export signalling") else { continue };
let out = std::process::Command::new(&ffprobe)
.args([
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=color_transfer,color_primaries,color_space:stream_side_data",
"-of",
"default=nw=1",
&path,
])
.output()
.unwrap();
let txt = String::from_utf8_lossy(&out.stdout).to_string();
assert!(txt.contains(&format!("color_transfer={ff_transfer}")), "{path}: {txt}");
assert!(txt.contains("color_primaries=bt2020"), "{path}: {txt}");
assert!(txt.contains("color_space=bt2020nc"), "{path}: {txt}");
if transfer == Transfer::Pq {
assert!(txt.contains("Mastering display metadata") && txt.contains("max_luminance=10000000/10000"), "{path}: {txt}");
assert!(txt.contains("Content light level metadata"), "{path}: {txt}");
}
eprintln!("{path}:\n{txt}");
}
}
+21 -4
View File
@@ -7,7 +7,7 @@
use std::sync::Arc;
use filmcraft_color::{ColorInfo, Matrix, Range, linear_to_srgb_u8, normalize_c, normalize_y, srgb_u8_to_linear_table, to_linear, ycbcr_to_rgb};
use filmcraft_color::{ColorInfo, DecodeTable, Matrix, Range, linear_to_srgb_u8, normalize_c, normalize_y, srgb_u8_to_linear_table, to_linear, ycbcr_to_rgb};
use filmcraft_time::Tick;
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
@@ -107,6 +107,13 @@ impl VideoFrame {
/// in linear light. Reduced-resolution playback uses this so it never builds full-size float
/// buffers. Returns (width, height, pixels).
pub fn to_linear_f32_decimated(&self, n: usize) -> (usize, usize, Vec<f32>) {
self.to_linear_f32_decimated_with(n, None)
}
/// Like [`VideoFrame::to_linear_f32_decimated`], decoding each channel's signal through
/// `decode` (a colour-managed curve: log, PQ, HLG scene light…) instead of the frame's
/// transfer. Float frames are already linear and ignore it.
pub fn to_linear_f32_decimated_with(&self, n: usize, decode: Option<&DecodeTable>) -> (usize, usize, Vec<f32>) {
let n = n.max(1);
let (w, h) = (self.width as usize, self.height as usize);
let (ow, oh) = ((w / n).max(1), (h / n).max(1));
@@ -119,8 +126,11 @@ impl VideoFrame {
}
// Encoded (0..1, quantised to 12 bits) → linear lookup for this frame's transfer.
let info = self.color;
let lin: Vec<f32> = (0..4096).map(|i| to_linear(i as f32 / 4095.0, info.transfer)).collect();
let q = |v: f32| lin[(v.clamp(0.0, 1.0) * 4095.0 + 0.5) as usize];
let lin: Vec<f32> = if decode.is_some() { Vec::new() } else { (0..4096).map(|i| to_linear(i as f32 / 4095.0, info.transfer)).collect() };
let q = |v: f32| match decode {
Some(t) => t.lookup(v),
None => lin[(v.clamp(0.0, 1.0) * 4095.0 + 0.5) as usize],
};
let inv = 1.0 / (n * n) as f32;
match &self.data {
PixelData::RgbaF32(d) => {
@@ -144,7 +154,14 @@ impl VideoFrame {
});
}
PixelData::Rgba8(d) => {
let lut = srgb_u8_to_linear_table();
let table: Vec<f32>;
let lut: &[f32] = match decode {
Some(t) => {
table = (0..256).map(|i| t.lookup(i as f32 / 255.0)).collect();
&table
}
None => srgb_u8_to_linear_table(),
};
out.par_chunks_mut(ow * 4).enumerate().for_each(|(oy, row)| {
for ox in 0..ow {
let mut acc = [0f32; 4];
+2
View File
@@ -16,6 +16,8 @@ workspace = true
[dev-dependencies]
filmcraft-testkit = { workspace = true }
filmcraft-color = { workspace = true }
filmcraft-frame = { workspace = true }
filmcraft-time = { workspace = true }
filmcraft-geom = { workspace = true }
filmcraft-media = { workspace = true }
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

@@ -0,0 +1,8 @@
asset: hdr_tone_map.png
title: Colour management: Rec. 2100 PQ footage tone mapped into a Rec. 709 sequence
author: FilmCraft contributors
source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-01 by FilmCraft contributors (FILMCRAFT_BLESS=1)
notes: Golden reference image generated by crates/golden/tests/golden.rs (hdr_tone_map). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork.
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -0,0 +1,8 @@
asset: log_to_rec709.png
title: Colour management: S-Log3/S-Gamut3.Cine footage to Rec. 709 (tone mapped)
author: FilmCraft contributors
source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-01 by FilmCraft contributors (FILMCRAFT_BLESS=1)
notes: Golden reference image generated by crates/golden/tests/golden.rs (log_to_rec709). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork.
+77 -1
View File
@@ -10,6 +10,7 @@
use std::path::PathBuf;
use std::sync::Arc;
use filmcraft_color::{ColorInfo, ColorSpace, Gamut, Primaries, Transfer};
use filmcraft_geom::Vec2;
use filmcraft_media::generators::GeneratorSource;
use filmcraft_media::{DemoScene, Generator, MediaSource, SharedSource};
@@ -74,6 +75,18 @@ impl Builder {
self.media(Generator::Demo(scene))
}
/// Demo footage re-encoded as camera log / HDR (see [`Encoded`]), with an optional Interpret
/// Footage colour-space override.
fn encoded(&mut self, scene: DemoScene, target: ColorSpace, gain: f32, flag: Option<ColorInfo>, interpret: Option<ColorSpace>) -> ItemId {
let id = self.demo(scene);
let inner = self.map.0.remove(&id).unwrap();
self.map.0.insert(id, Arc::new(Encoded { inner, target, gain, flag }) as SharedSource);
if let ItemKind::Media(m) = &mut self.p.item_mut(id).unwrap().kind {
m.interpret.color_space = interpret;
}
id
}
/// Place `item` on video track `track` (frames), scaled to the frame size.
fn place(&mut self, track: usize, item: ItemId, start: i64, dur: i64) -> ClipId {
let mut ti = self.p.make_track_item(item, TrackKind::Video, RATE.tick_of(start), TimeRange::new(Tick::ZERO, RATE.tick_of(dur)), RATE).unwrap();
@@ -143,6 +156,46 @@ fn blend(name: &str) -> ParamValue {
ParamValue::Choice(filmcraft_project::effect::BLEND_MODES.iter().position(|b| *b == name).unwrap_or_else(|| panic!("blend {name}")) as u32)
}
/// A source that re-encodes another source's (sRGB) frames into a camera log or HDR colour space:
/// linear BT.709 × `gain` → `target` gamut → `target` curve, stored as full-range RGBA8. `flag`
/// replaces the frame's colour metadata (e.g. PQ/BT.2020, so auto-detection kicks in).
struct Encoded {
inner: SharedSource,
target: ColorSpace,
gain: f32,
flag: Option<ColorInfo>,
}
impl MediaSource for Encoded {
fn info(&self) -> &filmcraft_media::MediaInfo {
self.inner.info()
}
fn video_frame(&self, req: filmcraft_media::FrameRequest) -> filmcraft_media::Result<Arc<filmcraft_frame::VideoFrame>> {
let f = self.inner.video_frame(req)?;
let rgba = f.to_rgba8();
let m = filmcraft_color::spaces::to_f32(&filmcraft_color::spaces::gamut_matrix(Gamut::Bt709, self.target.gamut()));
let curve = self.target.curve();
let out: Vec<u8> = rgba
.chunks_exact(4)
.flat_map(|p| {
let lin = [0, 1, 2].map(|k| filmcraft_color::srgb_to_linear(p[k] as f32 / 255.0) * self.gain);
let c = filmcraft_color::spaces::apply3(&m, lin);
let e = c
.map(|v| (filmcraft_color::transform::encode_channel(v as f64, curve, filmcraft_color::Range::Full).clamp(0.0, 1.0) * 255.0).round() as u8);
[e[0], e[1], e[2], p[3]]
})
.collect();
let mut nf = filmcraft_frame::VideoFrame::rgba8(f.width, f.height, out);
if let Some(c) = self.flag {
nf.color = c;
}
Ok(Arc::new(nf))
}
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<filmcraft_frame::AudioBuffer> {
self.inner.audio(start, frames, sample_rate)
}
}
// ---- scenes ----
/// Motion (scale, rotation, position) and 70 % opacity over a full-frame background.
@@ -327,6 +380,25 @@ fn graphic_shapes() -> Scene {
graphic_scene(DemoScene::Aurora, vec![ell, poly, path, txt])
}
/// Camera log → Rec. 709: Ocean Sunset encoded as S-Log3 / S-Gamut3.Cine one stop over, then
/// interpreted as such in a Rec. 709 sequence (decode, gamut compression, BT.2390 tone mapping).
fn log_to_rec709() -> Scene {
let mut b = Builder::new(1);
let src = b.encoded(DemoScene::OceanSunset, ColorSpace::SLog3SGamut3Cine, 2.0, None, Some(ColorSpace::SLog3SGamut3Cine));
b.place(0, src, 0, 48);
b.at(12)
}
/// HDR → SDR: City Night as Rec. 2100 PQ with highlights up to ~1200 cd/m² (×6 over reference
/// white), detected from the frame's metadata and tone mapped into a Rec. 709 sequence.
fn hdr_tone_map() -> Scene {
let mut b = Builder::new(1);
let pq = ColorInfo { transfer: Transfer::Pq, primaries: Primaries::Bt2020, ..ColorInfo::SRGB_FULL };
let src = b.encoded(DemoScene::CityNight, ColorSpace::Rec2100Pq, 6.0, Some(pq), None);
b.place(0, src, 0, 48);
b.at(12)
}
/// (name, title, scene).
fn scenes() -> Vec<(&'static str, &'static str, fn() -> Scene)> {
vec![
@@ -341,6 +413,8 @@ fn scenes() -> Vec<(&'static str, &'static str, fn() -> Scene)> {
("text_burnin", "Timecode and Clip Name burn-in text", text_burnin),
("graphic_title", "Graphic clip: title text with stroke and shadow, lower-third bar", graphic_title),
("graphic_shapes", "Graphic clip: ellipse, polygon, path and rotated text with background", graphic_shapes),
("log_to_rec709", "Colour management: S-Log3/S-Gamut3.Cine footage to Rec. 709 (tone mapped)", log_to_rec709),
("hdr_tone_map", "Colour management: Rec. 2100 PQ footage tone mapped into a Rec. 709 sequence", hdr_tone_map),
]
}
@@ -393,7 +467,9 @@ goldens!(
transition_wipe,
text_burnin,
graphic_title,
graphic_shapes
graphic_shapes,
log_to_rec709,
hdr_tone_map
);
/// Sanity: the scenes are not trivially empty or identical to each other.
+10
View File
@@ -156,6 +156,11 @@ pub struct EncoderConfig {
pub slices: usize,
/// Access unit delimiters in Annex-B output.
pub aud: bool,
/// HDR static metadata sent in an SEI with the first access unit: the 24-byte SMPTE ST 2086
/// mastering display payload (payloadType 137) …
pub mastering_display: Option<[u8; 24]>,
/// … and (MaxCLL, MaxFALL) content light level (payloadType 144).
pub content_light: Option<(u16, u16)>,
}
impl EncoderConfig {
@@ -178,6 +183,8 @@ impl EncoderConfig {
sar: (1, 1),
scenecut: true,
slices: 0,
mastering_display: None,
content_light: None,
aud: true,
}
}
@@ -818,6 +825,9 @@ impl Encoder {
self.sei_sent = true;
let text = format!("filmcraft-h264enc {} - clean-room H.264 encoder", env!("CARGO_PKG_VERSION"));
nals.push(nal::nal(0, NAL_SEI, &nal::sei_user_data_rbsp(&text)));
if self.cfg.mastering_display.is_some() || self.cfg.content_light.is_some() {
nals.push(nal::nal(0, NAL_SEI, &nal::sei_hdr_rbsp(self.cfg.mastering_display.as_ref(), self.cfg.content_light)));
}
}
for r in results {
nals.push(r.nal);
+19
View File
@@ -181,6 +181,25 @@ pub fn sei_user_data_rbsp(text: &str) -> Vec<u8> {
w.finish()
}
/// SEI with HDR static metadata: mastering_display_colour_volume (payloadType 137, the 24-byte
/// ST 2086 payload) and/or content_light_level_info (payloadType 144), H.264 Annex D.
pub fn sei_hdr_rbsp(mastering: Option<&[u8; 24]>, cll: Option<(u16, u16)>) -> Vec<u8> {
let mut w = BitWriter::new();
if let Some(m) = mastering {
w.write_bits(137, 8);
w.write_bits(24, 8);
w.write_bytes(m);
}
if let Some((a, b)) = cll {
w.write_bits(144, 8);
w.write_bits(4, 8);
w.write_bits(a as u32, 16);
w.write_bits(b as u32, 16);
}
w.rbsp_trailing();
w.finish()
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SliceType {
P = 0,
+69
View File
@@ -41,6 +41,62 @@ pub struct VideoParams {
pub field_info: Option<FieldInfo>,
/// QuickTime `gama` (16.16 fixed point).
pub gamma: Option<u32>,
/// `mdcv`: mastering display colour volume (SMPTE ST 2086).
pub mastering_display: Option<MasteringDisplay>,
/// `clli`: content light level (MaxCLL, MaxFALL in cd/m²; 0 = unknown).
pub content_light: Option<(u16, u16)>,
}
/// SMPTE ST 2086 mastering display metadata, in the units of the `mdcv` box and the H.264/HEVC
/// SEI: chromaticities in 0.00002 steps (order G, B, R as the SEI), luminance in 0.0001 cd/m².
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MasteringDisplay {
pub primaries: [(u16, u16); 3],
pub white_point: (u16, u16),
pub max_luminance: u32,
pub min_luminance: u32,
}
impl MasteringDisplay {
/// BT.2020 primaries, D65, with the given peak and black in cd/m².
pub fn bt2020(max_nits: f64, min_nits: f64) -> Self {
let q = |v: f64| (v / 0.00002).round() as u16;
MasteringDisplay {
primaries: [(q(0.170), q(0.797)), (q(0.131), q(0.046)), (q(0.708), q(0.292))],
white_point: (q(0.3127), q(0.3290)),
max_luminance: (max_nits * 10_000.0).round() as u32,
min_luminance: (min_nits * 10_000.0).round() as u32,
}
}
pub fn max_nits(&self) -> f64 {
self.max_luminance as f64 / 10_000.0
}
/// The 24-byte payload shared by `mdcv` and the SEI message.
pub fn to_bytes(&self) -> Vec<u8> {
let mut v = Vec::with_capacity(24);
for (x, y) in self.primaries {
v.extend_from_slice(&x.to_be_bytes());
v.extend_from_slice(&y.to_be_bytes());
}
v.extend_from_slice(&self.white_point.0.to_be_bytes());
v.extend_from_slice(&self.white_point.1.to_be_bytes());
v.extend_from_slice(&self.max_luminance.to_be_bytes());
v.extend_from_slice(&self.min_luminance.to_be_bytes());
v
}
pub fn parse(p: &[u8]) -> Option<Self> {
if p.len() < 24 {
return None;
}
let u16_at = |i: usize| u16::from_be_bytes([p[i], p[i + 1]]);
let u32_at = |i: usize| u32::from_be_bytes([p[i], p[i + 1], p[i + 2], p[i + 3]]);
Some(MasteringDisplay {
primaries: [(u16_at(0), u16_at(2)), (u16_at(4), u16_at(6)), (u16_at(8), u16_at(10))],
white_point: (u16_at(12), u16_at(14)),
max_luminance: u32_at(16),
min_luminance: u32_at(20),
})
}
}
/// `colr` box contents.
@@ -779,6 +835,8 @@ pub(crate) fn parse_sample_entry(format: FourCc, payload: &[u8], handler: FourCc
b"av1C" if f == b"av01" => codec = Some(CodecConfig::Av1(Av1Config::parse(p)?)),
b"vpcC" if f == b"vp09" => codec = Some(CodecConfig::Vp9(VpcConfig::parse(p)?)),
b"colr" => v.color = parse_colr(p).ok(),
b"mdcv" => v.mastering_display = MasteringDisplay::parse(p),
b"clli" if p.len() >= 4 => v.content_light = Some((u16::from_be_bytes([p[0], p[1]]), u16::from_be_bytes([p[2], p[3]]))),
b"pasp" => {
let mut c = Cur::new(p, "pasp");
v.pixel_aspect = Some((c.u32()?, c.u32()?));
@@ -1216,6 +1274,14 @@ impl SampleEntry {
}
b.end(m);
}
if let Some(md) = &v.mastering_display {
b.leaf(b"mdcv", &md.to_bytes());
}
if let Some((cll, fall)) = v.content_light {
let mut d = cll.to_be_bytes().to_vec();
d.extend_from_slice(&fall.to_be_bytes());
b.leaf(b"clli", &d);
}
if let Some((h, vv)) = v.pixel_aspect {
let m = b.start(b"pasp");
b.u32(h);
@@ -1373,8 +1439,11 @@ mod tests {
e.video.as_mut().unwrap().pixel_aspect = Some((4, 3));
e.video.as_mut().unwrap().field_info = Some(FieldInfo { fields: 2, detail: 9 });
e.video.as_mut().unwrap().clean_aperture = Some(CleanAperture { width: (1920, 1), height: (1080, 1), horiz_offset: (-2, 1), vert_offset: (0, 1) });
e.video.as_mut().unwrap().mastering_display = Some(MasteringDisplay::bt2020(1000.0, 0.0001));
e.video.as_mut().unwrap().content_light = Some((1000, 400));
e.bitrate = Some(BitRate { buffer_size: 1, max_bitrate: 2, avg_bitrate: 3 });
assert_eq!(roundtrip(&e, false, b"vide"), e);
assert_eq!(MasteringDisplay::bt2020(1000.0, 0.0001).max_nits(), 1000.0);
}
#[test]
+2 -2
View File
@@ -17,8 +17,8 @@ mod source;
pub use bytes::FourCc;
pub use codec::{
AacConfig, AudioParams, Av1Config, AvcConfig, BitRate, CleanAperture, CodecConfig, ColorInfo, FieldInfo, FlacConfig, HevcConfig, HevcNalArray, OpusConfig,
PcmConfig, SampleEntry, TimecodeConfig, VideoParams, VpcConfig, parse_asc,
AacConfig, AudioParams, Av1Config, AvcConfig, BitRate, CleanAperture, CodecConfig, ColorInfo, FieldInfo, FlacConfig, HevcConfig, HevcNalArray,
MasteringDisplay, OpusConfig, PcmConfig, SampleEntry, TimecodeConfig, VideoParams, VpcConfig, parse_asc,
};
pub use demux::{Edit, Metadata, Mp4File, Sample, SampleGroup, Track, TrackKind, open};
pub use error::{Error, Result};
+13 -1
View File
@@ -153,6 +153,9 @@ pub struct Interpretation {
pub par: Option<(u32, u32)>,
pub ignore_alpha: bool,
pub invert_alpha: bool,
/// Color Management ▸ override the colour space detected from the file's metadata.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color_space: Option<filmcraft_color::ColorSpace>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -623,8 +626,16 @@ pub struct SequenceSettings {
pub preview_codec: String,
pub max_bit_depth: bool,
pub max_render_quality: bool,
/// Working colour space.
/// Working colour space (display label; [`SequenceSettings::color`] is authoritative).
pub working_space: String,
/// Colour pipeline: working space (Rec. 709 / Rec. 2100 PQ / HLG), wide-gamut compositing,
/// Auto Tone Map Media.
#[serde(default = "default_color_pipeline")]
pub color: filmcraft_color::ColorPipeline,
}
fn default_color_pipeline() -> filmcraft_color::ColorPipeline {
filmcraft_color::ColorPipeline::REC709
}
impl Default for SequenceSettings {
@@ -642,6 +653,7 @@ impl Default for SequenceSettings {
max_bit_depth: false,
max_render_quality: false,
working_space: "Rec. 709".into(),
color: filmcraft_color::ColorPipeline::REC709,
}
}
}
+131
View File
@@ -0,0 +1,131 @@
//! Colour management in the renderer: source colour space resolution (Interpret Footage override
//! or stream metadata), source → working conversion of decoded frames, and the working → monitor
//! transform. The maths lives in `filmcraft_color::transform`.
//!
//! A sequence whose pipeline is "plain" (Rec. 709 working space without wide gamut) and media
//! whose metadata says Rec. 709 / sRGB / linear take the original fast path unchanged.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use filmcraft_color::{ColorPipeline, ColorSpace, InputTransform, OutputTransform, Range};
use filmcraft_frame::VideoFrame;
use filmcraft_project::{ItemId, ItemKind, Project};
use crate::Image;
/// The Interpret Footage colour-space override of an item (following subclips to their media).
pub fn override_of(project: &Project, item: ItemId) -> Option<ColorSpace> {
match &project.item(item)?.kind {
ItemKind::Media(m) => m.interpret.color_space,
ItemKind::Subclip { parent, .. } => override_of(project, *parent),
_ => None,
}
}
/// The colour space a frame of `item` is interpreted in.
pub fn source_space(project: &Project, item: ItemId, frame: &VideoFrame) -> ColorSpace {
override_of(project, item).unwrap_or_else(|| ColorSpace::from_info(&frame.color))
}
/// Whether the frame needs more than the default (transfer-only) decode.
pub fn needs_management(pipe: &ColorPipeline, cs: ColorSpace, frame: &VideoFrame) -> bool {
let default_decode = cs == ColorSpace::from_info(&frame.color) && matches!(cs, ColorSpace::Rec709 | ColorSpace::Srgb | ColorSpace::Linear709);
!(pipe.is_plain() && default_decode)
}
type Key = (ColorSpace, Range, ColorPipeline);
/// A cached input transform.
pub fn input_transform(cs: ColorSpace, range: Range, pipe: &ColorPipeline) -> Arc<InputTransform> {
static C: OnceLock<Mutex<HashMap<Key, Arc<InputTransform>>>> = OnceLock::new();
let c = C.get_or_init(Default::default);
let key = (cs, range, *pipe);
if let Some(t) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
return t.clone();
}
let t = Arc::new(InputTransform::new(cs, range, pipe, None));
c.lock().unwrap_or_else(|e| e.into_inner()).insert(key, t.clone());
t
}
/// Decode a frame of `item` (box-decimated by `n`) into the working space of `pipe`.
pub fn decode(project: &Project, item: ItemId, frame: &VideoFrame, n: usize, pipe: &ColorPipeline) -> Image {
let cs = source_space(project, item, frame);
if !needs_management(pipe, cs, frame) {
let (w, h, px) = frame.to_linear_f32_decimated(n);
return Image { w, h, px };
}
// RGB stills/generators carry full-range code values; YUV carries its own range flag
let range = match frame.data {
filmcraft_frame::PixelData::Rgba8(_) | filmcraft_frame::PixelData::RgbaF32(_) => Range::Full,
_ => frame.color.range,
};
let t = input_transform(cs, range, pipe);
let float_src = matches!(frame.data, filmcraft_frame::PixelData::RgbaF32(_));
// float frames are linear already: only the gamut/tone stages apply to them
let (w, h, px) = frame.to_linear_f32_decimated_with(n, if float_src { None } else { Some(&t.table) });
let mut img = Image { w, h, px };
if t.has_pixel_stage() {
img.map_rgb(|c, _, _| t.apply(c));
}
img
}
/// Working space → monitor (SDR BT.709) for a non-plain pipeline; no-op otherwise.
pub fn to_display(img: &mut Image, pipe: &ColorPipeline) {
if pipe.is_plain() {
return;
}
let o = display_transform(pipe);
if o.is_identity() {
return;
}
img.map_rgb(|c, _, _| o.apply(c));
}
pub fn display_transform(pipe: &ColorPipeline) -> Arc<OutputTransform> {
static C: OnceLock<Mutex<HashMap<ColorPipeline, Arc<OutputTransform>>>> = OnceLock::new();
let c = C.get_or_init(Default::default);
c.lock().unwrap_or_else(|e| e.into_inner()).entry(*pipe).or_insert_with(|| Arc::new(OutputTransform::display(pipe))).clone()
}
#[cfg(test)]
mod tests {
use super::*;
use filmcraft_color::{ColorInfo, Primaries, Transfer, WorkingSpace};
fn frame(rgb: [u8; 3], color: ColorInfo) -> VideoFrame {
let mut f = VideoFrame::rgba8(2, 2, [rgb[0], rgb[1], rgb[2], 255].repeat(4));
f.color = color;
f
}
#[test]
fn plain_media_keeps_the_fast_path() {
let f = frame([200, 100, 50], ColorInfo::SRGB_FULL);
assert!(!needs_management(&ColorPipeline::REC709, ColorSpace::Srgb, &f));
let hdr = ColorPipeline { working: WorkingSpace::Rec2100Pq, ..ColorPipeline::REC709 };
assert!(needs_management(&hdr, ColorSpace::Srgb, &f));
let pq = ColorInfo { transfer: Transfer::Pq, primaries: Primaries::Bt2020, ..ColorInfo::SRGB_FULL };
assert!(needs_management(&ColorPipeline::REC709, ColorSpace::from_info(&pq), &frame([1, 2, 3], pq)));
}
#[test]
fn pq_frame_is_tone_mapped_into_rec709() {
let p = Project::new("t");
let pq = ColorInfo { transfer: Transfer::Pq, primaries: Primaries::Bt2020, ..ColorInfo::SRGB_FULL };
// ~1000 cd/m² white (PQ 0.75) → SDR peak after BT.2390; 203 cd/m² (PQ 0.58) → ≈ 0.8
let hi = decode(&p, ItemId(99), &frame([192, 192, 192], pq), 1, &ColorPipeline::REC709);
assert!((hi.px[0] - 1.0).abs() < 0.03, "{:?}", &hi.px[..4]);
let rw = decode(&p, ItemId(99), &frame([148, 148, 148], pq), 1, &ColorPipeline::REC709);
assert!((0.7..0.9).contains(&rw.px[0]), "{:?}", &rw.px[..4]);
// the same frame in a PQ sequence keeps its HDR values (working units, 1.0 = 203 cd/m²)
let hdr = ColorPipeline { working: WorkingSpace::Rec2100Pq, ..ColorPipeline::REC709 };
let w = decode(&p, ItemId(99), &frame([192, 192, 192], pq), 1, &hdr);
assert!(w.px[0] > 4.0, "{:?}", &w.px[..4]);
let mut d = w.clone();
to_display(&mut d, &hdr);
assert!((d.px[0] - 1.0).abs() < 0.03);
}
}
+12 -6
View File
@@ -10,6 +10,7 @@
pub mod audio;
pub mod audio_fx;
pub mod blend;
pub mod colorman;
pub mod effects;
pub mod graphic_clip;
pub mod graphics;
@@ -52,11 +53,14 @@ pub struct RenderOptions {
/// Draw the sequence's visible caption tracks over the picture (Program monitor, burn-in on
/// export). Never applies to nested sequences.
pub captions: bool,
/// Return the image in the sequence's working space (HDR exports, scopes) instead of
/// converting it for an SDR monitor. Only matters for HDR / wide-gamut sequences.
pub working_output: bool,
}
impl Default for RenderOptions {
fn default() -> Self {
Self { scale: 1.0, effects: true, depth: 0, captions: false }
Self { scale: 1.0, effects: true, depth: 0, captions: false, working_output: false }
}
}
@@ -139,6 +143,9 @@ fn render_seq(project: &Project, seq: &Sequence, t: Tick, opts: RenderOptions, s
blend::composite(&mut canvas, &layer, op, bl);
}
}
if opts.depth == 0 && !opts.working_output {
colorman::to_display(&mut canvas, &seq.settings.color);
}
if opts.captions && opts.depth == 0 {
for o in caption_overlays(seq, t, w, h) {
o.composite_onto(&mut canvas.px, w, h);
@@ -238,11 +245,10 @@ pub(crate) fn item_layer(
let src = sources.source(item.item)?;
let frame = src.video_frame(FrameRequest { time: mt, scale: want }).ok()?;
let n = decimation(frame.width as f32, src_size.0 as f32 * want);
let (w, h, px) = frame.to_linear_f32_decimated(n);
Image { w, h, px }
colorman::decode(project, item.item, &frame, n, &seq.settings.color)
}
ItemKind::Sequence(nested) => {
let sub = RenderOptions { scale: want, effects: opts.effects, depth: opts.depth + 1, captions: false };
let sub = RenderOptions { scale: want, effects: opts.effects, depth: opts.depth + 1, captions: false, working_output: true };
render_seq(project, nested, mt, sub, sources)
}
ItemKind::AdjustmentLayer { .. } => return None,
@@ -307,8 +313,8 @@ pub fn render_item(project: &Project, item: ItemId, t: Tick, scale: f32, sources
let f = src.video_frame(FrameRequest { time: t, scale }).ok()?;
let full_w = src.info().video.as_ref().map_or(f.width, |v| v.width) as f32;
let n = decimation(f.width as f32, full_w * scale);
let (w, h, px) = f.to_linear_f32_decimated(n);
Some(Image { w, h, px })
// the Source monitor shows media as SDR Rec. 709 (log/HDR tone mapped per its colour space)
Some(colorman::decode(project, item, &f, n, &filmcraft_color::ColorPipeline::REC709))
}
}
}
+12 -4
View File
@@ -56,6 +56,10 @@ fn gpu_simple(project: &Project, item: &TrackItem, mt: Tick) -> bool {
pub fn plan_frame(project: &Project, seq_id: ItemId, t: Tick, opts: RenderOptions, sources: &dyn SourceProvider) -> FramePlan {
let Some(seq) = project.sequence(seq_id) else { return FramePlan::Image(crate::Image::new(1, 1)) };
let (w, h) = output_size(seq, opts.scale);
// HDR / wide-gamut sequences composite and convert on the CPU.
if !seq.settings.color.is_plain() {
return FramePlan::Image(crate::render_sequence(project, seq_id, t, opts, sources));
}
// Whole-frame fallback: adjustment layers or complex transitions anywhere at t.
for tr in &seq.video_tracks {
if !tr.enabled {
@@ -162,10 +166,14 @@ fn push_item(
let lin = ((motion.a * motion.a + motion.b * motion.b).sqrt()).max((motion.c * motion.c + motion.d * motion.d).sqrt());
let want = (lin * opts.scale as f64).clamp(1.0 / 64.0, 1.0) as f32;
let Ok(frame) = src.video_frame(FrameRequest { time: mt, scale: want }) else { return };
let px_scale = frame.width as f64 / size.0.max(1) as f64;
let m = Affine::scale(opts.scale as f64, opts.scale as f64).then_apply(&motion).then_apply(&Affine::scale(1.0 / px_scale, 1.0 / px_scale));
out.push(PlanLayer { frame, matrix: m, opacity: op * extra_opacity });
return;
let cs = crate::colorman::source_space(project, item.item, &frame);
// log / HDR / wide-gamut media is converted on the CPU (below)
if !crate::colorman::needs_management(&seq.settings.color, cs, &frame) {
let px_scale = frame.width as f64 / size.0.max(1) as f64;
let m = Affine::scale(opts.scale as f64, opts.scale as f64).then_apply(&motion).then_apply(&Affine::scale(1.0 / px_scale, 1.0 / px_scale));
out.push(PlanLayer { frame, matrix: m, opacity: op * extra_opacity });
return;
}
}
// CPU-rendered layer (standard effects): drawn by the GPU as a pre-rendered canvas image.
let tc = filmcraft_time::format_time(t, seq.settings.frame_rate, seq.settings.drop_frame, filmcraft_time::TimeDisplay::Timecode, 48_000);
+11
View File
@@ -182,6 +182,17 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val
app.dialog = Some(crate::Dialog::AudioGain);
return Ok(json!({"dialog": "audioGain"}));
}
// Colour dialogs from the menus; with params the engine command applies directly.
"clip.interpretFootage" if params.get("colorSpace").is_none() => {
filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session))?;
crate::panels::color_dialogs::open_interpret(app, &params);
return Ok(json!({"dialog": "interpretFootage"}));
}
"sequence.colorSettings" if params.as_object().is_none_or(|m| m.is_empty()) => {
filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session))?;
crate::panels::color_dialogs::open_sequence(app);
return Ok(json!({"dialog": "sequenceColor"}));
}
// From menus/shortcuts (no params) these ask first; agents pass params to act directly.
"file.revert" if params.as_object().is_none_or(|m| m.is_empty()) && app.session.is_dirty() => {
if app.session.path.is_none() {
+122
View File
@@ -0,0 +1,122 @@
//! Colour dialogs: Clip ▸ Modify ▸ Interpret Footage… (Color Management) and Sequence ▸ Color
//! Management… (working space, wide gamut, Auto Tone Map Media). Each applies one engine command
//! on OK, so it is a single undo step. Automation ids: `colorDialog.space.<id>`,
//! `colorDialog.working.<id>`, `colorDialog.wideGamut`, `colorDialog.autoToneMap`,
//! `colorDialog.ok`, `colorDialog.cancel`.
use filmcraft_color::{ColorSpace, WorkingSpace};
use serde_json::{Value, json};
use crate::FilmcraftApp;
use crate::state::ColorDialog;
pub fn open_interpret(app: &mut FilmcraftApp, params: &Value) {
let items: Vec<u64> = match params.get("items").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(Value::as_u64).collect(),
None => {
let s = &app.session;
let mut v: Vec<u64> = s.state.project_selection.iter().map(|i| i.0).collect();
if v.is_empty()
&& let Some(q) = s.active_sequence()
{
v = s.state.selection.iter().filter_map(|c| q.find_item(*c).map(|(_, it)| it.item.0)).collect();
v.dedup();
}
v
}
};
let current = items
.first()
.and_then(|i| app.session.execute("media.colorInfo", json!({"item": i})).ok())
.and_then(|v| v["override"].as_str().map(str::to_string))
.unwrap_or_else(|| "auto".into());
app.ui.color_dialog = Some(ColorDialog::Interpret { items, color_space: current });
}
pub fn open_sequence(app: &mut FilmcraftApp) {
let c = app.session.active_sequence().map(|q| q.settings.color).unwrap_or(filmcraft_color::ColorPipeline::REC709);
app.ui.color_dialog = Some(ColorDialog::Sequence { working_space: c.working.id().into(), wide_gamut: c.wide_gamut, auto_tone_map: c.auto_tone_map });
}
fn radio(app: &mut FilmcraftApp, ui: &mut egui::Ui, id: &str, selected: bool, label: &str) -> bool {
let r = ui.radio(selected, label);
app.auto.add(id, r.rect, label);
r.clicked()
}
fn buttons(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> (bool, bool) {
let mut out = (false, false);
ui.add_space(6.0);
ui.horizontal(|ui| {
let c = ui.button("Cancel");
app.auto.add("colorDialog.cancel", c.rect, "Cancel");
let o = ui.button("OK");
app.auto.add("colorDialog.ok", o.rect, "OK");
out = (o.clicked(), c.clicked());
});
out
}
pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
let Some(mut d) = app.ui.color_dialog.clone() else { return };
let mut open = true;
let mut apply: Option<(&'static str, Value)> = None;
let mut close = false;
match &mut d {
ColorDialog::Interpret { items, color_space } => {
let detected = items
.first()
.and_then(|i| app.session.execute("media.colorInfo", json!({"item": i})).ok())
.and_then(|v| v["detectedLabel"].as_str().map(str::to_string))
.unwrap_or_else(|| "—".into());
let n = items.len();
egui::Window::new("Interpret Footage").open(&mut open).collapsible(false).resizable(false).default_width(380.0).show(ctx, |ui| {
ui.label(egui::RichText::new(format!("Color Management · {n} item{}", if n == 1 { "" } else { "s" })).strong());
ui.label(format!("Media colour space (from file metadata): {detected}"));
ui.add_space(4.0);
ui.label("Override media colour space:");
if radio(app, ui, "colorDialog.space.auto", color_space == "auto", "Use file metadata") {
*color_space = "auto".into();
}
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
for cs in ColorSpace::ALL {
if radio(app, ui, &format!("colorDialog.space.{}", cs.id()), color_space == cs.id(), cs.label()) {
*color_space = cs.id().into();
}
}
});
let (ok, cancel) = buttons(app, ui);
if ok {
apply = Some(("clip.interpretFootage", json!({"items": items, "colorSpace": color_space})));
}
close = ok || cancel;
});
}
ColorDialog::Sequence { working_space, wide_gamut, auto_tone_map } => {
egui::Window::new("Sequence Color Management").open(&mut open).collapsible(false).resizable(false).default_width(340.0).show(ctx, |ui| {
ui.label(egui::RichText::new("Working Color Space").strong());
for w in WorkingSpace::ALL {
if radio(app, ui, &format!("colorDialog.working.{}", w.id()), working_space == w.id(), w.label()) {
*working_space = w.id().into();
}
}
ui.add_space(4.0);
let r = ui.checkbox(wide_gamut, "Wide gamut color (composite in BT.2020)");
app.auto.add("colorDialog.wideGamut", r.rect, "Wide gamut");
let r = ui.checkbox(auto_tone_map, "Auto tone map media (HDR and log into SDR)");
app.auto.add("colorDialog.autoToneMap", r.rect, "Auto tone map");
let (ok, cancel) = buttons(app, ui);
if ok {
apply = Some(("sequence.colorSettings", json!({"workingSpace": working_space, "wideGamut": *wide_gamut, "autoToneMap": *auto_tone_map})));
}
close = ok || cancel;
});
}
}
app.ui.color_dialog = if close || !open { None } else { Some(d) };
if let Some((cmd, p)) = apply
&& let Err(e) = app.session.execute(cmd, p)
{
app.ui.status = e.to_string();
}
}
+1
View File
@@ -4,6 +4,7 @@
use crate::{Dialog, FilmcraftApp};
pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
crate::panels::color_dialogs::show(app, ctx);
let Some(d) = app.dialog else { return };
if let Some(still_open) = crate::panels::file_dialogs::show(app, ctx, d) {
if !still_open && app.dialog == Some(d) {
+70
View File
@@ -206,6 +206,11 @@ pub fn scopes(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let key = FrameKey { target: Target::Sequence(seq_id), frame, size: 250, revision: app.session.revision };
let project = app.session.project.clone();
app.frames.request(key, rate.tick_of(frame), 0.25, &project, 2);
let pipe = app.session.active_sequence().map(|q| q.settings.color).unwrap_or(filmcraft_color::ColorPipeline::REC709);
if pipe.working.is_hdr() {
hdr_scopes(app, ui, rect, (seq_id.0, frame, app.session.revision));
return;
}
let Some(img) = app.frames.get(&key) else {
crate::dock::placeholder(ui, rect, &t, "…");
return;
@@ -243,3 +248,68 @@ pub fn scopes(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
ui.painter().circle_stroke(vs.center(), vs_size / 2.0, egui::Stroke::new(1.0, Color32::from_white_alpha(40)));
}
/// Nits → waveform height (0..1) on the PQ scale used by HDR waveforms.
fn pq_axis(nits: f32) -> f32 {
filmcraft_color::pq_inverse_eotf((nits / 10_000.0).clamp(0.0, 1.0))
}
/// Scopes of an HDR (Rec. 2100 PQ/HLG) sequence: luminance waveform in cd/m² on a PQ-scaled
/// axis (0–10 000 nits, reference white 203 marked) and a BT.2020 vectorscope. The frame is the
/// sequence rendered in its working space (HDR values kept), small and cached per frame.
fn hdr_scopes(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, key: (u64, i64, u64)) {
use std::sync::Mutex;
static CACHE: Mutex<Option<((u64, i64, u64), filmcraft_render::Image)>> = Mutex::new(None);
let t = app.tokens;
let img = {
let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner());
if c.as_ref().is_none_or(|(k, _)| *k != key)
&& let Some(img) = app.session.render_program_working(0.125)
{
*c = Some((key, img));
}
c.as_ref().map(|(_, i)| i.clone())
};
let Some(img) = img else {
crate::dock::placeholder(ui, rect, &t, "…");
return;
};
let half = rect.width() / 2.0;
let wf = Rect::from_min_size(rect.min + vec2(34.0, 8.0), vec2(half - 38.0, rect.height() - 16.0));
let vs_size = (rect.height() - 16.0).min(half - 12.0);
let vs = Rect::from_center_size(pos2(rect.min.x + half + half / 2.0, rect.center().y), vec2(vs_size, vs_size));
ui.painter().rect_filled(wf, 0.0, Color32::BLACK);
ui.painter().rect_filled(vs, vs_size / 2.0, Color32::BLACK);
let luma = filmcraft_color::Gamut::Bt2020.luma();
let (w, h) = (img.w, img.h);
let mut mesh = egui::Mesh::default();
let mut peak = 0f32;
for y in 0..h {
for x in 0..w {
let p = img.get(x, y);
let a = p[3].max(1e-6);
let c = [p[0] / a, p[1] / a, p[2] / a];
let nits = (luma[0] as f32 * c[0] + luma[1] as f32 * c[1] + luma[2] as f32 * c[2]).max(0.0) * filmcraft_color::REFERENCE_WHITE_NITS as f32;
peak = peak.max(nits);
let px = wf.min.x + x as f32 / w as f32 * wf.width();
let py = wf.max.y - pq_axis(nits) * wf.height();
mesh.add_colored_rect(Rect::from_min_size(pos2(px, py), vec2(1.5, 1.0)), Color32::from_rgba_unmultiplied(120, 255, 140, 70));
// vectorscope on PQ-encoded BT.2020 Y'CbCr
let e = c.map(|v| pq_axis(v.max(0.0) * filmcraft_color::REFERENCE_WHITE_NITS as f32));
let ycc = filmcraft_color::rgb_to_ycbcr(e[0], e[1], e[2], filmcraft_color::Matrix::Bt2020Ncl);
let vx = vs.center().x + ycc[1] * vs_size;
let vy = vs.center().y - ycc[2] * vs_size;
mesh.add_colored_rect(Rect::from_min_size(pos2(vx, vy), vec2(1.0, 1.0)), Color32::from_rgba_unmultiplied(200, 255, 200, 60));
}
}
ui.painter().add(mesh);
for (nits, label) in [(0.0, "0"), (10.0, "10"), (100.0, "100"), (203.0, "203"), (1000.0, "1000"), (4000.0, "4000"), (10_000.0, "10000")] {
let y = wf.max.y - pq_axis(nits) * wf.height();
let col = if nits == 203.0 { Color32::from_rgba_unmultiplied(255, 200, 80, 90) } else { Color32::from_white_alpha(30) };
ui.painter().line_segment([pos2(wf.min.x, y), pos2(wf.max.x, y)], egui::Stroke::new(0.5, col));
ui.painter().text(pos2(wf.min.x - 3.0, y), Align2::RIGHT_CENTER, label, Tokens::ui(8.0), t.text_faint);
}
ui.painter().text(wf.left_top() + vec2(4.0, 4.0), Align2::LEFT_TOP, format!("HDR · cd/m² · peak {peak:.0}"), Tokens::ui(9.0), t.text_dim);
app.auto.add("scopes.hdrWaveform", wf, &format!("HDR waveform, peak {peak:.0} nits"));
ui.painter().circle_stroke(vs.center(), vs_size / 2.0, egui::Stroke::new(1.0, Color32::from_white_alpha(40)));
}
+1
View File
@@ -1,6 +1,7 @@
//! Panel bodies. `show` dispatches on [`PanelKind`]; drag-and-drop between panels (project items,
//! effects) is carried in egui temp data so the timeline/monitors can accept drops.
pub mod color_dialogs;
pub mod dialogs;
pub mod effect_controls;
pub mod effects;
+4
View File
@@ -301,6 +301,10 @@ fn item_interactions(
actions.push(("edit.duplicate".into(), json!({})));
ui.close();
}
if ui.button("Interpret Footage…").clicked() {
actions.push(("clip.interpretFootage".into(), json!({"items": [id.0]})));
ui.close();
}
ui.menu_button("Label", |ui| {
for l in filmcraft_project::Label::ALL {
if ui.button(l.name()).clicked() {
+13
View File
@@ -292,6 +292,18 @@ pub struct UiState {
/// Pen tool: path points placed so far (sequence pixels).
#[serde(default)]
pub pen_points: Vec<[f64; 2]>,
/// Open colour dialog (Interpret Footage ▸ Color Management, Sequence ▸ Color Management).
#[serde(default)]
pub color_dialog: Option<ColorDialog>,
}
/// Colour dialogs: drafts are applied with one engine command on OK.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum ColorDialog {
/// `clip.interpretFootage`: the media items and the chosen colour space id ("auto" = metadata).
Interpret { items: Vec<u64>, color_space: String },
/// `sequence.colorSettings`.
Sequence { working_space: String, wide_gamut: bool, auto_tone_map: bool },
}
/// The Audio Gain dialog (Clip ▸ Audio Gain…, G).
@@ -356,6 +368,7 @@ impl Default for UiState {
audio_gain: AudioGainDraft::default(),
gfx_edit: None,
pen_points: vec![],
color_dialog: None,
}
}
}
+38
View File
@@ -165,3 +165,41 @@ fn lumetri_lut_menus_and_section_switches() {
let e = lumetri(&mut d, clip);
assert_eq!(e["params"]["creative_on"]["value"], json!("Bool(true)"), "{e}");
}
#[test]
fn interpret_footage_and_sequence_color_dialogs_and_hdr_scopes() {
let mut d = Driver::demo();
d.ok("ui.set", json!({"workspace": "Color"}));
d.frames(3);
let clip = select_first_clip(&mut d);
let seq = d.exec("sequence.inspect", json!({}));
let item = seq["video"][0]["items"].as_array().unwrap().iter().find(|i| i["clip"] == clip).unwrap()["item"].clone();
// Clip ▸ Modify ▸ Interpret Footage… opens the dialog (no params)
let r = d.ok("ui.menu.invoke", json!({"id": "clip.interpretFootage"}));
assert_eq!(r["dialog"], "interpretFootage");
d.frames(3);
d.click("colorDialog.space.slog3-sgamut3cine");
d.snapshot("color-interpret-footage", None);
d.click("colorDialog.ok");
let info = d.exec("media.colorInfo", json!({"item": item}));
assert_eq!(info["override"], "slog3-sgamut3cine", "{info}");
// Sequence ▸ Color Management…: Rec. 2100 PQ
let r = d.ok("ui.menu.invoke", json!({"id": "sequence.colorSettings"}));
assert_eq!(r["dialog"], "sequenceColor");
d.frames(3);
d.click("colorDialog.working.rec2100-pq");
d.snapshot("color-sequence-settings", None);
d.click("colorDialog.ok");
let st = d.exec("sequence.colorSettings", json!({"autoToneMap": true}));
assert_eq!(st["workingSpace"], "rec2100-pq");
// the scopes switch to the HDR waveform (cd/m², PQ scale)
d.ok("ui.panel.show", json!({"panel": "LumetriScopes"}));
d.frames(6);
assert!(d.ids("scopes.").iter().any(|i| i == "scopes.hdrWaveform"), "{:?}", d.ids("scopes."));
d.snapshot("color-hdr-scopes", None);
// undo the working space and the interpretation
d.exec("edit.undo", json!({}));
d.exec("edit.undo", json!({}));
let info = d.exec("media.colorInfo", json!({"item": item}));
assert_eq!(info["override"], Value::Null, "{info}");
}
+40
View File
@@ -259,6 +259,46 @@ Mix: bus sum → pre-fader inserts → fader → meter → post-fader inserts
mix (Track Mixer and Audio Meters read them) and the newest project snapshot, which the audio
callback uses, so edits made during playback are heard.
### 5.2 Colour management
```text
frame (Y'CbCr/RGB + metadata) ─► source colour space: Interpret Footage override, else VUI/colr/MKV Colour
─► decode table (sRGB/BT.709, PQ, HLG scene light, camera log) ─► HLG OOTF ─► 3×3 to working gamut
─► BT.2390 tone map (HDR/log into an SDR sequence, Auto Tone Map Media) ─► gamut compression
─► effects + compositing in working linear (1.0 = reference white = SDR white = 203 cd/m²)
─► monitors: working → SDR BT.709 (tone map from HDR, gamut map from BT.2020)
─► HDR export: working → PQ/HLG BT.2020 R'G'B' → Y'CbCr (BT.2020 NCL) + VUI/colr/mdcv/clli/SEI
```
- **Model.** `SequenceSettings::color` (`ColorPipeline`: working space Rec. 709 / Rec. 2100 PQ /
Rec. 2100 HLG, wide gamut, auto tone map) and `Interpretation::color_space` (per media item;
`None` = from metadata). Commands: `sequence.colorSettings`, `clip.interpretFootage`,
`color.spaces`, `media.colorInfo`.
- **Maths** in `filmcraft-color` (`transform`, `log`, `spaces`; formulas and sources in
[crates/color/README.md](../crates/color/README.md)); the renderer side is `render::colorman`.
- **Fast path.** A Rec. 709 sequence without wide gamut and media whose metadata says Rec. 709 /
sRGB decodes exactly as before, and its layers stay on the GPU. Log, HDR or wide-gamut media,
and every layer of an HDR/wide-gamut sequence, are converted on the CPU (the GPU path draws them
as pre-rendered images, like layers with effects).
- **Outputs.** `RenderOptions::working_output` returns working-space pixels (HDR exports,
scopes); otherwise the top-level render is converted for an SDR monitor. Lumetri and the other
colour effects work on display-encoded values clamped to 0..1, so in an HDR sequence they clip
highlights above reference white (HDR-aware grading is future work).
- **HDR export.** H.264 and ProRes exports of a PQ/HLG sequence encode BT.2020 PQ/HLG (ProRes
10-bit; our H.264 encoder is 8-bit, so H.264 HDR is 8-bit) and signal it in the VUI / ProRes
frame header, `colr`, and for PQ `mdcv` (BT.2020 / D65, 1000 / 0.0001 cd/m²) and `clli`
(MaxCLL/MaxFALL 0 = unknown, they are not measured) plus the matching H.264 SEI. `sdr: true`
exports the tone-mapped SDR picture instead; render previews always do.
- **Monitors / display colour management.** The monitors are SDR (sRGB-encoded RGBA8 textures):
HDR sequences are shown tone mapped. macOS EDR (extended-range `CAMetalLayer` output) is not
wired up: there is no `platform` crate yet and eframe/wgpu do not expose an EDR surface, so HDR
values above SDR white are never sent to the display. The scopes of an HDR sequence show the
working-space values (waveform in cd/m² on a PQ scale, BT.2020 vectorscope).
- **LUTs.** Lumetri Input LUT and Creative Look reference `lib:<id>` (the project's LUT library,
`Project::luts`, embedded `.cube`/`.3dl` text) or `builtin:<id>` (code-generated camera
conversions and looks). `filmcraft-gpu::GpuLut` is the WGSL tetrahedral counterpart, tested for
parity.
## 6. Export jobs (`filmcraft-export`)
```text