mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
Merge upstream/main into gap-behaviors-presets
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,7 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
image = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
@@ -33,14 +33,16 @@ pub struct Stamp {
|
||||
pub dated: bool,
|
||||
}
|
||||
|
||||
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = y.div_euclid(400);
|
||||
let yoe = y - era * 400;
|
||||
let mp = (m + 9) % 12;
|
||||
let doy = (153 * mp + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
/// Days since 1970-01-01 of the proleptic-Gregorian date `(y, m, d)`. `d` is added linearly (as
|
||||
/// the previous hand-rolled `days_from_civil` did), so a day past the month's end rolls over;
|
||||
/// `None` when the year/month is out of chrono's range. `m` must be 1..=12.
|
||||
fn days_from_civil(y: i64, m: i64, d: i64) -> Option<i64> {
|
||||
let year = i32::try_from(y).ok()?;
|
||||
let month = u32::try_from(m).ok()?;
|
||||
let first = chrono::NaiveDate::from_ymd_opt(year, month, 1)?;
|
||||
// Days since the Unix epoch: `num_days_from_ce` counts from 0001-01-01; 719_162 days precede 1970-01-01.
|
||||
use chrono::Datelike;
|
||||
i64::from(first.num_days_from_ce()).checked_sub(719_163)?.checked_add(d.checked_sub(1)?)
|
||||
}
|
||||
|
||||
/// `hh:mm:ss(.fff)` or timecode `hh:mm:ss:ff` / `hh:mm:ss;ff` → seconds.
|
||||
@@ -71,7 +73,7 @@ pub fn parse_stamp(v: &Value, fps: f64) -> Option<Stamp> {
|
||||
// ISO 8601: date [T| ] time [zone].
|
||||
if s.len() >= 10 && s.as_bytes()[4] == b'-' && s.as_bytes()[7] == b'-' {
|
||||
let (y, m, d) = (s[0..4].parse::<i64>().ok()?, s[5..7].parse::<i64>().ok()?, s[8..10].parse::<i64>().ok()?);
|
||||
let mut secs = days_from_civil(y, m, d) as f64 * 86_400.0;
|
||||
let mut secs = days_from_civil(y, m, d)? as f64 * 86_400.0;
|
||||
let rest = s[10..].trim_start_matches(['T', 't', ' ']);
|
||||
if !rest.is_empty() {
|
||||
// Zone: Z or ±hh:mm / ±hhmm.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Edit menu.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::{Comp, Expression, ItemKind, Layer, LayerId, LayerSource, TrackMatte};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
@@ -73,6 +74,10 @@ fn select_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!(s.state.selected_layers.len()))
|
||||
}
|
||||
fn deselect_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
// A deselected layer never keeps an open text edit: commit the edit first, exactly as
|
||||
// `text.endEdit` (an empty Type-tool layer included), so every route — menu, shortcut,
|
||||
// script, MCP — leaves one final state.
|
||||
super::text_edit::end_edit(s, &Value::Null)?;
|
||||
s.state.selected_layers.clear();
|
||||
s.state.selected_props.clear();
|
||||
s.state.selected_keys.clear();
|
||||
@@ -201,6 +206,21 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if l.track_matte.is_some_and(|m| ids.contains(&m.layer)) {
|
||||
l.track_matte = None;
|
||||
}
|
||||
// A layer-reference property (an Environment light's Source, an effect's Clone Source,
|
||||
// Back Layer…) must read as None once its layer is gone, in this same history step: a
|
||||
// dead id renders as nothing while the UI shows "None", which means "fall back to the
|
||||
// comp's Environment Layer" (#2153). Keys carry the same value kind as the property.
|
||||
l.props.walk_mut(&mut |p| {
|
||||
let dead = |v: &KV| matches!(v, KV::Layer(Some(id)) if ids.contains(&LayerId(*id)));
|
||||
if dead(&p.value) {
|
||||
p.value = KV::Layer(None);
|
||||
}
|
||||
for k in &mut p.keys {
|
||||
if dead(&k.value) {
|
||||
k.value = KV::Layer(None);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
st.selected_layers.clear();
|
||||
st.selected_props.clear();
|
||||
|
||||
@@ -186,6 +186,101 @@ fn pick_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"prop": uid, "color": color}))
|
||||
}
|
||||
|
||||
/// What a Cryptomatte effect has at a layer-space point: the ID with the most coverage in its
|
||||
/// Cryptomatte layer, with the aux channels (for the manifest) and the effect's Selection.
|
||||
struct CryptoHit {
|
||||
at: (effectcraft_project::ItemId, LayerId, Tick),
|
||||
aux: std::sync::Arc<effectcraft_raster::AuxChannels>,
|
||||
layer: String,
|
||||
hash: u32,
|
||||
selection: String,
|
||||
prop: Uid,
|
||||
/// The Selection has an expression: setting its value wouldn't change what it reads.
|
||||
driven: bool,
|
||||
}
|
||||
|
||||
impl CryptoHit {
|
||||
fn manifest(&self) -> Option<&[(String, u32)]> {
|
||||
self.aux.manifests.iter().find(|(l, _)| *l == self.layer).map(|(_, m)| m.as_slice())
|
||||
}
|
||||
fn reply(&self, sel: &str) -> Value {
|
||||
let selected = effectcraft_effects::selection_hashes(sel, self.manifest()).contains(&self.hash);
|
||||
let name = self.aux.manifest_name(&self.layer, self.hash);
|
||||
json!({"name": name, "hash": format!("{:08x}", self.hash), "layer": self.layer, "selected": selected})
|
||||
}
|
||||
}
|
||||
|
||||
/// The ID under `{x, y}` (effect space, which is layer space on footage and precomp layers) for
|
||||
/// the Cryptomatte effect `{layer?, effect?}`, from the aux channels its render reads. The point
|
||||
/// snaps to the centre of the pixel the effect's input buffer samples in a render at `scale`
|
||||
/// (the viewer's resolution).
|
||||
fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Option<CryptoHit>> {
|
||||
let (cid, lid, fx_uid) = find_fx(s, p, cmd)?;
|
||||
let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) };
|
||||
let scale = f_p(p, "scale").unwrap_or(1.0);
|
||||
if !(scale.is_finite() && scale > 0.0 && scale <= 1.0) {
|
||||
return Err(bad(cmd, "`scale` is the preview's resolution factor, above 0 and at most 1"));
|
||||
}
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let fx = layer.effects().ok_or_else(|| bad(cmd, "no effects"))?;
|
||||
let pos = fx.groups().position(|g| g.uid == fx_uid).ok_or_else(|| bad(cmd, "no such effect"))?;
|
||||
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 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 params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr));
|
||||
let r = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() });
|
||||
let aux = r.layer_aux(&ctx, layer);
|
||||
let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset");
|
||||
let Some((aux, layer_name)) = aux.and_then(|a| a.resolve_crypto_layer(want).map(|l| (a, l))) else {
|
||||
return Err(bad(cmd, "this layer has no Cryptomatte channels (a multi-layer OpenEXR with Cryptomatte, or a 3D precomp)"));
|
||||
};
|
||||
// The grid of the effect's input buffer (footage keeps native pixels from 3/4 scale up,
|
||||
// proxies have their own size, earlier effects can pad it), rendered and cached.
|
||||
let b = r.layer_input(&ctx, layer, pos).ok_or_else(|| bad(cmd, "this layer has no pixels"))?;
|
||||
let (bs, o) = (b.scale, b.offset);
|
||||
// The centre of its pixel under the point, at the aux point the effect reads there.
|
||||
let [ax, ay] = effectcraft_effects::aux_point(bs, o, [(x * bs + o[0]).floor() + 0.5, (y * bs + o[1]).floor() + 0.5]);
|
||||
let Some(hash) = aux.index_at(ax, ay).and_then(|i| aux.crypto_id_at(&layer_name, i)) else { return Ok(None) };
|
||||
let (selection, driven) = (params.s("selection").to_string(), prop.expr.as_ref().is_some_and(|e| e.enabled));
|
||||
Ok(Some(CryptoHit { at: (cid, lid, t), aux, layer: layer_name, hash, selection, prop: prop.uid, driven }))
|
||||
}
|
||||
|
||||
/// `effect.cryptomatteAt`: the ID under a point, for the viewer's hover hint. The first call per
|
||||
/// frame renders the effect's input to learn its pixel grid (cached afterwards).
|
||||
fn crypto_at(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(crypto_hit(s, p, "effect.cryptomatteAt")?.map_or(Value::Null, |h| h.reply(&h.selection)))
|
||||
}
|
||||
|
||||
/// The Cryptomatte eyedropper: replace the effect's Selection with the ID under a point, add it
|
||||
/// or remove it, in one undo step at the time it was read (none when its meaning stays).
|
||||
fn pick_crypto(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use effectcraft_effects::crypto_select::{PickMode, edit_selection, token_for};
|
||||
let cmd = "effect.pickCryptomatte";
|
||||
let mode = match str_p(p, "mode").unwrap_or("replace") {
|
||||
"replace" => PickMode::Replace,
|
||||
"add" => PickMode::Add,
|
||||
"remove" => PickMode::Remove,
|
||||
m => return Err(bad(cmd, format!("unknown `mode` {m:?} (replace, add or remove)"))),
|
||||
};
|
||||
let h = crypto_hit(s, p, cmd)?.ok_or_else(|| bad(cmd, "no Cryptomatte ID there (empty or outside the layer)"))?;
|
||||
if h.driven {
|
||||
return Err(bad(cmd, "the Selection is driven by an expression; disable it to pick"));
|
||||
}
|
||||
let m = h.manifest();
|
||||
let sel = edit_selection(&h.selection, &token_for(h.aux.manifest_name(&h.layer, h.hash), h.hash, m), h.hash, mode, m);
|
||||
let changed = sel != h.selection;
|
||||
if changed {
|
||||
let (cid, lid, t) = h.at;
|
||||
s.execute("prop.set", json!({"comp": cid.0, "layer": lid.0, "prop": h.prop, "value": sel, "time": t.seconds(), "timeBase": "comp"}))?;
|
||||
}
|
||||
let mut out = h.reply(&sel);
|
||||
out["selection"] = json!(sel);
|
||||
out["changed"] = json!(changed);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = super::comp_id(s, p)?;
|
||||
let fx = effects_p(s, p, "effect.remove")?;
|
||||
@@ -632,6 +727,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_layers,
|
||||
pick_color
|
||||
),
|
||||
cmd!(
|
||||
"effect.pickCryptomatte",
|
||||
"Pick Cryptomatte",
|
||||
[],
|
||||
None,
|
||||
"{layer?, effect?: index|uid|name, x, y (effect space), mode?: replace|add|remove (default replace), scale? (preview resolution, default 1), time?} → {name, hash, layer, selection, changed, selected} — edits the Cryptomatte effect's Selection with the ID under the point",
|
||||
has_layers,
|
||||
pick_crypto
|
||||
),
|
||||
crate::query!(
|
||||
"effect.cryptomatteAt",
|
||||
"Cryptomatte At",
|
||||
"{layer?, effect?: index|uid|name, x, y (effect space), scale?, time?} → {name, hash, layer, selected} | null — the Cryptomatte ID under the point",
|
||||
crypto_at
|
||||
),
|
||||
cmd!("effect.applyLast", "Last Effect", ["Effect"], Some("Cmd+Alt+Shift+E"), "{layers?}", has_layers, last),
|
||||
cmd!("effect.removeAll", "Remove All", ["Effect"], Some("Cmd+Shift+E"), "{layers?}", has_layers, remove_all),
|
||||
cmd!("effect.remove", "Remove Effect", [], None, "{layer?, effect: index|uid|name}", has_layers, remove),
|
||||
|
||||
@@ -665,6 +665,15 @@ fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
proj.settings.time_display = effectcraft_project::TimeDisplayStyle::parse(t)
|
||||
.ok_or_else(|| bad("file.projectSettings", format!("timeDisplay: timecode|frames|feet35|feet16, not `{t}`")))?;
|
||||
}
|
||||
if let Some(f) = p.get("frameStart") {
|
||||
// Frame Count: After Effects offers Start at 0 and Start at 1 only. Scripts and dialog
|
||||
// number fields may send `1.0`.
|
||||
proj.settings.frame_start = match f.as_f64() {
|
||||
Some(0.0) => 0,
|
||||
Some(1.0) => 1,
|
||||
_ => return Err(bad(cmd, format!("frameStart: 0|1, not `{f}`"))),
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(serde_json::to_value(&s.project.settings).unwrap_or_default())
|
||||
@@ -745,7 +754,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Project Settings...",
|
||||
["File"],
|
||||
Some("Cmd+Alt+Shift+K"),
|
||||
"{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, workingGamma?: 2.2|2.4 (with no working space), linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16}",
|
||||
"{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, workingGamma?: 2.2|2.4 (with no working space), linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16, frameStart?: 0|1 (Frame Count: Start at 0 / Start at 1)}",
|
||||
always,
|
||||
project_settings
|
||||
),
|
||||
|
||||
@@ -4,8 +4,8 @@ use effectcraft_color::{BlendMode, Label};
|
||||
use effectcraft_keyframe::{Justify, TextDoc, Value as KV};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{
|
||||
Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid, TrackMatte,
|
||||
limits,
|
||||
Comp, FootageKind, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid,
|
||||
TrackMatte, limits,
|
||||
};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
@@ -307,6 +307,103 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"layer": id.0}))
|
||||
}
|
||||
|
||||
/// A layer whose source is an item (footage, precomposition or solid), as in After Effects'
|
||||
/// Replace Source. Text, shape, null, camera, light and 3D model layers have no AVItem source to
|
||||
/// replace.
|
||||
fn replaceable_layer(l: &Layer) -> bool {
|
||||
matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. })
|
||||
}
|
||||
|
||||
/// The source a Project item gives a layer, or why it can't replace one.
|
||||
fn replacement_source(item: ItemId, it: &effectcraft_project::Item) -> Result<LayerSource> {
|
||||
const CMD: &str = "layer.replaceSource";
|
||||
match &it.kind {
|
||||
ItemKind::Comp(_) => Ok(LayerSource::Comp { item }),
|
||||
ItemKind::Footage(f) if f.kind == FootageKind::Data => {
|
||||
Err(bad(CMD, "data files can't be layer sources; read them in expressions with footage(\"name\").sourceData"))
|
||||
}
|
||||
ItemKind::Footage(f) if f.kind == FootageKind::Model => Err(bad(CMD, "3D model items can't replace a 2D layer source; add them as 3D layers")),
|
||||
ItemKind::Footage(_) => Ok(LayerSource::Footage { item }),
|
||||
ItemKind::Solid(_) => Ok(LayerSource::Solid { item }),
|
||||
ItemKind::Folder => Err(bad(CMD, "folders can't be layer sources")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a source brings audio properties (precompositions always can; footage when it has an
|
||||
/// audio stream).
|
||||
fn source_has_audio(project: &Project, source: &LayerSource) -> bool {
|
||||
match source {
|
||||
LayerSource::Comp { .. } => true,
|
||||
LayerSource::Footage { item } => project.item(*item).is_some_and(|i| matches!(&i.kind, ItemKind::Footage(f) if f.has_audio)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_replace_source(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
let c = s.active_comp().ok_or("no composition")?;
|
||||
if !s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(replaceable_layer) {
|
||||
return Err("select a footage, composition or solid layer".into());
|
||||
}
|
||||
if s.state.project_selection.is_empty() {
|
||||
return Err("select a footage item, a composition or a solid in the Project panel".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace the source of the selected (or `layers`) AV layers with a Project item, as After
|
||||
/// Effects' Alt-drag and Ctrl+Alt+/ do. The layer keeps every property: name, timing, transforms,
|
||||
/// keyframes, expressions, effects, masks, switches, parenting and track matte. Only its source
|
||||
/// changes; precompositions bring their Essential Properties group through `sync_project`, and a
|
||||
/// source with audio adds an Audio group when the layer has none.
|
||||
fn replace_source(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "layer.replaceSource";
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let ids = unlocked(s, cid, ids, CMD)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(CMD, "select a layer first"));
|
||||
}
|
||||
let item = match p.get("item") {
|
||||
Some(Value::Number(n)) => ItemId(n.as_u64().ok_or_else(|| bad(CMD, "item: a project item id"))?),
|
||||
Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id).ok_or_else(|| bad(CMD, format!("no item `{name}`")))?,
|
||||
_ => *s.state.project_selection.first().ok_or_else(|| bad(CMD, "select a footage item, a composition or a solid in the Project panel"))?,
|
||||
};
|
||||
let it = s.project.item(item).ok_or_else(|| bad(CMD, format!("no item {}", item.0)))?;
|
||||
let source = replacement_source(item, it)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
for lid in &ids {
|
||||
let l = comp.layer(*lid).ok_or_else(|| bad(CMD, "no such layer"))?;
|
||||
if !replaceable_layer(l) {
|
||||
return Err(bad(CMD, format!("“{}” has no source item to replace", l.name)));
|
||||
}
|
||||
}
|
||||
if let LayerSource::Comp { item: new } = &source
|
||||
&& s.project.comp_contains(*new, cid)
|
||||
{
|
||||
return Err(bad(CMD, "a composition can't contain itself"));
|
||||
}
|
||||
let has_audio = source_has_audio(&s.project, &source);
|
||||
let source_for_edit = source.clone();
|
||||
s.edit("Replace Layer Source", None, |proj, _| {
|
||||
for lid in &ids {
|
||||
let needs_audio = has_audio && proj.comp(cid).and_then(|c| c.layer(*lid)).is_some_and(|l| l.props.sub("audio").is_none());
|
||||
let audio = if needs_audio {
|
||||
let mut next = Ids(&mut proj.next_id);
|
||||
Some(build::audio(&mut next))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
l.source = source_for_edit.clone();
|
||||
if let Some(audio) = audio {
|
||||
l.props.children.push(audio.into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"layers": ids.iter().map(|l| l.0).collect::<Vec<_>>(), "item": item.0}))
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
@@ -417,8 +514,10 @@ fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str,
|
||||
let applies = |l: &Layer| match name {
|
||||
"audio" => l.props.sub("audio").is_some(),
|
||||
"lock" | "locked" => true,
|
||||
// Nulls have a Video switch too (it hides their outline in the viewer).
|
||||
"video" | "eye" => (l.source.is_av() || matches!(l.source, LayerSource::Null)) && !mattes.contains(&l.id),
|
||||
// Every layer has a Video switch (it hides a null's outline in the viewer and turns a
|
||||
// camera or light off), as the Timeline's eye cell shows; track mattes keep theirs off
|
||||
// by design.
|
||||
"video" | "eye" => !mattes.contains(&l.id),
|
||||
_ => l.source.is_av() && !mattes.contains(&l.id),
|
||||
};
|
||||
let mut alone = true;
|
||||
@@ -1628,6 +1727,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_comp,
|
||||
add_item
|
||||
),
|
||||
cmd!(
|
||||
"layer.replaceSource",
|
||||
"Replace Layer Source",
|
||||
[],
|
||||
Some("Cmd+Alt+/"),
|
||||
"{comp?, layers?, layer?, item?: id|name (default the Project panel selection), fixExpressions?: bool (accepted for After Effects compatibility)} → {layers, item}",
|
||||
has_replace_source,
|
||||
replace_source
|
||||
),
|
||||
cmd!("layer.select", "Select Layers", [], None, "{layers: [id|name|#n], add?, toggle?}", has_comp, select),
|
||||
cmd!("layer.selectNext", "Select Next Layer", [], Some("Cmd+ArrowDown"), "{add?}", has_comp, select_next),
|
||||
cmd!("layer.selectPrevious", "Select Previous Layer", [], Some("Cmd+ArrowUp"), "{add?}", has_comp, select_prev),
|
||||
|
||||
@@ -114,10 +114,10 @@ fn frame_blending(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn hide_other_video(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
s.edit("Hide Other Video", None, |proj, _| {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut() {
|
||||
if !ids.contains(&l.id) && (l.source.is_av() || matches!(l.source, LayerSource::Null)) {
|
||||
l.switches.video = false;
|
||||
}
|
||||
// Every layer has a Video switch (nulls, cameras and lights included, as the Timeline's
|
||||
// eye cell shows): the selection stays shown.
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| !ids.contains(&l.id)) {
|
||||
l.switches.video = false;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -754,17 +754,33 @@ fn reveal_in_finder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn reveal_source(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let item = source_item(s, p, "layer.revealSource")?;
|
||||
s.state.project_selection = vec![item];
|
||||
frontend(s, "window.panel", &json!({"panel": "project"}))?;
|
||||
Ok(json!({"item": item.0}))
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let mut items: Vec<ItemId> = Vec::new();
|
||||
{
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
for layer in ids.iter().filter_map(|id| comp.layer(*id)) {
|
||||
if let Some(item) = layer.source.item()
|
||||
&& s.project.item(item).is_some()
|
||||
&& !items.contains(&item)
|
||||
{
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
if items.is_empty() {
|
||||
return Err(bad("layer.revealSource", "the selected layers have no source item"));
|
||||
}
|
||||
s.state.project_selection = items.clone();
|
||||
let raw: Vec<u64> = items.iter().map(|item| item.0).collect();
|
||||
frontend(s, "project.reveal", &json!({"items": raw}))?;
|
||||
Ok(json!({"item": items[0].0, "items": raw}))
|
||||
}
|
||||
|
||||
fn reveal_comp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
s.state.project_selection = vec![cid];
|
||||
frontend(s, "window.panel", &json!({"panel": "project"}))?;
|
||||
Ok(json!({"item": cid.0}))
|
||||
frontend(s, "project.reveal", &json!({"items": [cid.0]}))?;
|
||||
Ok(json!({"item": cid.0, "items": [cid.0]}))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -909,7 +925,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("layer.trackMatte", "Track Matte", [], None, "{layers?, op: none|alpha|alphaInverted|luma|lumaInverted|above|below}", has_layers, track_matte_op),
|
||||
cmd!("layer.openSource", "Open Layer Source", ["Layer"], None, "{layer?}", has_source_item, open_source),
|
||||
cmd!("layer.revealInFinder", "Reveal in Finder", ["Layer"], None, "{layer?}", has_footage_layer, reveal_in_finder),
|
||||
cmd!("layer.revealSource", "Reveal Layer Source in Project", ["Layer", "Reveal"], None, "{layer?}", has_source_item, reveal_source),
|
||||
cmd!("layer.revealSource", "Reveal Layer Source in Project", ["Layer", "Reveal"], None, "{layers?}", has_source_item, reveal_source),
|
||||
cmd!("comp.revealInProject", "Reveal Composition in Project", ["Layer", "Reveal"], None, "{comp?}", has_comp, reveal_comp),
|
||||
cmd!("layer.revealExpressionErrors", "Reveal Expression Errors", ["Layer", "Reveal"], None, "{comp?}", has_comp, reveal_expression_errors),
|
||||
cmd!(
|
||||
|
||||
@@ -22,8 +22,11 @@ pub(crate) fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, Lay
|
||||
});
|
||||
}
|
||||
let path = str_p(p, "path").ok_or_else(|| bad(cmd, "missing `path` (e.g. transform/position) or `prop` uid"))?;
|
||||
let pr = layer.props.prop(path).ok_or_else(|| {
|
||||
let group = layer.props.group(path).or_else(|| path.trim().strip_prefix('@')?.parse().ok().and_then(|uid| layer.props.find_group(uid)));
|
||||
// `effects/<match name>[#n]/<property>` is how `essential.list`, presets and Copy write an
|
||||
// effect property; turn it into the uid path `PropGroup::prop` parses (#2064).
|
||||
let resolved = super::resolve_effect_path(layer, path);
|
||||
let pr = layer.props.prop(&resolved).ok_or_else(|| {
|
||||
let group = layer.props.group(&resolved).or_else(|| path.trim().strip_prefix('@')?.parse().ok().and_then(|uid| layer.props.find_group(uid)));
|
||||
if group.is_some() {
|
||||
bad(cmd, format!("`{path}` is a property group; use layer.tree (MCP: get_layer) to inspect its children, then select a leaf property"))
|
||||
} else {
|
||||
@@ -179,7 +182,9 @@ pub(crate) fn key_time(layer: &Layer, frame_rate: FrameRate, p: &Value, param: &
|
||||
pub(crate) fn key_timing(layer: &Layer, t: Tick) -> Value {
|
||||
let ct = layer.comp_time(t);
|
||||
let mut out = json!({"time": t.seconds(), "layerTime": t.seconds(), "compTime": ct.seconds()});
|
||||
if !layer.is_active_at(ct) {
|
||||
// A key exactly on the out point ends a fade normally: it is outside the half-open visible
|
||||
// range, but it is not a caller mistake, so it does not warn (#2084).
|
||||
if ct < layer.in_point || ct > layer.out_point {
|
||||
out["warning"] = json!(format!(
|
||||
"Keyframe at comp time {} s is outside the layer's active range [{} s, {} s).",
|
||||
ct.seconds(),
|
||||
|
||||
@@ -159,7 +159,7 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// `text.endEdit`: stop editing (a Type-tool layer left empty is deleted).
|
||||
fn end_edit(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
pub(crate) fn end_edit(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let Some(e) = s.state.text_edit.take() else { return Ok(Value::Null) };
|
||||
if e.created && layer_doc(s, e.layer).is_some_and(|d| d.text.is_empty()) {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
|
||||
@@ -28,6 +28,20 @@ pub fn display_time(s: &Session, comp: &Comp, t: Tick) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// A length (a layer's Duration) in the project's Time Display Style: a count of frames, so
|
||||
/// neither Start Numbering Frames At nor the comp's Start Timecode moves it.
|
||||
pub fn display_duration(s: &Session, comp: &Comp, len: Tick) -> String {
|
||||
let fr = comp.frame_rate;
|
||||
let n = fr.frame_at(len);
|
||||
match s.project.settings.time_display {
|
||||
TimeDisplayStyle::Frames => format!("{n:05}"),
|
||||
TimeDisplayStyle::Feet35 | TimeDisplayStyle::Feet16 => {
|
||||
effectcraft_time::format_feet_frames(n, s.project.settings.time_display.frames_per_foot().unwrap_or(16))
|
||||
}
|
||||
TimeDisplayStyle::Timecode => effectcraft_time::format_timecode_ae(n, fr, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a time typed in the project's Time Display Style (relative `+n`/`-n` from frame
|
||||
/// `current`) into a comp frame.
|
||||
pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) -> std::result::Result<i64, String> {
|
||||
|
||||
@@ -46,13 +46,22 @@ pub fn resolve(stored: &str, project: Option<&str>) -> Result<PathBuf, NotFollow
|
||||
let p = Path::new(p);
|
||||
std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf())
|
||||
});
|
||||
let path = craft_docpath::resolve(stored, project.as_deref()).map_err(NotFollowed::Refused)?;
|
||||
// A path that is still relative would be taken from the working directory, which the
|
||||
// project does not choose.
|
||||
if path.is_relative() {
|
||||
return Err(NotFollowed::NoProjectFolder);
|
||||
}
|
||||
Ok(path)
|
||||
craft_docpath::resolve_absolute(stored, project.as_deref()).map_err(|why| match why {
|
||||
// Resolved, but still relative: it would be taken from the working directory, which the
|
||||
// project does not choose. A Windows drive or share path (`C:\x`) stays invalid outside
|
||||
// Windows even with a project.
|
||||
Refused::Invalid if project.is_none() && craft_docpath::resolve(stored, None).is_ok_and(|p| cfg!(windows) || !is_windows_rooted(&p)) => {
|
||||
NotFollowed::NoProjectFolder
|
||||
}
|
||||
why => NotFollowed::Refused(why),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `p` is a Windows drive or share path, which a project cannot make absolute outside Windows.
|
||||
fn is_windows_rooted(p: &Path) -> bool {
|
||||
let s = p.to_string_lossy();
|
||||
let b = s.as_bytes();
|
||||
(b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':') || s.starts_with(r"\\") || s.starts_with("//")
|
||||
}
|
||||
|
||||
/// The paths of `f` (its file and every frame of an image sequence) resolved against the document
|
||||
|
||||
@@ -34,9 +34,11 @@ impl Services for Recording {
|
||||
}
|
||||
}
|
||||
|
||||
/// Probes every file as a still.
|
||||
/// Probes every file as a still. Only the Unix part of the test below imports files.
|
||||
#[cfg(unix)]
|
||||
struct Stills;
|
||||
|
||||
#[cfg(unix)]
|
||||
impl crate::Importer for Stills {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
if !std::path::Path::new(path).is_file() {
|
||||
@@ -147,7 +149,23 @@ fn stored_paths_resolve_against_the_project() {
|
||||
let doc = doc.to_str().unwrap();
|
||||
assert_eq!(super::resolve("clip.mov", Some(doc)), Ok(std::env::temp_dir().join("art").join("clip.mov")));
|
||||
assert_eq!(super::resolve("clip.mov", None), Err(super::NotFollowed::NoProjectFolder));
|
||||
assert_eq!(super::resolve("../logo.png", None), Err(super::NotFollowed::NoProjectFolder));
|
||||
// A drive path is not a local file on Unix, with or without a project: it is not a missing project.
|
||||
#[cfg(unix)]
|
||||
for stored in [r"C:\x.mov", "C:x.mov"] {
|
||||
assert_eq!(super::resolve(stored, None), Err(super::NotFollowed::Refused(super::Refused::Invalid)), "{stored}");
|
||||
}
|
||||
assert_eq!(super::resolve(r"\\192.0.2.1\share\x.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Network)));
|
||||
assert_eq!(super::resolve("jar:clip.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Url)));
|
||||
// Windows reads a colon in a name as an alternate data stream; elsewhere it is a plain character.
|
||||
if cfg!(windows) {
|
||||
assert_eq!(super::resolve("shots/take:1.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Invalid)));
|
||||
} else {
|
||||
assert_eq!(super::resolve("shots/take:1.mov", Some(doc)), Ok(std::env::temp_dir().join("art").join("shots").join("take:1.mov")));
|
||||
}
|
||||
// A Windows drive path names no file on other platforms.
|
||||
#[cfg(unix)]
|
||||
assert_eq!(super::resolve(r"C:\art\clip.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Invalid)));
|
||||
#[cfg(windows)]
|
||||
assert!(super::resolve(r"\\server\share\x.mov", Some(r"\\SERVER\share\job\intro.ecproj")).is_ok());
|
||||
}
|
||||
|
||||
@@ -129,17 +129,22 @@ pub fn reload_items(s: &mut Session, ids: &[ItemId]) -> crate::Result<usize> {
|
||||
/// writing it) is left as it was. Returns the number reloaded and the files that couldn't be read
|
||||
/// (only without `undoable`).
|
||||
fn reload(s: &mut Session, ids: &[ItemId], undoable: bool) -> crate::Result<(usize, BTreeSet<String>)> {
|
||||
if s.importer.is_none() {
|
||||
let Some(importer) = s.importer.clone() else {
|
||||
return Err(crate::EngineError::Other("media import is not available in this build".into()));
|
||||
}
|
||||
};
|
||||
let mut updates = vec![];
|
||||
let mut paths = BTreeSet::new();
|
||||
let mut failed = BTreeSet::new();
|
||||
for i in ids {
|
||||
if let Some(ItemKind::Footage(f)) = s.project.item(*i).map(|x| &x.kind) {
|
||||
// Probed the way it imports by default, so a sequence is found as one. A path that
|
||||
// is not followed is not read.
|
||||
let probed = s.stored_paths.check(f).map_err(|(p, why)| crate::doc_path::refusal(&p, why)).and_then(|_| s.probe_footage(&f.path));
|
||||
// A sequence is probed the way it imports by default, so its run is found again; any
|
||||
// other item is its one file, not the run a numbered still sits in (#2074). A path
|
||||
// that is not followed is not read.
|
||||
let probed = s
|
||||
.stored_paths
|
||||
.check(f)
|
||||
.map_err(|(p, why)| crate::doc_path::refusal(&p, why))
|
||||
.and_then(|_| if f.kind == FootageKind::Sequence { s.probe_footage(&f.path) } else { importer.probe(&f.path) });
|
||||
let nf = match probed {
|
||||
Ok(p) => refreshed(s, f, p),
|
||||
Err(_) if undoable => Footage { missing: true, ..f.clone() },
|
||||
@@ -193,6 +198,8 @@ fn forget(s: &mut Session, paths: &BTreeSet<String>) {
|
||||
s.footage.forget(p);
|
||||
}
|
||||
s.layer_cache.clear();
|
||||
// Reloaded files may keep their footage settings but not their size and time.
|
||||
s.refresh_disk_salt(true);
|
||||
s.events.push(crate::Event::PurgeCaches);
|
||||
}
|
||||
|
||||
|
||||
@@ -521,6 +521,8 @@ pub struct Session {
|
||||
pub footage_stamps: std::collections::HashMap<String, footage_reload::Stamp>,
|
||||
/// The stamps being taken on a worker thread ([`footage_reload::start_scan`]).
|
||||
pub footage_scan: Option<footage_reload::Scan>,
|
||||
/// The disk cache's footage salt and the footage it was taken for ([`Session::refresh_disk_salt`]).
|
||||
pub disk_salt: Option<(u64, u64)>,
|
||||
/// How many script-issued commands are running ([`Session::execute_for_script`]): while one
|
||||
/// is, settings that guard what scripts may do can't change (see
|
||||
/// [`prefs::Scripting::same_permissions`]).
|
||||
@@ -618,6 +620,7 @@ impl Default for Session {
|
||||
check_footage_on_open: false,
|
||||
stored_paths: Default::default(),
|
||||
footage_stamps: Default::default(),
|
||||
disk_salt: None,
|
||||
footage_scan: None,
|
||||
script_calls: ScriptCalls::default(),
|
||||
}
|
||||
@@ -736,12 +739,42 @@ impl Session {
|
||||
if Arc::ptr_eq(&self.project, &self.saved_project) {
|
||||
self.saved_revision = self.revision;
|
||||
}
|
||||
if self.disk_cache.is_some() {
|
||||
self.layer_cache.set_disk_salt(effectcraft_render::disk_cache::footage_salt(&self.project));
|
||||
}
|
||||
self.refresh_disk_salt(false);
|
||||
self.events.push(Event::ProjectChanged { revision: self.revision });
|
||||
}
|
||||
|
||||
/// Salt the disk cache's layer keys with the project's footage files
|
||||
/// ([`effectcraft_render::disk_cache::footage_salt`]). Taking it reads the size and time of
|
||||
/// every file, every frame of an image sequence included, so it is taken again only when the
|
||||
/// footage items changed, or with `force` (a project opened, footage reloaded, the disk cache
|
||||
/// attached), not at every edit: with a few large sequences that cost each keystroke in a
|
||||
/// text field over half a second (#1926).
|
||||
pub fn refresh_disk_salt(&mut self, force: bool) -> Option<u64> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
if self.disk_cache.is_none() {
|
||||
self.disk_salt = None;
|
||||
return None;
|
||||
}
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
for (id, it) in &self.project.items {
|
||||
if let effectcraft_project::ItemKind::Footage(f) = &it.kind {
|
||||
id.hash(&mut h);
|
||||
format!("{f:?}").hash(&mut h);
|
||||
}
|
||||
}
|
||||
let footage = h.finish();
|
||||
if let Some((seen, salt)) = self.disk_salt
|
||||
&& seen == footage
|
||||
&& !force
|
||||
{
|
||||
return Some(salt);
|
||||
}
|
||||
let salt = effectcraft_render::disk_cache::footage_salt(&self.project);
|
||||
self.disk_salt = Some((footage, salt));
|
||||
self.layer_cache.set_disk_salt(salt);
|
||||
Some(salt)
|
||||
}
|
||||
|
||||
/// Set a comp's Resolution. As in After Effects it is saved with the project (which becomes
|
||||
/// modified) but is no undo step, and it changes no pixels: the comp's cached frames stay
|
||||
/// valid ([`effectcraft_project::Comp::same_pixels`]).
|
||||
@@ -1036,12 +1069,18 @@ impl Session {
|
||||
|
||||
/// Render a comp frame with the session's footage + expression hosts.
|
||||
pub fn render(&self, comp: ItemId, t: Tick, opts: RenderOpts) -> Image {
|
||||
self.renderer(opts).comp_frame(comp, t)
|
||||
}
|
||||
|
||||
/// The renderer [`Session::render`] uses: the session's footage, expressions, layer cache and
|
||||
/// accelerator, and the render prefs (nested switches, draft shadows) over `opts`.
|
||||
pub fn renderer(&self, opts: RenderOpts) -> Renderer<'_> {
|
||||
let opts = RenderOpts { nested_switches: self.prefs.general.switches_affect_nested_comps, draft_shadows: self.prefs.three_d.realtime_shadows, ..opts };
|
||||
let mut r = Renderer::new(&self.project, self.footage.as_ref(), opts);
|
||||
r.expr = self.expr.as_deref();
|
||||
r.cache = Some(&self.layer_cache);
|
||||
r.accel = self.accel.as_deref();
|
||||
r.comp_frame(comp, t)
|
||||
r
|
||||
}
|
||||
|
||||
/// Resolve a comp reference (item id number or name; `None`/null = the active comp).
|
||||
@@ -1169,6 +1208,8 @@ impl Session {
|
||||
}
|
||||
self.layer_cache.clear();
|
||||
self.project = Arc::new(p);
|
||||
// Its files may have changed since they were last stamped.
|
||||
self.disk_salt = None;
|
||||
self.history = History::default();
|
||||
self.state = EditorState { snapping: true, view_layout: 1, ..Default::default() };
|
||||
self.path = path;
|
||||
@@ -1242,6 +1283,8 @@ mod tests_codec_options;
|
||||
#[cfg(test)]
|
||||
mod tests_color_view;
|
||||
#[cfg(test)]
|
||||
mod tests_crypto_pick;
|
||||
#[cfg(test)]
|
||||
mod tests_delete_keys;
|
||||
#[cfg(test)]
|
||||
mod tests_disk_cache;
|
||||
@@ -1290,6 +1333,8 @@ mod tests_proxy;
|
||||
#[cfg(test)]
|
||||
mod tests_quick_offset;
|
||||
#[cfg(test)]
|
||||
mod tests_replace_source;
|
||||
#[cfg(test)]
|
||||
mod tests_rig3d;
|
||||
#[cfg(test)]
|
||||
mod tests_sequence;
|
||||
|
||||
@@ -93,28 +93,34 @@ fn thread_name() -> String {
|
||||
return "main".to_string();
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds) for `ms` milliseconds since 1970.
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds) for `ms` milliseconds since 1970. A clock set
|
||||
/// absurdly far ahead keeps its real year (preserved) instead of reading as the epoch.
|
||||
fn timestamp(ms: u64) -> String {
|
||||
let secs = ms / 1000;
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, ms % 1000)
|
||||
let (secs, millis) = (ms / 1000, (ms % 1000) as u32);
|
||||
let (y, t) = utc_parts(secs, millis * 1_000_000);
|
||||
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned since the clock is clamped at the epoch). `days` is at most
|
||||
/// `u64::MAX / 86_400_000`, so none of the arithmetic below can overflow.
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days + 719_468;
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + u64::from(m <= 2);
|
||||
(y, m, d)
|
||||
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
|
||||
/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years
|
||||
/// (146_097 days), so month, day and time of day come from chrono and only the year is carried,
|
||||
/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is
|
||||
/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`.
|
||||
fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
|
||||
use chrono::Datelike;
|
||||
let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
|
||||
match (date, time) {
|
||||
(Some(d), Some(t)) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
|
||||
}
|
||||
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
/// One log line (without the newline): `<timestamp> <LEVEL> [<thread>] <target>: <message>`.
|
||||
@@ -358,7 +364,12 @@ mod tests {
|
||||
assert_eq!(timestamp(1_709_164_800_000), "2024-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(951_782_400_000), "2000-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(1_767_225_599_000), "2025-12-31T23:59:59.000Z");
|
||||
let _ = timestamp(u64::MAX);
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(253_402_300_800_000), "10000-01-01T00:00:00.000Z");
|
||||
// A clock absurdly far ahead keeps its real year (years far outside chrono's own range are
|
||||
// preserved) instead of collapsing to the epoch (reviewer rule from the sibling PRs).
|
||||
assert_eq!(timestamp(10_000_000_000_000_000), "318857-05-20T17:46:40.000Z");
|
||||
assert_eq!(timestamp(u64::MAX), "584556019-04-03T14:25:51.615Z");
|
||||
}
|
||||
|
||||
/// Each launch keeps the previous runs' logs, so the log of a run that crashed survives.
|
||||
|
||||
@@ -264,22 +264,33 @@ pub fn parent(dir: &str) -> Option<String> {
|
||||
std::path::Path::new(dir).parent().map(|p| p.to_string_lossy().to_string()).filter(|p| !p.is_empty())
|
||||
}
|
||||
|
||||
/// Format seconds since the epoch as an ISO 8601 UTC date-time.
|
||||
/// Format seconds since the epoch as an ISO 8601 UTC date-time. Years far outside chrono's own
|
||||
/// range are preserved (the old hand-rolled conversion kept them) rather than collapsing to the epoch.
|
||||
pub fn iso_date(secs: u64) -> String {
|
||||
let days = (secs / 86_400) as i64;
|
||||
let rem = secs % 86_400;
|
||||
// Civil-from-days (Howard Hinnant's algorithm).
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)
|
||||
let (y, t) = utc_parts(secs);
|
||||
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%SZ"))
|
||||
}
|
||||
|
||||
/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range
|
||||
/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so
|
||||
/// month, day and time of day come from chrono and only the year is carried, which keeps every
|
||||
/// input exact instead of collapsing out-of-range years to the epoch. `secs` is `u64`, so `days`
|
||||
/// always fits an `i64`.
|
||||
fn utc_parts(secs: u64) -> (i64, chrono::NaiveDateTime) {
|
||||
use chrono::Datelike;
|
||||
let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
|
||||
match (date, time) {
|
||||
(Some(d), Some(t)) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
|
||||
}
|
||||
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
/// File dates and size: (created, modified, size).
|
||||
@@ -506,5 +517,10 @@ mod tests {
|
||||
assert_eq!(iso_date(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(iso_date(1_700_000_000), "2023-11-14T22:13:20Z");
|
||||
assert_eq!(iso_date(951_782_400), "2000-02-29T00:00:00Z");
|
||||
// Years far outside chrono's own range are preserved (the old hand-rolled conversion kept
|
||||
// them) rather than collapsing to the epoch.
|
||||
assert_eq!(iso_date(253_402_300_800), "10000-01-01T00:00:00Z");
|
||||
assert_eq!(iso_date(10_000_000_000_000), "318857-05-20T17:46:40Z");
|
||||
assert_eq!(iso_date(u64::MAX), "584554051223-11-09T07:00:15Z");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -733,6 +733,7 @@ Layer
|
||||
Shape Layer | layer.newShape
|
||||
Adjustment Layer | layer.newAdjustment
|
||||
Content-Aware Fill Layer... | layer.newContentAwareFill
|
||||
3D Primitive... | layer.newPrimitive
|
||||
Cube | layer.new3dPrimitive {"kind":"cube"}
|
||||
Sphere | layer.new3dPrimitive {"kind":"sphere"}
|
||||
Plane | layer.new3dPrimitive {"kind":"plane"}
|
||||
|
||||
@@ -26,16 +26,19 @@ pub const PREFS_FILE: &str = "prefs.json";
|
||||
pub const LANGUAGES: &[(&str, &str)] = &[
|
||||
("Match System", "system"),
|
||||
("English", "en"),
|
||||
// Alphabetical by the language's own name after English; Cyrillic, Georgian, then CJK names
|
||||
// follow the Latin ones.
|
||||
("Čeština", "cs"),
|
||||
("Deutsch", "de"),
|
||||
("Español", "es"),
|
||||
("Français", "fr"),
|
||||
("Português (Brasil)", "pt-br"),
|
||||
("Русский", "ru"),
|
||||
("Українська", "uk"),
|
||||
("ქართული", "ka"),
|
||||
("日本語", "ja"),
|
||||
("简体中文", "zh-hans"),
|
||||
("繁體中文", "zh-hant"),
|
||||
("Українська", "uk"),
|
||||
("Español", "es"),
|
||||
("Português (Brasil)", "pt-br"),
|
||||
("Русский", "ru"),
|
||||
("Français", "fr"),
|
||||
("Deutsch", "de"),
|
||||
("Čeština", "cs"),
|
||||
];
|
||||
|
||||
/// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value).
|
||||
@@ -110,7 +113,8 @@ page!(General {
|
||||
/// Synchronize Time of All Related Items.
|
||||
sync_time_related_items: bool = true,
|
||||
expression_pick_whip_compact: bool = true,
|
||||
create_split_layers_above: bool = false,
|
||||
/// Create Split Layers Above Original Layer (After Effects' default).
|
||||
create_split_layers_above: bool = true,
|
||||
use_system_color_picker: bool = false,
|
||||
/// Number of projects in File ▸ Open Recent.
|
||||
recent_items: u32 = 10,
|
||||
|
||||
@@ -263,6 +263,8 @@ pub struct FrameServer {
|
||||
/// The disk cache's layer buffers (see the module docs) and the project's salt.
|
||||
layer_store: Option<Arc<PrefetchStore>>,
|
||||
salt: u64,
|
||||
/// Size and content hash for footage paths with no native metadata (the browser file table).
|
||||
virtual_id: Option<Arc<dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync>>,
|
||||
}
|
||||
|
||||
/// A frame whose readbacks are in flight.
|
||||
@@ -294,9 +296,16 @@ impl FrameServer {
|
||||
pending: None,
|
||||
layer_store: None,
|
||||
salt: 0,
|
||||
virtual_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Identity of virtual footage files, used when native metadata cannot describe the path.
|
||||
/// Called from [`FrameServer::load`], not from the UI thread's cache lookup.
|
||||
pub fn set_virtual_id(&mut self, id: Option<Arc<dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync>>) {
|
||||
self.virtual_id = id;
|
||||
}
|
||||
|
||||
/// The store behind the layer cache when a request asks for `layers` (the browser: the
|
||||
/// disk cache's layer buffers, fetched before the request).
|
||||
pub fn set_layer_store(&mut self, store: Option<Arc<PrefetchStore>>) {
|
||||
@@ -338,7 +347,7 @@ impl FrameServer {
|
||||
|
||||
fn load(&mut self, revision: u64) -> Result<(), String> {
|
||||
let p = Project::from_json(&self.doc.to_string()).map_err(|e| e.to_string())?;
|
||||
self.salt = effectcraft_render::disk_cache::footage_salt(&p);
|
||||
self.salt = effectcraft_render::disk_cache::footage_salt_with(&p, self.virtual_id.as_deref());
|
||||
self.cache.set_store_salt(self.salt);
|
||||
self.project = Some(Arc::new(p));
|
||||
self.revision = Some(revision);
|
||||
|
||||
@@ -102,7 +102,7 @@ impl Session {
|
||||
/// and attach it to the layer cache. Without a folder setting the platform cache folder is
|
||||
/// used, but only by frontends with a settings store (headless sessions stay off disk).
|
||||
pub fn configure_disk_cache(&mut self) {
|
||||
use effectcraft_render::disk_cache::{DiskCache, default_folder, footage_salt};
|
||||
use effectcraft_render::disk_cache::{DiskCache, default_folder};
|
||||
let d = &self.prefs.disk;
|
||||
let folder = if d.disk_cache_folder.trim().is_empty() {
|
||||
(self.config.is_some() && !cfg!(target_arch = "wasm32")).then(default_folder)
|
||||
@@ -116,6 +116,11 @@ impl Session {
|
||||
self.layer_cache.set_disk(None);
|
||||
return;
|
||||
};
|
||||
// Tests: the Roto/Object Matte segment store below is one per process, so a test that
|
||||
// opens a disk cache (often the platform folder, through a settings store) would take
|
||||
// over the store of a roto test running beside it. Serialise them with the roto tests.
|
||||
#[cfg(test)]
|
||||
crate::tests_roto::hold_roto_cache_test_lock();
|
||||
let dc = match &self.disk_cache {
|
||||
Some(c) if c.folder() == folder.as_path() => {
|
||||
c.set_max_bytes(max);
|
||||
@@ -131,7 +136,9 @@ impl Session {
|
||||
}
|
||||
},
|
||||
};
|
||||
self.layer_cache.set_disk(Some((dc.clone(), footage_salt(&self.project))));
|
||||
self.disk_cache = Some(dc.clone());
|
||||
let salt = self.refresh_disk_salt(true).unwrap_or_default();
|
||||
self.layer_cache.set_disk(Some((dc.clone(), salt)));
|
||||
// Roto Brush / Object Matte segmentations persist there too (held weakly: switching the
|
||||
// disk cache off drops it).
|
||||
let store: std::sync::Arc<dyn effectcraft_effects::roto::SegStore> = dc.clone();
|
||||
|
||||
@@ -486,10 +486,10 @@ pub fn encode_thumb(w: u32, h: u32, rgba: &[u8]) -> String {
|
||||
/// Decode a thumbnail to RGB bytes (THUMB_W×THUMB_H×3).
|
||||
pub fn decode_thumb(text: &str) -> Option<Vec<u8>> {
|
||||
let t = text.trim();
|
||||
if t.len() != THUMB_W * THUMB_H * 6 {
|
||||
if t.len() != THUMB_W * THUMB_H * 6 || !t.is_ascii() {
|
||||
return None;
|
||||
}
|
||||
(0..t.len()).step_by(2).map(|i| u8::from_str_radix(&t[i..i + 2], 16).ok()).collect()
|
||||
(0..t.len()).step_by(2).map(|i| u8::from_str_radix(t.get(i..i + 2)?, 16).ok()).collect()
|
||||
}
|
||||
|
||||
/// Render a project's main comp at its poster time into a thumbnail.
|
||||
@@ -943,6 +943,12 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::config::{DirConfig, MemoryConfig};
|
||||
|
||||
#[test]
|
||||
fn non_ascii_thumbnail_is_rejected_without_panicking() {
|
||||
let malformed = format!("aé{}", "0".repeat(THUMB_W * THUMB_H * 6 - 3));
|
||||
assert!(decode_thumb(&malformed).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_builtin_builds_with_controls_and_renders() {
|
||||
let s = Session::default();
|
||||
|
||||
@@ -131,6 +131,23 @@ fn environment_light_is_listed_and_takes_a_source_layer() {
|
||||
assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), None);
|
||||
}
|
||||
|
||||
/// Deleting the layer a Source points at leaves no dangling id: "None" means the comp's
|
||||
/// Environment Layer, and a dead id rendered as nothing at all (#2153).
|
||||
#[test]
|
||||
fn deleting_a_source_layer_clears_the_reference() {
|
||||
let mut s = session();
|
||||
let sky = s.execute("layer.newSolid", json!({"width": 256, "height": 128, "name": "Sky"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let env = s.execute("layer.newLight", json!({"kind": "Environment", "name": "Env"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": env, "path": "lightOptions/source", "value": sky})).unwrap();
|
||||
assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), Some(sky));
|
||||
s.execute("edit.clear", json!({"layers": [sky]})).unwrap();
|
||||
assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), None, "the reference goes with the layer");
|
||||
// One history step with the delete, so undo brings both back.
|
||||
s.undo();
|
||||
assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), Some(sky));
|
||||
assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(sky)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_d_switch_undo_redo() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
//! `effect.pickCryptomatte` / `effect.cryptomatteAt` (#473): the Cryptomatte eyedropper reads the
|
||||
//! aux channels the effect renders from (footage at its source time, a 3D precomp's generated
|
||||
//! pass) and edits the Selection text in one undo step.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId};
|
||||
use effectcraft_raster::channels3d::{crypto_float, crypto_hash};
|
||||
use effectcraft_raster::{AuxChannels, Image};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::render::FootageSource;
|
||||
|
||||
const COMMA: &str = "Green, \"Glossy\"";
|
||||
|
||||
/// 8 × 4 footage with a 16 × 8 Cryptomatte (two aux pixels per layer pixel). Layer pixels in x:
|
||||
/// 0-1 `left` (0.6) over `right` (0.4); 2-3 `left` (0.3) under the comma name (0.7), ranks out of
|
||||
/// order; 4-5 `star*`; 6 an ID missing from the manifest; 7 a 0.5 / 0.5 tie of `left` and
|
||||
/// `right`. Rows 3+ are empty. From 1 s of source time `late` replaces columns 0-1.
|
||||
struct Crypto;
|
||||
|
||||
fn aux_at(t: Tick) -> AuxChannels {
|
||||
let (w, h) = (16usize, 8usize);
|
||||
let mut planes = vec![vec![0.0f32; w * h]; 4];
|
||||
for (i, x) in (0..w * h).map(|i| (i, i % w)).filter(|(i, _)| i / w < 6) {
|
||||
let late = if t >= Tick::from_seconds_f64(1.0) { "late" } else { "left" };
|
||||
let (a, ca, b, cb) = match x {
|
||||
0..4 => (late, 0.6, "right", 0.4),
|
||||
4..8 => ("left", 0.3, COMMA, 0.7),
|
||||
8..12 => ("star*", 1.0, "", 0.0),
|
||||
12..14 => ("ghost", 1.0, "", 0.0),
|
||||
_ => ("left", 0.5, "right", 0.5),
|
||||
};
|
||||
for (k, v) in [crypto_float(crypto_hash(a)), ca, crypto_float(crypto_hash(b)), cb].into_iter().enumerate() {
|
||||
planes[k][i] = v;
|
||||
}
|
||||
}
|
||||
let mut aux = AuxChannels::new(w as u32, h as u32, 1.0);
|
||||
for (k, c) in ["R", "G", "B", "A"].iter().enumerate() {
|
||||
aux.channels.push((format!("CryptoObject00.{c}"), planes[k].clone()));
|
||||
}
|
||||
aux.manifests = vec![("CryptoObject".into(), ["left", "right", "late", COMMA, "star*"].iter().map(|n| (n.to_string(), crypto_hash(n))).collect())];
|
||||
aux
|
||||
}
|
||||
|
||||
impl FootageSource for Crypto {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
Some(Arc::new(Image::new(f.width, f.height)))
|
||||
}
|
||||
fn aux(&self, _: ItemId, _: &Footage, t: Tick) -> Option<Arc<AuxChannels>> {
|
||||
Some(Arc::new(aux_at(t)))
|
||||
}
|
||||
}
|
||||
|
||||
struct Probe;
|
||||
impl crate::Importer for Probe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
let duration = Tick::from_seconds_f64(2.0);
|
||||
// A half-size proxy of the EXR.
|
||||
let (width, height) = if path.contains("proxy") { (4, 2) } else { (8, 4) };
|
||||
Ok(Footage {
|
||||
path: path.into(),
|
||||
kind: FootageKind::Video,
|
||||
width,
|
||||
height,
|
||||
has_video: true,
|
||||
frame_rate: FrameRate::from_f64(24.0),
|
||||
duration,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session { footage: Arc::new(Crypto), importer: Some(Arc::new(Probe)), ..Default::default() };
|
||||
let item = s.execute("file.import", json!({"paths": ["/renders/crypto.exr"]})).unwrap()["items"][0].clone();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap();
|
||||
s.execute("layer.addItem", json!({"item": item})).unwrap();
|
||||
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn pick(s: &mut Session, x: f64, mode: &str) -> Value {
|
||||
s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": x, "y": 1.5, "mode": mode})).unwrap()
|
||||
}
|
||||
|
||||
fn selection(s: &Session) -> String {
|
||||
let l = &s.active_comp().unwrap().layers[0];
|
||||
let g = l.effects().unwrap().groups().next().unwrap();
|
||||
match &g.prop("selection").unwrap().value {
|
||||
effectcraft_keyframe::Value::Str(t) => t.clone(),
|
||||
v => panic!("{v:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_replace_add_and_remove_in_one_undo_step_each() {
|
||||
let mut s = session();
|
||||
let steps = s.history.undo.len();
|
||||
let r = pick(&mut s, 0.5, "replace");
|
||||
assert_eq!((r["name"].as_str(), r["selection"].as_str(), r["changed"].as_bool()), (Some("left"), Some("left"), Some(true)), "{r}");
|
||||
assert_eq!(r["hash"], format!("{:08x}", crypto_hash("left")));
|
||||
assert_eq!(s.history.undo.len(), steps + 1);
|
||||
// The highest coverage wins even when it is not rank 0; the name is quoted.
|
||||
assert_eq!(pick(&mut s, 2.5, "add")["selection"], r#"left, "Green, \"Glossy\"""#);
|
||||
// Adding it again changes nothing and leaves no undo step.
|
||||
let again = pick(&mut s, 3.5, "add");
|
||||
assert_eq!((again["changed"].as_bool(), s.history.undo.len()), (Some(false), steps + 2));
|
||||
// A `*` name and an unnamed ID go in as hashes; a tie goes to rank 0.
|
||||
pick(&mut s, 4.5, "add");
|
||||
assert_eq!(pick(&mut s, 6.5, "add")["selection"], format!(r#"left, "Green, \"Glossy\"", <{:08x}>, <{:08x}>"#, crypto_hash("star*"), crypto_hash("ghost")));
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 7.5, "y": 0.5})).unwrap()["name"], "left");
|
||||
let r = pick(&mut s, 0.0, "remove");
|
||||
assert_eq!(r["selection"], format!(r#""Green, \"Glossy\"", <{:08x}>, <{:08x}>"#, crypto_hash("star*"), crypto_hash("ghost")));
|
||||
assert_eq!(r["selected"], false);
|
||||
// The text parses back to exactly the picked IDs.
|
||||
let manifest = aux_at(Tick::ZERO).manifests[0].1.clone();
|
||||
let got = effectcraft_effects::selection_hashes(&selection(&s), Some(&manifest));
|
||||
assert_eq!(got, [COMMA, "star*", "ghost"].map(crypto_hash));
|
||||
assert_eq!(s.history.undo.len(), steps + 5);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(selection(&s).starts_with("left, "), "{}", selection(&s));
|
||||
pick(&mut s, 2.0, "replace");
|
||||
assert_eq!(selection(&s), r#""Green, \"Glossy\"""#);
|
||||
// The same selection with other spacing: no change, no undo step.
|
||||
s.execute_checked("prop.set", json!({"path": "effects/#1/selection", "value": " \"Green, \\\"Glossy\\\"\" "})).unwrap();
|
||||
let n = s.history.undo.len();
|
||||
assert_eq!((pick(&mut s, 2.0, "replace")["changed"].as_bool(), s.history.undo.len()), (Some(false), n));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_points_layers_and_modes_are_errors_and_change_nothing() {
|
||||
let mut s = session();
|
||||
let at = |s: &mut Session, x: f64, y: f64| s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": x, "y": y}));
|
||||
for (x, y) in [(-0.1, 1.0), (8.0, 1.0), (1.0, 4.0), (1.0, 3.5), (1e300, -1e300)] {
|
||||
assert!(at(&mut s, x, y).is_err(), "{x},{y}");
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": x, "y": y})).unwrap(), Value::Null);
|
||||
}
|
||||
assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1, "mode": "toggle"})).is_err());
|
||||
assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1})).is_err());
|
||||
for scale in [0.0, -1.0, 2.0] {
|
||||
assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1, "scale": scale})).is_err(), "{scale}");
|
||||
}
|
||||
s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur"})).unwrap();
|
||||
assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 2, "x": 1, "y": 1})).is_err(), "not a Cryptomatte effect");
|
||||
assert_eq!(selection(&s), "");
|
||||
// A layer without aux channels.
|
||||
s.execute("layer.newSolid", json!({"name": "S"})).unwrap();
|
||||
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
let e = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1})).unwrap_err().to_string();
|
||||
assert!(e.contains("no Cryptomatte channels"), "{e}");
|
||||
// An expression-driven Selection would ignore the edit.
|
||||
let mut s = session();
|
||||
s.execute_checked("prop.setExpression", json!({"path": "effects/#1/selection", "expression": "\"right\""})).unwrap();
|
||||
assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1})).is_err());
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 1, "y": 1})).unwrap()["selected"], false);
|
||||
}
|
||||
|
||||
/// The pick reads the frame the render shows: Time Remap's source time, not the layer time.
|
||||
#[test]
|
||||
fn time_remap_picks_the_shown_source_frame() {
|
||||
let mut s = session();
|
||||
assert_eq!(pick(&mut s, 0.5, "replace")["name"], "left");
|
||||
s.execute_checked("layer.enableTimeRemap", json!({})).unwrap();
|
||||
s.execute_checked("prop.set", json!({"path": "timeRemap", "value": 1.5})).unwrap();
|
||||
assert_eq!(pick(&mut s, 0.5, "replace")["name"], "late");
|
||||
}
|
||||
|
||||
/// A precomp of a 3D comp: the generated CryptoObject pass names its layers.
|
||||
#[test]
|
||||
fn picks_on_a_precomp_of_a_3d_comp() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 20, "frameRate": 24, "duration": 1})).unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({"name": "Red, Ball", "width": 40, "height": 20})).unwrap()["layer"].clone();
|
||||
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("layer.precompose", json!({"layers": [l], "name": "Inner"})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [s.active_comp().unwrap().layers[0].id.0]})).ok();
|
||||
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 20, "y": 10})).unwrap();
|
||||
assert_eq!((r["name"].as_str(), r["selection"].as_str()), (Some("Red, Ball"), Some("\"Red, Ball\"")), "{r}");
|
||||
}
|
||||
|
||||
/// The matte the rendered Cryptomatte effect gives the picked ID at the buffer pixel under
|
||||
/// `{x, y}`: the pick has to name the ID the effect's own input buffer samples there.
|
||||
fn rendered_matte_of_pick(s: &mut Session, x: f64, y: f64, scale: f64) -> (String, f32) {
|
||||
let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": "Cryptomatte", "x": x, "y": y, "scale": scale, "mode": "replace"})).unwrap();
|
||||
let fx = s.active_comp().unwrap().layers[0].effects().unwrap().groups().position(|g| g.match_id == "ec.3d.cryptomatte").unwrap() + 1;
|
||||
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 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];
|
||||
(r["name"].as_str().unwrap_or("?").to_string(), m)
|
||||
}
|
||||
|
||||
/// Between 3/4 and full resolution footage keeps its native pixels: at 0.8 the effect samples
|
||||
/// layer pixel 2 (the comma name, 0.7) at x = 2.2, not the 0.8-grid pixel snapped back to 1.
|
||||
#[test]
|
||||
fn pick_at_scale_0_8_matches_the_native_buffer_the_effect_renders() {
|
||||
let mut s = session();
|
||||
let (name, m) = rendered_matte_of_pick(&mut s, 2.2, 1.5, 0.8);
|
||||
assert!(m >= 0.5, "picked {name:?}, but the render gives it matte {m} there");
|
||||
assert_eq!(name, COMMA);
|
||||
}
|
||||
|
||||
/// A half-size proxy renders a buffer at 0.5 layer pixels per pixel even at full resolution:
|
||||
/// x = 6.5 lies in buffer pixel 3, which samples layer x = 7 (the `left` / `right` tie).
|
||||
#[test]
|
||||
fn pick_on_a_proxy_matches_the_proxy_sized_buffer() {
|
||||
let mut s = session();
|
||||
let item = *s.project.items.iter().find(|(_, i)| matches!(i.kind, effectcraft_project::ItemKind::Footage(_))).unwrap().0;
|
||||
s.execute("file.setProxy", json!({"item": item.0, "path": "/renders/crypto_proxy.exr"})).unwrap();
|
||||
let (name, m) = rendered_matte_of_pick(&mut s, 6.5, 1.5, 1.0);
|
||||
assert!(m >= 0.5, "picked {name:?}, but the render gives it matte {m} there");
|
||||
assert_eq!(name, "left");
|
||||
}
|
||||
|
||||
/// 1920-px footage with `left` up to x = 120 and `right` after it, and a 104-px proxy.
|
||||
struct Edge;
|
||||
impl FootageSource for Edge {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
Some(Arc::new(Image::new(f.width, f.height)))
|
||||
}
|
||||
fn aux(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<AuxChannels>> {
|
||||
let (w, h) = (1920usize, 40usize);
|
||||
let mut aux = AuxChannels::new(w as u32, h as u32, 1.0);
|
||||
let ids = (0..w * h).map(|i| crypto_float(crypto_hash(if i % w < 120 { "left" } else { "right" }))).collect();
|
||||
aux.channels = vec![("CryptoObject00.R".into(), ids), ("CryptoObject00.G".into(), vec![1.0; w * h])];
|
||||
aux.manifests = vec![("CryptoObject".into(), ["left", "right"].iter().map(|n| (n.to_string(), crypto_hash(n))).collect())];
|
||||
Some(Arc::new(aux))
|
||||
}
|
||||
}
|
||||
|
||||
/// Proxy buffer pixel 6's centre maps to layer x = 6.5 / (104 / 1920): 120.0 by division, but
|
||||
/// 119.99999999999999 by the reciprocal the effect multiplies with. The pick must agree.
|
||||
#[test]
|
||||
fn pick_maps_pixel_centres_bit_exactly_as_the_effect() {
|
||||
let mut s = Session { footage: Arc::new(Edge), ..Default::default() };
|
||||
let at = |path: &str, width| Footage {
|
||||
path: path.into(),
|
||||
kind: FootageKind::Video,
|
||||
width,
|
||||
height: 40,
|
||||
has_video: true,
|
||||
frame_rate: FrameRate::from_f64(24.0),
|
||||
duration: Tick::from_seconds_f64(2.0),
|
||||
..Default::default()
|
||||
};
|
||||
s.importer = Some(Arc::new(EdgeProbe(at("/renders/edge.exr", 1920), at("/renders/edge_proxy.exr", 104))));
|
||||
let item = s.execute("file.import", json!({"paths": ["/renders/edge.exr"]})).unwrap()["items"][0].clone();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 1920, "height": 40, "frameRate": 24, "duration": 2})).unwrap();
|
||||
s.execute("layer.addItem", json!({"item": item})).unwrap();
|
||||
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
s.execute("file.setProxy", json!({"item": item, "path": "/renders/edge_proxy.exr"})).unwrap();
|
||||
let (name, m) = rendered_matte_of_pick(&mut s, 118.0, 5.0, 1.0);
|
||||
assert_eq!((name.as_str(), m), ("left", 1.0));
|
||||
}
|
||||
|
||||
struct EdgeProbe(Footage, Footage);
|
||||
impl crate::Importer for EdgeProbe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
Ok(if path.contains("proxy") { self.1.clone() } else { self.0.clone() })
|
||||
}
|
||||
}
|
||||
|
||||
/// Footage whose decoded frames are `num / den` of the probed size (a file re-rendered at another
|
||||
/// resolution, a relinked file): the render stretches them over the layer.
|
||||
struct OffNominal(u32, u32);
|
||||
impl FootageSource for OffNominal {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
Some(Arc::new(Image::new(f.width * self.0 / self.1, f.height * self.0 / self.1)))
|
||||
}
|
||||
fn aux(&self, _: ItemId, _: &Footage, t: Tick) -> Option<Arc<AuxChannels>> {
|
||||
Some(Arc::new(aux_at(t)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Points along rows `ys` where the picked ID isn't lit in the rendered matte under the point.
|
||||
fn mismatches(s: &mut Session, scales: &[f64], xs: impl Iterator<Item = f64> + Clone, ys: &[f64]) -> Vec<String> {
|
||||
let mut bad = vec![];
|
||||
for &scale in scales {
|
||||
for (x, &y) in ys.iter().flat_map(|y| xs.clone().map(move |x| (x, y))) {
|
||||
if s.execute("effect.cryptomatteAt", json!({"effect": "Cryptomatte", "x": x, "y": y, "scale": scale})).unwrap().is_null() {
|
||||
continue;
|
||||
}
|
||||
let (name, m) = rendered_matte_of_pick(s, x, y, scale);
|
||||
if m < 0.5 {
|
||||
bad.push(format!("scale {scale} ({x:.2}, {y}): picked {name:?}, matte {m}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
bad
|
||||
}
|
||||
|
||||
const PREVIEW_SCALES: [f64; 7] = [1.0, 0.8, 0.75, 0.74, 0.5, 0.33, 0.25];
|
||||
|
||||
/// The pick follows the decoded frame's real size, not the footage's nominal one.
|
||||
#[test]
|
||||
fn pick_matches_the_render_when_frames_decode_off_nominal_size() {
|
||||
for (num, den) in [(2, 1), (1, 2), (3, 4)] {
|
||||
let mut s = Session { footage: Arc::new(OffNominal(num, den)), importer: Some(Arc::new(Probe)), ..Default::default() };
|
||||
let item = s.execute("file.import", json!({"paths": ["/renders/crypto.exr"]})).unwrap()["items"][0].clone();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap();
|
||||
s.execute("layer.addItem", json!({"item": item})).unwrap();
|
||||
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
let bad = mismatches(&mut s, &PREVIEW_SCALES, (0..80).map(|i| i as f64 * 0.1 + 0.05), &[0.3, 1.7, 2.6]);
|
||||
assert!(bad.is_empty(), "decode {num}/{den}: {} mismatches:\n{}", bad.len(), bad.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
/// A precomp of a 3D comp, with and without Preserve Resolution (drawn at full size below full
|
||||
/// resolution), behind a disabled effect: the pick names the ID the rendered matte shows.
|
||||
#[test]
|
||||
fn pick_matches_the_render_on_precomps_with_and_without_preserve_resolution() {
|
||||
for preserve in [false, true] {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 20, "frameRate": 24, "duration": 2})).unwrap();
|
||||
let a = s.execute("layer.newSolid", json!({"name": "A", "width": 40, "height": 20})).unwrap()["layer"].clone();
|
||||
let b = s.execute("layer.newSolid", json!({"name": "B", "width": 7, "height": 20})).unwrap()["layer"].clone();
|
||||
s.execute_checked("prop.set", json!({"layer": b, "path": "transform/position", "value": [3.5, 10, -10]})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap();
|
||||
let inner = *s.project.items.iter().find(|(_, i)| i.name == "Inner").unwrap().0;
|
||||
s.execute_checked("comp.settings", json!({"comp": inner.0, "preserveResolution": preserve})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [s.active_comp().unwrap().layers[0].id.0]})).ok();
|
||||
s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur"})).unwrap();
|
||||
s.execute_checked("effect.toggle", json!({"effect": 1, "value": false})).unwrap();
|
||||
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
let bad = mismatches(&mut s, &PREVIEW_SCALES, (0..93).map(|i| i as f64 * 0.15 + 0.05), &[10.0]);
|
||||
assert!(bad.is_empty(), "preserve {preserve}: {} mismatches:\n{}", bad.len(), bad.join("\n"));
|
||||
}
|
||||
}
|
||||
@@ -772,6 +772,27 @@ fn preset_round_trips_effect_properties_by_match_name() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2064: `prop.addKey` accepts the `effects/<match name>[#n]/<property>` path that
|
||||
/// `essential.list` reports.
|
||||
#[test]
|
||||
fn add_key_resolves_effect_match_name_paths() {
|
||||
let (mut s, a, _) = comp_with_two_solids();
|
||||
apply(&mut s, a, "Slider Control");
|
||||
apply(&mut s, a, "Slider Control");
|
||||
let slider = |s: &Session, n: usize| match layer(s, a).effects().unwrap().groups().nth(n).unwrap().get("slider").unwrap().value {
|
||||
KV::Scalar(v) => v,
|
||||
ref other => panic!("not a scalar: {other:?}"),
|
||||
};
|
||||
for (path, n, value) in [("effects/ec.control.slider/slider", 0, 30.0), ("effects/ec.control.slider#2/slider", 1, 70.0)] {
|
||||
let reply = s.execute("prop.addKey", json!({"layer": a, "path": path, "time": 0, "value": value})).unwrap();
|
||||
assert_eq!(reply, json!(1), "{path}");
|
||||
let got = s.execute("prop.get", json!({"layer": a, "path": path, "time": 0})).unwrap();
|
||||
assert_eq!(got["value"], json!(value), "{path}");
|
||||
assert_eq!(got["keys"].as_array().unwrap().len(), 1, "{path}");
|
||||
assert_eq!(slider(&s, n), 0.0, "the static value stays put; the key carries {value}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1746: Edit ▸ Copy of an effect property writes the same `effects/ec.blur.gaussian/blurriness` path,
|
||||
/// and Paste (the value, or Copy Expression Only) sets that property of the other layer's effect.
|
||||
#[test]
|
||||
|
||||
@@ -57,7 +57,10 @@ fn default_layer_times_are_preserved_and_outside_keys_warn() {
|
||||
assert!(selected.get("warning").is_some());
|
||||
let moved = s.execute("keys.set", json!({"layer": layer, "path": "transform/opacity", "time": 15.3, "newTime": 9.5, "timeBase": "layer"})).unwrap();
|
||||
assert_seconds(&moved["compTime"], 20.0);
|
||||
assert!(moved.get("warning").is_some(), "out point is exclusive");
|
||||
assert!(moved.get("warning").is_none(), "a key on the out point is accepted");
|
||||
let past = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/rotation", "time": 20.1, "timeBase": "comp", "value": 45})).unwrap();
|
||||
assert_seconds(&past["compTime"], 20.1);
|
||||
assert!(past.get("warning").is_some(), "a key past the out point warns");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -102,3 +102,38 @@ fn commands_cap_sizes_at_30000_px() {
|
||||
let f = s.project.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.clone()) } else { None }).unwrap();
|
||||
assert_eq!((f.width, f.height, f.frame_rate), (limits::MAX_SIZE, 8, FrameRate::FPS_29_97));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_project_with_a_precomp_cycle_opens_adds_and_renders_without_running_out_of_stack() {
|
||||
// Comps A and B nest each other (a damaged file); C is unrelated. Checking whether A
|
||||
// contains C walked the A ⇄ B cycle until the stack ran out (#1237).
|
||||
let mut s = Session::default();
|
||||
let mut ids = vec![];
|
||||
for name in ["A", "B", "C"] {
|
||||
s.execute("comp.new", json!({"name": name, "width": 32, "height": 32, "frameRate": 30, "duration": 1})).unwrap();
|
||||
ids.push(s.active_comp_id().unwrap());
|
||||
}
|
||||
let (a, b, c) = (ids[0], ids[1], ids[2]);
|
||||
{
|
||||
let proj = std::sync::Arc::make_mut(&mut s.project);
|
||||
for (outer, inner) in [(a, b), (b, a)] {
|
||||
let comp = proj.comp(outer).unwrap().clone();
|
||||
let l = effectcraft_project::build::layer(proj, &comp, "pre", effectcraft_project::LayerSource::Comp { item: inner }, (32, 32), None);
|
||||
let ItemKind::Comp(cc) = &mut proj.item_mut(outer).unwrap().kind else { panic!("comp") };
|
||||
std::sync::Arc::make_mut(cc).layers.push(l);
|
||||
}
|
||||
}
|
||||
let path = tmp("cycle.ecproj");
|
||||
std::fs::write(&path, s.project.to_file_json().unwrap()).unwrap();
|
||||
let mut t = Session::default();
|
||||
t.execute("file.open", json!({"path": path})).unwrap();
|
||||
t.open_comp(c);
|
||||
// Adding A into C asks whether A contains C: it doesn't, so the layer goes in.
|
||||
let r = t.execute("layer.addItem", json!({"comp": c.0, "item": a.0})).unwrap();
|
||||
assert!(r["layer"].as_u64().is_some(), "{r}");
|
||||
// Adding C into A would nest C in itself through A: refused, as before.
|
||||
assert!(t.execute("layer.addItem", json!({"comp": a.0, "item": c.0})).is_err());
|
||||
// C renders: inside A the renderer skips B's copy of A instead of looping.
|
||||
let img = t.render(c, Tick::ZERO, RenderOpts { scale: 1.0, ..Default::default() });
|
||||
assert_eq!((img.width, img.height), (32, 32));
|
||||
}
|
||||
|
||||
@@ -42,14 +42,14 @@ fn split_layers_go_above_or_below_the_original() {
|
||||
let mut s = session();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
let below = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap();
|
||||
let order: Vec<u64> = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect();
|
||||
assert_eq!(order, vec![a, below], "off (default): the new part goes below");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("prefs.set", json!({"key": "general.createSplitLayersAbove", "value": true})).unwrap();
|
||||
let above = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap();
|
||||
let order: Vec<u64> = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect();
|
||||
assert_eq!(order, vec![above, a]);
|
||||
assert_eq!(order, vec![above, a], "on (default): the new part goes above");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("prefs.set", json!({"key": "general.createSplitLayersAbove", "value": false})).unwrap();
|
||||
let below = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap();
|
||||
let order: Vec<u64> = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect();
|
||||
assert_eq!(order, vec![a, below]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -111,6 +111,29 @@ fn set_switch_others() {
|
||||
assert!(s.execute("layer.setSwitch", json!({"layers": [c], "switch": "nope", "others": true})).is_err());
|
||||
}
|
||||
|
||||
/// The Video switch has a cell on every layer's row, cameras and lights included, so Alt-clicking
|
||||
/// it (and Hide Other Video) takes them along with the rest.
|
||||
#[test]
|
||||
fn video_others_takes_cameras_and_lights() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
let light = s.execute("layer.newLight", json!({"kind": "Point"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let get = |s: &Session, f: fn(&effectcraft_project::Layer) -> bool| [a, cam, light].map(|l| f(&layer(s, l)));
|
||||
// The camera and the light are hidden: Alt-click on the solid's eye brings them back, since
|
||||
// they stand apart with it, and the next Alt-click hides them with every other layer.
|
||||
s.execute("layer.setSwitch", json!({"layers": [cam, light], "switch": "video", "value": false})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "others": true})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.video), [true; 3]);
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "others": true})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.video), [true, false, false]);
|
||||
// Hide Other Video takes them too.
|
||||
s.execute("layer.showAllVideo", json!({})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.video), [true; 3]);
|
||||
s.execute("layer.hideOtherVideo", json!({"layers": [a]})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.video), [true, false, false]);
|
||||
}
|
||||
|
||||
/// A null's Video switch (the eye) works like any layer's, as in After Effects: it toggles,
|
||||
/// Alt-click and Hide Other Video take nulls along, and its children still follow it while it is
|
||||
/// hidden (parenting ignores the eye).
|
||||
@@ -725,6 +748,37 @@ fn reveal_properties() {
|
||||
assert!(frontend_events(&mut s).iter().all(|(c, _)| c == "timeline.revealProps"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_layer_source_selects_each_layers_source_item() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let b = solid(&mut s, "#00ff00");
|
||||
let ia = layer(&s, a).source.item().unwrap();
|
||||
let ib = layer(&s, b).source.item().unwrap();
|
||||
assert_ne!(ia, ib);
|
||||
s.execute("layer.select", json!({"layers": [a, b]})).unwrap();
|
||||
let r = s.execute("layer.revealSource", json!({})).unwrap();
|
||||
assert_eq!(r["item"], ia.0);
|
||||
assert_eq!(r["items"], json!([ia.0, ib.0]));
|
||||
assert_eq!(s.state.project_selection, vec![ia, ib]);
|
||||
assert_eq!(frontend_events(&mut s), vec![("project.reveal".to_string(), json!({"items": [ia.0, ib.0]}))]);
|
||||
// A null has no source. Alone it is refused; in a mixed selection only the sources are revealed.
|
||||
let n = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.select", json!({"layers": [n]})).unwrap();
|
||||
assert!(s.execute("layer.revealSource", json!({})).is_err());
|
||||
s.execute("layer.select", json!({"layers": [n, a]})).unwrap();
|
||||
let r = s.execute("layer.revealSource", json!({})).unwrap();
|
||||
assert_eq!(r["items"], json!([ia.0]));
|
||||
assert_eq!(s.state.project_selection, vec![ia]);
|
||||
// Reveal Composition in Project opens the same panel and folders.
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
s.drain_events();
|
||||
let r = s.execute("comp.revealInProject", json!({"comp": cid.0})).unwrap();
|
||||
assert_eq!(r["item"], cid.0);
|
||||
assert_eq!(s.state.project_selection, vec![cid]);
|
||||
assert_eq!(frontend_events(&mut s), vec![("project.reveal".to_string(), json!({"items": [cid.0]}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_layers() {
|
||||
let mut s = comp();
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Layer ▸ Replace Source (#991, and the `replaceSource` part of #813): Alt-drag and Ctrl+Alt+/
|
||||
//! replace an AV layer's source while every other layer property stays.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerSource, Solid};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn comp() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn layer(s: &Session, id: u64) -> effectcraft_project::Layer {
|
||||
s.active_comp().unwrap().layer(effectcraft_project::LayerId(id)).unwrap().clone()
|
||||
}
|
||||
|
||||
fn solid(s: &mut Session, name: &str) -> u64 {
|
||||
s.execute("layer.newSolid", json!({"name": name, "color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
fn add_footage(s: &mut Session, name: &str, kind: FootageKind, width: u32, height: u32, has_audio: bool) -> ItemId {
|
||||
let f = Footage { path: format!("/tmp/{name}"), kind, width, height, has_video: kind != FootageKind::Data, has_audio, ..Default::default() };
|
||||
Arc::make_mut(&mut s.project).add_item(name, Label::None, None, ItemKind::Footage(f))
|
||||
}
|
||||
|
||||
/// The replacement changes `source` and nothing else: name, label, comment, timing, stretch,
|
||||
/// switches, blending, parenting, track matte and the whole property tree (keys, expression,
|
||||
/// effect, mask) stay.
|
||||
#[test]
|
||||
fn replace_source_keeps_every_layer_property() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "Hero");
|
||||
let parent = solid(&mut s, "Parent");
|
||||
let matte = solid(&mut s, "Matte");
|
||||
s.execute("layer.timing", json!({"layers": [a], "in": 1.0, "out": 5.0})).unwrap();
|
||||
s.execute("layer.timeStretch", json!({"layers": [a], "percent": 50})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "preserveTransparency", "value": true})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "motionBlur", "value": true})).unwrap();
|
||||
s.execute("layer.setBlendMode", json!({"layers": [a], "mode": "Multiply"})).unwrap();
|
||||
s.execute("layer.setParent", json!({"layers": [a], "parent": parent, "compensate": false})).unwrap();
|
||||
s.execute("layer.setTrackMatte", json!({"layer": a, "matte": matte, "kind": "alpha"})).unwrap();
|
||||
s.execute("layer.setComment", json!({"layers": [a], "comment": "keep me"})).unwrap();
|
||||
s.execute("effect.apply", json!({"layer": a, "effect": "Gaussian Blur"})).unwrap();
|
||||
s.execute("mask.new", json!({"layer": a, "vertices": [[10, 10], [90, 10], [90, 90]], "closed": true})).unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": 1.0, "value": 25.0})).unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": 2.0, "value": 75.0})).unwrap();
|
||||
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "expression": "time * 90"})).unwrap();
|
||||
let before = layer(&s, a);
|
||||
let item = add_footage(&mut s, "new.png", FootageKind::Still, 120, 80, false);
|
||||
s.execute("layer.replaceSource", json!({"layers": [a], "item": item.0})).unwrap();
|
||||
let after = layer(&s, a);
|
||||
assert_eq!(after.source, LayerSource::Footage { item });
|
||||
assert_eq!(after.name, before.name);
|
||||
assert_eq!(after.label, before.label);
|
||||
assert_eq!(after.comment, before.comment);
|
||||
assert_eq!((after.start_time, after.in_point, after.out_point, after.stretch), (before.start_time, before.in_point, before.out_point, before.stretch));
|
||||
assert_eq!((after.switches, after.blend_mode, after.preserve_transparency), (before.switches, before.blend_mode, before.preserve_transparency));
|
||||
assert_eq!((after.parent, after.track_matte), (before.parent, before.track_matte));
|
||||
assert_eq!(after.props, before.props);
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 3, "no layer is added or removed");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(layer(&s, a).source, before.source, "one undo step restores the source");
|
||||
}
|
||||
|
||||
/// Composition, footage and solid sources are interchangeable; a precomposition adds its Audio
|
||||
/// group when the layer has none, and an existing Audio group is kept when the next source has no
|
||||
/// audio.
|
||||
#[test]
|
||||
fn replace_source_crosses_types_and_adds_audio() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "Layer");
|
||||
let inner = s.execute("comp.new", json!({"name": "Inner", "width": 200, "height": 100, "frameRate": 25, "duration": 3})).unwrap()["comp"].as_u64().unwrap();
|
||||
let main = s.project.items.values().find(|i| i.name == "Main").unwrap().id;
|
||||
s.execute("comp.open", json!({"comp": main})).unwrap();
|
||||
let before = layer(&s, a);
|
||||
s.execute("layer.replaceSource", json!({"layers": [a], "item": inner})).unwrap();
|
||||
let after = layer(&s, a);
|
||||
assert_eq!(after.source, LayerSource::Comp { item: ItemId(inner) });
|
||||
assert_eq!(after.name, before.name);
|
||||
assert!(after.props.sub("audio").is_some(), "a precomposition brings Audio");
|
||||
let footage = add_footage(&mut s, "still.png", FootageKind::Still, 64, 32, false);
|
||||
s.execute("layer.replaceSource", json!({"layers": [a], "item": footage.0})).unwrap();
|
||||
let after = layer(&s, a);
|
||||
assert_eq!(after.source, LayerSource::Footage { item: footage });
|
||||
assert!(after.props.sub("audio").is_some(), "the Audio group is preserved for the next source");
|
||||
let new_solid = Arc::make_mut(&mut s.project).add_item(
|
||||
"New Solid",
|
||||
Label::None,
|
||||
None,
|
||||
ItemKind::Solid(Solid { color: [0.0, 1.0, 0.0], width: 10, height: 10, pixel_aspect: 1.0 }),
|
||||
);
|
||||
s.execute("layer.replaceSource", json!({"layers": [a], "item": new_solid.0})).unwrap();
|
||||
assert_eq!(layer(&s, a).source, LayerSource::Solid { item: new_solid });
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(layer(&s, a).source, LayerSource::Footage { item: footage });
|
||||
}
|
||||
|
||||
/// Folders, data files, 3D models, non-source layers and composition cycles are refused before
|
||||
/// anything is edited.
|
||||
#[test]
|
||||
fn replace_source_refuses_invalid_sources_targets_and_cycles() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "Layer");
|
||||
let folder = Arc::make_mut(&mut s.project).add_item("Folder", Label::None, None, ItemKind::Folder);
|
||||
let data = add_footage(&mut s, "data.txt", FootageKind::Data, 0, 0, false);
|
||||
let model = add_footage(&mut s, "model.glb", FootageKind::Model, 10, 10, false);
|
||||
for (item, needle) in [(folder, "folder"), (data, "data"), (model, "3D model")] {
|
||||
let err = s.execute("layer.replaceSource", json!({"layers": [a], "item": item.0})).unwrap_err().to_string();
|
||||
assert!(err.contains(needle), "{err}");
|
||||
}
|
||||
let footage = add_footage(&mut s, "still.png", FootageKind::Still, 10, 10, false);
|
||||
let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let err = s.execute("layer.replaceSource", json!({"layers": [text], "item": footage.0})).unwrap_err().to_string();
|
||||
assert!(err.contains("no source item"), "{err}");
|
||||
let inner = s.execute("comp.new", json!({"name": "Inner", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap()["comp"].as_u64().unwrap();
|
||||
let main = s.project.items.values().find(|i| i.name == "Main").unwrap().id;
|
||||
s.execute("comp.open", json!({"comp": main})).unwrap();
|
||||
let inner_layer = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap();
|
||||
let err = s.execute("layer.replaceSource", json!({"layers": [inner_layer], "item": main.0})).unwrap_err().to_string();
|
||||
assert!(err.contains("contain"), "{err}");
|
||||
}
|
||||
|
||||
/// Ctrl+Alt+/ takes the source from the Project panel selection and replaces every selected
|
||||
/// layer; locked layers are left alone (and an all-locked selection is an error).
|
||||
#[test]
|
||||
fn replace_source_uses_the_project_selection_and_skips_locked_layers() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "A");
|
||||
let b = solid(&mut s, "B");
|
||||
let footage = add_footage(&mut s, "still.png", FootageKind::Still, 10, 10, false);
|
||||
s.state.project_selection = vec![footage];
|
||||
let r = s.execute("layer.replaceSource", json!({"layers": [a, b]})).unwrap();
|
||||
assert_eq!(r["item"], json!(footage.0));
|
||||
assert!(matches!(layer(&s, a).source, LayerSource::Footage { item } if item == footage));
|
||||
assert!(matches!(layer(&s, b).source, LayerSource::Footage { item } if item == footage));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap();
|
||||
let old_b = layer(&s, b).source;
|
||||
let r = s.execute("layer.replaceSource", json!({"layers": [a, b], "item": footage.0})).unwrap();
|
||||
assert_eq!(r["layers"], json!([a]));
|
||||
assert!(matches!(layer(&s, a).source, LayerSource::Footage { item } if item == footage));
|
||||
assert_eq!(layer(&s, b).source, old_b);
|
||||
let err = s.execute("layer.replaceSource", json!({"layers": [b], "item": footage.0})).unwrap_err().to_string();
|
||||
assert!(err.contains("locked"), "{err}");
|
||||
}
|
||||
|
||||
/// After Effects' Ctrl+Alt+/ default (Cmd+Alt+/ in EffectCraft's shortcut syntax).
|
||||
#[test]
|
||||
fn replace_source_is_bound_to_ctrl_alt_slash() {
|
||||
let b = crate::shortcuts::bindables(&[]).into_iter().find(|b| b.command == "layer.replaceSource").expect("layer.replaceSource is bindable");
|
||||
assert!(b.defaults.iter().any(|d| d == "Cmd+Alt+/"), "{:?}", b.defaults);
|
||||
}
|
||||
@@ -8,14 +8,16 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Probes every file as a 64×32 still (the media layer's single-file probe).
|
||||
/// Probes every file as a still 64 + its byte length wide and 32 high (the media layer's
|
||||
/// single-file probe).
|
||||
struct Stills;
|
||||
impl crate::Importer for Stills {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
if !std::path::Path::new(path).is_file() {
|
||||
return Err(format!("{path}: no such file"));
|
||||
}
|
||||
Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, codec: "PNG".into(), ..Default::default() })
|
||||
let width = 64 + std::fs::metadata(path).map_or(0, |m| m.len() as u32);
|
||||
Ok(Footage { path: path.into(), kind: FootageKind::Still, width, height: 32, has_video: true, codec: "PNG".into(), ..Default::default() })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +198,19 @@ fn replace_footage_and_reload_keep_sequences() {
|
||||
assert_eq!(f.duration, FrameRate::from_f64(12.0).tick_of(5));
|
||||
}
|
||||
|
||||
/// #2074: reloading a numbered still probed it as its run, so it took the first file's size.
|
||||
#[test]
|
||||
fn reloading_a_numbered_still_measures_its_own_file() {
|
||||
let dir = folder(&RUN);
|
||||
let mut s = session();
|
||||
let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "sequence": false})).unwrap()["items"][0].clone();
|
||||
std::fs::write(at(&dir, "shot_0002.png"), [0; 16]).unwrap();
|
||||
s.execute("file.reloadFootage", json!({"items": [item]})).unwrap();
|
||||
let (name, f) = footage(&s, &item);
|
||||
assert_eq!((name.as_str(), f.kind, f.width, f.sequence.len()), ("shot_0002.png", FootageKind::Still, 80, 0));
|
||||
assert_eq!(f.path, at(&dir, "shot_0002.png"));
|
||||
}
|
||||
|
||||
/// #756: numbered DPX and Cineon scans import as sequences, as in After Effects (`.cin` was not
|
||||
/// an image to the Media Browser, so its runs came in one file at a time).
|
||||
#[test]
|
||||
@@ -207,3 +222,31 @@ fn dpx_and_cineon_runs_import_as_sequences() {
|
||||
got.sort();
|
||||
assert_eq!(got, [("plate.[0001-0003].dpx".to_string(), 3), ("scan_[01-02].cin".to_string(), 2)], "{r}");
|
||||
}
|
||||
|
||||
/// #1926: an edit that leaves the footage alone doesn't stamp every frame of its image sequences
|
||||
/// again for the disk cache's salt (with large sequences each keystroke in a text field took over
|
||||
/// half a second); Reload Footage does, and so does new footage.
|
||||
#[test]
|
||||
fn edits_keep_the_disk_salt_and_reload_stamps_the_files_again() {
|
||||
let dir = folder(&RUN);
|
||||
let cache = std::env::temp_dir().join(format!("ec-seq-salt-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&cache);
|
||||
let mut s = session();
|
||||
s.execute_checked("prefs.set", json!({"values": {"disk.diskCacheFolder": cache.to_string_lossy(), "disk.diskCacheEnabled": true}})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 64, "height": 32, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "sequence": true, "addToComp": true})).unwrap();
|
||||
let item = r["items"][0].clone();
|
||||
let salt = s.refresh_disk_salt(false).expect("a disk cache");
|
||||
// A frame rewritten in place: same footage settings, another size on disk.
|
||||
std::fs::write(at(&dir, "shot_0004.png"), b"a new render of this frame").unwrap();
|
||||
s.execute("layer.newText", json!({"text": "title"})).unwrap();
|
||||
s.execute("layer.setText", json!({"text": "title 2"})).unwrap();
|
||||
assert_eq!(s.refresh_disk_salt(false), Some(salt), "edits don't stamp the files again");
|
||||
s.execute("file.reloadFootage", json!({"items": [item]})).unwrap();
|
||||
let reloaded = s.refresh_disk_salt(false).unwrap();
|
||||
assert_ne!(reloaded, salt, "Reload Footage stamps them again");
|
||||
std::fs::write(at(&dir, "extra.png"), b"").unwrap();
|
||||
s.execute("file.import", json!({"paths": [at(&dir, "extra.png")]})).unwrap();
|
||||
assert_ne!(s.refresh_disk_salt(false), Some(reloaded), "new footage counts");
|
||||
let _ = std::fs::remove_dir_all(&cache);
|
||||
}
|
||||
|
||||
@@ -1064,6 +1064,12 @@ fn interface_language_is_validated_persisted_and_backward_compatible() {
|
||||
cs.load_settings();
|
||||
assert_eq!(cs.prefs.general.language, "cs");
|
||||
assert_eq!(Prefs::from_json(r#"{"general":{"language":"cs"}}"#).general.language, "cs");
|
||||
// Georgian too.
|
||||
assert!(crate::prefs::LANGUAGES.contains(&("ქართული", "ka")));
|
||||
s.execute("prefs.set", json!({"key": "general.language", "value": "ka"})).unwrap();
|
||||
let mut ka = Session { config: Some(store.clone()), ..Default::default() };
|
||||
ka.load_settings();
|
||||
assert_eq!(ka.prefs.general.language, "ka");
|
||||
s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap();
|
||||
let mut reloaded = Session { config: Some(store), ..Default::default() };
|
||||
reloaded.load_settings();
|
||||
|
||||
@@ -392,3 +392,38 @@ fn set_text_schema_accepts_every_opentype_key_and_requires_none() {
|
||||
s.execute_checked("layer.setText", json!({"layer": t, "discretionaryLigatures": true, "ss02": true, "ss20": true})).unwrap();
|
||||
assert!(doc(&s, t).style_at(0).opentype.stylistic_set(20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deselect_all_ends_the_open_edit() {
|
||||
let (mut s, t) = session("Hello");
|
||||
s.execute("text.edit", json!({"layer": t, "caret": 5})).unwrap();
|
||||
s.execute("text.insert", json!({"text": "!"})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "Hello!");
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
assert!(s.state.text_edit.is_none(), "Deselect All ends the open text edit on every route");
|
||||
assert!(s.state.selected_layers.is_empty(), "and leaves nothing selected");
|
||||
assert_eq!(doc(&s, t).text, "Hello!", "the text stays committed");
|
||||
// Ending an edit is no undoable step of its own (as with text.endEdit): undo takes
|
||||
// the typed text, it does not reopen the edit.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "Hello", "undo reverts the typed text, not the deselection");
|
||||
assert!(s.state.text_edit.is_none(), "undo does not reopen the edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deselect_all_discards_an_empty_type_tool_layer() {
|
||||
let (mut s, _keep) = session("Hello");
|
||||
let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert!(
|
||||
s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == effectcraft_project::LayerId(new)),
|
||||
"the Type tool opens the edit on its new, empty layer"
|
||||
);
|
||||
let count = |s: &Session| s.active_comp().map_or(0, |c| c.layers.len());
|
||||
let before = count(&s);
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
assert!(s.state.text_edit.is_none());
|
||||
assert!(s.state.selected_layers.is_empty());
|
||||
assert_eq!(count(&s), before - 1, "a Type-tool layer left empty is removed, as text.endEdit does");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(count(&s), before, "that removal is one undo step of its own, as on text.endEdit");
|
||||
}
|
||||
|
||||
@@ -613,6 +613,51 @@ fn time_set_reads_frames_style_digits_as_a_frame_count() {
|
||||
assert_eq!(r["display"], "00100");
|
||||
}
|
||||
|
||||
/// Project Settings ▸ Time Display Style ▸ Frames ▸ Frame Count: Start at 0 / Start at 1
|
||||
/// (`app.project.framesCountType`), undoable and saved with the project (#1807).
|
||||
#[test]
|
||||
fn project_settings_frame_count_starts_at_one() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Count", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
|
||||
s.execute("file.projectSettings", json!({"timeDisplay": "frames"})).unwrap();
|
||||
assert_eq!(s.execute("time.set", json!({"frame": 12})).unwrap()["display"], "00012");
|
||||
|
||||
s.execute("file.projectSettings", json!({"frameStart": 1})).unwrap();
|
||||
assert_eq!(s.execute("time.set", json!({"frame": 12})).unwrap()["display"], "00013");
|
||||
for bad in [json!(2), json!(-1), json!("one")] {
|
||||
assert!(s.execute("file.projectSettings", json!({"frameStart": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.project.settings.frame_start, 1);
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("ec-frame-start-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("count.ecproj").to_string_lossy().to_string();
|
||||
s.execute("file.save", json!({"path": path})).unwrap();
|
||||
let mut reopened = Session::default();
|
||||
reopened.execute("file.open", json!({"path": path})).unwrap();
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
assert_eq!(reopened.project.settings.frame_start, 1);
|
||||
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.execute("time.set", json!({"frame": 12})).unwrap()["display"], "00012");
|
||||
}
|
||||
|
||||
/// A Duration is a count of frames: Start at 1 and the comp's Start Timecode number positions,
|
||||
/// not lengths (#1807).
|
||||
#[test]
|
||||
fn durations_ignore_the_frame_count_and_the_start_timecode() {
|
||||
use crate::commands::time::display_duration;
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Len", "width": 64, "height": 64, "frameRate": 30, "duration": 10, "startTimecode": "0:00:01:00"})).unwrap();
|
||||
s.execute("file.projectSettings", json!({"frameStart": 1})).unwrap();
|
||||
let comp = s.active_comp().unwrap().clone();
|
||||
let len = comp.frame_rate.tick_of(30);
|
||||
for (style, want) in [("frames", "00030"), ("feet35", "0001+14"), ("timecode", "0:00:01:00")] {
|
||||
s.execute("file.projectSettings", json!({"timeDisplay": style})).unwrap();
|
||||
assert_eq!(display_duration(&s, &comp, len), want, "{style}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_dimensions_split_and_rejoin() {
|
||||
let (mut s, l) = setup();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! File ▸ Import / Export ▸ Adobe Premiere Pro Project… (timeline interchange) through the command
|
||||
//! registry, without media decoding (pixels: `crates/host/tests/timeline_interop.rs`).
|
||||
//! registry, without media decoding (pixels: `crates/host/tests/it/timeline_interop.rs`).
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod key_time_post_snap_bounds;
|
||||
mod key_time_reply_contract;
|
||||
mod parenting_3d_position;
|
||||
Reference in New Issue
Block a user