M8.10: HDR-aware Lumetri, mastering metadata tone-map peak, HSL Refine, Lumetri Presets grid, Comparison View match

- Lumetri grades PQ / HLG working spaces in their own signal normalised to
  HDR White (Basic Correction) / HDR Range (Curves) in cd/m²
  (filmcraft_color::GradeSpace): exposure in stops of light, highlights above
  HDR White kept, HDR Specular scales them; Rec. 709 unchanged.
- HDR static metadata (mdcv/clli, Matroska MasteringMetadata/MaxCLL/MaxFALL)
  on VideoStreamInfo::hdr; PQ media use MaxCLL / mastering peak as the
  BT.2390 source peak; media.colorInfo reports it.
- HSL Secondary Refine: Denoise (median of the key) and Blur (Gaussian).
- Lumetri Presets (18 original looks in 4 folders) with thumbnails rendered by
  our Lumetri on a procedural preview picture; Effects panel rows and a
  thumbnail grid when wide; lumetri.presets / applyPreset / presetThumbnails.
- Color Match: Comparison View toggle; Apply Match uses its reference frame.
- Goldens lumetri_hdr_pq and lumetri_presets_grid; D-Log M stays unsupported
  (no published formula).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 10:31:00 -04:00
parent 468321391f
commit 2a199425d0
40 changed files with 1624 additions and 85 deletions

View File

