Merge main into M14.5 (settings and menus)

Display Color Space setting joins the live list; View3D::Default maps to view.3d.default for
the Assign Shortcut submenu; stub tests use the stubs that remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 03:29:39 -04:00
co-authored by Claude Opus 5.5
47 changed files with 3914 additions and 126 deletions
+1
View File
@@ -242,6 +242,7 @@ pub(crate) fn view_command(v: effectcraft_render::three_d::View3D) -> &'static s
use effectcraft_render::three_d::View3D::*;
match v {
ActiveCamera => "view.3d.activeCamera",
Default => "view.3d.default",
Front => "view.3d.front",
Left => "view.3d.left",
Top => "view.3d.top",
+42 -5
View File
@@ -326,15 +326,52 @@ fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(l) = p.get("blendLinear").or_else(|| p.get("blendColorsUsing1Gamma")).and_then(Value::as_bool) {
proj.settings.blend_linear = l;
}
use effectcraft_project::{ColorEngine, ColorSpace, HdrMode};
let cmd = "file.projectSettings";
if let Some(e) = str_p(p, "colorEngine") {
let engine = match e.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() {
"adobe" | "adobemanaged" | "builtin" => ColorEngine::Adobe,
"ocio" | "ociomanaged" => ColorEngine::Ocio,
_ => return Err(bad(cmd, format!("colorEngine: adobe|ocio, not `{e}`"))),
};
if engine == ColorEngine::Ocio && proj.settings.color_engine != ColorEngine::Ocio {
// The OCIO built-in config works in ACES and renders the display through its
// tone-mapped view.
if !proj.settings.working_space.is_some_and(|w| w.is_linear()) {
proj.settings.working_space = Some(ColorSpace::AcesCg);
}
if proj.settings.hdr == HdrMode::Clip {
proj.settings.hdr = HdrMode::ToneMap;
}
}
proj.settings.color_engine = engine;
}
if let Some(w) = p.get("workingSpace") {
let ids = ColorSpace::WORKING.iter().map(|c| c.id()).collect::<Vec<_>>().join("|");
proj.settings.working_space = match w.as_str() {
None | Some("none" | "None" | "") => None,
Some(n) => Some(
effectcraft_project::ColorSpace::parse(n)
.ok_or_else(|| bad("file.projectSettings", format!("workingSpace: none|srgb|rec709|rec2020|p3, not `{n}`")))?,
ColorSpace::parse(n).filter(|c| ColorSpace::WORKING.contains(c)).ok_or_else(|| bad(cmd, format!("workingSpace: none|{ids}, not `{n}`")))?,
),
};
}
if proj.settings.color_engine == ColorEngine::Ocio && !proj.settings.working_space.is_some_and(|w| w.is_linear()) {
return Err(bad(cmd, "the OCIO built-in config's working spaces are acescg and aces2065"));
}
if let Some(h) = str_p(p, "hdr") {
proj.settings.hdr = match h.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() {
"clip" | "off" | "none" => HdrMode::Clip,
"compand" => HdrMode::Compand,
"tonemap" | "tonemapped" => HdrMode::ToneMap,
_ => return Err(bad(cmd, format!("hdr: clip|compand|toneMap, not `{h}`"))),
};
}
if let Some(o) = p.get("outputSpace") {
proj.settings.output_space = match o.as_str() {
None | Some("none" | "None" | "" | "default") => None,
Some(n) => Some(ColorSpace::parse(n).ok_or_else(|| bad(cmd, format!("outputSpace: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, not `{n}`")))?),
};
}
if let Some(r) = p.get("renderer").or_else(|| p.get("gpuAcceleration")) {
proj.settings.gpu_acceleration = match r {
Value::Bool(b) => *b,
@@ -347,8 +384,8 @@ fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
};
}
if let Some(t) = str_p(p, "timeDisplay") {
proj.settings.time_display =
if t.eq_ignore_ascii_case("frames") { effectcraft_project::TimeDisplayStyle::Frames } else { effectcraft_project::TimeDisplayStyle::Timecode };
proj.settings.time_display = effectcraft_project::TimeDisplayStyle::parse(t)
.ok_or_else(|| bad("file.projectSettings", format!("timeDisplay: timecode|frames|feet35|feet16, not `{t}`")))?;
}
Ok(())
})?;
@@ -421,7 +458,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Project Settings...",
["File"],
Some("Cmd+Alt+Shift+K"),
"{bitDepth?: 8|16|32, workingSpace?: none|srgb|rec709|rec2020|p3, linearize?, blendLinear?, renderer?: gpu|software, timeDisplay?: timecode|frames}",
"{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16}",
always,
project_settings
),
+3 -1
View File
@@ -40,6 +40,7 @@ mod proxy;
pub mod puppet;
mod query;
mod render_queue;
pub(crate) mod rig3d;
pub mod roto_cmds;
mod settings;
mod shape_stroke;
@@ -48,7 +49,7 @@ mod styles;
mod text_anim;
pub mod text_edit;
mod three_d;
mod time;
pub mod time;
mod track;
mod view;
pub mod viewer_cmds;
@@ -123,6 +124,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(styles::specs());
v.extend(three_d::specs());
v.extend(model3d::specs());
v.extend(rig3d::specs());
v.extend(text_anim::specs());
v.extend(text_edit::specs());
v.extend(layer_time::specs());
+614
View File
@@ -0,0 +1,614 @@
//! Camera and light rigs: Layer ▸ Camera ▸ Create Stereo 3D Rig / Create Orbit Null / Create
//! Cameras from 3D Model, Layer ▸ Light ▸ Create Lights from 3D Model / Control Light with
//! Camera / Create Environment Light Background Layer.
use effectcraft_color::Label;
use effectcraft_effects::controls::{STEREO_CONFIGURATIONS, STEREO_CONTROLS, STEREO_CONVERGENCE};
use effectcraft_geom::{Mat4, Vec3, vec3};
use effectcraft_keyframe::Value as KV;
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{AutoOrient, Comp, Expression, ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, Project};
use effectcraft_render::EvalCtx;
use effectcraft_render::three_d::adv::scene::model_to_layer;
use effectcraft_render::three_d::camera::{self, layer_frame};
use serde_json::{Value, json};
use super::layer::insert_layer;
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, resolve_layer};
use crate::{EngineError, Result, Session, cmd};
fn arr(v: Vec3) -> [f64; 3] {
[v.x, v.y, v.z]
}
/// Set a property's static value (clears its keyframes and expression).
fn set(l: &mut Layer, path: &str, v: KV) {
if let Some(pr) = l.props.prop_mut(path) {
pr.value = v;
pr.keys.clear();
pr.expr = None;
}
}
fn set_expr(l: &mut Layer, path: &str, text: String) {
if let Some(pr) = l.props.prop_mut(path) {
pr.expr = Some(Expression { text, enabled: true });
}
}
/// What evaluation needs from the session (cloned so edits can evaluate their own project).
struct Env {
time: effectcraft_time::Tick,
expr: Option<std::sync::Arc<dyn effectcraft_render::ExprHost>>,
footage: std::sync::Arc<dyn effectcraft_render::FootageSource>,
}
impl Env {
fn of(s: &Session, cid: ItemId) -> Env {
Env { time: s.time_of(cid), expr: s.expr.clone(), footage: s.footage.clone() }
}
fn ctx<'a>(&'a self, proj: &'a Project, cid: ItemId, comp: &'a Comp) -> EvalCtx<'a> {
EvalCtx { project: proj, comp_id: cid, comp, time: self.time, expr: self.expr.as_deref(), footage: Some(self.footage.as_ref()) }
}
}
/// The camera a rig command acts on: `layer`, else a selected camera, else the active camera.
fn camera_p(s: &Session, p: &Value, cid: ItemId, cmd: &str) -> Result<LayerId> {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let id = match p.get("layer").or_else(|| p.get("camera")) {
Some(v) => resolve_layer(comp, v).ok_or_else(|| bad(cmd, "no such layer"))?,
None => s
.state
.selected_layers
.iter()
.copied()
.find(|id| comp.layer(*id).is_some_and(Layer::is_camera))
.or_else(|| comp.active_camera(s.time_of(cid)).map(|l| l.id))
.ok_or_else(|| bad(cmd, "the composition has no camera (Layer ▸ New ▸ Camera)"))?,
};
if !comp.layer(id).is_some_and(Layer::is_camera) {
return Err(bad(cmd, "the layer is not a camera"));
}
Ok(id)
}
/// Distance from a camera to what it looks at: its point of interest (two-node), else its zoom.
fn focus_distance(ctx: &EvalCtx, cam: &Layer) -> f64 {
let st = camera::layer_camera(ctx, cam);
if camera::is_two_node(cam)
&& let Some(tr) = cam.transform()
{
let poi = Vec3::from(ctx.v3(cam, tr, "poi", [0.0; 3]));
let poi = camera::parent_world(ctx, cam).apply(poi);
return (poi - st.eye).length().max(1.0);
}
st.zoom
}
// ---------------------------------------------------------------- stereo 3D rig
/// Stereo rig settings (Stereo 3D Controls).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct StereoParams {
/// 0 Stereo Pair (Left & Right), 1 Center & Right, 2 Center & Left.
pub configuration: u32,
/// Interaxial separation, % of the comp width.
pub scene_depth: f64,
pub convergence: bool,
/// 0 the camera's point of interest, 1 its zoom plane.
pub convergence_of: u32,
pub z_offset: f64,
}
/// Eye offsets along the camera's x axis, in units of the interaxial separation.
pub fn eye_offsets(configuration: u32) -> [f64; 2] {
match configuration {
1 => [0.0, 1.0],
2 => [-1.0, 0.0],
_ => [-0.5, 0.5],
}
}
/// (position, point of interest) of the left (`eye` 0) or right (1) camera, from the master
/// camera's eye, axes, focus distance and zoom.
pub fn eye_camera(sp: &StereoParams, eye: usize, comp_w: f64, master: (Vec3, Vec3, Vec3), focus: f64, zoom: f64) -> (Vec3, Vec3) {
let (e, fwd, right) = master;
let d = sp.scene_depth / 100.0 * comp_w;
let pos = e + right * (eye_offsets(sp.configuration)[eye.min(1)] * d);
let poi = if sp.convergence {
let dc = if sp.convergence_of == 1 { zoom } else { focus } + sp.z_offset;
e + fwd * dc.max(1.0)
} else {
pos + fwd * focus
};
(pos, poi)
}
/// Expression snippets that rebuild [`eye_camera`] from the master camera and the controls.
fn eye_exprs(src_comp: &str, master: &str, controls: &str, two_node: bool, eye: usize) -> (String, String, String) {
let q = |s: &str| serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into());
let k = eye_offsets(0)[eye.min(1)];
let k1 = eye_offsets(1)[eye.min(1)];
let k2 = eye_offsets(2)[eye.min(1)];
let head = format!(
"var src = comp({c});\nvar m = src.layer({m});\nvar fx = src.layer({x}).effect(\"Stereo 3D Controls\");\n\
var d = fx(\"Stereo Scene Depth\").value / 100 * src.width;\nvar cfg = fx(\"Configuration\").value;\n\
var k = cfg == 2 ? {k1} : (cfg == 3 ? {k2} : {k});\n\
var E = m.toWorld([0, 0, 0]);\nvar F = normalize(m.toWorldVec([0, 0, 1]));\n\
var eye = add(E, mul(normalize(m.toWorldVec([1, 0, 0])), k * d));\n",
c = q(src_comp),
m = q(master),
x = q(controls),
);
let dm = if two_node { "Math.max(length(sub(m.transform.pointOfInterest, m.transform.position)), 1)" } else { "m.cameraOption.zoom" };
let pos = format!("{head}eye;");
let poi = format!(
"{head}var dm = {dm};\nvar dc = (fx(\"Convergence Of\").value == 2 ? m.cameraOption.zoom : dm) + fx(\"Convergence Z Offset\").value;\n\
fx(\"Enable Convergence\").value ? add(E, mul(F, Math.max(dc, 1))) : add(eye, mul(F, dm));"
);
let zoom = format!("comp({}).layer({}).cameraOption.zoom;", q(src_comp), q(master));
(pos, poi, zoom)
}
fn stereo_p(p: &Value, cmd: &str) -> Result<StereoParams> {
let pick = |k: &str, opts: &[&str], ids: &[&str]| -> Result<Option<u32>> {
match p.get(k) {
None => Ok(None),
Some(Value::Number(n)) => Ok(n.as_u64().map(|v| (v as u32).min(opts.len() as u32 - 1))),
Some(Value::String(s)) => {
let n = s.to_ascii_lowercase().replace([' ', '-', '_', '&', '(', ')'], "");
let i = ids
.iter()
.position(|v| v.to_ascii_lowercase() == n)
.or_else(|| opts.iter().position(|o| o.to_ascii_lowercase().replace([' ', '-', '_', '&', '(', ')'], "") == n));
i.map(|i| Some(i as u32)).ok_or_else(|| bad(cmd, format!("{k}: {}", ids.join("|"))))
}
_ => Err(bad(cmd, format!("{k}: {}", ids.join("|")))),
}
};
Ok(StereoParams {
configuration: pick("configuration", &STEREO_CONFIGURATIONS, &["stereoPair", "centerRight", "centerLeft"])?.unwrap_or(0),
scene_depth: f_p(p, "sceneDepth").unwrap_or(3.0).clamp(0.0, 100.0),
convergence: b_p(p, "convergence").unwrap_or(false),
convergence_of: pick("convergenceOf", &STEREO_CONVERGENCE, &["poi", "zoom"])?.unwrap_or(0),
z_offset: f_p(p, "zOffset").unwrap_or(0.0),
})
}
fn unique_item_name(proj: &Project, base: &str) -> String {
let mut name = base.to_string();
let mut k = 2;
while proj.items.values().any(|i| i.name == name) {
name = format!("{base} {k}");
k += 1;
}
name
}
/// An empty comp with the settings of `src`.
fn sibling_comp(src: &Comp) -> Comp {
let mut c = src.clone();
c.layers.clear();
c.markers.clear();
c
}
fn stereo_rig(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "camera.stereoRig";
let cid = comp_id(s, p)?;
let sp = stereo_p(p, cmd)?;
let view3d = p.get("view3d").and_then(Value::as_u64).unwrap_or(5) as u32;
let src = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let src_name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
let t = s.time_of(cid);
let w = src.width as f64;
let existing = src.active_camera(t).map(|l| l.id);
let env = Env::of(s, cid);
let r = s.edit("Create Stereo 3D Rig", None, |proj, st| {
// The master camera: the comp's active camera, else a new one.
let master_id = match existing {
Some(id) => id,
None => {
let name = src.unique_layer_name("Master Camera");
let l = build::layer(proj, &src, &name, LayerSource::Camera, (src.width, src.height), None);
insert_layer(proj, st, cid, l)?
}
};
let controls_name = proj.comp(cid).map(|c| c.unique_layer_name("Stereo 3D Controls")).unwrap_or_default();
let src_now = proj.comp(cid).cloned().ok_or(EngineError::NoComp)?;
let mut ctl = build::layer(proj, &src_now, &controls_name, LayerSource::Null, (src.width, src.height), None);
ctl.switches.video = false;
let spec = effectcraft_effects::find(STEREO_CONTROLS).ok_or_else(|| bad(cmd, "Stereo 3D Controls effect missing"))?;
let mut fx = effectcraft_effects::instantiate(spec, &mut Ids(&mut proj.next_id), spec.name, [100.0, 100.0]);
for (k, v) in [
("configuration", KV::Enum(sp.configuration)),
("sceneDepth", KV::Scalar(sp.scene_depth)),
("convergence", KV::Bool(sp.convergence)),
("convergenceOf", KV::Enum(sp.convergence_of)),
("zOffset", KV::Scalar(sp.z_offset)),
] {
if let Some(pr) = fx.get_mut(k) {
pr.value = v;
}
}
if let Some(g) = ctl.props.sub_mut("effects") {
g.children.push(fx.into());
}
let ctl_id = ctl.id;
let src_now = proj.comp(cid).cloned().ok_or(EngineError::NoComp)?;
let at = src_now.layers.iter().position(|l| l.id == master_id).unwrap_or(0);
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.insert(at, ctl);
// Master camera frame at the current time.
let src_now = proj.comp(cid).cloned().ok_or(EngineError::NoComp)?;
let master = src_now.layer(master_id).cloned().ok_or(EngineError::NoComp)?;
let ctx = env.ctx(proj, cid, &src_now);
let (e, fwd, down) = layer_frame(&ctx, &master);
let right = down.cross(fwd).normalize();
let focus = focus_distance(&ctx, &master);
let zoom = camera::layer_camera(&ctx, &master).zoom;
let two = camera::is_two_node(&master);
// Left and right eye comps: the source comp collapsed, seen by an eye camera.
let mut eye_comps = vec![];
for (eye, label) in [(0usize, "Left"), (1, "Right")] {
let name = unique_item_name(proj, &format!("{src_name} {label} Eye"));
let ec = sibling_comp(&src_now);
let eid = proj.add_item(&name, Label::Sandstone, proj.item(cid).and_then(|i| i.parent), ItemKind::Comp(ec.clone().into()));
let mut nest = build::layer(proj, &ec, &src_name, LayerSource::Comp { item: cid }, (src.width, src.height), Some(src.duration));
nest.switches.three_d = true;
nest.switches.collapse = true;
let (pos, poi) = eye_camera(&sp, eye, w, (e, fwd, right), focus, zoom);
let mut cam = build::layer(proj, &ec, &format!("{label} Eye Camera"), LayerSource::Camera, (src.width, src.height), None);
set(&mut cam, "transform/position", KV::Vec3(arr(pos)));
set(&mut cam, "transform/poi", KV::Vec3(arr(poi)));
set(&mut cam, "cameraOptions/zoom", KV::Scalar(zoom));
set(&mut cam, "cameraOptions/focusDistance", KV::Scalar(focus));
let (pe, poe, ze) = eye_exprs(&src_name, &master.name, &controls_name, two, eye);
set_expr(&mut cam, "transform/position", pe);
set_expr(&mut cam, "transform/poi", poe);
set_expr(&mut cam, "cameraOptions/zoom", ze);
cam.auto_orient = AutoOrient::TowardsPointOfInterest;
let comp = proj.comp_mut(eid).ok_or(EngineError::NoComp)?;
comp.layers = vec![cam, nest];
eye_comps.push(eid);
}
// The output comp: both eyes through 3D Glasses.
let out_name = unique_item_name(proj, &format!("{src_name} Stereo 3D"));
let oc = sibling_comp(&src_now);
let oid = proj.add_item(&out_name, Label::Sandstone, proj.item(cid).and_then(|i| i.parent), ItemKind::Comp(oc.clone().into()));
let left_name = proj.item(eye_comps[0]).map(|i| i.name.clone()).unwrap_or_default();
let right_name = proj.item(eye_comps[1]).map(|i| i.name.clone()).unwrap_or_default();
let mut left = build::layer(proj, &oc, &left_name, LayerSource::Comp { item: eye_comps[0] }, (src.width, src.height), Some(src.duration));
let mut right = build::layer(proj, &oc, &right_name, LayerSource::Comp { item: eye_comps[1] }, (src.width, src.height), Some(src.duration));
right.switches.video = false;
let glasses = effectcraft_effects::find("ec.perspective.3dglasses").ok_or_else(|| bad(cmd, "3D Glasses effect missing"))?;
let mut g = effectcraft_effects::instantiate(glasses, &mut Ids(&mut proj.next_id), glasses.name, [w, src.height as f64]);
for (k, v) in [("leftView", KV::Layer(Some(left.id.0))), ("rightView", KV::Layer(Some(right.id.0))), ("view3d", KV::Enum(view3d.min(8)))] {
if let Some(pr) = g.get_mut(k) {
pr.value = v;
}
}
if let Some(fxg) = left.props.sub_mut("effects") {
fxg.children.push(g.into());
}
let (lid, rid) = (left.id, right.id);
proj.comp_mut(oid).ok_or(EngineError::NoComp)?.layers = vec![left, right];
st.project_selection = vec![oid];
Ok(json!({
"controls": ctl_id.0,
"master": master_id.0,
"leftComp": eye_comps[0].0,
"rightComp": eye_comps[1].0,
"stereoComp": oid.0,
"leftLayer": lid.0,
"rightLayer": rid.0,
}))
})?;
Ok(r)
}
// ---------------------------------------------------------------- orbit null
/// Create Orbit Null: a 3D null at the camera's point of interest becomes the camera's parent,
/// so rotating the null orbits the camera around what it looks at.
fn orbit_null(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "camera.orbitNull";
let cid = comp_id(s, p)?;
let cam_id = camera_p(s, p, cid, cmd)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let cam = comp.layer(cam_id).cloned().ok_or(EngineError::NoComp)?;
if cam.parent.is_some() {
return Err(bad(cmd, format!("`{}` already has a parent", cam.name)));
}
let env = Env::of(s, cid);
let ctx = env.ctx(&s.project, cid, &comp);
let (eye, fwd, _) = layer_frame(&ctx, &cam);
let target = eye + fwd * focus_distance(&ctx, &cam);
let name = comp.unique_layer_name(&format!("{} Orbit Null", cam.name));
let r = s.edit("Create Orbit Null", None, |proj, st| {
let mut null = build::layer(proj, &comp, &name, LayerSource::Null, (comp.width, comp.height), None);
null.switches.three_d = true;
null.switches.video = true;
set(&mut null, "transform/position", KV::Vec3(arr(target)));
// World → null space (a translated, unrotated, unscaled null: p − P + anchor).
let anchor = Vec3::from(null.props.prop("transform/anchor").map(|a| a.value.as_vec3()).unwrap_or([0.0; 3]));
let to_null = |v: [f64; 3]| arr(Vec3::from(v) - target + anchor);
let nid = null.id;
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let at = c.layers.iter().position(|l| l.id == cam_id).unwrap_or(0);
c.layers.insert(at, null);
let l = c.layer_mut(cam_id).ok_or(EngineError::NoComp)?;
l.parent = Some(nid);
for path in ["transform/position", "transform/poi"] {
if let Some(pr) = l.props.prop_mut(path) {
pr.value = KV::Vec3(to_null(pr.value.as_vec3()));
for k in &mut pr.keys {
k.value = KV::Vec3(to_null(k.value.as_vec3()));
}
}
}
st.selected_layers = vec![nid];
Ok(json!({"null": nid.0, "camera": cam_id.0, "position": arr(target)}))
})?;
Ok(r)
}
// ---------------------------------------------------------------- control light with camera
/// Control Light with Camera: the light rides on the camera (parented at the camera's eye,
/// shining where it looks). Again (or `on: false`) releases it where it is.
fn control_light(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "light.controlWithCamera";
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let lights: Vec<LayerId> = match p.get("layers").or_else(|| p.get("layer")) {
Some(Value::Array(a)) => a.iter().filter_map(|v| resolve_layer(&comp, v)).collect(),
Some(v) => resolve_layer(&comp, v).into_iter().collect(),
None => s.state.selected_layers.iter().copied().filter(|id| comp.layer(*id).is_some_and(Layer::is_light)).collect(),
};
if lights.is_empty() || lights.iter().any(|id| !comp.layer(*id).is_some_and(Layer::is_light)) {
return Err(bad(cmd, "select a light layer"));
}
let cam_id = match p.get("camera") {
Some(v) => resolve_layer(&comp, v).filter(|id| comp.layer(*id).is_some_and(Layer::is_camera)).ok_or_else(|| bad(cmd, "no such camera"))?,
None => comp.active_camera(s.time_of(cid)).map(|l| l.id).ok_or_else(|| bad(cmd, "the composition has no camera (Layer ▸ New ▸ Camera)"))?,
};
let on = b_p(p, "on").unwrap_or_else(|| comp.layer(lights[0]).is_none_or(|l| l.parent != Some(cam_id)));
let env = Env::of(s, cid);
let ctx = env.ctx(&s.project, cid, &comp);
// Released lights keep their current world placement.
let frames: Vec<(LayerId, Vec3, Vec3)> = lights
.iter()
.filter_map(|id| comp.layer(*id))
.map(|l| {
let (e, f, _) = layer_frame(&ctx, l);
(l.id, e, f)
})
.collect();
let (w, h) = (comp.width as f64, comp.height as f64);
s.edit(if on { "Control Light with Camera" } else { "Release Light from Camera" }, None, |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for (id, e, f) in &frames {
let l = c.layer_mut(*id).ok_or(EngineError::NoComp)?;
if on {
l.parent = Some(cam_id);
set(l, "transform/position", KV::Vec3([0.0; 3]));
set(l, "transform/poi", KV::Vec3([0.0, 0.0, 1000.0]));
set(l, "transform/orientation", KV::Vec3([0.0; 3]));
for k in ["transform/rotationX", "transform/rotationY", "transform/rotation"] {
set(l, k, KV::Scalar(0.0));
}
l.auto_orient = AutoOrient::Off;
} else if l.parent == Some(cam_id) {
l.parent = None;
set(l, "transform/position", KV::Vec3(arr(*e)));
set(l, "transform/poi", KV::Vec3(arr(*e + *f * w.max(h))));
if l.transform().is_some_and(|t| t.get("poi").is_some()) {
l.auto_orient = AutoOrient::TowardsPointOfInterest;
}
}
}
Ok(())
})?;
Ok(json!({"controlled": on, "camera": cam_id.0, "lights": lights.iter().map(|l| l.0).collect::<Vec<_>>()}))
}
// ---------------------------------------------------------------- cameras and lights from models
/// Model layers a "from 3D Model" command reads: `layers`/`layer`, else the selected model layers.
fn model_layers(s: &Session, p: &Value, comp: &Comp, cmd: &str) -> Result<Vec<LayerId>> {
let ids: Vec<LayerId> = match p.get("layers").or_else(|| p.get("layer")) {
Some(Value::Array(a)) => a.iter().filter_map(|v| resolve_layer(comp, v)).collect(),
Some(v) => resolve_layer(comp, v).into_iter().collect(),
None => s.state.selected_layers.iter().copied().filter(|id| comp.layer(*id).is_some_and(|l| matches!(l.source, LayerSource::Model { .. }))).collect(),
};
if ids.is_empty() || ids.iter().any(|id| !comp.layer(*id).is_some_and(|l| matches!(l.source, LayerSource::Model { .. }))) {
return Err(bad(cmd, "select a 3D model layer"));
}
Ok(ids)
}
/// A camera or light found in a model: (name, world eye, world forward, the model data).
enum FromModel {
Camera { name: String, eye: Vec3, fwd: Vec3, zoom: f64 },
Light { name: String, eye: Vec3, fwd: Vec3, light: effectcraft_model::ModelLight },
}
fn from_model(s: &mut Session, p: &Value, lights: bool) -> Result<Value> {
let cmd = if lights { "light.fromModel" } else { "camera.fromModel" };
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let ids = model_layers(s, p, &comp, cmd)?;
let env = Env::of(s, cid);
let ctx = env.ctx(&s.project, cid, &comp);
let mut found = vec![];
for id in &ids {
let l = comp.layer(*id).ok_or(EngineError::NoComp)?;
let LayerSource::Model { item } = l.source else { continue };
let Some(ItemKind::Footage(f)) = s.project.item(item).map(|i| &i.kind) else { continue };
let Some(m) = s.footage.model(item, f) else { return Err(bad(cmd, format!("can't read the model of `{}`", l.name))) };
let unit = l.props.sub("geometryOptions").map_or(1.0, |g| ctx.f(l, g, "unitScale", 1.0));
let to_world = ctx.world_matrix(l) * model_to_layer(unit);
for pl in m.placed(lights, None, 0.0) {
let w: Mat4 = to_world * pl.world;
let eye = w.apply(Vec3::ZERO);
let fwd = w.apply_vec(vec3(0.0, 0.0, -1.0)).normalize();
if lights {
let ml = m.lights[pl.index].clone();
found.push(FromModel::Light { name: ml.name.clone(), eye, fwd, light: ml });
} else {
let mc = &m.cameras[pl.index];
let zoom = match mc.projection {
effectcraft_model::CameraProjection::Perspective { yfov, .. } => comp.height as f64 * 0.5 / (yfov * 0.5).tan(),
// Orthographic cameras become perspective ones framing the same height.
effectcraft_model::CameraProjection::Orthographic { .. } => effectcraft_geom::default_camera_zoom(comp.width as f64),
};
found.push(FromModel::Camera { name: mc.name.clone(), eye, fwd, zoom });
}
}
}
if found.is_empty() {
return Err(bad(cmd, if lights { "the model has no lights (KHR_lights_punctual)" } else { "the model has no cameras" }));
}
let made = s.edit(if lights { "Create Lights from 3D Model" } else { "Create Cameras from 3D Model" }, None, |proj, st| {
let mut made = vec![];
for f in &found {
let c = proj.comp(cid).cloned().ok_or(EngineError::NoComp)?;
let l = match f {
FromModel::Camera { name, eye, fwd, zoom } => {
let mut l = build::layer(proj, &c, &c.unique_layer_name(name), LayerSource::Camera, (c.width, c.height), None);
set(&mut l, "transform/position", KV::Vec3(arr(*eye)));
set(&mut l, "transform/poi", KV::Vec3(arr(*eye + *fwd * *zoom)));
set(&mut l, "cameraOptions/zoom", KV::Scalar(*zoom));
set(&mut l, "cameraOptions/focusDistance", KV::Scalar(*zoom));
l
}
FromModel::Light { name, eye, fwd, light } => {
let kind = match light.kind {
effectcraft_model::ModelLightKind::Directional => LightKind::Parallel,
effectcraft_model::ModelLightKind::Point => LightKind::Point,
effectcraft_model::ModelLightKind::Spot { .. } => LightKind::Spot,
};
let mut l = build::layer(proj, &c, &c.unique_layer_name(name), LayerSource::Light { kind }, (c.width, c.height), None);
set(&mut l, "transform/position", KV::Vec3(arr(*eye)));
set(&mut l, "transform/poi", KV::Vec3(arr(*eye + *fwd * c.width.max(c.height) as f64)));
let col = light.color;
set(&mut l, "lightOptions/color", KV::Color([col[0], col[1], col[2], 1.0]));
// Intensity: 1 unit of the file (lux / candela) = 100%.
set(&mut l, "lightOptions/intensity", KV::Scalar((light.intensity * 100.0).clamp(0.0, 1000.0)));
if let effectcraft_model::ModelLightKind::Spot { inner, outer } = light.kind {
set(&mut l, "lightOptions/coneAngle", KV::Scalar((outer * 2.0).to_degrees().clamp(0.0, 180.0)));
set(&mut l, "lightOptions/coneFeather", KV::Scalar(((1.0 - inner / outer.max(1e-9)) * 100.0).clamp(0.0, 100.0)));
}
if let Some(r) = light.range {
set(&mut l, "lightOptions/falloff", KV::Enum(1));
set(&mut l, "lightOptions/falloffDistance", KV::Scalar(r * 100.0));
}
l
}
};
made.push(insert_layer(proj, st, cid, l)?.0);
}
Ok(made)
})?;
Ok(json!({"layers": made}))
}
// ---------------------------------------------------------------- environment background
/// Create Environment Light Background Layer: a layer showing the environment light's image as
/// the scene's backdrop (an equirectangular sky drawn through the camera).
fn environment_background(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "light.environmentBackground";
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let t = s.time_of(cid);
let is_env = |l: &Layer| matches!(l.source, LayerSource::Light { kind: LightKind::Environment });
let light = match p.get("layer") {
Some(v) => resolve_layer(&comp, v).and_then(|id| comp.layer(id)).filter(|l| is_env(l)).ok_or_else(|| bad(cmd, "not an environment light"))?,
None => s
.state
.selected_layers
.iter()
.filter_map(|id| comp.layer(*id))
.find(|l| is_env(l))
.or_else(|| comp.layers.iter().find(|l| is_env(l)))
.ok_or_else(|| bad(cmd, "the composition has no environment light (Layer ▸ New ▸ Light ▸ Environment)"))?,
};
let src_layer = light
.props
.prop("lightOptions/source")
.and_then(|pr| pr.value_at(light.layer_time(t)).as_layer())
.and_then(|id| comp.layer(LayerId(id)))
.or_else(|| comp.layers.iter().find(|l| l.environment))
.ok_or_else(|| bad(cmd, "the environment light has no Source (or set an Environment Layer)"))?
.clone();
if !matches!(src_layer.source, LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. }) {
return Err(bad(cmd, "the environment source must be a footage, comp or solid layer"));
}
let light_id = light.id;
let name = comp.unique_layer_name(&format!("{} Background", src_layer.name));
let size = effectcraft_render::source_size(&s.project, &src_layer);
let id = s.edit("Create Environment Light Background Layer", None, |proj, st| {
let mut l = build::layer(proj, &comp, &name, src_layer.source.clone(), size, None);
l.switches.three_d = true;
l.environment_background = true;
// The backdrop sits at the bottom of the stack.
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let id = l.id;
c.layers.push(l);
st.selected_layers = vec![id];
Ok(id)
})?;
Ok(json!({"layer": id.0, "light": light_id.0, "source": src_layer.id.0}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"camera.stereoRig",
"Create Stereo 3D Rig",
["Layer", "Camera"],
None,
"{comp?, configuration?: stereoPair|centerRight|centerLeft, sceneDepth? (% of comp width, default 3), convergence?: bool, convergenceOf?: poi|zoom, zOffset?, view3d?: 3D Glasses view index (default 5 Balanced Colored Red Blue)}",
has_comp,
stereo_rig
),
cmd!("camera.orbitNull", "Create Orbit Null", ["Layer", "Camera"], None, "{comp?, layer?: camera (default selected/active)}", has_comp, orbit_null),
cmd!(
"camera.fromModel",
"Create Cameras from 3D Model",
["Layer", "Camera"],
None,
"{comp?, layers?: 3D model layers (default selected)}",
has_comp,
|s, p| { from_model(s, p, false) }
),
cmd!(
"light.fromModel",
"Create Lights from 3D Model",
["Layer", "Light"],
None,
"{comp?, layers?: 3D model layers (default selected)}",
has_comp,
|s, p| { from_model(s, p, true) }
),
cmd!(
"light.controlWithCamera",
"Control Light with Camera",
["Layer", "Light"],
None,
"{comp?, layers?: lights (default selected), camera? (default active), on?: bool (default toggle)}",
has_comp,
control_light
),
cmd!(
"light.environmentBackground",
"Create Environment Light Background Layer",
["Layer", "Light"],
None,
"{comp?, layer?: environment light (default selected / first)}",
has_comp,
environment_background
),
]
}
-11
View File
@@ -25,17 +25,6 @@ pub fn specs() -> Vec<CommandSpec> {
stub!("layer.autoTrace", "Auto-trace...", ["Layer"], "{}"),
stub!("layer.sceneEditDetection", "Scene Edit Detection...", ["Layer"], "{}"),
// Cameras / lights / materials / 3D views (3D milestone).
stub!("camera.stereoRig", "Create Stereo 3D Rig", ["Layer", "Camera"], "{}"),
stub!("camera.orbitNull", "Create Orbit Null", ["Layer", "Camera"], "{}"),
stub!("camera.fromModel", "Create Cameras from 3D Model", ["Layer", "Camera"], "{}"),
stub!("light.fromModel", "Create Lights from 3D Model", ["Layer", "Light"], "{}"),
stub!("light.controlWithCamera", "Control Light with Camera", ["Layer", "Light"], "{}"),
stub!("light.environmentBackground", "Create Environment Light Background Layer", ["Layer", "Light"], "{}"),
stub!("view.3d.default", "Default", ["View", "Switch 3D View"], "{}"),
stub!("view.splitLockedViewer", "Split with New Locked Viewer", ["View"], "{}"),
// Color management.
stub!("view.displayColorManagement", "Use Display Color Management", ["View"], "{}"),
stub!("view.simulateOutput", "Simulate Output", [], "{profile}"),
// Keyframes / text / tracking.
// Workspaces and panels that don't exist yet.
stub!("window.unavailablePanel", "Panel", [], "{panel}"),
+4 -2
View File
@@ -297,7 +297,7 @@ fn views(s: &mut Session, cid: ItemId) -> &mut Views3D {
}
fn view_p(p: &Value, cmd: &str) -> Result<View3D> {
let v = str_p(p, "view").ok_or_else(|| bad(cmd, "missing `view` (activeCamera|front|left|top|back|right|bottom|custom1|custom2|custom3)"))?;
let v = str_p(p, "view").ok_or_else(|| bad(cmd, "missing `view` (activeCamera|default|front|left|top|back|right|bottom|custom1|custom2|custom3)"))?;
View3D::from_id(v).ok_or_else(|| bad(cmd, format!("unknown view `{v}`")))
}
@@ -334,6 +334,7 @@ view_cmd!(view_bottom, View3D::Bottom);
view_cmd!(view_custom1, View3D::Custom1);
view_cmd!(view_custom2, View3D::Custom2);
view_cmd!(view_custom3, View3D::Custom3);
view_cmd!(view_default, View3D::Default);
fn last_view(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
@@ -697,11 +698,12 @@ pub fn specs() -> Vec<CommandSpec> {
"Switch 3D View",
[],
None,
"{view: activeCamera|front|left|top|back|right|bottom|custom1|custom2|custom3, comp?}",
"{view: activeCamera|default|front|left|top|back|right|bottom|custom1|custom2|custom3, comp?}",
has_comp,
set_3d_view
),
cmd!("view.3d.activeCamera", "Active Camera", ["View", "Switch 3D View"], Some("F12"), "{comp?}", has_comp, view_active),
cmd!("view.3d.default", "Default", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_default),
cmd!("view.3d.front", "Front", ["View", "Switch 3D View"], Some("F10"), "{comp?}", has_comp, view_front),
cmd!("view.3d.left", "Left", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_left),
cmd!("view.3d.top", "Top", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_top),
+32 -2
View File
@@ -1,5 +1,6 @@
//! Time navigation (CTI) commands.
use effectcraft_project::{Comp, TimeDisplayStyle};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@@ -9,7 +10,36 @@ use crate::{EngineError, Result, Session, cmd};
fn report(s: &Session) -> Value {
let t = s.time();
let frame = s.active_comp().map(|c| c.frame_rate.frame_at(t)).unwrap_or(0);
json!({"time": t.seconds(), "frame": frame})
let display = s.active_comp().map(|c| display_time(s, c, t));
json!({"time": t.seconds(), "frame": frame, "display": display})
}
/// A comp time as the project's Time Display Style shows it: timecode (`0:00:02:15`, `;` for
/// drop-frame), frames (`00063`, from Start Numbering Frames At) or Feet + Frames (`0003+15`).
pub fn display_time(s: &Session, comp: &Comp, t: Tick) -> String {
let fr = comp.frame_rate;
let st = &s.project.settings;
match st.time_display {
TimeDisplayStyle::Frames => format!("{:05}", fr.frame_at(t) + st.frame_start),
TimeDisplayStyle::Feet35 | TimeDisplayStyle::Feet16 => {
effectcraft_time::format_feet_frames(fr.frame_at(t + comp.display_start) + st.frame_start, st.time_display.frames_per_foot().unwrap_or(16))
}
TimeDisplayStyle::Timecode => effectcraft_time::format_timecode_ae(fr.frame_at(t + comp.display_start), fr, false),
}
}
/// Parse a time typed in the project's Time Display Style (relative `+n`/`-n` from frame
/// `current`) into a comp frame.
pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) -> std::result::Result<i64, String> {
let fr = comp.frame_rate;
let st = &s.project.settings;
match st.time_display.frames_per_foot() {
Some(pf) => {
let start = fr.frame_at(comp.display_start) + st.frame_start;
effectcraft_time::parse_feet_frames(text, pf, current + start).map(|f| f - start).map_err(|e| e.0)
}
None => effectcraft_time::parse_timecode(text, fr, fr.supports_drop_frame(), current).map_err(|e| e.0),
}
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
@@ -18,7 +48,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
comp.frame_rate.tick_of(f)
} else if let Some(tc) = str_p(p, "timecode") {
let cur = comp.frame_rate.frame_at(s.time());
let f = effectcraft_time::parse_timecode(tc, comp.frame_rate, comp.frame_rate.supports_drop_frame(), cur).map_err(|e| super::bad("time.set", e.0))?;
let f = parse_display_time(s, comp, tc, cur).map_err(|e| super::bad("time.set", e))?;
comp.frame_rate.tick_of(f)
} else {
Tick::from_seconds_f64(f_p(p, "time").ok_or_else(|| super::bad("time.set", "need `time`, `frame` or `timecode`"))?)
+122 -4
View File
@@ -10,8 +10,8 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, merge_p, str_p};
use crate::viewer::{Channel, Snapshot};
use crate::{EngineError, Result, Session, VertexRef, cmd};
use crate::viewer::{Channel, SimProfile, Simulation, Snapshot};
use crate::{EngineError, Result, Session, VertexRef, cmd, query};
/// Composition ▸ Preview ▸ Fast Previews modes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -61,8 +61,8 @@ impl FastPreviews {
}
}
/// Viewer display options (Show Channel, exposure, snapshot, Fast Previews).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
/// Viewer display options (Show Channel, exposure, snapshot, Fast Previews, display colour).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ViewOptions {
pub channel: Channel,
@@ -73,6 +73,36 @@ pub struct ViewOptions {
/// Show Snapshot (F5 held): the viewer shows the snapshot instead of the frame.
pub show_snapshot: bool,
pub fast_previews: FastPreviews,
/// View ▸ Use Display Color Management (with a working space): the frame is converted
/// from the working space to the display; off shows the working space's numbers.
pub display_color_management: bool,
/// View ▸ Simulate Output.
pub simulation: Simulation,
/// The profile View ▸ Simulate Output ▸ Custom... uses.
pub custom_simulation: Simulation,
}
impl Default for ViewOptions {
fn default() -> Self {
ViewOptions {
channel: Channel::default(),
colorized: false,
exposure: 0.0,
show_snapshot: false,
fast_previews: FastPreviews::default(),
display_color_management: true,
simulation: Simulation::default(),
custom_simulation: Simulation { profile: SimProfile::Linear, preserve_rgb: true },
}
}
}
/// View ▸ Split with New Locked Viewer: a second Composition viewer that keeps showing one
/// comp and 3D view whatever comp is active.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct LockedViewer {
pub comp: effectcraft_project::ItemId,
pub view: effectcraft_render::three_d::View3D,
}
fn snapping(s: &mut Session, p: &Value) -> Result<Value> {
@@ -281,9 +311,97 @@ fn shape_layer_or_none(s: &Session) -> std::result::Result<(), String> {
has_comp(s)
}
// ---------------------------------------------------------------- display colour, locked viewer
/// View ▸ Use Display Color Management (toggle, or `value`).
fn display_cm(s: &mut Session, p: &Value) -> Result<Value> {
let v = b_p(p, "value").unwrap_or(!s.state.viewer.display_color_management);
s.state.viewer.display_color_management = v;
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
Ok(json!({"displayColorManagement": v, "workingSpace": s.project.settings.working_space.map(|w| w.id()), "display": s.prefs.previews.display_profile}))
}
/// View ▸ Simulate Output ▸ (profile). `custom` uses (and with `space` sets) the Custom profile.
fn simulate_output(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "view.simulateOutput";
let name = str_p(p, "profile").unwrap_or("none");
let ids = SimProfile::ALL.iter().map(|p| p.id()).collect::<Vec<_>>().join("|");
let sim = if name.eq_ignore_ascii_case("custom") {
let mut c = s.state.viewer.custom_simulation;
if let Some(sp) = str_p(p, "space") {
c.profile = SimProfile::parse(sp).ok_or_else(|| bad(cmd, format!("space: {ids}")))?;
}
if let Some(v) = b_p(p, "preserveRgb") {
c.preserve_rgb = v;
}
s.state.viewer.custom_simulation = c;
c
} else {
let profile = SimProfile::parse(name).ok_or_else(|| bad(cmd, format!("profile: {ids}|custom")))?;
Simulation { profile, preserve_rgb: b_p(p, "preserveRgb").unwrap_or(false) }
};
s.state.viewer.simulation = sim;
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
Ok(json!({"profile": sim.profile.id(), "label": sim.profile.label(), "preserveRgb": sim.preserve_rgb}))
}
/// View ▸ Split with New Locked Viewer: lock a second viewer to the comp (default the active
/// one) and its current 3D view (or `view`).
fn split_locked(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let view = match str_p(p, "view") {
Some(v) => effectcraft_render::three_d::View3D::from_id(v).ok_or_else(|| bad("view.splitLockedViewer", format!("unknown view `{v}`")))?,
None => s.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default(),
};
s.state.locked_viewer = Some(LockedViewer { comp: cid, view });
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
Ok(json!({"comp": cid.0, "view": view.id()}))
}
fn close_locked(s: &mut Session, _: &Value) -> Result<Value> {
let was = s.state.locked_viewer.take().is_some();
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
Ok(json!({"closed": was}))
}
fn has_locked(s: &Session) -> std::result::Result<(), String> {
if s.state.locked_viewer.is_some() { Ok(()) } else { Err("no locked viewer".into()) }
}
/// The viewer's colour settings and what they do.
fn display_color_state(s: &mut Session, _: &Value) -> Result<Value> {
let v = &s.state.viewer;
Ok(json!({
"displayColorManagement": v.display_color_management,
"simulation": {"profile": v.simulation.profile.id(), "preserveRgb": v.simulation.preserve_rgb},
"display": s.prefs.previews.display_profile,
"workingSpace": s.project.settings.working_space.map(|w| w.id()),
"active": crate::viewer::DisplayColor::of(s).is_some(),
"lockedViewer": s.state.locked_viewer.map(|l| json!({"comp": l.comp.0, "view": l.view.id()})),
}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("view.snapping", "Snapping", ["View"], None, "{value?}", always, snapping),
cmd!("view.displayColorManagement", "Use Display Color Management", ["View"], None, "{value?}", always, display_cm),
cmd!(
"view.simulateOutput",
"Simulate Output",
[],
None,
"{profile: none|rec709|ntsc|pal|mac18|srgb|rec2020|p3|linear|custom, preserveRgb?, space? (custom)}",
always,
simulate_output
),
cmd!("view.splitLockedViewer", "Split with New Locked Viewer", ["View"], None, "{comp?, view?: 3D view id}", has_comp, split_locked),
cmd!("view.closeLockedViewer", "Close Locked Viewer", [], None, "{}", has_locked, close_locked),
query!(
"view.displayColor",
"Viewer Color State",
"{} → display colour management, output simulation, display profile, locked viewer",
display_color_state
),
cmd!("view.channel", "Show Channel", [], None, "{channel: rgb|red|green|blue|alpha|rgbStraight, colorized?, toggle?}", always, channel),
cmd!("view.exposure", "Adjust Exposure", [], None, "{stops? | delta?}", always, exposure),
cmd!("view.resetExposure", "Reset Exposure", [], None, "{}", always, reset_exposure),
+7
View File
@@ -220,6 +220,9 @@ pub struct EditorState {
/// Essential Graphics can expose).
#[serde(default)]
pub essential_solo: bool,
/// View ▸ Split with New Locked Viewer: the second viewer's comp and 3D view.
#[serde(default)]
pub locked_viewer: Option<commands::viewer_cmds::LockedViewer>,
}
fn one_view() -> u8 {
@@ -742,6 +745,8 @@ mod tests_anim_tools;
#[cfg(test)]
mod tests_camera_track;
#[cfg(test)]
mod tests_color_view;
#[cfg(test)]
mod tests_disk_cache;
#[cfg(test)]
mod tests_effects;
@@ -766,6 +771,8 @@ mod tests_project_items;
#[cfg(test)]
mod tests_proxy;
#[cfg(test)]
mod tests_rig3d;
#[cfg(test)]
mod tests_settings;
#[cfg(test)]
mod tests_stubs;
+14
View File
@@ -298,6 +298,17 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
"view.layout" => Some(params.get("views").and_then(Value::as_u64) == Some(s.state.view_layout.max(1) as u64)),
"view.shareViewOptions" => Some(s.state.share_view_options),
"view.snapping" => Some(s.state.snapping),
"view.displayColorManagement" => Some(s.state.viewer.display_color_management),
"view.simulateOutput" => {
let want = pstr("profile")?;
let sim = s.state.viewer.simulation;
Some(if want == "custom" {
sim.profile != crate::viewer::SimProfile::None && sim == s.state.viewer.custom_simulation
} else {
crate::viewer::SimProfile::parse(want) == Some(sim.profile)
&& (sim != s.state.viewer.custom_simulation || sim.profile == crate::viewer::SimProfile::None)
})
}
"layer.mask.motionBlur" | "layer.mask.featherFalloff" | "path.rotoBezier" => {
use effectcraft_project::{FeatherFalloff, GroupKind, MaskMotionBlur};
let GroupKind::Mask { motion_blur, feather_falloff, roto_bezier, .. } = layer?.masks()?.groups().next()?.kind else { return None };
@@ -1007,6 +1018,9 @@ View
SDTV PAL | view.simulateOutput {"profile":"pal"}
Legacy Macintosh RGB (Gamma 1.8) | view.simulateOutput {"profile":"mac18"}
Internet Standard RGB (sRGB) | view.simulateOutput {"profile":"srgb"}
UHDTV (Rec. 2020) | view.simulateOutput {"profile":"rec2020"}
Display P3 | view.simulateOutput {"profile":"p3"}
Linear (1.0 Gamma) | view.simulateOutput {"profile":"linear"}
Custom... | view.simulateOutput {"profile":"custom"}
---
Show Rulers | view.rulers
+3
View File
@@ -105,6 +105,8 @@ page!(Previews {
/// `faster` or `moreAccurate`.
zoom_quality: String = "moreAccurate".into(),
show_gpu_info: bool = false,
/// The monitor's colour space for View ▸ Use Display Color Management: `srgb` or `p3`.
display_profile: String = "srgb".into(),
});
page!(Appearance {
@@ -915,6 +917,7 @@ pub fn pages() -> Vec<Page> {
s("previews.cacheFramesWhenIdle", "Cache Frames When Idle", B, true),
s("previews.fastPreviews", "Fast Previews (Draft 3D, Faster Effects)", B, true),
s("previews.zoomQuality", "Viewer Zoom Quality", Kind::Choice(&[("Faster", "faster"), ("More Accurate", "moreAccurate")]), true),
s("previews.displayProfile", "Display Color Space", Kind::Choice(&[("sRGB", "srgb"), ("Display P3", "p3")]), true),
Button { label: "GPU Information...", command: "app.gpuInfo", params: "{}" },
],
},
+223
View File
@@ -0,0 +1,223 @@
//! Viewer and colour completion (M7.7): View ▸ Switch 3D View ▸ Default, Split with New Locked
//! Viewer, Use Display Color Management, Simulate Output; Project Settings ▸ Color Engine,
//! ACES working spaces, HDR compand / tone mapping, Rec. 2100 output, Feet + Frames.
use effectcraft_project::{ColorEngine, ColorSpace, HdrMode, Project, TimeDisplayStyle};
use effectcraft_render::RenderOpts;
use effectcraft_render::three_d::View3D;
use effectcraft_time::Tick;
use serde_json::json;
use crate::Session;
use crate::viewer::{DisplayColor, SimProfile, Simulation};
fn session() -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "C", "width": 64, "height": 48, "frameRate": 24, "duration": 10})).unwrap();
s
}
#[test]
fn default_3d_view_ignores_camera_layers() {
let mut s = session();
let cid = s.active_comp_id().unwrap();
s.execute("layer.newCamera", json!({"position": [32, 24, -300], "poi": [32, 24, 0]})).unwrap();
assert_eq!(s.execute("view.3d.default", json!({})).unwrap()["view"], "default");
let vs = s.state.views3d.get(&cid).unwrap().clone();
assert_eq!(vs.current, View3D::Default);
// The comp's default camera: at −zoom looking at the centre, not the camera layer.
let cam = s.view_camera(cid).unwrap();
let zoom = effectcraft_geom::default_camera_zoom(64.0);
assert!((cam.eye.z + zoom).abs() < 1e-9 && (cam.eye.x - 32.0).abs() < 1e-9 && !cam.ortho, "{:?}", cam.eye);
// Orbitable like a custom view; Reset 3D View restores it; Last view goes back.
s.execute("camera.orbit", json!({"yaw": 30})).unwrap();
assert!((s.view_camera(cid).unwrap().eye.x - 32.0).abs() > 1.0);
s.execute("view.reset3DView", json!({})).unwrap();
assert!((s.view_camera(cid).unwrap().eye.x - 32.0).abs() < 1e-9);
s.execute("view.3d.last", json!({})).unwrap();
assert_eq!(s.state.views3d[&cid].current, View3D::ActiveCamera);
assert_eq!(s.execute("view.set3DView", json!({"view": "Default"})).unwrap()["view"], "default");
}
#[test]
fn locked_viewer_keeps_its_comp() {
let mut s = session();
let a = s.active_comp_id().unwrap();
s.execute("view.3d.top", json!({})).unwrap();
assert!(!s.is_enabled("view.closeLockedViewer"));
let r = s.execute("view.splitLockedViewer", json!({})).unwrap();
assert_eq!(r, json!({"comp": a.0, "view": "top"}));
// Another comp becomes active; the locked viewer stays on the first.
s.execute("comp.new", json!({"name": "B"})).unwrap();
assert_ne!(s.active_comp_id(), Some(a));
assert_eq!(s.state.locked_viewer.unwrap().comp, a);
let st = s.execute("view.displayColor", json!({})).unwrap();
assert_eq!(st["lockedViewer"]["comp"], a.0);
// Serde of the editor state; explicit comp and view.
let back: crate::EditorState = serde_json::from_value(serde_json::to_value(&s.state).unwrap()).unwrap();
assert_eq!(back.locked_viewer, s.state.locked_viewer);
s.execute("view.splitLockedViewer", json!({"comp": a.0, "view": "custom2"})).unwrap();
assert_eq!(s.state.locked_viewer.unwrap().view, View3D::Custom2);
assert!(s.execute("view.splitLockedViewer", json!({"view": "sideways"})).is_err());
assert_eq!(s.execute("view.closeLockedViewer", json!({})).unwrap()["closed"], true);
assert!(s.state.locked_viewer.is_none());
}
fn px(r: u8, g: u8, b: u8) -> Vec<[u8; 4]> {
vec![[r, g, b, 255]]
}
#[test]
fn display_color_management_and_simulation() {
let mut s = session();
// Unmanaged projects: nothing to convert.
assert!(DisplayColor::of(&s).is_none());
s.execute("file.projectSettings", json!({"workingSpace": "rec2020"})).unwrap();
// Managed, sRGB display, no simulation: the frame is already display-referred.
assert!(DisplayColor::of(&s).is_none());
assert_eq!(crate::menus::checked(&s, "view.displayColorManagement", &json!({})), Some(true));
// A Display P3 monitor: pure sRGB red becomes less saturated P3 numbers.
s.execute("prefs.set", json!({"key": "previews.displayProfile", "value": "p3"})).unwrap();
let mut p = px(255, 0, 0);
DisplayColor::of(&s).unwrap().apply(&mut p);
assert!((p[0][0] as i32 - 234).abs() <= 2 && (p[0][1] as i32 - 51).abs() <= 3 && (p[0][2] as i32 - 35).abs() <= 3, "{p:?}");
// Display colour management off: the working space's (Rec. 2020) numbers go to the screen.
s.execute("view.displayColorManagement", json!({})).unwrap();
assert_eq!(crate::menus::checked(&s, "view.displayColorManagement", &json!({})), Some(false));
let mut q = px(255, 0, 0);
DisplayColor::of(&s).unwrap().apply(&mut q);
assert!(q[0][0] < 230 && q[0][1] > 60, "raw Rec. 2020 numbers: {q:?}");
s.execute("view.displayColorManagement", json!({"value": true})).unwrap();
s.execute("prefs.set", json!({"key": "previews.displayProfile", "value": "srgb"})).unwrap();
// Simulate Output ▸ Linear with Preserve RGB: linear numbers shown as-is (darker mid-grey).
s.execute("view.simulateOutput", json!({"profile": "linear", "preserveRgb": true})).unwrap();
let mut g = px(128, 128, 128);
DisplayColor::of(&s).unwrap().apply(&mut g);
assert!((g[0][0] as i32 - 55).abs() <= 2, "{g:?}");
// Without Preserve RGB the round trip through 8-bit linear bands the shadows.
s.execute("view.simulateOutput", json!({"profile": "linear"})).unwrap();
let dc = DisplayColor::of(&s).unwrap();
let levels: std::collections::BTreeSet<u8> = (0..32u8)
.map(|v| {
let mut p = px(v, v, v);
dc.apply(&mut p);
p[0][0]
})
.collect();
assert!(levels.len() < 20, "banding: {levels:?}");
assert_eq!(crate::menus::checked(&s, "view.simulateOutput", &json!({"profile": "linear"})), Some(true));
// Rec. 709 simulation keeps in-gamut colours; legacy Mac gamma 1.8 with Preserve RGB darkens.
s.execute("view.simulateOutput", json!({"profile": "rec709"})).unwrap();
let mut c = px(200, 100, 50);
DisplayColor::of(&s).unwrap().apply(&mut c);
assert!((c[0][0] as i32 - 200).abs() <= 2 && (c[0][1] as i32 - 100).abs() <= 2, "{c:?}");
s.execute("view.simulateOutput", json!({"profile": "mac18", "preserveRgb": true})).unwrap();
let mut m = px(128, 128, 128);
DisplayColor::of(&s).unwrap().apply(&mut m);
assert!(m[0][0] < 120, "gamma 1.8 numbers on an sRGB display look darker: {m:?}");
// Custom… uses (and sets) the custom profile.
let r = s.execute("view.simulateOutput", json!({"profile": "custom", "space": "p3", "preserveRgb": false})).unwrap();
assert_eq!(r["profile"], "p3");
assert_eq!(s.state.viewer.custom_simulation, Simulation { profile: SimProfile::P3, preserve_rgb: false });
assert_eq!(crate::menus::checked(&s, "view.simulateOutput", &json!({"profile": "custom"})), Some(true));
s.execute("view.simulateOutput", json!({"profile": "none"})).unwrap();
assert!(DisplayColor::of(&s).is_none());
assert!(s.execute("view.simulateOutput", json!({"profile": "cmyk"})).is_err());
// Every menu profile parses.
for p in SimProfile::ALL {
assert_eq!(SimProfile::parse(p.id()), Some(p));
}
}
fn red_solid(s: &mut Session, hex: &str) {
s.execute("layer.newSolid", json!({"color": hex, "width": 64, "height": 48})).unwrap();
}
#[test]
fn color_engine_aces_working_spaces_and_hdr() {
let mut s = session();
let cid = s.active_comp_id().unwrap();
// OCIO engine: ACEScg working space and the tone-mapped display by default.
s.execute("file.projectSettings", json!({"colorEngine": "ocio"})).unwrap();
let st = s.project.settings.clone();
assert_eq!((st.color_engine, st.working_space, st.hdr), (ColorEngine::Ocio, Some(ColorSpace::AcesCg), HdrMode::ToneMap));
assert!(s.execute("file.projectSettings", json!({"workingSpace": "rec709"})).is_err(), "not in the OCIO config");
s.execute("file.projectSettings", json!({"workingSpace": "aces2065"})).unwrap();
assert!(s.execute("file.projectSettings", json!({"workingSpace": "rec2100pq"})).is_err(), "not a working space");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.settings.working_space, Some(ColorSpace::AcesCg));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.settings.color_engine, ColorEngine::Adobe);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project.settings.color_engine, ColorEngine::Ocio);
// 32 bpc, a solid of 4.0 (over-range) in ACEScg: tone mapped below white, not clipped.
s.execute("file.projectSettings", json!({"bitDepth": 32, "hdr": "toneMap"})).unwrap();
s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 64, "height": 48})).unwrap();
let lid = s.active_comp().unwrap().layers[0].id.0;
s.execute("effect.apply", json!({"layers": [lid], "effect": "Exposure"})).unwrap();
s.execute("prop.set", json!({"layer": lid, "path": "effects/#1/master/exposure", "value": 2.0})).unwrap();
let mapped = s.render(cid, Tick::ZERO, RenderOpts::default()).data[0];
assert!(mapped[0] > 0.5 && mapped[0] < 0.99, "tone mapped: {mapped:?}");
s.execute("file.projectSettings", json!({"hdr": "clip"})).unwrap();
let clipped = s.render(cid, Tick::ZERO, RenderOpts::default()).data[0];
assert!(clipped[0] > 1.1, "32 bpc keeps over-range: {clipped:?}");
s.execute("file.projectSettings", json!({"hdr": "compand"})).unwrap();
let comp = s.render(cid, Tick::ZERO, RenderOpts::default()).data[0];
assert!(comp[0] > 0.9 && comp[0] < 1.0, "companded: {comp:?}");
// Rec. 2100 PQ output: HDR, no tone mapping: over-range white encodes above SDR white's 0.58.
s.execute("file.projectSettings", json!({"outputSpace": "rec2100pq", "hdr": "toneMap"})).unwrap();
let pq = s.render(cid, Tick::ZERO, RenderOpts::default()).data[0];
assert!(pq[0] > 0.6 && pq[0] < 0.8, "PQ: {pq:?}");
s.execute("file.projectSettings", json!({"outputSpace": "rec2100hlg"})).unwrap();
let hlg = s.render(cid, Tick::ZERO, RenderOpts::default()).data[0];
assert!(hlg[0] > 0.85, "HLG: {hlg:?}");
assert!(s.execute("file.projectSettings", json!({"hdr": "glow"})).is_err());
// Serde keeps the new settings; older files load with the defaults.
let back: Project = serde_json::from_str(&serde_json::to_string(&*s.project).unwrap()).unwrap();
assert_eq!(back.settings, s.project.settings);
let mut v = serde_json::to_value(&s.project.settings).unwrap();
for k in ["color_engine", "hdr", "output_space"] {
v.as_object_mut().unwrap().remove(k);
}
let old: effectcraft_project::ProjectSettings = serde_json::from_value(v).unwrap();
assert_eq!((old.color_engine, old.hdr, old.output_space), (ColorEngine::Adobe, HdrMode::Clip, None));
}
#[test]
fn aces_working_space_matches_the_ocio_config() {
// ACEScg in the colour module and in the OCIO effects' built-in config agree.
let mut s = session();
s.execute("file.projectSettings", json!({"workingSpace": "acescg"})).unwrap();
red_solid(&mut s, "#808080");
let cid = s.active_comp_id().unwrap();
// Authored ACEScg values (0.5 linear AP1 grey) → sRGB display.
let out = s.render(cid, Tick::ZERO, RenderOpts::default()).data[0];
let want = effectcraft_color::linear_to_srgb(128.0 / 255.0);
assert!((out[0] - want).abs() < 0.01 && (out[1] - want).abs() < 0.01, "{out:?} vs {want}");
}
#[test]
fn feet_and_frames_display_and_entry() {
let mut s = session();
let comp = s.active_comp().unwrap().clone();
s.execute("file.projectSettings", json!({"timeDisplay": "feet35"})).unwrap();
assert_eq!(s.project.settings.time_display, TimeDisplayStyle::Feet35);
let t = comp.frame_rate.tick_of(16 * 3 + 5);
assert_eq!(crate::commands::time::display_time(&s, &comp, t), "0003+05");
// Typed feet+frames in the time field.
let r = s.execute("time.set", json!({"timecode": "2+10"})).unwrap();
assert_eq!(r["frame"], 42);
assert_eq!(r["display"], "0002+10");
let r = s.execute("time.set", json!({"timecode": "+6"})).unwrap();
assert_eq!(r["frame"], 48);
s.execute("file.projectSettings", json!({"timeDisplay": "feet16"})).unwrap();
assert_eq!(crate::commands::time::display_time(&s, &comp, comp.frame_rate.tick_of(95)), "0002+15");
s.execute("file.projectSettings", json!({"timeDisplay": "frames"})).unwrap();
assert_eq!(crate::commands::time::display_time(&s, &comp, comp.frame_rate.tick_of(95)), "00095");
s.execute("file.projectSettings", json!({"timeDisplay": "timecode"})).unwrap();
assert_eq!(crate::commands::time::display_time(&s, &comp, comp.frame_rate.tick_of(48)), "0:00:02:00");
assert!(s.execute("file.projectSettings", json!({"timeDisplay": "furlongs"})).is_err());
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.settings.time_display, TimeDisplayStyle::Frames);
assert_eq!(serde_json::to_value(TimeDisplayStyle::Feet16).unwrap(), json!("Feet16"));
}
+1 -1
View File
@@ -487,7 +487,7 @@ fn frontend_commands_emit_events_and_stubs_are_disabled() {
let ev = frontend_events(&mut s);
assert_eq!(ev[0].0, "view.zoomIn");
assert_eq!(ev[1], ("window.panel".to_string(), json!({"panel": "align"})));
for id in ["layer.autoTrace", "camera.stereoRig", "view.displayColorManagement", "view.3d.default", "layer.create", "track.motion"] {
for id in ["layer.autoTrace", "layer.sceneEditDetection", "layer.create", "track.motion"] {
assert!(!s.is_enabled(id), "{id}");
assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id}");
}
+338
View File
@@ -0,0 +1,338 @@
//! Camera and light rigs (M7.7): Create Stereo 3D Rig, Create Orbit Null, Control Light with
//! Camera, Create Cameras / Lights from 3D Model and Create Environment Light Background Layer
//! (behaviour, values, pixels, undo/redo, serde).
use std::sync::Arc;
use effectcraft_geom::{Vec3, vec3};
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, LayerSource, LightKind, Project};
use effectcraft_render::three_d::camera::layer_frame;
use effectcraft_render::{EvalCtx, FootageSource, RenderOpts};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
use crate::{Importer, Session};
fn session() -> Session {
let mut s = Session { footage: Arc::new(DiskModels), importer: Some(Arc::new(ModelImporter)), ..Default::default() };
s.execute("comp.new", json!({"name": "Scene", "width": 640, "height": 360, "frameRate": 30, "duration": 2})).unwrap();
s
}
fn cid(s: &Session) -> ItemId {
s.active_comp_id().unwrap()
}
fn layer_in(s: &Session, comp: ItemId, id: u64) -> Layer {
s.project.comp(comp).unwrap().layer(LayerId(id)).unwrap().clone()
}
fn v3(l: &Layer, path: &str) -> [f64; 3] {
l.props.prop(path).unwrap().value.as_vec3()
}
fn close3(a: [f64; 3], b: [f64; 3], tol: f64) -> bool {
(0..3).all(|i| (a[i] - b[i]).abs() <= tol)
}
/// World (eye, forward) of a layer in the active comp at time 0.
fn frame(s: &Session, id: u64) -> (Vec3, Vec3) {
let c = cid(s);
let comp = s.project.comp(c).unwrap();
let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None };
let (e, f, _) = layer_frame(&ctx, comp.layer(LayerId(id)).unwrap());
(e, f)
}
fn camera(s: &mut Session) -> u64 {
s.execute("layer.newCamera", json!({"name": "Cam", "position": [320, 180, -800], "poi": [320, 180, 0], "zoom": 900})).unwrap()["layer"].as_u64().unwrap()
}
#[test]
fn stereo_rig_builds_eye_comps_and_glasses() {
let mut s = session();
let src = cid(&s);
let cam = camera(&mut s);
let items_before = s.project.items.len();
let r = s.execute("camera.stereoRig", json!({"sceneDepth": 4})).unwrap();
assert_eq!(s.project.items.len(), items_before + 3, "left, right and stereo comps");
assert_eq!(r["master"].as_u64(), Some(cam));
// Controls on a hidden null in the source comp.
let ctl = layer_in(&s, src, r["controls"].as_u64().unwrap());
assert_eq!(ctl.name, "Stereo 3D Controls");
assert!(!ctl.switches.video);
let fx = ctl.effects().unwrap().groups().next().unwrap().clone();
assert!(matches!(&fx.kind, GroupKind::Effect { effect } if effect == effectcraft_effects::controls::STEREO_CONTROLS));
assert_eq!(fx.get("sceneDepth").unwrap().value, KV::Scalar(4.0));
// Eye comps: an eye camera (two-node, linked by expressions) and the collapsed source comp.
let d = 0.04 * 640.0;
for (key, name, x) in [("leftComp", "Scene Left Eye", 320.0 - d / 2.0), ("rightComp", "Scene Right Eye", 320.0 + d / 2.0)] {
let ec = ItemId(r[key].as_u64().unwrap());
assert_eq!(s.project.item(ec).unwrap().name, name);
let c = s.project.comp(ec).unwrap();
assert_eq!((c.width, c.height), (640, 360));
assert_eq!(c.layers.len(), 2);
let cam = &c.layers[0];
assert!(cam.is_camera() && effectcraft_render::three_d::camera::is_two_node(cam));
assert!(close3(v3(cam, "transform/position"), [x, 180.0, -800.0], 1e-6), "{:?}", v3(cam, "transform/position"));
// Parallel eyes (no convergence): each looks straight ahead.
assert!(close3(v3(cam, "transform/poi"), [x, 180.0, 0.0], 1e-6), "{:?}", v3(cam, "transform/poi"));
assert_eq!(cam.props.prop("cameraOptions/zoom").unwrap().value, KV::Scalar(900.0));
for p in ["transform/position", "transform/poi", "cameraOptions/zoom"] {
let e = cam.props.prop(p).unwrap().expr.clone().unwrap();
assert!(e.text.contains("comp(\"Scene\")") && e.text.contains("Cam"), "{p}: {}", e.text);
}
let nest = &c.layers[1];
assert_eq!(nest.source, LayerSource::Comp { item: src });
assert!(nest.switches.collapse && nest.switches.three_d);
}
// The stereo comp: left eye on top with 3D Glasses fed by both eyes; right eye hidden.
let oc = ItemId(r["stereoComp"].as_u64().unwrap());
let out = s.project.comp(oc).unwrap();
assert_eq!(s.project.item(oc).unwrap().name, "Scene Stereo 3D");
let (l, rr) = (r["leftLayer"].as_u64().unwrap(), r["rightLayer"].as_u64().unwrap());
assert_eq!(out.layers[0].id.0, l);
assert!(!out.layers[1].switches.video);
let g = out.layers[0].effects().unwrap().groups().next().unwrap().clone();
assert!(matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.perspective.3dglasses"));
assert_eq!(g.get("leftView").unwrap().value, KV::Layer(Some(l)));
assert_eq!(g.get("rightView").unwrap().value, KV::Layer(Some(rr)));
// One undo step; redo; serde.
let json = serde_json::to_string(&*s.project).unwrap();
let back: Project = serde_json::from_str(&json).unwrap();
assert_eq!(back, *s.project);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.items.len(), items_before);
assert_eq!(s.project.comp(src).unwrap().layers.len(), 1);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project.items.len(), items_before + 3);
}
#[test]
fn stereo_rig_configurations_and_convergence() {
let mut s = session();
camera(&mut s);
let r = s.execute("camera.stereoRig", json!({"configuration": "centerRight", "sceneDepth": 5, "convergence": true})).unwrap();
let d = 0.05 * 640.0;
let eye = |key: &str| s.project.comp(ItemId(r[key].as_u64().unwrap())).unwrap().layers[0].clone();
let (l, rr) = (eye("leftComp"), eye("rightComp"));
assert!(close3(v3(&l, "transform/position"), [320.0, 180.0, -800.0], 1e-6));
assert!(close3(v3(&rr, "transform/position"), [320.0 + d, 180.0, -800.0], 1e-6));
// Converged on the master's point of interest.
assert!(close3(v3(&l, "transform/poi"), [320.0, 180.0, 0.0], 1e-6));
assert!(close3(v3(&rr, "transform/poi"), [320.0, 180.0, 0.0], 1e-6));
// A comp without a camera gets a Master Camera.
let mut s = session();
let r = s.execute("camera.stereoRig", json!({"configuration": "centerLeft"})).unwrap();
let m = layer_in(&s, cid(&s), r["master"].as_u64().unwrap());
assert_eq!(m.name, "Master Camera");
assert!(s.execute("camera.stereoRig", json!({"configuration": "sideways"})).is_err());
}
/// The eye cameras' expressions reproduce the static values (and follow the master).
#[test]
fn stereo_rig_maths() {
use crate::commands::rig3d::{StereoParams, eye_camera};
let sp = StereoParams { configuration: 0, scene_depth: 10.0, convergence: false, convergence_of: 0, z_offset: 0.0 };
let master = (vec3(0.0, 0.0, -100.0), vec3(0.0, 0.0, 1.0), vec3(1.0, 0.0, 0.0));
let (p0, q0) = eye_camera(&sp, 0, 1000.0, master, 100.0, 500.0);
assert!(close3([p0.x, p0.y, p0.z], [-50.0, 0.0, -100.0], 1e-9) && close3([q0.x, q0.y, q0.z], [-50.0, 0.0, 0.0], 1e-9));
let sp = StereoParams { convergence: true, convergence_of: 1, z_offset: 50.0, ..sp };
let (_, q1) = eye_camera(&sp, 1, 1000.0, master, 100.0, 500.0);
// Zoom plane (500) + Z offset (50) in front of the master eye.
assert!(close3([q1.x, q1.y, q1.z], [0.0, 0.0, 450.0], 1e-9), "{q1:?}");
}
#[test]
fn orbit_null_parents_the_camera_at_its_point_of_interest() {
let mut s = session();
let cam = camera(&mut s);
let (eye0, f0) = frame(&s, cam);
let r = s.execute("camera.orbitNull", json!({})).unwrap();
let nid = r["null"].as_u64().unwrap();
let n = layer_in(&s, cid(&s), nid);
assert!(n.is_3d() && matches!(n.source, LayerSource::Null));
assert_eq!(n.name, "Cam Orbit Null");
assert!(close3(v3(&n, "transform/position"), [320.0, 180.0, 0.0], 1e-9));
let c = layer_in(&s, cid(&s), cam);
assert_eq!(c.parent, Some(LayerId(nid)));
// The camera stays where it was.
let (eye1, f1) = frame(&s, cam);
assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?}");
// Turning the null orbits the camera around the point of interest.
s.execute("prop.set", json!({"layer": nid, "path": "transform/rotationY", "value": 90})).unwrap();
let (eye2, f2) = frame(&s, cam);
let poi = vec3(320.0, 180.0, 0.0);
assert!(((eye2 - poi).length() - 800.0).abs() < 1e-6, "{eye2:?}");
assert!((eye2.z).abs() < 1e-6 && (eye2.x - 320.0).abs() > 700.0, "{eye2:?}");
assert!(((poi - eye2).normalize() - f2).length() < 1e-6, "still looks at the POI: {f2:?}");
// A parented camera can't get another orbit null; undo restores the camera.
assert!(s.execute("camera.orbitNull", json!({"layer": cam})).is_err());
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer_in(&s, cid(&s), cam).parent, None);
assert!(s.active_comp().unwrap().layer(LayerId(nid)).is_none());
}
#[test]
fn control_light_with_camera_follows_and_releases() {
let mut s = session();
let cam = camera(&mut s);
let light = s.execute("layer.newLight", json!({"kind": "Spot", "position": [0, 0, -500], "poi": [100, 100, 0]})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.select", json!({"layers": [light]})).unwrap();
let r = s.execute("light.controlWithCamera", json!({})).unwrap();
assert_eq!(r["controlled"], true);
let l = layer_in(&s, cid(&s), light);
assert_eq!(l.parent, Some(LayerId(cam)));
let (ce, cf) = frame(&s, cam);
let (le, lf) = frame(&s, light);
assert!((le - ce).length() < 1e-6 && (lf - cf).length() < 1e-6, "light at the camera: {le:?} {lf:?}");
// Moving the camera carries the light.
s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [100, 180, -800]})).unwrap();
let (le2, _) = frame(&s, light);
assert!((le2 - vec3(100.0, 180.0, -800.0)).length() < 1e-6, "{le2:?}");
// Again: released where it is (unparented, aimed the same way).
let r = s.execute("light.controlWithCamera", json!({"layers": [light]})).unwrap();
assert_eq!(r["controlled"], false);
let l = layer_in(&s, cid(&s), light);
assert_eq!(l.parent, None);
let (le3, lf3) = frame(&s, light);
assert!((le3 - le2).length() < 1e-6, "{le3:?}");
let (_, cf2) = frame(&s, cam);
assert!((lf3 - cf2).length() < 1e-6, "{lf3:?} {cf2:?}");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer_in(&s, cid(&s), light).parent, Some(LayerId(cam)));
// Not a light → error.
assert!(s.execute("light.controlWithCamera", json!({"layers": [cam]})).is_err());
}
// ---------------------------------------------------------------- from 3D model
struct DiskModels;
impl FootageSource for DiskModels {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<effectcraft_raster::Image>> {
None
}
fn model(&self, _: ItemId, f: &Footage) -> Option<Arc<effectcraft_model::Model>> {
let bytes = std::fs::read(&f.path).ok()?;
effectcraft_model::load(&f.path, &bytes, &|_| None).ok().map(Arc::new)
}
}
struct ModelImporter;
impl Importer for ModelImporter {
fn probe(&self, path: &str) -> Result<Footage, String> {
effectcraft_model::format_of(path).ok_or("not a model")?;
Ok(Footage { path: path.into(), kind: FootageKind::Model, frame_rate: FrameRate::FPS_30, codec: "glTF".into(), ..Default::default() })
}
}
const RIGGED: &str = r#"{
"asset": {"version": "2.0"},
"scenes": [{"nodes": [0, 1, 2]}],
"nodes": [
{"name": "ShotCam", "camera": 0, "translation": [0, 0, 5]},
{"name": "Key", "translation": [0, 3, 0], "rotation": [-0.7071068, 0, 0, 0.7071068], "extensions": {"KHR_lights_punctual": {"light": 0}}},
{"name": "Fill", "translation": [2, 0, 0], "extensions": {"KHR_lights_punctual": {"light": 1}}}
],
"cameras": [{"type": "perspective", "name": "Shot", "perspective": {"yfov": 0.6, "znear": 0.1}}],
"extensions": {"KHR_lights_punctual": {"lights": [
{"type": "spot", "name": "Key Light", "color": [1, 0.8, 0.6], "intensity": 1.5, "spot": {"innerConeAngle": 0.25, "outerConeAngle": 0.5}},
{"type": "point", "name": "Fill Light", "intensity": 0.5, "range": 4}
]}}
}"#;
fn rigged_model(s: &mut Session) -> u64 {
let dir = std::env::temp_dir().join(format!("ec-rig3d-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("rigged.gltf");
std::fs::write(&path, RIGGED).unwrap();
let r = s.execute("layer.newModel", json!({"path": path.to_string_lossy()})).unwrap();
r["layer"].as_u64().unwrap()
}
#[test]
fn cameras_and_lights_from_model() {
let mut s = session();
let model = rigged_model(&mut s);
let m = layer_in(&s, cid(&s), model);
let unit = m.props.prop("geometryOptions/unitScale").unwrap().value.as_f64();
let pos = v3(&m, "transform/position");
s.execute("layer.select", json!({"layers": [model]})).unwrap();
let r = s.execute("camera.fromModel", json!({})).unwrap();
let cams = r["layers"].as_array().unwrap();
assert_eq!(cams.len(), 1);
let c = layer_in(&s, cid(&s), cams[0].as_u64().unwrap());
assert_eq!(c.name, "Shot");
// glTF +Z (towards the viewer) is AE −Z; the camera looks into the scene (+Z).
let want = [pos[0], pos[1], pos[2] - 5.0 * unit];
assert!(close3(v3(&c, "transform/position"), want, 1e-6), "{:?} vs {want:?}", v3(&c, "transform/position"));
let zoom = 180.0 / (0.3f64).tan();
assert!((c.props.prop("cameraOptions/zoom").unwrap().value.as_f64() - zoom).abs() < 1e-6);
let (_, f) = frame(&s, c.id.0);
assert!((f - vec3(0.0, 0.0, 1.0)).length() < 1e-6, "{f:?}");
// Lights: a spot (cone 2 × outer, feather from inner/outer) and a point light.
s.execute("layer.select", json!({"layers": [model]})).unwrap();
let r = s.execute("light.fromModel", json!({})).unwrap();
let ls: Vec<Layer> = r["layers"].as_array().unwrap().iter().map(|v| layer_in(&s, cid(&s), v.as_u64().unwrap())).collect();
assert_eq!(ls.len(), 2);
let key = ls.iter().find(|l| l.name == "Key Light").unwrap();
assert_eq!(key.source, LayerSource::Light { kind: LightKind::Spot });
assert!((key.props.prop("lightOptions/coneAngle").unwrap().value.as_f64() - 1.0f64.to_degrees()).abs() < 1e-6);
assert!((key.props.prop("lightOptions/coneFeather").unwrap().value.as_f64() - 50.0).abs() < 1e-6);
assert!((key.props.prop("lightOptions/intensity").unwrap().value.as_f64() - 150.0).abs() < 1e-9);
// Points down in glTF (−Y) = down on screen in AE (+Y).
let (ke, kf) = frame(&s, key.id.0);
assert!((kf - vec3(0.0, 1.0, 0.0)).length() < 1e-5, "{kf:?}");
assert!((ke.y - (pos[1] - 3.0 * unit)).abs() < 1e-6, "{ke:?}");
let fill = ls.iter().find(|l| l.name == "Fill Light").unwrap();
assert_eq!(fill.source, LayerSource::Light { kind: LightKind::Point });
assert!((fill.props.prop("lightOptions/falloffDistance").unwrap().value.as_f64() - 400.0).abs() < 1e-9);
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_comp().unwrap().layer(key.id).is_none());
// Not a model layer → error.
assert!(s.execute("camera.fromModel", json!({"layers": [c.id.0]})).is_err());
}
// ---------------------------------------------------------------- environment background
fn px(img: &effectcraft_raster::Image, x: u32, y: u32) -> [f32; 4] {
img.data[(y * img.width + x) as usize]
}
#[test]
fn environment_background_draws_the_sky_through_the_camera() {
let mut s = session();
let scene = cid(&s);
// An equirectangular "sky": red upper hemisphere, blue lower.
let sky = s.execute("comp.new", json!({"name": "Sky", "width": 200, "height": 100, "frameRate": 30, "duration": 2})).unwrap()["comp"].as_u64().unwrap();
s.execute("layer.newSolid", json!({"color": "#0000ff", "width": 200, "height": 100})).unwrap();
let top = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 50})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": top, "path": "transform/position", "value": [100, 25]})).unwrap();
s.execute("comp.open", json!({"comp": scene})).unwrap();
let src = s.execute("layer.addItem", json!({"item": sky})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.environment", json!({"layers": [src], "on": true})).unwrap();
// No environment light yet.
assert!(s.execute("light.environmentBackground", json!({})).is_err());
let env = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap()["layer"].as_u64().unwrap();
let r = s.execute("light.environmentBackground", json!({})).unwrap();
assert_eq!(r["light"].as_u64(), Some(env));
assert_eq!(r["source"].as_u64(), Some(src));
let bg = layer_in(&s, scene, r["layer"].as_u64().unwrap());
assert!(bg.environment_background && bg.is_3d());
assert_eq!(bg.source, LayerSource::Comp { item: ItemId(sky) });
assert_eq!(s.active_comp().unwrap().layers.last().unwrap().id, bg.id, "at the bottom");
let img = s.render(scene, Tick::ZERO, RenderOpts::default());
// Looking at the horizon: rays above it see red, below it blue.
let (t, b) = (px(&img, 320, 5), px(&img, 320, 354));
assert!(t[0] > 0.9 && t[2] < 0.1 && t[3] > 0.99, "top {t:?}");
assert!(b[2] > 0.9 && b[0] < 0.1, "bottom {b:?}");
// Serde keeps the flag; undo removes the layer.
let json = serde_json::to_value(&bg).unwrap();
assert_eq!(json["environment_background"], Value::Bool(true));
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_comp().unwrap().layer(bg.id).is_none());
}
+1 -1
View File
@@ -332,7 +332,7 @@ fn implemented_stubs_are_enabled_and_workspaces_are_frontend() {
assert!(s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == id)));
}
// What remains a stub is still disabled.
for id in ["view.displayColorManagement", "layer.autoTrace"] {
for id in ["layer.autoTrace", "layer.sceneEditDetection"] {
assert!(!s.is_enabled(id), "{id}");
}
}
+268
View File
@@ -5,6 +5,7 @@
use std::sync::Arc;
use effectcraft_color::{ColorSpace, space};
use effectcraft_geom::vec2;
use effectcraft_project::{Layer, LayerId};
use effectcraft_raster::Image;
@@ -359,6 +360,273 @@ pub fn display_transform(px: &mut [[u8; 4]], channel: Channel, colorized: bool,
}
}
// ---------------------------------------------------------------- display colour management
/// View ▸ Simulate Output: the output device a preview imitates.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SimProfile {
#[default]
None,
/// HDTV (Rec. 709): ITU-R BT.709 primaries, BT.1886 gamma 2.4.
Rec709,
/// UHDTV (Rec. 2020): ITU-R BT.2020 primaries, gamma 2.4.
Rec2020,
/// Display P3: SMPTE EG 432-1 primaries, D65, the sRGB curve.
P3,
/// Internet Standard RGB (sRGB, IEC 61966-2-1).
Srgb,
/// Linear light with Rec. 709 primaries (no transfer curve).
Linear,
/// SDTV NTSC: SMPTE 170M primaries, gamma 2.4.
Ntsc,
/// SDTV PAL: EBU Tech 3213 primaries, gamma 2.4.
Pal,
/// Legacy Macintosh RGB: Rec. 709 primaries, gamma 1.8.
Mac18,
}
/// A transfer curve of a simulated or display profile.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Curve {
Srgb,
Gamma(f32),
Linear,
/// A colour space's own curve (PQ, HLG…).
Space(space::Curve),
}
impl Curve {
fn decode(self, v: f32) -> f32 {
let a = v.abs();
let l = match self {
Curve::Srgb => srgb_to_linear(a),
Curve::Gamma(g) => a.powf(g),
Curve::Linear => a,
Curve::Space(c) => c.decode(a),
};
l.copysign(v)
}
fn encode(self, v: f32) -> f32 {
let a = v.abs();
let e = match self {
Curve::Srgb => linear_to_srgb(a),
Curve::Gamma(g) => a.powf(1.0 / g),
Curve::Linear => a,
Curve::Space(c) => c.encode(a),
};
e.copysign(v)
}
}
const P709: [[f64; 2]; 3] = [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]];
impl SimProfile {
pub const ALL: [SimProfile; 9] = [
SimProfile::None,
SimProfile::Rec709,
SimProfile::Ntsc,
SimProfile::Pal,
SimProfile::Mac18,
SimProfile::Srgb,
SimProfile::Rec2020,
SimProfile::P3,
SimProfile::Linear,
];
pub fn id(self) -> &'static str {
match self {
SimProfile::None => "none",
SimProfile::Rec709 => "rec709",
SimProfile::Rec2020 => "rec2020",
SimProfile::P3 => "p3",
SimProfile::Srgb => "srgb",
SimProfile::Linear => "linear",
SimProfile::Ntsc => "ntsc",
SimProfile::Pal => "pal",
SimProfile::Mac18 => "mac18",
}
}
pub fn label(self) -> &'static str {
match self {
SimProfile::None => "No Output Simulation",
SimProfile::Rec709 => "HDTV (Rec. 709)",
SimProfile::Rec2020 => "UHDTV (Rec. 2020)",
SimProfile::P3 => "Display P3",
SimProfile::Srgb => "Internet Standard RGB (sRGB)",
SimProfile::Linear => "Linear (1.0 Gamma)",
SimProfile::Ntsc => "SDTV NTSC",
SimProfile::Pal => "SDTV PAL",
SimProfile::Mac18 => "Legacy Macintosh RGB (Gamma 1.8)",
}
}
pub fn parse(s: &str) -> Option<SimProfile> {
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
SimProfile::ALL
.into_iter()
.find(|p| p.id() == k || p.label().chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase() == k)
.or(match k.as_str() {
"off" => Some(SimProfile::None),
"hdtv" | "bt709" => Some(SimProfile::Rec709),
"bt2020" | "uhdtv" => Some(SimProfile::Rec2020),
"displayp3" => Some(SimProfile::P3),
"lin" | "linearlight" => Some(SimProfile::Linear),
_ => None,
})
}
/// (primaries, curve); `None` for No Output Simulation.
pub fn space(self) -> Option<([[f64; 2]; 3], Curve)> {
Some(match self {
SimProfile::None => return None,
SimProfile::Rec709 => (P709, Curve::Gamma(2.4)),
SimProfile::Rec2020 => (ColorSpace::Rec2020.primaries(), Curve::Gamma(2.4)),
SimProfile::P3 => (ColorSpace::DisplayP3.primaries(), Curve::Srgb),
SimProfile::Srgb => (P709, Curve::Srgb),
SimProfile::Linear => (P709, Curve::Linear),
SimProfile::Ntsc => ([[0.630, 0.340], [0.310, 0.595], [0.155, 0.070]], Curve::Gamma(2.4)),
SimProfile::Pal => ([[0.640, 0.330], [0.290, 0.600], [0.150, 0.060]], Curve::Gamma(2.4)),
SimProfile::Mac18 => (P709, Curve::Gamma(1.8)),
})
}
}
/// View ▸ Simulate Output setting: the profile, and Preserve RGB (the output numbers are sent
/// to the display unconverted, as on a device that doesn't colour-manage).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(default)]
pub struct Simulation {
pub profile: SimProfile,
pub preserve_rgb: bool,
}
/// An RGB space for viewer conversions: primaries (D65) and curve.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Prof {
/// Linear RGB → XYZ D65.
xyz: [[f64; 3]; 3],
curve: Curve,
}
impl Prof {
fn new(prims: [[f64; 2]; 3], curve: Curve) -> Prof {
Prof { xyz: space::rgb_to_xyz(prims, D65), curve }
}
/// A colour space (linear when `linear`).
fn of(cs: ColorSpace, linear: bool) -> Prof {
let curve = if linear { Curve::Linear } else { Curve::Space(cs.curve()) };
Prof { xyz: cs.to_xyz(), curve }
}
}
/// A linear-RGB matrix plus curves between two profiles.
#[derive(Clone, Copy, Debug)]
struct Conv {
from: Curve,
m: Option<[[f32; 3]; 3]>,
to: Curve,
}
impl Conv {
fn new(a: Prof, b: Prof) -> Conv {
let m = (a.xyz != b.xyz).then(|| {
let m = space::mul(&space::invert(&b.xyz), &a.xyz);
m.map(|r| r.map(|v| v as f32))
});
Conv { from: a.curve, m, to: b.curve }
}
fn apply(&self, c: [f32; 3]) -> [f32; 3] {
let l = c.map(|v| self.from.decode(v));
let l = match &self.m {
Some(m) => [0, 1, 2].map(|i| m[i][0] * l[0] + m[i][1] * l[1] + m[i][2] * l[2]),
None => l,
};
l.map(|v| self.to.encode(v))
}
}
const D65: [f64; 2] = [0.3127, 0.3290];
/// The viewer's colour conversion from the rendered frame (sRGB-encoded display pixels) to
/// what the monitor shows (View ▸ Use Display Color Management, View ▸ Simulate Output and
/// the display profile in Settings ▸ Previews). `None` when nothing changes.
#[derive(Clone, Copy, Debug)]
pub struct DisplayColor {
sim: Option<(Conv, Option<Conv>)>,
display: Option<Conv>,
}
impl DisplayColor {
/// `working`: the project working space (None = unmanaged: nothing is converted, as in
/// After Effects); `linear`: the working space is linear; `source`: the space rendered
/// frames are in (the project's output space, sRGB by default); `dcm`: Use Display Color
/// Management; `display`: the monitor's space.
pub fn new(working: Option<ColorSpace>, linear: bool, source: ColorSpace, dcm: bool, display: ColorSpace, sim: Simulation) -> Option<DisplayColor> {
let ws = working?;
let src = Prof::of(source, false);
if !dcm {
// Without display colour management the working space's numbers go to the screen.
let raw = Conv::new(src, Prof::of(ws, linear));
return Some(DisplayColor { sim: None, display: Some(raw) });
}
let sim = sim.profile.space().map(|(prims, curve)| {
let out = Prof::new(prims, curve);
(
Conv::new(src, out),
Some(if sim.preserve_rgb {
Conv::new(Prof::new(P709, Curve::Srgb), Prof::of(display, false))
} else {
Conv::new(out, Prof::of(display, false))
}),
)
});
let display = (display != source).then(|| Conv::new(src, Prof::of(display, false)));
(sim.is_some() || display.is_some()).then_some(DisplayColor { sim, display })
}
/// The conversion a session's viewer uses.
pub fn of(s: &crate::Session) -> Option<DisplayColor> {
let st = &s.project.settings;
let display = ColorSpace::parse(&s.prefs.previews.display_profile).unwrap_or(ColorSpace::Srgb);
let v = &s.state.viewer;
let linear = st.working_space.is_some_and(|w| st.linearize || w.is_linear());
DisplayColor::new(st.working_space, linear, st.output_space.unwrap_or(ColorSpace::Srgb), v.display_color_management, display, v.simulation)
}
/// One straight colour (0..1 floats).
pub fn apply_straight(&self, c: [f32; 3]) -> [f32; 3] {
let c = match &self.sim {
// Through the output's 8-bit encoding (clipped and quantised like the real output),
// then shown on the display: converted from the output profile, or — Preserve RGB —
// the output numbers taken as if they were sRGB.
Some((to, show)) => {
let o = to.apply(c).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() / 255.0);
show.map_or(o, |b| b.apply(o))
}
None => self.display.map_or(c, |d| d.apply(c)),
};
c.map(|v| v.clamp(0.0, 1.0))
}
/// Premultiplied 8-bit RGBA pixels in place.
pub fn apply(&self, px: &mut [[u8; 4]]) {
use rayon::prelude::*;
px.par_iter_mut().for_each(|p| {
let a = p[3];
if a == 0 {
return;
}
let af = a as f32 / 255.0;
let c = [0, 1, 2].map(|i| (p[i] as f32 / 255.0 / af).min(1.0));
let o = self.apply_straight(c);
for i in 0..3 {
p[i] = (o[i] * af * 255.0 + 0.5) as u8;
}
});
}
}
// ---------------------------------------------------------------- snapshot
/// Take Snapshot (Shift+F5): a rendered frame kept for comparison (Show Snapshot, F5).