Merge main into #1739; keep both footage re-exports (Cineon + VIDEO_EXTENSIONS) and main's gaps.md localization row with the PR's AVI row

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-30 07:44:20 +07:00
co-authored by Claude Opus 5.5
181 changed files with 8437 additions and 619 deletions
+24 -2
View File
@@ -596,6 +596,22 @@ pub fn shape_simple_op(ids: &mut Ids, kind: &str) -> Option<PropGroup> {
.with(ids.prop("phase", "Temporal Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
.with(wiggle_spatial_phase(ids))
.with(ids.prop("seed", "Random Seed", Value::Scalar(0.0))),
"wiggler" | "wiggleTransform" => {
// The Transform group holds how far each transformation wiggles.
let tr = ids
.group("transform", "Transform")
.with(ids.prop("anchor", "Anchor Point", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point))
.with(ids.prop("position", "Position", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point))
.with(ids.prop("scale", "Scale", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Percent))
.with(ids.prop("rotation", "Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle));
indexed(ids.group("wiggler", "Wiggle Transform 1"))
.with(ids.prop("speed", "Wiggles/Second", Value::Scalar(2.0)))
.with(ids.prop("correlation", "Correlation", Value::Scalar(50.0)).with_ui(ParamUi::Percent))
.with(ids.prop("phase", "Temporal Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
.with(ids.prop("spatialPhase", "Spatial Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
.with(ids.prop("seed", "Random Seed", Value::Scalar(0.0)))
.with(tr)
}
"merge" => indexed(ids.group("merge", "Merge Paths 1")).with(ids.prop("mode", "Mode", Value::Enum(0)).with_ui(popup(&[
"Merge",
"Add",
@@ -681,10 +697,16 @@ pub fn light_options(ids: &mut Ids, kind: LightKind) -> PropGroup {
}
if kind == LightKind::Environment {
// The environment image: a layer (footage, comp…) mapped as an equirectangular panorama;
// none = the comp's Environment Layer.
// none = the comp's Environment Layer, or a flat default environment of the light's
// colour × intensity when there is none. No Falloff: an environment light shines from
// every direction at once. Environment lights cast shadows too (After Effects' Light
// Settings shows Cast Shadows for them; not for Ambient) (#2436).
return g
.with(ids.prop("source", "Source", Value::Layer(None)).with_ui(ParamUi::Layer))
.with(ids.prop("rotation", "Environment Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle));
.with(ids.prop("rotation", "Environment Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
.with(ids.prop("castsShadows", "Casts Shadows", Value::Bool(false)).with_ui(ParamUi::Checkbox))
.with(ids.prop("shadowDarkness", "Shadow Darkness", Value::Scalar(100.0)).with_ui(ParamUi::Percent))
.with(ids.prop("shadowDiffusion", "Shadow Diffusion", Value::Scalar(0.0)).with_ui(ParamUi::Pixels));
}
if kind != LightKind::Ambient {
g = g
+4 -4
View File
@@ -87,8 +87,8 @@ pub struct Comp {
pub display_start: Tick,
pub background: [f32; 3],
pub work_area: (Tick, Tick),
/// Layer #1 first (top of the stack).
pub layers: Vec<Layer>,
/// Layer #1 first (top of the stack). Each layer is shared between undo states until edited.
pub layers: super::Layers,
#[serde(default)]
pub markers: Vec<Marker>,
pub shutter_angle: f64,
@@ -188,7 +188,7 @@ impl Comp {
display_start: Tick::ZERO,
background: [0.0, 0.0, 0.0],
work_area: (Tick::ZERO, duration),
layers: vec![],
layers: super::Layers::new(),
markers: vec![],
shutter_angle: 180.0,
shutter_phase: -90.0,
@@ -239,7 +239,7 @@ impl Comp {
}
pub fn layer_mut(&mut self, id: LayerId) -> Option<&mut Layer> {
self.layers.iter_mut().find(|l| l.id == id)
self.layers.find_mut(|l| l.id == id)
}
/// 1-based layer number (#) of a layer.
+151
View File
@@ -57,6 +57,142 @@ pub enum AlphaMode {
Ignore,
}
/// Interpret Footage ▸ Cineon Settings ▸ Units: how the Converted Black and White Points are
/// shown and entered.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum CineonUnits {
/// 10-bit code values (0–1023).
#[default]
TenBit,
/// 8-bit values (0–255).
EightBit,
/// Floating point (0–1).
Float,
}
impl CineonUnits {
pub const ALL: [CineonUnits; 3] = [CineonUnits::TenBit, CineonUnits::EightBit, CineonUnits::Float];
/// The id commands use: `10bit`, `8bit` or `float`.
pub fn id(self) -> &'static str {
match self {
CineonUnits::TenBit => "10bit",
CineonUnits::EightBit => "8bit",
CineonUnits::Float => "float",
}
}
pub fn label(self) -> &'static str {
match self {
CineonUnits::TenBit => "10-bit",
CineonUnits::EightBit => "8-bit",
CineonUnits::Float => "Float",
}
}
pub fn parse(s: &str) -> Option<CineonUnits> {
match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
"10bit" | "10" => Some(CineonUnits::TenBit),
"8bit" | "8" => Some(CineonUnits::EightBit),
"float" | "32bit" => Some(CineonUnits::Float),
_ => None,
}
}
/// A 10-bit value (0–1023) in these units.
pub fn from_ten_bit(self, v: f64) -> f64 {
match self {
CineonUnits::TenBit => v,
CineonUnits::EightBit => v * 255.0 / 1023.0,
CineonUnits::Float => v / 1023.0,
}
}
/// A value in these units as a 10-bit value (0–1023).
pub fn to_ten_bit(self, v: f64) -> f64 {
match self {
CineonUnits::TenBit => v,
CineonUnits::EightBit => v * 1023.0 / 255.0,
CineonUnits::Float => v * 1023.0,
}
}
}
/// Interpret Footage ▸ Cineon Settings of DPX and Cineon footage: how the file's logarithmic
/// (printing density) code values become the footage's pixels. Code values are counted in 10-bit
/// units whatever the file's bit depth.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CineonSettings {
/// Logarithmic Conversion: convert the code values from log to the Current Gamma. When it
/// is off, the code values are shown as stored.
pub log_conversion: bool,
/// Converted Black Point: the level the 10 Bit Black Point becomes (10-bit units).
pub converted_black: f32,
/// Converted White Point: the level the 10 Bit White Point becomes (10-bit units).
pub converted_white: f32,
/// 10 Bit Black Point: the code value of minimum density.
pub black: f32,
/// 10 Bit White Point: the code value of maximum density.
pub white: f32,
/// Current Gamma: the gamma the converted values are encoded with (1: linear light).
pub gamma: f32,
/// Highlight Rolloff (0–100): values near and above the white point ramp off gradually
/// instead of clipping; 0 keeps over-range values.
pub rolloff: f32,
/// Units of the Converted Black and White Points.
pub units: CineonUnits,
}
impl Default for CineonSettings {
fn default() -> Self {
CineonSettings {
log_conversion: false,
converted_black: 0.0,
converted_white: 1023.0,
black: 95.0,
white: 685.0,
gamma: 1.7,
rolloff: 20.0,
units: CineonUnits::TenBit,
}
}
}
impl CineonSettings {
/// The highest 10-bit value.
pub const MAX: f32 = 1023.0;
/// A code value (0–1 of the 10-bit range) converted from log to the Current Gamma and the
/// converted black and white levels (0–1; above 1 for over-range highlights).
pub fn convert(&self, v: f32) -> f32 {
let lin = cineon_log_to_lin(v, self.black, self.white, 1.0, self.gamma, self.rolloff);
let out = (self.converted_black + (self.converted_white - self.converted_black) * lin) / Self::MAX;
if out.is_finite() { out } else { 0.0 }
}
}
/// Cineon log to linear, shared by Interpret Footage ▸ Cineon Settings and the Cineon Converter
/// effect. `v` is a code value as 0–1 of the 10-bit range; `black` and `white` are the 10 Bit
/// Black and White Points. The result is 0 at the black point and 1 at the white point, raised to
/// `scale / gamma`, with values above `1 - rolloff / 100` ramped off toward 1 (Highlight Rolloff
/// 0–100; 0 keeps over-range values). A Gamma below 0.01 is raised to 0.01. Cineon Settings passes
/// a `scale` of 1 (Gamma 1 gives linear light) and the Cineon Converter passes 1.7 (its default
/// Gamma 1.7 gives linear light).
pub fn cineon_log_to_lin(v: f32, black: f32, white: f32, scale: f32, gamma: f32, rolloff: f32) -> f32 {
// Kodak printing density: 0.002 density per code value, negative gamma 0.6.
let exposure = |code: f32| 10f32.powf((code - white) * 0.002 / 0.6);
let offset = exposure(black);
let mut lin = ((exposure(v * CineonSettings::MAX) - offset) / (1.0 - offset).max(1e-6)).max(0.0);
lin = lin.powf(scale / gamma.max(0.01));
let knee = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99);
if knee < 1.0 && lin > knee {
let w = 1.0 - knee;
lin = knee + w * (1.0 - (-(lin - knee) / w).exp());
}
lin
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Footage {
pub path: String,
@@ -103,6 +239,9 @@ pub struct Footage {
/// colour profile, Interpret As Linear Light and the working space don't apply.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub preserve_rgb: bool,
/// Interpret Footage ▸ Cineon Settings (DPX and Cineon files only; [`Footage::cineon_applies`]).
#[serde(default, skip_serializing_if = "is_default")]
pub cineon: CineonSettings,
/// Data footage ([`FootageKind::Data`]): the file's text (JSON, CSV or TSV), kept in the
/// project so expressions read it everywhere (and on the web).
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -154,6 +293,7 @@ impl Default for Footage {
invert_alpha: false,
linear_light: false,
preserve_rgb: false,
cineon: CineonSettings::default(),
data: None,
layer: None,
page: 0,
@@ -165,6 +305,17 @@ impl Default for Footage {
}
impl Footage {
/// The file is DPX or Cineon (the formats Interpret Footage ▸ Cineon Settings applies to).
pub fn is_cineon_file(&self) -> bool {
let ext = std::path::Path::new(&self.path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
matches!(ext.as_str(), "dpx" | "cin")
}
/// Cineon Settings ▸ Logarithmic Conversion applies: it is on and the file is DPX or Cineon.
pub fn cineon_applies(&self) -> bool {
self.cineon.log_conversion && self.is_cineon_file()
}
/// Interpret As Linear Light applies (Preserve RGB passes the file's values through).
pub fn linear_light_applies(&self) -> bool {
self.linear_light && !self.preserve_rgb
+209
View File
@@ -0,0 +1,209 @@
//! A comp's layer stack with structural sharing.
//!
//! Every undoable edit keeps the previous project for undo, so the comp it touches is shared and
//! `Arc::make_mut` copies it. With the layers stored inline that copy is the whole stack, every
//! layer and every keyframe, so an edit costs time in proportion to the comp's size and a script
//! that sets many keys in a big comp goes quadratic. Here each layer sits behind its own `Arc`:
//! copying the stack copies pointers, and only the layer an edit actually changes is copied.
//!
//! The API mirrors the `Vec<Layer>` it replaces. Serialized form is identical (a list of layers).
use std::ops::{Index, IndexMut};
use std::sync::Arc;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use super::layer::Layer;
/// Layer #1 first (top of the stack).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Layers(Vec<Arc<Layer>>);
impl Layers {
pub const fn new() -> Self {
Self(Vec::new())
}
pub fn with_capacity(n: usize) -> Self {
Self(Vec::with_capacity(n))
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn iter(&self) -> impl DoubleEndedIterator<Item = &Layer> + ExactSizeIterator + Clone {
self.0.iter().map(|l| &**l)
}
/// Mutable access to every layer: each one that is still shared with an undo state is copied,
/// so prefer [`Layers::get_mut`] (or `iter` to find, then `get_mut`) when only some change.
pub fn iter_mut(&mut self) -> impl DoubleEndedIterator<Item = &mut Layer> + ExactSizeIterator {
self.0.iter_mut().map(Arc::make_mut)
}
/// Mutable access to just the layers `keep` accepts. `keep` reads each layer without copying
/// it; only the matching ones are copied (if shared). Use this, not `iter_mut().filter(..)`.
pub fn iter_mut_where<'a>(&'a mut self, mut keep: impl FnMut(&Layer) -> bool + 'a) -> impl Iterator<Item = &'a mut Layer> + 'a {
self.0.iter_mut().filter(move |l| keep(l)).map(Arc::make_mut)
}
/// The first layer `pred` accepts, copied only if it is that one. Use this, not `iter_mut().find(..)`.
pub fn find_mut(&mut self, mut pred: impl FnMut(&Layer) -> bool) -> Option<&mut Layer> {
self.0.iter_mut().find(|l| pred(l)).map(Arc::make_mut)
}
pub fn get(&self, i: usize) -> Option<&Layer> {
self.0.get(i).map(|l| &**l)
}
pub fn get_mut(&mut self, i: usize) -> Option<&mut Layer> {
self.0.get_mut(i).map(Arc::make_mut)
}
pub fn first(&self) -> Option<&Layer> {
self.get(0)
}
pub fn last(&self) -> Option<&Layer> {
self.0.last().map(|l| &**l)
}
pub fn first_mut(&mut self) -> Option<&mut Layer> {
self.get_mut(0)
}
pub fn last_mut(&mut self) -> Option<&mut Layer> {
self.0.last_mut().map(Arc::make_mut)
}
pub fn push(&mut self, l: Layer) {
self.0.push(Arc::new(l));
}
pub fn insert(&mut self, i: usize, l: Layer) {
self.0.insert(i, Arc::new(l));
}
pub fn remove(&mut self, i: usize) -> Layer {
Arc::unwrap_or_clone(self.0.remove(i))
}
pub fn pop(&mut self) -> Option<Layer> {
self.0.pop().map(Arc::unwrap_or_clone)
}
pub fn retain(&mut self, mut f: impl FnMut(&Layer) -> bool) {
self.0.retain(|l| f(l));
}
pub fn swap(&mut self, a: usize, b: usize) {
self.0.swap(a, b);
}
pub fn truncate(&mut self, n: usize) {
self.0.truncate(n);
}
pub fn clear(&mut self) {
self.0.clear();
}
pub fn reverse(&mut self) {
self.0.reverse();
}
pub fn sort_by_key<K: Ord>(&mut self, mut f: impl FnMut(&Layer) -> K) {
self.0.sort_by_key(|l| f(l));
}
pub fn sort_by(&mut self, mut f: impl FnMut(&Layer, &Layer) -> std::cmp::Ordering) {
self.0.sort_by(|a, b| f(a, b));
}
pub fn rotate_left(&mut self, n: usize) {
self.0.rotate_left(n);
}
pub fn rotate_right(&mut self, n: usize) {
self.0.rotate_right(n);
}
/// `layers[range].rotate_left(n)` (a slice of `Arc`s can't be handed out as a `&mut [Layer]`).
pub fn rotate_range_left(&mut self, r: impl std::slice::SliceIndex<[Arc<Layer>], Output = [Arc<Layer>]>, n: usize) {
if let Some(s) = self.0.get_mut(r) {
s.rotate_left(n);
}
}
pub fn rotate_range_right(&mut self, r: impl std::slice::SliceIndex<[Arc<Layer>], Output = [Arc<Layer>]>, n: usize) {
if let Some(s) = self.0.get_mut(r) {
s.rotate_right(n);
}
}
pub fn split_off(&mut self, at: usize) -> Layers {
Layers(self.0.split_off(at))
}
pub fn append(&mut self, other: &mut Layers) {
self.0.append(&mut other.0);
}
/// Remove a range of layers and return them (owned).
pub fn drain(&mut self, r: impl std::ops::RangeBounds<usize>) -> std::vec::IntoIter<Layer> {
self.0.drain(r).map(Arc::unwrap_or_clone).collect::<Vec<_>>().into_iter()
}
/// Whether `self` and `other` hold the very same (unchanged) layer at `i`: a cheap "did this
/// layer change" test between two project states.
pub fn same_at(&self, other: &Layers, i: usize) -> bool {
matches!((self.0.get(i), other.0.get(i)), (Some(a), Some(b)) if Arc::ptr_eq(a, b))
}
/// The shared layers themselves, for parallel iteration (`rayon` over `&[Arc<Layer>]`).
pub fn as_arcs(&self) -> &[Arc<Layer>] {
&self.0
}
pub fn to_vec(&self) -> Vec<Layer> {
self.iter().cloned().collect()
}
}
impl Index<usize> for Layers {
type Output = Layer;
fn index(&self, i: usize) -> &Layer {
&self.0[i]
}
}
impl IndexMut<usize> for Layers {
fn index_mut(&mut self, i: usize) -> &mut Layer {
Arc::make_mut(&mut self.0[i])
}
}
impl From<Vec<Layer>> for Layers {
fn from(v: Vec<Layer>) -> Self {
Self(v.into_iter().map(Arc::new).collect())
}
}
impl FromIterator<Layer> for Layers {
fn from_iter<I: IntoIterator<Item = Layer>>(it: I) -> Self {
Self(it.into_iter().map(Arc::new).collect())
}
}
impl Extend<Layer> for Layers {
fn extend<I: IntoIterator<Item = Layer>>(&mut self, it: I) {
self.0.extend(it.into_iter().map(Arc::new));
}
}
impl IntoIterator for Layers {
type Item = Layer;
type IntoIter = std::iter::Map<std::vec::IntoIter<Arc<Layer>>, fn(Arc<Layer>) -> Layer>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter().map(Arc::unwrap_or_clone as fn(Arc<Layer>) -> Layer)
}
}
impl<'a> IntoIterator for &'a Layers {
type Item = &'a Layer;
type IntoIter = std::iter::Map<std::slice::Iter<'a, Arc<Layer>>, fn(&'a Arc<Layer>) -> &'a Layer>;
fn into_iter(self) -> Self::IntoIter {
self.0.iter().map(|l| &**l)
}
}
impl<'a> IntoIterator for &'a mut Layers {
type Item = &'a mut Layer;
type IntoIter = std::iter::Map<std::slice::IterMut<'a, Arc<Layer>>, fn(&'a mut Arc<Layer>) -> &'a mut Layer>;
fn into_iter(self) -> Self::IntoIter {
self.0.iter_mut().map(Arc::make_mut)
}
}
impl Serialize for Layers {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_seq(self.iter())
}
}
impl<'de> Deserialize<'de> for Layers {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Vec::<Layer>::deserialize(d).map(Layers::from)
}
}
+5 -3
View File
@@ -1,8 +1,8 @@
//! The EffectCraft document model.
//!
//! `Project` → items (folders, compositions, footage, solids) → `Comp` → `Layer` → property tree
//! ([`props`]). Compositions sit behind `Arc` so undo snapshots share everything that did not
//! change.
//! ([`props`]). Compositions, and each layer within them ([`Layers`]), sit behind `Arc` so undo
//! snapshots share everything that did not change.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -33,12 +33,14 @@ mod comp;
mod footage;
mod item;
mod layer;
mod layers;
mod settings;
pub use comp::{Comp, Guide, Renderer, Resolution};
pub use footage::{AlphaMode, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer, VIDEO_EXTENSIONS};
pub use footage::{AlphaMode, CineonSettings, CineonUnits, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer, VIDEO_EXTENSIONS, cineon_log_to_lin};
pub use item::{Item, ItemId, ItemKind, LayerId};
pub use layer::{AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte};
pub use layers::Layers;
pub use settings::{
APP_VERSION, BitDepth, ColorEngine, CuePoint, HdrMode, Marker, ProjectError, ProjectSettings, SCHEMA_EFFECT_SPACE, SCHEMA_VERSION, SavedView,
TimeDisplayStyle, is_newer_version, saved_by, saved_view,
+1 -1
View File
@@ -470,7 +470,7 @@ mod tests {
let mut ids = Ids(&mut p.next_id);
add_style(&mut a, &mut ids, "dropShadow", &gl, true);
add_style(&mut b, &mut ids, "bevelEmboss", &gl, true);
comp.layers = vec![a, b];
comp.layers = vec![a, b].into();
comp.layers[1].props.sub_mut(GROUP).unwrap().sub_mut(BLENDING).unwrap().get_mut("globalLightAngle").unwrap().value = Value::Scalar(45.0);
assert!(needs_sync(&comp));
sync_comp(&mut comp);
+43
View File
@@ -291,6 +291,49 @@ fn comp_contains_ends_on_cycles_long_chains_and_shared_precomps() {
assert!(!p.comp_contains(c[n - 1], c[0]));
}
/// Interpret Footage ▸ Cineon Settings: the 10 Bit Black and White Points become the converted
/// points, Current Gamma 1 gives linear light, Highlight Rolloff 0 keeps over-range values and
/// a higher one ramps them off; the settings apply only to DPX and Cineon files, and projects
/// saved without them read the defaults.
#[test]
fn cineon_settings_convert_log_code_values() {
use crate::{CineonSettings, CineonUnits, Footage};
let code = |v: f32| v / 1023.0;
let c = CineonSettings { log_conversion: true, rolloff: 0.0, ..Default::default() };
assert!(c.convert(code(95.0)).abs() < 1e-6, "black point");
assert!((c.convert(code(685.0)) - 1.0).abs() < 1e-5, "white point");
// Code 400: (10^(-0.95) - 10^(-1.9667)) / (1 - 10^(-1.9667)) = 0.10251 linear, ^(1/1.7).
assert!((c.convert(code(400.0)) - 0.26190).abs() < 1e-4, "{}", c.convert(code(400.0)));
let linear = CineonSettings { gamma: 1.0, ..c };
assert!((linear.convert(code(400.0)) - 0.10251).abs() < 1e-4, "{}", linear.convert(code(400.0)));
assert!(c.convert(code(1023.0)) > 1.5, "over range kept: {}", c.convert(code(1023.0)));
// The default rolloff (20) ramps the white point and above off, up to 1.
let rolled = CineonSettings { rolloff: 20.0, ..c };
let (w, mid, top) = (rolled.convert(code(685.0)), rolled.convert(code(720.0)), rolled.convert(code(1023.0)));
assert!(w > 0.8 && w < mid && mid < top && top <= 1.0, "{w} {mid} {top}");
let points = CineonSettings { converted_black: 64.0, converted_white: 940.0, ..c };
assert!((points.convert(code(95.0)) - 64.0 / 1023.0).abs() < 1e-5);
assert!((points.convert(code(685.0)) - 940.0 / 1023.0).abs() < 1e-5);
// Units.
assert!((CineonUnits::EightBit.from_ten_bit(1023.0) - 255.0).abs() < 1e-9);
assert!((CineonUnits::Float.to_ten_bit(0.5) - 511.5).abs() < 1e-9);
assert_eq!(CineonUnits::parse("8-bit"), Some(CineonUnits::EightBit));
assert!(CineonUnits::ALL.iter().all(|u| CineonUnits::parse(u.id()) == Some(*u)));
// DPX and Cineon files only, and only with Logarithmic Conversion on.
let on = CineonSettings { log_conversion: true, ..Default::default() };
let dpx = Footage { path: "/m/plate.0001.DPX".into(), cineon: on, ..Default::default() };
assert!(dpx.cineon_applies());
assert!(Footage { path: "/m/scan.cin".into(), cineon: on, ..Default::default() }.cineon_applies());
assert!(!Footage { path: "/m/still.png".into(), cineon: on, ..Default::default() }.cineon_applies());
assert!(!Footage { path: "/m/plate.dpx".into(), ..Default::default() }.cineon_applies());
// Saved only when changed; read back, and absent in older projects.
let plain = serde_json::to_value(Footage { path: "/m/a.dpx".into(), ..Default::default() }).unwrap();
assert!(plain.get("cineon").is_none(), "{plain}");
let saved = serde_json::to_string(&dpx).unwrap();
assert_eq!(serde_json::from_str::<Footage>(&saved).unwrap().cineon, on);
assert_eq!(serde_json::from_value::<Footage>(plain).unwrap().cineon, CineonSettings::default());
}
/// A Wiggle Paths 100 000 groups deep gets Spatial Phase on a 2 MiB stack.
#[test]
fn upgrading_deeply_nested_shape_groups_fits_a_small_stack() {