@ -60,6 +60,8 @@ procedural demo footage (`crates/media/src/generators.rs`); no external media. R
| `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/lumetri_hdr_pq.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `lumetri_hdr_pq`) from procedurally generated media re-encoded as Rec. 2100 PQ | MIT OR Apache-2.0 |
| `crates/golden/goldens/lumetri_presets_grid.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, `lumetri_presets_grid`): FilmCraft's own Lumetri presets on its procedural preview picture | 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 |
| `crates/golden/goldens/transform_opacity.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `transform_opacity`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/transition_cross_dissolve.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `transition_cross_dissolve`) from procedurally generated media | MIT OR Apache-2.0 |
@ -101,6 +103,7 @@ is traced or derived from Adobe artwork.
| Demo footage (ocean sunset, aurora, city night, dunes, forest, plasma), bars and tone, counting leader, colour matte | `crates/media/src/generators.rs` |
| Built-in graphics templates (Lower Third – Slab / Rule, Ticker – Crawl, Title – Centered / Boxed, End Credits – Roll, Callout – Pointer / Tag) and their placeholder text | `crates/project/src/gtemplate.rs` (`builtin_templates`): original designs defined in code with the bundled Inter font; thumbnails are rendered at runtime, no template, image or font files |
| Lumetri "Look" presets (Teal & Orange, Warm Film, …) | `crates/render/src/effects.rs` (`apply_look`): procedural colour transforms, not LUT files |
| Lumetri Presets (Effects panel: Cinematic, Film Emulation, Monochrome, Technical) and their preview picture | `crates/render/src/lumetri_presets.rs`: Lumetri parameter values chosen by contributors, descriptive names, and a procedural preview picture (dusk sky, ridges, lake, grey ramp, colour chips) |
| Colour palette and layout metrics | `crates/ui-egui/src/theme.rs`: colour values and sizes only; no artwork |
## First-party brand marks

View File

@ -154,6 +154,20 @@ fn sample_entry(c: &Codec, private: &[u8], v: Option<&filmcraft_matroska::VideoI
})
}
/// HDR static metadata from the Matroska `Colour` element.
fn hdr_metadata(c: &filmcraft_matroska::Colour) -> Option<filmcraft_color::HdrMetadata> {
if c.mastering.is_none() && c.max_cll.is_none() && c.max_fall.is_none() {
return None;
}
let nz = |v: f64| (v > 0.0).then_some(v as f32);
Some(filmcraft_color::HdrMetadata {
mastering_max_nits: c.mastering.as_ref().and_then(|m| nz(m.luminance_max)),
mastering_min_nits: c.mastering.as_ref().map(|m| m.luminance_min as f32),
max_cll: c.max_cll.and_then(|v| nz(v as f64)),
max_fall: c.max_fall.and_then(|v| nz(v as f64)),
})
}
fn color_of(v: &filmcraft_matroska::VideoInfo, w: u32, h: u32) -> (ColorInfo, bool) {
let mut c = ColorInfo { matrix: filmcraft_frame::default_matrix(w, h), transfer: Transfer::Bt709, primaries: Primaries::Bt709, range: Range::Limited };
let Some(col) = &v.colour else { return (c, false) };
@ -251,6 +265,7 @@ impl MkvSource {
color,
has_alpha: v.alpha_mode != 0,
bitrate,
hdr: v.colour.as_ref().and_then(hdr_metadata),
}
});
let audio = atrack.map(|i| {

View File

@ -46,6 +46,20 @@ pub fn sniff(b: &[u8]) -> bool {
b.len() >= 12 && matches!(&b[4..8], b"ftyp" | b"moov" | b"mdat" | b"wide" | b"free" | b"skip")
}
/// HDR static metadata from the sample entry's `mdcv` / `clli` boxes (0 = unknown).
fn hdr_metadata(md: Option<&filmcraft_isobmff::MasteringDisplay>, cll: Option<(u16, u16)>) -> Option<filmcraft_color::HdrMetadata> {
if md.is_none() && cll.is_none() {
return None;
}
let nz = |v: f32| (v > 0.0).then_some(v);
Some(filmcraft_color::HdrMetadata {
mastering_max_nits: md.and_then(|m| nz(m.max_nits() as f32)),
mastering_min_nits: md.map(|m| m.min_luminance as f32 / 10_000.0),
max_cll: cll.and_then(|c| nz(c.0 as f32)),
max_fall: cll.and_then(|c| nz(c.1 as f32)),
})
}
fn color_from(entry: &filmcraft_isobmff::SampleEntry, w: u32, h: u32) -> ColorInfo {
let mut c = ColorInfo { matrix: filmcraft_frame::default_matrix(w, h), transfer: Transfer::Bt709, primaries: Primaries::Bt709, range: Range::Limited };
// VP9 carries its colour description in vpcC (used when there is no colr box).
@ -129,6 +143,7 @@ impl Mp4Source {
color,
has_alpha: matches!(&entry.codec, CodecConfig::ProRes { fourcc } if fourcc.0 == *b"ap4h" || fourcc.0 == *b"ap4x"),
bitrate,
hdr: entry.video.as_ref().and_then(|v| hdr_metadata(v.mastering_display.as_ref(), v.content_light)),
};
if matches!(entry.codec, CodecConfig::Dnx { .. }) {
// the sample entry doesn't say which VC-3 compression ID it is: read the first frame header

View File

@ -257,6 +257,7 @@ impl MpegSource {
color: filmcraft_color::ColorInfo::REC709,
has_alpha: false,
bitrate: None,
hdr: None,
});
src.setup_audio(file, astream, None);
}
@ -458,6 +459,7 @@ impl MpegSource {
color,
has_alpha: false,
bitrate: (secs > 0.0).then(|| (total_bytes as f64 * 8.0 / secs) as u64),
hdr: None,
});
Ok(())
}

View File

@ -320,6 +320,7 @@ impl MxfSource {
color,
has_alpha: p.alpha_depth > 0,
bitrate: (secs > 0.0).then(|| (bytes_total as f64 * 8.0 / (t.samples.len().max(1) as f64 / rate.as_f64().max(1e-9))) as u64),
hdr: None,
});
}
// Audio: all PCM sound tracks of the first track's rate, channels in track order

View File

@ -41,7 +41,8 @@ for limited- and full-range files.
The encoders are the exact inverses given in the same documents. DJI **D-Log M** is not
implemented: DJI has not published its formula (only conversion LUTs), and we do not derive curves
from third-party LUTs.
from third-party LUTs. (Checked again in October 2026: DJI's published white papers cover D-Log /
D-Gamut only; D-Log M remains unsupported, so D-Log M footage should be interpreted with a LUT.)
Gamut primaries (all D65 white, x/y):
@ -84,6 +85,27 @@ HLG, and for log media the curve's own peak capped at 4000 cd/m² (so the knee s
grey). Target peak: 203 cd/m² = SDR white. HDR reference white then lands at ≈ 0.79 linear (≈ 90 %
on the sRGB curve).
## Grading signal (`grade`)
Lumetri's controls work on a 0…1 signal. `GradeSpace` picks it from the working space:
| Working space | Signal of working-linear `c` (1.0 = 203 cd/m² in HDR) | 1.0 means |
|---|---|---|
| Rec. 709 | sRGB curve, clamped back to 0…1 | SDR white |
| Rec. 2100 PQ | `PQ⁻¹(c·203/10000) / PQ⁻¹(W/10000)` (SMPTE ST 2084) | HDR White `W` cd/m² |
| Rec. 2100 HLG | `F = c·203/W`, `Y = BT.2020 luma(F)`, `E = F·Y^((1−γ)/γ)`, signal = HLG OETF(E), γ = 1.2 + 0.42·log10(W/1000) (BT.2100 inverse OOTF, note 5f) | display peak `W` |
`W` is Lumetri's HDR White (Basic Correction) or HDR Range (Curves), default 1000 cd/m². HDR
values above `W` are signals above 1 and are kept (PQ to 10 000 cd/m², HLG to signal 1.5). Checked
values: PQ 100 / 203 / 1000 cd/m² = 0.508 / 0.581 / 0.752 of the plain PQ signal (BT.2408); HLG on
a 1000 cd/m² display: 203 cd/m² = 0.75, 26 cd/m² (18 % grey) ≈ 0.38 (BT.2408).
## HDR static metadata
`HdrMetadata` (mastering display max / min luminance, MaxCLL, MaxFALL; 0 = unknown) gives the
tone-mapping source peak of PQ media: MaxCLL when known (the brightest pixel of the content),
else the mastering peak, clamped to 203…10 000 cd/m²; without metadata 1000 cd/m² is assumed.
## Gamut mapping
* **Input** (camera gamuts wider than the working gamut): per-channel distance compression towards

176
crates/color/src/grade.rs Normal file
View File

@ -0,0 +1,176 @@
//! The grading signal colour correctors (Lumetri) work on.
//!
//! Grading controls (whites / blacks, highlights / shadows, contrast, curves, wheels, keys) are
//! defined on a perceptual signal from 0 (black) to 1 (white), not on linear light. In an SDR
//! working space that signal is the sRGB-encoded display value, and 1 is SDR white.
//!
//! In an HDR working space (Rec. 2100 PQ or HLG) the signal follows the working space's own
//! transfer function and is **normalised to HDR White** (cd/m², Lumetri ▸ Basic Correction ▸ HDR
//! White; the curves have their own HDR Range): signal 1 is HDR White, so the sliders and curves
//! span 0 … HDR White nits the way they span black … white in SDR, and highlights above HDR
//! White (speculars) are values above 1 that pass through instead of clipping:
//!
//! - **PQ** (SMPTE ST 2084): `signal = PQ⁻¹(nits / 10000) / PQ⁻¹(white / 10000)`.
//! - **HLG** (ITU-R BT.2100): the inverse OOTF of a display with peak `white` (system gamma
//! γ = 1.2 + 0.42·log10(white / 1000), BT.2100 note 5f), then the HLG OETF; so 203 cd/m²
//! (BT.2408 HDR reference white) is signal 0.75 with HDR White = 1000 cd/m².
//!
//! Working units are linear light with 1.0 = 203 cd/m² ([`crate::REFERENCE_WHITE_NITS`]) in HDR
//! working spaces, as in [`crate::transform`].
use crate::spaces::{Gamut, WorkingSpace};
use crate::{REFERENCE_WHITE_NITS, hlg_inverse_oetf, hlg_oetf, linear_to_srgb, pq_eotf, pq_inverse_eotf, srgb_to_linear};
/// Default HDR White / curve HDR Range (cd/m²): the nominal HLG display peak and the usual PQ
/// mastering peak.
pub const DEFAULT_HDR_WHITE_NITS: f32 = 1000.0;
/// The signal a grading operation works on.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum GradeSpace {
/// sRGB-encoded display values, clamped to 0…1 on the way back (the classic SDR behaviour).
Sdr,
/// PQ signal normalised to `white` cd/m².
Pq { white: f32, norm: f32 },
/// HLG signal of a display with peak `white` cd/m² (`gamma` = its system gamma).
Hlg { white: f32, gamma: f32 },
}
impl GradeSpace {
/// The grading space of a working space, with HDR White `white_nits` (ignored for SDR).
pub fn new(working: WorkingSpace, white_nits: f32) -> Self {
let white = white_nits.clamp(100.0, 10_000.0);
match working {
WorkingSpace::Rec709 => GradeSpace::Sdr,
WorkingSpace::Rec2100Pq => GradeSpace::Pq { white, norm: pq_inverse_eotf(white / 10_000.0) },
WorkingSpace::Rec2100Hlg => GradeSpace::Hlg { white, gamma: 1.2 + 0.42 * (white / 1000.0).log10() },
}
}
pub fn is_hdr(&self) -> bool {
!matches!(self, GradeSpace::Sdr)
}
/// HDR White in cd/m² (None for SDR).
pub fn white_nits(&self) -> Option<f32> {
match *self {
GradeSpace::Sdr => None,
GradeSpace::Pq { white, .. } | GradeSpace::Hlg { white, .. } => Some(white),
}
}
/// Working linear → grading signal (1 = white; HDR values above HDR White are > 1).
#[inline]
pub fn encode(&self, c: [f32; 3]) -> [f32; 3] {
match *self {
GradeSpace::Sdr => c.map(|v| linear_to_srgb(v.max(0.0))),
GradeSpace::Pq { norm, .. } => c.map(|v| pq_inverse_eotf((v.max(0.0) * REFERENCE_WHITE_NITS as f32 / 10_000.0).min(1.0)) / norm),
GradeSpace::Hlg { white, gamma } => {
// display light normalised to the peak, inverse OOTF (scene light), OETF
let fd = c.map(|v| v.max(0.0) * REFERENCE_WHITE_NITS as f32 / white);
let l = luma2020();
let yd = l[0] * fd[0] + l[1] * fd[1] + l[2] * fd[2];
if yd <= 0.0 {
return [0.0; 3];
}
let k = yd.powf((1.0 - gamma) / gamma);
fd.map(|v| hlg_oetf(v * k))
}
}
}
/// Grading signal → working linear. SDR clamps to 0…1 (white); HDR keeps values above HDR
/// White up to 10 000 cd/m² (PQ) or the HLG signal's range.
#[inline]
pub fn decode(&self, v: [f32; 3]) -> [f32; 3] {
match *self {
GradeSpace::Sdr => v.map(|q| srgb_to_linear(q.clamp(0.0, 1.0))),
GradeSpace::Pq { norm, .. } => v.map(|q| pq_eotf((q * norm).clamp(0.0, 1.0)) * 10_000.0 / REFERENCE_WHITE_NITS as f32),
GradeSpace::Hlg { white, gamma } => {
// graded highlights may go past signal 1.0 (the display peak); keep them up to
// 1.5 (≈ 28× the peak) instead of clipping
let es = v.map(|q| hlg_inverse_oetf(q.clamp(0.0, 1.5)));
let l = luma2020();
let ys = l[0] * es[0] + l[1] * es[1] + l[2] * es[2];
if ys <= 0.0 {
return [0.0; 3];
}
let k = ys.powf(gamma - 1.0);
es.map(|e| e * k * white / REFERENCE_WHITE_NITS as f32)
}
}
}
/// The signal of a grey of `nits` cd/m² (HDR) — what a nit-scaled slider position means.
pub fn signal_of_nits(&self, nits: f32) -> f32 {
let w = nits / REFERENCE_WHITE_NITS as f32;
self.encode([w; 3])[1]
}
/// The cd/m² of a grey of signal `s` (HDR).
pub fn nits_of_signal(&self, s: f32) -> f32 {
self.decode([s; 3])[1] * REFERENCE_WHITE_NITS as f32
}
}
fn luma2020() -> [f32; 3] {
let l = Gamut::Bt2020.luma();
[l[0] as f32, l[1] as f32, l[2] as f32]
}
#[cfg(test)]
mod tests {
use super::*;
fn grey(nits: f32) -> [f32; 3] {
[nits / REFERENCE_WHITE_NITS as f32; 3]
}
#[test]
fn sdr_is_the_srgb_signal() {
let g = GradeSpace::new(WorkingSpace::Rec709, 1000.0);
assert!(!g.is_hdr());
let e = g.encode([0.18, 0.5, 1.0]);
assert!((e[0] - linear_to_srgb(0.18)).abs() < 1e-6 && (e[2] - 1.0).abs() < 1e-6);
// values above white clip on the way back (SDR)
assert_eq!(g.decode([1.4, 1.4, 1.4]), [1.0; 3]);
}
#[test]
fn pq_known_nit_values() {
// HDR White 10 000: the plain PQ signal; 203 cd/m² ≈ 58 % (BT.2408), 1000 cd/m² ≈ 75.2 %
let g = GradeSpace::new(WorkingSpace::Rec2100Pq, 10_000.0);
assert!((g.signal_of_nits(203.0) - 0.5807).abs() < 1e-3);
assert!((g.signal_of_nits(1000.0) - 0.7518).abs() < 1e-3);
assert!((g.signal_of_nits(100.0) - 0.5081).abs() < 1e-3);
// HDR White 1000: 1000 cd/m² is signal 1, reference white 0.772
let g = GradeSpace::new(WorkingSpace::Rec2100Pq, 1000.0);
assert!((g.signal_of_nits(1000.0) - 1.0).abs() < 1e-4);
assert!((g.signal_of_nits(203.0) - 0.5807 / 0.7518).abs() < 1e-3);
// above HDR White: > 1, not clipped, round trip to 4000 cd/m²
let s = g.signal_of_nits(4000.0);
assert!(s > 1.1);
assert!((g.nits_of_signal(s) - 4000.0).abs() < 4.0);
for n in [0.05f32, 1.0, 26.0, 100.0, 203.0, 600.0, 1000.0, 2500.0, 10_000.0] {
let back = g.decode(g.encode(grey(n)))[0] * REFERENCE_WHITE_NITS as f32;
assert!((back - n).abs() <= n * 2e-3 + 1e-3, "{n} → {back}");
}
}
#[test]
fn hlg_known_nit_values() {
// a 1000 cd/m² HLG display: reference white 203 cd/m² = 75 % (BT.2408), the peak = 100 %
let g = GradeSpace::new(WorkingSpace::Rec2100Hlg, 1000.0);
assert!((g.signal_of_nits(203.0) - 0.75).abs() < 2e-3, "{}", g.signal_of_nits(203.0));
assert!((g.signal_of_nits(1000.0) - 1.0).abs() < 1e-3);
// 18 % grey card (≈ 26 cd/m² on that display, BT.2408 table) ≈ 38 %
assert!((g.signal_of_nits(26.0) - 0.38).abs() < 0.01, "{}", g.signal_of_nits(26.0));
// colour round trip (the inverse OOTF depends on luminance)
let c = [2.0f32, 0.5, 0.25];
let back = g.decode(g.encode(c));
assert!(c.iter().zip(back).all(|(a, b)| (a - b).abs() < 1e-3 * a.max(1.0)), "{back:?}");
// a brighter display has a higher system gamma (BT.2100: 1.2 + 0.42 log10(Lw / 1000))
let GradeSpace::Hlg { gamma, .. } = GradeSpace::new(WorkingSpace::Rec2100Hlg, 2000.0) else { panic!() };
assert!((gamma - 1.2 - 0.42 * 2f32.log10()).abs() < 1e-5);
}
}

View File

@ -8,11 +8,13 @@
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
pub mod grade;
pub mod log;
pub mod lut;
pub mod spaces;
pub mod transform;
pub use grade::GradeSpace;
pub use log::LogCurve;
pub use lut::{Lut, Lut1d, Lut3d, LutFormat};
pub use spaces::{ColorPipeline, ColorSpace, Curve, Gamut, WorkingSpace};
@ -101,6 +103,33 @@ pub struct ColorInfo {
pub range: Range,
}
/// HDR static metadata of a stream: SMPTE ST 2086 mastering display luminance and the CTA-861.3
/// content light levels (MaxCLL / MaxFALL), in cd/m². Read from `mdcv` / `clli` (MP4 / MOV) and
/// Matroska `MasteringMetadata` / `MaxCLL` / `MaxFALL`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HdrMetadata {
pub mastering_max_nits: Option<f32>,
pub mastering_min_nits: Option<f32>,
pub max_cll: Option<f32>,
pub max_fall: Option<f32>,
}
impl HdrMetadata {
/// The content's peak for tone mapping: MaxCLL when signalled (the brightest pixel actually
/// in the content), else the mastering display's peak; `None` when neither is (0 means
/// unknown in both). Never below HDR reference white.
pub fn peak_nits(&self) -> Option<f32> {
let known = |v: Option<f32>| v.filter(|n| *n > 0.0);
let p = match (known(self.max_cll), known(self.mastering_max_nits)) {
(Some(c), Some(m)) => c.min(m),
(Some(c), None) => c,
(None, m) => m?,
};
Some(p.clamp(transform::REFERENCE_WHITE_NITS as f32, 10_000.0))
}
}
impl ColorInfo {
pub const SRGB_FULL: ColorInfo = ColorInfo { matrix: Matrix::Bt709, transfer: Transfer::Srgb, primaries: Primaries::Bt709, range: Range::Full };
pub const REC709: ColorInfo = ColorInfo { matrix: Matrix::Bt709, transfer: Transfer::Bt709, primaries: Primaries::Bt709, range: Range::Limited };

View File

@ -757,6 +757,7 @@ fn offline_file(s: &mut Session, p: &Value) -> Result<Value> {
color: Default::default(),
has_alpha: false,
bitrate: None,
hdr: None,
}),
audio: has_a.then(|| filmcraft_media::AudioStreamInfo {
sample_rate: u64_p(p, "sampleRate").unwrap_or(48_000) as u32,

View File

@ -4,6 +4,10 @@
//!
//! 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).
//!
//! Lumetri Presets (Effects panel): `lumetri.presets` lists them, `lumetri.applyPreset` adds a
//! Lumetri Color configured as the preset to clips (one undo step), `lumetri.presetThumbnails`
//! renders a folder's thumbnail grid (our Lumetri on the procedural preview picture) to a PNG.
use filmcraft_color::{ColorSpace, Lut, LutFormat, WorkingSpace};
use filmcraft_project::{ClipId, ItemId, ItemKind, ParamValue, ProjectLut, TrackKind};
@ -41,6 +45,14 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
interpret,
),
query("color.spaces", "List Colour Spaces", "{}", spaces),
query("lumetri.presets", "List Lumetri Presets", r#"{"folder":str?}"#, list_presets),
spec("lumetri.applyPreset", "Apply Lumetri Preset", &[], r#"{"name":str,"clips":[id]?}"#, has_seq, apply_preset),
query(
"lumetri.presetThumbnails",
"Lumetri Preset Thumbnails",
r#"{"folder":str?,"names":[str]?,"width":n=160,"columns":n=4,"path":str?}"#,
preset_thumbnails,
),
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),
@ -323,9 +335,74 @@ fn color_info(s: &mut Session, p: &Value) -> Result<Value> {
"effective": effective.map(|c| c.id()),
"effectiveLabel": effective.map(|c| c.label()),
"hdr": effective.is_some_and(|c| c.is_hdr()),
// mastering display / content light level and the peak tone mapping uses
"hdrMetadata": m.info.video.as_ref().and_then(|v| v.hdr),
"toneMapPeakNits": m.info.video.as_ref().and_then(|v| v.hdr.and_then(|h| h.peak_nits())),
}))
}
fn presets_of(p: &Value) -> Vec<filmcraft_render::lumetri_presets::LumetriPreset> {
let all = filmcraft_render::lumetri_presets::presets();
let names: Option<Vec<String>> = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_lowercase)).collect());
let folder = str_p(p, "folder");
all.into_iter()
.filter(|x| folder.is_none_or(|f| x.folder.eq_ignore_ascii_case(f)))
.filter(|x| names.as_ref().is_none_or(|n| n.contains(&x.name.to_lowercase())))
.collect()
}
fn list_presets(_: &mut Session, p: &Value) -> Result<Value> {
let v: Vec<Value> = presets_of(p).iter().map(|x| json!({"folder": x.folder, "name": x.name, "description": x.description})).collect();
Ok(json!({"folders": filmcraft_render::lumetri_presets::FOLDERS, "presets": v}))
}
/// Lumetri Presets ▸ apply: a new Lumetri Color configured as the preset on each video clip
/// (standard effects go before the intrinsic ones, like `effects.apply`).
fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "lumetri.applyPreset";
let name = str_p(p, "name").ok_or_else(|| bad(cmd, "need `name`"))?;
let preset = filmcraft_render::lumetri_presets::find(name).ok_or_else(|| bad(cmd, format!("no Lumetri preset `{name}`")))?;
let clips = crate::commands::clips_p(s, p);
if clips.is_empty() {
return Err(bad(cmd, "select clips first"));
}
let inst = preset.instance();
let n = s.edit_sequence(&format!("Apply {}", preset.name), |q, _, _| {
let mut n = 0;
for t in q.video_tracks.iter_mut() {
for it in t.items.iter_mut().filter(|i| clips.contains(&i.id)) {
let pos = it.effects.iter().position(|e| e.def().is_some_and(|d| d.intrinsic)).unwrap_or(it.effects.len());
it.effects.insert(pos, inst.clone());
n += 1;
}
}
Ok(n)
})?;
if n == 0 {
return Err(bad(cmd, "no video clips among the clips"));
}
Ok(json!({"preset": preset.name, "clips": n}))
}
fn preset_thumbnails(_: &mut Session, p: &Value) -> Result<Value> {
let cmd = "lumetri.presetThumbnails";
let presets = presets_of(p);
if presets.is_empty() {
return Err(bad(cmd, "no presets match"));
}
let w = p.get("width").and_then(Value::as_u64).unwrap_or(160).clamp(16, 1920) as usize;
let h = (w * 9 / 16).max(9);
let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(4).clamp(1, 32) as usize;
let img = filmcraft_render::lumetri_presets::grid(&presets, cols, w, h);
let mut out = json!({"presets": presets.iter().map(|x| x.name).collect::<Vec<_>>(), "width": img.w, "height": img.h, "cell": [w, h], "columns": cols});
if let Some(path) = str_p(p, "path") {
let png = filmcraft_export::encode_png(img.over_black_rgba8(), img.w as u32, img.h as u32).map_err(|e| bad(cmd, e.to_string()))?;
std::fs::write(path, png).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
out["path"] = json!(path);
}
Ok(out)
}
/// Lumetri ▸ Color Wheels & Match ▸ Apply Match (see `filmcraft_render::color_match`): match the
/// clip at the playhead to the sequence frame at the reference time. Sets the three wheels, their
/// lightness and Basic saturation in one undo step.

View File

@ -194,3 +194,63 @@ fn interpret_footage_and_sequence_colour_settings() {
assert_eq!(spaces["colorSpaces"].as_array().unwrap().len(), filmcraft_color::ColorSpace::ALL.len());
assert!(s.execute("sequence.colorSettings", json!({"workingSpace": "xyz"})).is_err());
}
#[test]
fn lumetri_presets_list_apply_and_thumbnails() {
let mut s = demo();
let clip = pick_clip(&mut s);
let r = s.execute("lumetri.presets", json!({})).unwrap();
assert_eq!(r["folders"], json!(["Cinematic", "Film Emulation", "Monochrome", "Technical"]));
let mono = s.execute("lumetri.presets", json!({"folder": "monochrome"})).unwrap();
assert!(mono["presets"].as_array().unwrap().iter().all(|p| p["folder"] == "Monochrome"));
// apply: one undo step adding a configured Lumetri Color before the intrinsic effects
let before = s.active_sequence().unwrap().find_item(clip).unwrap().1.effects.len();
let r = s.execute("lumetri.applyPreset", json!({"name": "Neutral Mono"})).unwrap();
assert_eq!(r["clips"], 1);
let it = s.active_sequence().unwrap().find_item(clip).unwrap().1.clone();
assert_eq!(it.effects.len(), before + 1);
let k = it.effects.iter().position(|e| e.effect == "lumetri").unwrap();
assert!(it.effects[k + 1..].iter().all(|e| e.def().is_some_and(|d| d.intrinsic)) || k + 1 == it.effects.len());
assert_eq!(it.effects[k].param("saturation").unwrap().value, ParamValue::Float(0.0));
let img = s.render_program(0.25).unwrap();
let grey = img.px.chunks_exact(4).filter(|p| p[3] > 0.5).all(|p| (p[0] - p[1]).abs() < 0.01 && (p[1] - p[2]).abs() < 0.01);
assert!(grey, "the program is black and white");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active_sequence().unwrap().find_item(clip).unwrap().1.effects.len(), before);
assert!(s.execute("lumetri.applyPreset", json!({"name": "No Such Look"})).is_err());
// thumbnails of a folder, written as a PNG
let path = tmp("lumetri-presets.png");
let r = s.execute("lumetri.presetThumbnails", json!({"folder": "Technical", "width": 64, "columns": 3, "path": path})).unwrap();
let n = r["presets"].as_array().unwrap().len();
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(3 * 68 + 4), Some((n.div_ceil(3) * 40 + 4) as u64)));
assert!(std::fs::read(&path).unwrap().starts_with(b"\x89PNG"));
}
#[test]
fn hdr_lumetri_and_mastering_metadata_through_commands() {
let mut s = demo();
let clip = pick_clip(&mut s);
s.execute("sequence.colorSettings", json!({"workingSpace": "rec2100-pq"})).unwrap();
s.execute("effects.apply", json!({"clips": [clip.0], "effect": "lumetri"})).unwrap();
let idx = s.active_sequence().unwrap().find_item(clip).unwrap().1.effects.iter().position(|e| e.effect == "lumetri").unwrap();
// +1 stop doubles the light of the (SDR) clip in the PQ working space: no clipping at
// 203 cd/m², the HDR working output keeps the values above reference white
let base = s.render_program_working(0.25).unwrap();
s.execute("effects.setParam", json!({"clip": clip.0, "effect": idx, "param": "exposure", "value": 1.0})).unwrap();
let up = s.render_program_working(0.25).unwrap();
let (m0, m1) = (mean(&base), mean(&up));
for k in 0..3 {
assert!((m1[k] / m0[k] - 2.0).abs() < 0.08, "channel {k}: {m0:?} → {m1:?}");
}
assert!(up.px.chunks_exact(4).any(|p| p[1] > 1.2), "values above reference white survive");
// HDR White / HDR Specular / HDR Range are ordinary Lumetri parameters
s.execute("effects.setParam", json!({"clip": clip.0, "effect": idx, "param": "hdr_white", "value": 2000.0})).unwrap();
s.execute("effects.setParam", json!({"clip": clip.0, "effect": idx, "param": "hdr_specular", "value": -50.0})).unwrap();
s.execute("effects.setParam", json!({"clip": clip.0, "effect": idx, "param": "curves_hdr_range", "value": 4000.0})).unwrap();
let it = s.active_sequence().unwrap().find_item(clip).unwrap().1.clone();
assert_eq!(it.effects[idx].param("hdr_white").unwrap().value, ParamValue::Float(2000.0));
// media.colorInfo reports the HDR metadata (none for the demo's generated media)
let item = it.item;
let r = s.execute("media.colorInfo", json!({"item": item.0})).unwrap();
assert!(r["hdrMetadata"].is_null() && r["toneMapPeakNits"].is_null(), "{r}");
}

View File

@ -222,6 +222,12 @@ fn hdr_exports_signal_pq_and_hlg() {
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}");
// PQ exports carry mastering metadata (1000 cd/m² peak; MaxCLL 0 = unknown), which sets
// the tone-mapping peak when the file is used again
if transfer == Transfer::Pq {
let hdr = v.hdr.unwrap_or_else(|| panic!("{path}: no HDR metadata"));
assert_eq!((hdr.mastering_max_nits, hdr.max_cll, hdr.peak_nits()), (Some(1000.0), None, Some(1000.0)), "{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);

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@ -0,0 +1,8 @@
asset: lumetri_hdr_pq.png
title: Lumetri grading in a Rec. 2100 PQ sequence (HDR White, HDR Specular), 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-03 by FilmCraft contributors (FILMCRAFT_BLESS=1)
notes: Golden reference image generated by crates/golden/tests/golden.rs (lumetri_hdr_pq). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork.

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@ -0,0 +1,8 @@
asset: lumetri_presets_grid.png
title: Lumetri Presets thumbnail grid
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-03 by FilmCraft contributors (FILMCRAFT_BLESS=1)
notes: Golden reference image generated by crates/golden/tests/golden.rs (lumetri_presets_grid). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork.

View File

@ -457,6 +457,23 @@ fn hdr_tone_map() -> Scene {
b.at(12)
}
/// HDR grading: Rec. 2100 PQ footage in a PQ sequence, graded by Lumetri in the PQ signal
/// (exposure, contrast over HDR White, HDR Specular pulling the speculars in), shown tone mapped.
fn lumetri_hdr_pq() -> Scene {
let mut b = Builder::new(1);
b.p.sequence_mut(b.seq).unwrap().settings.color =
filmcraft_color::ColorPipeline { working: filmcraft_color::WorkingSpace::Rec2100Pq, ..filmcraft_color::ColorPipeline::REC709 };
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);
let c = b.place(0, src, 0, 48);
b.effect(
c,
"lumetri",
&[("exposure", fl(0.4)), ("contrast", fl(30.0)), ("hdr_white", fl(1000.0)), ("hdr_specular", fl(-60.0)), ("temperature", fl(-15.0))],
);
b.at(12)
}
/// Gaussian Blur limited to a feathered ellipse mask (media is 640×360 clip pixels).
fn mask_blur() -> Scene {
let mut b = Builder::new(1);
@ -608,6 +625,7 @@ fn scenes() -> Vec<(&'static str, &'static str, fn() -> Scene)> {
("graphic_rich_text", "Per-character text styles and a box pinned around the text", graphic_rich_text),
("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),
("lumetri_hdr_pq", "Lumetri grading in a Rec. 2100 PQ sequence (HDR White, HDR Specular), tone mapped", lumetri_hdr_pq),
("mask_blur", "Masks: Gaussian Blur inside a feathered ellipse mask", mask_blur),
("mask_color", "Masks: Black & White inside an expanded polygon minus a feathered Bezier mask", mask_color),
("mask_inverted", "Masks: Mosaic outside an inverted ellipse mask at 80% opacity", mask_inverted),
@ -675,6 +693,7 @@ goldens!(
graphic_rich_text,
log_to_rec709,
hdr_tone_map,
lumetri_hdr_pq,
mask_blur,
mask_color,
mask_inverted,
@ -687,6 +706,18 @@ goldens!(
vfx_stylize
);
/// Effects panel ▸ Lumetri Presets thumbnail grid: every built-in preset graded by our Lumetri on
/// the procedural preview picture (`filmcraft_render::lumetri_presets`).
#[test]
fn lumetri_presets_grid() {
use filmcraft_render::lumetri_presets as lp;
let img = lp::grid(&lp::presets(), 6, 96, 54);
let rgba = Rgba8::new(img.w as u32, img.h as u32, img.over_black_rgba8());
let name = "lumetri_presets_grid";
let d = assert_golden(&golden_path(name), &rgba, Tolerance::RENDER, "Lumetri Presets thumbnail grid", &format!("crates/golden/tests/golden.rs ({name})"));
eprintln!("{name}: {d}");
}
/// Sanity: the scenes are not trivially empty or identical to each other.
#[test]
fn scenes_are_distinct() {

View File

@ -221,6 +221,7 @@ mod tests {
color: Default::default(),
has_alpha: false,
bitrate: None,
hdr: None,
}),
audio: (audio > 0).then(|| AudioStreamInfo { sample_rate: 48_000, channels: audio, codec: "aac".into(), bits_per_sample: None }),
container: "mp4".into(),

View File

@ -249,6 +249,7 @@ pub(crate) fn media_info(name: &str, spec: &MediaSpec) -> MediaInfo {
color: ColorInfo::REC709,
has_alpha: false,
bitrate: None,
hdr: None,
}),
audio: spec.audio.map(|(sr, ch)| AudioStreamInfo { sample_rate: sr, channels: ch, codec: String::new(), bits_per_sample: None }),
container: String::new(),

View File

@ -23,6 +23,7 @@ pub fn media_info(name: &str, video: bool, audio: bool, rate: FrameRate, secs: i
color: Default::default(),
has_alpha: false,
bitrate: None,
hdr: None,
}),
audio: audio.then(|| filmcraft_media::AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "aac".into(), bits_per_sample: None }),
container: "mp4".into(),

View File

@ -99,6 +99,7 @@ impl GeneratorSource {
color: ColorInfo::SRGB_FULL,
has_alpha: matches!(generator, Generator::TransparentVideo),
bitrate: None,
hdr: None,
});
let audio =
generator.has_audio().then(|| AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "PCM (synthetic)".into(), bits_per_sample: Some(32) });

View File

@ -70,6 +70,9 @@ pub struct VideoStreamInfo {
pub has_alpha: bool,
/// Bitrate in bits/s when known.
pub bitrate: Option<u64>,
/// HDR static metadata (mastering display, content light level) when the file carries it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hdr: Option<filmcraft_color::HdrMetadata>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]

View File

@ -145,6 +145,7 @@ impl ImageSequenceSource {
color: filmcraft_color::ColorInfo::SRGB_FULL,
has_alpha,
bitrate: None,
hdr: None,
}),
audio: None,
container: "Image Sequence".into(),

View File

@ -50,6 +50,7 @@ impl StillSource {
color: filmcraft_color::ColorInfo::SRGB_FULL,
has_alpha,
bitrate: None,
hdr: None,
}),
audio: None,
container: "Image".into(),

View File

@ -411,6 +411,9 @@ fn build_effects() -> Vec<EffectDef> {
grp(f("whites", "Whites", 0.0, -100.0, 100.0, ""), "Basic Correction"),
grp(f("blacks", "Blacks", 0.0, -100.0, 100.0, ""), "Basic Correction"),
grp(fs("saturation", "Saturation", 100.0, (0.0, 200.0), (0.0, 200.0), "", 1), "Basic Correction"),
// HDR mode (PQ / HLG sequences): the grading range in cd/m² and the speculars above it
grp(fs("hdr_white", "HDR White", 1000.0, (100.0, 10_000.0), (100.0, 10_000.0), "nits", 0), "Basic Correction"),
grp(f("hdr_specular", "HDR Specular", 0.0, -100.0, 100.0, ""), "Basic Correction"),
grp(b("creative_on", "Creative", true), "Creative"),
grp(
ch(
@ -439,6 +442,7 @@ fn build_effects() -> Vec<EffectDef> {
grp(curve("hue_vs_luma", "Hue vs Luma", true), "Curves"),
grp(curve("luma_vs_sat", "Luma vs Sat", true), "Curves"),
grp(curve("sat_vs_sat", "Sat vs Sat", true), "Curves"),
grp(fs("curves_hdr_range", "HDR Range", 1000.0, (100.0, 10_000.0), (100.0, 10_000.0), "nits", 0), "Curves"),
grp(b("wheels_on", "Color Wheels & Match", true), "Color Wheels & Match"),
grp(wheel("wheel_shadows", "Shadows"), "Color Wheels & Match"),
grp(f("wheel_shadows_l", "Shadows Lightness", 0.0, -100.0, 100.0, ""), "Color Wheels & Match"),
@ -454,6 +458,8 @@ fn build_effects() -> Vec<EffectDef> {
grp(fs("hsl_luma_max", "Luma Max", 95.0, (0.0, 100.0), (0.0, 100.0), "", 0), "HSL Secondary"),
grp(f("hsl_soft", "Soften", 20.0, 0.0, 100.0, ""), "HSL Secondary"),
grp(ch("hsl_show_mask", "Show Mask", &["Off", "Color/Gray", "Color/Black", "White/Black"], 0), "HSL Secondary"),
grp(f("hsl_denoise", "Denoise", 0.0, 0.0, 100.0, ""), "HSL Secondary"),
grp(f("hsl_blur", "Blur", 0.0, 0.0, 100.0, ""), "HSL Secondary"),
grp(f("hsl_temp", "Temperature", 0.0, -100.0, 100.0, ""), "HSL Secondary"),
grp(f("hsl_tint", "Tint", 0.0, -100.0, 100.0, ""), "HSL Secondary"),
grp(fs("hsl_sat", "Saturation", 100.0, (0.0, 200.0), (0.0, 200.0), "", 1), "HSL Secondary"),

View File

@ -184,7 +184,16 @@ fn set_params(base: &EffectInstance, x: &[f32; 10]) -> EffectInstance {
fn graded_stats(img: &Image, base: &EffectInstance, x: &[f32; 10], skin_protect: bool) -> Stats {
let mut g = img.clone();
let cx = FxCtx { t: Tick::ZERO, px_scale: g.w as f32 / 1920.0, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None };
let cx = FxCtx {
t: Tick::ZERO,
px_scale: g.w as f32 / 1920.0,
seconds: 0.0,
timecode: "",
clip_name: "",
project: None,
env: None,
working: filmcraft_color::WorkingSpace::Rec709,
};
apply(&mut g, &set_params(base, x), &cx);
stats(&g, skin_protect)
}
@ -344,7 +353,16 @@ mod tests {
fn graded(img: &Image, base: &EffectInstance, x: [f32; 10]) -> Image {
let mut g = img.clone();
let cx = FxCtx { t: Tick::ZERO, px_scale: 0.1, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None };
let cx = FxCtx {
t: Tick::ZERO,
px_scale: 0.1,
seconds: 0.0,
timecode: "",
clip_name: "",
project: None,
env: None,
working: filmcraft_color::WorkingSpace::Rec709,
};
apply(&mut g, &set_params(base, &x), &cx);
g
}

View File

@ -34,17 +34,27 @@ pub fn needs_management(pipe: &ColorPipeline, cs: ColorSpace, frame: &VideoFrame
!(pipe.is_plain() && default_decode)
}
type Key = (ColorSpace, Range, ColorPipeline);
type Key = (ColorSpace, Range, ColorPipeline, Option<u32>);
/// A cached input transform.
pub fn input_transform(cs: ColorSpace, range: Range, pipe: &ColorPipeline) -> Arc<InputTransform> {
/// The content peak (cd/m²) of an item's HDR metadata (MaxCLL, else the mastering display peak),
/// following subclips to their media. Used as the tone-mapping source peak of PQ media.
pub fn source_peak_nits(project: &Project, item: ItemId) -> Option<f32> {
match &project.item(item)?.kind {
ItemKind::Media(m) => m.info.video.as_ref()?.hdr.as_ref()?.peak_nits(),
ItemKind::Subclip { parent, .. } => source_peak_nits(project, *parent),
_ => None,
}
}
/// A cached input transform (`peak_nits`: the PQ content peak from the file's HDR metadata).
pub fn input_transform(cs: ColorSpace, range: Range, pipe: &ColorPipeline, peak_nits: Option<f32>) -> 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);
let key = (cs, range, *pipe, peak_nits.map(|p| p.round() as u32));
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));
let t = Arc::new(InputTransform::new(cs, range, pipe, key.3.map(f64::from)));
c.lock().unwrap_or_else(|e| e.into_inner()).insert(key, t.clone());
t
}
@ -61,7 +71,7 @@ pub fn decode(project: &Project, item: ItemId, frame: &VideoFrame, n: usize, pip
filmcraft_frame::PixelData::Rgba8(_) | filmcraft_frame::PixelData::RgbaF32(_) => Range::Full,
_ => frame.color.range,
};
let t = input_transform(cs, range, pipe);
let t = input_transform(cs, range, pipe, source_peak_nits(project, item));
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) });
@ -101,6 +111,56 @@ mod tests {
f
}
#[test]
fn mastering_metadata_sets_the_tone_mapping_peak() {
use filmcraft_color::HdrMetadata;
use filmcraft_media::MediaSource;
use filmcraft_project::{Label, MediaClip, MediaRef};
let pq = ColorInfo { transfer: Transfer::Pq, primaries: Primaries::Bt2020, ..ColorInfo::SRGB_FULL };
// a PQ grey of ≈ 2000 cd/m² (code 0.83) and one of ≈ 4000 cd/m² (code 0.90)
let px = |nits: f32| (filmcraft_color::pq_inverse_eotf(nits / 10_000.0) * 255.0).round() as u8;
let item_with = |hdr: Option<HdrMetadata>| {
let mut p = Project::new("t");
let g = filmcraft_media::Generator::BlackVideo;
let mut info = filmcraft_media::generators::GeneratorSource::new(g.clone(), 2, 2, Default::default(), filmcraft_time::Tick(1)).info().clone();
if let Some(v) = info.video.as_mut() {
v.color = pq;
v.hdr = hdr;
}
let clip = MediaClip {
media: MediaRef::Generator(g),
info,
interpret: Default::default(),
mark_in: None,
mark_out: None,
markers: vec![],
offline: false,
proxy: None,
identity: None,
};
let id = p.add_item("hdr", Label::Iris, ItemKind::Media(clip), None);
(p, id)
};
let out = |hdr: Option<HdrMetadata>, nits: f32| {
let (p, id) = item_with(hdr);
decode(&p, id, &frame([px(nits); 3], pq), 1, &ColorPipeline::REC709).px[0]
};
// no metadata: a 1000 cd/m² master is assumed, so 2000 and 4000 both clip to SDR white
assert!((out(None, 2000.0) - 1.0).abs() < 0.01 && (out(None, 4000.0) - 1.0).abs() < 0.01);
// MaxCLL 4000: the knee spreads up to 4000, so 1000 and 2000 stay below white and 4000 reaches it
let m4000 = HdrMetadata { max_cll: Some(4000.0), mastering_max_nits: Some(4000.0), ..Default::default() };
let (a, a2, b) = (out(Some(m4000), 1000.0), out(Some(m4000), 2000.0), out(Some(m4000), 4000.0));
assert!(a < out(None, 1000.0) - 0.01 && a > 0.8 && a < a2 && a2 < b && (b - 1.0).abs() < 0.02, "{a} {a2} {b}");
// the mastering peak alone works too; MaxCLL wins when it is lower (the real content peak)
let md = HdrMetadata { mastering_max_nits: Some(4000.0), ..Default::default() };
assert_eq!(md.peak_nits(), Some(4000.0));
let both = HdrMetadata { mastering_max_nits: Some(4000.0), max_cll: Some(1500.0), ..Default::default() };
assert_eq!(both.peak_nits(), Some(1500.0));
assert_eq!(HdrMetadata { max_cll: Some(0.0), ..Default::default() }.peak_nits(), None, "0 = unknown");
// and reference white is no lower peak than 203
assert_eq!(HdrMetadata { max_cll: Some(80.0), ..Default::default() }.peak_nits(), Some(203.0));
}
#[test]
fn plain_media_keeps_the_fast_path() {
let f = frame([200, 100, 50], ColorInfo::SRGB_FULL);

View File

@ -5,7 +5,7 @@
//! (so ½/¼ playback resolution renders the same look). Colour-grading math runs on display-encoded
//! straight colour where artists expect it (contrast, levels, posterize), linear light elsewhere.
use filmcraft_color::{hsl_to_rgb, linear_to_srgb, luma709, rgb_to_hsl, srgb_to_linear};
use filmcraft_color::{GradeSpace, hsl_to_rgb, linear_to_srgb, luma709, rgb_to_hsl, srgb_to_linear};
use filmcraft_geom::{Affine, Vec2};
use filmcraft_project::{EffectInstance, ParamValue};
use filmcraft_time::Tick;
@ -29,6 +29,9 @@ pub struct FxCtx<'a> {
/// The clip's surroundings (other frames, other tracks, sequence geometry) for temporal,
/// track-reading and reframing effects; `None` in isolated tests and adjustment layers.
pub env: Option<&'a dyn crate::vfx::FxEnv>,
/// The sequence's working colour space: Lumetri grades HDR (PQ / HLG) working spaces in their
/// own signal, normalised to HDR White ([`filmcraft_color::GradeSpace`]).
pub working: filmcraft_color::WorkingSpace,
}
fn f(e: &EffectInstance, id: &str, cx: &FxCtx) -> f32 {
@ -845,10 +848,33 @@ pub(crate) fn key(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
});
}
/// The grading signal of the effect's section: HDR White (`white_id`) applies in HDR working
/// spaces only.
fn grade_space(e: &EffectInstance, cx: &FxCtx, white_id: &str) -> GradeSpace {
GradeSpace::new(cx.working, f(e, white_id, cx))
}
/// Run `f` on a grading signal that may exceed 1 (HDR highlights above HDR White) by scaling it
/// into 0…1 first and back afterwards, so operations defined on 0…1 (LUTs, HSL) keep the
/// highlights instead of clipping them. SDR signals are clamped as before.
#[inline]
fn within_unit(v: [f32; 3], hdr: bool, f: impl Fn([f32; 3]) -> [f32; 3]) -> [f32; 3] {
if !hdr {
return f(v.map(|q| q.clamp(0.0, 1.0)));
}
let m = v[0].max(v[1]).max(v[2]).max(1.0);
let o = f(v.map(|q| (q / m).max(0.0)));
o.map(|q| q * m)
}
fn lumetri(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let (basic_on, creative_on, vignette_on) = (on(e, "basic_on"), on(e, "creative_on"), on(e, "vignette_on"));
let input_lut = if basic_on { crate::luts::resolve(cx.project, text(e, "input_lut")) } else { None };
let bf = |id: &str| if basic_on { f(e, id, cx) } else { 0.0 };
// HDR: the sliders work on the PQ / HLG signal normalised to HDR White (cd/m²)
let gs = grade_space(e, cx, "hdr_white");
let hdr = gs.is_hdr();
let specular = if hdr { bf("hdr_specular") / 100.0 } else { 0.0 };
let temp = bf("temperature") / 100.0;
let tint = bf("tint") / 100.0;
let exposure = 2f32.powf(bf("exposure"));
@ -872,12 +898,12 @@ fn lumetri(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let aspect = w / h;
img.map_rgb(|c, x, y| {
let c = match &input_lut {
Some(l) => dec(l.apply(enc(c))),
Some(l) => gs.decode(within_unit(gs.encode(c), hdr, |v| l.apply(v))),
None => c,
};
// white balance + exposure in linear light
let lin = [c[0] * gains[0] * exposure, c[1] * gains[1] * exposure, c[2] * gains[2] * exposure];
let mut v = enc(lin);
let mut v = gs.encode(lin);
// whites / blacks: endpoints
let b0 = -bl * 0.15;
let w0 = 1.0 - wh * 0.15;
@ -891,15 +917,23 @@ fn lumetri(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let k = nl / l;
v = v.map(|q| q * k);
}
// contrast: smooth S-curve around mid grey
// contrast: smooth S-curve around mid grey (HDR: over 0 … HDR White; speculars above it
// keep their distance from white)
if contrast.abs() > 1e-4 {
let k = 1.0 + contrast;
v = v.map(|q| {
if hdr && q > 1.0 {
return q;
}
let q = q.clamp(0.0, 1.0);
let s = q * q * (3.0 - 2.0 * q);
if k >= 1.0 { q + (s - q) * (k - 1.0) } else { 0.5 + (q - 0.5) * k }
});
}
// HDR Specular: brightness of the highlights above HDR White
if specular.abs() > 1e-4 {
v = v.map(|q| if q > 1.0 { 1.0 + (q - 1.0) * (1.0 + specular).max(0.0) } else { q });
}
// faded film: lift blacks and compress
if faded > 0.0 {
v = v.map(|q| q * (1.0 - 0.25 * faded) + 0.12 * faded);
@ -926,9 +960,10 @@ fn lumetri(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let k = 1.0 + va * 0.2 * e2;
v = v.map(|q| if va < 0.0 { q * k.max(0.0) } else { q + (1.0 - q) * (k - 1.0) });
}
dec(v)
gs.decode(v)
});
lumetri_advanced(img, e, cx);
hsl_secondary(img, e, cx);
if sharpen.abs() > 1e-3 {
unsharp(img, 1.2 * cx.px_scale.max(0.35), sharpen.max(-1.0), 0.0);
}
@ -1109,7 +1144,16 @@ mod tests {
use filmcraft_project::find_effect;
fn cx() -> FxCtx<'static> {
FxCtx { t: Tick::ZERO, px_scale: 1.0, seconds: 0.0, timecode: "00:00:01:00", clip_name: "x", project: None, env: None }
FxCtx {
t: Tick::ZERO,
px_scale: 1.0,
seconds: 0.0,
timecode: "00:00:01:00",
clip_name: "x",
project: None,
env: None,
working: filmcraft_color::WorkingSpace::Rec709,
}
}
#[test]
@ -1303,7 +1347,6 @@ fn lumetri_advanced(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let (ws, wm, wh) = (wheel_rgb(v2("wheel_shadows")), wheel_rgb(v2("wheel_midtones")), wheel_rgb(v2("wheel_highlights")));
let (ls, lm, lh) = (f(e, "wheel_shadows_l", cx) / 100.0, f(e, "wheel_midtones_l", cx) / 100.0, f(e, "wheel_highlights_l", cx) / 100.0);
let wheels = wheels_on && (ws.iter().chain(&wm).chain(&wh).any(|v| v.abs() > 1e-5) || ls.abs() + lm.abs() + lh.abs() > 1e-5);
let hsl_on = b(e, "hsl_on");
let any = luma_c.is_some()
|| red_c.is_some()
|| green_c.is_some()
@ -1315,30 +1358,30 @@ fn lumetri_advanced(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
|| svs.is_some()
|| look > 0
|| look_lut.is_some()
|| wheels
|| hsl_on;
|| wheels;
if !any {
return;
}
let (hc, hr) = (f(e, "hsl_hue", cx) / 360.0, (f(e, "hsl_hue_range", cx) / 360.0).max(1e-3));
let (smin, lmin, lmax) = (f(e, "hsl_sat_min", cx) / 100.0, f(e, "hsl_luma_min", cx) / 100.0, f(e, "hsl_luma_max", cx) / 100.0);
let soft = (f(e, "hsl_soft", cx) / 100.0 * 0.3).max(0.01);
let show_mask = choice(e, "hsl_show_mask");
let (htemp, htint, hsat, hshift) =
(f(e, "hsl_temp", cx) / 100.0, f(e, "hsl_tint", cx) / 100.0, f(e, "hsl_sat", cx) / 100.0, f(e, "hsl_hue_shift", cx) / 360.0);
// HDR: curves, wheels and looks span 0 … the curves' HDR Range (cd/m²)
let gs = grade_space(e, cx, "curves_hdr_range");
let hdr = gs.is_hdr();
let sample = |l: &Vec<f32>, x: f32| {
// above HDR White a curve continues with slope 1 from its end point
if hdr && x > 1.0 {
return l[N - 1] + (x - 1.0);
}
let p = x.clamp(0.0, 1.0) * (N - 1) as f32;
let i = p as usize;
let j = (i + 1).min(N - 1);
l[i] + (l[j] - l[i]) * (p - i as f32)
};
img.map_rgb(|c, _, _| {
let mut v = enc(c);
let mut v = gs.encode(c);
if let Some(l) = &look_lut {
let lk = l.apply(v);
let lk = within_unit(v, hdr, |u| l.apply(u));
v = [v[0] + (lk[0] - v[0]) * look_k, v[1] + (lk[1] - v[1]) * look_k, v[2] + (lk[2] - v[2]) * look_k];
} else if look > 0 {
let lk = apply_look(look, v);
let lk = if hdr { within_unit(v, true, |u| apply_look(look, u)) } else { apply_look(look, v) };
v = [v[0] + (lk[0] - v[0]) * look_k, v[1] + (lk[1] - v[1]) * look_k, v[2] + (lk[2] - v[2]) * look_k];
}
if wheels {
@ -1366,57 +1409,178 @@ fn lumetri_advanced(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
v[2] = sample(l, v[2]);
}
if hvs.is_some() || hvh.is_some() || hvl.is_some() || lvs.is_some() || svs.is_some() {
let mut h = rgb_to_hsl(v[0].clamp(0.0, 1.0), v[1].clamp(0.0, 1.0), v[2].clamp(0.0, 1.0));
let (h0, s0, l0) = (h[0], h[1], h[2]);
if let Some(t) = &hvh {
h[0] = (h[0] + (sample(t, h0) - 0.5)).rem_euclid(1.0);
}
let mut sm = 1.0;
if let Some(t) = &hvs {
sm *= sample(t, h0) * 2.0;
}
if let Some(t) = &lvs {
sm *= sample(t, l0) * 2.0;
}
if let Some(t) = &svs {
sm *= sample(t, s0) * 2.0;
}
h[1] = (h[1] * sm).clamp(0.0, 1.0);
if let Some(t) = &hvl {
h[2] = (h[2] + (sample(t, h0) - 0.5) * 0.5).clamp(0.0, 1.0);
}
v = hsl_to_rgb(h[0], h[1], h[2]);
}
if hsl_on {
let h = rgb_to_hsl(v[0].clamp(0.0, 1.0), v[1].clamp(0.0, 1.0), v[2].clamp(0.0, 1.0));
let dh = (h[0] - hc).abs().min(1.0 - (h[0] - hc).abs());
let mh = 1.0 - ((dh - hr / 2.0) / soft).clamp(0.0, 1.0);
let ms = ((h[1] - smin) / soft).clamp(0.0, 1.0);
let ml = ((h[2] - lmin) / soft).clamp(0.0, 1.0).min(((lmax - h[2]) / soft).clamp(0.0, 1.0));
let m = mh * ms * ml;
match show_mask {
1 => {
let g = h[2];
v = [g + (v[0] - g) * m, g + (v[1] - g) * m, g + (v[2] - g) * m];
v = within_unit(v, hdr, |u| {
let mut h = rgb_to_hsl(u[0], u[1], u[2]);
let (h0, s0, l0) = (h[0], h[1], h[2]);
if let Some(t) = &hvh {
h[0] = (h[0] + (sample(t, h0) - 0.5)).rem_euclid(1.0);
}
2 => v = v.map(|q| q * m),
3 => v = [m, m, m],
_ => {
let mut hh = h;
hh[0] = (hh[0] + hshift).rem_euclid(1.0);
hh[1] = (hh[1] * hsat).clamp(0.0, 1.0);
let mut c2 = hsl_to_rgb(hh[0], hh[1], hh[2]);
c2[0] *= 1.0 + 0.25 * htemp;
c2[2] *= 1.0 - 0.25 * htemp;
c2[1] *= 1.0 - 0.2 * htint;
v = [v[0] + (c2[0] - v[0]) * m, v[1] + (c2[1] - v[1]) * m, v[2] + (c2[2] - v[2]) * m];
let mut sm = 1.0;
if let Some(t) = &hvs {
sm *= sample(t, h0) * 2.0;
}
}
if let Some(t) = &lvs {
sm *= sample(t, l0) * 2.0;
}
if let Some(t) = &svs {
sm *= sample(t, s0) * 2.0;
}
h[1] = (h[1] * sm).clamp(0.0, 1.0);
if let Some(t) = &hvl {
h[2] = (h[2] + (sample(t, h0) - 0.5) * 0.5).clamp(0.0, 1.0);
}
hsl_to_rgb(h[0], h[1], h[2])
});
}
dec(v)
gs.decode(v)
});
}
/// The HSL Secondary key of one pixel's grading signal (0…1 per channel).
#[inline]
fn hsl_key(v: [f32; 3], hc: f32, hr: f32, smin: f32, lmin: f32, lmax: f32, soft: f32) -> f32 {
let h = rgb_to_hsl(v[0], v[1], v[2]);
let dh = (h[0] - hc).abs().min(1.0 - (h[0] - hc).abs());
let mh = 1.0 - ((dh - hr / 2.0) / soft).clamp(0.0, 1.0);
let ms = ((h[1] - smin) / soft).clamp(0.0, 1.0);
let ml = ((h[2] - lmin) / soft).clamp(0.0, 1.0).min(((lmax - h[2]) / soft).clamp(0.0, 1.0));
mh * ms * ml
}
/// Lumetri ▸ HSL Secondary: key on hue / saturation / lightness, Refine (Denoise, Blur) the key,
/// then correct inside it (or show the mask).
///
/// - **Denoise** removes speckle from the key: a median filter (radius 1–3 px at full
/// resolution, scaled with the playback resolution) mixed in by the amount.
/// - **Blur** softens the key's edges: a Gaussian (σ up to 20 px at full resolution, three box
/// passes) on the key.
fn hsl_secondary(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
if !b(e, "hsl_on") {
return;
}
let gs = grade_space(e, cx, "curves_hdr_range");
let hdr = gs.is_hdr();
let (hc, hr) = (f(e, "hsl_hue", cx) / 360.0, (f(e, "hsl_hue_range", cx) / 360.0).max(1e-3));
let (smin, lmin, lmax) = (f(e, "hsl_sat_min", cx) / 100.0, f(e, "hsl_luma_min", cx) / 100.0, f(e, "hsl_luma_max", cx) / 100.0);
let soft = (f(e, "hsl_soft", cx) / 100.0 * 0.3).max(0.01);
let show_mask = choice(e, "hsl_show_mask");
let (htemp, htint, hsat, hshift) =
(f(e, "hsl_temp", cx) / 100.0, f(e, "hsl_tint", cx) / 100.0, f(e, "hsl_sat", cx) / 100.0, f(e, "hsl_hue_shift", cx) / 360.0);
let (denoise, blur) = (f(e, "hsl_denoise", cx).clamp(0.0, 100.0) / 100.0, f(e, "hsl_blur", cx).clamp(0.0, 100.0) / 100.0);
let (w, h) = (img.w, img.h);
let unit = |c: [f32; 3]| {
let v = gs.encode(c);
if hdr {
let m = v[0].max(v[1]).max(v[2]).max(1.0);
v.map(|q| (q / m).max(0.0))
} else {
v.map(|q| q.clamp(0.0, 1.0))
}
};
// the key
let mut mask: Vec<f32> = img
.px
.par_chunks(4)
.map(|p| if p[3] <= 1e-6 { 0.0 } else { hsl_key(unit(Image::unpremul([p[0], p[1], p[2], p[3]])), hc, hr, smin, lmin, lmax, soft) })
.collect();
if denoise > 0.0 {
let r = ((1.0 + 2.0 * denoise) * cx.px_scale).round().max(1.0) as usize;
let med = median_filter(&mask, w, h, r);
mask.iter_mut().zip(med).for_each(|(m, d)| *m += (d - *m) * denoise.min(1.0));
}
if blur > 0.0 {
let sigma = blur * 20.0 * cx.px_scale;
if sigma >= 0.3 {
blur_plane(&mut mask, w, h, sigma);
}
}
let mask = &mask;
img.map_rgb(|c, x, y| {
let m = mask[y * w + x];
let v = gs.encode(c);
let u = unit(c);
let scale = if hdr { v[0].max(v[1]).max(v[2]).max(1.0) } else { 1.0 };
let out = match show_mask {
1 => {
let g = rgb_to_hsl(u[0], u[1], u[2])[2];
[g + (u[0] - g) * m, g + (u[1] - g) * m, g + (u[2] - g) * m]
}
2 => u.map(|q| q * m),
3 => [m, m, m],
_ => {
let mut hh = rgb_to_hsl(u[0], u[1], u[2]);
hh[0] = (hh[0] + hshift).rem_euclid(1.0);
hh[1] = (hh[1] * hsat).clamp(0.0, 1.0);
let mut c2 = hsl_to_rgb(hh[0], hh[1], hh[2]);
c2[0] *= 1.0 + 0.25 * htemp;
c2[2] *= 1.0 - 0.25 * htemp;
c2[1] *= 1.0 - 0.2 * htint;
[u[0] + (c2[0] - u[0]) * m, u[1] + (c2[1] - u[1]) * m, u[2] + (c2[2] - u[2]) * m]
}
};
// the mask views are display images (no HDR scaling)
let out = if show_mask == 0 { out.map(|q| q * scale) } else { out };
gs.decode(out)
});
}
/// Median of the `(2r+1)²` neighbourhood of every sample of a single-channel plane (edges
/// clamped).
fn median_filter(src: &[f32], w: usize, h: usize, r: usize) -> Vec<f32> {
let mut out = vec![0f32; src.len()];
out.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
let mut win: Vec<f32> = Vec::with_capacity((2 * r + 1) * (2 * r + 1));
for (x, o) in row.iter_mut().enumerate() {
win.clear();
for yy in y.saturating_sub(r)..(y + r + 1).min(h) {
let line = &src[yy * w..yy * w + w];
win.extend_from_slice(&line[x.saturating_sub(r)..(x + r + 1).min(w)]);
}
let k = win.len() / 2;
*o = *win.select_nth_unstable_by(k, |a, b| a.total_cmp(b)).1;
}
});
out
}
/// Gaussian blur of a single-channel plane (three box passes per axis, edges clamped).
fn blur_plane(p: &mut [f32], w: usize, h: usize, sigma: f32) {
let radii = boxes_for_gauss(sigma, 3);
let pass = |p: &mut [f32], w: usize, h: usize, r: usize| {
if r == 0 {
return;
}
p.par_chunks_mut(w).for_each(|row| {
let src = row.to_vec();
let n = (2 * r + 1) as f32;
let at = |i: isize| src[i.clamp(0, w as isize - 1) as usize];
let mut acc: f32 = (-(r as isize)..=r as isize).map(at).sum();
for x in 0..w {
row[x] = acc / n;
acc += at(x as isize + r as isize + 1) - at(x as isize - r as isize);
}
});
let _ = h;
};
let transpose = |p: &[f32], w: usize, h: usize| {
let mut t = vec![0f32; p.len()];
for y in 0..h {
for x in 0..w {
t[x * h + y] = p[y * w + x];
}
}
t
};
for &r in &radii {
pass(p, w, h, r);
}
let mut t = transpose(p, w, h);
for &r in &radii {
pass(&mut t, h, w, r);
}
p.copy_from_slice(&transpose(&t, h, w));
}
#[cfg(test)]
mod curve_tests {
use super::*;

View File

@ -16,6 +16,7 @@ pub mod effects;
pub mod graphic_clip;
pub mod graphics;
pub mod image;
pub mod lumetri_presets;
pub mod luts;
pub mod mask;
pub mod mixer;
@ -148,6 +149,7 @@ pub(crate) fn render_seq_tracks(project: &Project, seq: &Sequence, t: Tick, opts
clip_name: &item.name,
project: Some(project),
env: None,
working: seq.settings.color.working,
};
for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic)) {
mask::apply_effect(&mut adjusted, e, &cx);
@ -314,6 +316,7 @@ pub(crate) fn item_layer(
clip_name: &item.name,
project: Some(project),
env: Some(&env),
working: seq.settings.color.working,
};
for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic) && !filmcraft_project::graphic::is_layer(e)) {
if filmcraft_media::cancel::cancelled() {
@ -483,6 +486,9 @@ pub fn arc_source(s: impl filmcraft_media::MediaSource + 'static) -> SharedSourc
#[cfg(test)]
mod tests;
#[cfg(test)]
mod lumetri_hdr_tests;
#[cfg(test)]
#[path = "adjustment_tests.rs"]
mod adjustment_tests;

View File

@ -0,0 +1,183 @@
//! Lumetri in HDR working spaces (known cd/m² values through PQ and HLG) and HSL Secondary ▸
//! Refine (Denoise, Blur of the key).
use filmcraft_color::{GradeSpace, REFERENCE_WHITE_NITS, WorkingSpace};
use filmcraft_project::{EffectInstance, ParamValue, find_effect};
use filmcraft_time::Tick;
use crate::Image;
use crate::effects::{FxCtx, apply};
const RW: f32 = REFERENCE_WHITE_NITS as f32;
fn cx(working: WorkingSpace) -> FxCtx<'static> {
FxCtx { t: Tick::ZERO, px_scale: 1.0, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None, working }
}
fn lumetri(params: &[(&str, ParamValue)]) -> EffectInstance {
let mut e = find_effect("lumetri").unwrap().instance();
for (k, v) in params {
e.params.get_mut(*k).unwrap_or_else(|| panic!("no param {k}")).value = v.clone();
}
e
}
/// A row of grey pixels at these cd/m², graded; returns their cd/m² afterwards.
fn grade(working: WorkingSpace, nits: &[f32], e: &EffectInstance) -> Vec<f32> {
let mut img = Image::new(nits.len(), 1);
for (i, n) in nits.iter().enumerate() {
img.px[i * 4..i * 4 + 4].copy_from_slice(&[n / RW, n / RW, n / RW, 1.0]);
}
apply(&mut img, e, &cx(working));
(0..nits.len()).map(|i| img.px[i * 4 + 1] * RW).collect()
}
fn close(got: &[f32], want: &[f32], rel: f32) {
for (g, w) in got.iter().zip(want) {
assert!((g - w).abs() <= w.abs() * rel + 0.02, "got {got:?}, want {want:?}");
}
}
const F: fn(f64) -> ParamValue = ParamValue::Float;
const LEVELS: [f32; 6] = [1.0, 26.0, 100.0, 203.0, 1000.0, 4000.0];
#[test]
fn identity_keeps_hdr_highlights_in_pq_and_hlg() {
let e = lumetri(&[]);
close(&grade(WorkingSpace::Rec2100Pq, &LEVELS, &e), &LEVELS, 2e-3);
// HLG signal tops out around 12× its peak's scene light: 4000 cd/m² is still inside
close(&grade(WorkingSpace::Rec2100Hlg, &LEVELS, &e), &LEVELS, 2e-3);
// the same grade in a Rec. 709 sequence clips at SDR white (1.0 = 203 cd/m² here)
let sdr = grade(WorkingSpace::Rec709, &[1000.0], &e);
assert!((sdr[0] - RW).abs() < 0.5, "{sdr:?}");
}
#[test]
fn exposure_is_stops_of_light_in_hdr() {
let e = lumetri(&[("exposure", F(1.0))]);
let want: Vec<f32> = LEVELS.iter().map(|n| n * 2.0).collect();
close(&grade(WorkingSpace::Rec2100Pq, &LEVELS, &e), &want, 3e-3);
close(&grade(WorkingSpace::Rec2100Hlg, &LEVELS[..5], &e), &want[..5], 3e-3);
let e = lumetri(&[("exposure", F(-2.0))]);
close(&grade(WorkingSpace::Rec2100Pq, &[400.0, 4000.0], &e), &[100.0, 1000.0], 3e-3);
}
#[test]
fn whites_and_contrast_scale_with_hdr_white() {
// Whites +100 stretches the signal so 85 % of HDR White becomes HDR White
for white in [1000.0f32, 4000.0] {
let gs = GradeSpace::new(WorkingSpace::Rec2100Pq, white);
let e = lumetri(&[("whites", F(100.0)), ("hdr_white", F(white as f64))]);
let src = gs.nits_of_signal(0.85);
let got = grade(WorkingSpace::Rec2100Pq, &[0.0, src], &e);
assert!(got[0].abs() < 1e-3, "black stays black");
assert!((got[1] - white).abs() < white * 3e-3, "HDR White {white}: {src} cd/m² → {} (want {white})", got[1]);
}
// Contrast pivots on the middle of the HDR White range and leaves speculars above it alone
let gs = GradeSpace::new(WorkingSpace::Rec2100Pq, 1000.0);
let (mid, dark, bright) = (gs.nits_of_signal(0.5), gs.nits_of_signal(0.3), gs.nits_of_signal(0.7));
let e = lumetri(&[("contrast", F(60.0))]);
let got = grade(WorkingSpace::Rec2100Pq, &[mid, dark, bright, 4000.0], &e);
assert!((got[0] - mid).abs() < mid * 3e-3, "pivot {mid} → {}", got[0]);
assert!(got[1] < dark && got[2] > bright, "{got:?}");
assert!((got[3] - 4000.0).abs() < 4000.0 * 3e-3, "specular kept: {}", got[3]);
// the same in HLG: reference white is 75 % of a 1000 cd/m² display's signal
let hlg = GradeSpace::new(WorkingSpace::Rec2100Hlg, 1000.0);
assert!((hlg.signal_of_nits(203.0) - 0.75).abs() < 2e-3);
let e = lumetri(&[("whites", F(100.0))]);
let got = grade(WorkingSpace::Rec2100Hlg, &[hlg.nits_of_signal(0.85)], &e);
assert!((got[0] - 1000.0).abs() < 4.0, "{got:?}");
}
#[test]
fn hdr_specular_sets_highlights_above_hdr_white() {
let e = lumetri(&[("hdr_specular", F(-100.0))]);
let got = grade(WorkingSpace::Rec2100Pq, &[203.0, 1000.0, 4000.0], &e);
close(&got, &[203.0, 1000.0, 1000.0], 3e-3);
let gs = GradeSpace::new(WorkingSpace::Rec2100Pq, 1000.0);
let e = lumetri(&[("hdr_specular", F(50.0))]);
let got = grade(WorkingSpace::Rec2100Pq, &[1000.0, 4000.0], &e);
let want = gs.nits_of_signal(1.0 + (gs.signal_of_nits(4000.0) - 1.0) * 1.5);
close(&got, &[1000.0, want], 3e-3);
// no effect in SDR (the slider is HDR-only)
let sdr = grade(WorkingSpace::Rec709, &[150.0], &lumetri(&[("hdr_specular", F(-100.0))]));
assert!((sdr[0] - 150.0).abs() < 0.2);
}
#[test]
fn curves_span_the_hdr_range() {
// a luma curve lifting the middle: 0.5 → 0.6 of the HDR Range
let curve = ParamValue::Curve(vec![[0.0, 0.0], [0.5, 0.6], [1.0, 1.0]]);
for range in [1000.0f32, 2000.0] {
let gs = GradeSpace::new(WorkingSpace::Rec2100Pq, range);
let e = lumetri(&[("curve_luma", curve.clone()), ("curves_hdr_range", F(range as f64))]);
let got = grade(WorkingSpace::Rec2100Pq, &[gs.nits_of_signal(0.5), range, 6000.0], &e);
close(&got, &[gs.nits_of_signal(0.6), range, 6000.0], 4e-3);
}
}
/// A 40×40 image: grey background, a red 16×16 square, and isolated red speckles.
fn keyed_image() -> Image {
let mut img = Image::filled(40, 40, [0.2, 0.2, 0.2, 1.0]);
let red = [0.8, 0.02, 0.02, 1.0];
for y in 12..28 {
for x in 12..28 {
img.px[(y * 40 + x) * 4..][..4].copy_from_slice(&red);
}
}
for (x, y) in [(3, 3), (35, 6), (5, 33), (33, 34), (20, 4)] {
img.px[(y * 40 + x) * 4..][..4].copy_from_slice(&red);
}
img
}
fn key(denoise: f64, blur: f64) -> Image {
let mut img = keyed_image();
let e = lumetri(&[
("hsl_on", ParamValue::Bool(true)),
("hsl_hue", F(0.0)),
("hsl_hue_range", F(40.0)),
("hsl_show_mask", ParamValue::Choice(3)),
("hsl_denoise", F(denoise)),
("hsl_blur", F(blur)),
]);
apply(&mut img, &e, &cx(WorkingSpace::Rec709));
img
}
#[test]
fn hsl_refine_denoise_removes_speckles() {
let m = |img: &Image, x: usize, y: usize| filmcraft_color::linear_to_srgb(img.get(x, y)[1]);
let raw = key(0.0, 0.0);
assert!(m(&raw, 3, 3) > 0.9 && m(&raw, 20, 20) > 0.9 && m(&raw, 8, 20) < 0.01, "the key: speckles and square");
let clean = key(100.0, 0.0);
for (x, y) in [(3, 3), (35, 6), (5, 33), (33, 34), (20, 4)] {
assert!(m(&clean, x, y) < 0.05, "speckle at {x},{y}: {}", m(&clean, x, y));
}
assert!(m(&clean, 20, 20) > 0.95, "the square stays keyed");
// half denoise: half way
let half = key(50.0, 0.0);
assert!((0.3..0.7).contains(&m(&half, 3, 3)), "{}", m(&half, 3, 3));
}
#[test]
fn hsl_refine_blur_softens_the_key() {
let soft = |img: &Image| (0..40).filter(|&x| (0.05..0.95).contains(&img.get(x, 20)[1])).count();
let hard = key(0.0, 0.0);
assert_eq!(soft(&hard), 0, "a hard key");
let blurred = key(0.0, 25.0);
let n = soft(&blurred);
assert!(n >= 6, "soft edge pixels: {n}");
// the key's total is kept (blur only moves it around)
let total = |img: &Image| (0..40 * 40).map(|i| filmcraft_color::linear_to_srgb(img.px[i * 4 + 1])).sum::<f32>();
assert!((total(&blurred) - total(&hard)).abs() / total(&hard) < 0.05);
// the correction follows the refined key: a hue shift inside, soft at the edge
let mut img = keyed_image();
let e =
lumetri(&[("hsl_on", ParamValue::Bool(true)), ("hsl_hue", F(0.0)), ("hsl_hue_range", F(40.0)), ("hsl_hue_shift", F(120.0)), ("hsl_denoise", F(100.0))]);
apply(&mut img, &e, &cx(WorkingSpace::Rec709));
let inside = img.get(20, 20);
assert!(inside[1] > inside[0], "red turned green inside: {inside:?}");
let speck = img.get(3, 3);
assert!(speck[0] > speck[1], "speckle left alone after denoise: {speck:?}");
}

View File

@ -0,0 +1,288 @@
//! Lumetri Presets (Effects panel ▸ Lumetri Presets): built-in Lumetri Color looks, and their
//! thumbnails rendered by our own Lumetri on a generated preview picture.
//!
//! The presets are original work: parameter values chosen by FilmCraft contributors, named
//! descriptively (no third-party look names, LUTs or preview images). The preview picture
//! ([`preview_image`]) is procedural: a dusk sky with a sun, mountain ridges, a lake with its
//! reflection, a grey ramp down the left edge and six colour chips along the bottom, so a
//! thumbnail shows what a look does to sky, shadows, saturated colours and neutrals.
use filmcraft_color::srgb_to_linear;
use filmcraft_geom::Vec2;
use filmcraft_project::{EffectInstance, ParamValue, find_effect};
use filmcraft_time::Tick;
use crate::Image;
use crate::effects::{FxCtx, apply};
/// One preset: a Lumetri Color instance with these parameters (the rest at their defaults).
#[derive(Clone, Debug)]
pub struct LumetriPreset {
/// Sub-folder of Lumetri Presets.
pub folder: &'static str,
pub name: &'static str,
pub description: &'static str,
pub params: Vec<(&'static str, ParamValue)>,
}
impl LumetriPreset {
/// A Lumetri Color effect instance configured as the preset.
pub fn instance(&self) -> EffectInstance {
let mut e = find_effect("lumetri").expect("lumetri is defined").instance();
for (k, v) in &self.params {
if let Some(p) = e.params.get_mut(*k) {
p.value = v.clone();
}
}
e
}
}
/// The preset folders in Effects panel order.
pub const FOLDERS: [&str; 4] = ["Cinematic", "Film Emulation", "Monochrome", "Technical"];
fn fl(v: f64) -> ParamValue {
ParamValue::Float(v)
}
fn rgb(r: f32, g: f32, b: f32) -> ParamValue {
ParamValue::Color([r, g, b, 1.0])
}
/// A colour-wheel offset towards hue `deg` (0 = red, 120 = green, 240 = blue) of length `len`.
fn wheel(deg: f64, len: f64) -> ParamValue {
let a = deg.to_radians();
ParamValue::Vec2(Vec2::new(a.cos() * len, a.sin() * len))
}
fn curve(points: &[[f32; 2]]) -> ParamValue {
ParamValue::Curve(points.to_vec())
}
/// Every built-in preset, by folder.
pub fn presets() -> Vec<LumetriPreset> {
let p = |folder, name, description, params| LumetriPreset { folder, name, description, params };
vec![
// ---- Cinematic
p(
"Cinematic",
"Teal Shadows, Warm Skin",
"Cyan-teal shadows and warm highlights with a little more contrast",
vec![("shadow_tint", rgb(0.36, 0.52, 0.6)), ("highlight_tint", rgb(0.64, 0.53, 0.42)), ("contrast", fl(20.0)), ("creative_sat", fl(110.0))],
),
p(
"Cinematic",
"Night Exterior",
"Day for night: darker, cooler and less saturated",
vec![("temperature", fl(-45.0)), ("exposure", fl(-0.9)), ("contrast", fl(15.0)), ("creative_sat", fl(70.0)), ("shadow_tint", rgb(0.4, 0.45, 0.62))],
),
p(
"Cinematic",
"Golden Dusk",
"Warm, soft late-afternoon light",
vec![("temperature", fl(35.0)), ("tint", fl(8.0)), ("highlights", fl(-15.0)), ("vibrance", fl(20.0)), ("highlight_tint", rgb(0.66, 0.55, 0.4))],
),
p("Cinematic", "Steel Blue", "Cool, desaturated and punchy", vec![("temperature", fl(-25.0)), ("saturation", fl(80.0)), ("contrast", fl(25.0))]),
p(
"Cinematic",
"Hard Sun",
"High contrast with held highlights and deep shadows",
vec![("contrast", fl(45.0)), ("shadows", fl(-20.0)), ("highlights", fl(-25.0)), ("vibrance", fl(15.0))],
),
// ---- Film Emulation
p(
"Film Emulation",
"Faded Print",
"Lifted blacks, softer contrast, muted colour",
vec![("faded_film", fl(45.0)), ("contrast", fl(-10.0)), ("creative_sat", fl(85.0))],
),
p(
"Film Emulation",
"Silver Retention",
"Bleach-bypass style: desaturated with hard contrast",
vec![("look", ParamValue::Choice(4)), ("look_intensity", fl(80.0))],
),
p(
"Film Emulation",
"Cross Processed",
"Yellow-green highlights and blue-lifted shadows from crossed channel curves",
vec![
("curve_red", curve(&[[0.0, 0.0], [0.5, 0.56], [1.0, 1.0]])),
("curve_green", curve(&[[0.0, 0.02], [0.5, 0.54], [1.0, 0.98]])),
("curve_blue", curve(&[[0.0, 0.12], [1.0, 0.82]])),
],
),
p("Film Emulation", "Warm Negative", "Warm print stock with gentle roll-off", vec![("look", ParamValue::Choice(2)), ("temperature", fl(10.0))]),
// ---- Monochrome
p("Monochrome", "Neutral Mono", "Black and white", vec![("saturation", fl(0.0))]),
p(
"Monochrome",
"Hard Mono",
"Black and white with crushed blacks and strong contrast",
vec![("saturation", fl(0.0)), ("contrast", fl(60.0)), ("blacks", fl(-25.0))],
),
p(
"Monochrome",
"Sepia Tone",
"Monochrome toned warm brown",
vec![("look", ParamValue::Choice(6)), ("wheel_midtones", wheel(30.0, 0.35)), ("wheel_highlights", wheel(45.0, 0.2))],
),
p("Monochrome", "Cold Mono", "Monochrome toned cyan-blue", vec![("look", ParamValue::Choice(6)), ("wheel_midtones", wheel(210.0, 0.3))]),
// ---- Technical
p("Technical", "Lift Shadows", "Opens up the shadows", vec![("shadows", fl(40.0)), ("blacks", fl(10.0))]),
p("Technical", "Protect Highlights", "Pulls highlights and whites down", vec![("highlights", fl(-40.0)), ("whites", fl(-15.0))]),
p("Technical", "Half Saturation", "Saturation at 50 %", vec![("saturation", fl(50.0))]),
p("Technical", "Boost Vibrance", "More colour in the less saturated areas", vec![("vibrance", fl(40.0))]),
p(
"Technical",
"Legal Range Squeeze",
"Maps full-range levels into 16–235 (video range) with a luma curve",
vec![("curve_luma", curve(&[[0.0, 16.0 / 255.0], [1.0, 235.0 / 255.0]]))],
),
]
}
/// The preset called `name` (case-insensitive).
pub fn find(name: &str) -> Option<LumetriPreset> {
presets().into_iter().find(|p| p.name.eq_ignore_ascii_case(name))
}
/// The procedural preview picture (`w`×`h`, opaque, linear premultiplied like every [`Image`]).
pub fn preview_image(w: usize, h: usize) -> Image {
let mut img = Image::new(w, h);
let (wf, hf) = (w as f32, h as f32);
let horizon = 0.56;
let ramp_w = (wf * 0.05).max(2.0);
let chips: [[f32; 3]; 6] = [[0.85, 0.12, 0.1], [0.95, 0.8, 0.1], [0.15, 0.7, 0.2], [0.1, 0.75, 0.8], [0.15, 0.25, 0.85], [0.8, 0.2, 0.75]];
let chip_h = (hf * 0.12).max(2.0);
let mix = |a: [f32; 3], b: [f32; 3], t: f32| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
// mountain ridge heights (fraction of the height above the horizon), two layers
let ridge = |u: f32, k: f32| 0.12 * k * (0.55 + 0.25 * (u * 7.3 + k).sin() + 0.15 * (u * 17.1 + 2.0 * k).sin() + 0.05 * (u * 41.0).sin());
let sky = |v: f32| mix([0.98, 0.62, 0.32], [0.16, 0.3, 0.62], (1.0 - v / horizon).clamp(0.0, 1.0).powf(0.8));
let (sun_x, sun_y, sun_r) = (0.68f32, 0.33f32, 0.07f32);
for y in 0..h {
for x in 0..w {
let (u, v) = ((x as f32 + 0.5) / wf, (y as f32 + 0.5) / hf);
let mut c = if v < horizon {
let mut c = sky(v);
// the sun and its glow
let d = (((u - sun_x) * wf / hf).powi(2) + (v - sun_y).powi(2)).sqrt();
if d < sun_r {
c = [1.0, 0.93, 0.7];
} else {
let g = (1.0 - (d - sun_r) / 0.25).clamp(0.0, 1.0).powi(2) * 0.35;
c = mix(c, [1.0, 0.85, 0.55], g);
}
// far ridge (blue-grey), near ridge (dark green-brown)
if horizon - v < ridge(u, 1.6) {
c = mix([0.36, 0.4, 0.52], c, 0.2);
}
if horizon - v < ridge(u, 0.9) {
c = mix([0.16, 0.2, 0.12], [0.3, 0.28, 0.2], ((horizon - v) / 0.12).clamp(0.0, 1.0));
}
c
} else {
// the lake: a darker, bluer reflection of the sky, rippled
let mirror = horizon - (v - horizon);
let ripple = 0.04 * ((v * 140.0).sin() * (u * 9.0 + v * 30.0).cos());
let r = sky(mirror.max(0.0));
mix(mix(r, [0.05, 0.12, 0.22], 0.45 + (v - horizon) * 0.8), [0.0; 3], 0.1 + ripple)
};
// grey ramp down the left edge
if (x as f32) < ramp_w {
let g = 1.0 - v;
c = [g, g, g];
}
// colour chips along the bottom
if (y as f32) >= hf - chip_h && (x as f32) >= ramp_w {
let k = (((x as f32 - ramp_w) / (wf - ramp_w)) * 6.0).floor().clamp(0.0, 5.0) as usize;
c = chips[k];
}
let i = (y * w + x) * 4;
img.px[i] = srgb_to_linear(c[0].clamp(0.0, 1.0));
img.px[i + 1] = srgb_to_linear(c[1].clamp(0.0, 1.0));
img.px[i + 2] = srgb_to_linear(c[2].clamp(0.0, 1.0));
img.px[i + 3] = 1.0;
}
}
img
}
/// A preset's thumbnail: the preview picture graded by the preset (Rec. 709).
pub fn thumbnail(preset: &LumetriPreset, w: usize, h: usize) -> Image {
let mut img = preview_image(w, h);
let cx = FxCtx {
t: Tick::ZERO,
px_scale: w as f32 / 1920.0,
seconds: 0.0,
timecode: "",
clip_name: "",
project: None,
env: None,
working: filmcraft_color::WorkingSpace::Rec709,
};
apply(&mut img, &preset.instance(), &cx);
img
}
/// A grid of thumbnails (`cols` columns of `w`×`h` cells, 4 px gaps over a dark background), in
/// the order given: the Effects panel's thumbnail view of a Lumetri Presets folder.
pub fn grid(presets: &[LumetriPreset], cols: usize, w: usize, h: usize) -> Image {
const GAP: usize = 4;
let cols = cols.max(1);
let rows = presets.len().div_ceil(cols).max(1);
let (gw, gh) = (cols * (w + GAP) + GAP, rows * (h + GAP) + GAP);
let bg = srgb_to_linear(0.11);
let mut out = Image::filled(gw, gh, [bg, bg, bg, 1.0]);
for (k, p) in presets.iter().enumerate() {
let t = thumbnail(p, w, h);
let (x0, y0) = (GAP + (k % cols) * (w + GAP), GAP + (k / cols) * (h + GAP));
for y in 0..h {
let src = &t.px[y * w * 4..(y + 1) * w * 4];
let d = ((y0 + y) * gw + x0) * 4;
out.px[d..d + w * 4].copy_from_slice(src);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn presets_are_valid_and_change_the_picture() {
let all = presets();
assert!(all.len() >= 16);
let mut names: Vec<&str> = all.iter().map(|p| p.name).collect();
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), all.len(), "unique names");
let def = find_effect("lumetri").unwrap();
let base = preview_image(64, 36);
for p in &all {
assert!(FOLDERS.contains(&p.folder), "{}", p.name);
for (k, _) in &p.params {
assert!(def.param(k).is_some(), "{}: unknown param {k}", p.name);
}
let t = thumbnail(p, 64, 36);
let diff: f32 = t.px.iter().zip(&base.px).map(|(a, b)| (a - b).abs()).sum::<f32>() / base.px.len() as f32;
assert!(diff > 0.004, "{} barely changes the picture ({diff})", p.name);
assert!(t.px.iter().all(|v| v.is_finite()));
}
// Monochrome presets are monochrome (Sepia / Cold are toned: low but not zero chroma)
let t = thumbnail(&find("Neutral Mono").unwrap(), 64, 36);
assert!(t.px.chunks(4).all(|p| (p[0] - p[1]).abs() < 1e-3 && (p[1] - p[2]).abs() < 1e-3));
assert!(find("neutral mono").is_some() && find("nope").is_none());
}
#[test]
fn preview_picture_has_ramp_chips_and_sky() {
let img = preview_image(320, 180);
let px = |x: usize, y: usize| img.get(x, y);
assert!(px(4, 2)[0] > 0.9 && px(4, 177)[0] < 0.05, "grey ramp: white at the top, black at the bottom");
let red = px(60, 176);
assert!(red[0] > 0.5 && red[1] < 0.1, "a red chip: {red:?}");
let sky = px(160, 10);
assert!(sky[2] > sky[0], "blue sky at the top: {sky:?}");
let grid = grid(&presets()[..5], 3, 32, 18);
assert_eq!((grid.w, grid.h), (3 * 36 + 4, 2 * 22 + 4));
}
}

View File

@ -226,7 +226,16 @@ fn effect_costs() {
for d in filmcraft_project::effect_defs().iter().filter(|d| d.kind == filmcraft_project::EffectKind::Video && !d.intrinsic) {
let mut img = crate::Image::filled(1920, 1080, [0.4, 0.5, 0.6, 1.0]);
let e = d.instance();
let cx = FxCtx { t: Tick::ZERO, px_scale: 1.0, seconds: 0.0, timecode: "00:00:00:00", clip_name: "x", project: None, env: None };
let cx = FxCtx {
t: Tick::ZERO,
px_scale: 1.0,
seconds: 0.0,
timecode: "00:00:00:00",
clip_name: "x",
project: None,
env: None,
working: filmcraft_color::WorkingSpace::Rec709,
};
apply(&mut img, &e, &cx);
let t0 = std::time::Instant::now();
for _ in 0..3 {

View File

@ -102,6 +102,7 @@ impl FxEnv for ItemEnv<'_> {
clip_name: &self.item.name,
project: Some(self.project),
env: None,
working: self.seq.settings.color.working,
};
for e in &self.item.effects {
if std::ptr::eq(e, upto) {

View File

@ -9,7 +9,16 @@ use filmcraft_project::{EffectKind, ParamKind, find_effect};
use super::*;
fn cx() -> FxCtx<'static> {
FxCtx { t: Tick::ZERO, px_scale: 1.0, seconds: 0.0, timecode: "01:00:00:00", clip_name: "clip", project: None, env: None }
FxCtx {
t: Tick::ZERO,
px_scale: 1.0,
seconds: 0.0,
timecode: "01:00:00:00",
clip_name: "clip",
project: None,
env: None,
working: filmcraft_color::WorkingSpace::Rec709,
}
}
fn inst(id: &str) -> EffectInstance {

View File

@ -1,5 +1,12 @@
//! Effects panel: searchable folder tree of presets, audio/video effects and transitions (from the
//! effect definitions). Drag onto a clip or edit point; double-click applies to the selection.
//!
//! Lumetri Presets: each preset row has a thumbnail (our Lumetri on the procedural preview
//! picture, `filmcraft_render::lumetri_presets`); double-click (or right-click ▸ Apply to Selected
//! Clips, `effects.presetMenu.apply`) applies it (`lumetri.applyPreset`).
//! When the panel is wide (maximized), clicking a Lumetri Presets folder shows its presets as a
//! thumbnail grid to the right of the tree. Automation ids: `effects.lumetriPreset.<name>`,
//! `effects.presetGrid` (the grid area), `effects.presetGrid.<name>`.
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
use serde_json::json;
@ -16,6 +23,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
crate::widgets::search_field(&mut child, &mut q, "Search effects", sr.width(), &t);
app.ui.effects_search = q.clone();
let body = Rect::from_min_max(pos2(rect.min.x, sr.max.y + 6.0), rect.max);
// wide panel: the tree on the left, the Lumetri Presets thumbnail grid on the right
let grid_area = (body.width() > 640.0).then(|| Rect::from_min_max(pos2(body.min.x + 300.0, body.min.y), body.max));
let body = match grid_area {
Some(g) => Rect::from_min_max(body.min, pos2(g.min.x - 4.0, body.max.y)),
None => body,
};
let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("fx-body"));
bui.set_clip_rect(body);
let filter = q.to_ascii_lowercase();
@ -23,6 +36,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
// folder tree: top-level categories in Premiere's order
let tops = filmcraft_project::vtransition::EFFECT_TOP_FOLDERS;
let mut apply: Option<String> = None;
let mut lumetri_apply: Option<String> = None;
let mut preset_action: Option<(String, String)> = None;
egui::ScrollArea::vertical().id_salt("fx-scroll").auto_shrink([false, false]).show(&mut bui, |ui| {
for &top in tops {
@ -33,6 +47,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
continue;
}
if top == "Lumetri Presets" {
if let Some(name) = lumetri_rows(app, ui, &filter) {
lumetri_apply = Some(name);
}
continue;
}
let mut items: Vec<_> = defs
.iter()
.filter(|d| d.category.first() == Some(&top))
@ -101,6 +121,16 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
});
if let Some(g) = grid_area
&& let Some(name) = preset_grid(app, ui, g)
{
lumetri_apply = Some(name);
}
if let Some(name) = lumetri_apply
&& let Err(e) = app.session.execute("lumetri.applyPreset", json!({"name": name}))
{
app.ui.status = e.to_string();
}
if let Some((cmd, name)) = preset_action {
crate::panels::presets::run(app, &cmd, &name);
}
@ -139,3 +169,126 @@ fn folder_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, key: &str, depth: usize
}
open
}
/// Right-click menu of a Lumetri preset: Apply to Selected Clips (automation id
/// `effects.presetMenu.apply`). True when chosen.
fn apply_menu(app: &mut FilmcraftApp, resp: &egui::Response) -> bool {
let mut chosen = false;
resp.context_menu(|ui| {
let b = ui.button("Apply to Selected Clips");
app.auto.add("effects.presetMenu.apply", b.rect, "Apply to Selected Clips");
if b.clicked() {
chosen = true;
ui.close();
}
});
chosen
}
/// A preset's thumbnail texture (rendered once per size, cached by name).
fn preset_texture(app: &mut FilmcraftApp, ctx: &egui::Context, p: &filmcraft_render::lumetri_presets::LumetriPreset, w: usize) -> egui::TextureId {
let name = format!("lumetri-preset-{}-{w}", p.name);
if let Some((id, _)) = app.texture_existing(&name) {
return id;
}
let h = (w * 9 / 16).max(1);
let img = filmcraft_render::lumetri_presets::thumbnail(p, w, h);
let rgba = crate::frames::Rgba { w: img.w, h: img.h, px: img.over_black_rgba8() };
let key = crate::frames::FrameKey {
target: crate::frames::Target::Item(filmcraft_project::ItemId(u64::MAX)),
frame: 0,
size: w as u32,
revision: 0,
draft: false,
};
app.texture_for(ctx, &name, key, &rgba)
}
/// The Lumetri Presets folder of the tree: sub-folders and preset rows with thumbnails. Returns
/// the preset to apply (double-click).
fn lumetri_rows(app: &mut FilmcraftApp, ui: &mut egui::Ui, filter: &str) -> Option<String> {
use filmcraft_render::lumetri_presets as lp;
let t = app.tokens;
let top = "Lumetri Presets";
let presets: Vec<lp::LumetriPreset> = lp::presets().into_iter().filter(|p| filter.is_empty() || p.name.to_ascii_lowercase().contains(filter)).collect();
if !filter.is_empty() && presets.is_empty() {
return None;
}
if !folder_row(app, ui, top, 0, filter) {
return None;
}
let mut apply = None;
for folder in lp::FOLDERS {
let items: Vec<&lp::LumetriPreset> = presets.iter().filter(|p| p.folder == folder).collect();
if items.is_empty() {
continue;
}
let key = format!("{top}/{folder}");
let was_open = !filter.is_empty() || app.ui.expanded_fx.contains(&key);
let open = folder_row(app, ui, &key, 1, filter);
// the folder clicked last is the one the wide panel shows as a grid (`folder_row` toggles
// the expanded state on a click and returns the state it drew)
let now = !filter.is_empty() || app.ui.expanded_fx.contains(&key);
if now != was_open {
app.ui.lumetri_grid_folder = Some(folder.to_string());
}
if !open {
continue;
}
for p in items {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover);
}
let tex = preset_texture(app, ui.ctx(), p, 64);
let tr = Rect::from_min_size(pos2(r.min.x + 40.0, r.min.y + 3.0), vec2(32.0, 18.0));
ui.painter().image(tex, tr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
ui.painter().text(pos2(tr.max.x + 8.0, r.center().y), Align2::LEFT_CENTER, p.name, Tokens::ui(12.0), t.text);
app.auto.add(&format!("effects.lumetriPreset.{}", p.name), r, p.name);
let resp = resp.on_hover_text(p.description);
if apply_menu(app, &resp) {
apply = Some(p.name.to_string());
}
if resp.double_clicked() {
apply = Some(p.name.to_string());
}
}
}
apply
}
/// The wide Effects panel's thumbnail grid of the selected Lumetri Presets folder. Returns the
/// preset to apply (double-click).
fn preset_grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) -> Option<String> {
use filmcraft_render::lumetri_presets as lp;
let t = app.tokens;
let folder = app.ui.lumetri_grid_folder.clone()?;
app.auto.add("effects.presetGrid", area, &folder);
let presets: Vec<lp::LumetriPreset> = lp::presets().into_iter().filter(|p| p.folder == folder).collect();
let (cw, ch, gap, label_h) = (150.0f32, 84.0f32, 6.0f32, 18.0f32);
let cols = (((area.width() - gap) / (cw + gap)).floor() as usize).max(1);
let mut apply = None;
for (k, p) in presets.iter().enumerate() {
let x = area.min.x + gap + (k % cols) as f32 * (cw + gap);
let y = area.min.y + gap + (k / cols) as f32 * (ch + label_h + gap);
let cell = Rect::from_min_size(pos2(x, y), vec2(cw, ch + label_h));
if cell.max.y > area.max.y {
break;
}
let pic = Rect::from_min_size(cell.min, vec2(cw, ch));
let tex = preset_texture(app, ui.ctx(), p, 160);
let resp = ui.interact(cell, egui::Id::new(("lumetri-grid", p.name)), Sense::click()).on_hover_text(p.description);
ui.painter().rect_filled(cell, 3.0, if resp.hovered() { t.hover } else { t.field_bg });
ui.painter().image(tex, pic.shrink(2.0), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
let label = ui.painter().layout(p.name.to_string(), Tokens::ui(11.0), t.text, cw - 6.0);
ui.painter().galley(pos2(cell.min.x + 4.0, pic.max.y + 2.0), label, t.text);
app.auto.add(&format!("effects.presetGrid.{}", p.name), cell, p.name);
if apply_menu(app, &resp) {
apply = Some(p.name.to_string());
}
if resp.double_clicked() {
apply = Some(p.name.to_string());
}
}
apply
}

View File

@ -1,5 +1,12 @@
//! Lumetri Color panel: Basic Correction, Creative, Curves (RGB + hue curves), Color Wheels,
//! HSL Secondary, Vignette — editing the selected clip's Lumetri effect through engine commands.
//!
//! In an HDR sequence (Rec. 2100 PQ / HLG working space) Basic Correction shows HDR White and
//! HDR Specular and Curves shows HDR Range (cd/m²): the sliders and curves then span 0 … that
//! many nits (see `filmcraft_color::grade`). Automation ids for those and HSL Secondary ▸ Refine:
//! `lumetri.param.<id>` (`hdr_white`, `hdr_specular`, `curves_hdr_range`, `hsl_denoise`,
//! `hsl_blur`); Color Match: `lumetri.match.comparisonView` (toggles the Program monitor's
//! Comparison View, whose reference frame Apply Match then uses).
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use filmcraft_project::{ClipId, EffectInstance, ParamValue};
@ -37,6 +44,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
return;
};
let e = it.effects[idx].clone();
// HDR mode: the sequence's working space is Rec. 2100 PQ or HLG
let hdr = app.session.active_sequence().is_some_and(|q| q.settings.color.working.is_hdr());
let mut auto_rows: Vec<(String, Rect, &'static str)> = Vec::new();
let ph = app.session.playhead();
let mt = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1)));
let mut actions: Actions = Vec::new();
@ -71,6 +81,13 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
] {
slider(ui, &e, mt, id, label, None, &t, &mut actions, clip, idx);
}
if hdr {
sub(ui, &t, "HDR");
for (id, label) in [("hdr_white", "HDR White"), ("hdr_specular", "HDR Specular")] {
let r = slider(ui, &e, mt, id, label, None, &t, &mut actions, clip, idx);
auto_rows.push((format!("lumetri.param.{id}"), r, label));
}
}
}
if open_section(ui, app, &e, "Creative", "creative_on", "creative", &mut sections) {
let look = match e.param("look").map(|p| &p.value) {
@ -128,6 +145,10 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
("curve_blue", Color32::from_rgb(80, 130, 250)),
][ch];
curve_editor(ui, &e, id, false, col, &t, &mut actions, clip, idx);
if hdr {
let r = slider(ui, &e, mt, "curves_hdr_range", "HDR Range", None, &t, &mut actions, clip, idx);
auto_rows.push(("lumetri.param.curves_hdr_range".into(), r, "HDR Range"));
}
sub(ui, &t, "Hue Saturation Curves");
for (id, label) in [
("hue_vs_sat", "Hue vs Sat"),
@ -183,6 +204,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
});
});
sub(ui, &t, "Refine");
for (id, label) in [("hsl_denoise", "Denoise"), ("hsl_blur", "Blur")] {
let r = slider(ui, &e, mt, id, label, None, &t, &mut actions, clip, idx);
auto_rows.push((format!("lumetri.param.{id}"), r, label));
}
sub(ui, &t, "Correction");
slider(ui, &e, mt, "hsl_temp", "Temperature", Some(Gradient::Temp), &t, &mut actions, clip, idx);
slider(ui, &e, mt, "hsl_tint", "Tint", Some(Gradient::Tint), &t, &mut actions, clip, idx);
@ -198,6 +224,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
ui.add_space(20.0);
});
for (id, r, label) in auto_rows {
if r.is_positive() {
app.auto.add(&id, r, label);
}
}
for a in actions {
if let Err(err) = app.session.execute("effects.setParam", a) {
app.ui.status = err.to_string();
@ -209,6 +240,14 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
for (cmd, mut p) in lut_actions {
if cmd == "view.display" {
// Comparison View on / off (a Program monitor display mode)
let d = p["display"].as_str().unwrap_or("composite").to_string();
if let Some(Err(err)) = crate::panels::monitor_view::route(app, &format!("view.display.{d}"), &json!({"monitor": "program"})) {
app.ui.status = err;
}
continue;
}
if p.get("browse").is_some() {
let Some(path) = app.hooks.pick_open_file.as_mut().and_then(|f| f("LUT", &["cube", "3dl"])) else { continue };
p = json!({"path": path});
@ -338,9 +377,9 @@ fn slider(
actions: &mut Actions,
clip: ClipId,
idx: usize,
) {
let Some(pd) = e.def().and_then(|d| d.param(id)) else { return };
let filmcraft_project::ParamKind::Float { min, max, soft_min, soft_max, decimals, .. } = pd.kind else { return };
) -> Rect {
let Some(pd) = e.def().and_then(|d| d.param(id)) else { return Rect::NOTHING };
let filmcraft_project::ParamKind::Float { min, max, soft_min, soft_max, decimals, .. } = pd.kind else { return Rect::NOTHING };
let v = e.param(id).map(|p| p.f64_at(mt)).unwrap_or(pd.default.as_f64().unwrap_or(0.0));
let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::hover());
ui.painter().text(pos2(row.min.x + 4.0, row.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim);
@ -396,6 +435,7 @@ fn slider(
if let Some(nv) = out.or(nv) {
actions.push(json!({"clip": clip.0, "effect": idx, "param": id, "value": (nv * 10f64.powi(decimals as i32)).round() / 10f64.powi(decimals as i32)}));
}
row
}
fn lerp_col(a: Color32, b: Color32, f: f32) -> Color32 {
@ -578,6 +618,18 @@ fn match_controls(ui: &mut egui::Ui, app: &mut FilmcraftApp, out: &mut Vec<(&'st
let fd_key = egui::Id::new("lumetri-match-face");
let mut tc: String = ui.data(|d| d.get_temp(key)).unwrap_or_else(|| "00:00:00:00".to_string());
let mut face: bool = ui.data(|d| d.get_temp(fd_key)).unwrap_or(true);
// Comparison View: the Program monitor shows the reference frame next to the current one, and
// Apply Match uses that reference
let comparing = app.ui.program.display_mode() == Some(crate::state::DisplayMode::Comparison);
let b = ui.selectable_label(comparing, "Comparison View");
app.auto.add("lumetri.match.comparisonView", b.rect, "Comparison View");
if b.clicked() {
out.push(("view.display", json!({"display": if comparing { "composite" } else { "comparison" }})));
}
let reference = app.ui.program.compare_ref.filter(|_| comparing).map(filmcraft_time::Tick);
if let (Some(r), Some(q)) = (reference, app.session.active_sequence()) {
tc = filmcraft_time::format_time(r, q.settings.frame_rate, q.settings.drop_frame, filmcraft_time::TimeDisplay::Timecode, 48000);
}
ui.horizontal(|ui| {
ui.add_sized(vec2(110.0, 20.0), egui::Label::new(egui::RichText::new("Reference").color(t.text_dim)));
let r = ui.add(egui::TextEdit::singleline(&mut tc).desired_width(96.0));
@ -588,7 +640,11 @@ fn match_controls(ui: &mut egui::Ui, app: &mut FilmcraftApp, out: &mut Vec<(&'st
let b = ui.button("Apply Match");
app.auto.add("lumetri.match.apply", b.rect, "Apply Match");
if b.clicked() {
out.push(("lumetri.applyMatch", json!({"referenceTimecode": tc, "faceDetection": face})));
let p = match reference {
Some(r) => json!({"referenceTime": r.0, "faceDetection": face}),
None => json!({"referenceTimecode": tc, "faceDetection": face}),
};
out.push(("lumetri.applyMatch", p));
}
ui.data_mut(|d| {
d.insert_temp(key, tc);

View File

@ -347,6 +347,9 @@ pub struct UiState {
pub expanded_bins: Vec<u64>,
/// Expanded folders in the Effects panel.
pub expanded_fx: Vec<String>,
/// Effects panel: the Lumetri Presets folder shown as a thumbnail grid (wide panel).
#[serde(default)]
pub lumetri_grid_folder: Option<String>,
/// Collapsed effect sections in Effect Controls ("clip:index").
pub collapsed_fx: Vec<String>,
pub show_menu_bar: bool,
@ -718,6 +721,7 @@ impl Default for UiState {
effects_search: String::new(),
expanded_bins: vec![],
expanded_fx: vec!["Video Transitions".into(), "Video Transitions/Dissolve".into()],
lumetri_grid_folder: None,
collapsed_fx: vec![],
show_menu_bar: true,
dark: true,

View File

@ -231,3 +231,96 @@ fn apply_match_from_the_color_wheels_section() {
let ui = d.ok("ui.inspect", json!({}));
assert!(ui.to_string().contains("reference frame is inside"), "{ui}");
}
impl Driver {
fn app(&mut self) -> &mut FilmcraftApp {
self.harness.state_mut()
}
}
#[test]
fn hdr_lumetri_controls_hsl_refine_and_comparison_view() {
let mut d = Driver::demo();
d.ok("ui.set", json!({"workspace": "Color"}));
d.frames(3);
let clip = select_first_clip(&mut d);
d.exec("effects.apply", json!({"clips": [clip], "effect": "lumetri"}));
d.frames(3);
// SDR sequence: no HDR controls
assert!(d.ids("lumetri.param.hdr_white").is_empty());
// a Rec. 2100 PQ sequence: Basic Correction ▸ HDR White / HDR Specular, Curves ▸ HDR Range
d.exec("sequence.colorSettings", json!({"workingSpace": "rec2100-pq"}));
d.frames(3);
for id in ["lumetri.param.hdr_white", "lumetri.param.hdr_specular"] {
assert!(!d.ids(id).is_empty(), "{id} missing: {:?}", d.ids("lumetri."));
}
d.click("lumetri.section.Curves");
assert!(!d.ids("lumetri.param.curves_hdr_range").is_empty());
d.snapshot("color-lumetri-hdr", None);
// clicking on the HDR White slider sets it (an undoable engine edit)
let r = d.element("lumetri.param.hdr_white").unwrap();
d.ok("ui.click", json!({"x": r[0] + 112.0 + 4.0, "y": r[1] + r[3] / 2.0}));
d.frames(3);
let e = lumetri(&mut d, clip);
assert_ne!(e["params"]["hdr_white"]["value"], json!("Float(1000.0)"), "{e}");
// HSL Secondary ▸ Refine (fold Basic Correction and Curves so the section is on screen)
d.click("lumetri.section.Curves");
d.click("lumetri.section.Basic Correction");
d.click("lumetri.section.HSL Secondary");
for id in ["lumetri.param.hsl_denoise", "lumetri.param.hsl_blur"] {
assert!(!d.ids(id).is_empty(), "{id} missing");
}
d.snapshot("color-lumetri-hsl-refine", None);
// Color Match ▸ Comparison View toggles the Program monitor's Comparison View; Apply Match
// then uses its reference frame
d.click("lumetri.section.Color Wheels & Match");
d.click("lumetri.match.comparisonView");
assert_eq!(d.app().ui.program.display_mode(), Some(filmcraft_ui_egui::state::DisplayMode::Comparison));
assert!(!d.ids("program.compare.reference").is_empty());
let seq = d.exec("sequence.inspect", json!({}));
let last = seq["video"][0]["items"].as_array().unwrap().last().unwrap().clone();
let frame = last["startFrame"].as_i64().unwrap() + last["durationFrames"].as_i64().unwrap() / 2;
let rate = d.app().session.sequence_rate();
d.app().ui.program.compare_ref = Some(rate.tick_of(frame).0);
d.frames(3);
d.snapshot("color-comparison-view", None);
d.click("lumetri.match.apply");
let e = lumetri(&mut d, clip);
assert!(e["params"]["wheel_highlights"]["value"].as_str().unwrap() != "Vec2(Vec2 { x: 0.0, y: 0.0 })", "matched to the comparison reference: {e}");
d.click("lumetri.match.comparisonView");
assert_ne!(d.app().ui.program.display_mode(), Some(filmcraft_ui_egui::state::DisplayMode::Comparison));
}
#[test]
fn lumetri_presets_in_the_effects_panel() {
let mut d = Driver::demo();
d.ok("ui.panel.show", json!({"panel": "Effects"}));
d.frames(3);
let clip = select_first_clip(&mut d);
d.click("effects.folder.Lumetri Presets");
d.click("effects.folder.Lumetri Presets/Monochrome");
let rows = d.ids("effects.lumetriPreset.");
assert!(rows.iter().any(|r| r == "effects.lumetriPreset.Neutral Mono"), "{rows:?}");
// double-click a preset row: Lumetri Color configured as the preset on the selected clip
d.ok("ui.click", json!({"id": "effects.lumetriPreset.Neutral Mono", "button": "right"}));
d.frames(3);
d.click("effects.presetMenu.apply");
let e = lumetri(&mut d, clip);
assert_eq!(e["params"]["saturation"]["value"], json!("Float(0.0)"), "{e}");
d.exec("edit.undo", json!({}));
// maximized (wide) panel: the folder's thumbnail grid next to the tree
d.app().ui.keys.maximized = Some(filmcraft_ui_egui::dock::PanelKind::Effects);
d.frames(4);
assert!(!d.ids("effects.presetGrid").is_empty(), "no grid: {:?}", d.ids("effects."));
let cells = d.ids("effects.presetGrid.");
assert!(cells.iter().any(|c| c == "effects.presetGrid.Sepia Tone"), "{cells:?}");
d.click("effects.folder.Lumetri Presets/Cinematic");
let cells = d.ids("effects.presetGrid.");
assert!(cells.iter().any(|c| c == "effects.presetGrid.Golden Dusk"), "{cells:?}");
d.snapshot("color-lumetri-presets-grid", None);
d.ok("ui.click", json!({"id": "effects.presetGrid.Golden Dusk", "button": "right"}));
d.frames(3);
d.click("effects.presetMenu.apply");
let e = lumetri(&mut d, clip);
assert_eq!(e["params"]["temperature"]["value"], json!("Float(35.0)"), "{e}");
}

View File

@ -456,9 +456,25 @@ frame (Y'CbCr/RGB + metadata) ─► source colour space: Interpret Footage over
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).
scopes); otherwise the top-level render is converted for an SDR monitor. The other colour
effects work on display-encoded values clamped to 0..1 (they clip HDR highlights above
reference white).
- **HDR grading (Lumetri).** `FxCtx::working` carries the sequence's working space. In a PQ or HLG
sequence Lumetri grades the working space's own signal normalised to **HDR White** (cd/m²,
Basic Correction; the curves, wheels, looks and HSL Secondary use the Curves section's **HDR
Range**): `filmcraft_color::GradeSpace` (PQ: `PQ⁻¹(nits/10000) / PQ⁻¹(white/10000)`; HLG: the
BT.2100 inverse OOTF of a display with that peak, then the OETF). So the sliders and curves span
0 … HDR White like black … white in SDR, exposure is stops of light, and highlights above HDR
White pass through (contrast leaves them alone; **HDR Specular** scales them). Rec. 709
sequences grade exactly as before. HSL Secondary ▸ Refine: **Denoise** (median of the key,
radius 1–3 px) and **Blur** (Gaussian of the key, σ ≤ 20 px), both scaled with the playback
resolution.
- **HDR metadata.** `VideoStreamInfo::hdr` (`HdrMetadata`: ST 2086 mastering luminance, MaxCLL,
MaxFALL) is read from `mdcv` / `clli` (MP4 / MOV) and Matroska `MasteringMetadata` / `MaxCLL` /
`MaxFALL`. PQ media tone mapped into an SDR working space uses it as the BT.2390 source peak
(MaxCLL, else the mastering peak; 1000 cd/m² without metadata), so a 4000-nit master keeps its
highlight detail instead of clipping at 1000. `media.colorInfo` reports it (`hdrMetadata`,
`toneMapPeakNits`). The field is optional and serde-defaulted (no schema change).
- **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`
@ -502,9 +518,19 @@ multi-camera clip = nested source + TrackItem::multicam {enabled, angle}
- **Render.** `render::item_layer` renders a multi-camera clip's angle track only
(`render_seq_tracks`); `render::audio` mixes the nested source with only the audible tracks
(`Sequence::with_angle_audio`). Nested audio now plays at all (it was skipped when the nest had
no media source) and is limited to the clip's range. `render::multicam::render_grid` renders the
shown angles at the cell scale in parallel (rayon) and tiles them: the Multi-Camera view is one
frame job (`frames::Target::MulticamGrid`), prefetched while playing like the program.
no media source) and is limited to the clip's range. `render::multicam::render_grid_page` renders
one page of the shown angles at the cell scale in parallel (rayon) and tiles them: the
Multi-Camera view is one frame job (`frames::Target::MulticamGrid(source, layout, page)`),
prefetched while playing like the program. Only the page's angles are decoded.
- **Multi-Camera view settings** (`EditorState::multicam_view`, Program ▸ wrench menu): grid
layout (automatic = smallest square up to 4×4, or fixed 2×2 / 3×3 / 4×4) with pages when the
angles don't fit (`render::multicam::page_layout`; `multicam.gridLayout`, `multicam.page`,
page arrows; keys 1–9 pick cameras on the shown page, `multicam.cut {camera}` stays absolute);
Multi-Camera Selection Top Down (stacked multi-camera clips: topmost instead of lowest);
Show Multi-Camera Preview Monitor (off: the grid fills the monitor); Auto-Adjust Multi-Camera
Playback Quality (`grid_cell_scale`: ½ / ¼ of the cell scale while playing); Transmit
Multi-Camera View (stored; there is no transmit device yet). `multicam.grid` returns the page.
Edit Cameras… shows a thumbnail per angle (`frames::Target::MulticamAngle`).
- **Editing** (`edit::multicam`, `engine::multicam`). `multicam.switchAngle` (click an angle,
Ctrl/⌘-click for video only), `multicam.selectCamera1…9` (keys 1–9) and `cutToCamera1…9`
(Ctrl+1–9), Enable/Flatten, Edit Cameras, Audio Follows Video (`EditorState`). Live switching:

