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:
parent
468321391f
commit
2a199425d0
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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.
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ impl ImageSequenceSource {
|
|||
color: filmcraft_color::ColorInfo::SRGB_FULL,
|
||||
has_alpha,
|
||||
bitrate: None,
|
||||
hdr: None,
|
||||
}),
|
||||
audio: None,
|
||||
container: "Image Sequence".into(),
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ impl StillSource {
|
|||
color: filmcraft_color::ColorInfo::SRGB_FULL,
|
||||
has_alpha,
|
||||
bitrate: None,
|
||||
hdr: None,
|
||||
}),
|
||||
audio: None,
|
||||
container: "Image".into(),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue