From 1653430c4207c7de0a90457445207ddac8feed8b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 08:56:47 -0400 Subject: [PATCH] =?UTF-8?q?M8.8:=20colour=20management=20=E2=80=94=20seque?= =?UTF-8?q?nce=20working=20space=20(Rec.=20709=20/=20Rec.=202100=20PQ=20/?= =?UTF-8?q?=20HLG,=20wide=20gamut),=20Interpret=20Footage=20colour=20space?= =?UTF-8?q?,=20log/HDR=20decode=20with=20BT.2390=20tone=20mapping,=20HDR?= =?UTF-8?q?=20export=20signalling,=20HDR=20scopes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ATTRIBUTION.md | 2 + Cargo.lock | 3 + crates/color/README.md | 2 + crates/color/src/transform.rs | 17 +- crates/engine/src/color.rs | 132 ++++++++++- crates/engine/src/color_tests.rs | 42 +++- crates/engine/src/commands.rs | 4 +- crates/engine/src/lib.rs | 9 + crates/engine/src/previews.rs | 3 + crates/export/Cargo.toml | 1 + crates/export/src/lib.rs | 215 ++++++++++++++++-- crates/export/src/tests.rs | 59 +++++ crates/frame/src/lib.rs | 25 +- crates/golden/Cargo.toml | 2 + crates/golden/goldens/hdr_tone_map.png | Bin 0 -> 4715 bytes .../goldens/hdr_tone_map.png.attribution | 8 + crates/golden/goldens/log_to_rec709.png | Bin 0 -> 24310 bytes .../goldens/log_to_rec709.png.attribution | 8 + crates/golden/tests/golden.rs | 78 ++++++- crates/h264enc/src/lib.rs | 10 + crates/h264enc/src/nal.rs | 19 ++ crates/isobmff/src/codec.rs | 69 ++++++ crates/isobmff/src/lib.rs | 4 +- crates/project/src/lib.rs | 14 +- crates/render/src/colorman.rs | 131 +++++++++++ crates/render/src/lib.rs | 18 +- crates/render/src/plan.rs | 16 +- crates/ui-egui/src/menus.rs | 11 + crates/ui-egui/src/panels/color_dialogs.rs | 122 ++++++++++ crates/ui-egui/src/panels/dialogs.rs | 1 + crates/ui-egui/src/panels/misc.rs | 70 ++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/project.rs | 4 + crates/ui-egui/src/state.rs | 13 ++ crates/ui-egui/tests/color_ui.rs | 38 ++++ docs/architecture.md | 40 ++++ 36 files changed, 1145 insertions(+), 46 deletions(-) create mode 100644 crates/golden/goldens/hdr_tone_map.png create mode 100644 crates/golden/goldens/hdr_tone_map.png.attribution create mode 100644 crates/golden/goldens/log_to_rec709.png create mode 100644 crates/golden/goldens/log_to_rec709.png.attribution create mode 100644 crates/render/src/colorman.rs create mode 100644 crates/ui-egui/src/panels/color_dialogs.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 38a3d624e..06967d512 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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 | diff --git a/Cargo.lock b/Cargo.lock index 72dbdba55..d38f40b97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/color/README.md b/crates/color/README.md index 041db40b9..8726622cd 100644 --- a/crates/color/README.md +++ b/crates/color/README.md @@ -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. diff --git a/crates/color/src/transform.rs b/crates/color/src/transform.rs index 77cbbcb01..52788d443 100644 --- a/crates/color/src/transform.rs +++ b/crates/color/src/transform.rs @@ -195,6 +195,9 @@ pub struct InputTransform { hlg: Option<([f32; 3], f32)>, tone: Option, 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 } diff --git a/crates/engine/src/color.rs b/crates/engine/src/color.rs index 023bf1558..80d9766fd 100644 --- a/crates/engine/src/color.rs +++ b/crates/engine/src/color.rs @@ -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; @@ -23,6 +24,24 @@ fn query(id: &'static str, label: &'static str, params: &'static str, run: Run) pub(crate) fn commands() -> Vec { 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"|""}"#, + 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 { 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 { + 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 { + 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 = 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 { + 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::>(), "colorSpace": cs.map(|c| c.id()).unwrap_or("auto")})) +} + +fn spaces(_: &mut Session, _: &Value) -> Result { + let cs: Vec = 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 = 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 { + 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()), + })) +} diff --git a/crates/engine/src/color_tests.rs b/crates/engine/src/color_tests.rs index 14ea4078b..205c6dd95 100644 --- a/crates/engine/src/color_tests.rs +++ b/crates/engine/src/color_tests.rs @@ -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()); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index a47ed4aba..ed5616d05 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -171,7 +171,7 @@ fn track_p(s: &Session, p: &Value, k: &str) -> Option { 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 { +pub(crate) fn item_p(p: &Value, k: &str) -> Option { u64_p(p, k).map(ItemId) } @@ -1883,6 +1883,8 @@ fn export_media(s: &mut Session, p: &Value) -> Result { 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 { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 15aff7a42..f2936ceaa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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 { + 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 { let seq = self.state.active_sequence?; diff --git a/crates/engine/src/previews.rs b/crates/engine/src/previews.rs index c49db99c0..1d5141eb0 100644 --- a/crates/engine/src/previews.rs +++ b/crates/engine/src/previews.rs @@ -459,6 +459,9 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result { // 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) => { diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml index 23e6e0d32..71365269d 100644 --- a/crates/export/Cargo.toml +++ b/crates/export/Cargo.toml @@ -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 } diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index b25ea805f..5e1cc5806 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -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> { 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>> { - (format == Format::ProRes) - .then(|| Ok(Box::new(ProResEncoder { enc: filmcraft_prores::Encoder::new(filmcraft_prores::Profile::Hq, w, h), w, h, rate }) as Box)) +/// 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>> { + (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) + }) } /// 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, u: Vec, v: Vec, + 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> { - 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, 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, u: &mut Vec, v: &mut Vec) { + 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>> { 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) + .map(|enc| Box::new(H264Encoder { enc, w, h, rate, y: Vec::new(), u: Vec::new(), v: Vec::new(), signal: s.signal }) as Box) .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, seq: ItemId, settings: &ExportSettings, sources: &dyn SourceProvider, progress: &Progress) -> Result { 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, 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 { + 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 { 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, seq: ItemId, settings: &ExportSettings, so // Encode the first batch before creating tracks (encoders may finalise codec config then). let mut pending: Vec = Vec::new(); let first_end = (f0 + batch).min(f1); - let first: Vec> = (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, Vec) { if hdr_out { (Vec::new(), render_hdr(fi)) } else { (render(fi), Vec::new()) } }; + let first: Vec<(Vec, Vec)> = (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, seq: ItemId, settings: &ExportSettings, so return Err(ExportError::Cancelled); } let end = (f + batch).min(f1); - let frames: Vec> = (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, Vec)> = (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()))?; } diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index a77111249..4571c8307 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -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}"); + } +} diff --git a/crates/frame/src/lib.rs b/crates/frame/src/lib.rs index 6f4232dd9..f7bfbe305 100644 --- a/crates/frame/src/lib.rs +++ b/crates/frame/src/lib.rs @@ -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) { + 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) { 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 = (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 = 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; + 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]; diff --git a/crates/golden/Cargo.toml b/crates/golden/Cargo.toml index 8ed401cb5..fc14d15de 100644 --- a/crates/golden/Cargo.toml +++ b/crates/golden/Cargo.toml @@ -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 } diff --git a/crates/golden/goldens/hdr_tone_map.png b/crates/golden/goldens/hdr_tone_map.png new file mode 100644 index 0000000000000000000000000000000000000000..ad5a89d2fdb2d541d24335a5ebc7b9dc17d675f1 GIT binary patch literal 4715 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 L>OHMifkplY)2ROs literal 0 HcmV?d00001 diff --git a/crates/golden/goldens/hdr_tone_map.png.attribution b/crates/golden/goldens/hdr_tone_map.png.attribution new file mode 100644 index 000000000..acf022129 --- /dev/null +++ b/crates/golden/goldens/hdr_tone_map.png.attribution @@ -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. diff --git a/crates/golden/goldens/log_to_rec709.png b/crates/golden/goldens/log_to_rec709.png new file mode 100644 index 0000000000000000000000000000000000000000..33a44e15f24e03c58f21ff76c7f7ba8e48f32a02 GIT binary patch literal 24310 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 literal 0 HcmV?d00001 diff --git a/crates/golden/goldens/log_to_rec709.png.attribution b/crates/golden/goldens/log_to_rec709.png.attribution new file mode 100644 index 000000000..8efe6133a --- /dev/null +++ b/crates/golden/goldens/log_to_rec709.png.attribution @@ -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. diff --git a/crates/golden/tests/golden.rs b/crates/golden/tests/golden.rs index d007c69c6..6c7d8535c 100644 --- a/crates/golden/tests/golden.rs +++ b/crates/golden/tests/golden.rs @@ -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, interpret: Option) -> 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, +} + +impl MediaSource for Encoded { + fn info(&self) -> &filmcraft_media::MediaInfo { + self.inner.info() + } + fn video_frame(&self, req: filmcraft_media::FrameRequest) -> filmcraft_media::Result> { + 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 = 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 { + 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. diff --git a/crates/h264enc/src/lib.rs b/crates/h264enc/src/lib.rs index 3629f1718..dfc7d141f 100644 --- a/crates/h264enc/src/lib.rs +++ b/crates/h264enc/src/lib.rs @@ -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); diff --git a/crates/h264enc/src/nal.rs b/crates/h264enc/src/nal.rs index 8ac187b9e..440d9272d 100644 --- a/crates/h264enc/src/nal.rs +++ b/crates/h264enc/src/nal.rs @@ -181,6 +181,25 @@ pub fn sei_user_data_rbsp(text: &str) -> Vec { 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 { + 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, diff --git a/crates/isobmff/src/codec.rs b/crates/isobmff/src/codec.rs index f61122fb6..d55a621d2 100644 --- a/crates/isobmff/src/codec.rs +++ b/crates/isobmff/src/codec.rs @@ -41,6 +41,62 @@ pub struct VideoParams { pub field_info: Option, /// QuickTime `gama` (16.16 fixed point). pub gamma: Option, + /// `mdcv`: mastering display colour volume (SMPTE ST 2086). + pub mastering_display: Option, + /// `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 { + 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 { + 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] diff --git a/crates/isobmff/src/lib.rs b/crates/isobmff/src/lib.rs index 1b632f1cb..bfb8a51b5 100644 --- a/crates/isobmff/src/lib.rs +++ b/crates/isobmff/src/lib.rs @@ -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}; diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 4bd4cafd4..01c4e839e 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -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, } #[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, } } } diff --git a/crates/render/src/colorman.rs b/crates/render/src/colorman.rs new file mode 100644 index 000000000..9e07ca386 --- /dev/null +++ b/crates/render/src/colorman.rs @@ -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 { + 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 { + static C: OnceLock>>> = 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 { + static C: OnceLock>>> = 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); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 74e3e0b1a..80086fa7a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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)) } } } diff --git a/crates/render/src/plan.rs b/crates/render/src/plan.rs index 74a82edf9..47307e8f1 100644 --- a/crates/render/src/plan.rs +++ b/crates/render/src/plan.rs @@ -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); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 482119a56..cb17ae806 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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, ¶ms); + 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() { diff --git a/crates/ui-egui/src/panels/color_dialogs.rs b/crates/ui-egui/src/panels/color_dialogs.rs new file mode 100644 index 000000000..671653a45 --- /dev/null +++ b/crates/ui-egui/src/panels/color_dialogs.rs @@ -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.`, +//! `colorDialog.working.`, `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 = 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 = 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(); + } +} diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 43b7b8fea..699661367 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -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) { diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index 2c1b070d1..b0b8850fd 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -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> = 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))); +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index ccb6ec9b2..26a7ccc02 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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; diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index e9bfb3241..6c7877cc4 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -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() { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d90aeaadb..4e3b63aa1 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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, +} + +/// 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, 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, } } } diff --git a/crates/ui-egui/tests/color_ui.rs b/crates/ui-egui/tests/color_ui.rs index 091d9ab68..f4d92d61b 100644 --- a/crates/ui-egui/tests/color_ui.rs +++ b/crates/ui-egui/tests/color_ui.rs @@ -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}"); +} diff --git a/docs/architecture.md b/docs/architecture.md index b53241a16..185085893 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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:` (the project's LUT library, + `Project::luts`, embedded `.cube`/`.3dl` text) or `builtin:` (code-generated camera + conversions and looks). `filmcraft-gpu::GpuLut` is the WGSL tetrahedral counterpart, tested for + parity. + ## 6. Export jobs (`filmcraft-export`) ```text