Merge main into enhance/keyframe-text-clipboard

This commit is contained in:
tylerjenningsw
2026-10-25 14:09:38 -04:00
426 changed files with 24669 additions and 4513 deletions
+11 -9
View File
@@ -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.
+56 -4
View File
@@ -13,14 +13,23 @@ use super::{CommandSpec, always, b_p, bad, comp_id, has_comp, has_layers, has_pr
use crate::{EngineError, Event, Result, Session, cmd, query};
/// Run `f` (which may run several undoable commands) as one undo step named `label`; on error
/// the project is restored.
/// the project, editor state and undo history are restored.
///
/// Front-end events emitted by a failed `f` are discarded with the state. Pending-analysis
/// queues and the journal are not part of this rollback and keep their existing behaviour.
pub(crate) fn grouped<T>(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result<T>) -> Result<T> {
// The steps `f` records go to an empty stack (so the undo limit can't drop the snapshot
// taken before them), then become one step on the real one.
// taken before them), then become one step on the real one. Keep the complete pre-`f`
// history and editor state for a failure: rollback must not leave a partially retargeted
// editor or a `f`-recorded redo/branch behind.
let saved_history = s.history.clone();
let saved = std::mem::take(&mut s.history.undo);
let before = s.project.clone();
let before_state = s.state.clone();
let event_mark = s.events.len();
let r = f(s);
let changed = !s.history.undo.is_empty();
let interim_revision = s.ime_revision;
s.history.undo = saved;
s.history.merge_key = None;
match r {
@@ -36,9 +45,22 @@ pub(crate) fn grouped<T>(s: &mut Session, label: &str, f: impl FnOnce(&mut Sessi
Ok(v)
}
Err(e) => {
// Snapshot the failing editor before restoring the pre-`f` state: an intermediate
// text.edit / text change / comp switch must invalidate the pre-`f` editor token
// instead of reviving it.
let edited = super::text_edit::active_editor_snapshot(s);
s.project = before;
s.state = before_state;
s.history = saved_history;
s.history.merge_key = None;
// A failed intermediate comp switch must not reach the front end through an
// `OpenComp` event while its state has been rolled back.
s.events.truncate(event_mark);
s.sanitize_after_replacement(edited.as_ref());
// As in the batch rollback, the IME revision is a high-water mark: leave the highest
// intermediate stamp rather than rewinding to before `f`.
s.ime_revision = s.ime_revision.max(interim_revision);
if changed {
s.project = before;
s.sanitize_state();
s.bump();
}
Err(e)
@@ -704,6 +726,36 @@ pub fn specs() -> Vec<CommandSpec> {
mod tests {
use serde_json::json;
#[test]
fn grouped_rollback_restores_the_editor_state_and_history_after_a_transient_text_change() {
let mut s = crate::Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 1})).unwrap();
let lid = s.execute("layer.newText", json!({"text": "abcdef"})).unwrap()["layer"].as_u64().unwrap();
s.execute("text.edit", json!({"layer": lid, "caret": 3})).unwrap();
let original_comp = s.active_comp_id().unwrap();
let owner = s.state.text_edit.as_ref().unwrap().session;
let other =
s.execute("comp.new", json!({"name": "Other", "width": 160, "height": 90, "duration": 1, "open": false})).unwrap()["comp"].as_u64().unwrap();
let undo = s.history.undo.len();
s.drain_events();
let r = super::grouped(&mut s, "Rollback", |s| -> crate::Result<()> {
// An intermediate document edit and state switch, both undone by the error path.
s.execute("layer.setText", json!({"layer": lid, "text": "ab"}))?;
s.execute("comp.open", json!({"comp": other}))?;
Err(crate::EngineError::Other("stop".into()))
});
assert!(r.is_err());
assert_eq!(s.active_comp_id(), Some(original_comp), "the pre-group comp is restored");
assert_eq!(super::super::text_edit::layer_doc(&s, effectcraft_project::LayerId(lid)).unwrap().text, "abcdef", "the document is restored");
assert_eq!(s.history.undo.len(), undo, "the partial grouped steps are not left in history");
assert_ne!(s.state.text_edit.as_ref().unwrap().session, owner, "a transient replacement does not revive the old IME token");
assert!(
!s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenComp(_))),
"the failed intermediate comp switch is not published to the front end"
);
}
/// `File.execute()` opens the file as a well-formed `file://` URL (spaces, `#`, `%`).
#[test]
fn execute_file_opens_an_encoded_file_url() {
+15 -1
View File
@@ -56,6 +56,9 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
let label = str_p(p, "label").unwrap_or("Batch").to_string();
let atomic = p.get("atomic").and_then(Value::as_bool).unwrap_or(true);
let (project, history, state) = (s.project.clone(), s.history.clone(), s.state.clone());
// Rolled-back steps may have queued events (an `OpenComp`, a Toast) for the state that is
// about to be dropped: the UI must not act on them.
let event_mark = s.events.len();
let mut results: Vec<Value> = Vec::with_capacity(steps.len());
for (i, step) in steps.iter().enumerate() {
let run = |s: &mut Session, results: &[Value]| -> Result<Value> {
@@ -75,10 +78,21 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
Err(e) => {
let id = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).unwrap_or("?");
let note = if atomic {
// The snapshot is the intermediate state the failing step left: comparing it
// with the restored editor detects a restart or cross-comp switch inside the
// batch, so the pre-batch token is never revived. Do not restore
// `ime_revision`: a transient text edit inside the batch already advanced
// this process-monotonic high-water mark, and rewinding it would reuse a
// stamp (letting a wide-selection composition look current). An unrelated
// failed batch did not advance it, so its value stays exactly as it was.
let edited = crate::commands::text_edit::active_editor_snapshot(s);
let interim_revision = s.ime_revision;
s.project = project;
s.history = history;
s.state = state;
s.sanitize_state();
s.events.truncate(event_mark);
s.sanitize_after_replacement(edited.as_ref());
s.ime_revision = s.ime_revision.max(interim_revision);
s.bump();
"; the batch was rolled back (nothing changed)".to_string()
} else {
+23
View File
@@ -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,12 +206,30 @@ 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();
st.selected_keys.clear();
Ok(())
})?;
// A removed layer may have been the edited one: end that session before another command
// can reuse it. Undo restores a layer as a new editor, never by reviving the old IME session.
s.sanitize_state();
Ok(json!(ids.len()))
}
+110
View File
@@ -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")?;
@@ -633,6 +728,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),
+10 -1
View File
@@ -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
),
+123 -5
View File
@@ -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};
@@ -200,7 +200,14 @@ fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
let n = doc.char_len();
let id = insert_layer(proj, st, cid, l)?;
if edit {
st.text_edit = Some(super::text_edit::TextEdit { layer: id, anchor: 0, caret: n, created: true, ..Default::default() });
st.text_edit = Some(super::text_edit::TextEdit {
layer: id,
anchor: 0,
caret: n,
created: true,
session: super::text_edit::next_edit_session(),
..Default::default()
});
}
Ok(id)
})?;
@@ -307,6 +314,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 +521,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;
@@ -1375,6 +1481,9 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
}
s.bump();
}
// A changed Source Text is handled by `Session::edit`: its boundary clamps the selection and
// starts a new IME epoch only when the CTI document actually changed, so an identical text
// write stays a no-op and keeps the editor's owner.
s.edit("Edit Text", merge_p(p), |proj, _| {
let l = layer_mut(proj, cid, lid)?;
let lt = l.layer_time(t);
@@ -1628,6 +1737,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),
+27 -11
View File
@@ -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!(
+8 -3
View File
@@ -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(),
+399 -17
View File
@@ -6,15 +6,58 @@
//! Positions are character indices. The selection is `anchor..caret` (either order).
use effectcraft_keyframe::{CharStyle, TextDoc, Value as KV};
use effectcraft_project::{LayerId, LayerSource};
use effectcraft_project::{ItemId, LayerId, LayerSource};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
/// Session tokens for the on-canvas editor, unique across the process (so a recreated session
/// never reuses a live IME context). Exhaustion wraps; it cannot panic.
static NEXT_EDIT_SESSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
pub(super) fn next_edit_session() -> u64 {
NEXT_EDIT_SESSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
/// Process-global checked high-water allocator for [`crate::Session::ime_revision`] stamps.
///
/// egui's unavailable-IME receipt identity must be process-monotonic. Allocating the stamp from
/// one process-global counter means neither a second session nor a rollback can reuse a value.
/// The counter fails closed at `u64::MAX`: it returns `None` without wrapping, and callers keep
/// their existing stamp. An unavailable before/after pair then compares equal, so it earns no
/// receipt rather than claiming a mutation with a reused revision.
static NEXT_IME_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
pub(crate) fn next_ime_revision() -> Option<u64> {
allocate_ime_revision(&NEXT_IME_REVISION)
}
fn allocate_ime_revision(counter: &std::sync::atomic::AtomicU64) -> Option<u64> {
use std::sync::atomic::Ordering::Relaxed;
let mut current = counter.load(Relaxed);
loop {
let next = current.checked_add(1)?;
match counter.compare_exchange_weak(current, next, Relaxed, Relaxed) {
Ok(_) => return Some(next),
Err(observed) => current = observed,
}
}
}
#[test]
fn ime_revision_allocation_stops_at_exhaustion_without_reusing_a_stamp() {
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
let counter = AtomicU64::new(u64::MAX - 1);
assert_eq!(allocate_ime_revision(&counter), Some(u64::MAX));
assert_eq!(allocate_ime_revision(&counter), None);
assert_eq!(allocate_ime_revision(&counter), None);
assert_eq!(counter.load(Relaxed), u64::MAX);
}
use super::{CommandSpec, b_p, bad, comp_id, layer_mut, resolve_layer, str_p};
use crate::{EditorState, EngineError, Result, Session, cmd};
/// An on-canvas text editing session.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TextEdit {
pub layer: LayerId,
pub anchor: usize,
@@ -28,6 +71,23 @@ pub struct TextEdit {
/// Line position kept by repeated up / down moves (layout space).
#[serde(default)]
pub goal_x: Option<f64>,
/// This editing session's identity.
///
/// It changes when the editor is started and when its layer or document is replaced, by
/// undo/redo, a history jump, a rollback, or a time change showing another document. A
/// restore that keeps the same composition, layer and byte/style-equal document (an opacity
/// undo while editing) keeps it, so an active IME composition survives an unrelated change.
/// It is deliberately not serialized: a saved token belongs to a previous process and
/// cannot identify a new target. The IME owner uses it as its replacement epoch, so an old
/// composition is never inherited by a recreated layer or a replaced document.
#[serde(skip, default = "next_edit_session")]
pub session: u64,
}
impl Default for TextEdit {
fn default() -> Self {
Self { layer: LayerId(0), anchor: 0, caret: 0, pending: None, created: false, goal_x: None, session: next_edit_session() }
}
}
impl TextEdit {
@@ -49,6 +109,139 @@ pub fn layer_doc(s: &Session, lid: LayerId) -> Option<TextDoc> {
}
}
/// The largest committed-text window an IME observation copies.
///
/// This mirrors `egui::output::MAX_IME_SURROUNDING_TEXT_BYTES`: a selection wider than this is
/// reported as unavailable rather than silently truncated.
pub const MAX_IME_TEXT_BYTES: usize = 4000;
/// The editor's committed-text revision for an IME receipt whose surrounding text is
/// unavailable (a selection wider than [`MAX_IME_TEXT_BYTES`]).
///
/// Unlike [`Session::revision`], unrelated project edits (an opacity undo) do not advance it, so
/// they cannot make an active composition look stale.
pub fn ime_stamp_revision(s: &Session) -> u64 {
s.ime_revision
}
/// Bounded committed text around an IME selection, with UTF-8 byte offsets.
///
/// The Type tool keeps the input method's pre-edit in egui's temporary data, never in the
/// layer's [`TextDoc`], so [`Self::text`] is committed text only.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImeSurrounding {
/// At most [`MAX_IME_TEXT_BYTES`] bytes of the edited layer's text.
pub text: String,
/// The caret (primary selection end) as a UTF-8 byte offset in [`Self::text`].
pub cursor: usize,
/// The other selection end; larger than `cursor` for a reversed selection.
pub anchor: usize,
}
impl ImeSurrounding {
/// A bounded window of `text` around the selection `cursor`/`anchor`.
///
/// Returns `None` when an offset is past the text or inside a character, or when the
/// selection itself is wider than [`MAX_IME_TEXT_BYTES`]: the caller then reports
/// surrounding text as unavailable instead of sending a truncated selection.
pub fn from_text(text: &str, cursor: usize, anchor: usize) -> Option<Self> {
if cursor > text.len() || anchor > text.len() || !text.is_char_boundary(cursor) || !text.is_char_boundary(anchor) {
return None;
}
let (start, end) = (cursor.min(anchor), cursor.max(anchor));
let selection = end - start;
if selection > MAX_IME_TEXT_BYTES {
return None;
}
// Spend the remaining budget on both sides of the selection, giving unused room on one
// side to the other. Only this bounded window is copied.
let budget = MAX_IME_TEXT_BYTES - selection;
let mut before = (budget / 2).min(start);
let mut after = (budget - before).min(text.len() - end);
before = (budget - after).min(start);
after = (budget - before).min(text.len() - end);
let mut window_start = start - before;
let mut window_end = end + after;
// Byte budgets can land inside a multi-byte character: shrink the window so it never
// splits one and the selection stays inside it.
while window_start < start && !text.is_char_boundary(window_start) {
window_start += 1;
}
while end < window_end && !text.is_char_boundary(window_end) {
window_end -= 1;
}
Some(Self {
text: text.get(window_start..window_end)?.to_owned(),
cursor: cursor.checked_sub(window_start)?,
anchor: anchor.checked_sub(window_start)?,
})
}
}
/// The edited layer's committed text around its selection, or `None` when there is no edit
/// session, the layer is gone, or the selection is wider than [`MAX_IME_TEXT_BYTES`].
///
/// The caret and the anchor are converted to UTF-8 byte offsets separately, so a reversed
/// selection stays reversed. A valid empty document is `Some` with empty text, never `None`.
pub fn ime_surrounding(s: &Session) -> Option<ImeSurrounding> {
let e = s.state.text_edit.as_ref()?;
let comp = s.active_comp()?;
let layer = comp.layer(e.layer)?;
let prop = layer.props.prop("text/sourceText")?;
// A static Source Text (the normal case) is borrowed in place; only an animated one has to
// be evaluated into a temporary. Either way only the bounded window is copied.
if !prop.is_animated() {
return match &prop.value {
KV::Text(doc) => {
let (caret, anchor) = doc_bytes(doc, e.caret, e.anchor);
ImeSurrounding::from_text(&doc.text, caret, anchor)
}
KV::Str(text) => {
let (caret, anchor) = char_bytes(text, e.caret, e.anchor);
ImeSurrounding::from_text(text, caret, anchor)
}
_ => None,
};
}
match prop.value_at(layer.layer_time(s.time())) {
KV::Text(doc) => {
let (caret, anchor) = doc_bytes(&doc, e.caret, e.anchor);
ImeSurrounding::from_text(&doc.text, caret, anchor)
}
KV::Str(text) => {
let (caret, anchor) = char_bytes(&text, e.caret, e.anchor);
ImeSurrounding::from_text(&text, caret, anchor)
}
_ => None,
}
}
/// Byte offsets of two character positions in `text` (clamped to its end), scanning once.
///
/// The second position may be before the first; the returned pair keeps the caller's order.
fn char_bytes(text: &str, first: usize, second: usize) -> (usize, usize) {
let (lo, hi) = if first <= second { (first, second) } else { (second, first) };
let mut lo_byte = if lo == 0 { 0 } else { text.len() };
let mut hi_byte = if hi == 0 { 0 } else { text.len() };
if hi != 0 {
for (i, (b, _)) in text.char_indices().enumerate() {
if i == lo {
lo_byte = b;
}
if i == hi {
hi_byte = b;
break;
}
}
}
if first <= second { (lo_byte, hi_byte) } else { (hi_byte, lo_byte) }
}
/// Byte offsets of the editor's caret and anchor in a document, in that order.
fn doc_bytes(doc: &TextDoc, caret: usize, anchor: usize) -> (usize, usize) {
char_bytes(&doc.text, caret, anchor)
}
/// The text layer a text command targets: `layer`, the edited layer, or the selected text layer.
fn target(s: &Session, p: &Value, cmd: &str) -> Result<LayerId> {
let cid = comp_id(s, p)?;
@@ -78,7 +271,7 @@ fn session_on(s: &mut Session, lid: LayerId) -> TextEdit {
Some(e) if e.layer == lid => e.clone(),
_ => {
let n = layer_doc(s, lid).map_or(0, |d| d.char_len());
let e = TextEdit { layer: lid, anchor: n, caret: n, ..Default::default() };
let e = TextEdit { layer: lid, anchor: n, caret: n, session: next_edit_session(), ..Default::default() };
s.state.selected_layers = vec![lid];
s.state.text_edit = Some(e.clone());
e
@@ -101,7 +294,8 @@ pub(crate) fn edit_doc(
) -> Result<()> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let t = s.time();
s.edit(label, merge, |proj, st| {
let revision = s.revision;
s.edit_text_doc(label, merge, |proj, st| {
let l = layer_mut(proj, cid, lid)?;
let lt = l.layer_time(t);
let pr = l.props.prop_mut("text/sourceText").ok_or_else(|| bad("text", "not a text layer"))?;
@@ -110,16 +304,28 @@ pub(crate) fn edit_doc(
KV::Str(t) => TextDoc::plain(&t),
_ => TextDoc::default(),
};
let before = first_line_name(&doc.text);
let original = doc.clone();
f(&mut doc, st)?;
let after = first_line_name(&doc.text);
pr.set_value_at(lt, KV::Text(Box::new(doc)));
// Text layers are named after their text until renamed.
if before != after && !after.is_empty() && (l.name == before || l.name.starts_with("Text") || l.name.is_empty()) {
l.name = after;
// A no-op (an IME deletion with nothing around the selection, a set to the value it
// already has) must not turn a static Str into Text or add a keyframe at the CTI: that
// would change the project and record an empty undo step.
if doc != original {
let before = first_line_name(&original.text);
let after = first_line_name(&doc.text);
pr.set_value_at(lt, KV::Text(Box::new(doc)));
// Text layers are named after their text until renamed.
if before != after && !after.is_empty() && (l.name == before || l.name.starts_with("Text") || l.name.is_empty()) {
l.name = after;
}
}
Ok(())
})
})?;
// The editor's unavailable-text receipt identity follows only its own document: an edit to
// another text layer while this editor is open must not cancel the composition.
if s.revision != revision && s.state.text_edit.as_ref().is_some_and(|e| e.layer == lid) {
s.note_text_edit_change();
}
Ok(())
}
fn range_p(p: &Value) -> Option<std::ops::Range<usize>> {
@@ -153,13 +359,13 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
};
let created = b_p(p, "created").unwrap_or(false);
s.state.selected_layers = vec![lid];
s.state.text_edit = Some(TextEdit { layer: lid, anchor, caret, created, ..Default::default() });
s.state.text_edit = Some(TextEdit { layer: lid, anchor, caret, created, session: next_edit_session(), ..Default::default() });
s.bump();
Ok(state_json(s))
}
/// `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)?;
@@ -314,6 +520,78 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
Ok(state_json(s))
}
/// How many whole characters `before_bytes` / `after_bytes` cover around `r`.
///
/// This is the input method's own byte count, as winit reports it: a count that ends inside a
/// UTF-8 character leaves that whole character (stock egui's `surrounding_bytes_to_chars`), so a
/// malformed or platform-specific offset never splits or corrupts the document.
fn surrounding_bytes_as_chars(text: &str, n: usize, r: std::ops::Range<usize>, before_bytes: usize, after_bytes: usize) -> (usize, usize) {
let r = r.start.min(n)..r.end.min(n);
let (start_byte, end_byte) = char_bytes(text, r.start, r.end);
let from = start_byte.saturating_sub(before_bytes);
let before = text.get(..start_byte).map_or(0, |head| head.char_indices().rev().take_while(|(i, _)| *i >= from).count());
let after = text.get(end_byte..).map_or(0, |tail| tail.char_indices().take_while(|(i, c)| i.saturating_add(c.len_utf8()) <= after_bytes).count());
(before.min(r.start), after.min(n.saturating_sub(r.end)))
}
/// Delete the text an input method marks for replacement around the selection
/// (`DeleteSurrounding`, `DeleteSurroundingBytes`).
///
/// Unlike [`delete`] with an explicit range, the **selection stays**: the characters before its
/// start and after its end are removed in two separate ranges, and the following `Commit`
/// replaces the selected text. `bytes` counts UTF-8 bytes (`before`/`after` are characters
/// otherwise); a count that ends inside a character leaves that character.
///
/// The counts are computed from the one document `edit_doc` already evaluated, so an animated
/// Source Text and a large static document are not copied or evaluated again just to decide what
/// the input method asked to replace.
fn delete_surrounding(s: &mut Session, p: &Value) -> Result<Value> {
let lid = target(s, p, "text.deleteSurrounding")?;
// Only an open edit session has a selection to keep: without one there is nothing an input
// method could be replacing.
let e = match &s.state.text_edit {
Some(e) if e.layer == lid => e.clone(),
_ => return Err(bad("text.deleteSurrounding", "not editing this text layer")),
};
let count = |k: &str| p.get(k).and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX));
let (before_count, after_count) = (count("before"), count("after"));
let bytes = b_p(p, "bytes").unwrap_or(false);
let merge = str_p(p, "merge").map(str::to_string);
edit_doc(s, "Edit Text", merge.as_deref(), lid, |doc, st| {
let n = doc.char_len();
let r = e.range().start.min(n)..e.range().end.min(n);
let (before, after) = if bytes {
surrounding_bytes_as_chars(&doc.text, n, r.clone(), before_count, after_count)
} else {
(before_count.min(r.start), after_count.min(n.saturating_sub(r.end)))
};
let start = r.start.saturating_sub(before);
let end = r.end.saturating_add(after).min(n);
if start == r.start && end == r.end {
// Deletion-only batches with an empty count, or a document with nothing around the
// selection: no empty undo step.
return Ok(());
}
// The selection keeps its text: remove after it first, then before it, so the two ranges
// stay valid and the caret/selection shift by the characters before it.
if end > r.end {
doc.replace_range(r.end..end, "", None);
}
if r.start > start {
doc.replace_range(start..r.start, "", None);
}
if let Some(ed) = st.text_edit.as_mut().filter(|ed| ed.layer == lid) {
let shift = r.start.saturating_sub(start);
ed.anchor = ed.anchor.saturating_sub(shift);
ed.caret = ed.caret.saturating_sub(shift);
ed.pending = None;
ed.goal_x = None;
}
Ok(())
})?;
Ok(state_json(s))
}
/// Selected text (plain) of the session, and its formatted fragment.
fn selection_doc(s: &Session) -> Option<TextDoc> {
let e = s.state.text_edit.as_ref()?;
@@ -448,16 +726,111 @@ fn paste_formatting(s: &mut Session, p: &Value) -> Result<Value> {
Ok(state_json(s))
}
/// Keep the session valid after undo / external edits: clamp the selection, drop it when the
/// Give the current editor a fresh replacement epoch after its document/layer was replaced.
pub(crate) fn invalidate_session(s: &mut Session) {
let replaced = s.state.text_edit.is_some();
if let Some(e) = s.state.text_edit.as_mut() {
e.session = next_edit_session();
}
if replaced {
s.note_text_edit_change();
}
}
/// Clamp the editor selection and give it a fresh IME epoch after its document was replaced.
///
/// This is the one replacement path for document changes outside `edit_doc`:
/// [`crate::Session::edit`] calls [`after_external_change`] after every edit, [`crate::Session::set_time`]
/// and [`crate::Session::sync_related_times`] call it after moving the CTI, and
/// [`crate::Session::sanitize_after_replacement`] calls it with the editor document snapshotted
/// before a history restore or rollback.
/// Clamping first means a shortened document leaves the caret in range, so the next Backspace
/// deletes instead of being swallowed by an out-of-range selection.
pub(crate) fn document_replaced(s: &mut Session) {
sanitize(s);
invalidate_session(s);
}
/// The identity and document of the active on-canvas editor at one moment.
///
/// The composition and the editor session are part of the identity: a restoration must not keep
/// a composition alive when the snapshot came from another comp, or from an intermediate editor
/// that was restarted during the operation being rolled back.
#[derive(Clone, Debug, PartialEq)]
pub struct EditorSnapshot {
/// The composition the editor was attached to.
pub comp: ItemId,
/// The edited layer.
pub layer: LayerId,
/// The editor session token, unique across the process.
pub session: u64,
/// The Source Text the editor was showing.
pub doc: TextDoc,
}
/// The identity and document the active on-canvas editor is showing.
///
/// [`crate::Session::edit`] and the time setters snapshot this before a change and compare it
/// afterwards: only a change that actually replaces this document starts a new epoch, while a
/// write to another property, layer or composition, a move to a time showing the same text, or a
/// write that leaves the text as it was, keeps the editor's owner. With no editor open this is a
/// single `Option` check, with no document copy.
pub fn active_editor_snapshot(s: &Session) -> Option<EditorSnapshot> {
let e = s.state.text_edit.as_ref()?;
Some(EditorSnapshot { comp: s.active_comp_id()?, layer: e.layer, session: e.session, doc: layer_doc(s, e.layer)? })
}
/// Finish an edit outside the editor: if it replaced the document the editor owned, clamp the
/// selection and start a new IME epoch.
///
/// An edit that itself moves or closes the editor owns the resulting session, so an identity
/// mismatch is not a replacement here.
pub(crate) fn after_external_change(s: &mut Session, before: Option<&EditorSnapshot>) {
let Some(before) = before else { return };
let Some(e) = s.state.text_edit.as_ref() else { return };
if s.state.active_comp != Some(before.comp) || e.layer != before.layer || e.session != before.session {
return;
}
if layer_doc(s, before.layer).as_ref() != Some(&before.doc) {
document_replaced(s);
}
}
/// Finish a project-state replacement (undo, redo, a history jump, a rollback): keep the editor
/// only when the restored state still has the same composition, layer and editor session and
/// shows byte/style-equal text.
///
/// The restored session token is the one saved in the replaced state, so an intermediate
/// restart or cross-comp switch must be detected against the snapshot instead of being assumed
/// to be the editor's own change.
pub(crate) fn after_replacement(s: &mut Session, before: Option<&EditorSnapshot>) {
let Some(before) = before else {
// No editor was open when the snapshot was taken: a restored editor must not inherit a
// composition from the state the replacement replaced.
if s.state.text_edit.is_some() {
document_replaced(s);
}
return;
};
let same = s.state.text_edit.as_ref().is_some_and(|e| s.state.active_comp == Some(before.comp) && e.layer == before.layer && e.session == before.session);
if !same || layer_doc(s, before.layer).as_ref() != Some(&before.doc) {
document_replaced(s);
}
}
/// Keep the session valid after routine state cleanup: clamp the selection, drop it when the
/// layer is gone or not in the active comp.
///
/// This is also called after project-item cleanup that leaves the edited document alone, so it
/// must not advance [`TextEdit::session`]: that would make the viewer treat unrelated cleanup as
/// a document replacement and interrupt the active IME composition. Replacement paths call
/// [`document_replaced`] instead.
pub(crate) fn sanitize(s: &mut Session) {
let Some(e) = s.state.text_edit.clone() else { return };
match layer_doc(s, e.layer) {
Some(d) => {
let n = d.char_len();
if (e.anchor > n || e.caret > n)
&& let Some(ed) = s.state.text_edit.as_mut()
{
if let Some(ed) = s.state.text_edit.as_mut() {
ed.anchor = ed.anchor.min(n);
ed.caret = ed.caret.min(n);
}
@@ -594,6 +967,15 @@ pub fn specs() -> Vec<CommandSpec> {
editing_or_text_layer,
delete
),
cmd!(
"text.deleteSurrounding",
"Delete Text Around Selection",
[],
None,
"{layer?, before?, after?, bytes?, merge?} (input-method replacement: the characters before and after the selection are deleted, the selection stays; bytes counts UTF-8 bytes)",
editing_or_text_layer,
delete_surrounding
),
cmd!(
"edit.pasteTextMatchFormatting",
"Paste Text and Match Formatting",
+14
View File
@@ -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> {
+16 -7
View File
@@ -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
+19 -1
View File
@@ -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());
}
+12 -5
View File
@@ -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);
}
+2 -1
View File
@@ -341,13 +341,14 @@ impl Session {
let k = new_line.iter().position(|&i| i == target).unwrap_or(0);
let undo: Vec<(String, Arc<Project>)> = (0..k).map(|i| (a.label[new_line[i + 1]].clone(), a.state[new_line[i]].clone())).collect();
let redo: Vec<(String, Arc<Project>)> = new_line[k + 1..].iter().rev().map(|&i| (a.label[i].clone(), a.state[i].clone())).collect();
let edited = crate::commands::text_edit::active_editor_snapshot(self);
let cur = std::mem::replace(&mut self.project, a.state[target].clone());
self.keep_view_settings(&cur);
self.history.undo = undo;
self.history.redo = redo;
self.history.branches = branches;
self.history.merge_key = None;
self.sanitize_state();
self.sanitize_after_replacement(edited.as_ref());
self.bump();
true
}
+139 -7
View File
@@ -400,6 +400,15 @@ pub struct Session {
pub project: Arc<Project>,
pub history: History,
pub revision: u64,
/// Process-unique monotonic revision of the on-canvas editor's committed text and session.
/// Unlike [`Session::revision`], unrelated project edits do not advance it; the IME receipt
/// uses it when surrounding text is unavailable. Read it with
/// [`commands::text_edit::ime_stamp_revision`].
///
/// Stamps come from a process-global checked allocator and are never rewound: a failed
/// atomic batch keeps the highest intermediate stamp. On allocator exhaustion no stamp is
/// issued, so an unavailable receipt cannot be accepted with a reused revision.
pub ime_revision: u64,
pub saved_revision: u64,
/// The project as last opened or saved: the project is unmodified while it is this very
/// snapshot, so undoing back to the saved state clears the modified mark.
@@ -527,6 +536,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`]).
@@ -566,6 +577,8 @@ impl Default for Session {
project,
history: History::default(),
revision: 0,
// A fresh session gets its own process-monotonic stamp even before its first edit.
ime_revision: commands::text_edit::next_ime_revision().unwrap_or(u64::MAX),
saved_revision: 0,
path: None,
state: EditorState { snapping: true, view_layout: 1, ..Default::default() },
@@ -624,6 +637,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(),
}
@@ -689,7 +703,25 @@ impl Session {
}
/// Apply an undoable edit. `merge`: consecutive edits with the same key fold into one step.
///
/// Every edit is an external boundary for the on-canvas text editor: if it replaced the
/// document the editor is showing, the selection is clamped and the editor gets a fresh IME
/// epoch. The editor's own typing goes through `edit_text_doc`, which owns that
/// document and must not treat its own keystrokes as a replacement.
pub fn edit<T>(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result<T>) -> Result<T> {
let edited = commands::text_edit::active_editor_snapshot(self);
let out = self.edit_inner(label, merge, f)?;
commands::text_edit::after_external_change(self, edited.as_ref());
Ok(out)
}
/// [`Session::edit`] for the on-canvas editor's own text changes. Its document is the one
/// the editor owns, so a change here is typing, not a replacement behind the IME's back.
pub(crate) fn edit_text_doc<T>(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result<T>) -> Result<T> {
self.edit_inner(label, merge, f)
}
fn edit_inner<T>(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result<T>) -> Result<T> {
let before = self.project.clone();
let mut p = (*self.project).clone();
let mut st = self.state.clone();
@@ -736,18 +768,61 @@ impl Session {
Ok(r)
}
/// Note a change to the on-canvas editor's committed text or session.
///
/// Frontends compare this with [`commands::text_edit::ime_stamp_revision`]; an unrelated
/// project change must not advance it.
pub(crate) fn note_text_edit_change(&mut self) {
// Checked process-wide allocation: a stamp is never wrapped or reused. On exhaustion
// keep the previous value; an unavailable before/after pair then compares equal and
// earns no receipt instead of accepting a stale revision.
if let Some(revision) = commands::text_edit::next_ime_revision() {
self.ime_revision = self.ime_revision.max(revision);
}
}
pub fn bump(&mut self) {
self.revision += 1;
// Back at the saved state (undo, redo, a history jump or a rolled-back batch).
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`]).
@@ -778,26 +853,48 @@ impl Session {
pub fn undo(&mut self) -> bool {
let Some((label, p)) = self.history.undo.pop() else { return false };
let edited = commands::text_edit::active_editor_snapshot(self);
let cur = std::mem::replace(&mut self.project, p);
self.keep_view_settings(&cur);
self.history.redo.push((label, cur));
self.history.merge_key = None;
self.sanitize_state();
self.sanitize_after_replacement(edited.as_ref());
self.bump();
true
}
pub fn redo(&mut self) -> bool {
let Some((label, p)) = self.history.redo.pop() else { return false };
let edited = commands::text_edit::active_editor_snapshot(self);
let cur = std::mem::replace(&mut self.project, p);
self.keep_view_settings(&cur);
self.history.undo.push((label, cur));
self.history.merge_key = None;
self.sanitize_state();
self.sanitize_after_replacement(edited.as_ref());
self.bump();
true
}
/// Keep selections valid after the project was replaced by another state (undo, redo, a
/// history jump, a rollback): the same layer id can name a different document, so the
/// on-canvas editor gets a fresh IME epoch only when its own document changed.
///
/// Normally `before` is [`commands::text_edit::active_editor_snapshot`] taken from the
/// state this call is about to replace. Atomic batch rollback is the exception: it has
/// already restored the pre-batch state and passes the snapshot of the failing intermediate
/// state, so a transient restart or component switch inside the batch still rejects reviving
/// the old composition. A restored state that still has the same composition, layer and
/// editor session and shows byte/style-equivalent text (an opacity or other property undo)
/// keeps the editor's owner and selection; a different document, comp, layer or session clamps the
/// selection and starts a new epoch. A closed editor stays closed, and a fallback comp's
/// matching layer id never inherits the old comp's session. `None` means no editor was open
/// when the snapshot was taken; if one is open after the replacement it is treated as an
/// unverified document replacement, so it never inherits an old composition.
pub fn sanitize_after_replacement(&mut self, before: Option<&commands::text_edit::EditorSnapshot>) {
self.sanitize_state();
commands::text_edit::after_replacement(self, before);
}
/// Drop selections that no longer exist. Each comp keeps its own selection (#1001); a layer id
/// counts only inside the comp that owns it, so the active comp never sees another comp's layers.
pub fn sanitize_state(&mut self) {
@@ -814,6 +911,8 @@ impl Session {
});
if self.state.active_comp.is_some_and(|c| p.comp(c).is_none()) {
self.clear_live_selection();
// A matching layer id in the fallback comp is not the document being edited.
self.state.text_edit = None;
self.state.active_comp = self.state.open_comps.first().copied();
if let Some(id) = self.state.active_comp {
self.recall_selection(id);
@@ -873,13 +972,22 @@ impl Session {
self.state.active_comp.and_then(|c| self.state.times.get(&c).copied()).unwrap_or(Tick::ZERO)
}
/// Move the active comp's current time.
///
/// Time is an external boundary for the on-canvas text editor too: an animated Source Text
/// can show a different document at another time. The document is snapshotted only while an
/// editor is open, so playback with no text editor does no text copying.
pub fn set_time(&mut self, t: Tick) {
if let Some(c) = self.state.active_comp {
let t = self.project.comp(c).map_or(t, |comp| comp_frame_at(comp, t));
let edited = commands::text_edit::active_editor_snapshot(self);
self.state.times.insert(c, t);
if self.prefs.general.sync_time_related_items {
self.sync_related_times(c, t);
// Direct call: this `set_time` owns the editor snapshot for both time changes,
// so a synced move does not take a second snapshot or start a second epoch.
self.sync_related_times_inner(c, t);
}
commands::text_edit::after_external_change(self, edited.as_ref());
}
}
@@ -887,6 +995,14 @@ impl Session {
/// `comp` moves the current time of the comps nested in it (through their precomp layers'
/// timing) and of the comps it is nested in, so every related viewer shows the same moment.
pub fn sync_related_times(&mut self, comp: ItemId, t: Tick) {
// Moving a related comp can move the active one, so the editor's document is checked
// here too; the snapshot is skipped entirely when no text editor is open.
let edited = commands::text_edit::active_editor_snapshot(self);
self.sync_related_times_inner(comp, t);
commands::text_edit::after_external_change(self, edited.as_ref());
}
fn sync_related_times_inner(&mut self, comp: ItemId, t: Tick) {
let mut seen = std::collections::BTreeSet::from([comp]);
let mut todo = vec![(comp, t)];
let mut found = vec![];
@@ -930,6 +1046,9 @@ impl Session {
self.state.active_comp = Some(id);
self.recall_selection(id);
self.state.current_track = None;
// A text-edit session belongs to the comp that owned it. Switching comps ends it; even
// a same-numbered layer in the new comp must not inherit the old IME composition.
self.state.text_edit = None;
}
/// Leave no comp active, keeping the one that was active in [`EditorState::comp_selections`].
@@ -938,6 +1057,7 @@ impl Session {
self.state.active_comp = None;
self.clear_live_selection();
self.state.current_track = None;
self.state.text_edit = None;
}
fn remember_selection(&mut self) {
@@ -1042,12 +1162,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).
@@ -1175,6 +1301,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;
@@ -1248,6 +1376,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;
@@ -1298,6 +1428,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;
+31 -20
View File
@@ -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.
+31 -15
View File
@@ -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");
}
}
+1
View File
@@ -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"}
+12 -8
View File
@@ -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,
+10 -1
View File
@@ -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);
+9 -2
View File
@@ -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();
+8 -2
View File
@@ -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();
+17
View File
@@ -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();
+336
View File
@@ -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"));
}
}
+21
View File
@@ -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]
+4 -1
View File
@@ -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]
+35
View File
@@ -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));
}
+6 -6
View File
@@ -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]
+54
View File
@@ -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();
+158
View File
@@ -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);
}
+45 -2
View File
@@ -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);
}
+6
View File
@@ -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();
+863 -37
View File
@@ -6,7 +6,7 @@ use effectcraft_keyframe::{BaselineOption, Justify, Kerning, TextDoc};
use serde_json::json;
use crate::Session;
use crate::commands::text_edit::layer_doc;
use crate::commands::text_edit::{MAX_IME_TEXT_BYTES, ime_stamp_revision, ime_surrounding, layer_doc};
fn session(text: &str) -> (Session, u64) {
let mut s = Session::default();
@@ -24,6 +24,98 @@ fn sel(s: &Session) -> (usize, usize) {
(e.anchor, e.caret)
}
/// Start editing the text layer with everything selected (no caret given).
fn begin_edit(s: &mut Session, t: u64) {
s.execute("text.edit", json!({"layer": t})).unwrap();
}
/// Start editing the text layer with the caret at `caret`.
fn begin_edit_at(s: &mut Session, t: u64, caret: usize) {
s.execute("text.edit", json!({"layer": t, "caret": caret})).unwrap();
}
/// A session with one text layer already open in the editor at `caret`.
fn editing(text: &str, caret: usize) -> (Session, u64) {
let (mut s, t) = session(text);
begin_edit_at(&mut s, t, caret);
(s, t)
}
/// A session with one text layer already open in the editor with everything selected.
fn editing_all(text: &str) -> (Session, u64) {
let (mut s, t) = session(text);
begin_edit(&mut s, t);
(s, t)
}
/// The IME owner token of the open editor.
fn ime_owner(s: &Session) -> u64 {
s.state.text_edit.as_ref().unwrap().session
}
fn undo_steps(s: &Session) -> usize {
s.history.undo.len()
}
/// The editor kept its document (same IME owner).
fn assert_ime_owner(s: &Session, expected: u64, why: &str) {
assert_eq!(ime_owner(s), expected, "{why}");
}
/// The editor's document was replaced (new IME owner/epoch).
fn assert_ime_epoch_changed(s: &Session, previous: u64, why: &str) {
assert_ne!(ime_owner(s), previous, "{why}");
}
fn assert_text(s: &Session, t: u64, expected: &str) {
assert_eq!(doc(s, t).text, expected);
}
/// A new, empty composition called "Other" (the standard second comp).
fn new_other_comp(s: &mut Session) -> u64 {
s.execute("comp.new", json!({"name": "Other", "width": 320, "height": 180, "duration": 2})).unwrap()["comp"].as_u64().unwrap()
}
/// Stopwatch the layer's Source Text (one key at the CTI).
fn animate_source(s: &mut Session, t: u64) {
s.execute("prop.toggleAnimation", json!({"layer": t, "path": "text/sourceText"})).unwrap();
}
/// Add a Source Text key at `time`.
fn add_source_key(s: &mut Session, t: u64, time: f64, value: &str) {
s.execute("prop.addKey", json!({"layer": t, "path": "text/sourceText", "time": time, "value": value})).unwrap();
}
/// Write Source Text as a static value (a replacement when it differs from the shown text).
fn set_source(s: &mut Session, t: u64, value: &str) {
s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "value": value})).unwrap();
}
/// Write the Source Text key at `time`.
fn set_source_key(s: &mut Session, t: u64, time: f64, value: &str) {
s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "time": time, "value": value})).unwrap();
}
/// Set the Source Text key at the CTI (`keys.set`).
fn set_source_cti_key(s: &mut Session, t: u64, time: f64, value: &str) {
s.execute("keys.set", json!({"layer": t, "path": "text/sourceText", "time": time, "value": value})).unwrap();
}
/// Replace the whole Source Text of the layer (`layer.setText`).
fn replace_text(s: &mut Session, t: u64, text: &str) {
s.execute("layer.setText", json!({"layer": t, "text": text})).unwrap();
}
/// Select the character range `start..end` in the open editor.
fn select(s: &mut Session, start: usize, end: usize) {
s.execute("text.setSelection", json!({"start": start, "end": end})).unwrap();
}
/// Set both selection ends, keeping their direction (`anchor` is the other end).
fn select_anchor(s: &mut Session, anchor: usize, caret: usize) {
s.execute("text.setSelection", json!({"anchor": anchor, "caret": caret})).unwrap();
}
#[test]
fn set_text_with_a_range_creates_runs_and_undoes() {
let (mut s, t) = session("Hello World");
@@ -200,7 +292,596 @@ fn edit_state_round_trips_through_serde() {
s.execute("text.edit", json!({"layer": t, "select": [1, 2]})).unwrap();
let j = serde_json::to_value(&s.state).unwrap();
let back: crate::EditorState = serde_json::from_value(j).unwrap();
assert_eq!(back.text_edit, s.state.text_edit);
let before = s.state.text_edit.clone().expect("editing");
let after = back.text_edit.clone().expect("editing");
assert_eq!((after.layer, after.anchor, after.caret), (before.layer, before.anchor, before.caret));
assert_ne!(after.session, before.session, "a restored editor gets a process-unique replacement epoch");
assert_ne!(after.session, 0, "the restored epoch is always a real session token");
}
#[test]
fn ime_session_epoch_tracks_replaced_text_and_layer_restoration() {
let (mut s, t) = editing_all("abc");
let first = ime_owner(&s);
// Replacing Source Text is a new document even though the layer id stays.
replace_text(&mut s, t, "replaced");
let replaced = ime_owner(&s);
assert_ne!(replaced, first, "layer.setText starts a new IME epoch");
// Undo restores another document: it must not revive the token from before either edit.
s.execute("edit.undo", json!({})).unwrap();
let undone = ime_owner(&s);
assert_ne!(undone, replaced);
assert_ne!(undone, first);
// Clear + immediate undo in the same command sequence: the layer is gone long enough to
// end the session, and the restored layer cannot inherit a composition.
begin_edit(&mut s, t);
s.execute("edit.clear", json!({"layers": [t]})).unwrap();
assert!(s.state.text_edit.is_none(), "clearing the edited layer ends the session");
s.execute("edit.undo", json!({})).unwrap();
assert!(s.state.text_edit.is_none(), "undo does not recreate the old composition session");
begin_edit(&mut s, t);
assert_ne!(ime_owner(&s), first);
}
#[test]
fn no_op_ime_deletion_keeps_a_static_plain_text_value_and_records_no_step() {
use effectcraft_keyframe::Value as KV;
let (mut s, t) = session("abc");
// Legacy/imported text can still be stored as a plain Str rather than a TextDoc.
let cid = s.active_comp_id().unwrap();
let pr = std::sync::Arc::make_mut(&mut s.project)
.comp_mut(cid)
.unwrap()
.layer_mut(effectcraft_project::LayerId(t))
.unwrap()
.props
.prop_mut("text/sourceText")
.unwrap();
pr.value = KV::Str("abc".into());
begin_edit_at(&mut s, t, 0);
let steps = undo_steps(&s);
// A non-zero before count at the document start deletes nothing.
s.execute("text.deleteSurrounding", json!({"before": 1, "after": 0})).unwrap();
let pr = s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap().props.prop("text/sourceText").unwrap();
assert!(matches!(&pr.value, KV::Str(_)), "a no-op keeps the static Str variant");
assert!(pr.keys.is_empty());
assert_eq!(undo_steps(&s), steps, "a no-op adds no undo step");
}
#[test]
fn no_op_ime_deletion_does_not_add_a_keyframe_to_animated_source_text() {
let (mut s, t) = editing("abc", 0);
animate_source(&mut s, t);
let lid = effectcraft_project::LayerId(t);
let keys = |s: &Session| s.active_comp().unwrap().layer(lid).unwrap().props.prop("text/sourceText").unwrap().keys.len();
assert_eq!(keys(&s), 1, "one key from the stopwatch");
s.execute("time.set", json!({"time": 1.0})).unwrap();
let steps = undo_steps(&s);
// A non-zero before count at the document start deletes nothing, at a time with no key.
s.execute("text.deleteSurrounding", json!({"before": 1, "after": 0})).unwrap();
assert_eq!(keys(&s), 1, "a no-op does not key Source Text at the CTI");
assert_eq!(undo_steps(&s), steps, "a no-op adds no undo step");
}
#[test]
fn property_writes_replace_the_edited_document_only_when_the_text_changes() {
let (mut s, t) = session("abc");
let other = s.execute("layer.newText", json!({"text": "other"})).unwrap()["layer"].as_u64().unwrap();
begin_edit_at(&mut s, t, 1);
let owner = ime_owner(&s);
let steps = undo_steps(&s);
// Writing the text the document already has is a no-op: same document, same owner, no step.
set_source(&mut s, t, "abc");
assert_ime_owner(&s, owner, "a no-op write keeps the IME owner");
assert_eq!(undo_steps(&s), steps, "a no-op write records no undo step");
replace_text(&mut s, t, "abc");
assert_ime_owner(&s, owner, "a no-op layer.setText keeps the IME owner");
assert_eq!(undo_steps(&s), steps, "a no-op layer.setText records no undo step");
// Another property of the edited layer leaves its document alone.
s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 50})).unwrap();
assert_ime_owner(&s, owner, "an unrelated property keeps the IME owner");
// Source Text of another layer is not the editor's document either.
set_source(&mut s, other, "changed");
assert_ime_owner(&s, owner, "another layer's text keeps the IME owner");
// Replacing the edited layer's Source Text is a document replacement: a new IME epoch.
set_source(&mut s, t, "replaced");
assert_text(&s, t, "replaced");
assert_ime_epoch_changed(&s, owner, "replacing the document changes the IME owner");
}
#[test]
fn undo_and_redo_of_an_unrelated_property_keep_the_ime_owner_and_selection() {
let (mut s, t) = editing("abcdef", 3);
let owner = ime_owner(&s);
s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 50})).unwrap();
assert_ime_owner(&s, owner, "the opacity write keeps the owner");
assert_eq!(sel(&s), (3, 3), "the opacity write keeps the selection");
// The opacity step does not change Source Text: restoring it keeps the same editor.
s.execute("edit.undo", json!({})).unwrap();
assert_ime_owner(&s, owner, "undoing only opacity keeps the owner");
assert_eq!(sel(&s), (3, 3), "undoing only opacity keeps the selection");
s.execute("edit.redo", json!({})).unwrap();
assert_ime_owner(&s, owner, "redoing only opacity keeps the owner");
assert_eq!(sel(&s), (3, 3), "redoing only opacity keeps the selection");
}
#[test]
fn a_style_only_text_change_and_its_undo_replace_the_editor_epoch() {
let (mut s, t) = editing("abcdef", 3);
let owner = ime_owner(&s);
// The same characters with another run style are still a different document.
s.execute("layer.setText", json!({"layer": t, "range": [1, 4], "size": 12})).unwrap();
assert_eq!(doc(&s, t).text, "abcdef");
assert_eq!(doc(&s, t).style_at(2).size, 12.0);
assert_ime_epoch_changed(&s, owner, "a style-only change starts a new IME epoch");
assert_eq!(sel(&s), (3, 3), "a same-length style change keeps the caret in range");
let styled = ime_owner(&s);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(doc(&s, t).style_at(2).size, 60.0);
assert_ime_epoch_changed(&s, styled, "undoing a style change starts a new IME epoch");
}
#[test]
fn history_jumps_keep_the_owner_over_unrelated_steps_and_replace_it_when_the_text_changes() {
let (mut s, t) = editing("abcdef", 3);
let owner = ime_owner(&s);
s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 50})).unwrap();
s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 25})).unwrap();
let states = s.execute("edit.history.list", json!({})).unwrap()["states"].as_array().unwrap().clone();
let cur = states.iter().find(|n| n["current"] == true).unwrap()["index"].as_u64().unwrap();
let parent = states.iter().find(|n| n["index"].as_u64() == Some(cur)).unwrap()["parent"].as_u64().unwrap();
// Both states show the same Source Text: jumping between them keeps the editor.
s.execute("edit.history.goto", json!({"index": parent})).unwrap();
assert_ime_owner(&s, owner, "jumping to another opacity state keeps the owner");
assert_eq!(sel(&s), (3, 3), "and the selection");
s.execute("edit.history.goto", json!({"index": cur})).unwrap();
assert_ime_owner(&s, owner, "jumping back keeps the owner");
assert_eq!(sel(&s), (3, 3), "and the selection");
// A history state whose Source Text differs is a document replacement.
set_source(&mut s, t, "abc");
let changed = ime_owner(&s);
assert_ne!(changed, owner);
s.execute("edit.history.goto", json!({"index": parent})).unwrap();
assert_text(&s, t, "abcdef");
assert_ime_epoch_changed(&s, changed, "jumping to a different document starts a new epoch");
}
#[test]
fn atomic_batch_rollback_keeps_the_owner_for_unrelated_steps_and_replaces_it_for_text_changes() {
let (mut s, t) = editing("abcdef", 3);
let owner = ime_owner(&s);
let r = s.execute(
"engine.batch",
json!({"atomic": true, "steps": [
{"command": "prop.set", "params": {"layer": t, "path": "transform/opacity", "value": 50}},
{"command": "no.suchCommand", "params": {}}
]}),
);
assert!(r.is_err());
assert_ime_owner(&s, owner, "rolling back an unrelated step keeps the owner");
assert_eq!(sel(&s), (3, 3), "and the selection");
// A partially applied text replacement was still shown before the rollback restores the
// original document, so the editor must not revive the composition it had before the batch.
let r = s.execute(
"engine.batch",
json!({"atomic": true, "steps": [
{"command": "layer.setText", "params": {"layer": t, "text": "ab"}},
{"command": "no.suchCommand", "params": {}}
]}),
);
assert!(r.is_err());
assert_text(&s, t, "abcdef");
assert_ime_epoch_changed(&s, owner, "a rolled-back text change does not revive the old owner");
assert_eq!(sel(&s), (3, 3), "the restored document keeps the caret in range");
}
#[test]
fn atomic_batch_rollback_after_a_transient_editor_restart_bumps_the_epoch() {
let (mut s, t) = editing("abcdef", 3);
let owner = ime_owner(&s);
// `text.edit` restarts the session even on the layer that is already edited. The rollback
// restores the pre-batch TextEdit (with the old token), so the intermediate restart must be
// detected from the snapshot: neither the old composition nor its token may be revived.
let r = s.execute(
"engine.batch",
json!({"atomic": true, "steps": [
{"command": "text.edit", "params": {"layer": t, "caret": 1}},
{"command": "no.suchCommand", "params": {}}
]}),
);
assert!(r.is_err());
assert_eq!(sel(&s), (3, 3), "the restored selection is the pre-batch one");
assert_ime_epoch_changed(&s, owner, "a canceled intermediate editor must not revive the old token");
}
#[test]
fn atomic_batch_rollback_after_a_cross_comp_editor_switch_bumps_the_epoch() {
let (mut s, t) = session("abcdef");
let original = s.active_comp_id().unwrap();
// Give the other comp a layer with the same id and the same Source Text, so only the comp
// and editor-session identity distinguishes the two editors.
let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap().clone();
let other = new_other_comp(&mut s);
let other_id = effectcraft_project::ItemId(other);
std::sync::Arc::make_mut(&mut s.project).comp_mut(other_id).unwrap().layers.push(layer);
s.execute("comp.open", json!({"comp": original.0})).unwrap();
begin_edit_at(&mut s, t, 3);
let owner = ime_owner(&s);
let r = s.execute(
"engine.batch",
json!({"atomic": true, "steps": [
{"command": "comp.open", "params": {"comp": other}},
{"command": "text.edit", "params": {"layer": t, "caret": 1}},
{"command": "no.suchCommand", "params": {}}
]}),
);
assert!(r.is_err());
assert_eq!(s.active_comp_id(), Some(original), "the batch restored the original comp");
assert_eq!(sel(&s), (3, 3), "and the pre-batch selection");
assert_ime_epoch_changed(&s, owner, "an identical layer in another comp must not revive the old token");
}
#[test]
fn an_atomic_batch_drops_the_events_its_failed_steps_queued() {
let (mut s, _) = session("abcdef");
let original = s.active_comp_id().unwrap();
// `comp.new` opens the comp it creates; go back so the batch's switch is a real one.
let other = new_other_comp(&mut s);
s.execute("comp.open", json!({"comp": original.0})).unwrap();
s.drain_events();
let r = s.execute(
"engine.batch",
json!({"atomic": true, "steps": [
{"command": "comp.open", "params": {"comp": other}},
{"command": "no.suchCommand", "params": {}}
]}),
);
assert!(r.is_err());
assert_eq!(s.active_comp_id(), Some(original), "the batch restored the original comp");
assert!(
!s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenComp(id) if id.0 == other)),
"the rolled-back step's OpenComp is not queued for the UI"
);
}
#[test]
fn atomic_batch_rollback_after_closing_the_editor_does_not_revive_it() {
let (mut s, t) = session("abcdef");
let original = s.active_comp_id().unwrap();
let other = new_other_comp(&mut s);
s.execute("comp.open", json!({"comp": original.0})).unwrap();
begin_edit_at(&mut s, t, 3);
let owner = ime_owner(&s);
// The failing state has no editor at all: the restoration brings the pre-batch one back and
// must treat it as unverified rather than revive its composition.
let r = s.execute(
"engine.batch",
json!({"atomic": true, "steps": [
{"command": "comp.open", "params": {"comp": other}},
{"command": "no.suchCommand", "params": {}}
]}),
);
assert!(r.is_err());
assert!(s.state.text_edit.is_some(), "the batch restored the editor state");
assert_eq!(sel(&s), (3, 3));
assert_ime_epoch_changed(&s, owner, "a canceled editor close must not revive the old token");
}
#[test]
fn restoring_without_an_editor_snapshot_replaces_an_open_editor() {
let (mut s, _) = editing("abcdef", 3);
let owner = ime_owner(&s);
// `None` means no editor was open when the state was replaced; an editor that is open
// afterwards cannot prove it kept its document.
s.sanitize_after_replacement(None);
assert_ime_epoch_changed(&s, owner, "an unverified restored editor starts a new epoch");
assert_eq!(sel(&s), (3, 3));
}
#[test]
fn undo_to_a_shorter_document_clamps_the_caret() {
let (mut s, t) = editing("abc", 3);
replace_text(&mut s, t, "abcdef");
select_anchor(&mut s, 6, 6);
let owner = ime_owner(&s);
assert_eq!(sel(&s), (6, 6));
s.execute("edit.undo", json!({})).unwrap();
assert_text(&s, t, "abc");
assert_eq!(sel(&s), (3, 3), "undoing to a shorter document clamps the caret");
assert_ime_epoch_changed(&s, owner, "and starts a new epoch");
}
#[test]
fn the_ime_stamp_revision_follows_the_editor_not_unrelated_project_edits() {
let (mut s, t) = editing("abcdef", 6);
let revision = ime_stamp_revision(&s);
s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 50})).unwrap();
assert_eq!(ime_stamp_revision(&s), revision, "an unrelated property write does not advance it");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(ime_stamp_revision(&s), revision, "undoing it does not either");
let r = s.execute(
"engine.batch",
json!({"atomic": true, "steps": [
{"command": "prop.set", "params": {"layer": t, "path": "transform/opacity", "value": 25}},
{"command": "no.suchCommand", "params": {}}
]}),
);
assert!(r.is_err());
assert_eq!(ime_stamp_revision(&s), revision, "a rolled-back unrelated batch restores it");
s.execute("text.insert", json!({"text": "x"})).unwrap();
assert!(ime_stamp_revision(&s) > revision, "typing advances it");
let typed = ime_stamp_revision(&s);
s.execute("layer.setText", json!({"layer": t, "text": "y"})).unwrap();
assert!(ime_stamp_revision(&s) > typed, "a replaced document advances it too");
}
#[test]
fn a_failed_atomic_batch_with_a_transient_edit_keeps_its_high_water_ime_revision() {
let (mut s, t) = editing("abcdef", 6);
let revision = ime_stamp_revision(&s);
let r = s.execute(
"engine.batch",
json!({"atomic": true, "steps": [
{"command": "text.insert", "params": {"text": "x"}},
{"command": "text.delete", "params": {}},
{"command": "no.suchCommand", "params": {}}
]}),
);
assert!(r.is_err());
assert_text(&s, t, "abcdef");
// The intermediate document edit is real even though the final document equals the
// pre-batch one: its stamp must remain the high-water mark.
assert!(ime_stamp_revision(&s) > revision, "a transient edit must not rewind the IME revision");
let after_transient = ime_stamp_revision(&s);
// An unrelated failed batch really did not edit the document: it must preserve the revision
// exactly, or every wide-selection composition would be canceled by unrelated work.
let r = s.execute(
"engine.batch",
json!({"atomic": true, "steps": [
{"command": "prop.set", "params": {"layer": t, "path": "transform/opacity", "value": 25}},
{"command": "no.suchCommand", "params": {}}
]}),
);
assert!(r.is_err());
assert_eq!(ime_stamp_revision(&s), after_transient, "an unrelated rolled-back batch preserves the revision");
}
#[test]
fn a_replacing_property_write_clamps_the_selection_so_the_next_backspace_deletes() {
let (mut s, t) = editing("abcdef", 6);
let owner = ime_owner(&s);
set_source(&mut s, t, "a");
assert_text(&s, t, "a");
assert_eq!(sel(&s), (1, 1), "the shortened document leaves the caret in range");
assert_ne!(ime_owner(&s), owner);
// Backspace at the clamped caret removes the character instead of no-opping on 5..6.
s.execute("text.delete", json!({})).unwrap();
assert_text(&s, t, "");
assert_eq!(sel(&s), (0, 0));
}
#[test]
fn keyframe_writes_on_source_text_replace_the_edited_document_only_when_the_cti_value_changes() {
let (mut s, t) = editing("abc", 1);
// Keying the text the CTI already shows leaves the document equal.
animate_source(&mut s, t);
let owner = ime_owner(&s);
set_source_cti_key(&mut s, t, 0.0, "abc");
assert_ime_owner(&s, owner, "a no-op key write keeps the IME owner");
// `keys.set` changes the key at the CTI: a document replacement.
set_source_cti_key(&mut s, t, 0.0, "replaced");
assert_text(&s, t, "replaced");
assert_ime_epoch_changed(&s, owner, "a changed keyframe changes the IME owner");
}
#[test]
fn deleting_the_cti_keyframe_first_replaces_the_document_then_the_last_key_is_a_noop() {
let (mut s, t) = editing("abc", 1);
animate_source(&mut s, t);
add_source_key(&mut s, t, 1.0, "later");
let owner = ime_owner(&s);
let lid = effectcraft_project::LayerId(t);
let pr = s.active_comp().unwrap().layer(lid).unwrap().props.prop("text/sourceText").unwrap();
let uid = pr.uid;
let times: Vec<_> = pr.keys.iter().map(|k| k.time).collect();
assert_eq!(times.len(), 2);
// Deleting the key at the CTI (0s) while the 1s key remains shows the later text: replacing
// the document starts a new epoch.
s.state.selected_keys = vec![crate::KeyRef { layer: lid, prop: uid, time: times[0] }];
assert_eq!(s.execute("keys.delete", json!({})).unwrap(), json!(1));
assert_text(&s, t, "later");
let replaced = ime_owner(&s);
assert_ne!(replaced, owner, "deleting the CTI key changed the document");
// Removing the last key turns its value into the static CTI value. That value is what the
// CTI already showed, so the document does not change and the new owner stays.
s.state.selected_keys = vec![crate::KeyRef { layer: lid, prop: uid, time: times[1] }];
assert_eq!(s.execute("keys.delete", json!({})).unwrap(), json!(1));
assert_text(&s, t, "later");
assert_ime_owner(&s, replaced, "removing the last key is a document no-op");
}
#[test]
fn moving_the_cti_to_a_shorter_animated_key_clamps_and_starts_a_new_epoch() {
let (mut s, t) = session("abcdef");
animate_source(&mut s, t);
add_source_key(&mut s, t, 1.0, "a");
begin_edit_at(&mut s, t, 6);
let owner = ime_owner(&s);
// The same time still shows the same document: no new epoch.
s.execute("time.set", json!({"time": 0.0})).unwrap();
assert_text(&s, t, "abcdef");
assert_ime_owner(&s, owner, "an unchanged CTI document keeps the IME owner");
// The key at 1s holds shorter text: the document changes, the selection is clamped and the
// editor gets a new epoch.
s.execute("time.set", json!({"time": 1.0})).unwrap();
assert_text(&s, t, "a");
assert_eq!(sel(&s), (1, 1), "the shortened document leaves the caret in range");
let replaced = ime_owner(&s);
assert_ne!(replaced, owner, "a changed CTI document starts a new IME epoch");
// Backspace at the clamped caret removes the character instead of no-opping on 5..6.
s.execute("text.delete", json!({})).unwrap();
assert_text(&s, t, "");
assert_eq!(sel(&s), (0, 0));
assert_ime_owner(&s, replaced, "typing after the replacement keeps its owner");
// Staying at the same time after the edit is a no-op for the document too.
s.execute("time.set", json!({"time": 1.0})).unwrap();
assert_text(&s, t, "");
assert_ime_owner(&s, replaced, "a no-op time move keeps the IME owner");
}
#[test]
fn sync_related_times_replaces_the_edited_document_when_it_moves_the_active_comp() {
let (mut s, t) = session("abcdef");
animate_source(&mut s, t);
add_source_key(&mut s, t, 1.0, "a");
let outer = s.active_comp_id().unwrap();
let r = s.execute("layer.precompose", json!({"layers": [t], "name": "Inner", "open": true})).unwrap();
let inner = effectcraft_project::ItemId(r["comp"].as_u64().unwrap());
assert_eq!(s.active_comp_id(), Some(inner));
begin_edit_at(&mut s, t, 6);
let owner = ime_owner(&s);
s.sync_related_times(outer, effectcraft_time::Tick::from_seconds_f64(1.0));
assert_text(&s, t, "a");
assert_eq!(sel(&s), (1, 1), "the related time move clamps the selection");
assert_ime_epoch_changed(&s, owner, "the related time move changed the document");
}
#[test]
fn applying_a_preset_that_writes_source_text_replaces_the_edited_document() {
let (mut s, t) = editing("abc", 1);
let owner = ime_owner(&s);
let preset = json!({
"effectcraftAnimationPreset": 1,
"properties": [{"path": "text/sourceText", "value": {"t": "Text", "v": {"text": "replaced"}}, "keys": []}],
});
s.execute("anim.applyPreset", json!({"preset": preset, "layers": [t]})).unwrap();
assert_text(&s, t, "replaced");
assert_ime_epoch_changed(&s, owner, "a preset that replaces source text changes the IME owner");
}
#[test]
fn a_failed_property_write_keeps_the_edited_document_and_its_owner() {
let (mut s, t) = editing("abc", 1);
let owner = ime_owner(&s);
let steps = undo_steps(&s);
// null cannot coerce to a Text value: the edit fails before anything is committed.
assert!(s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "value": null})).is_err());
assert_text(&s, t, "abc");
assert_ime_owner(&s, owner, "a failed write keeps the IME owner");
assert_eq!(undo_steps(&s), steps, "a failed write records no undo step");
}
#[test]
fn property_writes_off_the_cti_keep_the_owner_until_they_change_the_cti_document() {
let (mut s, t) = editing("abc", 1);
animate_source(&mut s, t);
add_source_key(&mut s, t, 1.0, "later");
let owner = ime_owner(&s);
// Writing a key at 1s leaves the CTI (0s) value as it was.
set_source_key(&mut s, t, 1.0, "changed");
assert_text(&s, t, "abc");
assert_ime_owner(&s, owner, "an off-CTI write keeps the IME owner");
// Writing the CTI value itself replaces the document.
set_source(&mut s, t, "cti");
assert_text(&s, t, "cti");
assert_ime_epoch_changed(&s, owner, "a CTI write changes the IME owner");
}
#[test]
fn unrelated_project_cleanup_keeps_the_ime_owner_but_replacement_changes_it() {
let (mut s, t) = editing("abc", 1);
let owner = ime_owner(&s);
// file.removeUnusedFootage calls sanitize_state while the edited document is untouched.
s.execute("file.importSolid", json!({"name": "Unused"})).unwrap();
let removed = s.execute("file.removeUnusedFootage", json!({})).unwrap();
assert_eq!(removed["removed"], json!(1), "the unused solid was actually removed");
assert_ime_owner(&s, owner, "unrelated cleanup keeps the IME owner");
// Replacing the Source Text is a document replacement: a new owner.
replace_text(&mut s, t, "replaced");
assert_ime_epoch_changed(&s, owner, "replacing the document changes the owner");
}
#[test]
fn ime_session_does_not_cross_a_composition_switch() {
let (mut s, _) = editing_all("abc");
let other = new_other_comp(&mut s);
s.execute("comp.open", json!({"comp": other})).unwrap();
assert!(s.state.text_edit.is_none(), "the old comp's editor is not reused in another comp");
}
#[test]
fn removing_the_active_comp_does_not_retarget_its_text_editor() {
for replacement in [false, true] {
let (mut s, t) = session("original");
let original = s.active_comp_id().unwrap();
let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap().clone();
let other = new_other_comp(&mut s);
let other_id = effectcraft_project::ItemId(other);
std::sync::Arc::make_mut(&mut s.project).comp_mut(other_id).unwrap().layers.push(layer);
s.execute("comp.open", json!({"comp": other})).unwrap();
s.execute("comp.open", json!({"comp": original.0})).unwrap();
begin_edit_at(&mut s, t, 1);
assert!(s.state.text_edit.is_some());
// Reproduce cleanup after a project replacement, bypassing Session::edit's earlier
// document guard so this specifically exercises sanitize_state's fallback-comp branch.
let edited = crate::commands::text_edit::active_editor_snapshot(&s);
std::sync::Arc::make_mut(&mut s.project).items.remove(&original);
if replacement {
s.sanitize_after_replacement(edited.as_ref());
} else {
s.sanitize_state();
}
assert_eq!(s.active_comp_id(), Some(other_id));
assert!(s.state.text_edit.is_none(), "the same-numbered layer belongs to a different comp");
assert!(s.execute("text.insert", json!({"text": "wrong document"})).is_err());
assert_eq!(doc(&s, t).text, "original", "the fallback comp was not edited");
}
}
#[test]
@@ -260,26 +941,32 @@ fn fonts_lists_bundled_and_installed_families() {
assert_eq!(crate::font_styles("No Such Family").len(), 5);
}
/// A font file of just `name` (the family, Regular) and an `fvar` table with one axis pinned at
/// 400 (so the helpers that look for an installed font with a real axis range never pick it).
fn pinned_variable_font(family: &str) -> Vec<u8> {
let u16s = |v: &[u16]| -> Vec<u8> { v.iter().flat_map(|x| x.to_be_bytes()).collect() };
let strings: Vec<Vec<u8>> = [family, "Regular"].iter().map(|s| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect()).collect();
let mut name = u16s(&[0, 2, 30]);
/// A big-endian `u16` array, as sfnt tables store them.
fn u16s(v: &[u16]) -> Vec<u8> {
v.iter().flat_map(|x| x.to_be_bytes()).collect()
}
/// A `name` table with one Windows/Unicode English record per `(id, text)`.
fn name_table(strings: &[(u16, String)]) -> Vec<u8> {
let enc: Vec<(u16, Vec<u8>)> = strings.iter().map(|(id, s)| (*id, s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect())).collect();
let mut name = u16s(&[0, enc.len() as u16, 6 + 12 * enc.len() as u16]);
let mut off = 0;
for (id, st) in strings.iter().enumerate() {
name.extend(u16s(&[3, 1, 0x409, id as u16 + 1, st.len() as u16, off]));
for (id, st) in &enc {
name.extend(u16s(&[3, 1, 0x409, *id, st.len() as u16, off]));
off += st.len() as u16;
}
strings.iter().for_each(|st| name.extend(st));
let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, 0, 8]);
fvar.extend(b"wght".iter().copied().chain(u16s(&[0x190, 0, 0x190, 0, 0x190, 0, 0, 256])));
let mut out = u16s(&[1, 0, 2, 0, 0, 0]);
enc.iter().for_each(|(_, st)| name.extend(st));
name
}
/// A single-face font with `tables` in order (zero checksums, offsets after the directory).
fn sfnt(tables: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
let mut out = u16s(&[1, 0, tables.len() as u16, 0, 0, 0]);
let mut body: Vec<u8> = vec![];
for (tag, data) in [(b"fvar", fvar), (b"name", name)] {
out.extend(tag);
for (tag, data) in tables {
out.extend(*tag);
out.extend([0u8; 4]);
out.extend(((12 + 32 + body.len()) as u32).to_be_bytes());
out.extend(((12 + 16 * tables.len() + body.len()) as u32).to_be_bytes());
out.extend((data.len() as u32).to_be_bytes());
body.extend(data);
}
@@ -287,6 +974,15 @@ fn pinned_variable_font(family: &str) -> Vec<u8> {
out
}
/// A font file of just `name` (the family, Regular) and an `fvar` table with one axis pinned at
/// 400 (so the helpers that look for an installed font with a real axis range never pick it).
fn pinned_variable_font(family: &str) -> Vec<u8> {
let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, 0, 8]);
fvar.extend(b"wght".iter().copied().chain(u16s(&[0x190, 0, 0x190, 0, 0x190, 0, 0, 256])));
let name = name_table(&[(1, family.to_owned()), (2, "Regular".to_owned())]);
sfnt(&[(b"fvar", fvar), (b"name", name)])
}
/// The font menu's Variable Fonts filter (After Effects 26.3): `text.fonts {variable: true}`
/// lists only families with variation axes, every listing says which families are variable, and
/// the font menu's rows carry the flag the filter uses.
@@ -321,7 +1017,6 @@ fn fonts_filter_variable_families() {
/// `Light`) and the named `instances` (style, `wght`), each with a PostScript name
/// `<family without spaces>-<style without spaces>`.
fn instanced_variable_font(family: &str, instances: &[(&str, f32)]) -> Vec<u8> {
let u16s = |v: &[u16]| -> Vec<u8> { v.iter().flat_map(|x| x.to_be_bytes()).collect() };
let squash = |s: &str| s.chars().filter(char::is_ascii_alphanumeric).collect::<String>();
let mut strings: Vec<(u16, String)> = vec![(1, family.into()), (2, "Light".into())];
let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, instances.len() as u16, 10]);
@@ -334,25 +1029,7 @@ fn instanced_variable_font(family: &str, instances: &[(&str, f32)]) -> Vec<u8> {
fvar.extend(((w * 65536.0) as i32).to_be_bytes());
fvar.extend(u16s(&[id + 1]));
}
let enc: Vec<(u16, Vec<u8>)> = strings.iter().map(|(id, s)| (*id, s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect())).collect();
let mut name = u16s(&[0, enc.len() as u16, 6 + 12 * enc.len() as u16]);
let mut off = 0;
for (id, st) in &enc {
name.extend(u16s(&[3, 1, 0x409, *id, st.len() as u16, off]));
off += st.len() as u16;
}
enc.iter().for_each(|(_, st)| name.extend(st));
let mut out = u16s(&[1, 0, 2, 0, 0, 0]);
let mut body: Vec<u8> = vec![];
for (tag, data) in [(b"fvar", fvar), (b"name", name)] {
out.extend(tag);
out.extend([0u8; 4]);
out.extend(((12 + 32 + body.len()) as u32).to_be_bytes());
out.extend((data.len() as u32).to_be_bytes());
body.extend(data);
}
out.extend(body);
out
sfnt(&[(b"fvar", fvar), (b"name", name_table(&strings))])
}
/// #1563: a variable font's named instances are styles of its family in `text.fonts`, and a
@@ -392,3 +1069,152 @@ 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));
}
/// Incoming IME replacement (stock `DeleteSurrounding` / the fork's `DeleteSurroundingBytes`):
/// the characters around the selection go, the selected text stays for the following commit, and
/// a byte count that ends inside a UTF-8 character leaves that character.
#[test]
fn ime_replacement_keeps_the_selection_and_never_splits_a_character() {
let (mut s, t) = editing_all("abcdef");
select(&mut s, 1, 4);
// One character before "bcd" and everything after it: the selection survives for the commit.
s.execute("text.deleteSurrounding", json!({"before": 1, "after": 99, "merge": "typing-0"})).unwrap();
assert_text(&s, t, "bcd");
assert_eq!(sel(&s), (0, 3), "the selection is kept for the commit that follows");
s.execute("text.insert", json!({"text": "X", "merge": "typing-0"})).unwrap();
assert_text(&s, t, "X");
assert_eq!(sel(&s), (1, 1));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(doc(&s, t).text, "abcdef", "the surrounding delete and its commit are one undo step");
// An empty count is a no-op (deletion-only batches, empty commits), never an undo step.
s.execute("edit.redo", json!({})).unwrap();
select(&mut s, 0, 1);
s.execute("text.deleteSurrounding", json!({"before": 0, "after": 0})).unwrap();
assert_text(&s, t, "X");
// Byte counts end inside a character: it stays whole (never a split UTF-8 sequence).
let (mut s, t) = editing_all("A😀B");
select(&mut s, 3, 3);
s.execute("text.deleteSurrounding", json!({"before": 2, "after": 0, "bytes": true})).unwrap();
assert_eq!(doc(&s, t).text, "A😀", "B is whole, the emoji only partly covered, so both are not one range");
select(&mut s, 2, 2);
s.execute("text.deleteSurrounding", json!({"before": 4, "after": 0, "bytes": true})).unwrap();
assert_eq!(doc(&s, t).text, "A", "four bytes cover the whole emoji");
// A combining mark is one character of two bytes: one byte of it removes nothing.
let (mut s, t) = editing_all("e\u{301}");
select(&mut s, 2, 2);
s.execute("text.deleteSurrounding", json!({"before": 1, "after": 0, "bytes": true})).unwrap();
assert_eq!(doc(&s, t).text, "e\u{301}", "a partial combining mark stays");
s.execute("text.deleteSurrounding", json!({"before": 2, "after": 0, "bytes": true})).unwrap();
assert_eq!(doc(&s, t).text, "e", "the whole combining mark is one character");
}
/// The outgoing IME query: logical committed text (the pre-edit never enters the model), UTF-8
/// byte offsets in either selection direction, a bounded window, and an explicit unavailable
/// answer for a selection that cannot be represented.
#[test]
fn ime_surrounding_maps_characters_to_bytes_and_preserves_direction() {
let (mut s, _) = editing_all("café😀");
select_anchor(&mut s, 2, 5);
let w = ime_surrounding(&s).expect("an available document");
assert_eq!(w.text, "café😀");
assert_eq!((w.cursor, w.anchor), (9, 2), "the caret is six characters in, after the emoji; the anchor stays at byte 2");
assert_eq!(&w.text[w.cursor..], "");
assert_eq!(&w.text[..w.anchor], "ca");
// Reversed: the caret stays the primary end and the anchor the other one.
select_anchor(&mut s, 5, 2);
let reversed = ime_surrounding(&s).expect("an available document");
assert_eq!((reversed.cursor, reversed.anchor), (2, 9), "cursor > anchor is preserved after the conversion");
assert_eq!(&reversed.text[reversed.cursor..reversed.anchor], "fé😀");
// An available empty document is not the unavailable state.
let (s, _) = editing_all("");
let empty = ime_surrounding(&s).expect("empty text is available");
assert_eq!((empty.text.as_str(), empty.cursor, empty.anchor), ("", 0, 0));
// No edit session: unavailable, not an empty document.
let (s, _) = session("text");
assert!(ime_surrounding(&s).is_none());
}
#[test]
fn ime_surrounding_is_bounded_and_refuses_a_too_wide_selection() {
// A long line: the query copies only a window around the caret, never the document.
let text = "a".repeat(20_000);
let (mut s, _) = editing(&text, 10_000);
let w = ime_surrounding(&s).expect("a collapsed caret fits");
assert!(w.text.len() <= MAX_IME_TEXT_BYTES, "the payload is bounded");
assert!(w.text.len() > 1000, "the caret keeps a useful amount of context");
assert!(w.text.is_char_boundary(w.cursor) && w.text.is_char_boundary(w.anchor));
assert_eq!(w.cursor, w.anchor);
// A selection wider than the protocol limit is unavailable, not a truncated one.
select(&mut s, 0, 20_000);
assert!(ime_surrounding(&s).is_none(), "a too-wide selection is unavailable");
// Multi-byte text at the window boundary is never split, and offsets stay on boundaries.
let text = "é".repeat(5_000);
let (s, _) = editing(&text, 2_500);
let w = ime_surrounding(&s).expect("a collapsed caret fits");
assert!(w.text.len() <= MAX_IME_TEXT_BYTES);
assert!(w.text.is_char_boundary(w.cursor));
assert_eq!(&w.text[w.cursor..w.cursor + "é".len()], "é");
}
/// A reversed selection survives a byte-count deletion: both endpoints shift by the removed
/// characters before the selection, its direction stays reversed, and partial characters on
/// either side stay whole.
#[test]
fn ime_byte_deletion_keeps_a_reversed_selection_direction() {
let (mut s, t) = editing_all("😀ab😀");
select_anchor(&mut s, 3, 1);
let (anchor, caret) = sel(&s);
assert_eq!((anchor, caret), (3, 1), "reversed before the replacement");
// Three bytes cover only part of either four-byte emoji: neither is split or removed.
s.execute("text.deleteSurrounding", json!({"before": 3, "after": 3, "bytes": true})).unwrap();
assert_text(&s, t, "😀ab😀");
// Four bytes cover each whole emoji: the selection text stays, shifted, still reversed.
s.execute("text.deleteSurrounding", json!({"before": 4, "after": 4, "bytes": true})).unwrap();
assert_text(&s, t, "ab");
assert_eq!(sel(&s), (2, 0), "the anchor stays after the caret");
let w = ime_surrounding(&s).expect("available");
assert_eq!((w.text.as_str(), w.cursor, w.anchor), ("ab", 0, 2), "the outgoing offsets keep the direction");
}
#[test]
fn deselect_all_ends_the_open_edit() {
let (mut s, t) = editing("Hello", 5);
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");
}
+45
View File
@@ -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;