View File

@ -23,7 +23,7 @@ FILMCRAFT_REQUIRE_ORACLES=1 cargo test --workspace # CI: missing ffmpeg fails
| Golden images | `crates/golden/tests/golden.rs` | CPU renders vs committed PNGs; GPU vs CPU on the same scenes (§3) |
| Engine / command | `crates/engine/src/tests.rs` | run commands on the demo project, assert the sequence, undo/redo, disabled cases |
| Render | `crates/render/src/tests.rs` | compositing, opacity, Motion, cross dissolve midpoint, ½-res vs full, GPU plan vs reference, audio mix, audio-effect continuity |
| Multi-camera / sync | `crates/audio-dsp/src/sync.rs`, `crates/engine/src/multicam_tests.rs`, `crates/ui-egui/tests/multicam_ui.rs` | audio offsets within one sample: Rust-generated multi-mic speech (gains −20…+10 dB, SNR 40/25/10/3 dB, zero-phase colouring, fractional delays, a 0 dB-SNR room with a −4 dB echo) and ffmpeg-made camera/recorder files (pink-noise bursts, independent noise; exact lags recovered); timecode (± hours) and marker sync within a frame; Merge Clips; timeline Synchronize keeps frame-aligned starts; multicam structure, angle render (PSNR vs the camera), Switch Audio mix = the angle's track, live switching from a simulated playback clock (cuts, healing, one undo step), Ctrl+N cuts, flatten (frames identical), save/load; `perf_grid_of_four_1080p_angles` (ignored) prints the grid cost |
| Multi-camera / sync | `crates/audio-dsp/src/sync.rs`, `crates/engine/src/multicam_tests.rs`, `crates/ui-egui/tests/multicam_ui.rs` | audio offsets within one sample: Rust-generated multi-mic speech (gains −20…+10 dB, SNR 40/25/10/3 dB, zero-phase colouring, fractional delays, a 0 dB-SNR room with a −4 dB echo) and ffmpeg-made camera/recorder files (pink-noise bursts, independent noise; exact lags recovered); timecode (± hours) and marker sync within a frame; Merge Clips; timeline Synchronize keeps frame-aligned starts; multicam structure, angle render (PSNR vs the camera), Switch Audio mix = the angle's track, live switching from a simulated playback clock (cuts, healing, one undo step), Ctrl+N cuts, flatten (frames identical), save/load; 20 angles: paging (auto / 2×2 / 3×3), page-relative keys, absolute cameras beyond 16, a page decoding only its angles at the cell scale (recording source), Auto-Adjust scale, Selection Top Down; headless: page arrows, angle 17–20 cells, grid-only view, Edit Cameras thumbnails; `perf_grid_of_four_1080p_angles` (ignored) prints the grid cost |
| Project panel and Media Browser (M12.7) | `crates/engine/src/{project_panel,media_browser}_tests.rs`, `crates/ui-egui/tests/project_panel_ui.rs` | view settings and their preference round trip; Metadata Display columns (custom fields, Freeform keys hidden), numeric vs text sorting, view presets (save / save as / restore / rename / delete, ten slots, persisted in `preferences.json`); Freeform moves keeping offsets, snapping, Align to Grid, Clip Size, stacks, arrangements, Reset (each one undo step, saved in the project); bin rename / nesting; the Media Browser against a fake filesystem (folders first, hidden and unsupported files left out, file-type filter, back / forward / up, recent directories, Favorites, import of a selection or a folder, Open in Source Monitor, ingest, probe columns). Headless: List view (sort by header, drag a column wider, inline rename), Icon view (hover scrub + I / O set the clip's In / Out, Shift+] / [), Freeform (drag a card, stack from the card menu, options dialog), panel menu, Metadata Display dialog, view presets, bins in place / tab / window, footer buttons, Media Browser navigation, Favorites, import, Shift+O, Edit Columns…, thumbnails, drag to the Timeline (`FILMCRAFT_UI_SNAPSHOT_DIR=<dir>` writes `project-*.png`, `media-browser-*.png`) |
| Menu long tail (M3.11) | `crates/render/src/scene.rs`, `crates/engine/src/{scene_detect,sequence_extras,project_tools}_tests.rs`, `crates/interchange/src/ale.rs`, `crates/project/src/find.rs`, `crates/ui-egui/tests/menus_ui.rs` | Scene Edit Detection on synthetic frames (hard cuts vs pans, flashes, sensitivity) and on generated movies with known cut points (our own ProRes render of demo scenes; an ffmpeg-made H.264 concat of testsrc2 / SMPTE bars / Mandelbrot): cuts with linked audio, clip markers, subclips, one undo step, background job and cancel; Normalize Mix Track to a target peak; Simplify Sequence; Find / Find Next and search bins; Automate to Sequence (overlap, transitions, insert, unnumbered markers); ALE round trip; Project Settings and scratch disks; menu order and shortcuts against Premiere's; the dialogs by automation id |
| Mixer | `crates/render/src/mixer_tests.rs`, `crates/engine/src/mixer_tests.rs` | sample-exact fader gain and pan laws, automation at block boundaries (bit-identical however requests are cut), solo/mute/solo-safe, sends and submix routing, latency-compensation alignment, render-vs-playback identity, Touch ramp-back, recorder modes (Latch/Touch/Write), thinning, Audio Gain, transition curves; `perf_24_tracks_3_effects_realtime_factor` prints the realtime factor |