mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 04:57:54 -06:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
Generated
+1
@@ -1623,6 +1623,7 @@ name = "effectcraft-media"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-color",
|
||||
"effectcraft-model",
|
||||
"effectcraft-pdf",
|
||||
"effectcraft-project",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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])"),
|
||||
],
|
||||
),
|
||||
(
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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: &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
@@ -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() } };
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -12,4 +12,5 @@ mod relink;
|
||||
mod tga_import;
|
||||
mod timeline_interop;
|
||||
mod vector_import;
|
||||
mod wav_markers;
|
||||
mod webm_metadata;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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", <xt), 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 & 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 é"/></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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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])
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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
@@ -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)))),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, ¶ms);
|
||||
@@ -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, ¶ms);
|
||||
@@ -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, ¶ms);
|
||||
@@ -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, ¶ms);
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]]
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -111,6 +111,7 @@ pub const COMPLETIONS: &[&str] = &[
|
||||
"ease",
|
||||
"easeIn",
|
||||
"easeOut",
|
||||
"bezier",
|
||||
"clamp",
|
||||
"random",
|
||||
"gaussRandom",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user