mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-11 02:41:57 -06:00
* 3D: Orientation and X/Y/Z Rotation turn Z first, keyed Orientation slerps, as After Effects After Effects composes a 3D layer's (and a camera's) Orientation and its X/Y/Z Rotation as Rx * Ry * Rz each, Orientation before the axis rotations; EffectCraft used Rz * Ry * Rx. Measured on synthetic AE 2026 cases: Orientation [10,20,30] (488) was 5.2 % of the frame off and now matches to the anti-aliased edge (0.03 %); X/Y/Z Rotation 10/20/30 (800), Orientation + X Rotation (801) and one-node cameras (804, 805) likewise. Keyed Orientation moves along the shortest arc between the keys (quaternion slerp, eased in time, angles written in [0, 360)): keys [0,0,0] -> [10,20,30] read back [6.2643, 9.3531, 15.2190] half-way where EffectCraft gave [5,10,15]; [0,0,0] -> [60,80,40] linear and eased match AE's read-backs within 0.002 deg. lookAt() returns AE's angles (z axis on the target, x axis level; two AE read-backs within 1e-3 deg). The camera tracker, VR editor, Look At and orbit commands use the new decomposition. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keyframes: a linear side next to an eased side has 16.67 % influence, as After Effects In a segment that is Bezier on one side and linear on the other, the linear side keeps the segment's average speed, but with the 16.67 % influence After Effects shows for linear keys in the Keyframe Velocity dialog (and reports to scripts), not 33.3 %. Measured on an After Effects 2026 time-remap render (a square at x = 50 + 455 * remap): out of a key at 2 s (3.5, speed 0, influence 50 %) into a linear key at 4 s (0.5), AE plays 3.290 at 2.5 s and 2.657 at 3 s; EffectCraft played 3.308 and 2.629 (8 and 13 px off), now the same square positions to the pixel. LINEAR_SIDE_INFLUENCE is used by evaluation (scalar, per-dimension, paths and spatial), side_ease (Graph Editor, velocity dialog, keyIn/OutTemporalEase) and the Lottie export, so exported easing plays as EffectCraft does. Segments linear on both sides stay straight lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Timewarp: retime the layer's source without the effects above it, as After Effects After Effects' Timewarp reads the layer's source (with its masks) at the retimed times and ignores the effects applied before it: a red solid with a Gradient Ramp and then Timewarp (Speed 25) renders as the red solid in AE 2026 (EXPORTER e2 read-back). EffectCraft retimed the layer with its effects (mean difference 127.5/255 over the comp), now identical (0, CPU and GPU). Echo, Posterize Time and the other time effects keep reading the effects before them, as measured before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Image sequences: a gap in the numbering holds the previous frame, as After Effects After Effects 2026 shows the last existing file across a gap in a sequence's numbering (case 860: files 1, 2, 4, 5 show frame 2 at frame 3; VERIFY read-back). EffectCraft drew colour bars there. The media pool now holds the previous file (Footage::sequence_file_held); colour bars remain only where no file precedes the frame. Missing-frame reports on import are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Review: keyed Orientation uses LINEAR_SIDE_INFLUENCE for linear sides; real NaN assert evaluate_orientation still gave a linear side (and the unreachable missing-key fallback) 1/3 influence while segment_value, side_ease and the Lottie export use LINEAR_SIDE_INFLUENCE (1/6), so a keyed Orientation next to an eased key moved on a different curve than every other property (CodeRabbit on #1876). New test: orientation_linear_side_next_to_an_eased_side_matches_a_scalar (both directions). The always-true NaN assert in keyed_orientation_slerps_as_after_effects now checks that a non-finite key comes back as given; Property::value_at says why the match_id "orientation" routing is safe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Format keyframe orientation test (cargo fmt) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Move the image-sequence gap change out of this branch The gap change (hold the previous file instead of colour bars) reverses #297's documented behaviour and goes to its own PR, as requested in review. pool.rs, sequence.rs, the footage test and docs/footage.md / docs/agents.md are back to main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
166 lines
6.8 KiB
Rust
166 lines
6.8 KiB
Rust
//! Window ▸ VR Comp Editor: our own panel over the VR environments Composition ▸ VR ▸ Create VR
|
||
//! Environment builds (an equirectangular output comp ← a 3:2 cube map ← six face comps, each
|
||
//! with a 90° camera). It lists the environments and edits the 360 view's camera orientation:
|
||
//! the six face cameras turn together, so the whole equirectangular output rotates (pan / tilt /
|
||
//! roll the viewer's starting direction) without touching the scene.
|
||
//!
|
||
//! The view orientation is not stored separately: it is read back from the Front camera
|
||
//! (`camera = view × face base`), so editing a camera by hand shows up here too.
|
||
|
||
use effectcraft_geom::{Mat4, vec3};
|
||
use effectcraft_project::{GroupKind, ItemId, LayerId, LayerSource, Project};
|
||
use serde_json::{Value, json};
|
||
|
||
use super::app_more::{CELLS_3X2, FACES, ae, grouped, orientation_for_axes};
|
||
use super::{CommandSpec, always, bad};
|
||
use crate::{EngineError, Result, Session, cmd, query};
|
||
|
||
/// A VR environment: its output comp, cube map and the face cameras in [`FACES`] order.
|
||
pub struct VrEnv {
|
||
pub output: ItemId,
|
||
pub cube: ItemId,
|
||
pub faces: [(ItemId, LayerId); 6],
|
||
}
|
||
|
||
/// The VR environments of a project.
|
||
pub fn environments(p: &Project) -> Vec<VrEnv> {
|
||
let mut out = vec![];
|
||
for (oid, oc) in p.comps() {
|
||
for l in &oc.layers {
|
||
let LayerSource::Comp { item: cube } = l.source else { continue };
|
||
let vr = l.effects().is_some_and(|fx| fx.groups().any(|g| matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.vr.converter")));
|
||
if !vr {
|
||
continue;
|
||
}
|
||
let Some(cc) = p.comp(cube) else { continue };
|
||
let size = (cc.width / 3).max(1) as f64;
|
||
let mut faces: [Option<(ItemId, LayerId)>; 6] = [None; 6];
|
||
for fl in &cc.layers {
|
||
let LayerSource::Comp { item: face } = fl.source else { continue };
|
||
let pos = fl.props.prop("transform/position").map(|x| x.value.components()).unwrap_or_default();
|
||
let (Some(x), Some(y)) = (pos.first(), pos.get(1)) else { continue };
|
||
let cell = ((x / size).floor().max(0.0) as u32, (y / size).floor().max(0.0) as u32);
|
||
let Some(i) = CELLS_3X2.iter().position(|c| *c == cell) else { continue };
|
||
let cam = p.comp(face).and_then(|fc| fc.layers.iter().find(|x| x.is_camera())).map(|x| x.id);
|
||
if let Some(cam) = cam {
|
||
faces[i] = Some((face, cam));
|
||
}
|
||
}
|
||
if faces.iter().all(Option::is_some) {
|
||
out.push(VrEnv { output: *oid, cube, faces: faces.map(|f| f.unwrap_or((ItemId(0), LayerId(0)))) });
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn base(i: usize) -> [f64; 3] {
|
||
let (_, fw, r, u) = FACES[i];
|
||
orientation_for_axes(ae(r), ae([-u[0], -u[1], -u[2]]), ae(fw))
|
||
}
|
||
|
||
fn orient(o: [f64; 3]) -> Mat4 {
|
||
Mat4::orientation(vec3(o[0], o[1], o[2]))
|
||
}
|
||
|
||
fn transpose(m: Mat4) -> Mat4 {
|
||
let mut t = Mat4::IDENTITY;
|
||
for r in 0..3 {
|
||
for c in 0..3 {
|
||
t.0[r][c] = m.0[c][r];
|
||
}
|
||
}
|
||
t
|
||
}
|
||
|
||
fn norm(a: f64) -> f64 {
|
||
let r = a.rem_euclid(360.0);
|
||
let r = if (r - 360.0).abs() < 1e-7 { 0.0 } else { r };
|
||
(r * 1e6).round() / 1e6
|
||
}
|
||
|
||
/// Orientation angles (X, Y, Z degrees, [`Mat4::orientation`] order) of a rotation matrix.
|
||
pub fn euler(m: Mat4) -> [f64; 3] {
|
||
let o = m.orientation_angles();
|
||
[norm(o.x), norm(o.y), norm(o.z)]
|
||
}
|
||
|
||
/// The current view orientation of an environment (from its Front camera).
|
||
fn view_of(p: &Project, env: &VrEnv) -> [f64; 3] {
|
||
let (fc, cam) = env.faces[4];
|
||
let o = p.comp(fc).and_then(|c| c.layer(cam)).and_then(|l| l.props.prop("transform/orientation")).map(|x| x.value.components()).unwrap_or_default();
|
||
let o = [o.first().copied().unwrap_or(0.0), o.get(1).copied().unwrap_or(0.0), o.get(2).copied().unwrap_or(0.0)];
|
||
euler(orient(o) * transpose(orient(base(4))))
|
||
}
|
||
|
||
fn env_json(p: &Project, e: &VrEnv) -> Value {
|
||
let name = |i: ItemId| p.item(i).map(|x| x.name.clone()).unwrap_or_default();
|
||
json!({
|
||
"output": e.output.0, "name": name(e.output), "cubeMap": e.cube.0,
|
||
"faces": e.faces.iter().enumerate().map(|(i, (c, cam))| json!({"face": FACES[i].0, "comp": c.0, "camera": cam.0})).collect::<Vec<_>>(),
|
||
"view": view_of(p, e),
|
||
})
|
||
}
|
||
|
||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||
Ok(json!(environments(&s.project).iter().map(|e| env_json(&s.project, e)).collect::<Vec<_>>()))
|
||
}
|
||
|
||
/// The environment `comp` (its output, cube map or a face comp), else the active comp's, else
|
||
/// the only one.
|
||
fn env_p(s: &Session, p: &Value, cmd: &str) -> Result<VrEnv> {
|
||
let envs = environments(&s.project);
|
||
let want = p.get("comp").and_then(Value::as_u64).map(ItemId).or(s.state.active_comp);
|
||
let hit = |e: &VrEnv, c: ItemId| e.output == c || e.cube == c || e.faces.iter().any(|f| f.0 == c);
|
||
let i = want.and_then(|c| envs.iter().position(|e| hit(e, c))).or((envs.len() == 1).then_some(0));
|
||
let i = i.ok_or_else(|| {
|
||
bad(cmd, if envs.is_empty() { "no VR environment (Composition ▸ VR ▸ Create VR Environment…)" } else { "give `comp`: a VR environment's output comp" })
|
||
})?;
|
||
envs.into_iter().nth(i).ok_or(EngineError::NoComp)
|
||
}
|
||
|
||
fn set_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const C: &str = "comp.vr.setView";
|
||
let env = env_p(s, p, C)?;
|
||
let mut o = view_of(&s.project, &env);
|
||
match p.get("orientation") {
|
||
Some(Value::Array(a)) => {
|
||
for (k, v) in a.iter().take(3).enumerate() {
|
||
o[k] = v.as_f64().ok_or_else(|| bad(C, "orientation: [x, y, z] degrees"))?;
|
||
}
|
||
}
|
||
Some(_) => return Err(bad(C, "orientation: [x, y, z] degrees")),
|
||
None => {}
|
||
}
|
||
// Pan (Y), tilt (X) and roll (Z) by name.
|
||
for (k, i) in [("tilt", 0), ("pan", 1), ("roll", 2)] {
|
||
if let Some(v) = p.get(k).and_then(Value::as_f64) {
|
||
o[i] = v;
|
||
}
|
||
}
|
||
let v = orient(o);
|
||
grouped(s, "VR View Orientation", |s| {
|
||
for (i, (fc, cam)) in env.faces.iter().enumerate() {
|
||
let e = euler(v * orient(base(i)));
|
||
s.execute("prop.set", json!({"comp": fc.0, "layer": cam.0, "path": "transform/orientation", "value": e}))?;
|
||
}
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({"output": env.output.0, "view": view_of(&s.project, &env)}))
|
||
}
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
query!("comp.vr.environments", "VR Environments", "{} → [{output, name, cubeMap, faces: [{face, comp, camera}], view: [x, y, z]}]", list),
|
||
cmd!(
|
||
"comp.vr.setView",
|
||
"VR View Orientation",
|
||
[],
|
||
None,
|
||
"{comp? (an environment's output / cube map / face comp), orientation?: [x, y, z] degrees, pan?, tilt?, roll?} → turns the six face cameras together",
|
||
always,
|
||
set_view
|
||
),
|
||
]
|
||
}
|