Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
bflatastic
2026-10-30 05:08:34 +07:00
69 changed files with 2296 additions and 222 deletions
Generated
+1
View File
@@ -1623,6 +1623,7 @@ name = "effectcraft-media"
version = "0.18.0"
dependencies = [
"craft-docpath",
"effectcraft-color",
"effectcraft-model",
"effectcraft-pdf",
"effectcraft-project",
+1 -1
View File
@@ -207,7 +207,7 @@ pub(crate) fn source_frame(
t: Tick,
expr: Option<&dyn ExprHost>,
) -> Option<(Arc<Image>, [f64; 2])> {
let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None };
let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None, property_overrides: &[], media_overrides: &[] };
if let effectcraft_project::LayerSource::Footage { item } = &layer.source
&& let Some(effectcraft_project::ItemKind::Footage(f)) = project.item(*item).map(|i| &i.kind)
&& f.has_video
+50 -5
View File
@@ -136,7 +136,16 @@ fn warning(s: &mut Session, p: &Value) -> Result<Value> {
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let g = layer.effects().and_then(|fx| fx.groups().find(|g| g.uid == uid)).ok_or_else(|| bad(cmd, "no such effect"))?;
let t = super::time_p(s, p, Some(comp));
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: t,
expr: s.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
Ok(json!({"effect": uid, "warning": effectcraft_effects::warning(g, &mut |pr| ctx.value(layer, pr))}))
}
@@ -160,7 +169,16 @@ fn pick_color(s: &mut Session, p: &Value) -> Result<Value> {
.filter(|pr| pr.ui == effectcraft_project::ParamUi::Color)
.ok_or_else(|| bad(cmd, "not a colour parameter of this effect"))?;
let t = super::time_p(s, p, Some(comp));
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: t,
expr: s.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let mut r = effectcraft_render::Renderer::new(&s.project, &*s.footage, effectcraft_render::RenderOpts::default());
r.expr = s.expr.as_deref();
r.cache = Some(&s.layer_cache);
@@ -288,7 +306,16 @@ impl CryptoProbe {
let comp = r.project.comp(cid).ok_or("no composition")?;
let layer = comp.layer(lid).ok_or("no such layer")?;
let g = layer.effects().and_then(|fx| fx.groups().nth(pos)).ok_or("no such effect")?;
let ctx = effectcraft_render::EvalCtx { project: r.project, comp_id: cid, comp, time, expr: r.expr, footage: None };
let ctx = effectcraft_render::EvalCtx {
project: r.project,
comp_id: cid,
comp,
time,
expr: r.expr,
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr));
let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset");
let aux = r.layer_aux(&ctx, layer).ok_or(NO_CRYPTO)?;
@@ -351,7 +378,16 @@ fn probe_key<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(ProbeKey, Tick
let g = fx.groups().nth(pos).ok_or_else(|| bad(cmd, "no such effect"))?;
let prop = g.prop("selection").filter(|_| g.match_id == "ec.3d.cryptomatte").ok_or_else(|| bad(cmd, "not a Cryptomatte effect"))?;
let time = super::time_p(s, p, Some(comp));
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time,
expr: s.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let frame = probe_frame(&ctx, layer, pos);
let o = s.render_opts(Default::default());
let prefs = (o.nested_switches, o.draft_shadows);
@@ -503,7 +539,16 @@ fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Hit> {
let Some(hash) = b.aux.index_at(ax, ay).and_then(|i| b.aux.crypto_id_at(&b.layer, i)) else { return Ok(Hit::Nothing) };
let (cid, lid, ..) = key.at;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time,
expr: s.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let selection = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)).s("selection").to_string();
let driven = prop.expr.as_ref().is_some_and(|e| e.enabled);
let (aux, layer_name) = (b.aux.clone(), b.layer.clone());
+2
View File
@@ -163,6 +163,8 @@ pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)>
("easeIn(t, tMin, tMax, value1, value2)", "easeIn(t, tMin, tMax, value1, value2)"),
("easeOut(t, value1, value2)", "easeOut(t, value1, value2)"),
("easeOut(t, tMin, tMax, value1, value2)", "easeOut(t, tMin, tMax, value1, value2)"),
("bezier(t, value1, value2, [x1, y1, x2, y2])", "bezier(t, value1, value2, [x1, y1, x2, y2])"),
("bezier(t, tMin, tMax, value1, value2, [x1, y1, x2, y2])", "bezier(t, tMin, tMax, value1, value2, [x1, y1, x2, y2])"),
],
),
(
+259 -58
View File
@@ -9,10 +9,20 @@ pub(crate) struct ParentFix {
rotation: f64,
/// Multiply Scale.
scale: [f64; 2],
/// 3D AV layers retain their world-space anchor, including Z.
/// 3D AV layers retain their world-space anchor, including Z. Cameras and lights also
/// re-express their Point of Interest.
position_3d: Option<effectcraft_geom::Mat4>,
/// A uniform relative parent transform can preserve static Orientation and Scale.
pose_3d: Option<([f64; 3], f64)>,
/// A uniform relative parent transform re-expresses static Orientation and Scale.
pose_3d: Option<Pose3d>,
}
/// A uniform relative parent transform, as it applies to a 3D layer's Orientation and Scale.
#[derive(Clone, Copy)]
struct Pose3d {
/// Rotation that Orientation is re-expressed under: `Orientation' = correction * Orientation`.
correction: effectcraft_geom::Mat4,
/// Uniform scale factor, multiplied into Scale (negative for a mirrored parent).
factor: f64,
}
impl ParentFix {
@@ -22,7 +32,7 @@ impl ParentFix {
if let Some(m) = self.position_3d {
// Spatial Position speed measures distance along its path. Its parent-space
// unit changes even when the child's Orientation cannot be re-expressed.
let position_speed = uniform_parent_pose(m, [0.0; 3]).map(|(_, factor)| factor.abs());
let position_speed = uniform_rotation(m).map(|(_, factor)| factor.abs());
let map_position = |v: &KV| {
let q = m.apply(effectcraft_geom::Vec3::from(v.as_vec3()));
KV::Vec3([q.x, q.y, q.z])
@@ -44,7 +54,8 @@ impl ParentFix {
}
}
}
if let Some(pr) = tr.get_mut("position") {
// Position and a camera's or light's Point of Interest are both points in the parent's space.
let map_point = |pr: &mut effectcraft_project::Property| {
pr.value = map_position(&pr.value);
for key in &mut pr.keys {
key.value = map_position(&key.value);
@@ -64,34 +75,19 @@ impl ParentFix {
}
}
}
};
if let Some(pr) = tr.get_mut("position") {
map_point(pr);
}
// Cameras and lights aim with Point of Interest: move it by the same parent-space
// change, so a two-node rig keeps its world frame (#1010, #1020).
if let Some(pr) = tr.get_mut("poi") {
pr.value = map_position(&pr.value);
for key in &mut pr.keys {
key.value = map_position(&key.value);
for tangent in [&mut key.spatial_in, &mut key.spatial_out] {
let v = m.apply_vec(effectcraft_geom::Vec3::from(*tangent));
*tangent = [v.x, v.y, v.z];
}
}
if let Some(factor) = position_speed
.filter(|_| pr.spatial)
.filter(|factor| pr.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * *factor).is_finite()))
{
for key in &mut pr.keys {
for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) {
ease.speed *= factor;
}
}
}
map_point(pr);
}
if let Some((orientation, factor)) = self.pose_3d {
if let Some(pose) = self.pose_3d {
if let Some(pr) = tr.get_mut("orientation") {
pr.value = KV::Vec3(orientation);
reexpress_orientation(pose.correction, pr);
}
if let Some(pr) = tr.get_mut("scale") {
let factor = pose.factor;
let scaled = |value: &KV| KV::Vec3(value.as_vec3().map(|v| v * factor));
pr.value = scaled(&pr.value);
for key in &mut pr.keys {
@@ -154,13 +150,10 @@ impl ParentFix {
}
}
/// A uniform factor commutes with authored axis Rotation and Scale; prepend its orthogonal
/// rotation to static Orientation using the existing Rz * Ry * Rx order.
fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Option<([f64; 3], f64)> {
use effectcraft_geom::{Mat4, Vec3, vec3};
if !orientation.iter().all(|v| v.is_finite()) {
return None;
}
/// The rotation and signed scale factor of a uniform parent transform's linear part (negative for a
/// mirrored parent). `None` for a non-uniform or degenerate transform.
fn uniform_rotation(m: effectcraft_geom::Mat4) -> Option<(effectcraft_geom::Mat4, f64)> {
use effectcraft_geom::{Mat4, vec3};
let a = m.0;
let columns = [vec3(a[0][0], a[1][0], a[2][0]), vec3(a[0][1], a[1][1], a[2][1]), vec3(a[0][2], a[1][2], a[2][2])];
let magnitude = columns[0].x.hypot(columns[0].y).hypot(columns[0].z);
@@ -179,15 +172,188 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti
rotation.0[row][col] = a[row][col] / factor;
}
}
if (0..3).all(|row| (0..3).all(|col| (rotation.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) {
return Some((orientation, factor));
}
let r = (rotation * Mat4::orientation(Vec3::from(orientation))).0;
Some((rotation, factor))
}
/// The turn (degrees) of a rotation about Z alone, or `None` for any other rotation.
fn z_only_turn(correction: effectcraft_geom::Mat4) -> Option<f64> {
let m = correction.0;
let about_z = [m[0][2], m[1][2], m[2][0], m[2][1]].iter().all(|v| v.abs() < 1e-9) && (m[2][2] - 1.0).abs() < 1e-9;
about_z.then(|| m[1][0].atan2(m[0][0]).to_degrees())
}
/// ZYX Euler angles in degrees of a row-major rotation (`Rz * Ry * Rx`), safe at gimbal lock.
fn euler_zyx(r: [[f64; 4]; 4]) -> [f64; 3] {
// atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact.
let cy = r[0][0].hypot(r[1][0]);
let y = (-r[2][0]).atan2(cy);
let (x, z) = if cy > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) };
Some(([x.to_degrees(), y.to_degrees(), z.to_degrees()], factor))
[x.to_degrees(), y.to_degrees(), z.to_degrees()]
}
/// The equivalent ZYX Euler triple nearest `reference`: each axis moved by whole turns, or the
/// `(x + 180, 180 - y, z + 180)` form when that is nearer.
fn nearest_euler(euler: [f64; 3], reference: [f64; 3]) -> [f64; 3] {
let near = |v: [f64; 3]| -> [f64; 3] { std::array::from_fn(|i| v[i] + ((reference[i] - v[i]) / 360.0).round() * 360.0) };
let natural = near(euler);
let flipped = near([euler[0] + 180.0, 180.0 - euler[1], euler[2] + 180.0]);
let distance = |v: [f64; 3]| v.iter().zip(reference.iter()).map(|(a, b)| (a - b).powi(2)).sum::<f64>();
if distance(flipped) < distance(natural) { flipped } else { natural }
}
/// The Euler Orientation of `correction * Orientation(o)`, in the form nearest `reference`.
fn reexpress_euler(correction: effectcraft_geom::Mat4, o: [f64; 3], reference: [f64; 3]) -> [f64; 3] {
let world = correction * effectcraft_geom::Mat4::orientation(effectcraft_geom::Vec3::from(o));
nearest_euler(euler_zyx(world.0), reference)
}
/// Largest angle (degrees) a baked Orientation may drift from the exact rotation between its keys.
const BAKE_TOLERANCE_DEGREES: f64 = 0.05;
/// How many times a segment may be halved while baking, and the most keys a property may end up with.
const BAKE_MAX_DEPTH: u32 = 10;
const BAKE_MAX_KEYS: usize = 4096;
/// The angle (degrees) between two rotations.
fn rotation_angle(a: effectcraft_geom::Mat4, b: effectcraft_geom::Mat4) -> f64 {
let trace: f64 = (0..3).flat_map(|i| (0..3).map(move |j| (i, j))).map(|(i, j)| a.0[i][j] * b.0[i][j]).sum();
((trace - 1.0) / 2.0).clamp(-1.0, 1.0).acos().to_degrees()
}
/// Adds keys between `(ta, ea)` and `(tb, eb)` (already converted) wherever the straight Euler blend
/// between them drifts from `correction * Orientation(authored)` by more than the tolerance. `out`
/// never grows past `budget` keys: what the whole property may still add, not what one segment may.
fn bake_between(
correction: effectcraft_geom::Mat4,
authored: &effectcraft_project::Property,
(ta, ea): (Tick, [f64; 3]),
(tb, eb): (Tick, [f64; 3]),
depth: u32,
budget: usize,
out: &mut Vec<effectcraft_keyframe::Keyframe>,
) {
use effectcraft_geom::{Mat4, Vec3};
if depth == 0 || tb.0 - ta.0 < 2 || out.len() >= budget {
return;
}
let tm = Tick(ta.0 + (tb.0 - ta.0) / 2);
let blend = [(ea[0] + eb[0]) / 2.0, (ea[1] + eb[1]) / 2.0, (ea[2] + eb[2]) / 2.0];
let exact = correction * Mat4::orientation(Vec3::from(authored.value_at(tm).as_vec3()));
// Symmetric easing crosses the straight blend at its midpoint. Check both sides too before
// accepting a segment whose original easing will be replaced with a linear blend.
let quarter = (tb.0 - ta.0) / 4;
if [Tick(ta.0 + quarter), tm, Tick(tb.0 - quarter)].into_iter().all(|t| {
let u = (t.0 - ta.0) as f64 / (tb.0 - ta.0) as f64;
let linear = std::array::from_fn(|i| ea[i] + (eb[i] - ea[i]) * u);
let target = correction * Mat4::orientation(Vec3::from(authored.value_at(t).as_vec3()));
rotation_angle(Mat4::orientation(Vec3::from(linear)), target) <= BAKE_TOLERANCE_DEGREES
}) {
return;
}
let em = nearest_euler(euler_zyx(exact.0), blend);
bake_between(correction, authored, (ta, ea), (tm, em), depth - 1, budget, out);
if out.len() >= budget {
return;
}
out.push(effectcraft_keyframe::Keyframe::new(tm, KV::Vec3(em)));
bake_between(correction, authored, (tm, em), (tb, eb), depth - 1, budget, out);
}
/// Re-expresses Orientation as `correction * Orientation`. A Z-only correction shifts Z exactly in the
/// value and every key. For any other correction a static value is converted directly; keys are
/// converted, and keys are added between them wherever the Euler blend between two keys would drift
/// from the exact rotation (the segments become linear, with the original easing baked in).
fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraft_project::Property) {
if let Some(turn) = z_only_turn(correction) {
// No turn (an identity correction) leaves every authored value as it is.
if turn.abs() <= 1e-12 {
return;
}
let shift = |value: &KV| {
let [x, y, z] = value.as_vec3();
KV::Vec3([x, y, z + turn])
};
pr.value = shift(&pr.value);
for key in &mut pr.keys {
key.value = shift(&key.value);
}
return;
}
let value = pr.value.as_vec3();
pr.value = KV::Vec3(reexpress_euler(correction, value, value));
if pr.keys.is_empty() {
return;
}
let authored = pr.clone();
// Each key keeps the authored change from the previous key on top of its converted value.
let mut converted: Vec<[f64; 3]> = vec![];
let mut prev: Option<([f64; 3], [f64; 3])> = None;
for key in &authored.keys {
let o = key.value.as_vec3();
let reference = match prev {
Some((before, after)) => [after[0] + o[0] - before[0], after[1] + o[1] - before[1], after[2] + o[2] - before[2]],
None => o,
};
let n = reexpress_euler(correction, o, reference);
converted.push(n);
prev = Some((o, n));
}
use effectcraft_keyframe::Interp;
let mut keys: Vec<effectcraft_keyframe::Keyframe> = vec![];
let mut after_baked_segment = false;
for (i, key) in authored.keys.iter().enumerate() {
let mut k = key.clone();
k.value = KV::Vec3(converted[i]);
if after_baked_segment {
k.in_interp = Interp::Linear;
k.in_ease.clear();
k.auto_bezier = false;
k.continuous = false;
}
after_baked_segment = false;
let segment = authored.keys.get(i + 1).filter(|_| key.out_interp != Interp::Hold);
let mut between = vec![];
if let Some(next) = segment {
// What the whole property may still add: the keys it will have already (the ones written
// and the authored ones still to come) count against the cap.
let budget = BAKE_MAX_KEYS.saturating_sub(keys.len() + authored.keys.len() - i);
bake_between(correction, &authored, (key.time, converted[i]), (next.time, converted[i + 1]), BAKE_MAX_DEPTH, budget, &mut between);
// With the cap reached the segment can't be checked or subdivided: it keeps its authored easing between
// the converted keys, rather than becoming a straight blend that was never checked against the original.
if between.len() >= budget {
between.clear();
// An eased segment, or one that gained keys, becomes a run of straight blends.
} else if !between.is_empty() || key.out_interp != Interp::Linear || next.in_interp != Interp::Linear {
k.out_interp = Interp::Linear;
k.out_ease.clear();
k.auto_bezier = false;
k.continuous = false;
after_baked_segment = true;
}
}
keys.push(k);
keys.extend(between);
}
pr.keys = keys;
}
/// Correction for a two-node rig's Orientation: after a uniform parent change its aim is
/// `rotation * aim`, so the roll is re-expressed against that aim. `None` for a degenerate aim.
fn aim_correction(rotation: effectcraft_geom::Mat4, aim: [f64; 3]) -> Option<effectcraft_geom::Mat4> {
use effectcraft_geom::vec3;
use effectcraft_render::three_d::camera::basis_rotation;
let [x, y, z] = aim;
let length = (x * x + y * y + z * z).sqrt();
if !length.is_finite() || length <= 1e-9 {
return None;
}
let dir = vec3(x / length, y / length, z / length);
let turned = rotation.apply_vec(dir);
// A view along the up axis has no roll to keep, and its basis is undefined.
if 1.0 - dir.y * dir.y < 1e-12 || 1.0 - turned.y * turned.y < 1e-12 {
return None;
}
let up = vec3(0.0, 1.0, 0.0);
Some(basis_rotation(turned, up).transpose() * rotation * basis_rotation(dir, up))
}
/// A parent change retains 2D transforms and the world-space anchor of 3D layers (Position,
@@ -196,8 +362,16 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti
/// time.
pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Option<LayerId>) -> Vec<ParentFix> {
let Some(comp) = s.project.comp(cid) else { return vec![] };
let ectx =
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
let ectx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: s.time(),
expr: s.expr.as_deref(),
footage: Some(s.footage.as_ref()),
property_overrides: &[],
media_overrides: &[],
};
let space = |id: Option<LayerId>| -> effectcraft_geom::Mat3 {
id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY)
};
@@ -235,31 +409,58 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op
})
};
if let Some(inv) = space_3d(parent).inverse().filter(|m| m.0.iter().flatten().all(|v| v.is_finite())) {
// Cameras and lights compensate like 3D layers (Position, and Point of Interest for
// two-node rigs), so parenting keeps the world frame (#1010, #1020). They have no
// Scale, so `pose_3d` below stays `None` and Orientation is kept as authored.
for layer in comp.layers.iter().filter(|l| ids.contains(&l.id) && l.is_3d() && l.parent != parent) {
let m = inv * space_3d(layer.parent);
// A one-node camera or light has no Scale to carry a mirror, and its frame (forward and
// down) would come out reversed: compensate none of it, so Position and Orientation stay
// as authored instead of half-following the parent.
let rig = layer.is_camera() || layer.is_light();
if rig && !effectcraft_render::three_d::camera::is_two_node(layer) && uniform_rotation(m).is_some_and(|(_, factor)| factor <= 0.0) {
continue;
}
if m.0.iter().flatten().all(|v| v.is_finite()) {
let pose_3d = layer.transform().and_then(|tr| {
let orientation = tr.get("orientation")?;
let scale = tr.get("scale")?;
// Animated Euler Orientation and auto-orient cannot be re-expressed by
// prepending a constant Euler value. Expression-driven values stay authored.
if !orientation.keys.is_empty()
|| orientation.expr.is_some()
|| scale.expr.is_some()
|| layer.auto_orient != effectcraft_project::AutoOrient::Off
// Cameras and lights have no Scale; their Orientation is re-expressed alone.
let scale = tr.get("scale");
// Expression-driven values stay authored. Auto-orient replaces Orientation, except
// a two-node rig, whose roll is re-expressed against its new aim.
let two_node = (layer.is_camera() || layer.is_light()) && effectcraft_render::three_d::camera::is_two_node(layer);
if orientation.expr.is_some()
|| scale.is_some_and(|scale| scale.expr.is_some())
|| (layer.auto_orient != effectcraft_project::AutoOrient::Off && !two_node)
{
return None;
}
let pose = uniform_parent_pose(m, orientation.value.as_vec3())?;
let factor = pose.1;
let values_finite = std::iter::once(&scale.value)
.chain(scale.keys.iter().map(|key| &key.value))
.all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite()));
let speeds_finite = scale.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * factor).is_finite());
(values_finite && speeds_finite).then_some(pose)
let (rotation, factor) = uniform_rotation(m)?;
let correction = if two_node {
// A mirrored parent flips the aim, which this correction does not model.
if factor <= 0.0 {
return None;
}
let (w, h) = (comp.width as f64, comp.height as f64);
let pos = ectx.v3(layer, tr, "position", [w / 2.0, h / 2.0, -1000.0]);
let poi = ectx.v3(layer, tr, "poi", [w / 2.0, h / 2.0, 0.0]);
aim_correction(rotation, [poi[0] - pos[0], poi[1] - pos[1], poi[2] - pos[2]])?
} else {
rotation
};
let orientation_finite = std::iter::once(&orientation.value)
.chain(orientation.keys.iter().map(|key| &key.value))
.all(|value| value.as_vec3().iter().all(|v| v.is_finite()));
if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation_finite {
return None;
}
if let Some(scale) = scale {
let values_finite = std::iter::once(&scale.value)
.chain(scale.keys.iter().map(|key| &key.value))
.all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite()));
let speeds_finite = scale.keys.iter().all(|key| key.in_ease.iter().chain(&key.out_ease).all(|ease| (ease.speed * factor).is_finite()));
if !values_finite || !speeds_finite {
return None;
}
}
Some(Pose3d { correction, factor })
});
fixes.push(ParentFix { layer: layer.id, m: effectcraft_geom::Mat3::IDENTITY, rotation: 0.0, scale: [1.0; 2], position_3d: Some(m), pose_3d });
}
+10 -1
View File
@@ -185,7 +185,16 @@ fn freeze(s: &mut Session, p: &Value, last: bool) -> Result<Value> {
return Err(bad("layer.freezeFrame", "freezing needs a footage or composition layer"));
}
// What each layer shows now (honouring an existing remap) — evaluated before the edit.
let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: cti, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
let ectx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: cti,
expr: s.expr.as_deref(),
footage: Some(s.footage.as_ref()),
property_overrides: &[],
media_overrides: &[],
};
let now: Vec<(effectcraft_project::LayerId, f64)> = comp
.layers
.iter()
+14 -6
View File
@@ -296,8 +296,9 @@ fn convert(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers onto the
/// layer (layer time is the nested comp's time), skipping ones already there.
/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers, or the
/// markers a footage file carries (a WAV's cue points), onto the layer (layer time is the source's
/// time), skipping ones already there.
fn update_from_source(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let src: Vec<(LayerId, Vec<Marker>)> = {
@@ -307,13 +308,17 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result<Value> {
let l = comp.layer(*id)?;
match &l.source {
LayerSource::Comp { item } => Some((*id, s.project.comp(*item)?.markers.clone())),
LayerSource::Footage { item } => match &s.project.item(*item)?.kind {
effectcraft_project::ItemKind::Footage(f) => Some((*id, f.markers.clone())),
_ => None,
},
_ => None,
}
})
.collect()
};
if src.is_empty() {
return Err(bad("layer.updateMarkersFromSource", "select a precomposition layer"));
return Err(bad("layer.updateMarkersFromSource", "select a precomposition or footage layer"));
}
let n = s.edit("Update Markers From Source", None, |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
@@ -323,8 +328,11 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result<Value> {
if l.markers_locked {
continue;
}
// One set of the times already on the layer: a file with thousands of markers must not
// scan the layer's list for each of them.
let mut times: std::collections::HashSet<_> = l.markers.iter().map(|x| x.time).collect();
for m in ms {
if !l.markers.iter().any(|x| x.time == m.time) {
if times.insert(m.time) {
l.markers.push(m.clone());
n += 1;
}
@@ -339,10 +347,10 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result<Value> {
fn has_precomp_layer(s: &Session) -> std::result::Result<(), String> {
has_layers(s)?;
let c = s.active_comp().ok_or("no composition")?;
if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. })) {
if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. } | LayerSource::Footage { .. })) {
Ok(())
} else {
Err("select a precomposition layer".into())
Err("select a precomposition or footage layer".into())
}
}
+20 -3
View File
@@ -78,7 +78,16 @@ fn info(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, cmd: &str) -> Result<P
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let pr = layer.props.find(uid).ok_or_else(|| bad(cmd, "no such property"))?;
let t = s.time();
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: t,
expr: s.expr.as_deref(),
footage: Some(s.footage.as_ref()),
property_overrides: &[],
media_overrides: &[],
};
let KV::Path(path) = ctx.value(layer, pr) else { return Err(bad(cmd, "that property is not a path")) };
if path.vertices.is_empty() {
return Err(bad(cmd, "the path has no vertices"));
@@ -147,8 +156,16 @@ fn pin_targets(s: &Session, p: &Value, cmd: &str) -> Result<Option<(ItemId, Vec<
};
let cid = pins.first().map(|x| x.0).ok_or_else(|| bad(cmd, "no puppet pins"))?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let ctx =
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: s.time(),
expr: s.expr.as_deref(),
footage: Some(s.footage.as_ref()),
property_overrides: &[],
media_overrides: &[],
};
let mut out = vec![];
for (_, lid, uid) in pins {
let Some(layer) = comp.layer(lid) else { continue };
+10 -2
View File
@@ -1285,8 +1285,16 @@ fn convert_one_expr(s: &mut Session, cid: ItemId, lid: LayerId, uid: Uid) -> Res
let fd = comp.frame_duration();
let mut t = layer.in_point;
while t < layer.out_point {
let ctx =
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp: &comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp: &comp,
time: t,
expr: s.expr.as_deref(),
footage: Some(s.footage.as_ref()),
property_overrides: &[],
media_overrides: &[],
};
keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr)));
t += fd;
}
+2 -1
View File
@@ -38,7 +38,8 @@ pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick)
let fx = layer.effects()?;
let index = fx.groups().position(|g| g.uid == fx_uid)?;
let g = fx.groups().nth(index)?;
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let ctx =
EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] };
let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default());
r.expr = s.expr.as_deref();
r.cache = Some(&s.layer_cache);
+1 -1
View File
@@ -619,7 +619,7 @@ fn set_output_module(s: &mut Session, p: &Value) -> Result<Value> {
let (w, h) = item.settings.output_size(comp);
let mut crop_only = om.clone();
crop_only.resize.enabled = false;
let (cw, ch) = crop_only.frame_size(w, h, item.settings.resolution.clamp(0.01, 4.0));
let (cw, ch) = crop_only.frame_size(w, h, item.settings.effective_scale(comp));
om.resize.width = ((om.resize.height as f64 * cw as f64 / ch as f64).round() as u32).clamp(1, 30_000);
}
let post = match str_p(p, "postRenderAction") {
+10 -1
View File
@@ -48,7 +48,16 @@ impl 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()) }
EvalCtx {
project: proj,
comp_id: cid,
comp,
time: self.time,
expr: self.expr.as_deref(),
footage: Some(self.footage.as_ref()),
property_overrides: &[],
media_overrides: &[],
}
}
}
+5 -1
View File
@@ -142,6 +142,7 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result<Value> {
}
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or_else(|| bad(C, "no layer"))?;
let auid = animator_uid(s, &layer, p, C)?;
let mut index = 0;
s.edit("Add Text Animator Property", None, |proj, _| {
let mut next = proj.next_id;
let mut ids = Ids(&mut next);
@@ -151,10 +152,13 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result<Value> {
for k in &kinds {
add_props(&mut ids, anim, k, three);
}
// The requested property's 1-based position in the Properties group. A companion (such as
// Tracking Type) is added before it, so the first new child is not always the one asked for.
index = kinds.first().and_then(|k| anim.sub("properties")?.children.iter().position(|n| n.match_id() == k.as_str())).map_or(0, |i| i + 1);
proj.next_id = next;
Ok(())
})?;
Ok(json!({"animator": auid}))
Ok(json!({"animator": auid, "index": index}))
}
pub(crate) fn add_selector(s: &mut Session, p: &Value) -> Result<Value> {
+4 -2
View File
@@ -413,7 +413,8 @@ fn get_3d(s: &mut Session, p: &Value) -> Result<Value> {
let (w, h) = (comp.width as f64, comp.height as f64);
let vs = s.state.views3d.get(&cid).cloned().unwrap_or_default();
let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO);
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let ctx =
EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] };
let cam = vs.override_camera(w, h).unwrap_or_else(|| camera::active_camera(&ctx));
let f = cam.forward();
let lights: Vec<Value> = comp
@@ -555,7 +556,8 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result<Value> {
/// through each layer's world matrix; cameras and lights contribute their position).
fn layer_points(s: &Session, cid: ItemId, ids: &[LayerId], t: Tick) -> Vec<Vec3> {
let Some(comp) = s.project.comp(cid) else { return vec![] };
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let ctx =
EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] };
let mut out = vec![];
for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) {
let m = ctx.world_matrix(l);
@@ -81,6 +81,8 @@ fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage {
nf.codec = probed.codec;
nf.has_video = probed.has_video;
nf.has_audio = probed.has_audio;
// The markers the file carries now (a WAV's cue points), for Update Markers From Source.
nf.markers = probed.markers;
if matches!(f.kind, FootageKind::Video | FootageKind::Audio) {
nf.duration = probed.duration;
// Interpret Footage ▸ Conform to frame rate stays; the file's own rate is refreshed.
+10 -1
View File
@@ -190,7 +190,16 @@ fn rendered_matte_of_pick(s: &mut Session, x: f64, y: f64, scale: f64) -> (Strin
s.execute_checked("prop.set", json!({"path": format!("effects/#{fx}/matteOnly"), "value": true})).unwrap();
let (cid, comp) = (s.active_comp_id().unwrap(), s.active_comp().unwrap());
let layer = &comp.layers[0];
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: Tick::ZERO,
expr: None,
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let buf = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }).content_buf(&ctx, layer).unwrap();
let (px, py) = buf.to_px([x, y]);
let m = buf.img.get(px.floor() as i64, py.floor() as i64)[0];
+122
View File
@@ -367,6 +367,128 @@ fn media_replacement_swaps_footage_per_instance() {
assert!(center(&s, main)[0] > 0.99);
}
/// #1747: a collapsed precomp with an Essential Property override must keep drawing its nested
/// layers past the precomp's frame instead of falling back to a flattened, clipped frame.
#[test]
fn collapsed_precomp_applies_property_overrides_without_clipping() {
struct AddY;
impl effectcraft_render::ExprHost for AddY {
fn eval(
&self,
_: &effectcraft_render::EvalCtx,
_: &effectcraft_project::Layer,
_: &effectcraft_project::Property,
value: &effectcraft_keyframe::Value,
) -> Result<effectcraft_keyframe::Value, String> {
Ok(match value {
effectcraft_keyframe::Value::Vec3([x, y, z]) => effectcraft_keyframe::Value::Vec3([*x, y + 100.0, *z]),
_ => value.clone(),
})
}
}
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Inner", "width": 400, "height": 300, "frameRate": 30, "duration": 2})).unwrap();
let inner = s.active_comp_id().unwrap();
let box_ = s.execute("layer.newSolid", json!({"name": "Box", "color": "#ffffff", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": box_, "path": "transform/position", "value": [200, 50, 0]})).unwrap();
// The source property's expression still runs after the instance override.
s.execute("prop.setExpression", json!({"layer": box_, "path": "transform/position", "expression": "value + [0, 100, 0]"})).unwrap();
let position = s.execute("essential.addProperty", json!({"layer": box_, "path": "transform/position"})).unwrap()["controls"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 600, "frameRate": 30, "duration": 2})).unwrap();
let outer = s.active_comp_id().unwrap();
let instance = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
let other = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [instance, other], "switch": "collapse", "value": true})).unwrap();
s.expr = Some(std::sync::Arc::new(AddY));
s.execute("layer.select", json!({"layers": [instance]})).unwrap();
s.execute("essential.set", json!({"layer": instance, "control": position, "value": [200, -120, 0]})).unwrap();
s.execute("layer.select", json!({"layers": [other]})).unwrap();
s.execute("essential.set", json!({"layer": other, "control": position, "value": [200, 220, 0]})).unwrap();
let img = s.render(outer, Tick::ZERO, RenderOpts::default());
// The expression adds 100 px: the overrides give inner y = -20 and 320. The first maps to
// outer y = 130 (the precomp is centred at 300) and its 100 px box spans 80..179, 70 px above
// the instance's frame (150..449), where a flattened precomp would stop. The second maps to
// 470 and spans 420..519, past the bottom.
assert!(img.get(200, 100)[3] > 0.99, "the override draws above the nested frame: {:?}", img.get(200, 100));
assert!(img.get(200, 90)[3] > 0.99, "the box reaches the top of its overridden bounds");
assert!(img.get(200, 500)[3] > 0.99, "the other instance keeps its own override: {:?}", img.get(200, 500));
assert!(img.get(200, 200)[3] < 0.01, "nothing is left between the instances");
}
/// #1747: two collapsed instances of the same comp keep their own source-affecting property
/// overrides in the layer cache (the nested layers' ids and static values are identical).
#[test]
fn collapsed_instances_keep_distinct_property_overrides_in_the_cache() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
let card = s.active_comp_id().unwrap();
let solid = s.execute("layer.newSolid", json!({"name": "BG", "color": "#ffffff", "width": 64, "height": 64})).unwrap()["layer"].as_u64().unwrap();
s.execute("effect.apply", json!({"layer": solid, "effect": "Fill"})).unwrap();
let color = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/color"})).unwrap()["controls"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name": "Main", "width": 200, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
let main = s.active_comp_id().unwrap();
let a = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "collapse", "value": true})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [60, 50, 0]})).unwrap();
s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [140, 50, 0]})).unwrap();
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("essential.set", json!({"layer": a, "control": color, "value": [1.0, 0.0, 0.0, 1.0]})).unwrap();
s.execute("layer.select", json!({"layers": [b]})).unwrap();
s.execute("essential.set", json!({"layer": b, "control": color, "value": [0.0, 0.0, 1.0, 1.0]})).unwrap();
let img = s.render(main, Tick::ZERO, RenderOpts::default());
let left = img.get(60, 50);
let right = img.get(140, 50);
assert!(left[0] > 0.99 && left[2] < 0.01, "left instance is red: {left:?}");
assert!(right[2] > 0.99 && right[0] < 0.01, "right instance is blue: {right:?}");
}
/// #1747 with Media Replacement: the replacement footage and the position override both apply
/// while the precomp stays collapsed, so the box is blue and still draws above the frame.
#[test]
fn collapsed_precomp_applies_media_replacement_without_clipping() {
struct Colors;
impl crate::render::FootageSource for Colors {
fn frame(&self, _: ItemId, f: &effectcraft_project::Footage, _: Tick) -> Option<std::sync::Arc<effectcraft_raster::Image>> {
let c = if f.path.contains("blue") { [0.0, 0.0, 1.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] };
Some(std::sync::Arc::new(effectcraft_raster::Image::filled(100, 100, c)))
}
}
let mut s = Session { footage: std::sync::Arc::new(Colors), ..Default::default() };
let footage = |path: &str| effectcraft_project::Footage {
path: path.into(),
kind: effectcraft_project::FootageKind::Still,
width: 100,
height: 100,
has_video: true,
..Default::default()
};
let (red, blue) = s
.edit("test", None, |proj, _| {
let red = proj.add_item("red.png", Default::default(), None, effectcraft_project::ItemKind::Footage(footage("red.png")));
let blue = proj.add_item("blue.png", Default::default(), None, effectcraft_project::ItemKind::Footage(footage("blue.png")));
Ok((red, blue))
})
.unwrap();
s.execute("comp.new", json!({"name": "Inner", "width": 400, "height": 300, "frameRate": 30, "duration": 2})).unwrap();
let inner = s.active_comp_id().unwrap();
let clip = s.execute("layer.addItem", json!({"item": red.0})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": clip, "path": "transform/position", "value": [200, 50, 0]})).unwrap();
let media = s.execute("essential.addMedia", json!({"layer": clip})).unwrap()["control"].as_u64().unwrap();
let position = s.execute("essential.addProperty", json!({"layer": clip, "path": "transform/position"})).unwrap()["controls"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 600, "frameRate": 30, "duration": 2})).unwrap();
let outer = s.active_comp_id().unwrap();
let instance = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [instance], "switch": "collapse", "value": true})).unwrap();
s.execute("layer.select", json!({"layers": [instance]})).unwrap();
s.execute("essential.set", json!({"layer": instance, "control": position, "value": [200, -20, 0]})).unwrap();
s.execute("essential.set", json!({"layer": instance, "control": media, "item": blue.0})).unwrap();
let img = s.render(outer, Tick::ZERO, RenderOpts::default());
let above = img.get(200, 100);
assert!(above[2] > 0.99 && above[0] < 0.01, "the replacement media is blue above the frame: {above:?}");
assert!(img.get(200, 200)[3] < 0.01, "the collapsed box is not clipped at the nested frame");
}
/// Responsive Design — Time: stretching a precomp keeps its protected regions at their
/// original speed; the unprotected parts absorb the stretch.
#[test]
+31
View File
@@ -2,6 +2,7 @@
use effectcraft_keyframe::Value as KV;
use effectcraft_project::essential::{self, ControlType};
use effectcraft_time::Tick;
use serde_json::json;
use crate::{Event, Session};
@@ -102,3 +103,33 @@ fn open_in_essential_graphics_sets_the_primary_comp() {
);
assert_eq!(s.execute("essential.list", json!({})).unwrap()["name"], "A");
}
/// #1747: through a collapsed precomp, a Text override and a Font override of the same Source
/// Text both apply: the Font merges into the overridden text, as `essential::with_overrides`
/// does on the flattened path, instead of one entry replacing the other.
#[test]
fn collapsed_instance_merges_text_and_font_overrides() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Title", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap();
let title = s.active_comp_id().unwrap();
let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap();
let body = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText"})).unwrap()["controls"][0].as_u64().unwrap();
let font = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font"})).unwrap()["controls"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap();
let main = s.active_comp_id().unwrap();
let inst = s.execute("layer.addItem", json!({"item": title.0})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [inst], "switch": "collapse", "value": true})).unwrap();
s.execute("essential.set", json!({"layer": inst, "control": body, "value": "Bye"})).unwrap();
s.execute("essential.set", json!({"layer": inst, "control": font, "value": {"font": "Serif Test", "size": 40}})).unwrap();
// Evaluate the nested text in the collapsed instance's context, as the renderer does.
let r = effectcraft_render::Renderer::new(&s.project, &*s.footage, crate::render::RenderOpts::default());
let ctx = effectcraft_render::EvalCtx::new(&s.project, main, s.project.comp(main).unwrap(), Tick::ZERO);
let layer = ctx.comp.layer(effectcraft_project::LayerId(inst)).unwrap();
let collapsed = r.collapsed(&ctx, layer).expect("the instance collapses");
let (_, nctx) = r.collapse_into(&ctx, layer, &collapsed, 1.0).expect("a nested context");
let src = nctx.comp.layer(effectcraft_project::LayerId(text)).unwrap();
match nctx.value(src, src.props.prop("text/sourceText").unwrap()) {
KV::Text(d) => assert_eq!((d.text.as_str(), d.font.as_str(), d.size), ("Bye", "Serif Test", 40.0)),
o => panic!("{o:?}"),
}
}
+183 -2
View File
@@ -7,7 +7,7 @@ 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::three_d::camera::{is_two_node, layer_frame};
use effectcraft_render::{EvalCtx, FootageSource, RenderOpts};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
@@ -40,7 +40,7 @@ fn close3(a: [f64; 3], b: [f64; 3], tol: f64) -> bool {
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 ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &[], media_overrides: &[] };
let (e, f, _) = layer_frame(&ctx, comp.layer(LayerId(id)).unwrap());
(e, f)
}
@@ -217,8 +217,189 @@ fn control_light_with_camera_follows_and_releases() {
assert!(s.execute("light.controlWithCamera", json!({"layers": [cam]})).is_err());
}
/// Parenting keeps a camera where it is (After Effects' Parent & Link): Position and Point of
/// Interest are re-expressed in the null's space, so the frame does not move.
#[test]
fn parented_camera_keeps_its_frame_under_an_offset_null() {
let mut s = session();
let cam = camera(&mut s);
let (eye0, f0) = frame(&s, cam);
let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 0]})).unwrap();
s.execute("layer.setParent", json!({"layers": [cam], "parent": nul})).unwrap();
let (eye1, f1) = frame(&s, cam);
assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?}");
// The null's origin is Position minus its default anchor (50, 50): (50, 10) in the comp.
let c = layer_in(&s, cid(&s), cam);
assert!(close3(v3(&c, "transform/position"), [270.0, 170.0, -800.0], 1e-6), "{:?}", v3(&c, "transform/position"));
assert!(close3(v3(&c, "transform/poi"), [270.0, 170.0, 0.0], 1e-6), "{:?}", v3(&c, "transform/poi"));
// Moving the null carries the camera and keeps its aim.
s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [140, 90, 0]})).unwrap();
let (eye2, f2) = frame(&s, cam);
assert!((eye2 - (eye0 + vec3(40.0, 30.0, 0.0))).length() < 1e-6, "{eye2:?}");
assert!((f2 - f0).length() < 1e-6, "{f2:?}");
}
/// World (eye, forward, down) of a layer in the active comp at time `t`.
fn frame_at(s: &Session, id: u64, t: Tick) -> (Vec3, Vec3, Vec3) {
let c = cid(s);
let comp = s.project.comp(c).unwrap();
let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: t, expr: None, footage: None, property_overrides: &[], media_overrides: &[] };
layer_frame(&ctx, comp.layer(LayerId(id)).unwrap())
}
/// Whether two frames agree on eye, forward and down.
fn frames_match(a: (Vec3, Vec3, Vec3), b: (Vec3, Vec3, Vec3)) -> bool {
(a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-6 && (a.2 - b.2).length() < 1e-6
}
/// A 3D null off-centre in the comp, with an Orientation and a Z rotation.
fn rotated_null(s: &mut Session, orientation: [f64; 3], turn: f64) -> u64 {
let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [nul], "switch": "threeD", "value": true})).unwrap();
s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 40]})).unwrap();
s.execute("prop.set", json!({"layer": nul, "path": "transform/orientation", "value": orientation})).unwrap();
s.execute("prop.set", json!({"layer": nul, "path": "transform/rotation", "value": turn})).unwrap();
nul
}
/// A two-node camera under a rotated null keeps its eye, aim and roll: its Orientation is
/// re-expressed against the new aim, so Up does not drift.
#[test]
fn two_node_camera_under_rotated_null_keeps_its_roll() {
let mut s = session();
let cam = camera(&mut s);
s.execute("prop.set", json!({"layer": cam, "path": "transform/rotation", "value": 25})).unwrap();
assert!(is_two_node(&layer_in(&s, cid(&s), cam)));
let before = frame_at(&s, cam, Tick::ZERO);
let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0);
s.execute("layer.setParent", json!({"layers": [cam], "parent": nul})).unwrap();
let after = frame_at(&s, cam, Tick::ZERO);
assert!(frames_match(before, after), "{before:?} {after:?}");
}
#[test]
fn parented_point_light_keeps_its_direction_under_a_rotated_null() {
let mut s = session();
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": light, "path": "transform/orientation", "value": [30, 0, 0]})).unwrap();
let (eye0, f0) = frame(&s, light);
let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 0]})).unwrap();
s.execute("prop.set", json!({"layer": nul, "path": "transform/rotation", "value": 30})).unwrap();
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
let (eye1, f1) = frame(&s, light);
assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?} {f0:?} {f1:?}");
}
/// Keyed Orientation under a Z-only turn is shifted exactly, so the light keeps its direction at
/// every time, between the keys too.
#[test]
fn keyed_orientation_under_a_z_turn_keeps_the_direction_at_every_time() {
let mut s = session();
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] {
s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap();
}
let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64);
let before = times.map(|t| frame_at(&s, light, t));
let nul = rotated_null(&mut s, [0.0, 0.0, 0.0], 30.0);
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
for (t, b) in times.into_iter().zip(before) {
assert!(frames_match(b, frame_at(&s, light, t)), "a Z-only turn keeps the light's direction");
}
}
/// Under a tilted null the keys alone cannot follow (Euler angles are blended between them), so keys are
/// added between them: the light keeps its direction within a small angle at every time.
#[test]
fn keyed_orientation_under_a_tilted_null_follows_between_the_keys() {
{
let mut s = session();
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.5, json!([50.0, 118.0, 74.0]))] {
s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap();
}
let times = [0.0, 0.2, 0.5, 0.9, 1.3, 1.5].map(Tick::from_seconds_f64);
let before = times.map(|t| frame_at(&s, light, t));
let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0);
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
for (t, b) in times.into_iter().zip(before) {
let a = frame_at(&s, light, t);
// 0.05 degrees is about 9e-4 of a radian on a unit vector.
assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 2e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1);
}
assert!(layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len() > 2, "keys were added between the two");
}
}
/// A one-node light parented under a mirrored null (Z scale -100%) keeps the Position and
/// Orientation it was authored with: it has no Scale to carry the mirror, so none of it is
/// compensated rather than reversing its frame (a half-compensated light would point backwards).
#[test]
fn one_node_light_under_a_mirrored_null_keeps_its_authored_values() {
let mut s = session();
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": light, "path": "transform/orientation", "value": [30, 0, 0]})).unwrap();
let before = layer_in(&s, cid(&s), light);
let nul = rotated_null(&mut s, [0.0, 0.0, 0.0], 0.0);
s.execute("prop.set", json!({"layer": nul, "path": "transform/scale", "value": [100, 100, -100]})).unwrap();
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
let after = layer_in(&s, cid(&s), light);
assert_eq!(after.parent, Some(LayerId(nul)));
assert_eq!(v3(&after, "transform/position"), v3(&before, "transform/position"));
assert_eq!(v3(&after, "transform/orientation"), v3(&before, "transform/orientation"));
}
/// The cap on keys added by baking is for the whole Orientation property, not for each segment:
/// many strongly eased segments under a tilted parent stay within it.
#[test]
fn baked_orientation_keys_are_capped_across_the_whole_property() {
let mut s = session();
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
let count = 120;
for i in 0..count {
let swing = if i % 2 == 0 { json!([0.0, 0.0, 0.0]) } else { json!([170.0, 60.0, 120.0]) };
s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": i as f64 * 0.05, "value": swing})).unwrap();
}
let uid = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().uid;
let keys: Vec<_> = (0..count).map(|i| json!({"layer": light, "prop": uid, "time": i as f64 * 0.05})).collect();
s.execute("keys.select", json!({"keys": keys})).unwrap();
s.execute("keys.easyEase", json!({})).unwrap();
let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0);
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
let n = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len();
assert!(n <= 4096, "{n} keys: the cap is for the property, not per segment");
// the segments the cap left unchecked keep their authored easing instead of turning into straight blends
let orientation = layer_in(&s, cid(&s), light);
let keys = &orientation.props.prop("transform/orientation").unwrap().keys;
let last = keys.iter().rev().nth(1).expect("the penultimate key");
assert_ne!(last.out_interp, effectcraft_keyframe::Interp::Linear, "the last segment keeps its authored easing");
}
// ---------------------------------------------------------------- from 3D model
/// Easy Ease agrees with a straight blend at the midpoint, but not at the quarter points.
#[test]
fn eased_orientation_under_x_parent_keeps_motion_between_keys() {
let mut s = session();
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
for (time, value) in [(0.0, json!([0.0, 0.0, 0.0])), (1.0, json!([90.0, 0.0, 0.0]))] {
s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap();
}
let uid = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().uid;
s.execute("keys.select", json!({"keys": [{"layer": light, "prop": uid, "time": 0.0}, {"layer": light, "prop": uid, "time": 1.0}]})).unwrap();
s.execute("keys.easyEase", json!({})).unwrap();
let times: Vec<_> = (0..=100).map(|i| Tick::from_seconds_f64(i as f64 / 100.0)).collect();
let before: Vec<_> = times.iter().map(|t| frame_at(&s, light, *t)).collect();
let parent = rotated_null(&mut s, [30.0, 0.0, 0.0], 0.0);
s.execute("layer.setParent", json!({"layers": [light], "parent": parent})).unwrap();
for (t, b) in times.into_iter().zip(before) {
let a = frame_at(&s, light, t);
assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1);
}
}
struct DiskModels;
impl FootageSource for DiskModels {
@@ -410,12 +410,12 @@ fn parenting_static_orientation_is_exact_just_off_gimbal_lock() {
fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_boundary() {
for animated in [true, false] {
let (mut s, p, c) = fixture(true, true);
// A non-uniform parent has no uniform pose to re-express, animated or not.
set(&mut s, p, "transform/scale", json!([120.0, 100.0, 80.0]));
if animated {
for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] {
command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/orientation","time":time,"value":value}));
}
} else {
set(&mut s, p, "transform/scale", json!([120.0, 100.0, 80.0]));
}
let layer = s.active_comp().unwrap().layer(LayerId(c)).unwrap();
let orientation = layer.props.prop("transform/orientation").unwrap().clone();
@@ -430,6 +430,27 @@ fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_bound
}
}
/// Keyed Orientation under a uniform parent is re-expressed at every key, so the child's world pose
/// and pixels stay put at every time (the parent is Z-only, so the paths between keys stay exact too).
#[test]
fn parenting_uniform_parent_re_expresses_keyed_orientation() {
for factor in [120.0, -120.0] {
let (mut s, p, c) = fixture(true, false);
set(&mut s, p, "transform/rotation", json!(42.0));
set(&mut s, p, "transform/scale", json!([factor, factor, factor]));
for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] {
command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/orientation","time":time,"value":value}));
}
let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64);
let before = times.map(|t| world(&s, c, t));
let images = times.map(|t| cpu(&s, t));
parent(&mut s, c, Some(p), true);
let error = times.into_iter().zip(before).map(|(t, b)| displacement(b, world(&s, c, t))).fold(0.0, f64::max);
let pixels = times.into_iter().zip(&images).map(|(t, i)| diff(i, &cpu(&s, t))).fold(0.0, f32::max);
assert!(error < 1e-8 && pixels < 2e-5, "keyed Orientation under a uniform parent (factor {factor}): corners {error}, pixels {pixels}");
}
}
/// Position's temporal speed is measured along its spatial path, in parent-space units.
#[test]
fn parenting_uniform_parent_preserves_eased_position_at_quarter_times_and_undo() {
+3 -3
View File
@@ -240,7 +240,7 @@ impl<'a> Cx<'a> {
// compositors differ slightly, so a per-frame choice made renders differ run to run.
let backend = if self.project.settings.gpu_acceleration { Backend::Gpu } else { Backend::Cpu };
let opts = RenderOpts {
scale: s.resolution.clamp(0.01, 4.0),
scale: self.comp().map(|c| s.effective_scale(c)).unwrap_or_else(|| s.resolution.clamp(0.01, 4.0)),
motion_blur: s.motion_blur_override() != SwitchOverride::OffForAll,
guides: s.guide_layers == CurrentOrOff::Current,
draft: s.quality == RenderQuality::Draft,
@@ -389,7 +389,7 @@ impl<'a> Cx<'a> {
return None;
}
let om = self.job.output;
let scale = self.job.settings.resolution.clamp(0.01, 4.0);
let scale = self.job.settings.effective_scale(comp);
let size = |n: u32| ((n as f64 * scale).round() as u32).max(1);
if om.resize.enabled || (size(comp.width), size(comp.height)) != (w, h) || om.crop.edges(w, h, scale) != (0, 0, 0, 0) {
return None;
@@ -435,7 +435,7 @@ impl<'a> Cx<'a> {
/// Output Module ▸ Crop, then Resize.
fn crop_resize(&self, img: Image) -> Image {
let om = self.job.output;
let scale = self.job.settings.resolution.clamp(0.01, 4.0);
let scale = self.comp().map(|c| self.job.settings.effective_scale(c)).unwrap_or_else(|| self.job.settings.resolution.clamp(0.01, 4.0));
let (t, l, b, r) = om.crop.edges(img.width, img.height, scale);
let img = if (t, l, b, r) == (0, 0, 0, 0) { img } else { crop(&img, t, l, b, r) };
if !om.resize.enabled {
+20 -5
View File
@@ -10,7 +10,7 @@
use effectcraft_geom::{Mat3, Mat4};
use effectcraft_keyframe::Value;
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup, Property};
use effectcraft_render::{EvalCtx, source_size};
use effectcraft_render::EvalCtx;
use effectcraft_time::Tick;
/// A name-or-index argument (`layer("A")`, `layer(2)`, `effect(1)`).
@@ -299,7 +299,17 @@ impl<'a> Resolver<'a> {
}
fn ctx_at(&self, c: u64, t: f64) -> Option<EvalCtx<'a>> {
let comp = self.comp(c)?;
Some(EvalCtx { project: self.ctx.project, comp_id: ItemId(c), comp, time: Tick::from_seconds_f64(t), expr: self.ctx.expr, footage: self.ctx.footage })
let same_comp = c == self.ctx.comp_id.0;
Some(EvalCtx {
project: self.ctx.project,
comp_id: ItemId(c),
comp,
time: Tick::from_seconds_f64(t),
expr: self.ctx.expr,
footage: self.ctx.footage,
property_overrides: if same_comp { self.ctx.property_overrides } else { &[] },
media_overrides: if same_comp { self.ctx.media_overrides } else { &[] },
})
}
fn prop(&self, c: u64, l: u64, path: &str) -> Option<(&'a Layer, &'a Property)> {
let layer = self.layer(c, l)?;
@@ -372,7 +382,7 @@ impl<'a> Resolver<'a> {
Req::LayerInfo { comp, layer } => {
let c = self.comp(*comp)?;
let l = self.layer(*comp, *layer)?;
let (mut w, mut h) = source_size(project, l);
let (mut w, mut h) = self.ctx_at(*comp, self.ctx.time.seconds())?.source_size(l);
if w == 0 || h == 0 {
(w, h) = (c.width, c.height);
}
@@ -467,7 +477,7 @@ impl<'a> Resolver<'a> {
}
}
_ => {
let (w, h) = source_size(project, l);
let (w, h) = ctx.source_size(l);
[0.0, 0.0, w as f64, h as f64]
}
};
@@ -501,7 +511,12 @@ impl<'a> Resolver<'a> {
Req::SourceInfo { comp, layer } => {
let c = self.comp(*comp)?;
let l = self.layer(*comp, *layer)?;
let it = project.item(l.source.item()?)?;
let ctx = self.ctx_at(*comp, self.ctx.time.seconds())?;
let item = match &l.source {
LayerSource::Footage { item } => ctx.media_item(l, *item),
_ => l.source.item()?,
};
let it = project.item(item)?;
let (kind, aspect, frame, duration) = match &it.kind {
ItemKind::Comp(nc) => ("comp", nc.pixel_aspect, nc.frame_duration().seconds(), nc.duration.seconds()),
ItemKind::Footage(f) => ("footage", f.pixel_aspect, f.frame_rate.frame_duration().seconds(), f.duration.seconds()),
+12 -2
View File
@@ -17,7 +17,8 @@
//! `sourceRectAtTime`), property methods (`valueAtTime`, `velocityAtTime`, `speedAtTime`, `key`,
//! `nearestKey`, `numKeys`, `wiggle`, `temporalWiggle`, `smooth`, `loopIn/Out[Duration]`) and the
//! global helpers (`linear`/`ease`/`easeIn`/`easeOut`, `random`/`gaussRandom`/`seedRandom`/
//! `noise`, vector maths, `clamp`, `lookAt`, time and colour conversions, `posterizeTime`).
//! `noise`, vector maths, `clamp`, `lookAt`, time and colour conversions, `posterizeTime`),
//! plus `bezier`, a CSS-style cubic-Bezier easing helper that After Effects does not have.
//! * **Determinism**: `wiggle` and `random` are seeded by layer index, property uid (and time /
//! `seedRandom`), so frames render identically on every thread and platform.
//! * **Runtime**: one boa `Context` per thread with compiled scripts cached by text. Scripts read
@@ -162,7 +163,16 @@ fn evaluate_out(
return Err("Error: expressions reference each other too deeply (circular reference?)".into());
}
budget_left()?;
let ctx = EvalCtx { project: ctx.project, comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, expr: Some(host), footage: ctx.footage };
let ctx = EvalCtx {
project: ctx.project,
comp_id: ctx.comp_id,
comp: ctx.comp,
time: ctx.time,
expr: Some(host),
footage: ctx.footage,
property_overrides: ctx.property_overrides,
media_overrides: ctx.media_overrides,
};
let comp = ctx.comp_id.0;
let path = layer.props.path_of(prop.uid).unwrap_or_else(|| format!("@{}", prop.uid));
let resolver = Resolver { ctx, own: Own { comp, layer, prop, path: path.clone() } };
+54
View File
@@ -207,6 +207,60 @@ function ease() { return __interp(__ease, arguments); }
function easeIn() { return __interp(__easeIn, arguments); }
function easeOut() { return __interp(__easeOut, arguments); }
// Cubic-Bezier easing (the CSS cubic-bezier() shape): [x1, y1, x2, y2], with x1 / x2 in 0..1
// and y1 / y2 free (overshoot included). Solves x(u) = x by Newton–Raphson, then by bisection
// when Newton leaves 0..1 or the derivative vanishes, and returns y(u). A curve that is not four
// finite numbers is linear, never an error.
function __cubicBezier(curve) {
var x1, y1, x2, y2;
if (Array.isArray(curve) && curve.length === 4) {
x1 = curve[0]; y1 = curve[1]; x2 = curve[2]; y2 = curve[3];
}
if (typeof x1 !== 'number' || typeof y1 !== 'number' || typeof x2 !== 'number' || typeof y2 !== 'number' ||
!isFinite(x1) || !isFinite(y1) || !isFinite(x2) || !isFinite(y2)) {
return __lin;
}
x1 = Math.max(0, Math.min(1, x1));
x2 = Math.max(0, Math.min(1, x2));
function bx(u) { var v = 1 - u; return 3 * v * v * u * x1 + 3 * v * u * u * x2 + u * u * u; }
function dbx(u) { var v = 1 - u; return 3 * v * v * x1 + 6 * v * u * (x2 - x1) + 3 * u * u * (1 - x2); }
function by(u) { var v = 1 - u; return 3 * v * v * u * y1 + 3 * v * u * u * y2 + u * u * u; }
function dby(u) { var v = 1 - u; return 3 * v * v * y1 + 6 * v * u * (y2 - y1) + 3 * u * u * (1 - y2); }
return function (x) {
if (x <= 0) return 0;
if (x >= 1) return 1;
var u = x;
for (var i = 0; i < 8; i++) {
var f = bx(u) - x;
var d = dbx(u);
if (Math.abs(d) < 1e-12) break;
// Stop when the *value* is accurate: near the endpoints dy/dx is steep, so a residual in x
// alone can still mean a visible error in y (f * dy/dx estimates it).
if (Math.abs(f * dby(u) / d) < 1e-9) return by(u);
var n = u - f / d;
if (!(n >= 0 && n <= 1)) break;
u = n;
}
var lo = 0, hi = 1;
for (var j = 0; j < 60; j++) {
var mid = (lo + hi) / 2;
if (bx(mid) < x) lo = mid; else hi = mid;
}
return by((lo + hi) / 2);
};
}
// bezier(t, tMin, tMax, value1, value2, curve), or bezier(t, value1, value2, curve): the curve is
// the sixth / fourth argument. Read it by position, not from the end: when it is missing or
// unusable, the interpolation falls back to linear without consuming the last value.
function bezier() {
var a = arguments, n = a.length;
if (n >= 6) return __interp(__cubicBezier(a[5]), [a[0], a[1], a[2], a[3], a[4]]);
if (n === 5) return __interp(__lin, [a[0], a[1], a[2], a[3], a[4]]);
if (n === 4) return __interp(__cubicBezier(a[3]), [a[0], a[1], a[2]]);
return __interp(__lin, [a[0], a[1], a[2]]);
}
// ---------------------------------------------------------------------------------------------
// Random numbers and noise.
+124 -2
View File
@@ -314,8 +314,78 @@ fn interpolation_with_arrays() {
assert_eq!(f.pos("ease(time, 0, 1, [0, 0], [100, 50])", 1.0), [100.0, 50.0, 0.0]);
}
// ---- object model -----------------------------------------------------------------------------
#[test]
fn cubic_bezier_curves() {
let f = fx();
// ease(t, …) is cubic-bezier(1/3, 0, 2/3, 1): x(u) = u and y(u) = 3u² - 2u³.
assert_close!(f.num("bezier(0.25, 0, 1, 0, 100, [0.3333333333333333, 0, 0.6666666666666666, 1])", 0.0), f.num("ease(0.25, 0, 1, 0, 100)", 0.0));
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.3333333333333333, 0, 0.6666666666666666, 1])", 0.0), 50.0);
// Symmetric ease-in-out: slower at both ends, faster in the middle.
let early = f.num("bezier(0.25, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0);
let late = f.num("bezier(0.75, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0);
assert!(early < 25.0 && late > 75.0, "ease-in-out: {early}, {late}");
// Control points on the diagonal are linear.
assert_close!(f.num("bezier(0.3, 0, 1, 0, 100, [0, 0, 1, 1])", 0.0), 30.0);
// t is clamped to tMin..tMax; tMin == tMax behaves like the other helpers.
assert_close!(f.num("bezier(-1, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 0.0);
assert_close!(f.num("bezier(2, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 100.0);
assert_close!(f.num("bezier(5, 2, 2, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 100.0);
}
#[test]
fn cubic_bezier_short_form_arrays_and_fallbacks() {
let f = fx();
// Short form: no tMin / tMax.
assert_close!(f.num("bezier(0.25, 10, 20, [0, 0, 1, 1])", 0.0), 12.5);
assert_eq!(f.pos("bezier(time, 0, 1, [0, 0], [100, 50], [0.42, 0, 0.58, 1])", 1.0), [100.0, 50.0, 0.0]);
// y outside 0..1 overshoots, as CSS allows.
let over = f.num("bezier(0.5, 0, 1, 0, 100, [0.5, 2, 0.5, 2])", 0.0);
assert!(over > 100.0, "overshoot: {over}");
// A curve that is not four finite numbers falls back to linear instead of throwing.
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [1, 0])", 0.0), 50.0);
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, 'ease')", 0.0), 50.0);
// A missing or unusable curve must not consume the last value: read the curve by position.
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100)", 0.0), 50.0);
assert_close!(f.num("bezier(0.5, 0, 100)", 0.0), 50.0);
assert_close!(f.num("bezier(0.5, 0, 100, [1, 0])", 0.0), 50.0);
let v = f.ev(f.shape, "contents/#1/contents/fill/color", "bezier(0.5, 0, 1, [0, 0, 0, 1], [1, 1, 1, 1])", 0.0).unwrap();
assert_eq!(v, Value::Color([0.5, 0.5, 0.5, 1.0]));
// Exactly four values are a curve; extra ones mean the curve is malformed, so linear.
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.42, 0, 0.58, 1, 0])", 0.0), 50.0);
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.42, 0, 0.58, 1, 2, 3])", 0.0), 50.0);
// x control points outside 0..1 are clamped, as CSS requires.
let v = f.num("bezier(0.5, 0, 1, 0, 100, [-1, 0, 2, 1])", 0.0);
assert!(v.is_finite(), "clamped curve: {v}");
}
#[test]
fn cubic_bezier_is_accurate_near_the_endpoints() {
/// The value the curve reaches at `x`, by bisection on its monotone x(u).
fn reference(x1: f64, y1: f64, x2: f64, y2: f64, x: f64) -> f64 {
let bx = |u: f64| 3.0 * (1.0 - u) * (1.0 - u) * u * x1 + 3.0 * (1.0 - u) * u * u * x2 + u * u * u;
let by = |u: f64| 3.0 * (1.0 - u) * (1.0 - u) * u * y1 + 3.0 * (1.0 - u) * u * u * y2 + u * u * u;
let (mut lo, mut hi) = (0.0f64, 1.0f64);
for _ in 0..100 {
let mid = 0.5 * (lo + hi);
if bx(mid) < x {
lo = mid;
} else {
hi = mid;
}
}
by(0.5 * (lo + hi))
}
// Steep curves: a residual in x is not a residual in y near the endpoints (dy/dx is in the
// thousands), so the solver stops on the estimated y error instead.
let f = fx();
for (c, x) in [([0.0, 1000.0, 1.0, 1.0], 1e-6), ([0.0, 100.0, 1.0, 0.0], 1e-9), ([0.8, -500.0, 1.0, 1.0], 0.999999)] {
let got = f.num(&format!("bezier({x}, 0, 1, [{}, {}, {}, {}])", c[0], c[1], c[2], c[3]), 0.0);
let want = reference(c[0], c[1], c[2], c[3], x);
assert!((got - want).abs() < 1e-6, "curve {c:?} at {x}: {got} vs {want}");
}
}
// ---- object model -----------------------------------------------------------------------------
#[test]
fn comp_attributes() {
let f = fx();
@@ -908,6 +978,49 @@ fn source_rect_at_time() {
assert!((s[0] + 31.0).abs() < 2.0 && (s[1] + 16.0).abs() < 2.0 && (s[2] - 62.0).abs() < 3.0, "{s:?}");
}
#[test]
fn source_dimensions_follow_media_replacement() {
let mut p = Project::default();
let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let footage = |p: &mut Project, name: &str, width, height| {
p.add_item(
name,
Label::SeaFoam,
None,
ItemKind::Footage(effectcraft_project::Footage {
path: name.into(),
kind: effectcraft_project::FootageKind::Still,
width,
height,
has_video: true,
..Default::default()
}),
)
};
let original = footage(&mut p, "original.png", 100, 80);
let replacement = footage(&mut p, "replacement.png", 30, 40);
let mut l = build::layer(&mut p, &comp, "Clip", LayerSource::Footage { item: original }, (100, 80), None);
l.props.prop_mut(ROT).unwrap().expr =
Some(Expression { text: "thisLayer.width + thisLayer.height + thisLayer.source.width + sourceRectAtTime().width".into(), enabled: true });
let lid = l.id;
p.comp_mut(cid).unwrap().layers.push(l);
let c = p.comp(cid).unwrap();
let l = c.layer(lid).unwrap();
let media = effectcraft_render::MediaOverride::new(lid, replacement);
let ctx = EvalCtx {
project: &p,
comp_id: cid,
comp: c,
time: Tick::ZERO,
expr: Some(&Expressions),
footage: None,
property_overrides: &[],
media_overrides: &[media],
};
assert_eq!(ctx.value(l, l.props.prop(ROT).unwrap()), Value::Scalar(130.0));
}
// ---- colour -----------------------------------------------------------------------------------
#[test]
@@ -1115,7 +1228,16 @@ fn text_expression_selector_values() {
let sel_of = |p: &Project| {
let c = p.comp(cid).unwrap();
let l = c.layer(lid).unwrap();
let ctx = EvalCtx { project: p, comp_id: cid, comp: c, time: Tick::ZERO, expr: Some(&Expressions), footage: None };
let ctx = EvalCtx {
project: p,
comp_id: cid,
comp: c,
time: Tick::ZERO,
expr: Some(&Expressions),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let doc = effectcraft_render::text::source_text(&ctx, l).unwrap();
let lay = effectcraft_text::layout_doc(&doc);
let anim = l.props.group("text/animators/#1").unwrap();
+2
View File
@@ -93,6 +93,8 @@ const SEEDS: &[&str] = &[
"linear(time, 0, 1, 0, 100)",
"ease(time, 0, 2, [0, 0], [100, 50])",
"easeIn(time, 0, 1, 0, 360) + easeOut(time, 1, 2, 0, 10)",
"bezier(time, 0, 1, 0, 100, [0.42, 0, 0.58, 1])",
"bezier(time, [0, 0], [100, 50], [0.25, 0.1, 0.25, 1])",
"clamp(value * 2, 0, 100)",
"length([0, 0], [3, 4]) + dot([1, 0], [0, 1]) + cross([1, 0, 0], [0, 1, 0])[2]",
"normalize([3, 4])[0] * degreesToRadians(180)",
+20 -2
View File
@@ -96,7 +96,16 @@ pub(crate) fn scene_project() -> (Project, ItemId) {
pub(crate) fn scene(p: &Project, cid: ItemId) -> Scene {
let r = Renderer::new(p, &NoFootage, RenderOpts::default());
let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let ctx = EvalCtx {
project: p,
comp_id: cid,
comp: p.comp(cid).unwrap(),
time: Tick::ZERO,
expr: None,
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect();
adv::scene_of(&r, &ctx, &run, (ctx.comp.width, ctx.comp.height))
}
@@ -277,7 +286,16 @@ pub(crate) fn off_share(a: &[[f32; 4]], b: &[[f32; 4]]) -> (f64, f32) {
}
pub(crate) fn run_of(p: &Project, cid: ItemId) -> (EvalCtx<'_>, Vec<&effectcraft_project::Layer>) {
let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let ctx = EvalCtx {
project: p,
comp_id: cid,
comp: p.comp(cid).unwrap(),
time: Tick::ZERO,
expr: None,
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect();
(ctx, run)
}
+11 -4
View File
@@ -89,7 +89,7 @@ fn draw_comp<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, canvas: &mut
i = j;
} else {
match r.collapsed(ctx, visible[i]) {
Some(item) => draw_collapsed(e, r, ctx, visible[i], item, canvas)?,
Some(collapsed) => draw_collapsed(e, r, ctx, visible[i], &collapsed, canvas)?,
None => draw_layer(e, r, ctx, visible[i], canvas)?,
}
i += 1;
@@ -187,19 +187,26 @@ fn draw_split(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, split: &effectcraft_rend
Some(cv)
}
fn draw_collapsed<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut GpuImage) -> Option<()> {
fn draw_collapsed<'a>(
e: &mut Enc,
r: &Renderer<'a>,
ctx: &EvalCtx<'a>,
layer: &'a Layer,
collapsed: &effectcraft_render::Collapsed,
canvas: &mut GpuImage,
) -> Option<()> {
let opacity = r.layer_opacity(ctx, layer);
if opacity <= 0.0 {
return Some(());
}
let matte = r.track_matte(ctx, layer).is_some();
if layer.blend_mode == BlendMode::Normal && !matte && !layer.preserve_transparency {
if let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, opacity) {
if let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, opacity) {
draw_comp(e, &sub, &nctx, canvas)?;
}
return Some(());
}
let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, 1.0) else { return Some(()) };
let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, 1.0) else { return Some(()) };
let mut iso = e.zeros(canvas.width, canvas.height);
draw_comp(e, &sub, &nctx, &mut iso)?;
composite_iso(e, r, ctx, layer, iso, canvas, opacity)
+20 -3
View File
@@ -269,7 +269,16 @@ fn value(s: &effectcraft_engine::Session, l: u64, path: &str) -> effectcraft_eng
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap();
let layer = comp.layer(effectcraft_engine::project::LayerId(l)).unwrap();
let ctx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Default::default(), expr: s.expr.as_deref(), footage: None };
let ctx = effectcraft_engine::render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: Default::default(),
expr: s.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
ctx.value(layer, layer.props.prop(path).unwrap())
}
@@ -523,8 +532,16 @@ fn stereo_rig_expressions_follow_the_master_camera() {
let v3 = |s: &effectcraft_engine::Session, path: &str| {
let comp = s.project.comp(left).unwrap();
let layer = &comp.layers[0];
let ctx =
effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: left, comp, time: Default::default(), expr: s.expr.as_deref(), footage: None };
let ctx = effectcraft_engine::render::EvalCtx {
project: &s.project,
comp_id: left,
comp,
time: Default::default(),
expr: s.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
ctx.value(layer, layer.props.prop(path).unwrap()).as_vec3()
};
let close = |a: [f64; 3], b: [f64; 3]| (0..3).all(|i| (a[i] - b[i]).abs() < 1e-6);
+1
View File
@@ -12,4 +12,5 @@ mod relink;
mod tga_import;
mod timeline_interop;
mod vector_import;
mod wav_markers;
mod webm_metadata;
+126
View File
@@ -0,0 +1,126 @@
//! Markers a WAV file carries, through the fully wired session (media import included): the
//! layer made from the file starts with them, and Update Markers From Source copies them again,
//! through its bound `layers` parameter as well as the selection, as one undo step.
use effectcraft_engine::Session;
use effectcraft_project::LayerSource;
use serde_json::json;
/// The comments of a layer's markers, in time order.
fn names(s: &mut Session, layer: u64) -> Vec<String> {
let list = s.execute_checked("markers.list", json!({"layer": layer})).unwrap();
list.as_array().unwrap().iter().map(|m| m["comment"].as_str().unwrap().to_string()).collect()
}
/// A one-second mono 8 kHz WAV with the named cue points `(id, sample, name)` (a `cue ` chunk and a
/// `LIST` `adtl`).
fn marker_wav(points: &[(u32, u32, &str)]) -> Vec<u8> {
fn chunk(id: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut v = id.to_vec();
v.extend((body.len() as u32).to_le_bytes());
v.extend(body);
if body.len() % 2 == 1 {
v.push(0);
}
v
}
let mut fmt = Vec::new();
fmt.extend(1u16.to_le_bytes());
fmt.extend(1u16.to_le_bytes());
fmt.extend(8000u32.to_le_bytes());
fmt.extend(16000u32.to_le_bytes());
fmt.extend(2u16.to_le_bytes());
fmt.extend(16u16.to_le_bytes());
let mut cue = (points.len() as u32).to_le_bytes().to_vec();
let mut adtl = b"adtl".to_vec();
for &(id, at, name) in points {
for v in [id, 0] {
cue.extend(v.to_le_bytes());
}
cue.extend(b"data");
for v in [0, 0, at] {
cue.extend(u32::to_le_bytes(v));
}
let mut labl = id.to_le_bytes().to_vec();
labl.extend(name.as_bytes());
labl.push(0);
adtl.extend(chunk(b"labl", &labl));
}
let mut body = b"WAVE".to_vec();
for c in [chunk(b"fmt ", &fmt), chunk(b"data", &[0; 16000]), chunk(b"cue ", &cue), chunk(b"LIST", &adtl)] {
body.extend(c);
}
let mut file = b"RIFF".to_vec();
file.extend((body.len() as u32).to_le_bytes());
file.extend(body);
file
}
/// A WAV's cue points become markers on the layer made from it, named as in the file, and
/// Update Markers From Source brings back one that was deleted (through the fully wired session,
/// media import included).
#[test]
fn a_wav_layer_starts_with_the_markers_in_its_file() {
let mut s = effectcraft_host::session();
s.execute_checked("comp.new", json!({"name": "C", "width": 64, "height": 48, "frameRate": 30, "duration": 2})).unwrap();
let dir = std::env::temp_dir().join(format!("effectcraft-host-wav-markers-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("cues.wav");
std::fs::write(&path, marker_wav(&[(1, 0, "Intro"), (2, 4000, "Middle")])).unwrap();
let r = s.execute_checked("file.import", json!({"paths": [path.to_string_lossy()], "addToComp": true})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
let layer = s.active_comp().unwrap().layers.iter().find(|l| matches!(l.source, LayerSource::Footage { .. })).unwrap().id.0;
let list = s.execute_checked("markers.list", json!({"layer": layer})).unwrap();
let marks: Vec<(String, f64)> =
list.as_array().unwrap().iter().map(|m| (m["comment"].as_str().unwrap().to_string(), m["time"].as_f64().unwrap())).collect();
assert_eq!(marks, [("Intro".to_string(), 0.0), ("Middle".to_string(), 0.5)]);
s.execute_checked("markers.delete", json!({"layer": layer, "index": 0})).unwrap();
assert_eq!(names(&mut s, layer), ["Middle"]);
s.execute_checked("layer.select", json!({"layers": [layer]})).unwrap();
// The bound `layers` parameter (what a script or the menu's own binding passes) brings the marker back...
let r = s.execute_checked("layer.updateMarkersFromSource", json!({"layers": [layer]})).unwrap();
assert_eq!(r["added"], 1);
assert_eq!(names(&mut s, layer), ["Intro", "Middle"]);
// ...as one undo step: undo removes it again, redo restores it.
assert!(s.undo());
assert_eq!(names(&mut s, layer), ["Middle"]);
assert!(s.redo());
assert_eq!(names(&mut s, layer), ["Intro", "Middle"]);
// The selection route (no parameter) finds nothing more to add.
assert_eq!(s.execute_checked("layer.updateMarkersFromSource", json!({})).unwrap()["added"], 0);
let _ = std::fs::remove_dir_all(&dir);
}
/// Reload Footage re-reads the markers the file carries: a cue point added to the file after the
/// import is available to Update Markers From Source (and a layer already made keeps its own).
#[test]
fn reloading_a_wav_picks_up_cue_points_added_to_the_file() {
let mut s = effectcraft_host::session();
s.execute_checked("comp.new", json!({"name": "C", "width": 64, "height": 48, "frameRate": 30, "duration": 2})).unwrap();
let dir = std::env::temp_dir().join(format!("effectcraft-host-wav-reload-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("cues.wav");
std::fs::write(&path, marker_wav(&[(1, 0, "Intro")])).unwrap();
let r = s.execute_checked("file.import", json!({"paths": [path.to_string_lossy()], "addToComp": true})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
let (layer, item) = s
.active_comp()
.unwrap()
.layers
.iter()
.find_map(|l| match l.source {
LayerSource::Footage { item } => Some((l.id.0, item.0)),
_ => None,
})
.unwrap();
assert_eq!(names(&mut s, layer), ["Intro"]);
// The file gains a cue point; the layer does not change until asked.
std::fs::write(&path, marker_wav(&[(1, 0, "Intro"), (2, 4000, "Added later")])).unwrap();
let r = s.execute_checked("file.reloadFootage", json!({"items": [item]})).unwrap();
assert_eq!(r["reloaded"], 1, "{r}");
assert_eq!(names(&mut s, layer), ["Intro"]);
let r = s.execute_checked("layer.updateMarkersFromSource", json!({"layers": [layer]})).unwrap();
assert_eq!(r["added"], 1);
assert_eq!(names(&mut s, layer), ["Intro", "Added later"]);
let _ = std::fs::remove_dir_all(&dir);
}
+1
View File
@@ -12,6 +12,7 @@ workspace = true
[dependencies]
craft-docpath = { workspace = true }
effectcraft-time = { workspace = true }
effectcraft-color = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-raster = { workspace = true }
effectcraft-render = { workspace = true }
+1
View File
@@ -25,6 +25,7 @@ pub use layered::vector_doc;
mod pool;
mod probe;
mod stream;
mod wav_markers;
mod webp_anim;
pub use pool::{DEFAULT_BUDGET, MediaPool, PoolStats};
+3
View File
@@ -107,8 +107,11 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result<Footage> {
let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let turn = video_turn(&bytes);
let rate = video_rate(&bytes);
// A WAV's cue points and XMP markers become the footage's markers.
let markers = crate::wav_markers::read(&bytes);
let src = filmcraft_codecs::open_bytes(&name, bytes)?;
let mut f = footage_from_info(path, src.info());
f.markers = markers;
turn_footage(&mut f, turn);
correct_rate(&mut f, rate);
Ok(f)
+517
View File
@@ -0,0 +1,517 @@
//! Markers a WAV file carries, read from its RIFF chunks.
//!
//! Two encodings are read and merged:
//! - **Cue points** (the standard): a `cue ` chunk with one point per marker, named by `labl` (or
//! `note`) sub-chunks of a `LIST` `adtl` chunk, and given a length by `ltxt`.
//! - **XMP** (Adobe's tools): an `_PMX` chunk holding an XMP packet whose `xmpDM:markers` list has
//! one entry per marker (`xmpDM:startTime`, `xmpDM:duration`, `xmpDM:name`), counted in frames
//! of `xmpDM:frameRate` (`f44100` for samples at 44.1 kHz).
//!
//! RF64 files (WAVs over 4 GB) carry a `0xFFFFFFFF` size for a chunk too big for 32 bits and give its real size in a
//! `ds64` chunk; those sizes are resolved so the chunks after the audio are still found.
//!
//! The file is untrusted input: every read is bounds-checked, sizes use checked arithmetic, and
//! the number of markers, of `adtl` sub-chunks looked at and the size of the XMP packet are capped.
use std::collections::{HashMap, HashSet};
use effectcraft_color::Label;
use effectcraft_project::Marker;
use effectcraft_time::Tick;
/// Most markers read from one file.
pub const MAX_MARKERS: usize = 10_000;
/// Largest XMP packet read (bytes).
const MAX_XMP: usize = 4 << 20;
/// Most `adtl` sub-chunks (`labl`, `note`, `ltxt`) looked at in one file: a few per cue point is plenty.
const MAX_ADTL_RECORDS: usize = 4 * MAX_MARKERS;
/// Most entries read from an RF64 file's `ds64` size table.
const MAX_DS64_TABLE: usize = 1_024;
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
let s = b.get(at..at.checked_add(4)?)?;
Some(u32::from_le_bytes(s.try_into().ok()?))
}
fn u64_at(b: &[u8], at: usize) -> Option<u64> {
let s = b.get(at..at.checked_add(8)?)?;
Some(u64::from_le_bytes(s.try_into().ok()?))
}
/// The text of a NUL-terminated label (the rest of the chunk), lossily decoded.
fn label(b: &[u8]) -> String {
let end = b.iter().position(|&c| c == 0).unwrap_or(b.len());
String::from_utf8_lossy(b.get(..end).unwrap_or_default()).trim().to_string()
}
fn secs(frames: f64, rate: f64) -> Tick {
if rate > 0.0 && frames.is_finite() { Tick::from_seconds_f64((frames / rate).max(0.0)) } else { Tick::ZERO }
}
/// Whether `bytes` start a RIFF/WAVE file.
pub fn is_wav(bytes: &[u8]) -> bool {
matches!(bytes.get(0..4), Some(b"RIFF" | b"RF64")) && bytes.get(8..12) == Some(b"WAVE")
}
/// The sizes an RF64 file's `ds64` chunk gives for chunks too big for the 32-bit size field.
struct Ds64 {
/// The `data` chunk's size.
data: u64,
/// Other chunks: `(id, size)`.
table: Vec<([u8; 4], u64)>,
}
impl Ds64 {
/// The real size of chunk `id`, when the file says.
fn size_of(&self, id: &[u8; 4]) -> Option<u64> {
if id == b"data" { Some(self.data) } else { self.table.iter().find(|(t, _)| t == id).map(|(_, size)| *size) }
}
}
/// The `ds64` chunk of an RF64 file (`RF64`, size, `WAVE`, then `ds64`); `None` for any other file or one whose
/// `ds64` doesn't fit.
fn ds64(bytes: &[u8]) -> Option<Ds64> {
if bytes.get(0..4) != Some(b"RF64") || bytes.get(12..16) != Some(b"ds64") {
return None;
}
let size = usize::try_from(u32_at(bytes, 16)?).ok()?;
let body = bytes.get(20..20usize.checked_add(size)?)?;
// riff size (8), data size (8), sample count (8), table length (4), then `(id, size)` entries of 12 bytes
let data = u64_at(body, 8)?;
let entries = usize::try_from(u32_at(body, 24)?).ok()?;
let mut table = Vec::new();
for i in 0..entries.min(MAX_DS64_TABLE) {
let at = 28usize.checked_add(i.checked_mul(12)?)?;
let id = <[u8; 4]>::try_from(body.get(at..at.checked_add(4)?)?).ok()?;
table.push((id, u64_at(body, at + 4)?));
}
Some(Ds64 { data, table })
}
/// The chunks of a WAVE file as `(id, body)`, stopping at the first one that doesn't fit.
fn chunks(bytes: &[u8]) -> Vec<([u8; 4], &[u8])> {
let big = ds64(bytes);
let mut out = Vec::new();
let mut at = 12usize;
while let Some(head) = bytes.get(at..at.saturating_add(8)) {
let (Ok(id), Some(field)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break };
// In an RF64 file a size field of 0xFFFFFFFF stands for the size `ds64` gives.
let size = match &big {
Some(d) if field == u32::MAX => d.size_of(&id).unwrap_or(u64::from(field)),
_ => u64::from(field),
};
let start = at.saturating_add(8);
let Some(end) = usize::try_from(size).ok().and_then(|n| start.checked_add(n)) else { break };
let Some(body) = bytes.get(start..end) else { break };
out.push((id, body));
// Chunks are padded to an even size.
at = end.saturating_add(end & 1);
}
out
}
/// The markers `bytes` carry, in time order (empty for anything that isn't a WAV with markers).
pub fn read(bytes: &[u8]) -> Vec<Marker> {
if !is_wav(bytes) {
return Vec::new();
}
let chunks = chunks(bytes);
let rate = chunks.iter().find(|(id, _)| id == b"fmt ").and_then(|(_, b)| u32_at(b, 4)).map_or(0.0, f64::from);
let mut out = cue_markers(&chunks, rate);
if let Some((_, xmp)) = chunks.iter().find(|(id, _)| id == b"_PMX") {
// Tools that write both encodings repeat each marker.
let mut seen: HashSet<(String, Tick)> = out.iter().map(|o| (o.comment.clone(), o.time)).collect();
for m in xmp_markers(xmp, rate) {
if seen.insert((m.comment.clone(), m.time)) {
out.push(m);
}
}
}
out.truncate(MAX_MARKERS);
out.sort_by_key(|m| m.time);
out
}
/// Cue points named and sized by the `adtl` list.
fn cue_markers(chunks: &[([u8; 4], &[u8])], rate: f64) -> Vec<Marker> {
let Some((_, cue)) = chunks.iter().find(|(id, _)| id == b"cue ") else { return Vec::new() };
let count = (u32_at(cue, 0).unwrap_or(0) as usize).min(MAX_MARKERS);
// The cue points first: `(id, offset in samples)`.
let mut points = Vec::new();
for i in 0..count {
let Some(point) = cue.get(4usize.saturating_add(i.saturating_mul(24))..) else { break };
let (Some(id), Some(offset)) = (u32_at(point, 0), u32_at(point, 20)) else { break };
points.push((id, offset));
}
// Only the names and lengths of those ids are kept, so a hostile `adtl` list cannot grow them past the cue count,
// and each record is a lookup, not a scan.
let wanted: HashSet<u32> = points.iter().map(|(id, _)| *id).collect();
let mut names: HashMap<u32, String> = HashMap::new();
let mut lengths: HashMap<u32, u32> = HashMap::new();
let mut records = 0usize;
'lists: for (_, list) in chunks.iter().filter(|(id, b)| id == b"LIST" && b.get(0..4) == Some(b"adtl")) {
let mut at = 4usize;
while let Some(head) = list.get(at..at.saturating_add(8)) {
let (Ok(id), Some(size)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break };
let start = at.saturating_add(8);
let Some(end) = start.checked_add(size as usize) else { break };
let Some(body) = list.get(start..end) else { break };
records += 1;
if records > MAX_ADTL_RECORDS {
break 'lists;
}
if let Some(cue_id) = u32_at(body, 0).filter(|c| wanted.contains(c)) {
match &id {
// A `labl` is the name; a `note` only when there is none.
b"labl" => {
names.insert(cue_id, label(body.get(4..).unwrap_or_default()));
}
b"note" => {
names.entry(cue_id).or_insert_with(|| label(body.get(4..).unwrap_or_default()));
}
// The first `ltxt` of a cue point gives its length.
b"ltxt" => {
lengths.entry(cue_id).or_insert_with(|| u32_at(body, 4).unwrap_or(0));
}
_ => {}
}
}
at = end.saturating_add(end & 1);
}
}
points
.into_iter()
.map(|(id, offset)| {
let comment = names.get(&id).cloned().unwrap_or_default();
let length = lengths.get(&id).copied().unwrap_or(0);
Marker { time: secs(f64::from(offset), rate), duration: secs(f64::from(length), rate), comment, label: Label::None, ..Default::default() }
})
.collect()
}
/// The text of `name` in an XMP entry, as an attribute (`name="v"`) or an element
/// (`<name>v</name>`).
fn xmp_value(entry: &str, name: &str) -> Option<String> {
let attr = format!("{name}=\"");
if let Some(i) = entry.find(&attr) {
let rest = entry.get(i + attr.len()..)?;
return Some(unescape(rest.get(..rest.find('"')?)?));
}
let open = format!("<{name}>");
let i = entry.find(&open)?;
let rest = entry.get(i + open.len()..)?;
Some(unescape(rest.get(..rest.find('<')?)?))
}
fn unescape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(i) = rest.find('&') {
out.push_str(rest.get(..i).unwrap_or_default());
let tail = rest.get(i..).unwrap_or_default();
let Some(semi) = tail.find(';').filter(|&n| n <= 10) else {
out.push('&');
rest = tail.get(1..).unwrap_or_default();
continue;
};
let ent = tail.get(1..semi).unwrap_or_default();
match ent {
"amp" => out.push('&'),
"lt" => out.push('<'),
"gt" => out.push('>'),
"quot" => out.push('"'),
"apos" => out.push('\''),
_ => {
let code = ent.strip_prefix("#x").and_then(|h| u32::from_str_radix(h, 16).ok()).or_else(|| ent.strip_prefix('#').and_then(|d| d.parse().ok()));
match code.and_then(char::from_u32) {
Some(c) => out.push(c),
None => out.push_str(tail.get(..=semi).unwrap_or_default()),
}
}
}
rest = tail.get(semi + 1..).unwrap_or_default();
}
out.push_str(rest);
out.trim().to_string()
}
/// A frame rate as XMP writes it: `f44100`, or `f30000s1001` (rate over scale).
fn xmp_rate(text: &str) -> Option<f64> {
let t = text.trim().strip_prefix('f')?;
let (num, den) = t.split_once('s').unwrap_or((t, "1"));
let (n, d): (f64, f64) = (num.parse().ok()?, den.parse().ok()?);
(n > 0.0 && d > 0.0).then_some(n / d)
}
/// The markers of an XMP packet's `xmpDM:markers` entries.
fn xmp_markers(packet: &[u8], file_rate: f64) -> Vec<Marker> {
let text = String::from_utf8_lossy(packet.get(..packet.len().min(MAX_XMP)).unwrap_or_default());
let mut out = Vec::new();
// The frame rate belongs to the track an entry follows.
let mut rate = file_rate;
for entry in text.split("<rdf:li").skip(1) {
if let Some(r) = xmp_value(entry, "xmpDM:frameRate").as_deref().and_then(xmp_rate) {
rate = r;
}
let Some(start) = xmp_value(entry, "xmpDM:startTime").and_then(|v| v.trim().parse::<f64>().ok()) else { continue };
let length = xmp_value(entry, "xmpDM:duration").and_then(|v| v.trim().parse::<f64>().ok()).unwrap_or(0.0);
let comment = xmp_value(entry, "xmpDM:name").unwrap_or_default();
out.push(Marker { time: secs(start, rate), duration: secs(length, rate), comment, label: Label::None, ..Default::default() });
if out.len() >= MAX_MARKERS {
break;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn chunk(id: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut v = id.to_vec();
v.extend((body.len() as u32).to_le_bytes());
v.extend(body);
if body.len() % 2 == 1 {
v.push(0);
}
v
}
fn fmt(rate: u32) -> Vec<u8> {
let mut b = Vec::new();
b.extend(1u16.to_le_bytes()); // PCM
b.extend(1u16.to_le_bytes()); // mono
b.extend(rate.to_le_bytes());
b.extend((rate * 2).to_le_bytes());
b.extend(2u16.to_le_bytes());
b.extend(16u16.to_le_bytes());
chunk(b"fmt ", &b)
}
fn wav(chunks: &[Vec<u8>]) -> Vec<u8> {
let body: Vec<u8> = chunks.iter().flatten().copied().collect();
let mut v = b"RIFF".to_vec();
v.extend((body.len() as u32 + 4).to_le_bytes());
v.extend(b"WAVE");
v.extend(body);
v
}
fn cue(points: &[(u32, u32)]) -> Vec<u8> {
let mut b = (points.len() as u32).to_le_bytes().to_vec();
for (id, offset) in points {
b.extend(id.to_le_bytes());
b.extend(0u32.to_le_bytes());
b.extend(b"data");
b.extend(0u32.to_le_bytes());
b.extend(0u32.to_le_bytes());
b.extend(offset.to_le_bytes());
}
chunk(b"cue ", &b)
}
fn labl(id: &[u8; 4], cue_id: u32, text: &str) -> Vec<u8> {
let mut b = cue_id.to_le_bytes().to_vec();
b.extend(text.as_bytes());
b.push(0);
chunk(id, &b)
}
fn adtl(subs: &[Vec<u8>]) -> Vec<u8> {
let mut b = b"adtl".to_vec();
b.extend(subs.iter().flatten());
chunk(b"LIST", &b)
}
#[test]
fn cue_points_are_named_by_the_adtl_list_and_timed_in_samples() {
let file = wav(&[
fmt(44100),
chunk(b"data", &[0; 8]),
cue(&[(2, 88200), (1, 22050)]),
adtl(&[labl(b"labl", 1, "SEQ01_Prologue"), labl(b"labl", 2, "SEQ02_Vallee_P1_Agriculture")]),
]);
let m = read(&file);
assert_eq!(m.len(), 2);
assert_eq!((m[0].comment.as_str(), m[0].time), ("SEQ01_Prologue", Tick::from_seconds_f64(0.5)));
assert_eq!((m[1].comment.as_str(), m[1].time), ("SEQ02_Vallee_P1_Agriculture", Tick::from_seconds_f64(2.0)));
}
#[test]
fn a_note_names_a_cue_only_when_it_has_no_label_and_ltxt_gives_a_length() {
let mut ltxt = 1u32.to_le_bytes().to_vec();
ltxt.extend(44100u32.to_le_bytes());
ltxt.extend([0; 16]);
let file = wav(&[
fmt(44100),
cue(&[(1, 0), (2, 44100)]),
adtl(&[labl(b"note", 1, "from note"), chunk(b"ltxt", &ltxt), labl(b"labl", 2, "named"), labl(b"note", 2, "ignored")]),
]);
let m = read(&file);
assert_eq!((m[0].comment.as_str(), m[0].duration), ("from note", Tick::from_seconds_f64(1.0)));
assert_eq!(m[1].comment, "named");
}
const XMP_ELEMENTS: &str = r#"<x:xmpmeta><rdf:RDF><rdf:Description><xmpDM:Tracks><rdf:Bag><rdf:li rdf:parseType="Resource"><xmpDM:trackName>Markers</xmpDM:trackName><xmpDM:trackType>Cue</xmpDM:trackType><xmpDM:frameRate>f44100</xmpDM:frameRate><xmpDM:markers><rdf:Seq><rdf:li rdf:parseType="Resource"><xmpDM:startTime>44100</xmpDM:startTime><xmpDM:duration>0</xmpDM:duration><xmpDM:name>One &amp; Two</xmpDM:name></rdf:li><rdf:li rdf:parseType="Resource"><xmpDM:startTime>132300</xmpDM:startTime><xmpDM:duration>22050</xmpDM:duration><xmpDM:name>Three</xmpDM:name></rdf:li></rdf:Seq></xmpDM:markers></rdf:li></rdf:Bag></xmpDM:Tracks></rdf:Description></rdf:RDF></x:xmpmeta>"#;
#[test]
fn xmp_markers_are_read_as_elements_in_frames_of_the_track_rate() {
let file = wav(&[fmt(48000), chunk(b"_PMX", XMP_ELEMENTS.as_bytes())]);
let m = read(&file);
assert_eq!(m.len(), 2);
// 44100 frames at f44100 is one second, whatever the file's own rate.
assert_eq!((m[0].comment.as_str(), m[0].time), ("One & Two", Tick::from_seconds_f64(1.0)));
assert_eq!((m[1].comment.as_str(), m[1].time, m[1].duration), ("Three", Tick::from_seconds_f64(3.0), Tick::from_seconds_f64(0.5)));
}
#[test]
fn xmp_markers_are_read_as_attributes_and_use_the_file_rate_without_a_track_rate() {
let xmp = r#"<rdf:Seq><rdf:li xmpDM:startTime="24000" xmpDM:duration="0" xmpDM:name="Attr &#233;"/></rdf:Seq>"#;
let m = read(&wav(&[fmt(48000), chunk(b"_PMX", xmp.as_bytes())]));
assert_eq!((m[0].comment.as_str(), m[0].time), ("Attr é", Tick::from_seconds_f64(0.5)));
}
#[test]
fn a_marker_in_both_encodings_is_listed_once() {
let xmp = r#"<rdf:li xmpDM:startTime="44100" xmpDM:name="Same"/><rdf:li xmpDM:startTime="88200" xmpDM:name="Only XMP"/>"#;
let file = wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Same")]), chunk(b"_PMX", xmp.as_bytes())]);
let names: Vec<String> = read(&file).into_iter().map(|m| m.comment).collect();
assert_eq!(names, ["Same", "Only XMP"]);
}
#[test]
fn importing_a_wav_puts_its_markers_on_the_footage() {
let file = wav(&[
fmt(44100),
chunk(b"data", &[0; 4 * 44100]),
cue(&[(1, 0), (2, 44100)]),
adtl(&[labl(b"labl", 1, "SEQ01_Prologue"), labl(b"labl", 2, "SEQ02_Vallee_P1_Agriculture")]),
]);
let f = crate::probe::probe_bytes("markers.wav", file.into()).expect("probe");
assert!(f.has_audio);
let names: Vec<&str> = f.markers.iter().map(|m| m.comment.as_str()).collect();
assert_eq!(names, ["SEQ01_Prologue", "SEQ02_Vallee_P1_Agriculture"]);
assert_eq!(f.markers[1].time, Tick::from_seconds_f64(1.0));
}
#[test]
fn damaged_or_hostile_files_read_as_far_as_they_go_without_panicking() {
assert!(read(b"").is_empty());
assert!(read(b"RIFF").is_empty());
assert!(read(b"not a wav file at all").is_empty());
let good = wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Kept")])]);
// Every truncation is safe.
for n in 0..good.len() {
let _ = read(&good[..n]);
}
// A cue count far beyond the chunk, and chunk sizes past the end of the file.
let mut huge = 0xFFFF_FFFFu32.to_le_bytes().to_vec();
huge.extend([0; 24]);
let m = read(&wav(&[fmt(44100), chunk(b"cue ", &huge)]));
assert!(m.len() <= 1);
let mut lying = good.clone();
lying.extend(b"LIST\xff\xff\xff\xffadtl");
assert_eq!(read(&lying).len(), 1);
// No sample rate: times are zero, not a division by zero.
let m = read(&wav(&[cue(&[(1, 44100)])]));
assert_eq!(m[0].time, Tick::ZERO);
}
/// A chunk whose size field says `field` (an RF64 file's `0xFFFFFFFF`) while its body is `body`.
fn chunk_with_size_field(id: &[u8; 4], field: u32, body: &[u8]) -> Vec<u8> {
let mut v = id.to_vec();
v.extend(field.to_le_bytes());
v.extend(body);
if body.len() % 2 == 1 {
v.push(0);
}
v
}
/// An RF64 `ds64` chunk: the `data` chunk's size and a table of other chunks' sizes.
fn ds64_chunk(data_size: u64, table: &[([u8; 4], u64)]) -> Vec<u8> {
let mut b = 0u64.to_le_bytes().to_vec(); // riff size (not used)
b.extend(data_size.to_le_bytes());
b.extend(0u64.to_le_bytes()); // sample count
b.extend((table.len() as u32).to_le_bytes());
for (id, size) in table {
b.extend(id);
b.extend(size.to_le_bytes());
}
chunk(b"ds64", &b)
}
fn rf64(chunks: &[Vec<u8>]) -> Vec<u8> {
let body: Vec<u8> = chunks.iter().flatten().copied().collect();
let mut v = b"RF64".to_vec();
v.extend(u32::MAX.to_le_bytes());
v.extend(b"WAVE");
v.extend(body);
v
}
#[test]
fn rf64_markers_after_a_data_chunk_of_sentinel_size_are_read() {
let audio = [0u8; 8];
let file = rf64(&[
ds64_chunk(audio.len() as u64, &[]),
fmt(44100),
chunk_with_size_field(b"data", u32::MAX, &audio),
cue(&[(1, 44100)]),
adtl(&[labl(b"labl", 1, "After the audio")]),
]);
let m = read(&file);
assert_eq!(m.len(), 1);
assert_eq!((m[0].comment.as_str(), m[0].time), ("After the audio", Tick::from_seconds_f64(1.0)));
}
#[test]
fn rf64_sizes_of_other_chunks_come_from_the_ds64_table() {
let junk = [7u8; 6];
let tail = || [cue(&[(1, 8000)]), adtl(&[labl(b"labl", 1, "Found")])].concat();
let with_entry = rf64(&[ds64_chunk(0, &[(*b"junk", junk.len() as u64)]), fmt(8000), chunk_with_size_field(b"junk", u32::MAX, &junk), tail()]);
assert_eq!(read(&with_entry)[0].comment, "Found");
// Without the table entry the sentinel is all there is, and what follows is not reached.
let without = rf64(&[ds64_chunk(0, &[]), fmt(8000), chunk_with_size_field(b"junk", u32::MAX, &junk), tail()]);
assert!(read(&without).is_empty());
}
#[test]
fn hostile_rf64_size_tables_read_as_far_as_they_go_without_panicking() {
// sizes that overflow when added to an offset
for data in [u64::MAX, u64::MAX - 7, 1 << 63] {
let file = rf64(&[ds64_chunk(data, &[(*b"junk", u64::MAX)]), fmt(8000), chunk_with_size_field(b"data", u32::MAX, &[0; 4]), cue(&[(1, 0)])]);
assert!(read(&file).is_empty());
}
// a table that claims far more entries than its chunk holds
let mut liar = ds64_chunk(0, &[]);
liar[8 + 24..8 + 28].copy_from_slice(&u32::MAX.to_le_bytes());
let _ = read(&rf64(&[liar, fmt(8000), cue(&[(1, 0)])]));
// every truncation of a good RF64 file is safe
let good =
rf64(&[ds64_chunk(4, &[]), fmt(44100), chunk_with_size_field(b"data", u32::MAX, &[0; 4]), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Kept")])]);
assert_eq!(read(&good).len(), 1);
for n in 0..good.len() {
let _ = read(&good[..n]);
}
}
#[test]
fn a_huge_adtl_list_is_read_in_linear_time_and_only_for_the_cue_points_that_exist() {
// Records for ids that have no cue point are skipped, not stored, and the number looked at is capped.
let junk = |n: u32| (1000..1000 + n).map(|id| labl(b"labl", id, "n")).collect::<Vec<_>>();
let started = std::time::Instant::now();
// the real label comes first: read
let mut subs = vec![labl(b"labl", 1, "Real")];
subs.extend(junk(100_000));
let m = read(&wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&subs)]));
assert_eq!(m[0].comment, "Real");
// it comes after more records than are looked at: not read, and the marker is still there
let mut subs = junk(MAX_ADTL_RECORDS as u32 + 10);
subs.push(labl(b"labl", 1, "Too late"));
let m = read(&wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&subs)]));
assert_eq!((m.len(), m[0].comment.as_str()), (1, ""));
assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed());
}
}
+9 -1
View File
@@ -881,6 +881,14 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource
_ => {}
}
}
// A footage layer starts with the markers its file carries (layer time is footage time).
let footage_markers = match &source {
LayerSource::Footage { item } => match project.item(*item).map(|i| &i.kind) {
Some(crate::ItemKind::Footage(f)) => f.markers.clone(),
_ => vec![],
},
_ => vec![],
};
let has_audio = matches!(&source, LayerSource::Footage { item } if matches!(project.item(*item).map(|i| &i.kind), Some(crate::ItemKind::Footage(f)) if f.has_audio))
|| matches!(source, LayerSource::Comp { .. });
if has_audio {
@@ -910,7 +918,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource
preserve_transparency: false,
track_matte: None,
parent: None,
markers: vec![],
markers: footage_markers,
markers_locked: false,
environment: false,
environment_background: false,
+6 -1
View File
@@ -1,4 +1,4 @@
use super::settings::yes;
use super::settings::{Marker, yes};
use super::{ColorSpace, Deserialize, FrameRate, Serialize, Tick};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -115,6 +115,10 @@ pub struct Footage {
/// timecode isn't read). It only relabels source time, as in After Effects.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_timecode: Option<i64>,
/// Markers the file carries (a WAV's cue points and XMP markers), at footage time. A layer
/// made from the footage starts with them, and Update Markers From Source copies new ones.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub markers: Vec<Marker>,
}
fn is_default<T: Default + PartialEq>(v: &T) -> bool {
@@ -150,6 +154,7 @@ impl Default for Footage {
page: 0,
alphabetical: false,
start_timecode: None,
markers: vec![],
}
}
}
+34 -3
View File
@@ -442,9 +442,18 @@ impl RenderSettings {
pub fn frame_time(&self, comp: &Comp, i: u64) -> Tick {
self.rate(comp).tick_of(self.first_frame(comp) + i as i64)
}
pub fn output_size(&self, comp: &Comp) -> (u32, u32) {
pub fn effective_scale(&self, comp: &Comp) -> f64 {
let s = self.resolution.clamp(0.01, 4.0);
(((comp.width as f64 * s).round() as u32).max(1), ((comp.height as f64 * s).round() as u32).max(1))
let max = f64::from(crate::limits::MAX_SIZE);
s.min(max / f64::from(comp.width.max(1))).min(max / f64::from(comp.height.max(1)))
}
pub fn output_size(&self, comp: &Comp) -> (u32, u32) {
let s = self.effective_scale(comp);
// The scale is clamped so neither side exceeds the comp limit, keeping the aspect
// ratio: a 30 000 px side at 4x renders at 1x instead of cropping to a corner.
let w = ((comp.width as f64 * s).round() as u32).max(1);
let h = ((comp.height as f64 * s).round() as u32).max(1);
(w, h)
}
pub fn resolution_label(&self) -> &'static str {
match self.resolution {
@@ -1126,7 +1135,7 @@ impl OutputModule {
/// rounding).
pub fn output_size(&self, comp: &Comp, settings: &RenderSettings) -> (u32, u32) {
let (w, h) = settings.output_size(comp);
let (w, h) = self.frame_size(w, h, settings.resolution.clamp(0.01, 4.0));
let (w, h) = self.frame_size(w, h, settings.effective_scale(comp));
self.format.coded_size(w, h)
}
/// Every setting as `(label, value)` lines (render logs).
@@ -1673,6 +1682,28 @@ mod tests {
assert_eq!(m.output_size(&c, &half), (400, 200));
}
#[test]
fn output_size_never_exceeds_the_comp_limit() {
// The effective scale is clamped so neither side exceeds the limit (exporter
// `w * h * 4` would overflow at 120 000 px sides); the pipeline renders at the
// same scale, so it never requests a frame larger than this reports.
let rs = RenderSettings { resolution: 4.0, ..Default::default() };
let m = OutputModule::for_format(OutputFormat::PngSequence);
let square = Comp::new(30_000, 30_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0));
assert_eq!(rs.effective_scale(&square), 1.0);
assert_eq!(rs.output_size(&square), (30_000, 30_000));
assert_eq!(m.output_size(&square, &rs), (30_000, 30_000));
// Wide comps keep their aspect ratio instead of clamping each side on its own.
let wide = Comp::new(30_000, 15_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0));
assert_eq!(rs.effective_scale(&wide), 1.0);
assert_eq!(rs.output_size(&wide), (30_000, 15_000));
assert_eq!(m.output_size(&wide, &rs), (30_000, 15_000));
// A mid-size comp renders at the largest scale that still fits.
let mid = Comp::new(20_000, 10_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0));
assert_eq!(mid.width as f64 * rs.effective_scale(&mid), 30_000.0);
assert_eq!(rs.output_size(&mid), (30_000, 15_000));
}
#[test]
fn uncompressed_quicktime_format() {
let f = OutputFormat::from_name("uncompressed").unwrap();
+5 -4
View File
@@ -184,8 +184,9 @@ impl<'a> Renderer<'a> {
pub fn layer_aux(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<effectcraft_raster::AuxChannels>> {
match &layer.source {
LayerSource::Footage { item } => {
let ItemKind::Footage(f) = &self.project.item(*item)?.kind else { return None };
let a = self.footage.aux(*item, f, ctx.source_time(layer))?;
let item = ctx.media_item(layer, *item);
let ItemKind::Footage(f) = &self.project.item(item)?.kind else { return None };
let a = self.footage.aux(item, f, ctx.source_time(layer))?;
// Aux pixels per layer pixel (the layer is the footage at its nominal size). The
// planes are only copied when that changes the scale.
let scale = a.width as f64 / f.width as f64;
@@ -332,8 +333,8 @@ impl<'a> Renderer<'a> {
}
/// Draw a collapsed precomp layer into `canvas` on the CPU.
pub fn draw_collapsed_cpu(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut Image) {
self.draw_collapsed(ctx, layer, item, canvas, false);
pub fn draw_collapsed_cpu(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &crate::Collapsed, canvas: &mut Image) {
self.draw_collapsed(ctx, layer, collapsed, canvas, false);
}
/// The layer's finished buffers (styles included) in the blending space.
+20 -2
View File
@@ -479,6 +479,19 @@ fn group_has(g: &PropGroup, match_id: &str) -> bool {
/// Feed every property of `g` that can change over time (keyframes or expression) as its value
/// at the context time. The static structure is hashed separately.
/// Mix the collapsed-instance overrides that target `id` into a cache key. They replace values
/// without editing the layer, so a key without them would reuse another instance's buffer.
fn hash_overrides(h: &mut KeyHasher, ctx: &EvalCtx, id: effectcraft_project::LayerId) {
for o in ctx.property_overrides.iter().filter(|o| o.layer == id) {
o.prop.hash(h);
o.font.hash(h);
hash_debug(h, &o.value);
}
for m in ctx.media_overrides.iter().filter(|m| m.layer == id) {
m.item.hash(h);
}
}
fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) {
for c in &g.children {
match c {
@@ -605,8 +618,9 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo
hash_debug(&mut h, s);
}
LayerSource::Text | LayerSource::Shape => {}
LayerSource::Footage { item } if footage || still_footage(ctx, *item) => {
let it = ctx.project.item(*item)?;
LayerSource::Footage { item } if footage || still_footage(ctx, ctx.media_item(layer, *item)) => {
let item = ctx.media_item(layer, *item);
let it = ctx.project.item(item)?;
let ItemKind::Footage(f) = &it.kind else { return None };
hash_debug(&mut h, f);
// A proxy (and its Use Proxy switch) changes the pixels.
@@ -629,6 +643,10 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo
scale.to_bits().hash(&mut h);
draft.hash(&mut h);
layer.id.hash(&mut h);
// Collapsed-instance overrides replace values without editing the nested layer, so mix them
// into the key: a Font override, effect parameter or any other source-affecting override
// must not reuse another instance's processed buffer.
hash_overrides(&mut h, ctx, layer.id);
hash_debug(&mut h, &layer.source);
// (Audio, Lock and Shy don't change pixels.)
hash_debug(&mut h, &layer.switches.pixels());
+7
View File
@@ -133,6 +133,9 @@ impl Dependencies<'_> {
// Include transforms/parents as well as pixels: selected-layer anchors and
// continuously rasterized sources can depend on them.
hash_debug(self.h, layer);
// A referenced layer's static values can be overridden by the collapsed instance without
// editing it (`hash_values` only sees animated ones), so the owner's key needs them too.
super::hash_overrides(self.h, ctx, layer.id);
hash_values(self.h, ctx, layer, &layer.props);
hash_debug(self.h, &ctx.world_matrix(layer));
// A cached owner never runs this layer's effects, so the comp camera and lights its
@@ -145,6 +148,10 @@ impl Dependencies<'_> {
}
match layer.source {
LayerSource::Solid { item } | LayerSource::Footage { item } | LayerSource::Comp { item } => {
let item = match layer.source {
LayerSource::Footage { .. } => ctx.media_item(layer, item),
_ => item,
};
let source = ctx.project.item(item);
hash_debug(self.h, &source);
if let Some(source) = source {
+64 -10
View File
@@ -39,17 +39,33 @@ pub struct EvalCtx<'a> {
/// Footage frames for expressions that read rendered pixels (`sampleImage`): the renderer's
/// own source while rendering; `None` renders footage as transparent.
pub footage: Option<&'a dyn crate::FootageSource>,
/// Property values overridden by the Essential Properties of a collapsed precomp instance.
pub property_overrides: &'a [crate::PropertyOverride],
/// Media Replacement values overridden by the Essential Properties of a collapsed precomp
/// instance.
pub media_overrides: &'a [crate::MediaOverride],
}
impl<'a> EvalCtx<'a> {
pub fn new(project: &'a Project, comp_id: ItemId, comp: &'a Comp, time: Tick) -> EvalCtx<'a> {
EvalCtx { project, comp_id, comp, time, expr: None, footage: None }
EvalCtx { project, comp_id, comp, time, expr: None, footage: None, property_overrides: &[], media_overrides: &[] }
}
pub fn at(&self, time: Tick) -> EvalCtx<'a> {
EvalCtx { time, ..*self }
}
/// Value of a property at the context time (keyframes, then expression).
pub fn value(&self, layer: &Layer, prop: &Property) -> Value {
// A collapsed precomp instance's Essential Property override replaces the base value;
// its expression (if any) still runs, and sees the overrides of this context.
if let Some(o) = self.property_overrides.iter().rev().find(|o| o.layer == layer.id && o.prop == prop.uid) {
let v = o.value(layer, prop, self.time);
if prop.has_expression()
&& let Some(h) = self.expr
{
return h.eval(self, layer, prop, &v).unwrap_or(v);
}
return v;
}
// Separated Position reads as the combination of X/Y/Z Position.
if prop.match_id == "position"
&& let Some(tr) = layer.transform()
@@ -145,13 +161,15 @@ impl<'a> EvalCtx<'a> {
/// Source pixel aspect relative to the comp's: non-square footage (or solids, or precomps
/// with another pixel aspect) is stretched horizontally by this factor in the comp.
pub fn par_ratio(&self, layer: &Layer) -> f64 {
let par = match &layer.source {
LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match self.project.item(*item).map(|i| &i.kind) {
Some(effectcraft_project::ItemKind::Footage(f)) => f.pixel_aspect,
Some(effectcraft_project::ItemKind::Solid(s)) => s.pixel_aspect,
Some(effectcraft_project::ItemKind::Comp(c)) => c.pixel_aspect,
_ => 1.0,
},
let item = match &layer.source {
LayerSource::Footage { item } => Some(self.media_item(layer, *item)),
LayerSource::Solid { item } | LayerSource::Comp { item } => Some(*item),
_ => None,
};
let par = match item.and_then(|item| self.project.item(item)).map(|i| &i.kind) {
Some(effectcraft_project::ItemKind::Footage(f)) => f.pixel_aspect,
Some(effectcraft_project::ItemKind::Solid(s)) => s.pixel_aspect,
Some(effectcraft_project::ItemKind::Comp(c)) => c.pixel_aspect,
_ => 1.0,
};
let r = par / if self.comp.pixel_aspect > 0.0 { self.comp.pixel_aspect } else { 1.0 };
@@ -237,9 +255,27 @@ impl<'a> EvalCtx<'a> {
(Mat3([[m[0][0], m[0][1], m[0][3]], [m[1][0], m[1][1], m[1][3]], [0.0, 0.0, 1.0]]), 0.0)
}
}
/// The footage item a layer uses, honouring this instance's Media Replacement override.
pub fn media_item(&self, layer: &Layer, item: ItemId) -> ItemId {
self.media_overrides.iter().rev().find(|m| m.layer == layer.id).map_or(item, |m| m.item)
}
/// Size of a layer's source in layer pixels, honouring Media Replacement.
pub fn source_size(&self, layer: &Layer) -> (u32, u32) {
let item = match &layer.source {
LayerSource::Footage { item } => Some(self.media_item(layer, *item)),
LayerSource::Comp { item } | LayerSource::Solid { item } => Some(*item),
_ => None,
};
match layer.source {
LayerSource::Null => (100, 100),
_ => item.and_then(|item| self.project.item(item)).and_then(|i| i.dimensions()).unwrap_or((0, 0)),
}
}
/// [`effect_bounds`] of a layer of this comp.
pub fn effect_bounds(&self, layer: &Layer) -> ([f64; 2], [f64; 2]) {
effect_bounds(self.project, self.comp, layer)
effect_bounds_of(self.comp, self.source_size(layer))
}
/// Effect space (see [`effect_bounds`]) → comp matrix.
pub fn effect_to_comp(&self, layer: &Layer) -> Mat3 {
@@ -248,6 +284,20 @@ impl<'a> EvalCtx<'a> {
}
}
impl crate::PropertyOverride {
fn value(&self, layer: &Layer, prop: &Property, time: Tick) -> Value {
if self.font {
// A Font control merges only the font into the target text, as
// `essential::with_overrides` does.
let mut value = prop.value_at(layer.layer_time(time));
effectcraft_project::essential::merge_font(&mut value, &self.value);
value
} else {
self.value.clone()
}
}
}
/// The protected regions of a comp (Responsive Design — Time markers), merged, sorted and
/// clamped to the comp, in seconds.
pub fn protected_regions(comp: &Comp) -> Vec<(f64, f64)> {
@@ -321,7 +371,11 @@ pub fn responsive_source_time(comp: &Comp, stretch: f64, elapsed: f64) -> Option
/// surrounds. Effects work in **effect space**, measured from that corner (After Effects' layer
/// space for effect points: a shape layer's default effect point is its bounds' centre).
pub fn effect_bounds(project: &Project, comp: &Comp, layer: &Layer) -> ([f64; 2], [f64; 2]) {
match source_size(project, layer) {
effect_bounds_of(comp, source_size(project, layer))
}
fn effect_bounds_of(comp: &Comp, size: (u32, u32)) -> ([f64; 2], [f64; 2]) {
match size {
(0, _) => {
let (w, h) = (comp.width as f64, comp.height as f64);
([w, h], [-w / 2.0, -h / 2.0])
+2 -2
View File
@@ -1,6 +1,6 @@
use super::footage::NoFootage;
use super::opts::RenderOpts;
use super::{EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, source_size, text};
use super::{EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, text};
/// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on.
/// One field of an interlaced frame as a full frame: the field's lines (`parity` 0 = even /
@@ -132,7 +132,7 @@ pub fn content_bounds(ctx: &EvalCtx, layer: &effectcraft_project::Layer) -> Opti
LayerSource::Shape => layer.props.sub("contents").and_then(|c| shapes::content_bounds(ctx, layer, c)).map(|r| [r.x0, r.y0, r.x1, r.y1]),
LayerSource::Camera | LayerSource::Light { .. } => None,
_ => {
let (w, h) = source_size(ctx.project, layer);
let (w, h) = ctx.source_size(layer);
(w > 0 && h > 0).then_some([0.0, 0.0, w as f64, h as f64])
}
}
+3 -2
View File
@@ -98,12 +98,13 @@ impl EffectHost for FxHost<'_, '_, '_> {
fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option<Vec<f32>> {
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
let LayerSource::Footage { item } = &other.source else { return None };
let ItemKind::Footage(f) = &self.r.project.item(*item)?.kind else { return None };
let item = self.ctx.media_item(other, *item);
let ItemKind::Footage(f) = &self.r.project.item(item)?.kind else { return None };
if !f.has_audio {
return None;
}
let st = other.layer_time(Tick::from_seconds_f64(start));
self.r.footage.audio(*item, f, st, frames, rate)
self.r.footage.audio(item, f, st, frames, rate)
}
fn self_at(&self, layer_time: f64, effects: usize) -> Option<Buf> {
+2 -2
View File
@@ -2,7 +2,7 @@ use super::frames::adaptive_samples;
use super::opts::{LayerTiming, Through};
use super::{
Arc, BlendMode, Buf, Comp, EvalCtx, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, Layer, LayerSource, Mat3,
MatteKind, ParallelIterator, Quality, Region, Renderer, Sampling, Tick, WarpOpts, cache, color, composite_warp_changed, masks, source_size, styles, vec2,
MatteKind, ParallelIterator, Quality, Region, Renderer, Sampling, Tick, WarpOpts, cache, color, composite_warp_changed, masks, styles, vec2,
};
/// Blend policy for placing a layer, including optional output-depth quantization.
@@ -52,7 +52,7 @@ impl<'a> Renderer<'a> {
}
}
let t0 = web_time::Instant::now();
let size = source_size(self.project, layer);
let size = ctx.source_size(layer);
let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
let st = styles::render(ctx, layer, &content, layer_size);
if let Some(t) = timing {
+1
View File
@@ -60,6 +60,7 @@ pub use layers::{Placement, StyledLayer};
pub(crate) use opts::Collapse3d;
pub use opts::{LayerTiming, RenderOpts};
use opts::{MAX_THROUGH, Through};
pub use pipeline::{Collapsed, MediaOverride, PropertyOverride};
pub struct Renderer<'a> {
pub project: &'a Project,
+117 -16
View File
@@ -7,6 +7,8 @@ use super::{
Arc, AutoKey, AutoPick, BlendMode, Buf, Comp, EffectCtx, EffectEnv, EvalCtx, Footage, FootageKind, FrameBlend, FrameRate, GroupKind, Image, ItemId,
ItemKind, Layer, LayerCache, LayerSource, Params, Project, Quality, Region, Renderer, Tick, cache, color, masks, shapes, styles, text, three_d, vec2,
};
use effectcraft_keyframe::Value;
use effectcraft_project::LayerId;
/// Coordinate space and stack range shared by CPU and accelerated effect evaluation.
#[derive(Clone, Copy)]
@@ -16,6 +18,88 @@ pub(crate) struct EffectStack {
pub origin: [f64; 2],
}
/// A property value an Essential Properties override gives a layer of a collapsed precomp.
#[derive(Clone, Debug)]
pub struct PropertyOverride {
pub(crate) layer: LayerId,
pub(crate) prop: u64,
pub(crate) value: Value,
/// A Font control changes only the font of the target text, not its text.
pub(crate) font: bool,
}
/// A Media Replacement an Essential Properties override gives a footage layer of a collapsed
/// precomp.
#[derive(Clone, Copy, Debug)]
pub struct MediaOverride {
pub(crate) layer: LayerId,
pub(crate) item: ItemId,
}
impl MediaOverride {
/// A Media Replacement of `layer`'s footage by `item`.
pub fn new(layer: LayerId, item: ItemId) -> Self {
MediaOverride { layer, item }
}
}
/// A collapsed precomp layer: the nested comp and the instance overrides that apply to it.
pub struct Collapsed {
pub item: ItemId,
pub(crate) property_overrides: Vec<PropertyOverride>,
pub(crate) media_overrides: Vec<MediaOverride>,
}
/// The property and media overrides of a collapsed precomp layer. `None` when its Essential
/// Properties group or the source comp's definitions are missing (the flattened path then
/// preserves the old behaviour).
fn collapsed_instance(ctx: &EvalCtx, layer: &Layer, item: ItemId) -> Option<(Vec<PropertyOverride>, Vec<MediaOverride>)> {
use effectcraft_project::essential::{self, ControlType, EgKind};
let over = essential::overridden(layer);
if over.is_empty() {
return Some((vec![], vec![]));
}
let g = essential::group(layer)?;
let eg = ctx.project.comp(item)?.essential.as_ref()?;
let mut property = vec![];
let mut fonts: Vec<PropertyOverride> = vec![];
let mut media = vec![];
g.walk("", &mut |_, p| {
if !over.contains(&p.uid) {
return;
}
let Some(control) = essential::control_of(&p.match_id) else { return };
let Some(ctl) = eg.resolve(control) else { return };
let value = ctx.value(layer, p);
match &ctl.kind {
EgKind::Property { .. } => {
let font = ctl.as_type == Some(ControlType::Font);
for (layer, prop) in ctl.kind.targets() {
let o = PropertyOverride { layer, prop, value: value.clone(), font };
if font { fonts.push(o) } else { property.push(o) }
}
}
EgKind::Media { layer: target } => {
let Value::Scalar(id) = value else { return };
let item = ItemId(id.max(0.0) as u64);
if matches!(ctx.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(_))) {
media.push(MediaOverride { layer: *target, item });
}
}
_ => {}
}
});
// A Font control merges into the text its instance shows, after a whole-value (Text) override
// of the same property, as `essential::with_overrides` does: keep one entry per property.
for f in fonts {
match property.iter_mut().rev().find(|o| o.layer == f.layer && o.prop == f.prop) {
Some(o) => effectcraft_project::essential::merge_font(&mut o.value, &f.value),
None => property.push(f),
}
}
Some((property, media))
}
impl<'a> Renderer<'a> {
pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> {
Renderer {
@@ -122,8 +206,10 @@ impl<'a> Renderer<'a> {
/// The nested comp of a precomp layer whose transformations collapse into this comp: the
/// Collapse Transformations switch is on and the layer has no masks, effects or layer
/// styles (those force a flattened render of the precomp, as in After Effects).
pub fn collapsed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<ItemId> {
/// styles (those force a flattened render of the precomp, as in After Effects). Essential
/// Properties overrides travel with it and are applied as the nested layers are evaluated,
/// so the nested comp is not rasterised at its frame.
pub fn collapsed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Collapsed> {
if !layer.switches.collapse || layer.switches.adjustment || self.depth > 12 {
return None;
}
@@ -140,24 +226,37 @@ impl<'a> Renderer<'a> {
if styles::active(ctx, layer) {
return None;
}
// Instance overrides (Essential Properties) need the nested comp rendered on its own.
if !effectcraft_project::essential::overridden(layer).is_empty() {
// A composition proxy or frame blending is drawn by the flattened path (`precomp_source`);
// collapsing renders the nested comp directly and would ignore both.
if self.proxy_for(*item).is_some() || frame_blend_mode(ctx, layer) != FrameBlend::Off {
return None;
}
Some(*item)
let (property_overrides, media_overrides) = collapsed_instance(ctx, layer, *item)?;
Some(Collapsed { item: *item, property_overrides, media_overrides })
}
/// A renderer and context that draw a collapsed precomp's layers straight into this comp:
/// their transforms are concatenated with the precomp layer's (one resample, no
/// rasterisation at the precomp's bounds), their opacity multiplied by `opacity`, and their
/// 3D layers use this comp's camera and lights.
pub fn collapse_into(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, item: ItemId, opacity: f32) -> Option<(Renderer<'a>, EvalCtx<'a>)> {
pub fn collapse_into<'p>(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &'p Collapsed, opacity: f32) -> Option<(Renderer<'p>, EvalCtx<'p>)>
where
'a: 'p,
{
let item = collapsed.item;
let nc = self.project.comp(item)?;
let t = ctx.nested_time(layer);
if !nc.covers(t) {
return None;
}
let nctx = EvalCtx { comp_id: item, comp: nc, time: t, ..*ctx };
let nctx = EvalCtx {
comp_id: item,
comp: nc,
time: t,
property_overrides: &collapsed.property_overrides,
media_overrides: &collapsed.media_overrides,
..*ctx
};
let (l2c, _) = ctx.layer_to_comp(layer);
let outer = Some(self.outer.map_or(l2c, |o| o * l2c));
let world = ctx.world_matrix(layer);
@@ -189,19 +288,19 @@ impl<'a> Renderer<'a> {
/// that then takes the precomp layer's track matte, Preserve Transparency and blend mode.
/// Returns the region left to re-quantise (nested layers drawn straight into `canvas`
/// quantise their own footprints).
pub(crate) fn draw_collapsed(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut Image, blank: bool) -> Region {
pub(crate) fn draw_collapsed(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &Collapsed, canvas: &mut Image, blank: bool) -> Region {
let opacity = ctx.opacity(layer) as f32 * self.opacity_mul;
if opacity <= 0.0 {
return Region::Empty;
}
let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some());
if layer.blend_mode == BlendMode::Normal && !matte && !layer.preserve_transparency {
if let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, opacity) {
if let Some((sub, nctx)) = self.collapse_into(ctx, layer, collapsed, opacity) {
sub.draw_comp(&nctx, canvas, blank);
}
return Region::Empty;
}
let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, 1.0) else { return Region::Empty };
let Some((sub, nctx)) = self.collapse_into(ctx, layer, collapsed, 1.0) else { return Region::Empty };
let mut iso = Image::new(canvas.width, canvas.height);
sub.draw_comp(&nctx, &mut iso, true);
self.composite_iso(ctx, layer, iso, canvas, opacity);
@@ -230,6 +329,7 @@ impl<'a> Renderer<'a> {
let continuous = match layer.source {
LayerSource::Text | LayerSource::Shape => true,
LayerSource::Footage { item } => {
let item = ctx.media_item(layer, item);
layer.switches.collapse && matches!(self.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if is_vector_footage(f))
}
_ => false,
@@ -267,7 +367,7 @@ impl<'a> Renderer<'a> {
}
pub(crate) fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> {
EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr, footage: Some(self.footage) }
EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr, footage: Some(self.footage), property_overrides: &[], media_overrides: &[] }
}
/// This renderer with another accelerator (or none): the passes of
@@ -406,7 +506,7 @@ impl<'a> Renderer<'a> {
blank = false;
} else {
let changed = match self.collapsed(ctx, visible[i]) {
Some(item) => self.draw_collapsed(ctx, visible[i], item, canvas, blank),
Some(collapsed) => self.draw_collapsed(ctx, visible[i], &collapsed, canvas, blank),
None => self.draw_layer(ctx, visible[i], canvas, blank),
};
i += 1;
@@ -637,7 +737,8 @@ impl<'a> Renderer<'a> {
Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: bs })
}
LayerSource::Footage { item } => {
let it = self.project.item(*item)?;
let item = ctx.media_item(layer, *item);
let it = self.project.item(item)?;
let ItemKind::Footage(f) = &it.kind else { return None };
if !f.has_video {
return None;
@@ -645,7 +746,7 @@ impl<'a> Renderer<'a> {
// Continuously rasterised vector footage: drawn at the on-screen scale.
if layer.switches.collapse && is_vector_footage(f) {
let k = self.raster_scale(ctx, layer);
if let Some(img) = self.footage.vector_frame(*item, f, k) {
if let Some(img) = self.footage.vector_frame(item, f, k) {
let mut buf = Buf { img: (*img).clone(), offset: [0.0; 2], scale: k };
if let Some(c) = self.pipe.media_in(f) {
color::convert(&mut buf.img, &c);
@@ -655,10 +756,10 @@ impl<'a> Renderer<'a> {
}
let lt = ctx.source_time(layer);
// The proxy is decoded instead, at its own size, and fills the footage's frame.
let proxy = self.proxy_for(*item);
let proxy = self.proxy_for(item);
let pf = proxy.unwrap_or(f);
let size = self.reduced_size(f.width, f.height).filter(|_| proxy.is_none());
let img = self.footage_frame(ctx, layer, *item, pf, lt, size)?;
let img = self.footage_frame(ctx, layer, item, pf, lt, size)?;
Some(self.footage_buf(&img, pf, f.width, f.height))
}
LayerSource::Text => Some(self.authored(text::render(ctx, layer, self.raster_scale(ctx, layer)))),
+61 -1
View File
@@ -3,7 +3,7 @@
use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::{TextDoc, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, Layer, LayerSource, Project, Quality, Solid};
use effectcraft_project::{BitDepth, Comp, FrameBlend, ItemId, ItemKind, Layer, LayerSource, Project, Quality, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{EvalCtx, Image, NoFootage, RenderOpts, Renderer, render_frame};
@@ -110,6 +110,66 @@ fn masks_on_a_collapsed_layer_force_a_flattened_render() {
assert!(max_diff(&a, &b) < 1e-4, "{}", max_diff(&a, &b));
}
/// #1747: the flattened path honours a composition proxy and frame blending; a collapsed layer
/// renders the nested comp directly and ignores both, so such a layer must not collapse.
#[test]
fn proxy_and_frame_blending_keep_a_precomp_on_the_flattened_path() {
let (mut p, cid) = scene(true, false);
let id = p.comp(cid).unwrap().layers[0].id;
let collapses = |p: &Project| {
let r = Renderer::new(p, &NoFootage, RenderOpts::default());
let ctx = EvalCtx::new(p, cid, p.comp(cid).unwrap(), Tick::ZERO);
r.collapsed(&ctx, ctx.comp.layer(id).unwrap()).is_some()
};
assert!(collapses(&p), "a plain precomp collapses");
// Frame blending needs both the comp's switch and the layer's.
p.comp_mut(cid).unwrap().enable_frame_blending = true;
p.comp_mut(cid).unwrap().layers[0].switches.frame_blend = FrameBlend::FrameMix;
assert!(!collapses(&p), "frame blending keeps the flattened path");
p.comp_mut(cid).unwrap().enable_frame_blending = false;
p.comp_mut(cid).unwrap().layers[0].switches.frame_blend = FrameBlend::Off;
// A composition proxy stands in for the precomp's pixels.
let LayerSource::Comp { item } = p.comp(cid).unwrap().layers[0].source else { panic!("a precomp layer") };
let proxy =
effectcraft_project::Proxy { footage: effectcraft_project::Footage { has_video: true, width: 32, height: 32, ..Default::default() }, enabled: true };
p.item_mut(item).unwrap().proxy = Some(Box::new(proxy));
assert!(!collapses(&p), "a proxy keeps the flattened path");
}
/// #1747: an effect on B that reads A must not reuse its cached buffer when two collapsed
/// instances give A different static overrides (`hash_values` only sees animated values).
#[test]
fn referenced_layer_overrides_change_the_owner_cache_key() {
let mut p = project();
let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let a = solid(&mut p, &comp, [1.0, 1.0, 1.0], (40, 40), [30.0, 50.0, 0.0]);
let aid = a.id;
let mut b = solid(&mut p, &comp, [1.0, 1.0, 1.0], (100, 100), [50.0, 50.0, 0.0]);
let bid = b.id;
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let spec = effectcraft_effects::find("ec.channel.setmatte").unwrap();
let mut fx = effectcraft_effects::instantiate(spec, &mut ids, spec.name, [100.0, 100.0]);
fx.prop_mut("takeMatteFromLayer").unwrap().value = Value::Layer(Some(a.id.0));
p.next_id = next;
b.props.sub_mut("effects").unwrap().children.push(fx.into());
let mut c = comp;
c.layers = vec![b, a];
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into()));
let comp = p.comp(cid).unwrap();
let b = comp.layer(bid).unwrap();
let opacity = comp.layer(aid).unwrap().props.prop("transform/opacity").unwrap().uid;
let key = |value: f64| {
let overrides = [crate::PropertyOverride { layer: aid, prop: opacity, value: Value::Scalar(value), font: false }];
let ctx =
EvalCtx { project: &p, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &overrides, media_overrides: &[] };
crate::cache::layer_key(&ctx, b, 1.0, false, false)
};
let (opaque, half) = (key(100.0), key(50.0));
assert!(opaque.is_some(), "the owner is cacheable");
assert_ne!(opaque, half, "A's static override changes B's key");
}
#[test]
fn collapsed_3d_layers_join_the_parent_scene() {
// Parent: a red 3D solid at z = 0 above (in the stack) a collapsed 3D precomp holding a
+10 -1
View File
@@ -134,7 +134,16 @@ fn input_keys_ignore_later_effects() {
}
let key = |p: &Project| {
let comp = p.comp(cid).unwrap();
let ctx = crate::EvalCtx { project: p, comp_id: cid, comp, time: Tick::from_seconds_f64(0.5), expr: None, footage: None };
let ctx = crate::EvalCtx {
project: p,
comp_id: cid,
comp,
time: Tick::from_seconds_f64(0.5),
expr: None,
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let l = &comp.layers[0];
(crate::cache::input_key(&ctx, l, 1.0, false, false, 1), crate::cache::input_key(&ctx, l, 1.0, false, false, 2))
};
+4 -4
View File
@@ -857,8 +857,8 @@ fn emit_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, b: &mut Bu
if layer.environment || layer.switches.adjustment || ctx.opacity(layer) <= 0.0 || !layer.has_video() {
return;
}
if let Some(item) = r.collapsed(ctx, layer) {
collapsed_layer(r, ctx, layer, item, b);
if let Some(collapsed) = r.collapsed(ctx, layer) {
collapsed_layer(r, ctx, layer, &collapsed, b);
} else if layer.source.is_model() {
model_layer(r, ctx, layer, b);
} else if matches!(layer.source, LayerSource::Text | LayerSource::Shape)
@@ -874,9 +874,9 @@ fn emit_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, b: &mut Bu
/// (meshes, extrusions, cards) placed by the precomp layer's world transform, so they
/// intersect, occlude and shadow the parent's layers. Nested 2D layers lie on the precomp
/// layer's plane; nested lights and cameras are ignored (the parent's light the scene).
fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: effectcraft_project::ItemId, b: &mut Builder) {
fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, collapsed: &crate::Collapsed, b: &mut Builder) {
let op = ctx.opacity(layer) as f32 * r.opacity_mul();
let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return };
let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, op) else { return };
let saved = b.outer;
b.outer = saved * ctx.world_matrix(layer);
let t = nctx.time;
+40 -4
View File
@@ -370,7 +370,16 @@ fn extruded_text_renders_as_a_mesh() {
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
set(l, "transform/rotationY", Value::Scalar(40.0));
});
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let ctx = crate::EvalCtx {
project: &p,
comp_id: cid,
comp: p.comp(cid).unwrap(),
time: Tick::ZERO,
expr: None,
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let l = &ctx.comp.layers[0];
let params = scene::extrusion_params(&ctx, l).unwrap();
let meshes = scene::extruded_meshes(&ctx, l, &params);
@@ -409,7 +418,16 @@ fn shape_surface_items_colour_the_extruded_surfaces() {
set(l, "geometryOptions/extrusionDepth", Value::Scalar(40.0));
set(l, "transform/position", Value::Vec3([100.0, 100.0, 0.0]));
});
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let ctx = crate::EvalCtx {
project: &p,
comp_id: cid,
comp: p.comp(cid).unwrap(),
time: Tick::ZERO,
expr: None,
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let l = &ctx.comp.layers[0];
let params = scene::extrusion_params(&ctx, l).unwrap();
let meshes = scene::extruded_meshes(&ctx, l, &params);
@@ -445,7 +463,16 @@ fn text_animators_colour_the_extruded_surfaces() {
set(l, "geometryOptions/extrusionDepth", Value::Scalar(30.0));
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
});
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let ctx = crate::EvalCtx {
project: &p,
comp_id: cid,
comp: p.comp(cid).unwrap(),
time: Tick::ZERO,
expr: None,
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let l = &ctx.comp.layers[0];
let params = scene::extrusion_params(&ctx, l).unwrap();
let meshes = scene::extruded_meshes(&ctx, l, &params);
@@ -643,7 +670,16 @@ fn extruded_strokes_render_as_bevelled_meshes() {
set(l, "geometryOptions/extrusionDepth", Value::Scalar(20.0));
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
});
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let ctx = crate::EvalCtx {
project: &p,
comp_id: cid,
comp: p.comp(cid).unwrap(),
time: Tick::ZERO,
expr: None,
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let l = &ctx.comp.layers[0];
let params = scene::extrusion_params(&ctx, l).unwrap();
let meshes = scene::extruded_meshes(&ctx, l, &params);
+11 -18
View File
@@ -195,13 +195,13 @@ fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize {
}
/// Prepare a layer (buffer, geometry, material). `None` when it shows nothing.
pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option<Item<'a>> {
pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option<Item<'a>> {
let buf = r.blend_layer_buf(ctx, layer)?;
prepare_with(r, ctx, layer, order, out, in_run, PlaneInput { buf, local: Mat4::IDENTITY })
}
/// Prepare a layer as planes: one per character for per-character 3D text, else one.
pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec<Item<'a>> {
pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec<Item<'a>> {
if crate::text::per_char_3d(ctx, layer) {
return crate::text::per_char_planes(ctx, layer, r.opts.scale)
.into_iter()
@@ -218,7 +218,7 @@ struct PlaneInput {
}
/// Prepare the plane's geometry, motion samples and material.
fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool, plane: PlaneInput) -> Option<Item<'a>> {
fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool, plane: PlaneInput) -> Option<Item<'a>> {
let PlaneInput { mut buf, local } = plane;
if buf.img.is_empty() {
return None;
@@ -449,8 +449,8 @@ fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32,
.flat_map_iter(|(i, l)| {
// Stack order with room for a collapsed precomp's layers in between.
let i = i * 1024;
if let Some(item) = r.collapsed(ctx, l) {
return collapsed_items(r, ctx, l, item, i, out);
if let Some(collapsed) = r.collapsed(ctx, l) {
return collapsed_items(r, ctx, l, &collapsed, i, out);
}
let mut its = prepare_all(r, ctx, l, i, out, true);
if !its.is_empty() {
@@ -483,20 +483,13 @@ fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32,
/// 3D space (world = precomp layer's world × nested world), depth-sorted with the run, so they
/// intersect the parent's 3D layers; nested 2D layers lie on the precomp layer's plane in stack
/// order. (Nested adjustment layers are skipped here.)
fn collapsed_items<'a>(
r: &Renderer<'a>,
ctx: &EvalCtx<'a>,
layer: &'a Layer,
item: effectcraft_project::ItemId,
order: usize,
out: (u32, u32),
) -> Vec<Item<'a>> {
fn collapsed_items<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &crate::Collapsed, order: usize, out: (u32, u32)) -> Vec<Item<'a>> {
let op = ctx.opacity(layer) as f32 * r.opacity_mul();
let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return vec![] };
let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, op) else { return vec![] };
let t = nctx.time;
let any_solo = nctx.comp.any_solo();
let nested: Vec<&'a Layer> = nctx
.comp
let Some(nc) = r.project.comp(collapsed.item) else { return vec![] };
let any_solo = nc.any_solo();
let nested: Vec<&'a Layer> = nc
.layers
.iter()
.rev()
@@ -863,7 +856,7 @@ pub(crate) fn aux_pass(r: &Renderer, ctx: &EvalCtx) -> effectcraft_raster::AuxCh
if nv[2] > 0.0 {
nv = nv.map(|v| -v);
}
let (sw, sh) = crate::source_size(r.project, it.layer);
let (sw, sh) = ctx.source_size(it.layer);
let size = if sw == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [sw as f64, sh as f64] };
(number(it.layer), source_id(it.layer), crypto_hash(&it.layer.name), crypto_hash(&source_name(it.layer)), nv, size)
})
+65
View File
@@ -90,6 +90,44 @@ pub fn stroke_dash(name: &str) -> Option<&'static str> {
const TEXT_ANIMATOR: &[(&str, &str)] = &[("selectors", "ADBE Text Selectors"), ("properties", "ADBE Text Animator Properties")];
/// Animator property ids and their stable After Effects scripting names. Shared by lookup,
/// `.matchName`, and `addProperty` so the same property has one name in every direction.
pub const TEXT_ANIMATOR_PROPERTIES: &[(&str, &str)] = &[
("anchor", "ADBE Text Anchor Point 3D"),
("position", "ADBE Text Position 3D"),
("scale", "ADBE Text Scale 3D"),
("skew", "ADBE Text Skew"),
("rotation", "ADBE Text Rotation"),
("opacity", "ADBE Text Opacity"),
("fillColor", "ADBE Text Fill Color"),
("fillHue", "ADBE Text Fill Hue"),
("fillSaturation", "ADBE Text Fill Saturation"),
("fillBrightness", "ADBE Text Fill Brightness"),
("fillOpacity", "ADBE Text Fill Opacity"),
("strokeColor", "ADBE Text Stroke Color"),
("strokeHue", "ADBE Text Stroke Hue"),
("strokeSaturation", "ADBE Text Stroke Saturation"),
("strokeBrightness", "ADBE Text Stroke Brightness"),
("strokeOpacity", "ADBE Text Stroke Opacity"),
("strokeWidth", "ADBE Text Stroke Width"),
("tracking", "ADBE Text Tracking Amount"),
("lineAnchor", "ADBE Text Line Anchor"),
("lineSpacing", "ADBE Text Line Spacing"),
("characterOffset", "ADBE Text Character Offset"),
("characterValue", "ADBE Text Character Replace"),
("blur", "ADBE Text Blur"),
];
/// Companion properties that exist below an animator but are not kinds `addProperty` accepts:
/// they come with Tracking (Track Type), Skew (Skew Axis) and per-character 3D Rotation
/// (X and Y Rotation). Lookup and `.matchName` know them; `addProperty` does not.
pub const TEXT_ANIMATOR_COMPANIONS: &[(&str, &str)] = &[
("trackingType", "ADBE Text Track Type"),
("skewAxis", "ADBE Text Skew Axis"),
("rotationX", "ADBE Text Rotation X"),
("rotationY", "ADBE Text Rotation Y"),
];
const TEXT_SELECTORS: &[(&str, &str)] =
&[("rangeSelector", "ADBE Text Selector"), ("wigglySelector", "ADBE Text Wiggly Selector"), ("expressionSelector", "ADBE Text Expressible Selector")];
@@ -487,6 +525,9 @@ pub fn node(chain: &[&Node]) -> String {
"text" if pid == "animators" => "ADBE Text Animator".into(),
"text" if pid == "selectors" => find(TEXT_SELECTORS, id).map(str::to_string).unwrap_or_else(own),
"text" if pid == "rangeSelector" => find(TEXT_RANGE_SELECTOR, id).map(str::to_string).unwrap_or_else(own),
"text" if pid == "properties" && chain.len() == 5 && chain.get(1).is_some_and(|n| n.match_id() == "animators") => {
find(TEXT_ANIMATOR_PROPERTIES, id).or_else(|| find(TEXT_ANIMATOR_COMPANIONS, id)).map(str::to_string).unwrap_or_else(own)
}
"text" if grand.map(|g| g.match_id()) == Some("animators") => find(TEXT_ANIMATOR, id).map(str::to_string).unwrap_or_else(own),
"cameraOptions" => find(CAMERA, id).map(str::to_string).unwrap_or_else(own),
"lightOptions" => find(LIGHT, id).map(str::to_string).unwrap_or_else(own),
@@ -537,6 +578,30 @@ mod tests {
assert_eq!(shape_kind("nope"), None);
}
#[test]
fn text_animator_property_names_are_canonical_and_scoped() {
let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m));
let (text, animators, animator, properties) = (n("text"), n("animators"), n("animator"), n("properties"));
for (id, name) in TEXT_ANIMATOR_PROPERTIES {
let prop = n(id);
assert_eq!(node(&[&text, &animators, &animator, &properties, &prop]), *name);
assert_eq!(node(&[&properties, &prop]), *id, "an unrelated Properties group keeps its own ids");
}
}
/// #1063: the companions an animator property brings (Track Type, Skew Axis, X / Y Rotation)
/// have their After Effects names too, though `addProperty` does not accept them.
#[test]
fn text_animator_companions_have_after_effects_names() {
let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m));
let (text, animators, animator, properties) = (n("text"), n("animators"), n("animator"), n("properties"));
for (id, name) in TEXT_ANIMATOR_COMPANIONS {
let prop = n(id);
assert_eq!(node(&[&text, &animators, &animator, &properties, &prop]), *name);
assert!(!TEXT_ANIMATOR_PROPERTIES.iter().any(|(k, m)| k == id || m == name), "{id} is not an addProperty kind");
}
}
#[test]
fn text_range_selector_names() {
let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m));
+3 -27
View File
@@ -743,36 +743,12 @@ fn resolve_add(s: &Session, a: &J) -> R {
/// A text animator property (`layer.addTextAnimatorProperty` key) from an After Effects match
/// name or a display name.
fn text_animator_property(name: &str) -> Option<&'static str> {
const AE: &[(&str, &str)] = &[
("ADBE Text Anchor Point 3D", "anchor"),
("ADBE Text Position 3D", "position"),
("ADBE Text Scale 3D", "scale"),
("ADBE Text Skew", "skew"),
("ADBE Text Rotation", "rotation"),
("ADBE Text Opacity", "opacity"),
("ADBE Text Fill Color", "fillColor"),
("ADBE Text Fill Hue", "fillHue"),
("ADBE Text Fill Saturation", "fillSaturation"),
("ADBE Text Fill Brightness", "fillBrightness"),
("ADBE Text Fill Opacity", "fillOpacity"),
("ADBE Text Stroke Color", "strokeColor"),
("ADBE Text Stroke Hue", "strokeHue"),
("ADBE Text Stroke Saturation", "strokeSaturation"),
("ADBE Text Stroke Brightness", "strokeBrightness"),
("ADBE Text Stroke Opacity", "strokeOpacity"),
("ADBE Text Stroke Width", "strokeWidth"),
("ADBE Text Tracking Amount", "tracking"),
("ADBE Text Line Anchor", "lineAnchor"),
("ADBE Text Line Spacing", "lineSpacing"),
("ADBE Text Character Offset", "characterOffset"),
("ADBE Text Character Replace", "characterValue"),
("ADBE Text Blur", "blur"),
];
if let Some((_, k)) = AE.iter().find(|(m, _)| m.eq_ignore_ascii_case(name)) {
let table = matchnames::TEXT_ANIMATOR_PROPERTIES;
if let Some((k, _)) = table.iter().find(|(_, m)| m.eq_ignore_ascii_case(name)) {
return Some(k);
}
let n = loose(name);
AE.iter().map(|(_, k)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking"))
table.iter().map(|(k, _)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking"))
}
/// `app.findMenuCommandId(name)`: 1-based index of the command whose label matches (menu items
+2 -3
View File
@@ -734,9 +734,8 @@ PropertyGroup.prototype.addProperty = function (name) {
} else if (r.kind === "animator") {
uid = __call("layer.addTextAnimator", { comp: L.__comp, layer: L.__id, properties: [] }).animator;
} else if (r.kind === "animatorProperty") {
var before = this.numProperties;
__call("layer.addTextAnimatorProperty", { comp: L.__comp, layer: L.__id, animator: r.animator, property: r.property });
return this.property(before + 1);
var added = __call("layer.addTextAnimatorProperty", { comp: L.__comp, layer: L.__id, animator: r.animator, property: r.property });
return this.property(added.index);
} else if (r.kind === "selector") {
var n0 = this.numProperties;
__call("layer.addTextSelector", { comp: L.__comp, layer: L.__id, animator: r.animator, kind: r.selector });
+44
View File
@@ -1719,6 +1719,50 @@ fn layer_property_resolves_transform_and_source_text_shortcuts() {
assert_eq!(layer(c, "s").props.prop("transform/opacity").unwrap().value, KV::Scalar(25.0));
}
/// A text animator property added by its After Effects name is the one asked for, not its Tracking
/// Type companion, and it can be found again by that name (#1063).
#[test]
fn scripted_text_animator_property_is_the_one_asked_for() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("Track", 960, 300, 1, 3, 30);
var t = c.layers.addText("MOVARA");
var a = t.property("ADBE Text Properties").property("ADBE Text Animators").addProperty("ADBE Text Animator");
var props = a.property("ADBE Text Animator Properties");
var added = props.addProperty("ADBE Text Tracking Amount");
added.setValue(20);
[added.name, props.property("ADBE Text Tracking Amount").value, props.property("ADBE Text Tracking Amount").name,
added.matchName, props.property(added.matchName).propertyIndex]
"#,
);
assert_eq!(o.result, json!(["Tracking Amount", 20, "Tracking Amount", "ADBE Text Tracking Amount", 2]));
}
/// The companion properties added with Tracking and Skew are found by their After Effects names,
/// but cannot be added on their own (#1063).
#[test]
fn text_animator_companion_properties_resolve_by_name() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("Track", 960, 300, 1, 3, 30);
var t = c.layers.addText("MOVARA");
var props = t.property("ADBE Text Properties").property("ADBE Text Animators").addProperty("ADBE Text Animator").property("ADBE Text Animator Properties");
props.addProperty("ADBE Text Tracking Amount");
props.addProperty("ADBE Text Skew");
var type = props.property("ADBE Text Track Type");
var axis = props.property("ADBE Text Skew Axis");
var added = "";
try { props.addProperty("ADBE Text Track Type"); added = "added"; } catch (e) { added = "refused"; }
[type ? type.matchName : null, axis ? axis.matchName : null, added]
"#,
);
assert_eq!(o.result, json!(["ADBE Text Track Type", "ADBE Text Skew Axis", "refused"]));
}
/// #1936: an array whose length doesn't match the property's dimension is an error in After
/// Effects, not a value to truncate or broadcast. Two values for a 3-D property stay legal.
#[test]
+10 -1
View File
@@ -222,7 +222,16 @@ fn position_now(s: &Session, layer: u64) -> [f64; 2] {
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap();
let l = comp.layer(effectcraft_engine::project::LayerId(layer)).unwrap();
let ctx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: None };
let ctx = effectcraft_engine::render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: s.time(),
expr: s.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let v = ctx.value(l, l.props.prop("transform/position").unwrap()).components();
[v[0], v[1]]
}
+10 -1
View File
@@ -151,7 +151,16 @@ fn vertex_delta(app: &EffectcraftApp, layer: effectcraft_engine::project::LayerI
let s = &app.session;
let (Some(cid), Some(comp)) = (s.active_comp_id(), s.active_comp()) else { return d };
let Some(l) = comp.layer(layer) else { return d };
let ectx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: None };
let ectx = effectcraft_engine::render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: s.time(),
expr: s.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let (l2c, _) = crate::panels::viewer::l2c(&ectx, l);
l2c.inverse().map_or(d, |inv| {
let v = inv.apply_vec(vec2(d[0], d[1]));
@@ -166,7 +166,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let comp_name = app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
let snap_project = app.session.project.clone();
let snap_expr = app.session.expr.clone();
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: app.session.time(), expr: snap_expr.as_deref(), footage: None };
let ectx = EvalCtx {
project: &snap_project,
comp_id: cid,
comp: &comp,
time: app.session.time(),
expr: snap_expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let hdr = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0));
p.text(pos2(hdr.min.x + 10.0, hdr.center().y), Align2::LEFT_CENTER, format!("{} • {}", comp_name, layer.name), Tokens::ui(11.5), t.text_dim);
p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator));
+1
View File
@@ -111,6 +111,7 @@ pub const COMPLETIONS: &[&str] = &[
"ease",
"easeIn",
"easeOut",
"bezier",
"clamp",
"random",
"gaussRandom",
+10 -1
View File
@@ -104,7 +104,16 @@ fn render_tex(
match cached {
Some(c) if c.key == key => Some(c),
_ => {
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time, expr: app.session.expr.as_deref(), footage: None };
let ectx = EvalCtx {
project: &app.session.project,
comp_id: cid,
comp,
time,
expr: app.session.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() });
r.expr = app.session.expr.as_deref();
r.cache = Some(&app.session.layer_cache);
+10 -1
View File
@@ -70,7 +70,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let project = app.session.project.clone();
let expr = app.session.expr.clone();
let now = app.session.time();
let ectx = EvalCtx { project: &project, comp_id: cid, comp: &comp, time: now, expr: expr.as_deref(), footage: None };
let ectx = EvalCtx {
project: &project,
comp_id: cid,
comp: &comp,
time: now,
expr: expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let x0 = rect.min.x + PAD;
let w = rect.width() - 2.0 * PAD;
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("props-scroll"), rect);
+10 -1
View File
@@ -69,7 +69,16 @@ pub fn draw_overlay(
let ov = match cached {
Some(c) if c.key == key => Some(c),
_ => {
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time, expr: app.session.expr.as_deref(), footage: None };
let ectx = EvalCtx {
project: &app.session.project,
comp_id: cid,
comp,
time,
expr: app.session.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() });
r.expr = app.session.expr.as_deref();
r.cache = Some(&app.session.layer_cache);
+10 -1
View File
@@ -57,7 +57,16 @@ pub fn text_target(app: &EffectcraftApp) -> Option<TextTarget> {
.iter()
.filter_map(|id| comp.layer(*id))
.find(|l| matches!(l.source, effectcraft_engine::project::LayerSource::Text))?;
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time: app.session.time(), expr: app.session.expr.as_deref(), footage: None };
let ectx = EvalCtx {
project: &app.session.project,
comp_id: cid,
comp,
time: app.session.time(),
expr: app.session.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
effectcraft_engine::render::text::source_text(&ectx, layer).map(|d| TextTarget { layer: layer.id.0, doc: d, range: None })
}
+10 -1
View File
@@ -1865,7 +1865,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
pick_whip_edge_scroll(app, g);
let snap_project = app.session.project.clone();
let snap_expr = app.session.expr.clone();
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: g.time, expr: snap_expr.as_deref(), footage: None };
let ectx = EvalCtx {
project: &snap_project,
comp_id: cid,
comp: &comp,
time: g.time,
expr: snap_expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let mut rw = begin_rows(app, ui, tl);
let g = &mut rw;
draw_rows(app, ui, g, &ectx);
+10 -1
View File
@@ -178,7 +178,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
phase_frame(app, &mut v);
let snap_project = app.session.project.clone();
let snap_expr = app.session.expr.clone();
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: v.time, expr: snap_expr.as_deref(), footage: None };
let ectx = EvalCtx {
project: &snap_project,
comp_id: cid,
comp: &comp,
time: v.time,
expr: snap_expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
phase_guides(app, &ectx, &mut v);
phase_layers(app, ui, &ectx, &mut v);
phase_scene(app, &ectx, &mut v);
@@ -202,6 +202,8 @@ pub(super) fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b:
time: app.session.time(),
expr: None,
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let inv = l2c(&ectx, &l).0.inverse().unwrap_or(Mat3::IDENTITY);
let p0 = inv.apply(gv2(cx - w / 2.0, cy - h / 2.0));
+10 -1
View File
@@ -44,7 +44,16 @@ pub(super) fn aux_views(
// layout shows them), evaluated on a snapshot of the project.
let snap_project = app.session.project.clone();
let snap_expr = app.session.expr.clone();
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp, time: t, expr: snap_expr.as_deref(), footage: None };
let ectx = EvalCtx {
project: &snap_project,
comp_id: cid,
comp,
time: t,
expr: snap_expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let selected = app.session.state.selected_layers.clone();
let mut picked: Option<(usize, View3D)> = None;
for (i, (r, v)) in aux.into_iter().enumerate() {
+8 -3
View File
@@ -401,7 +401,10 @@ hierarchical block-matching optical flow and a bidirectional warp (Pixel Motion,
its nested layers straight into the parent with concatenated transforms (one resample), so
nested blend modes and adjustment layers act on the parent's layers; nested 3D layers use the
parent's camera and lights and, when the precomp layer is 3D, are depth-sorted with the parent's
3D run. Masks, effects or styles force a flattened render. Text layers are always rasterised at
3D run. Essential Properties overrides (properties, Font and Media Replacement) travel with the
collapsed context and are applied while the nested layers are evaluated, so they do not force a
flattened render or clip at the precomp's frame. Masks, effects or styles force a flattened
render. Text layers are always rasterised at
their on-screen scale, as text is vector in After Effects; on shape layers and vector footage the
switch is Continuously Rasterize and does the same. Quality: Draft samples
nearest-neighbour, Wireframe draws the layer bounds. Slip edit (`layer.slip`, Alt+PageUp/Down,
@@ -468,8 +471,10 @@ effects crate, hence the `effects → track` edge.
`essential` field lists its exposed controls (properties, Media Replacement, groups, comments).
Every precomp layer of such a comp gets an Essential Properties group (`GroupKind::Essential`,
children `eg<control id>`), kept in step by `Session::edit`; editing a child overrides it for
that instance. The renderer draws an instance with overrides from a copy of the project with the
overridden source properties set (`essential::with_overrides`). Templates are `.ectemplate`
that instance. A collapsed instance carries its property, Font and Media Replacement overrides
into the nested render context (`Collapsed`, `EvalCtx::{property_overrides, media_overrides}`);
other instances are drawn from a copy of the project with the overridden source properties set
(`essential::with_overrides`). Templates are `.ectemplate`
ZIPs: `manifest.json` (controls), `project.ecproj` (the comp and its dependencies), `media/…` and
`poster.png`; importing offsets every id past the project's (`essential::offset_ids`).
**Responsive Design — Time**: a time-stretched precomp maps time piecewise so its protected