mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-15 04:44:18 +00:00
* Disk cache: record the source files each frame reads Part of #3184 (step 1): renders now report which footage files they read, each with its identity (size and modification time) as read. Nothing uses the list yet: images, cache keys and cache behaviour are unchanged. - render: `reads` module with `FileStamp`, `ReadFile`, `ReadFiles` (sorted, deduplicated, bounded; marked incomplete when it can't vouch for every file) and the thread-safe `ReadLog`. `Renderer::reads` collects a frame's files; `FootageSource::recording` lets a source note what it reads. `file_stamp` uses the same identity. - Layer cache entries keep the files their buffer was made of and report them on every hit; derived entries (styles, blend space, flattened) keep their base's. Buffers from the disk cache or the browser store don't know their files yet and mark the list incomplete. - Media pool: every file read notes its identity, taken from the file it opens. Decoded frames, opened movies, GIFs, models and EXR channels keep it with the cache entry, so a frame served from memory reports its file without touching the disk. - Viewer frame worker collects each frame's list and carries it to where the disk entry is written (step 2 stores it). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Automation: confined footage passes on the files it reads Part of #3184: ConfinedFootage forwards FootageSource::recording to the source it wraps, so renders of an automation session list their files instead of coming back incomplete. The read tests also render through the viewer's CancellableFootage wrapper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Disk cache: frame entries keep their source files, comp key from memory Part of #3184 (step 2). Fixes #3175: opening a comp no longer stamps every footage file of the project before its first frame can show. - The comp's content key (`disk_cache::comp_content_key`) is taken from the project in memory alone: no footage or proxy file is stamped, no `VirtualFileId` is asked. `comp_content_key_with` is gone; `frame_key` hashes a new entry version, so entries of the earlier format are never looked up again (LRU evicts them). - Frame entries (formats 2 and 3) store the files the frame read (`EntryFiles`: size and modification time as read; size and content hash through `VirtualFileId` for files from memory; missing files), then the pixels. Formats 0 and 1 read as misses and are deleted. - Check on hit: `get_frame`/`get_frame_f16` look only at the listed files (one metadata call each) on the frame worker. A changed, back or unreadable file, or a damaged entry, is a miss: the entry is deleted and the frame rendered and stored again. - A frame whose file list is incomplete is shown but not stored. The RAM preview keeps each frame's files, so `persist_missing` writes only frames with a complete list. - Remote frame worker: `FrameServer` records each frame's reads (`take_files`); the web worker stores them with the entry and the page checks them through the file table before using it. The browser's media epoch existed only for the old key and is removed. Export does not use frame entries. - The cache indicator still only asks whether an entry exists. Tests: entry format round trip and damage, the check per file, and in the viewer: a changed file re-renders exactly its frames (and a reopen reads the rest from disk); a frame checks only its own files in a project with a large unused sequence and the key looks at no file; missing, unreadable files and damaged entries never serve a stale frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Disk cache: layer entries keep their source files, no footage salt Part of #3184 (step 3 of 4). Layer buffers on disk (and in the browser's layer store) now carry the files they were made of, like frame entries since step 2, and are checked against exactly those files when used. The project-wide footage salt is gone: nothing stamps every footage file any more. - Layer entry format 4: the buffer's EntryFiles, then the pixels. A hit checks only the listed files (metadata, or the VirtualFileId hook for files from memory; Missing must still be missing); a change, a damaged entry or the old format 0 is a miss and the entry is deleted, so the re-rendered buffer replaces it. A hit reports its file list, so frames made of buffers from disk are complete and get stored again. - Buffers whose files can't all be named are not written. - Layer disk keys: layer_disk_key = hash of the layer entry version, the renderer version and the layer cache key (which names the footage items with their paths and settings, from memory). - PrefetchStore checks on use; an entry made of changed files is dropped and forgotten, so the new buffer is queued over it. FrameServer passes its virtual-file hook to its layer cache. - Removed: footage_salt(_with), file_stamp, footage_stamp, set_disk_salt, set_store_salt, Session::refresh_disk_salt, poll_disk_salt, footage_fingerprint, disk_salt, disk_salt_background, disk_salt_job, DiskSaltJob, the UI's per-frame poll, the salt in FrameServer and in the availability worker (with its cache_enabled plumbing). The disk cache is attached as soon as it is configured. - Docs: architecture.md, web.md. Tests: layer entries check their files (size, time, missing/back, folder, damaged, old format, incomplete); virtual files; prefetch store; media tests with a real pool across restarts (only the changed file is read, projects with the same item ids never share an entry, missing, unreadable and damaged never serve stale buffers); engine tests that the cache is attached at once and edits do no file work (#1926, #3106). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Disk cache: no-salt worker test, sequence paths in the frame-blend key Part of #3184 (step 3 of 4). - A frame-blended footage frame's layer-cache key now hashes the sequence's file list: with no project-wide salt, two projects whose items share an id and a first frame must not share its disk entry. - Test: a frame worker syncing, patching and rendering a project with an unused 5000-frame sequence asks its file hook only about the file its buffer reads (the salt asked about every file at every sync). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Disk cache: the comp key covers what its expressions can read With an expression anywhere in the project, every comp's disk key hashed every item of the project, so any edit (another comp, an unused footage item) threw away every comp's frames on disk. The key now covers what the comp can draw plus what the expressions among it can reach (Project::pixel_reach): - without expressions: the items the comp draws, without silent layers, plus the footage its precomp layers swap in (Media Replacement); - with expressions: every layer of the comps they reach, their markers, the comps and footage they name with string literals (comp("Name"), footage("Name")) together with which items have those names now, and the footage named like a reached source (its data is read by name); - the whole project when an expression computes a name, uses the object model's constructors or internals, dynamic code, computed keys or writes, or text the scanner can't read for sure (regex literals, template strings, escapes): expression_reach errs towards reaching more. Markers count only where an expression can read them (#1083). Memory only, no file is looked at. Tests: scanner and reach unit tests in the project crate; engine tests for the key (own-comp expressions keep the key through edits elsewhere, comp("Other") follows Other and not a third comp, a second comp taking the name counts, a computed name counts every item, markers read from another comp); a ui-egui test that the disk frames outside an expression layer are carried when the comp it names changes. Part of #3184. Part of #1492. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Disk cache: Reload Footage drops the entries of its files, docs File > Reload Footage now deletes the frame and layer entries whose file lists name the reloaded item's files (DiskCache::drop_files), so a file replaced by one with the same size and modification time, which the check on hit can't tell apart, is read again. The index knows keys, not files: the writer thread reads the files section at the start of each frame and layer entry (not the pixels) after the writes queued before it, and deletes the entries that list one of the files. Until it is done, an entry made of one of them is a miss and frames aren't linked to new keys. The entries are gone for good, so they don't come back after a restart. Automatic reloads don't drop anything: they follow a changed stamp, which the check on hit catches. The browser needs nothing (its file identities are content hashes). Removed: disk_cache::seal, which steps 2 and 3 left without callers (the tests use seal_format). Updated the module, seal_format and forget_media docs, and docs/architecture.md / docs/web.md describe the final design: what is cached, keys from memory, file lists checked on hit, nothing scans the project, incomplete lists stay in memory, Reload Footage drops entries, edits keep the frames they can't change. Tests: render unit tests (same size and time passes the check but not the drop; other files' entries stay; an entry queued before the drop is dropped; a layer entry too; written again afterwards it is used; the drop survives a restart; a pending drop is a miss), and an engine test through file.reloadFootage. Closes #3184. Part of #1492. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Disk cache: file stamps keep the whole modification time A file's modification time was nanoseconds since 1970 as u64: a time before 1970 read as "no time" (two such files of one size looked alike) and times after 2554 were truncated. It is now i128 nanoseconds, negative before the epoch; None only means the file system has no time. The content-key hash writes a presence tag and the full value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Automation: a refused footage read leaves the render's file list incomplete Footage outside the read roots shows nothing and was noted as no file at all, so a picture made without it had a complete, empty list and could be kept on disk for a later session with other roots. A refused read during `recording` now marks the list incomplete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Render: a buffer kept by another worker comes with its own files LayerCache::insert returns the files of the buffer it keeps, and Renderer::keep notes them in the render's log and returns them, so a render that uses another worker's buffer lists that buffer's files (a file changed between the two reads then has two identities and the list is incomplete). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Render: sampleImage and text fonts note the files they read EvalCtx carries the render's read log. A sampleImage render records its footage reads, keeps them with its per-thread cached buffer and notes them in the evaluating render's log on every use. Text notes the font files it is drawn with, with the identity their bytes were read with; a family that isn't installed, a fallback or missing glyph, or a font from memory leaves the list incomplete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Disk cache: frame entries store the whole modification time A frame entry's file list stores a file's modification time as i128 nanoseconds since 1970 (negative before), as the file stamps now keep it; the frame entry format version goes to frame-entry-files-2 so entries in the old layout are not read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Disk cache: layer entries store the whole modification time Layer entries share the frame entries' file list layout, which now stores the full i128 modification time: the layer entry format version goes to layer-entry-files-2. The module doc names the font files text is drawn with among an entry's source files, and what keeps a list incomplete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Layer cache: buffers that read other layers are keyed by the switches passed down A layer whose effect reads another layer (Gradient Wipe, Set Matte, Blend…) gets that layer drawn by its own renderer. Through a precomp layer, Switches Affect Nested Comps, the switches inherited from the precomp layers above and motion blur on change those pixels, but the key held only the layers' own switches: a buffer made with the setting on was reused (from memory or the disk cache) with it off. Such layers now derive their key with what the renderer passes down. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Layer cache: masked buffers are keyed by the mask motion blur gate A mask with Mask Motion Blur On blurs even when the layer's own switch is off, as long as the render's gate is open (Render Settings motion blur, and with Switches Affect Nested Comps the precomp layers above). The key held the layer's own motion blur state only, so a buffer blurred with the gate open was reused with it closed (and the other way round), also from the disk cache. Masked layer, input and footprint keys now tell a closed gate apart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Render: cached buffers list what their expressions and substituted characters read - Buffers made under `Renderer::recorded` (layer content, a Time effect's input, an adjustment layer's footprint) evaluate their masks, shapes and effects with the context that notes into the buffer's own read log, so a file an expression reads (`sampleImage`, also at shutter times mask motion blur adds and the cache key doesn't see) is in the buffer's files. - A styled layer served from the cache lists the files of the cached body and passes too, so the buffers made of it (flattened, blending space) don't get a complete list when one of them came with an incomplete one. - A character put in by Character Offset or Character Value without a relayout notes the font it is drawn with; a fallback face (or none) leaves the list incomplete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Render: text lists the font file of a run that draws no glyph; tests clean their temp dirs An empty first paragraph draws no glyph, but in a box its face sets the first baseline that places every later line. The read list took faces only from glyphs, so it could be complete without that file. Note each style run's resolved face as well. The font-reads and confine tests use tempfile::tempdir(): a fresh directory per run, removed on panic too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Disk cache: Reload Footage closes the gaps around its drop (#3184) - A reload while the disk cache is off is kept on the session (pending_disk_drops) and applied when the cache is switched on, before anything is read from it. - Each render takes the cache's drop generation before it reads (frame worker; layer cache at the miss). An entry listing a file dropped after that is refused under the queue's lock, so a render that was under way during the reload can't store pixels from before it. Such a frame is shown with an incomplete file list, so persist_missing skips it too. The per-file record is bounded (past it, everything older is refused). - An entry read while its drop was pending is a miss even when the writer ends the sweep between the read and the check (a stale hit found under load): the pending drops and the generation are taken before the entry is read. - Test: a reopened project serves its frame from disk with no source file opened or decoded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Disk cache: RAM frames keep the drop generation their render started at Reload Footage empties the RAM preview, but an idle pass of persist_missing may already hold a frame from before the reload. It took the cache's drop generation when it ran, after the drop, so the old pixels could be written back and, with the file's size and time unchanged, served again after a restart. Each RAM frame now keeps the disk cache its render started with and that cache's drop generation then. persist_missing writes a frame only to that cache, with that generation, and skips it once one of its files was dropped since; a frame rendered without that cache is not written from there. Tests use tempfile directories (the layer drop test, the frame_files folders under target/). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Doc comments for the functions this PR touches * Disk cache: cap the free-space reserve at 10 GB, skip encodes the limit refuses The reserve the cache leaves free on its disk was max(2 GB, 5% of the disk) (#2551). On a 4 TB disk with 190 GB free that is 200 GB, so a cache set to 20 GB had a limit of 0: every write was refused, the cache never held anything, and only a log warning said so. The reserve is now clamp(5% of the disk, 2 GB, 10 GB). It is there to keep the cache from filling the disk, not to keep a share of a large disk free: 10 GB leaves the system room to work on any disk, a small disk still keeps 2 GB, and a 100 GB disk keeps 5 GB as before. After Effects has only a maximum cache size and no reserve; a clamp keeps the floor for small disks, where a fixed 10 GB would take most of the space. Writes the limit refuses cost no work: DiskCache::accepts_writes (one atomic read) is checked before a frame is converted (put_disk_frame) and before frames and layers are compressed (put_frame, put_frame_f16, put_layer). The frame workers spent about 8 ms per frame and 14 ms per layer on encodes that were then refused. persist_missing returns at once at limit 0 instead of converting the RAM frames on every idle pass. Settings > Disk shows the limit in effect in MB under Maximum Disk Cache Size, with a note when free disk space lowers it (settings.disk.cacheLimit). * Disk cache: refuse writes below 64 MB of room, show this disk's reserve Review follow-ups to the reserve fix: - accepts_writes also refuses while free disk space leaves less than 64 MB of room (a limit the setting asks for is always used): a few frames would fill it, and the rest would be compressed only to be refused. - While writes are refused the idle passes ask for no repaint, so the UI keeps a frame coming every 5 s: the free space is read again and the RAM frames are written once there is room. - Settings > Disk names the space kept free on this disk (the reserve the last reading applied; the rule from MIN/MAX_DISK_RESERVE when it is unknown), and the "no room" note follows accepts_writes. - The settings.disk.cacheLimit automation label is machine-readable and the same in every language: limitMb, maxMb, reserveMb, spaceLimited, writes. - cargo fmt. * Disk cache: follow 3D model sources in the comp's reach The reach walk followed comp, footage and solid sources but not LayerSource::Model. An expression reading a model layer's source (thisComp.layer("Model").source.name) left the model item out of the comp's content key, so renaming the model kept the key and the old frames on disk were served. Every source item is followed now (LayerSource::item), which also counts the model when no expression reads it (Advanced 3D draws it). The same omission is fixed in the RAM preview's content walk (frames.rs) and in the layer key's walk of referenced layers (cache/dependencies.rs). * Disk cache: a render's drop generation travels with its reads; no disk entries for Expression Selectors Drop generation per render: the layer cache recovered a buffer's drop generation from the latest miss of its key, so a second render of the key that started after File > Reload Footage lent its generation to a render still running from before, and that render's buffer (the file as it was) was written after the sweep. Now Renderer::recorded starts each buffer's read log at the store's drop generation before anything is read (ReadLog::starting_at); ReadFiles carries the earliest generation through merges, so derived buffers (styles, the blend-space copy, the flattened layer) and frames that use a buffer from memory carry it too. LayerCache::insert keeps a buffer made of a file dropped since its reads began neither on disk nor in memory; a list that doesn't say counts as made before every drop. DiskCache::dropped_since takes the list's own generation too. missed_at keeps only the miss time. Expression Selectors: the layer key evaluates a text Expression Selector's amount for the first character only, so a per-character dependency such as textIndex == 1 ? 0 : footage("ref.png").width was not in the key, and after the footage item's width changed a reopened session reused the old layer entry. A text layer with an Expression Selector now marks its reads unkeyed (ReadFiles::is_unkeyed), which merges like the generation; such buffers stay in memory and are never written to disk. Frame entries are unaffected: the comp's frame key hashes what the expressions name. The equality and hash of ReadFiles ignore both new fields. * Disk cache: never serve a RAM layer buffer made before a drop of its files A render that passed the drop check could sit in put_layer's encode while Reload Footage cleared the layer cache and dropped the file, then publish its old pixels to memory after the purge; get, and a fresh render's insert, then served them. The disk-hit promotion went through the same insert_mem. Every way the cache hands out a RAM buffer (get, the kept-first path of insert, an existing entry in insert_mem) now checks the entry's files against the store's drops from their own generation (ReadFiles::since): an entry made before a drop of one of its files is forgotten and the lookup misses, so a fresh render's buffer replaces it. insert_mem also re-checks a buffer just before publishing it. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2073 lines
97 KiB
Rust
2073 lines
97 KiB
Rust
use std::time::Instant;
|
||
|
||
mod deep_nesting;
|
||
mod fuzz;
|
||
|
||
use effectcraft_color::Label;
|
||
use effectcraft_keyframe::{Keyframe, ShapePath, TextDoc, Value};
|
||
use effectcraft_project::build::{self, Ids};
|
||
use effectcraft_project::{Comp, Expression, ItemId, ItemKind, Layer, LayerId, LayerSource, Marker, MaskMode, Project, PropGroup, Solid};
|
||
use effectcraft_render::{EvalCtx, NoFootage, RenderOpts, Renderer};
|
||
use effectcraft_time::{FrameRate, Tick};
|
||
|
||
use crate::{Expressions, check_syntax, eval_property, eval_standalone};
|
||
|
||
const ROT: &str = "transform/rotation";
|
||
const POS: &str = "transform/position";
|
||
const OPA: &str = "transform/opacity";
|
||
|
||
/// A 200×100, 30 fps, 10 s comp named "Main" with:
|
||
/// 1. "A": 20×20 red solid at (50, 40), slider effect = 42, rotation keyed 0→90 over 0–1 s
|
||
/// 2. "B": 40×40 blue solid at (100, 50)
|
||
/// 3. "Title": text layer "Hello"
|
||
/// 4. "Shape": shape layer with "Rectangle 1" (60×30 rect path, red fill)
|
||
///
|
||
/// plus an "Other" comp with one layer "Inner".
|
||
struct Fx {
|
||
p: Project,
|
||
cid: ItemId,
|
||
a: LayerId,
|
||
b: LayerId,
|
||
title: LayerId,
|
||
shape: LayerId,
|
||
}
|
||
|
||
fn solid(p: &mut Project, comp: &Comp, name: &str, color: [f32; 3], w: u32, h: u32) -> Layer {
|
||
let sid = p.add_item(name, Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 }));
|
||
build::layer(p, comp, name, LayerSource::Solid { item: sid }, (w, h), None)
|
||
}
|
||
|
||
fn group_push(p: &mut Project, f: impl FnOnce(&mut Ids) -> PropGroup) -> PropGroup {
|
||
let mut next = p.next_id;
|
||
let g = f(&mut Ids(&mut next));
|
||
p.next_id = next;
|
||
g
|
||
}
|
||
|
||
fn fx() -> Fx {
|
||
let mut p = Project::default();
|
||
let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(10.0));
|
||
let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||
|
||
let mut a = solid(&mut p, &comp, "A", [1.0, 0.0, 0.0], 20, 20);
|
||
a.props.prop_mut(POS).unwrap().value = Value::Vec3([50.0, 40.0, 0.0]);
|
||
a.props.prop_mut(ROT).unwrap().keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(secs(1.0), Value::Scalar(90.0))];
|
||
let spec = effectcraft_effects::find("ec.control.slider").unwrap();
|
||
let mut slider = group_push(&mut p, |ids| effectcraft_effects::instantiate(spec, ids, "Slider Control", [20.0, 20.0]));
|
||
slider.prop_mut("slider").unwrap().value = Value::Scalar(42.0);
|
||
a.props.sub_mut("effects").unwrap().children.push(slider.into());
|
||
let mask = group_push(&mut p, |ids| build::mask(ids, "Mask 1", ShapePath::rect([10.0, 10.0], 10.0, 10.0), MaskMode::Add, [255, 0, 0]));
|
||
a.props.sub_mut("masks").unwrap().children.push(mask.into());
|
||
a.markers.push(Marker { time: secs(2.0), comment: "beat".into(), ..Default::default() });
|
||
a.markers.push(Marker { time: secs(4.0), comment: "drop".into(), ..Default::default() });
|
||
|
||
let b = solid(&mut p, &comp, "B", [0.0, 0.0, 1.0], 40, 40);
|
||
|
||
let mut title = build::layer(&mut p, &comp, "Title", LayerSource::Text, (200, 100), None);
|
||
title.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "Hello".into(), size: 40.0, ..Default::default() }));
|
||
title.props.prop_mut(POS).unwrap().value = Value::Vec3([20.0, 60.0, 0.0]);
|
||
|
||
let mut shape = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 100), None);
|
||
let g = group_push(&mut p, |ids| {
|
||
let rect = build::shape_rect(ids, [60.0, 30.0], [0.0, 0.0], 0.0);
|
||
let fill = build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0]);
|
||
build::shape_group(ids, "Rectangle 1", vec![rect, fill])
|
||
});
|
||
shape.props.sub_mut("contents").unwrap().children.push(g.into());
|
||
|
||
let ids = (a.id, b.id, title.id, shape.id);
|
||
let c = p.comp_mut(cid).unwrap();
|
||
c.layers = vec![a, b, title, shape].into();
|
||
c.markers.push(Marker { time: secs(1.5), comment: "comp marker".into(), ..Default::default() });
|
||
|
||
let other = Comp::new(64, 32, FrameRate::FPS_24, Tick::from_seconds_f64(3.0));
|
||
let oid = p.add_item("Other", Label::Sandstone, None, ItemKind::Comp(other.clone().into()));
|
||
let inner = solid(&mut p, &other, "Inner", [0.0, 1.0, 0.0], 64, 32);
|
||
p.comp_mut(oid).unwrap().layers.push(inner);
|
||
|
||
Fx { p, cid, a: ids.0, b: ids.1, title: ids.2, shape: ids.3 }
|
||
}
|
||
|
||
fn secs(s: f64) -> Tick {
|
||
Tick::from_seconds_f64(s)
|
||
}
|
||
|
||
impl Fx {
|
||
fn ev(&self, layer: LayerId, path: &str, text: &str, t: f64) -> Result<Value, String> {
|
||
eval_standalone(&self.p, self.cid, layer, path, text, t)
|
||
}
|
||
/// Scalar result on layer A's rotation.
|
||
fn num(&self, text: &str, t: f64) -> f64 {
|
||
match self.ev(self.a, ROT, text, t) {
|
||
Ok(v) => v.as_f64(),
|
||
Err(e) => panic!("{text}: {e}"),
|
||
}
|
||
}
|
||
/// Position result on layer A (3 components).
|
||
fn pos(&self, text: &str, t: f64) -> [f64; 3] {
|
||
match self.ev(self.a, POS, text, t) {
|
||
Ok(v) => v.as_vec3(),
|
||
Err(e) => panic!("{text}: {e}"),
|
||
}
|
||
}
|
||
fn text(&self, text: &str, t: f64) -> String {
|
||
match self.ev(self.title, "text/sourceText", text, t) {
|
||
Ok(Value::Text(d)) => d.text,
|
||
Ok(v) => panic!("{text}: not text: {v:?}"),
|
||
Err(e) => panic!("{text}: {e}"),
|
||
}
|
||
}
|
||
fn err(&self, text: &str) -> String {
|
||
match self.ev(self.a, ROT, text, 0.0) {
|
||
Ok(v) => panic!("{text}: expected an error, got {v:?}"),
|
||
Err(e) => e,
|
||
}
|
||
}
|
||
fn set_expr(&mut self, layer: LayerId, path: &str, text: &str) {
|
||
let l = self.p.comp_mut(self.cid).unwrap().layer_mut(layer).unwrap();
|
||
l.props.prop_mut(path).unwrap().expr = Some(Expression { text: text.into(), enabled: true });
|
||
}
|
||
fn layer_mut(&mut self, layer: LayerId) -> &mut Layer {
|
||
self.p.comp_mut(self.cid).unwrap().layer_mut(layer).unwrap()
|
||
}
|
||
}
|
||
|
||
fn close(a: f64, b: f64) -> bool {
|
||
(a - b).abs() < 1e-6
|
||
}
|
||
|
||
macro_rules! assert_close {
|
||
($a:expr, $b:expr) => {{
|
||
let (a, b): (f64, f64) = ($a, $b);
|
||
assert!((a - b).abs() < 1e-6, "{} = {a}, expected {b}", stringify!($a));
|
||
}};
|
||
($a:expr, $b:expr, $eps:expr) => {{
|
||
let (a, b): (f64, f64) = ($a, $b);
|
||
assert!((a - b).abs() < $eps, "{} = {a}, expected {b} ± {}", stringify!($a), $eps);
|
||
}};
|
||
}
|
||
|
||
// ---- basics -----------------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn time_times_360() {
|
||
let f = fx();
|
||
assert_close!(f.num("time * 360", 0.5), 180.0);
|
||
assert_close!(f.num("time*360", 2.0), 720.0);
|
||
}
|
||
|
||
#[test]
|
||
fn value_is_the_keyframed_value() {
|
||
let f = fx();
|
||
assert_close!(f.num("value", 0.5), 45.0);
|
||
assert_close!(f.num("value + 10", 1.0), 100.0);
|
||
}
|
||
|
||
#[test]
|
||
fn math_object() {
|
||
let f = fx();
|
||
assert_close!(f.num("Math.sin(Math.PI / 2) * 100", 0.0), 100.0);
|
||
assert_close!(f.num("Math.max(3, 7) - Math.floor(2.7)", 0.0), 5.0);
|
||
}
|
||
|
||
#[test]
|
||
fn last_statement_is_the_result() {
|
||
let f = fx();
|
||
assert_close!(f.num("var x = 5;\nvar y = x * 2;\ny", 0.0), 10.0);
|
||
assert_close!(f.num("x = 3; x + 1", 0.0), 4.0);
|
||
}
|
||
|
||
#[test]
|
||
fn if_else_blocks_end_in_a_value() {
|
||
let f = fx();
|
||
assert_close!(f.num("if (time > 1) { 10 } else { 20 }", 2.0), 10.0);
|
||
assert_close!(f.num("if (time > 1) { 10 } else { 20 }", 0.0), 20.0);
|
||
assert_close!(f.num("if (time > 1) 1; else if (time > 0.5) 2; else 3", 0.75), 2.0);
|
||
}
|
||
|
||
#[test]
|
||
fn functions_and_let_are_reusable_across_evaluations() {
|
||
let f = fx();
|
||
let e = "function sq(x) { return x * x }\nlet k = 2;\nconst j = 1;\nsq(time) * k + j";
|
||
assert_close!(f.num(e, 3.0), 19.0);
|
||
assert_close!(f.num(e, 2.0), 9.0);
|
||
}
|
||
|
||
#[test]
|
||
fn an_expression_cannot_rebind_a_prelude_name_for_the_next_one() {
|
||
let f = fx();
|
||
// #2133: one runtime serves every expression on a thread and user code runs at global scope, so
|
||
// a write to a prelude name used to outlive the expression that made it and poison every later
|
||
// one — in this session's other projects too.
|
||
assert_close!(f.num("linear = function () { return 777 };\n50", 0.0), 50.0);
|
||
assert_close!(f.num("linear(time, 0, 1, 0, 100)", 0.0), 0.0);
|
||
assert_close!(f.num("var clamp = 5;\n10", 0.0), 10.0);
|
||
assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0);
|
||
assert_close!(f.num("Math.sin = function () { return 9 };\n1", 0.0), 1.0);
|
||
assert_close!(f.num("Math.sin(Math.PI / 2) * 100", 0.0), 100.0);
|
||
assert_close!(f.num("Object.prototype.leak = 9;\n2", 0.0), 2.0);
|
||
assert_close!(f.num("({}).leak === undefined ? 1 : 0", 0.0), 1.0);
|
||
// The runtime is one thread-local singleton, so a second project on this thread meets the same
|
||
// objects: the isolation has to hold between projects, not only between the evaluations of one.
|
||
let g = fx();
|
||
assert_close!(g.num("linear(time, 0, 1, 0, 100)", 0.0), 0.0);
|
||
assert_close!(g.num("clamp(50, 0, 100)", 0.0), 50.0);
|
||
assert_close!(g.num("Math.sin(Math.PI / 2) * 100", 0.0), 100.0);
|
||
assert_close!(g.num("({}).leak === undefined ? 1 : 0", 0.0), 1.0);
|
||
}
|
||
|
||
/// The four ways a script can reach a global binding, against a prelude *function* rather than one
|
||
/// of the unqualified accessors: assignment, `var`, `delete` and `defineProperty`. The lock is
|
||
/// `writable: false, configurable: false`, and it is the second half of that pair that answers the
|
||
/// last two — a `delete` of a non-configurable property is false, and a `defineProperty` over one
|
||
/// is a `TypeError`. Asked for by a reviewer on #2176.
|
||
#[test]
|
||
fn a_prelude_function_survives_every_way_a_script_can_reach_its_binding() {
|
||
let f = fx();
|
||
// Assignment and `var`: silent, and the next expression still has the original.
|
||
assert_close!(f.num("clamp = function () { return 777 };\n1", 0.0), 1.0);
|
||
assert_close!(f.num("var clamp = 5;\n2", 0.0), 2.0);
|
||
assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0);
|
||
// `delete` of a non-configurable binding answers false in sloppy mode rather than throwing.
|
||
assert_close!(f.num("delete clamp ? 1 : 0", 0.0), 0.0);
|
||
assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0);
|
||
assert_close!(f.num("typeof clamp === 'function' ? 1 : 0", 0.0), 1.0);
|
||
// `defineProperty` over one is a `TypeError`, so this is the one route the user does see. The
|
||
// expression fails; what it was reaching for is unchanged for the next one.
|
||
assert!(f.err("Object.defineProperty(globalThis, 'clamp', { value: 5 })").contains("TypeError"));
|
||
assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0);
|
||
// Reconfiguring it to writable is refused the same way, so the lock cannot be reopened.
|
||
assert!(f.err("Object.defineProperty(globalThis, 'clamp', { writable: true })").contains("TypeError"));
|
||
assert_close!(f.num("clamp = 7;\n3", 0.0), 3.0);
|
||
assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0);
|
||
// A second project on the same thread-local runtime meets the same bindings.
|
||
let g = fx();
|
||
assert_close!(g.num("clamp(50, 0, 100)", 0.0), 50.0);
|
||
assert_close!(g.num("typeof clamp === 'function' ? 1 : 0", 0.0), 1.0);
|
||
}
|
||
|
||
/// Which mode a write is silent in decides what the user sees, and the answer is that an expression
|
||
/// cannot reach strict mode at all: `Runtime::script` wraps the text in `try{…}catch(__e){throw __e}`,
|
||
/// so a `"use strict"` the user writes is an expression statement inside a block rather than a
|
||
/// directive prologue, and has no effect. A failed write is therefore always the silent kind.
|
||
/// Asked for by a reviewer on #2176.
|
||
#[test]
|
||
fn a_use_strict_in_an_expression_is_inert_so_a_refused_write_stays_silent() {
|
||
let f = fx();
|
||
assert_close!(f.num("\"use strict\";\nlinear = function () { return 777 };\n50", 0.0), 50.0);
|
||
assert_close!(f.num("linear(time, 0, 1, 0, 100)", 0.0), 0.0);
|
||
// The directive is inert rather than honoured: in strict mode an assignment to an undeclared
|
||
// name is a ReferenceError, and here it quietly creates a global instead.
|
||
assert_close!(f.num("\"use strict\";\nundeclared_global = 5;\nundeclared_global", 0.0), 5.0);
|
||
// Which is also why the refused write above cannot be made to report itself by the user: the
|
||
// only mode an expression runs in is the one where it is dropped.
|
||
assert_close!(f.num("\"use strict\";\ntypeof linear === 'function' ? 1 : 0", 0.0), 1.0);
|
||
}
|
||
|
||
/// The same leak, reached through the objects instead of the names: a prelude constructor keeps its
|
||
/// methods on its `prototype`, the prelude itself calls `Array.isArray` and `Object.setPrototypeOf`
|
||
/// while it answers an expression, and `thisProject` is reachable surface that holds no function at
|
||
/// all (#2133).
|
||
#[test]
|
||
fn an_expression_cannot_tamper_with_a_prelude_object_for_the_next_one() {
|
||
let f = fx();
|
||
// An unqualified name is an accessor, so it takes a `defineProperty` or a `delete` rather than
|
||
// an assignment; the setter an assignment goes through writes per-evaluation state.
|
||
assert_close!(f.num("try { Object.defineProperty(globalThis, 'width', { value: 777 }) } catch (e) {}\n1", 0.0), 1.0);
|
||
assert_close!(f.num("delete width;\n2", 0.0), 2.0);
|
||
assert_close!(f.num("width", 0.0), 20.0);
|
||
assert_close!(f.num("Comp.prototype = {};\n3", 0.0), 3.0);
|
||
assert_close!(f.num("Comp.prototype.leak = 9;\n4", 0.0), 4.0);
|
||
assert_close!(f.num("thisComp.width", 0.0), 200.0);
|
||
// `add` reads its arguments through `Array.isArray`, and reaching an effect builds a group with
|
||
// `Object.setPrototypeOf`, so a replacement for either would change the answers below.
|
||
assert_close!(f.num("Array.isArray = function () { return false };\n5", 0.0), 5.0);
|
||
assert_close!(f.num("Object.setPrototypeOf = function (o) { return o };\n6", 0.0), 6.0);
|
||
assert_close!(f.num("add([1, 2], [3, 4])[0]", 0.0), 4.0);
|
||
assert_close!(f.num("effect(\"Slider Control\")(\"Slider\")", 0.0), 42.0);
|
||
assert_close!(f.num("thisProject = 5;\nthisProject.leak = 9;\n7", 0.0), 7.0);
|
||
assert_close!(f.num("typeof thisProject === 'object' && thisProject.leak === undefined ? 1 : 0", 0.0), 1.0);
|
||
}
|
||
|
||
/// `Math` and `JSON` are namespaces rather than prototypes: nothing inherits from them, so the
|
||
/// prelude freezes them plainly instead of turning every method into a shadowable accessor, which
|
||
/// would put a getter call on every `Math.sin` of a per-frame expression for a shadowing no object
|
||
/// can do. They still cannot be rewritten, through a member or through the binding (#2176).
|
||
#[test]
|
||
fn math_and_json_are_frozen_and_cannot_be_replaced_for_the_next_expression() {
|
||
let f = fx();
|
||
assert_close!(f.num("Object.isFrozen(Math) && Object.isFrozen(JSON) ? 1 : 0", 0.0), 1.0);
|
||
// Plain data properties, not the accessor repair: the hot path reads the native method directly.
|
||
assert_close!(f.num("'value' in Object.getOwnPropertyDescriptor(Math, 'sin') ? 1 : 0", 0.0), 1.0);
|
||
assert_close!(f.num("'value' in Object.getOwnPropertyDescriptor(JSON, 'stringify') ? 1 : 0", 0.0), 1.0);
|
||
// A member write is dropped in silence, and the next expression still has the original.
|
||
assert_close!(f.num("Math.sin = function () { return 9 }; Math.max = function () { return -1 };\n1", 0.0), 1.0);
|
||
assert_close!(f.num("Math.sin(Math.PI / 2) * 100 + Math.max(3, 7)", 0.0), 107.0);
|
||
assert_close!(f.num("JSON.stringify = function () { return 'x' }; JSON.parse = null;\n2", 0.0), 2.0);
|
||
assert_close!(f.num("JSON.stringify(JSON.parse('[1,2]')) === '[1,2]' ? 1 : 0", 0.0), 1.0);
|
||
assert_close!(f.num("Math.leak = 5; JSON.leak = 6;\n3", 0.0), 3.0);
|
||
assert_close!(f.num("Math.leak === undefined && JSON.leak === undefined ? 1 : 0", 0.0), 1.0);
|
||
assert_close!(f.num("delete Math.floor ? 1 : 0", 0.0), 0.0);
|
||
assert!(f.err("Object.defineProperty(Math, 'floor', { value: 5 })").contains("TypeError"));
|
||
// The bindings are locked too: assignment, `var` and `delete` leave both in place.
|
||
assert_close!(f.num("Math = { sin: function () { return 9 } }; JSON = null;\n4", 0.0), 4.0);
|
||
assert_close!(f.num("var Math = 5; var JSON = 6;\n5", 0.0), 5.0);
|
||
assert_close!(f.num("delete Math || delete JSON ? 1 : 0", 0.0), 0.0);
|
||
assert_close!(f.num("Math.floor(2.7) + JSON.parse('3')", 0.0), 5.0);
|
||
// A second project on the same thread-local runtime meets the same objects.
|
||
let g = fx();
|
||
assert_close!(g.num("Math.sin(Math.PI / 2) * 100", 0.0), 100.0);
|
||
assert_close!(g.num("JSON.stringify([1]) === '[1]' ? 1 : 0", 0.0), 1.0);
|
||
}
|
||
|
||
/// Closing the prototypes must not cost an expression its own objects: once an inherited data
|
||
/// property is non-writable, an assignment meaning to shadow it on an instance is dropped in sloppy
|
||
/// mode rather than creating an own property, and using an object as a dictionary runs into exactly
|
||
/// those names (#2133).
|
||
#[test]
|
||
fn an_expression_can_shadow_an_inherited_name_on_its_own_object() {
|
||
let f = fx();
|
||
assert_close!(f.num("var o = {}; o.toString = function () { return 'x' }; o.toString() === 'x' ? 1 : 0", 0.0), 1.0);
|
||
assert_close!(f.num("var d = {}; d.constructor = 3; d.constructor", 0.0), 3.0);
|
||
assert_close!(f.num("var c = {}; c['valueOf'] = 2; c['valueOf']", 0.0), 2.0);
|
||
// The shared prototype itself still cannot be written through, which is the point of freezing.
|
||
assert_close!(f.num("Object.prototype.toString = function () { return 'z' };\n1", 0.0), 1.0);
|
||
assert_close!(f.num("({}).toString() === 'z' ? 0 : 1", 0.0), 1.0);
|
||
}
|
||
|
||
/// The names an expression shadows are not only `Object.prototype`'s: giving one array its own
|
||
/// `map` or one function its own `call` is ordinary JavaScript, and a prototype frozen without the
|
||
/// repair takes it away. The two halves fail differently, which is why both are checked here —
|
||
/// `map`, `forEach` and `filter` throw, while `push` and `join` silently run the native method and
|
||
/// return its answer, so an expression using them keeps working and quietly computes something
|
||
/// else (#2133).
|
||
#[test]
|
||
fn an_expression_can_shadow_an_inherited_method_on_its_own_array_or_function() {
|
||
let f = fx();
|
||
assert_close!(f.num("var a = []; a.map = function () { return 42 }; a.map()", 0.0), 42.0);
|
||
assert_close!(f.num("var a = [1, 2]; a.forEach = function () { return 5 }; a.forEach()", 0.0), 5.0);
|
||
assert_close!(f.num("var a = [1, 2]; a.filter = function () { return 6 }; a.filter()", 0.0), 6.0);
|
||
// `push` returned the native length and `join` the native string before the repair.
|
||
assert_close!(f.num("var a = [1]; a.push = function () { return 99 }; a.push(2)", 0.0), 99.0);
|
||
assert_close!(f.num("var a = [1]; a.push = function () { return 99 }; a.push(2); a.length", 0.0), 1.0);
|
||
assert_close!(f.num("var a = [1]; a.join = function () { return 'J' }; a.join() === 'J' ? 1 : 0", 0.0), 1.0);
|
||
assert_close!(f.num("var fn = function () {}; fn.call = function () { return 7 }; fn.call()", 0.0), 7.0);
|
||
assert_close!(f.num("var fn = function () {}; fn.apply = function () { return 8 }; fn.apply()", 0.0), 8.0);
|
||
// A symbol key on a prototype the loop reaches goes the same way.
|
||
assert_close!(f.num("var a = [1]; a[Symbol.toPrimitive] = function () { return 11 }; +a", 0.0), 11.0);
|
||
// Reading an unshadowed method still answers with the native one, through the repair.
|
||
assert_close!(f.num("[1, 2, 3].map(function (x) { return x * 2 })[2]", 0.0), 6.0);
|
||
assert_close!(f.num("[1, 2, 3].filter(function (x) { return x > 1 }).length", 0.0), 2.0);
|
||
assert_close!(f.num("[3, 1, 2].sort()[0]", 0.0), 1.0);
|
||
assert_close!(f.num("[1, 2].concat([3]).length", 0.0), 3.0);
|
||
// The shared prototype itself still cannot be written through, which is the point of freezing.
|
||
assert_close!(f.num("Array.prototype.map = function () { return 0 };\n1", 0.0), 1.0);
|
||
assert_close!(f.num("[1].map(function (x) { return x + 1 })[0]", 0.0), 2.0);
|
||
assert_close!(f.num("Function.prototype.call = function () { return 0 };\n1", 0.0), 1.0);
|
||
assert_close!(f.num("(function () { return 4 }).call(null)", 0.0), 4.0);
|
||
}
|
||
|
||
/// `name` and `message` on an error's prototype are the one exception to the repair, because the
|
||
/// host reads both off a thrown object to render it and has no context to call a getter with, nor
|
||
/// anywhere safe to call one: formatting happens after the evaluation's time budget stops being
|
||
/// checked. What matters is that the message still names the error and the line, and that the
|
||
/// assignment the exception costs is dropped in silence rather than failing the expression (#2133).
|
||
#[test]
|
||
fn an_error_still_renders_with_its_name_and_the_users_line() {
|
||
let f = fx();
|
||
// The write the exception costs: a no-op, not a throw, and the next statement still runs.
|
||
assert_close!(f.num("var e = new Error('x'); e.name = 'MyError'; 1", 0.0), 1.0);
|
||
assert_close!(f.num("var e = new Error('x'); e.name === 'Error' ? 1 : 0", 0.0), 1.0);
|
||
// An own `message` is the instance's, so it is untouched by any of this.
|
||
assert_close!(f.num("new Error('x').message === 'x' ? 1 : 0", 0.0), 1.0);
|
||
// The prototype itself still cannot be rebound for the next expression, which is the point.
|
||
assert_close!(f.num("Error.prototype.name = 'Hacked';\n1", 0.0), 1.0);
|
||
assert_close!(f.num("new Error('x').name === 'Error' ? 1 : 0", 0.0), 1.0);
|
||
// A getter the expression installs on its own error is reached through its own property, so it
|
||
// never goes near the host's read of the prototype.
|
||
assert_close!(f.num("var e = new Error('x'); Object.defineProperty(e, 'name', { get: function () { return 'Own' } }); e.name === 'Own' ? 1 : 0", 0.0), 1.0);
|
||
}
|
||
|
||
/// The repair must not turn a dropped assignment into a failed expression: in sloppy mode a write
|
||
/// to a frozen target is ignored, and the setter runs for every receiver that inherits the name,
|
||
/// including the other frozen prototypes and an object the expression froze itself.
|
||
#[test]
|
||
fn assigning_a_repaired_name_on_a_frozen_object_is_dropped_not_an_error() {
|
||
let f = fx();
|
||
// `hasOwnProperty` is `Object.prototype`'s, so this runs the repaired setter with a frozen
|
||
// `Array.prototype` as the receiver.
|
||
assert_close!(f.num("Array.prototype.hasOwnProperty = function () { return true };\n1", 0.0), 1.0);
|
||
assert_close!(f.num("[].hasOwnProperty('length') ? 1 : 0", 0.0), 1.0);
|
||
// The prelude freezes these, and each inherits the repaired names.
|
||
assert_close!(f.num("Math.toString = function () { return 'm' };\n2", 0.0), 2.0);
|
||
assert_close!(f.num("JSON.valueOf = 3;\n3", 0.0), 3.0);
|
||
assert_close!(f.num("thisProject.toLocaleString = 4;\n4", 0.0), 4.0);
|
||
// An object the expression froze itself behaves as it does outside the prelude.
|
||
assert_close!(f.num("var o = {}; Object.freeze(o); o.toString = function () { return 'x' };\n5", 0.0), 5.0);
|
||
assert_close!(f.num("var s = {}; Object.seal(s); s.constructor = 6;\n6", 0.0), 6.0);
|
||
// Still a no-op where it has to be, and still working where it has to work.
|
||
assert_close!(f.num("({}).toString() === '[object Object]' ? 1 : 0", 0.0), 1.0);
|
||
assert_close!(f.num("var k = {}; k.toString = function () { return 'y' }; k.toString() === 'y' ? 1 : 0", 0.0), 1.0);
|
||
// A primitive target boxes into a throwaway wrapper, and `null` is not extensible, which is why
|
||
// the receiver check alone is enough.
|
||
assert_close!(f.num("'abc'.toString = function () { return 'q' };\n7", 0.0), 7.0);
|
||
assert_close!(f.num("(5).constructor = 8;\n8", 0.0), 8.0);
|
||
assert_close!(f.num("Object.isExtensible(null) === false && Object.isExtensible(undefined) === false ? 1 : 0", 0.0), 1.0);
|
||
}
|
||
|
||
/// The repaired name is an accessor standing in for a data property, and an accessor is handed the
|
||
/// receiver while a data property has the receiver's own property consulted first, so the setter
|
||
/// has to do that itself or the substitution shows through. An ordinary assignment finds the own
|
||
/// property and never reaches the setter, so these call it with the receiver directly, which is
|
||
/// what `Reflect.set(Object.prototype, name, v, o)` would do; `Reflect` is not available to
|
||
/// expressions. Every expected value is what that `Reflect.set` answers in node, where the name
|
||
/// is still the data property this one stands in for.
|
||
#[test]
|
||
fn the_repair_answers_a_write_as_the_data_property_it_replaced_would() {
|
||
let f = fx();
|
||
let set = "var put = Object.getOwnPropertyDescriptor(Object.prototype, 'toString').set;";
|
||
// An own writable property is updated in place, even when the receiver takes no new ones.
|
||
assert_close!(f.num(&format!("{set} var o = {{ toString: 1 }}; Object.preventExtensions(o); put.call(o, 2); o.toString"), 0.0), 2.0);
|
||
assert_close!(f.num(&format!("{set} var o = {{ toString: 1 }}; put.call(o, 2); o.toString"), 0.0), 2.0);
|
||
// ... and keeps the attributes it had, rather than being redefined as a fresh enumerable one.
|
||
assert_close!(
|
||
f.num(
|
||
&format!(
|
||
"{set} var o = {{}}; Object.defineProperty(o, 'toString', {{ value: 1, writable: true, enumerable: false, configurable: true }});
|
||
put.call(o, 2); Object.getOwnPropertyDescriptor(o, 'toString').enumerable ? 1 : 0"
|
||
),
|
||
0.0
|
||
),
|
||
0.0
|
||
);
|
||
// An own non-writable property drops the write instead of being overwritten.
|
||
assert_close!(
|
||
f.num(
|
||
&format!(
|
||
"{set} var o = {{}}; Object.defineProperty(o, 'toString', {{ value: 1, writable: false, enumerable: false, configurable: true }});
|
||
put.call(o, 2); o.toString"
|
||
),
|
||
0.0
|
||
),
|
||
1.0
|
||
);
|
||
// Writing through the prototype itself changes nothing for anyone.
|
||
assert_close!(f.num(&format!("{set} put.call(Object.prototype, 2); ({{}}).toString === undefined ? 0 : 1"), 0.0), 1.0);
|
||
// An ordinary assignment finds the own property first and never reaches the setter at all.
|
||
assert_close!(f.num("var o = { toString: 1 }; o.toString = 2; o.toString", 0.0), 2.0);
|
||
}
|
||
|
||
/// `for…of`, destructuring and spread all go through the iterator intrinsics, and those are the
|
||
/// `prototype` of no global function, so freezing what the globals reach never touched them (#2133).
|
||
#[test]
|
||
fn an_expression_cannot_poison_iteration_for_the_next_one() {
|
||
let f = fx();
|
||
assert_close!(f.num("Object.getPrototypeOf([][Symbol.iterator]()).next = function () { return { done: true } };\n1", 0.0), 1.0);
|
||
assert_close!(f.num("var s = 0; for (var x of [1, 2, 3]) s += x; s", 0.0), 6.0);
|
||
assert_close!(f.num("var [a, b] = [4, 5]; a + b", 0.0), 9.0);
|
||
assert_close!(f.num("Math.max(...[1, 7, 3])", 0.0), 7.0);
|
||
}
|
||
|
||
/// Shadowing an inherited name on an instance has to keep working for symbol keys too: making an
|
||
/// object iterable by assigning `Symbol.iterator` is ordinary JavaScript, and a frozen
|
||
/// `Array.prototype` drops that write in silence, so `for…of` walks the array's own elements and
|
||
/// quietly returns a different number rather than failing (#2133).
|
||
#[test]
|
||
fn an_expression_can_give_its_own_array_an_iterator() {
|
||
let f = fx();
|
||
assert_close!(
|
||
f.num(
|
||
"var a = [1, 2];
|
||
a[Symbol.iterator] = function () { var d = false; return { next: function () { if (d) return { done: true }; d = true; return { value: 9, done: false } } } };
|
||
var s = 0; for (var x of a) s += x; s",
|
||
0.0
|
||
),
|
||
9.0
|
||
);
|
||
assert_close!(f.num("var a = [1, 2]; a[Symbol.iterator] = 1; a[Symbol.iterator] === 1 ? 1 : 0", 0.0), 1.0);
|
||
// `Object.prototype` carries no symbol keys, so a plain object was never affected.
|
||
assert_close!(f.num("var o = {}; o[Symbol.iterator] = 1; o[Symbol.iterator] === 1 ? 1 : 0", 0.0), 1.0);
|
||
// An inherited name that is already non-writable is left alone, as it is outside the prelude:
|
||
// `Date.prototype[Symbol.toPrimitive]` drops the write on `main` and in node as well.
|
||
assert_close!(f.num("var d = new Date(0); d[Symbol.toPrimitive] = 1; d[Symbol.toPrimitive] === 1 ? 1 : 0", 0.0), 0.0);
|
||
// Repairing the key must not reopen it: the shared prototype still cannot be written through.
|
||
assert_close!(
|
||
f.num(
|
||
"Array.prototype[Symbol.iterator] = function () { return { next: function () { return { done: true } } } };
|
||
1",
|
||
0.0
|
||
),
|
||
1.0
|
||
);
|
||
assert_close!(f.num("var s = 0; for (var x of [1, 2, 3]) s += x; s", 0.0), 6.0);
|
||
}
|
||
|
||
#[test]
|
||
fn comments_are_ignored() {
|
||
let f = fx();
|
||
assert_close!(f.num("// spin\n/* fast */ time * 100 // per second", 1.0), 100.0);
|
||
}
|
||
|
||
#[test]
|
||
fn ternary_and_comparison() {
|
||
let f = fx();
|
||
assert_close!(f.num("time > 1 ? 1 + 1 : 3 * 3", 2.0), 2.0);
|
||
assert_close!(f.num("time > 1 ? 1 + 1 : 3 * 3", 0.0), 9.0);
|
||
}
|
||
|
||
// ---- array maths ------------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn array_plus_array() {
|
||
let f = fx();
|
||
assert_eq!(f.pos("value + [10, 0]", 0.0), [60.0, 40.0, 0.0]);
|
||
assert_eq!(f.pos("[10, 5] + value", 0.0), [60.0, 45.0, 0.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn array_times_and_divide_scalar() {
|
||
let f = fx();
|
||
assert_eq!(f.pos("[1, 2] * 2", 0.0), [2.0, 4.0, 0.0]);
|
||
assert_eq!(f.pos("2 * [1, 2]", 0.0), [2.0, 4.0, 0.0]);
|
||
assert_eq!(f.pos("[10, 20] / 2", 0.0), [5.0, 10.0, 0.0]);
|
||
assert_eq!(f.pos("value - [50, 40]", 0.0), [0.0, 0.0, 0.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn array_precedence_and_unary_minus() {
|
||
let f = fx();
|
||
assert_eq!(f.pos("[1, 1] + [2, 3] * 2", 0.0), [5.0, 7.0, 0.0]);
|
||
assert_eq!(f.pos("([1, 1] + [2, 3]) * 2", 0.0), [6.0, 8.0, 0.0]);
|
||
assert_eq!(f.pos("-value + [100, 100]", 0.0), [50.0, 60.0, 0.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn compound_assignment_on_arrays() {
|
||
let f = fx();
|
||
assert_eq!(f.pos("var p = value;\np += [5, 5];\np", 0.0), [55.0, 45.0, 0.0]);
|
||
assert_eq!(f.pos("var p = [1, 2];\np *= 3;\np", 0.0), [3.0, 6.0, 0.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn string_concatenation_still_works() {
|
||
let f = fx();
|
||
assert_eq!(f.text("'Count: ' + 5", 0.0), "Count: 5");
|
||
assert_eq!(f.text("'a' + 1 + 2", 0.0), "a12");
|
||
assert_eq!(f.text("1 + 2 + 'a'", 0.0), "3a");
|
||
assert_eq!(f.text("'pos ' + [1, 2]", 0.0), "pos 1,2");
|
||
}
|
||
|
||
#[test]
|
||
fn vector_helpers() {
|
||
let f = fx();
|
||
assert_eq!(f.pos("add([1, 2], [3, 4])", 0.0), [4.0, 6.0, 0.0]);
|
||
assert_eq!(f.pos("sub([5, 5], [1, 2])", 0.0), [4.0, 3.0, 0.0]);
|
||
assert_eq!(f.pos("mul([1, 2], 3)", 0.0), [3.0, 6.0, 0.0]);
|
||
assert_eq!(f.pos("div([3, 6], 3)", 0.0), [1.0, 2.0, 0.0]);
|
||
assert_close!(f.num("dot([1, 2, 3], [4, 5, 6])", 0.0), 32.0);
|
||
assert_eq!(f.pos("cross([1, 0, 0], [0, 1, 0])", 0.0), [0.0, 0.0, 1.0]);
|
||
assert_close!(f.num("length([3, 4])", 0.0), 5.0);
|
||
assert_close!(f.num("length([1, 1], [4, 5])", 0.0), 5.0);
|
||
assert_close!(f.num("normalize([0, 10])[1]", 0.0), 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn clamp_scalar_and_array() {
|
||
let f = fx();
|
||
assert_close!(f.num("clamp(150, 0, 100)", 0.0), 100.0);
|
||
assert_close!(f.num("clamp(-5, 0, 100)", 0.0), 0.0);
|
||
assert_eq!(f.pos("clamp([-10, 500], 0, 100)", 0.0), [0.0, 100.0, 0.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn angles_and_look_at() {
|
||
let f = fx();
|
||
assert_close!(f.num("radiansToDegrees(Math.PI)", 0.0), 180.0);
|
||
assert_close!(f.num("degreesToRadians(90)", 0.0), std::f64::consts::FRAC_PI_2);
|
||
let o = f.ev(f.a, "transform/orientation", "lookAt([0, 0, 0], [100, 0, 100])", 0.0).unwrap().as_vec3();
|
||
assert!(close(o[1], 45.0) && close(o[0], 0.0), "{o:?}");
|
||
// After Effects 2026 read-backs of lookAt(position, …) on a layer at [960, 540, 0].
|
||
for (at, want) in [([1500.0, 200.0, 300.0], [48.5763, 49.9802, 319.0442]), ([500.0, 900.0, -400.0], [221.9872, 319.4766, 210.3184])] {
|
||
let e = format!("lookAt([960, 540, 0], [{}, {}, {}])", at[0], at[1], at[2]);
|
||
let o = f.ev(f.a, "transform/orientation", &e, 0.0).unwrap().as_vec3();
|
||
assert!((0..3).all(|i| (o[i] - want[i]).abs() < 1e-3), "{e}: {o:?}");
|
||
}
|
||
}
|
||
|
||
// ---- interpolation ----------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn linear_five_args_maps_and_clamps() {
|
||
let f = fx();
|
||
assert_close!(f.num("linear(time, 0, 2, 0, 100)", 1.0), 50.0);
|
||
assert_close!(f.num("linear(time, 0, 2, 0, 100)", 5.0), 100.0);
|
||
assert_close!(f.num("linear(time, 1, 2, 0, 100)", 0.0), 0.0);
|
||
assert_close!(f.num("linear(time, 2, 0, 0, 100)", 0.5), 75.0);
|
||
}
|
||
|
||
#[test]
|
||
fn linear_three_args() {
|
||
let f = fx();
|
||
assert_close!(f.num("linear(0.25, 10, 20)", 0.0), 12.5);
|
||
}
|
||
|
||
#[test]
|
||
fn ease_curves() {
|
||
let f = fx();
|
||
assert_close!(f.num("ease(time, 0, 1, 0, 100)", 0.5), 50.0);
|
||
assert!(f.num("ease(time, 0, 1, 0, 100)", 0.25) < 25.0);
|
||
assert!(f.num("easeIn(time, 0, 1, 0, 100)", 0.25) < 25.0);
|
||
assert!(f.num("easeOut(time, 0, 1, 0, 100)", 0.25) > 25.0);
|
||
assert_close!(f.num("easeIn(time, 0, 1, 0, 100)", 1.0), 100.0);
|
||
assert_close!(f.num("easeOut(0.5, 0, 10)", 0.0) + f.num("easeIn(0.5, 0, 10)", 0.0), 10.0);
|
||
}
|
||
|
||
#[test]
|
||
fn interpolation_with_arrays() {
|
||
let f = fx();
|
||
assert_eq!(f.pos("linear(time, 0, 1, [0, 0], [100, 50])", 0.5), [50.0, 25.0, 0.0]);
|
||
assert_eq!(f.pos("ease(time, 0, 1, [0, 0], [100, 50])", 1.0), [100.0, 50.0, 0.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn cubic_bezier_curves() {
|
||
let f = fx();
|
||
// ease(t, …) is cubic-bezier(1/3, 0, 2/3, 1): x(u) = u and y(u) = 3u² - 2u³.
|
||
assert_close!(f.num("bezier(0.25, 0, 1, 0, 100, [0.3333333333333333, 0, 0.6666666666666666, 1])", 0.0), f.num("ease(0.25, 0, 1, 0, 100)", 0.0));
|
||
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.3333333333333333, 0, 0.6666666666666666, 1])", 0.0), 50.0);
|
||
// Symmetric ease-in-out: slower at both ends, faster in the middle.
|
||
let early = f.num("bezier(0.25, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0);
|
||
let late = f.num("bezier(0.75, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0);
|
||
assert!(early < 25.0 && late > 75.0, "ease-in-out: {early}, {late}");
|
||
// Control points on the diagonal are linear.
|
||
assert_close!(f.num("bezier(0.3, 0, 1, 0, 100, [0, 0, 1, 1])", 0.0), 30.0);
|
||
// t is clamped to tMin..tMax; tMin == tMax behaves like the other helpers.
|
||
assert_close!(f.num("bezier(-1, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 0.0);
|
||
assert_close!(f.num("bezier(2, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 100.0);
|
||
assert_close!(f.num("bezier(5, 2, 2, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 100.0);
|
||
}
|
||
|
||
#[test]
|
||
fn cubic_bezier_short_form_arrays_and_fallbacks() {
|
||
let f = fx();
|
||
// Short form: no tMin / tMax.
|
||
assert_close!(f.num("bezier(0.25, 10, 20, [0, 0, 1, 1])", 0.0), 12.5);
|
||
assert_eq!(f.pos("bezier(time, 0, 1, [0, 0], [100, 50], [0.42, 0, 0.58, 1])", 1.0), [100.0, 50.0, 0.0]);
|
||
// y outside 0..1 overshoots, as CSS allows.
|
||
let over = f.num("bezier(0.5, 0, 1, 0, 100, [0.5, 2, 0.5, 2])", 0.0);
|
||
assert!(over > 100.0, "overshoot: {over}");
|
||
// A curve that is not four finite numbers falls back to linear instead of throwing.
|
||
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [1, 0])", 0.0), 50.0);
|
||
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, 'ease')", 0.0), 50.0);
|
||
// A missing or unusable curve must not consume the last value: read the curve by position.
|
||
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100)", 0.0), 50.0);
|
||
assert_close!(f.num("bezier(0.5, 0, 100)", 0.0), 50.0);
|
||
assert_close!(f.num("bezier(0.5, 0, 100, [1, 0])", 0.0), 50.0);
|
||
let v = f.ev(f.shape, "contents/#1/contents/fill/color", "bezier(0.5, 0, 1, [0, 0, 0, 1], [1, 1, 1, 1])", 0.0).unwrap();
|
||
assert_eq!(v, Value::Color([0.5, 0.5, 0.5, 1.0]));
|
||
// Exactly four values are a curve; extra ones mean the curve is malformed, so linear.
|
||
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.42, 0, 0.58, 1, 0])", 0.0), 50.0);
|
||
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.42, 0, 0.58, 1, 2, 3])", 0.0), 50.0);
|
||
// x control points outside 0..1 are clamped, as CSS requires.
|
||
let v = f.num("bezier(0.5, 0, 1, 0, 100, [-1, 0, 2, 1])", 0.0);
|
||
assert!(v.is_finite(), "clamped curve: {v}");
|
||
}
|
||
|
||
#[test]
|
||
fn cubic_bezier_is_accurate_near_the_endpoints() {
|
||
/// The value the curve reaches at `x`, by bisection on its monotone x(u).
|
||
fn reference(x1: f64, y1: f64, x2: f64, y2: f64, x: f64) -> f64 {
|
||
let bx = |u: f64| 3.0 * (1.0 - u) * (1.0 - u) * u * x1 + 3.0 * (1.0 - u) * u * u * x2 + u * u * u;
|
||
let by = |u: f64| 3.0 * (1.0 - u) * (1.0 - u) * u * y1 + 3.0 * (1.0 - u) * u * u * y2 + u * u * u;
|
||
let (mut lo, mut hi) = (0.0f64, 1.0f64);
|
||
for _ in 0..100 {
|
||
let mid = 0.5 * (lo + hi);
|
||
if bx(mid) < x {
|
||
lo = mid;
|
||
} else {
|
||
hi = mid;
|
||
}
|
||
}
|
||
by(0.5 * (lo + hi))
|
||
}
|
||
// Steep curves: a residual in x is not a residual in y near the endpoints (dy/dx is in the
|
||
// thousands), so the solver stops on the estimated y error instead.
|
||
let f = fx();
|
||
for (c, x) in [([0.0, 1000.0, 1.0, 1.0], 1e-6), ([0.0, 100.0, 1.0, 0.0], 1e-9), ([0.8, -500.0, 1.0, 1.0], 0.999999)] {
|
||
let got = f.num(&format!("bezier({x}, 0, 1, [{}, {}, {}, {}])", c[0], c[1], c[2], c[3]), 0.0);
|
||
let want = reference(c[0], c[1], c[2], c[3], x);
|
||
assert!((got - want).abs() < 1e-6, "curve {c:?} at {x}: {got} vs {want}");
|
||
}
|
||
}
|
||
|
||
// ---- object model -----------------------------------------------------------------------------
|
||
#[test]
|
||
fn comp_attributes() {
|
||
let f = fx();
|
||
assert_close!(f.num("thisComp.width + thisComp.height", 0.0), 300.0);
|
||
assert_close!(f.num("thisComp.duration", 0.0), 10.0);
|
||
assert_close!(f.num("thisComp.frameDuration * 30", 0.0), 1.0);
|
||
assert_close!(f.num("thisComp.numLayers", 0.0), 4.0);
|
||
assert_eq!(f.text("thisComp.name", 0.0), "Main");
|
||
}
|
||
|
||
#[test]
|
||
fn layer_attributes() {
|
||
let f = fx();
|
||
assert_eq!(f.text("thisLayer.name + ' ' + index", 0.0), "Title 3");
|
||
assert_close!(f.num("index", 0.0), 1.0);
|
||
assert_close!(f.num("inPoint + outPoint + startTime", 0.0), 10.0);
|
||
assert_close!(f.num("width * 1000 + height", 0.0), 20_020.0);
|
||
assert_close!(f.num("thisLayer.hasParent ? 1 : 0", 0.0), 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn layer_by_name_and_index() {
|
||
let f = fx();
|
||
assert_close!(f.num("thisComp.layer(\"B\").transform.position[0]", 0.0), 100.0);
|
||
assert_eq!(f.text("thisComp.layer(2).name", 0.0), "B");
|
||
assert_eq!(f.text("thisComp.layer(thisLayer, 1).name", 0.0), "Shape");
|
||
assert_eq!(f.text("thisComp.layer(thisLayer, -2).name", 0.0), "A");
|
||
}
|
||
|
||
#[test]
|
||
fn cross_layer_position_reference() {
|
||
let f = fx();
|
||
assert_eq!(f.pos("thisComp.layer(\"B\").transform.position", 0.0), [100.0, 50.0, 0.0]);
|
||
assert_eq!(f.pos("thisComp.layer(\"B\").position + [0, 10]", 0.0), [100.0, 60.0, 0.0]);
|
||
assert_close!(f.num("thisComp.layer('B').transform.scale[1]", 0.0), 100.0);
|
||
assert_close!(f.num("thisComp.layer('B').transform.opacity", 0.0), 100.0);
|
||
}
|
||
|
||
#[test]
|
||
fn layer_shorthand_attributes() {
|
||
let f = fx();
|
||
assert_close!(f.num("position[0] + anchorPoint[0]", 0.0), 60.0);
|
||
assert_close!(f.num("scale[0] + opacity", 0.0), 200.0);
|
||
assert_close!(f.num("transform.anchorPoint[1]", 0.0), 10.0);
|
||
}
|
||
|
||
#[test]
|
||
fn reading_a_property_that_has_an_expression() {
|
||
let mut f = fx();
|
||
f.set_expr(f.b, ROT, "time * 10");
|
||
assert_close!(f.num("thisComp.layer('B').transform.rotation + 1", 2.0), 21.0);
|
||
// Chains: B.opacity reads B.rotation (expression), A reads B.opacity.
|
||
f.set_expr(f.b, OPA, "transform.rotation * 2");
|
||
assert_close!(f.num("thisComp.layer('B').transform.opacity", 1.0), 20.0);
|
||
}
|
||
|
||
#[test]
|
||
fn own_property_reads_pre_expression_value() {
|
||
let f = fx();
|
||
assert_close!(f.num("transform.rotation + 1", 0.5), 46.0);
|
||
assert_close!(f.num("thisProperty.value * 2", 1.0), 180.0);
|
||
}
|
||
|
||
#[test]
|
||
fn circular_references_terminate() {
|
||
let mut f = fx();
|
||
f.set_expr(f.a, OPA, "thisComp.layer('B').transform.opacity");
|
||
f.set_expr(f.b, OPA, "thisComp.layer('A').transform.opacity");
|
||
let start = Instant::now();
|
||
let r = eval_property(&f.p, f.cid, f.a, OPA, 0.0);
|
||
// Only a hang guard (the budgets stop it far sooner); generous for a loaded machine.
|
||
assert!(start.elapsed().as_secs_f64() < 100.0, "{:?}", start.elapsed());
|
||
// The cycle is caught when A needs itself again, and falls back to keyframed values.
|
||
assert!(matches!(r, Ok(Value::Scalar(_))) || r.is_err(), "{r:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn an_expression_needing_its_own_value_fails_fast() {
|
||
// Found by the expression fuzzer (#786): rotation's `toWorld` reads the layer's transform,
|
||
// which evaluates rotation's expression again, twice per level (layer-to-comp and world
|
||
// matrices), so the cycle nested to `MAX_DEPTH` 2^16 times and the frame took 20 s.
|
||
let mut f = fx();
|
||
f.set_expr(f.a, ROT, "fromWorld(toWorld(anchorPoint))[0]");
|
||
let start = Instant::now();
|
||
let r = eval_property(&f.p, f.cid, f.a, ROT, 0.5);
|
||
assert!(start.elapsed().as_secs_f64() < 100.0, "{:?}", start.elapsed());
|
||
// The inner read falls back to the keyframed rotation, as in a standalone evaluation.
|
||
assert_eq!(r, f.ev(f.a, ROT, "fromWorld(toWorld(anchorPoint))[0]", 0.5));
|
||
}
|
||
|
||
#[test]
|
||
fn fanning_out_reads_stop_at_a_budget() {
|
||
// Two layers reading each other twice at other times: no cycle (the times differ), but
|
||
// 2^16 evaluations at `MAX_DEPTH`; the evaluation stops after `MAX_RUNS` script runs.
|
||
let mut f = fx();
|
||
let text = |other: &str| format!("var r = thisComp.layer('{other}').transform.rotation; r.valueAtTime(time + 0.001) + r.valueAtTime(time + 0.002)");
|
||
f.set_expr(f.a, ROT, &text("B"));
|
||
f.set_expr(f.b, ROT, &text("A"));
|
||
// Take the clock out of the race: on a loaded machine the time budget could otherwise stop
|
||
// the evaluation before the run count does, with a different error.
|
||
crate::runtime::TIME_LIMIT.with(|t| t.set(std::time::Duration::from_secs(3600)));
|
||
let start = Instant::now();
|
||
let r = eval_property(&f.p, f.cid, f.a, ROT, 0.5);
|
||
crate::runtime::TIME_LIMIT.with(|t| t.set(crate::MAX_TIME));
|
||
assert!(start.elapsed().as_secs_f64() < 300.0, "{:?}", start.elapsed());
|
||
assert!(r.unwrap_err().contains("too many"));
|
||
// The budget is per outermost evaluation: the next one starts afresh.
|
||
assert_close!(f.num("time * 2", 1.0), 2.0);
|
||
assert_close!(f.ev(f.b, OPA, "thisComp.layer('A').transform.opacity", 0.0).unwrap().as_f64(), 100.0);
|
||
}
|
||
|
||
#[test]
|
||
fn loops_stop_at_a_million_iterations() {
|
||
// Found by the expression fuzzer (#786): `for (…; i < 1e15; …)` ran ten million iterations
|
||
// per run (seconds), once more for every property the script reads after it.
|
||
let f = fx();
|
||
assert_close!(f.num("var n = 0; while (n < 900000) n++; n", 0.0), 900_000.0);
|
||
assert!(f.err("var n = 0; while (n < 2000000) n++; n").contains("iteration"));
|
||
assert!(f.err("for (;;) {}").contains("iteration"));
|
||
}
|
||
|
||
#[test]
|
||
fn a_long_running_script_stops_at_the_time_budget() {
|
||
// Found by the expression fuzzer (#786): `smooth(NaN, 1e9)` made a million host reads in
|
||
// one run (tens of seconds), and the loop limit counts per function call, so loops calling
|
||
// functions that loop run for hours. The run is now abandoned after `MAX_TIME`.
|
||
let f = fx();
|
||
let start = Instant::now();
|
||
let e = f.err("function g() { for (var i = 0; i < 900000; i++) {} }\nfor (var k = 0; k < 900000; k++) g()");
|
||
assert!(e.contains("too long"), "{e}");
|
||
assert!(start.elapsed() < crate::MAX_TIME * 12, "{:?}", start.elapsed());
|
||
// The thread's runtime starts afresh.
|
||
assert_close!(f.num("time * 2", 1.0), 2.0);
|
||
}
|
||
|
||
#[test]
|
||
fn a_path_with_non_numeric_points_is_an_error() {
|
||
// Found by the expression fuzzer (#786): an array in a vertex became NaN and tripped the
|
||
// rasterizer's clipping assertion when the mask was drawn.
|
||
let mut f = fx();
|
||
let text = "createPath([[0, 0], [[1, 2] * 2, 0], [100, 100]], [], [], true)";
|
||
assert!(f.ev(f.a, "masks/#1/path", text, 0.0).unwrap_err().contains("finite"));
|
||
assert!(f.ev(f.a, "masks/#1/path", "createPath([[0, 0], [1e400, 0], [100, 100]], [], [], true)", 0.0).is_err());
|
||
f.set_expr(f.a, "masks/#1/path", text);
|
||
let host = Expressions;
|
||
let mut r = Renderer::new(&f.p, &NoFootage, RenderOpts { motion_blur: false, ..Default::default() });
|
||
r.expr = Some(&host);
|
||
r.comp_frame(f.cid, Tick::ZERO);
|
||
assert!(f.ev(f.a, "masks/#1/path", "createPath([[0, 0], [10, 0], [10, 10]], [], [], true)", 0.0).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn errors_and_property_reads_keep_the_runtime_usable() {
|
||
// Errors thrown inside helpers, and the aborts every property read makes until the host
|
||
// answers, must not use up the thread's runtime (it used to fail every expression after a
|
||
// few hundred with "reached the maximum stack size").
|
||
let f = fx();
|
||
for _ in 0..3000 {
|
||
assert!(f.ev(f.b, ROT, "thisComp.layer('Ghost').position", 0.0).is_err());
|
||
}
|
||
for i in 0..1000 {
|
||
let t = i as f64 / 1000.0;
|
||
assert_close!(f.ev(f.b, ROT, "thisComp.layer('A').transform.rotation", t).unwrap().as_f64(), 90.0 * t);
|
||
}
|
||
assert_close!(f.num("time * 2", 1.0), 2.0);
|
||
}
|
||
|
||
#[test]
|
||
fn effect_slider_reference() {
|
||
let f = fx();
|
||
assert_close!(f.num("effect(\"Slider Control\")(\"Slider\")", 0.0), 42.0);
|
||
assert_close!(f.num("effect('Slider Control')('Slider') * 2", 0.0), 84.0);
|
||
assert_close!(f.num("thisLayer.effect('Slider Control').param('Slider')", 0.0), 42.0);
|
||
assert_close!(f.num("effect(1).param(1) + effect(1)(1)", 0.0), 84.0);
|
||
assert_close!(f.num("thisComp.layer('A').effect('Slider Control')('Slider').value", 0.0), 42.0);
|
||
assert_close!(f.ev(f.b, ROT, "thisComp.layer('A').effect('Slider Control')('Slider')", 0.0).unwrap().as_f64(), 42.0);
|
||
}
|
||
|
||
#[test]
|
||
fn effect_slider_with_expression_drives_another_layer() {
|
||
let mut f = fx();
|
||
f.set_expr(f.a, "effects/#1/slider", "time * 5");
|
||
assert_close!(f.ev(f.b, ROT, "thisComp.layer('A').effect('Slider Control')('Slider')", 2.0).unwrap().as_f64(), 10.0);
|
||
}
|
||
|
||
#[test]
|
||
fn missing_effect_is_an_error() {
|
||
let f = fx();
|
||
let e = f.err("effect('Nope')('Slider')");
|
||
assert!(e.contains("Nope"), "{e}");
|
||
}
|
||
|
||
#[test]
|
||
fn group_names_and_counts() {
|
||
let f = fx();
|
||
assert_eq!(f.text("thisComp.layer('A').effect(1).name", 0.0), "Slider Control");
|
||
assert_close!(f.ev(f.title, ROT, "thisComp.layer('A').transform.numProperties", 0.0).unwrap().as_f64(), 8.0);
|
||
}
|
||
|
||
#[test]
|
||
fn mask_properties() {
|
||
let f = fx();
|
||
assert_close!(f.num("mask('Mask 1').maskOpacity", 0.0), 100.0);
|
||
assert_close!(f.num("mask('Mask 1').maskExpansion + 1", 0.0), 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn shape_content_paths() {
|
||
let f = fx();
|
||
let v = f.ev(f.shape, ROT, "content('Rectangle 1').content('Rectangle Path 1').size[0]", 0.0).unwrap();
|
||
assert_close!(v.as_f64(), 60.0);
|
||
let c = f.ev(f.shape, ROT, "content('Rectangle 1').content('Fill 1').color[0] * 10", 0.0).unwrap();
|
||
assert_close!(c.as_f64(), 10.0);
|
||
}
|
||
|
||
#[test]
|
||
fn shape_fill_color_expression() {
|
||
let f = fx();
|
||
let path = "contents/#1/contents/fill/color";
|
||
let v = f.ev(f.shape, path, "[0, 1, 0]", 0.0).unwrap();
|
||
assert_eq!(v, Value::Color([0.0, 1.0, 0.0, 1.0]));
|
||
}
|
||
|
||
#[test]
|
||
fn comp_by_name() {
|
||
let f = fx();
|
||
assert_eq!(f.text("comp('Other').layer(1).name", 0.0), "Inner");
|
||
assert_close!(f.num("comp('Other').width", 0.0), 64.0);
|
||
assert!(f.err("comp('Missing').width").contains("Missing"));
|
||
}
|
||
|
||
#[test]
|
||
fn parent_attributes() {
|
||
let mut f = fx();
|
||
let b = f.b;
|
||
f.layer_mut(f.a).parent = Some(b);
|
||
assert_eq!(f.text("thisComp.layer('A').parent.name", 0.0), "B");
|
||
assert_close!(f.num("hasParent ? parent.transform.position[0] : -1", 0.0), 100.0);
|
||
}
|
||
|
||
#[test]
|
||
fn markers() {
|
||
let f = fx();
|
||
assert_close!(f.num("marker.numKeys", 0.0), 2.0);
|
||
assert_close!(f.num("marker.key(2).time", 0.0), 4.0);
|
||
assert_eq!(f.text("thisComp.layer('A').marker.key(1).comment", 0.0), "beat");
|
||
assert_close!(f.num("marker.key('drop').time", 0.0), 4.0);
|
||
assert_close!(f.num("marker.nearestKey(time).index", 3.5), 2.0);
|
||
assert_close!(f.num("thisComp.marker.key(1).time", 0.0), 1.5);
|
||
}
|
||
|
||
#[test]
|
||
fn layer_markers_follow_start_time() {
|
||
let mut f = fx();
|
||
f.layer_mut(f.a).start_time = secs(1.0);
|
||
assert_close!(f.num("marker.key(1).time", 0.0), 3.0);
|
||
}
|
||
|
||
// ---- keyframes, time ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn value_at_time() {
|
||
let f = fx();
|
||
assert_close!(f.num("valueAtTime(0.5)", 0.0), 45.0);
|
||
assert_close!(f.num("thisProperty.valueAtTime(time - 0.5)", 1.0), 45.0);
|
||
assert_close!(f.ev(f.b, ROT, "thisComp.layer('A').transform.rotation.valueAtTime(0.25)", 0.0).unwrap().as_f64(), 22.5);
|
||
}
|
||
|
||
#[test]
|
||
fn value_at_time_respects_layer_start_time() {
|
||
let mut f = fx();
|
||
f.layer_mut(f.a).start_time = secs(1.0);
|
||
// Keys at layer time 0 and 1 → comp time 1 and 2.
|
||
assert_close!(f.num("valueAtTime(1.5)", 0.0), 45.0);
|
||
assert_close!(f.num("key(1).time", 0.0), 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn velocity_and_speed() {
|
||
let f = fx();
|
||
assert_close!(f.num("velocityAtTime(0.5)", 0.0), 90.0, 1e-3);
|
||
assert_close!(f.num("velocity", 0.5), 90.0, 1e-3);
|
||
assert_close!(f.num("speedAtTime(0.5)", 0.0), 90.0, 1e-3);
|
||
assert_close!(f.num("thisComp.layer('B').transform.position.speed", 0.5), 0.0, 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn keys_and_nearest_key() {
|
||
let f = fx();
|
||
assert_close!(f.num("numKeys", 0.0), 2.0);
|
||
assert_close!(f.num("key(2).time * 1000 + key(2).value", 0.0), 1090.0);
|
||
assert_close!(f.num("nearestKey(0.8).index", 0.0), 2.0);
|
||
assert_close!(f.num("nearestKey(0.2).value", 0.0), 0.0);
|
||
assert!(f.err("key(3).time").contains("out of range"));
|
||
}
|
||
|
||
#[test]
|
||
fn loop_out_cycle() {
|
||
let f = fx();
|
||
assert_close!(f.num("loopOut('cycle')", 1.25), 22.5);
|
||
assert_close!(f.num("loopOut()", 2.5), 45.0);
|
||
assert_close!(f.num("loopOut('cycle')", 0.5), 45.0);
|
||
}
|
||
|
||
#[test]
|
||
fn loop_out_pingpong() {
|
||
let f = fx();
|
||
assert_close!(f.num("loopOut('pingpong')", 1.25), 67.5);
|
||
assert_close!(f.num("loopOut('pingpong')", 2.25), 22.5);
|
||
}
|
||
|
||
#[test]
|
||
fn loop_out_offset() {
|
||
let f = fx();
|
||
assert_close!(f.num("loopOut('offset')", 1.5), 135.0);
|
||
assert_close!(f.num("loopOut('offset')", 3.5), 315.0);
|
||
}
|
||
|
||
#[test]
|
||
fn loop_out_continue() {
|
||
let f = fx();
|
||
assert_close!(f.num("loopOut('continue')", 2.0), 180.0, 1e-3);
|
||
}
|
||
|
||
#[test]
|
||
fn loop_in_cycle_and_offset() {
|
||
let mut f = fx();
|
||
f.layer_mut(f.a).props.prop_mut(ROT).unwrap().keys = vec![Keyframe::new(secs(1.0), Value::Scalar(0.0)), Keyframe::new(secs(2.0), Value::Scalar(100.0))];
|
||
assert_close!(f.num("loopIn('cycle')", 0.25), 25.0);
|
||
assert_close!(f.num("loopIn('offset')", 0.25), -75.0);
|
||
assert_close!(f.num("loopIn('pingpong')", 0.75), 25.0);
|
||
assert_close!(f.num("loopIn('cycle')", 1.5), 50.0);
|
||
}
|
||
|
||
#[test]
|
||
fn loop_out_num_keyframes_and_duration() {
|
||
let mut f = fx();
|
||
let keys =
|
||
vec![Keyframe::new(secs(0.0), Value::Scalar(0.0)), Keyframe::new(secs(1.0), Value::Scalar(100.0)), Keyframe::new(secs(2.0), Value::Scalar(200.0))];
|
||
f.layer_mut(f.a).props.prop_mut(ROT).unwrap().keys = keys;
|
||
// Only the last segment (1 s → 2 s) loops.
|
||
assert_close!(f.num("loopOut('cycle', 1)", 2.5), 150.0);
|
||
assert_close!(f.num("loopOut('cycle', 0)", 2.5), 50.0);
|
||
assert_close!(f.num("loopOutDuration('cycle', 0.5)", 2.25), 175.0);
|
||
}
|
||
|
||
#[test]
|
||
fn loop_on_two_dimensional_position() {
|
||
let mut f = fx();
|
||
f.layer_mut(f.a).props.prop_mut(POS).unwrap().keys =
|
||
vec![Keyframe::new(secs(0.0), Value::Vec3([0.0, 0.0, 0.0])), Keyframe::new(secs(1.0), Value::Vec3([100.0, 50.0, 0.0]))];
|
||
let p = f.pos("loopOut('offset')", 1.5);
|
||
assert!(p.iter().zip([150.0, 75.0, 0.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{p:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn smooth_on_linear_ramp_is_identity() {
|
||
let f = fx();
|
||
assert_close!(f.num("smooth(0.2, 5)", 0.5), 45.0, 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn time_conversions() {
|
||
let f = fx();
|
||
assert_close!(f.num("timeToFrames()", 2.0), 60.0);
|
||
assert_close!(f.num("timeToFrames(1, 24)", 0.0), 24.0);
|
||
assert_close!(f.num("framesToTime(45)", 0.0), 1.5);
|
||
assert_eq!(f.text("timeToTimecode()", 61.5), "0:01:01:15");
|
||
assert_eq!(f.text("timeToCurrentFormat(2.5)", 0.0), "0:00:02:15");
|
||
}
|
||
|
||
#[test]
|
||
fn posterize_time() {
|
||
let f = fx();
|
||
assert_close!(f.num("posterizeTime(2); time", 0.7), 0.5);
|
||
assert_close!(f.num("posterizeTime(2); value", 0.7), 45.0);
|
||
}
|
||
|
||
// ---- wiggle / random / noise ------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn wiggle_is_deterministic() {
|
||
let f = fx();
|
||
let a = f.pos("wiggle(3, 25)", 1.234);
|
||
let b = f.pos("wiggle(3, 25)", 1.234);
|
||
assert_eq!(a, b);
|
||
let g = fx();
|
||
assert_eq!(g.pos("wiggle(3, 25)", 1.234), a);
|
||
let other_thread = std::thread::spawn(move || g.pos("wiggle(3, 25)", 1.234)).join().unwrap();
|
||
assert_eq!(other_thread, a);
|
||
}
|
||
|
||
#[test]
|
||
fn wiggle_amplitude_bounds_and_motion() {
|
||
let f = fx();
|
||
let mut seen = Vec::new();
|
||
for i in 0..120 {
|
||
let p = f.pos("wiggle(5, 30)", i as f64 / 30.0);
|
||
assert!((p[0] - 50.0).abs() <= 30.0 + 1e-9 && (p[1] - 40.0).abs() <= 30.0 + 1e-9, "{p:?}");
|
||
assert_eq!(p[2], 0.0, "2D layers keep z");
|
||
seen.push(p[0]);
|
||
}
|
||
let spread = seen.iter().cloned().fold(f64::MIN, f64::max) - seen.iter().cloned().fold(f64::MAX, f64::min);
|
||
assert!(spread > 10.0, "wiggle should move: spread {spread}");
|
||
}
|
||
|
||
#[test]
|
||
fn wiggle_octaves_bound() {
|
||
let f = fx();
|
||
for i in 0..60 {
|
||
let r = f.num("wiggle(2, 10, 3, 0.5) - value", i as f64 / 10.0);
|
||
assert!(r.abs() <= 10.0 * 1.75 + 1e-9, "{r}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn wiggle_differs_per_layer_and_dimension() {
|
||
let f = fx();
|
||
let a = f.ev(f.a, OPA, "wiggle(2, 20)", 0.7).unwrap().as_f64();
|
||
let b = f.ev(f.b, OPA, "wiggle(2, 20)", 0.7).unwrap().as_f64();
|
||
assert_ne!(a, b);
|
||
let p = f.pos("wiggle(2, 20) - value", 0.7);
|
||
assert_ne!(p[0], p[1]);
|
||
}
|
||
|
||
#[test]
|
||
fn wiggle_on_other_layer_property() {
|
||
let f = fx();
|
||
let v = f.num("thisComp.layer('B').transform.rotation.wiggle(1, 5)", 0.3);
|
||
assert!(v.abs() <= 5.0 && v != 0.0, "{v}");
|
||
}
|
||
|
||
#[test]
|
||
fn temporal_wiggle_stays_on_the_curve() {
|
||
let f = fx();
|
||
for i in 0..10 {
|
||
let v = f.num("temporalWiggle(2, 0.1)", 0.2 + i as f64 * 0.05);
|
||
assert!((0.0..=90.0).contains(&v), "{v}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn random_is_seeded_and_in_range() {
|
||
let f = fx();
|
||
let a = f.num("random()", 0.5);
|
||
assert!((0.0..1.0).contains(&a));
|
||
assert_eq!(a, f.num("random()", 0.5));
|
||
assert_ne!(a, f.num("random()", 0.6));
|
||
let r = f.num("random(10, 20)", 0.0);
|
||
assert!((10.0..20.0).contains(&r));
|
||
let p = f.pos("random([100, 50])", 0.0);
|
||
assert!((0.0..100.0).contains(&p[0]) && (0.0..50.0).contains(&p[1]));
|
||
assert_ne!(f.num("random() - random()", 0.0), 0.0, "successive calls differ");
|
||
}
|
||
|
||
#[test]
|
||
fn seed_random_timeless() {
|
||
let f = fx();
|
||
let a = f.num("seedRandom(5, true); random(100)", 0.1);
|
||
let b = f.num("seedRandom(5, true); random(100)", 3.7);
|
||
assert_eq!(a, b);
|
||
assert_ne!(a, f.num("seedRandom(6, true); random(100)", 0.1));
|
||
}
|
||
|
||
#[test]
|
||
fn gauss_random_distribution() {
|
||
let f = fx();
|
||
let e =
|
||
"var s = 0, n = 2000, inside = 0;\nfor (var i = 0; i < n; i++) { var g = gaussRandom(); s += g; if (g >= 0 && g <= 1) inside++; }\n[s / n, inside / n]";
|
||
let r = f.pos(e, 0.0);
|
||
assert!((r[0] - 0.5).abs() < 0.05, "mean {}", r[0]);
|
||
assert!((0.85..0.95).contains(&r[1]), "inside {}", r[1]);
|
||
}
|
||
|
||
#[test]
|
||
fn noise_is_smooth_and_bounded() {
|
||
let f = fx();
|
||
let a = f.num("noise(time)", 1.0);
|
||
let b = f.num("noise(time)", 1.001);
|
||
assert!(a.abs() <= 1.0 && (a - b).abs() < 0.01);
|
||
assert!(f.num("noise([1.5, 2.5, 3.5])", 0.0).abs() <= 1.0);
|
||
}
|
||
|
||
// ---- text -------------------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn source_text_from_expression_keeps_style() {
|
||
let f = fx();
|
||
let v = f.ev(f.title, "text/sourceText", "'Frame ' + timeToFrames()", 1.0).unwrap();
|
||
let Value::Text(doc) = v else { panic!() };
|
||
assert_eq!(doc.text, "Frame 30");
|
||
assert_eq!(doc.size, 40.0, "style from the keyframed document");
|
||
}
|
||
|
||
#[test]
|
||
fn source_text_reading_and_string_methods() {
|
||
let f = fx();
|
||
assert_eq!(f.text("value + '!'", 0.0), "Hello!");
|
||
assert_eq!(f.text("thisComp.layer('Title').text.sourceText + ' world'", 0.0), "Hello world");
|
||
assert_eq!(f.text("text.sourceText.toUpperCase()", 0.0), "HELLO");
|
||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.length", 0.0), 5.0);
|
||
}
|
||
|
||
#[test]
|
||
fn source_text_style_api() {
|
||
let mut f = fx();
|
||
let doc = |f: &Fx, e: &str| -> TextDoc {
|
||
match f.ev(f.title, "text/sourceText", e, 0.0) {
|
||
Ok(Value::Text(d)) => *d,
|
||
r => panic!("{e}: {r:?}"),
|
||
}
|
||
};
|
||
// Getters read the first character's style.
|
||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.fontSize", 0.0), 40.0);
|
||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.isAllCaps ? 1 : 0", 0.0), 0.0);
|
||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.autoLeading ? 1 : 0", 0.0), 1.0);
|
||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.leading", 0.0), 48.0);
|
||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.fillColor[1]", 0.0), 1.0);
|
||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.setFontSize(12).fontSize", 0.0), 12.0);
|
||
// Setters chain and return a styled document.
|
||
let d = doc(&f, "text.sourceText.style.setFontSize(80).setFillColor([1, 0, 0]).setAllCaps(true)");
|
||
assert_eq!(d.text, "Hello");
|
||
assert!(d.is_uniform());
|
||
assert_eq!((d.size, d.fill, d.all_caps), (80.0, [1.0, 0.0, 0.0, 1.0], true));
|
||
// Character ranges create runs; setText replaces the text.
|
||
let d = doc(&f, "text.sourceText.style.setText('Hi there').setFontSize(20, 3, 5).setFauxBold(true, 0, 2)");
|
||
assert_eq!(d.text, "Hi there");
|
||
let sizes: Vec<(usize, f64, bool)> = d.runs().iter().map(|r| (r.len, r.style.size, r.style.faux_bold)).collect();
|
||
assert_eq!(sizes, vec![(2, 40.0, true), (1, 40.0, false), (5, 20.0, false)]);
|
||
// getStyleAt reads a styled document per character (and at a time).
|
||
{
|
||
let l = f.layer_mut(f.title);
|
||
let p = l.props.prop_mut("text/sourceText").unwrap();
|
||
let Value::Text(d) = &mut p.value else { panic!() };
|
||
d.apply_style(1..3, |s| s.size = 99.0);
|
||
}
|
||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.getStyleAt(2, 0).fontSize", 0.0), 99.0);
|
||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.getStyleAt(4).fontSize", 0.0), 40.0);
|
||
// Returning the own style keeps its runs; plain strings keep the first character's style.
|
||
let d = doc(&f, "text.sourceText.style");
|
||
assert_eq!(d.runs().len(), 3);
|
||
let d = doc(&f, "'Bye'");
|
||
assert_eq!((d.text.as_str(), d.size), ("Bye", 40.0));
|
||
// Paragraph setters.
|
||
let d = doc(&f, "text.sourceText.style.setJustification('CENTER_JUSTIFY').setStartIndent(12).setSpaceBefore(4).setEveryLineComposer(false)");
|
||
assert_eq!(d.justify, effectcraft_keyframe::Justify::Center);
|
||
assert_eq!((d.indent_left, d.space_before), (12.0, 4.0));
|
||
assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.setStartIndent(7).startIndent", 0.0), 7.0);
|
||
// createStyle sets only what's given and keeps the existing runs.
|
||
let d = doc(&f, "thisComp.layer('Title').text.sourceText.createStyle().setTsume(50).setBaselineOption('superscript')");
|
||
assert_eq!(d.text, "Hello");
|
||
assert!(d.runs().iter().all(|r| r.style.tsume == 50.0 && r.style.baseline == effectcraft_keyframe::BaselineOption::Superscript));
|
||
assert_eq!(d.runs().len(), 3);
|
||
// Non-text properties have no style.
|
||
assert!(f.ev(f.a, ROT, "transform.rotation.style.fontSize", 0.0).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn numbers_to_source_text() {
|
||
let f = fx();
|
||
assert_eq!(f.text("Math.round(time * 10) / 10", 1.25), "1.3");
|
||
assert_eq!(f.text("(time * 2).toFixed(2)", 1.0), "2.00");
|
||
assert_eq!(f.text("12", 0.0), "12");
|
||
}
|
||
|
||
// ---- space conversions ------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn to_comp_and_from_comp() {
|
||
let f = fx();
|
||
// A: 20×20 solid, anchor (10, 10), position (50, 40).
|
||
assert_eq!(f.pos("toComp([0, 0])", 0.0), [40.0, 30.0, 0.0]);
|
||
assert_eq!(f.pos("fromComp([50, 40])", 0.0), [10.0, 10.0, 0.0]);
|
||
// Rotation keyed to 90° at 1 s.
|
||
let p = f.pos("toComp([20, 10])", 1.0);
|
||
assert!(close(p[0], 50.0) && close(p[1], 50.0), "{p:?}");
|
||
let q = f.pos("toWorld([10, 10, 0])", 0.0);
|
||
assert!(close(q[0], 50.0) && close(q[1], 40.0), "{q:?}");
|
||
let v = f.pos("toCompVec([1, 0])", 1.0);
|
||
assert!(close(v[0], 0.0) && close(v[1], 1.0), "{v:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn source_rect_at_time() {
|
||
let f = fx();
|
||
assert_eq!(f.pos("var r = sourceRectAtTime(); [r.width, r.height]", 0.0), [20.0, 20.0, 0.0]);
|
||
let t = f.ev(f.title, POS, "var r = sourceRectAtTime(); [r.width, r.height]", 0.0).unwrap().as_vec3();
|
||
assert!(t[0] > 40.0 && t[1] > 15.0, "{t:?}");
|
||
let s = f.ev(f.shape, POS, "var r = sourceRectAtTime(); [r.left, r.top, r.width]", 0.0).unwrap().as_vec3();
|
||
assert!((s[0] + 31.0).abs() < 2.0 && (s[1] + 16.0).abs() < 2.0 && (s[2] - 62.0).abs() < 3.0, "{s:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn source_dimensions_follow_media_replacement() {
|
||
let mut p = Project::default();
|
||
let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||
let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||
let footage = |p: &mut Project, name: &str, width, height| {
|
||
p.add_item(
|
||
name,
|
||
Label::SeaFoam,
|
||
None,
|
||
ItemKind::Footage(effectcraft_project::Footage {
|
||
path: name.into(),
|
||
kind: effectcraft_project::FootageKind::Still,
|
||
width,
|
||
height,
|
||
has_video: true,
|
||
..Default::default()
|
||
}),
|
||
)
|
||
};
|
||
let original = footage(&mut p, "original.png", 100, 80);
|
||
let replacement = footage(&mut p, "replacement.png", 30, 40);
|
||
let mut l = build::layer(&mut p, &comp, "Clip", LayerSource::Footage { item: original }, (100, 80), None);
|
||
l.props.prop_mut(ROT).unwrap().expr =
|
||
Some(Expression { text: "thisLayer.width + thisLayer.height + thisLayer.source.width + sourceRectAtTime().width".into(), enabled: true });
|
||
let lid = l.id;
|
||
p.comp_mut(cid).unwrap().layers.push(l);
|
||
let c = p.comp(cid).unwrap();
|
||
let l = c.layer(lid).unwrap();
|
||
let media = effectcraft_render::MediaOverride::new(lid, replacement);
|
||
let ctx = EvalCtx {
|
||
project: &p,
|
||
comp_id: cid,
|
||
comp: c,
|
||
time: Tick::ZERO,
|
||
expr: Some(&Expressions),
|
||
footage: None,
|
||
property_overrides: &[],
|
||
media_overrides: &[media],
|
||
reads: None,
|
||
};
|
||
assert_eq!(ctx.value(l, l.props.prop(ROT).unwrap()), Value::Scalar(130.0));
|
||
}
|
||
|
||
// ---- colour -----------------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn colour_conversions() {
|
||
let f = fx();
|
||
let hsl = f.pos("rgbToHsl([1, 0, 0, 1])", 0.0);
|
||
assert_eq!(hsl, [0.0, 1.0, 0.5]);
|
||
let back = f.pos("hslToRgb([2 / 3, 1, 0.5, 1])", 0.0);
|
||
assert!(close(back[0], 0.0) && close(back[1], 0.0) && close(back[2], 1.0), "{back:?}");
|
||
let hex = f.pos("hexToRgb('#FF8000')", 0.0);
|
||
assert!(close(hex[0], 1.0) && close(hex[1], 128.0 / 255.0) && close(hex[2], 0.0), "{hex:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn colour_result_keeps_alpha() {
|
||
let f = fx();
|
||
let path = "contents/#1/contents/fill/color";
|
||
let v = f.ev(f.shape, path, "hslToRgb([0, 0, 0.5])", 0.0).unwrap();
|
||
assert_eq!(v, Value::Color([0.5, 0.5, 0.5, 1.0]));
|
||
}
|
||
|
||
// ---- result conversion and errors -------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn checkbox_and_dropdown_results() {
|
||
let mut f = fx();
|
||
let spec = effectcraft_effects::find("ec.control.checkbox").unwrap();
|
||
let cb = group_push(&mut f.p, |ids| effectcraft_effects::instantiate(spec, ids, "Checkbox Control", [20.0, 20.0]));
|
||
let spec = effectcraft_effects::find("ec.control.dropdown").unwrap();
|
||
let dd = group_push(&mut f.p, |ids| effectcraft_effects::instantiate(spec, ids, "Dropdown Menu Control", [20.0, 20.0]));
|
||
let fxg = f.layer_mut(f.b).props.sub_mut("effects").unwrap();
|
||
fxg.children.push(cb.into());
|
||
fxg.children.push(dd.into());
|
||
assert_eq!(f.ev(f.b, "effects/#1/checkbox", "time > 1", 2.0).unwrap(), Value::Bool(true));
|
||
assert_eq!(f.ev(f.b, "effects/#1/checkbox", "0", 2.0).unwrap(), Value::Bool(false));
|
||
assert_eq!(f.ev(f.b, "effects/#2/menu", "3", 0.0).unwrap(), Value::Enum(2));
|
||
assert_close!(f.ev(f.b, ROT, "effect('Dropdown Menu Control')('Menu') * 10", 0.0).unwrap().as_f64(), 10.0);
|
||
}
|
||
|
||
#[test]
|
||
fn syntax_error_reports_line() {
|
||
let f = fx();
|
||
let e = f.err("var a = 1;\nvar b = ;\na");
|
||
assert!(e.contains("line 2"), "{e}");
|
||
assert!(check_syntax("var a = (1;").is_err());
|
||
assert!(check_syntax("value + [1, 2]").is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_error_reports_line() {
|
||
let f = fx();
|
||
let e = f.err("var a = 1;\nfoo.bar + a");
|
||
assert!(e.contains("foo"), "{e}");
|
||
assert!(e.contains("line 2"), "{e}");
|
||
}
|
||
|
||
#[test]
|
||
fn thrown_errors_and_missing_layers() {
|
||
let f = fx();
|
||
assert!(f.err("throw new Error('custom failure')").contains("custom failure"));
|
||
assert!(f.err("thisComp.layer('Ghost').position").contains("Ghost"));
|
||
assert!(f.err("thisComp.layer(99).name").contains("99"));
|
||
}
|
||
|
||
#[test]
|
||
fn result_kind_errors() {
|
||
let f = fx();
|
||
assert!(f.ev(f.a, POS, "5", 0.0).unwrap_err().contains("dimension"));
|
||
assert!(f.err("'abc'").contains("number"));
|
||
assert!(f.err("var x = 1").contains("undefined"));
|
||
assert!(f.err("0 / 0").contains("finite"));
|
||
}
|
||
|
||
#[test]
|
||
fn shadowing_layer_attribute_names_is_local() {
|
||
let f = fx();
|
||
assert_close!(f.num("var width = 3;\nwidth * 2", 0.0), 6.0);
|
||
assert_close!(f.num("width", 0.0), 20.0, 1e-9);
|
||
assert_close!(f.num("function width() { return 4; }\nwidth() * 2", 0.0), 8.0);
|
||
assert_close!(f.num("width", 0.0), 20.0, 1e-9);
|
||
}
|
||
|
||
#[test]
|
||
fn try_catch_does_not_swallow_host_reads() {
|
||
let f = fx();
|
||
assert_close!(f.num("try { thisComp.layer('B').transform.position[0] } catch (e) { -1 }", 0.0), 100.0);
|
||
assert_close!(f.num("try { thisComp.layer('Ghost').name; 1 } catch (e) { -1 }", 0.0), -1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn loops_over_layers() {
|
||
let f = fx();
|
||
let e = "var s = 0;\nfor (var i = 1; i <= thisComp.numLayers; i++) s += thisComp.layer(i).transform.position[0];\ns";
|
||
assert_close!(f.num(e, 0.0), 50.0 + 100.0 + 20.0 + 100.0);
|
||
}
|
||
|
||
#[test]
|
||
fn infinite_loops_are_stopped() {
|
||
let f = fx();
|
||
let e = f.err("while (true) {}");
|
||
assert!(!e.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn renderer_uses_expressions_through_eval_ctx() {
|
||
let mut f = fx();
|
||
f.set_expr(f.b, ROT, "time * 360");
|
||
let host = Expressions;
|
||
let comp = f.p.comp(f.cid).unwrap();
|
||
let b = comp.layer(f.b).unwrap();
|
||
let prop = b.props.prop(ROT).unwrap();
|
||
let mut ctx = EvalCtx::new(&f.p, f.cid, comp, secs(0.25));
|
||
assert_eq!(ctx.value(b, prop), Value::Scalar(0.0), "no host: keyframed value");
|
||
ctx.expr = Some(&host);
|
||
assert_eq!(ctx.value(b, prop), Value::Scalar(90.0));
|
||
}
|
||
|
||
#[test]
|
||
fn disabled_expression_is_ignored() {
|
||
let mut f = fx();
|
||
f.set_expr(f.b, ROT, "time * 360");
|
||
f.layer_mut(f.b).props.prop_mut(ROT).unwrap().expr.as_mut().unwrap().enabled = false;
|
||
assert_eq!(eval_property(&f.p, f.cid, f.b, ROT, 0.25).unwrap(), Value::Scalar(0.0));
|
||
}
|
||
|
||
// ---- render & performance ---------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn expression_drives_rotation_in_a_rendered_frame() {
|
||
let mut p = Project::default();
|
||
let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||
// A 80×10 horizontal bar at the centre; rotating 90° makes it vertical.
|
||
let mut bar = solid(&mut p, &comp, "Bar", [1.0, 1.0, 1.0], 80, 10);
|
||
bar.props.prop_mut(ROT).unwrap().expr = Some(Expression { text: "time * 360".into(), enabled: true });
|
||
p.comp_mut(cid).unwrap().layers.push(bar);
|
||
let host = Expressions;
|
||
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { motion_blur: false, ..Default::default() });
|
||
let still = r.comp_frame(cid, Tick::ZERO);
|
||
assert!(still.get(85, 50)[3] > 0.99 && still.get(50, 85)[3] < 0.01, "horizontal at t=0");
|
||
let no_expr = r.comp_frame(cid, secs(0.25));
|
||
assert!(no_expr.get(85, 50)[3] > 0.99, "without a host the bar stays put");
|
||
r.expr = Some(&host);
|
||
let img = r.comp_frame(cid, secs(0.25));
|
||
assert!(img.get(50, 85)[3] > 0.99, "vertical at t=0.25 (90°)");
|
||
assert!(img.get(85, 50)[3] < 0.01);
|
||
}
|
||
|
||
#[test]
|
||
fn hundred_wiggles_are_fast() {
|
||
let mut p = Project::default();
|
||
let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(10.0));
|
||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||
for i in 0..100 {
|
||
let mut l = solid(&mut p, &comp, &format!("L{i}"), [1.0, 1.0, 1.0], 10, 10);
|
||
l.props.prop_mut(POS).unwrap().expr = Some(Expression { text: "wiggle(2, 50)".into(), enabled: true });
|
||
p.comp_mut(cid).unwrap().layers.push(l);
|
||
}
|
||
let host = Expressions;
|
||
let c = p.comp(cid).unwrap();
|
||
let frame = |t: f64| {
|
||
let mut ctx = EvalCtx::new(&p, cid, c, secs(t));
|
||
ctx.expr = Some(&host);
|
||
c.layers.iter().map(|l| ctx.value(l, l.props.prop(POS).unwrap()).as_vec3()[0]).sum::<f64>()
|
||
};
|
||
frame(0.0); // warm up: runtime + compile
|
||
let n = 20;
|
||
let start = Instant::now();
|
||
let mut acc = 0.0;
|
||
for i in 0..n {
|
||
acc += frame(i as f64 / 30.0);
|
||
}
|
||
let per_frame = start.elapsed().as_secs_f64() * 1000.0 / n as f64;
|
||
println!("100 wiggle properties: {per_frame:.3} ms/frame (checksum {acc})");
|
||
// Generous bound so unoptimised CI builds pass; release is far below.
|
||
assert!(per_frame < 1000.0, "{per_frame} ms/frame");
|
||
}
|
||
|
||
#[test]
|
||
fn error_messages_point_at_the_users_line() {
|
||
let f = fx();
|
||
assert_eq!(f.err("var a = 1;\n\nthisComp.layer('Ghost').position"), "Error at line 3: Layer named \"Ghost\" does not exist");
|
||
assert_eq!(f.err("var a = 1;\nfoo.bar + a"), "Error at line 2: ReferenceError: foo is not defined");
|
||
// An unfinished last line is reported on that line, not on the hidden wrapper.
|
||
assert!(f.err("5 +").starts_with("Error at line 1: SyntaxError"));
|
||
}
|
||
|
||
/// Text Expression Selector: Amount evaluated per unit with textIndex / textTotal /
|
||
/// selectorValue.
|
||
#[test]
|
||
fn text_expression_selector_values() {
|
||
let mut p = Project::default();
|
||
let comp = Comp::new(400, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||
let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||
let mut l = build::layer(&mut p, &comp, "T", LayerSource::Text, (400, 200), None);
|
||
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "ABCD".into(), size: 40.0, ..Default::default() }));
|
||
let mut next = p.next_id;
|
||
let mut ids = Ids(&mut next);
|
||
let props = build::text_anim_props(&mut ids, "opacity", false);
|
||
let mut a = build::text_animator(&mut ids, "Animator 1", props);
|
||
let sel = build::expression_selector(&mut ids, "Expression Selector 1");
|
||
a.sub_mut("selectors").unwrap().children.push(sel.into());
|
||
p.next_id = next;
|
||
l.props.group_mut("text/animators").unwrap().children.push(a.into());
|
||
let lid = l.id;
|
||
p.comp_mut(cid).unwrap().layers.push(l);
|
||
let sel_of = |p: &Project| {
|
||
let c = p.comp(cid).unwrap();
|
||
let l = c.layer(lid).unwrap();
|
||
let ctx = EvalCtx {
|
||
project: p,
|
||
comp_id: cid,
|
||
comp: c,
|
||
time: Tick::ZERO,
|
||
expr: Some(&Expressions),
|
||
footage: None,
|
||
property_overrides: &[],
|
||
media_overrides: &[],
|
||
reads: None,
|
||
};
|
||
let doc = effectcraft_render::text::source_text(&ctx, l).unwrap();
|
||
let lay = effectcraft_text::layout_doc(&doc);
|
||
let anim = l.props.group("text/animators/#1").unwrap();
|
||
effectcraft_render::text::animator_selection(&ctx, l, anim, &lay).into_iter().map(|v| (v[0] * 1000.0).round() / 1000.0).collect::<Vec<_>>()
|
||
};
|
||
// Default: selectorValue * textIndex / textTotal (selectorValue = the full range selector).
|
||
assert_eq!(sel_of(&p), vec![0.25, 0.5, 0.75, 1.0]);
|
||
let set_expr = |p: &mut Project, text: &str| {
|
||
let l = p.comp_mut(cid).unwrap().layer_mut(lid).unwrap();
|
||
l.props.prop_mut("text/animators/#1/selectors/#2/amount").unwrap().expr = Some(Expression { text: text.into(), enabled: true });
|
||
};
|
||
// Range selector covering the first half → selectorValue 0 for the second half.
|
||
p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.prop_mut("text/animators/#1/selectors/#1/end").unwrap().value = Value::Scalar(50.0);
|
||
assert_eq!(sel_of(&p), vec![0.25, 0.5, 0.0, 0.0]);
|
||
// Plain numbers, conditionals on textIndex (1-based) and arrays all work.
|
||
p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.prop_mut("text/animators/#1/selectors/#1/end").unwrap().value = Value::Scalar(100.0);
|
||
set_expr(&mut p, "textIndex % 2 == 0 ? 100 : 0");
|
||
assert_eq!(sel_of(&p), vec![0.0, 1.0, 0.0, 1.0]);
|
||
set_expr(&mut p, "[50, 0, 0]");
|
||
assert_eq!(sel_of(&p), vec![0.5; 4]);
|
||
// The result replaces the selection so far (which it reads as selectorValue).
|
||
set_expr(&mut p, "selectorValue - textIndex / textTotal * 100");
|
||
assert_eq!(sel_of(&p), vec![0.75, 0.5, 0.25, 0.0]);
|
||
// Errors fall back to the static amount (100%).
|
||
set_expr(&mut p, "nope(");
|
||
assert_eq!(sel_of(&p), vec![1.0; 4]);
|
||
// Outside a selector the expression still evaluates (textIndex = textTotal = 1).
|
||
set_expr(&mut p, build::EXPRESSION_SELECTOR_DEFAULT);
|
||
let v = eval_property(&p, cid, lid, "text/animators/#1/selectors/#2/amount", 0.0).unwrap();
|
||
assert_eq!(v, Value::Vec3([100.0; 3]));
|
||
}
|
||
|
||
/// `sampleImage` reads the layer's rendered pixels (layer space, straight colour) after masks
|
||
/// and effects, or the bare source with `postEffect = false`; the radius averages a box.
|
||
#[test]
|
||
fn sample_image_reads_rendered_pixels() {
|
||
let f = fx();
|
||
let code = |c: &str| format!("var c = {c}; c[0] * 1000 + c[1] * 100 + c[2] * 10 + c[3]");
|
||
// B: 40×40 blue solid.
|
||
assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([20, 20])"), 0.0), 11.0);
|
||
// Half the box is outside the layer: alpha 0.5, colour still pure blue (straight).
|
||
assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([0, 20], [1, 1])"), 0.0), 10.5);
|
||
// A: red with a mask covering 5..15: outside the mask it's transparent after masks, red before.
|
||
assert_close!(f.num(&code("thisComp.layer(\"A\").sampleImage([2, 2])"), 0.0), 0.0);
|
||
assert_close!(f.num(&code("thisComp.layer(\"A\").sampleImage([2, 2], [0.5, 0.5], false)"), 0.0), 1001.0);
|
||
assert_close!(f.num(&code("thisComp.layer(\"A\").sampleImage([10, 10])"), 0.0), 1001.0);
|
||
// The result has four values; outside the layer it's transparent.
|
||
assert_close!(f.num("thisComp.layer(\"B\").sampleImage([20, 20]).length", 0.0), 4.0);
|
||
assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([-50, -50])"), 0.0), 0.0);
|
||
}
|
||
|
||
/// The footage `sampleImage` renders is a file the evaluating render read (#3184): it is noted in
|
||
/// the render's log, also when the sampled buffer comes from the per-thread cache.
|
||
#[test]
|
||
fn sample_image_notes_the_files_it_reads() {
|
||
use effectcraft_render::reads::{FileStamp, ReadFile, ReadLog};
|
||
/// A grey picture of any footage, noting `/clip.png` for every read.
|
||
struct Clip;
|
||
impl effectcraft_render::FootageSource for Clip {
|
||
fn frame(&self, _: ItemId, f: &effectcraft_project::Footage, _: Tick) -> Option<std::sync::Arc<effectcraft_render::Image>> {
|
||
let mut img = effectcraft_render::Image::new(f.width, f.height);
|
||
img.data.fill([0.5, 0.5, 0.5, 1.0]);
|
||
Some(std::sync::Arc::new(img))
|
||
}
|
||
fn recording(&self, reads: &ReadLog, f: &mut dyn FnMut()) {
|
||
f();
|
||
reads.note(ReadFile::new("/clip.png", FileStamp::Native { len: 3, modified: Some(1) }));
|
||
}
|
||
}
|
||
let mut p = Project::default();
|
||
let comp = Comp::new(20, 20, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||
let footage =
|
||
effectcraft_project::Footage { path: "/clip.png".into(), width: 8, height: 8, has_video: true, frame_rate: FrameRate::FPS_30, ..Default::default() };
|
||
let item = p.add_item("Clip", Label::Aqua, None, ItemKind::Footage(footage));
|
||
let layer = build::layer(&mut p, &comp, "Clip", LayerSource::Footage { item }, (8, 8), None);
|
||
let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||
let c = p.comp(cid).unwrap();
|
||
for pass in ["rendered", "cached"] {
|
||
let log = ReadLog::new();
|
||
let ctx = EvalCtx { footage: Some(&Clip), reads: Some(&log), ..EvalCtx::new(&p, cid, c, Tick::ZERO) };
|
||
let px = crate::sample::sample_image(ctx, cid, &layer, [4.0, 4.0], [0.5, 0.5], false, 0.0).unwrap();
|
||
assert!((px[0] - 0.5).abs() < 1e-3 && px[3] > 0.99, "{pass}: {px:?}");
|
||
let files = log.into_files();
|
||
assert_eq!(files.files().iter().map(|f| &*f.path).collect::<Vec<_>>(), ["/clip.png"], "{pass}");
|
||
assert!(files.is_complete(), "{pass}");
|
||
}
|
||
}
|
||
|
||
/// A huge sample point saturates the pixel coordinates; it must not overflow the loop bounds.
|
||
#[test]
|
||
fn sample_image_huge_point_is_transparent() {
|
||
let f = fx();
|
||
assert_close!(f.num("var c = thisComp.layer(\"B\").sampleImage([1e300, 1e300]); c[3]", 0.0), 0.0);
|
||
}
|
||
|
||
fn data_item(p: &mut Project, name: &str, text: &str) {
|
||
let f = effectcraft_project::Footage {
|
||
path: format!("/data/{name}"),
|
||
kind: effectcraft_project::FootageKind::Data,
|
||
data: Some(text.into()),
|
||
..Default::default()
|
||
};
|
||
p.add_item(name, Label::Sandstone, None, ItemKind::Footage(f));
|
||
}
|
||
|
||
/// Data-driven animation: `footage(name)` with `sourceData` / `sourceText` / `dataValue` for
|
||
/// JSON, CSV and TSV items.
|
||
#[test]
|
||
fn data_footage_json_csv_tsv() {
|
||
let mut f = fx();
|
||
data_item(&mut f.p, "stats.json", r#"{"title": "Q3", "items": [{"v": 3}, {"v": 7.5}]}"#);
|
||
data_item(&mut f.p, "scores.csv", "name,score\n\"Smith, J\",12\nLee,30.5\n");
|
||
data_item(&mut f.p, "table.tsv", "a\tb\n1\t2\n3\t4\n");
|
||
assert_close!(f.num("footage(\"stats.json\").sourceData.items[1].v", 0.0), 7.5);
|
||
assert_eq!(f.text("footage(\"stats.json\").sourceData.title", 0.0), "Q3");
|
||
assert_close!(f.num("footage(\"stats.json\").dataValue([\"items\", 0, \"v\"])", 0.0), 3.0);
|
||
assert_close!(f.num("footage(\"scores.csv\").sourceData[1].score", 0.0), 30.5);
|
||
assert_eq!(f.text("footage(\"scores.csv\").sourceData[0].name", 0.0), "Smith, J");
|
||
assert_close!(f.num("footage(\"scores.csv\").dataValue([1, 0])", 0.0), 12.0);
|
||
assert_close!(f.num("footage(\"scores.csv\").dataKeyCount", 0.0), 2.0);
|
||
assert_eq!(f.text("footage(\"scores.csv\").dataKeyNames.join('|')", 0.0), "name|score");
|
||
assert_close!(f.num("footage(\"table.tsv\").dataValue([1, 1])", 0.0), 4.0);
|
||
assert_close!(f.num("footage(\"table.tsv\").sourceText.length", 0.0), 12.0);
|
||
assert!(f.err("footage(\"missing.json\").sourceData").contains("does not exist"));
|
||
// Animated from data: the value follows the CSV row picked by time.
|
||
assert_close!(f.num("footage(\"scores.csv\").dataValue([1, Math.floor(time)])", 1.2), 30.5);
|
||
}
|
||
|
||
#[test]
|
||
fn project_and_time_globals() {
|
||
let mut f = fx();
|
||
assert_close!(f.num("thisProject.bitsPerChannel", 0.0), 8.0);
|
||
assert_close!(f.num("colorDepth", 0.0), 8.0);
|
||
assert_close!(f.num("thisProject.linearBlending ? 1 : 0", 0.0), 0.0);
|
||
assert_eq!(f.text("timeToFeetAndFrames(2)", 0.0), "3+12");
|
||
assert_close!(f.num("posterizeTime(4); time", 0.6), 0.5);
|
||
// Path API.
|
||
assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], true).points()[2][1]", 0.0), 10.0);
|
||
assert_close!(f.num("createPath([[0, 0], [10, 0]], [[1, 1], [2, 2]], [], false).inTangents()[1][0]", 0.0), 2.0);
|
||
assert_close!(f.num("mask(\"Mask 1\").maskPath.points().length", 0.0), 4.0);
|
||
assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], false).pointOnPath(0.75)[1]", 0.0), 5.0);
|
||
assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], false).pointOnPath(0.75)[0]", 0.0), 10.0);
|
||
assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], false).tangentOnPath(0.25)[0]", 0.0), 1.0);
|
||
assert_close!(f.num("mask(\"Mask 1\").maskPath.pointOnPath(0)[0] - mask(\"Mask 1\").maskPath.points()[0][0]", 0.0), 0.0);
|
||
assert_close!(f.num("mask(\"Mask 1\").maskPath.isClosed() ? 1 : 0", 0.0), 1.0);
|
||
// Marker keys carry protected regions and cue points.
|
||
{
|
||
let a = f.a;
|
||
let l = f.layer_mut(a);
|
||
l.markers[0].protected = true;
|
||
l.markers[0].cue_point = Some(effectcraft_project::CuePoint { name: "go".into(), navigation: false, params: vec![("k".into(), "v".into())] });
|
||
}
|
||
assert_close!(f.num("thisLayer.marker.key(1).protectedRegion ? 1 : 0", 0.0), 1.0);
|
||
assert_eq!(f.text("thisComp.layer(\"A\").marker.key(1).cuePointName + thisComp.layer(\"A\").marker.key(1).parameters.k", 0.0), "gov");
|
||
}
|
||
|
||
#[test]
|
||
fn thousands_of_host_reads_on_one_thread_keep_working() {
|
||
// Every pending host request unwinds through call frames, which leaks boa stack slots; the
|
||
// runtime replaces a context that fills up instead of failing every later expression.
|
||
let f = fx();
|
||
for i in 0..2000 {
|
||
let t = (i % 20) as f64 * 0.05;
|
||
assert_eq!(f.num("effect(\"Slider Control\")(\"Slider\")", t), 42.0, "evaluation {i}");
|
||
assert!(close(f.num("thisComp.layer(\"A\").transform.rotation.valueAtTime(1)", t), 90.0), "evaluation {i}");
|
||
assert_eq!(f.num("function a() { return effect(1)(1); } function b() { return a(); } b() + 1", t), 43.0, "evaluation {i}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn pending_text_style_baseline_setters_are_visible_to_expression_getters() {
|
||
let x = fx();
|
||
assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setSuperscript(true).isSuperscript ? 1 : 0", 0.0), 1.0);
|
||
assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setSubscript(true).isSubscript ? 1 : 0", 0.0), 1.0);
|
||
assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setSuperscript(true).setSubscript(true).isSuperscript ? 1 : 0", 0.0), 0.0);
|
||
assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setSuperscript(true).setSubscript(true).isSubscript ? 1 : 0", 0.0), 1.0);
|
||
assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setSubscript(true).setSubscript(false).isSubscript ? 1 : 0", 0.0), 0.0);
|
||
assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setBaselineOption('superscript').isSuperscript ? 1 : 0", 0.0), 1.0);
|
||
assert_eq!(x.text("var s = text.sourceText.style.setSuperscript(true); s.setText(s.isSuperscript ? 'super' : 'normal')", 0.0), "super");
|
||
}
|
||
|
||
#[test]
|
||
fn layer_source_is_the_layers_source_item() {
|
||
let mut f = fx();
|
||
// A solid's source is an item of the solid's size and name (#809).
|
||
assert_close!(f.num("thisComp.layer('A').source.width", 0.0), 20.0);
|
||
assert_close!(f.num("thisComp.layer('A').source.height", 0.0), 20.0);
|
||
assert_close!(f.num("thisComp.layer('A').source.name == 'A' ? 1 : 0", 0.0), 1.0);
|
||
// Text and shape layers have no source item.
|
||
assert_close!(f.num("thisComp.layer('Title').source == null ? 1 : 0", 0.0), 1.0);
|
||
// A precomp layer's source is its composition.
|
||
let main = f.p.comp(f.cid).unwrap().clone();
|
||
let other = f.p.items.values().find(|i| i.name == "Other").unwrap().id;
|
||
let pre = build::layer(&mut f.p, &main, "Pre", LayerSource::Comp { item: other }, (64, 32), None);
|
||
f.p.comp_mut(f.cid).unwrap().layers.push(pre);
|
||
assert_close!(f.num("thisComp.layer('Pre').source.width", 0.0), 64.0);
|
||
assert_close!(f.num("thisComp.layer('Pre').source.numLayers", 0.0), 1.0);
|
||
}
|
||
|
||
/// The fixture plus a 20×12 solid "Wide", a 48×36 still "Still" (item "still.png") and a precomp
|
||
/// layer "Pre" of the 64×32 "Other" comp.
|
||
fn implicit_source_fx() -> (Fx, LayerId, LayerId, LayerId) {
|
||
let mut f = fx();
|
||
let main = f.p.comp(f.cid).unwrap().clone();
|
||
let wide = solid(&mut f.p, &main, "Wide", [1.0, 1.0, 0.0], 20, 12);
|
||
let item = f.p.add_item(
|
||
"still.png",
|
||
Label::SeaFoam,
|
||
None,
|
||
ItemKind::Footage(effectcraft_project::Footage {
|
||
path: "still.png".into(),
|
||
kind: effectcraft_project::FootageKind::Still,
|
||
width: 48,
|
||
height: 36,
|
||
has_video: true,
|
||
..Default::default()
|
||
}),
|
||
);
|
||
let still = build::layer(&mut f.p, &main, "Still", LayerSource::Footage { item }, (48, 36), None);
|
||
let other = f.p.items.values().find(|i| i.name == "Other").unwrap().id;
|
||
let pre = build::layer(&mut f.p, &main, "Pre", LayerSource::Comp { item: other }, (64, 32), None);
|
||
let ids = (wide.id, still.id, pre.id);
|
||
let c = f.p.comp_mut(f.cid).unwrap();
|
||
c.layers.push(wide);
|
||
c.layers.push(still);
|
||
c.layers.push(pre);
|
||
(f, ids.0, ids.1, ids.2)
|
||
}
|
||
|
||
fn num_on(f: &Fx, layer: LayerId, text: &str) -> f64 {
|
||
match f.ev(layer, ROT, text, 0.0) {
|
||
Ok(v) => v.as_f64(),
|
||
Err(e) => panic!("{text}: {e}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn implicit_source_matches_this_layer_source() {
|
||
let (f, wide, still, pre) = implicit_source_fx();
|
||
let same = "['name', 'width', 'height', 'duration', 'frameDuration', 'pixelAspect']\
|
||
.every(function (k) { return source[k] === thisLayer.source[k]; }) ? 1 : 0";
|
||
for (layer, name, w, h) in [(wide, "Wide", 20.0, 12.0), (still, "still.png", 48.0, 36.0), (pre, "Other", 64.0, 32.0)] {
|
||
assert_close!(num_on(&f, layer, "source.width"), w);
|
||
assert_close!(num_on(&f, layer, "source.height"), h);
|
||
assert_close!(num_on(&f, layer, &format!("source.name == '{name}' ? 1 : 0")), 1.0);
|
||
assert_close!(num_on(&f, layer, same), 1.0);
|
||
}
|
||
assert_close!(num_on(&f, pre, "source.numLayers"), 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn implicit_source_is_null_without_a_source_item() {
|
||
let f = fx();
|
||
for layer in [f.title, f.shape] {
|
||
assert_close!(num_on(&f, layer, "source === null && thisLayer.source === null ? 1 : 0"), 1.0);
|
||
let bare = f.ev(layer, ROT, "source.width", 0.0).unwrap_err();
|
||
let qualified = f.ev(layer, ROT, "thisLayer.source.width", 0.0).unwrap_err();
|
||
assert!(qualified.contains("TypeError"), "{qualified}");
|
||
assert_eq!(bare, qualified);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn implicit_source_can_be_shadowed_locally() {
|
||
let (f, wide, ..) = implicit_source_fx();
|
||
assert_close!(num_on(&f, wide, "var source = 7;\nsource * 2"), 14.0);
|
||
assert_close!(num_on(&f, wide, "source.width"), 20.0);
|
||
assert_close!(num_on(&f, wide, "var source = thisComp.layer('B').source;\nsource.width"), 40.0);
|
||
assert_close!(num_on(&f, wide, "source.height"), 12.0);
|
||
}
|
||
|
||
#[test]
|
||
fn implicit_source_follows_the_current_layer() {
|
||
let (f, wide, still, pre) = implicit_source_fx();
|
||
let e = "source === null ? -1 : source.width * 1000 + source.height";
|
||
for _ in 0..2 {
|
||
for (layer, want) in [(wide, 20012.0), (still, 48036.0), (pre, 64032.0), (f.title, -1.0), (f.b, 40040.0)] {
|
||
assert_close!(num_on(&f, layer, e), want);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn layer_control_property_stands_for_its_layer() {
|
||
let mut f = fx();
|
||
// Layer A gets a Layer Control ("Map") that points at layer B (#809).
|
||
let spec = effectcraft_effects::find("ec.control.layer").unwrap();
|
||
let mut map = group_push(&mut f.p, |ids| effectcraft_effects::instantiate(spec, ids, "Map", [20.0, 20.0]));
|
||
map.prop_mut("layer").unwrap().value = Value::Layer(Some(f.b.0));
|
||
f.p.comp_mut(f.cid).unwrap().layers[0].props.sub_mut("effects").unwrap().children.push(map.into());
|
||
// effect("Map")(1) is the layer itself: its name, index, transform and value.
|
||
assert_close!(f.num("effect('Map')(1).name == 'B' ? 1 : 0", 0.0), 1.0);
|
||
assert_close!(f.num("effect('Map')(1).index", 0.0), 2.0);
|
||
assert_close!(f.num("effect('Map')(1).transform.position[0]", 0.0), 100.0);
|
||
assert_close!(f.num("effect('Map')(1).transform.scale[0]", 0.0), 100.0);
|
||
assert_close!(f.num("effect('Map')(1).value.name == 'B' ? 1 : 0", 0.0), 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn time_to_frames_rounds_durations_away_from_zero() {
|
||
let f = fx();
|
||
// Adobe's public expression reference distinguishes durations from absolute times.
|
||
// Expected integer counts are literal values, independent of the implementation.
|
||
for (expression, expected) in [
|
||
("timeToFrames(-0.01, 24, true)", -1.0),
|
||
("timeToFrames(-0.05, 24, true)", -2.0),
|
||
("timeToFrames(0, 24, true)", 0.0),
|
||
("timeToFrames(-0.125, 24, true)", -3.0),
|
||
("timeToFrames(0.125, 24, true)", 3.0),
|
||
("timeToFrames(0.01, 24, true)", 1.0),
|
||
("timeToFrames(0.05, 24, true)", 2.0),
|
||
] {
|
||
assert_eq!(f.num(expression, 0.0), expected, "{expression}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn time_to_frames_rounds_absolute_times_toward_negative_infinity() {
|
||
let f = fx();
|
||
for (expression, expected) in [
|
||
("timeToFrames(-0.01, 24, false)", -1.0),
|
||
("timeToFrames(-0.05, 24, false)", -2.0),
|
||
("timeToFrames(0, 24, false)", 0.0),
|
||
("timeToFrames(-0.125, 24, false)", -3.0),
|
||
("timeToFrames(0.125, 24, false)", 3.0),
|
||
("timeToFrames(0.01, 24, false)", 0.0),
|
||
("timeToFrames(0.05, 24, false)", 1.0),
|
||
] {
|
||
assert_eq!(f.num(expression, 0.0), expected, "{expression}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn time_to_frames_preserves_default_arguments() {
|
||
let f = fx();
|
||
// The fixture's composition is 30 fps with displayStartTime = 0.
|
||
for (expression, evaluation_time, expected) in [
|
||
("timeToFrames(0.01, 24)", 0.0, 0.0),
|
||
("timeToFrames(-0.01, 24)", 0.0, -1.0),
|
||
("timeToFrames(0.05)", 0.0, 1.0),
|
||
("timeToFrames(-0.05)", 0.0, -2.0),
|
||
("timeToFrames()", 0.0, 0.0),
|
||
("timeToFrames()", 0.05, 1.0),
|
||
("timeToFrames()", -0.05, -2.0),
|
||
("timeToFrames(undefined, 24, true)", 0.05, 2.0),
|
||
("timeToFrames(0.05, undefined, true)", 0.0, 2.0),
|
||
] {
|
||
assert_eq!(f.num(expression, evaluation_time), expected, "{expression} at {evaluation_time}s");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn time_to_frames_preserves_coercion_and_small_durations() {
|
||
let f = fx();
|
||
// Duration rounding must not erase tiny nonzero values; absolute times keep the existing tolerance.
|
||
for (expression, expected) in [
|
||
("timeToFrames('0.05', '24', false)", 1.0),
|
||
("timeToFrames('0.05', '24', true)", 2.0),
|
||
("timeToFrames('-0.05', '24', true)", -2.0),
|
||
("timeToFrames(null, 24, true)", 0.0),
|
||
("timeToFrames(0.05, null, true)", 0.0),
|
||
("timeToFrames(0.05, 0, true)", 0.0),
|
||
("timeToFrames(31 / 24 - 5 / 24, 24, true)", 26.0),
|
||
("timeToFrames(5 / 24 - 31 / 24, 24, true)", -26.0),
|
||
("timeToFrames(-1e-10, 24, false)", 0.0),
|
||
("1 / timeToFrames(-0, 24, true) === Infinity ? 1 : 0", 1.0),
|
||
("isNaN(timeToFrames(NaN, 24, true)) ? 1 : 0", 1.0),
|
||
("timeToFrames(Infinity, 24, true) === Infinity ? 1 : 0", 1.0),
|
||
("timeToFrames(-Infinity, 24, true) === -Infinity ? 1 : 0", 1.0),
|
||
("var calls = 0; var t = {valueOf: function () { calls++; return 0.05; }}; timeToFrames(t, 24, true) + 100 * calls", 102.0),
|
||
] {
|
||
assert_eq!(f.num(expression, 0.0), expected, "{expression}");
|
||
}
|
||
}
|
||
|
||
/// A layer is a property group too, as in After Effects: `thisLayer("transform")`, another
|
||
/// layer's `("effects")("Slider Control")` or `(1)`, as the pick whip writes them with Compact
|
||
/// English off (they failed: "not a callable function"). It is still a layer.
|
||
#[test]
|
||
fn layers_are_callable_property_groups() {
|
||
let f = fx();
|
||
assert_close!(f.num("thisLayer(\"transform\")(\"opacity\")", 0.0), 100.0);
|
||
assert_close!(f.num("thisComp.layer(\"A\")(\"effects\")(\"ec.control.slider\")(\"slider\")", 0.0), 42.0);
|
||
assert_close!(f.num("thisComp.layer(\"A\")(\"effects\")(\"Slider Control\")(1)", 0.0), 42.0);
|
||
assert_close!(f.num("thisComp.layer(1)(\"effects\")(1)(1)", 0.0), 42.0);
|
||
// Its own name, index and methods, and a layer as the result still reads as its index.
|
||
assert_close!(f.num("thisLayer.name == \"A\" ? thisComp.layer(\"B\").index : 0", 0.0), 2.0);
|
||
assert_close!(f.num("thisComp.layer(\"B\")", 0.0), 2.0);
|
||
assert_close!(f.num("thisComp.layer(thisComp.layer(\"B\"), -1).index", 0.0), 1.0);
|
||
assert!(f.err("thisLayer(\"nope\")").contains("no property"), "{}", f.err("thisLayer(\"nope\")"));
|
||
}
|
||
|
||
/// A layer is kept from one evaluation to the next (it is dear to make) but reads the project
|
||
/// afresh in each, and an expression's own fields on it don't reach the next one.
|
||
#[test]
|
||
fn a_kept_layer_reads_the_project_again() {
|
||
let mut f = fx();
|
||
let first = "thisComp.layer(\"B\").index * 10 + thisComp.layer(2).name.length";
|
||
assert_close!(f.num(first, 0.0), 21.0);
|
||
f.layer_mut(f.b).name = "Bee".into();
|
||
f.p.comp_mut(f.cid).unwrap().layers.swap(0, 1);
|
||
assert_close!(f.num("thisComp.layer(\"Bee\").index * 10 + thisComp.layer(2).name.length", 0.0), 11.0);
|
||
assert_close!(f.num("thisLayer.index", 0.0), 2.0);
|
||
let _ = f.ev(f.a, ROT, "thisLayer.mine = 5; 0", 0.0);
|
||
assert_close!(f.num("thisLayer.mine === undefined ? 1 : 0", 0.0), 1.0);
|
||
let _ = f.ev(f.a, ROT, "var L = thisLayer; L.__c = 'x'; L.__l = 99; L.__i = ['Z', 7]; L.__s = __S; 0", 0.0);
|
||
assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0);
|
||
let _ = f.ev(f.a, ROT, "var L = thisLayer; delete L.__c; delete L.__l; delete L.__i; delete L.__s; 0", 0.0);
|
||
assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0);
|
||
let _ = f.ev(f.a, ROT, "Object.freeze(thisLayer); 0", 0.0);
|
||
f.layer_mut(f.a).name = "Ay".into();
|
||
f.p.comp_mut(f.cid).unwrap().layers.swap(0, 1);
|
||
assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 12.0);
|
||
}
|
||
|
||
/// The kept layers are bounded: past the bound a layer isn't kept, an evaluation that makes more
|
||
/// layers than are kept still sees one object for each layer it had kept, and the evaluations
|
||
/// after it, which start over, read the right layers.
|
||
#[test]
|
||
fn the_kept_layers_are_bounded() {
|
||
let f = fx();
|
||
let same = "(thisLayer === thisComp.layer(thisLayer.index) ? 1 : 0)";
|
||
let many =
|
||
format!("var c = thisLayer.__c;\nfor (var i = 0; i < 5000; i++) __layer(c, 100000 + i);\n{same} * 10 + (__layer(c, 1e6) === __layer(c, 1e6) ? 0 : 1)");
|
||
assert_close!(f.num(&many, 0.0), 11.0);
|
||
assert_close!(f.num(&format!("{same} * 100 + thisLayer.index * 10 + thisLayer.name.length"), 0.0), 111.0);
|
||
assert_close!(f.num(&format!("{same} * 100 + thisLayer.index * 10 + thisLayer.name.length"), 0.0), 111.0);
|
||
}
|
||
|
||
/// A change to `Object.prototype` doesn't reach the kept layers: a frozen object put there under
|
||
/// the comp's id, before any of its layers is kept, doesn't stop them being kept.
|
||
#[test]
|
||
fn the_kept_layers_dont_go_through_object_prototype() {
|
||
let f = fx();
|
||
let _ = f.ev(f.a, ROT, "try { Object.prototype[thisComp.__c] = Object.freeze({}); } catch (e) {} 0", 0.0);
|
||
assert_close!(f.num("thisLayer === thisComp.layer(thisLayer.index) ? 1 : 0", 0.0), 1.0);
|
||
}
|
||
|
||
/// The prelude is kept from one evaluation to the next, so an expression can't change its object
|
||
/// model or internals for the ones after it.
|
||
#[test]
|
||
fn an_expression_cant_change_the_prelude_for_the_next() {
|
||
let f = fx();
|
||
let read = "[thisComp.layer(1).index, thisLayer.index, thisLayer('transform').property('rotation').value, thisLayer.transform.opacity.value, thisProject.bitsPerChannel, comp(thisComp.name).numLayers, width]";
|
||
let before = f.ev(f.a, ROT, read, 0.5).unwrap();
|
||
let attacks = [
|
||
"Comp.prototype.layer = function () { return 0; }",
|
||
"Object.getPrototypeOf(thisLayer).index = 0",
|
||
"Object.getPrototypeOf(thisLayer('transform')).property = function () { return 0; }",
|
||
"Object.getPrototypeOf(thisLayer.transform.opacity).value = 0",
|
||
"Comp = function () {}",
|
||
"Prop = function () {}",
|
||
"thisProject = null",
|
||
"__h = function () { return null; }",
|
||
"__layer = function () { return null; }",
|
||
"__child = function () {}",
|
||
"delete __mkProp",
|
||
"delete thisLayer",
|
||
"delete thisComp",
|
||
"delete width",
|
||
"Object.defineProperty(globalThis, 'thisComp', { get: function () { return 0; } })",
|
||
];
|
||
for a in attacks {
|
||
let _ = f.ev(f.a, ROT, &format!("try {{ {a} }} catch (e) {{}} 0"), 0.0);
|
||
assert_eq!(f.ev(f.a, ROT, read, 0.5).unwrap(), before, "after `{a}`");
|
||
}
|
||
}
|
||
|
||
mod property_group;
|
||
|
||
/// Timing harness for the prelude's cost on the evaluation path. Ignored: it is a measurement, not
|
||
/// an assertion. `cargo test -p effectcraft-expr --release --lib bench_evaluation -- --ignored
|
||
/// --nocapture`.
|
||
#[test]
|
||
#[ignore = "timing measurement, not a test"]
|
||
fn bench_evaluation() {
|
||
let f = fx();
|
||
// Each case runs on a property whose dimension its answer fits: a scalar handed to `position`
|
||
// is rejected after the script has already run, so the case would be timing an error path.
|
||
// Ordinary expressions first, then three that read an inherited name in a loop: the repair
|
||
// turns each of those reads into an accessor call, and `Array.prototype.map` is the one an
|
||
// expression is most likely to reach for in quantity.
|
||
let cases: [(&str, &str, &str, u32); 10] = [
|
||
("array maths", POS, "value + [10, 0]", 2500),
|
||
("scalar maths", ROT, "time * 100 + 5", 3000),
|
||
("prelude call", ROT, "linear(time, 0, 1, 0, 100)", 2500),
|
||
("cross-layer read", ROT, "thisComp.layer(\"B\").transform.position[0]", 200),
|
||
("effect read", ROT, "effect(\"Slider Control\")(\"Slider\")", 150),
|
||
("wiggle", POS, "wiggle(2, 10)", 800),
|
||
("dictionary", ROT, "var c = {}; var k = 'constructor'; c[k] = 1; c[k] + value", 2500),
|
||
("toString x100", ROT, "var o = {}; var n = 0; for (var i = 0; i < 100; i++) { if (o.toString) n++; } n", 250),
|
||
("hasOwnProperty x100", ROT, "var o = {}; var n = 0; for (var i = 0; i < 100; i++) { if (o.hasOwnProperty) n++; } n", 250),
|
||
("array method x100", ROT, "var a = [1]; var n = 0; for (var i = 0; i < 100; i++) { if (a.map) n++; } n", 250),
|
||
];
|
||
for (label, path, text, iters) in cases {
|
||
let mut best = u128::MAX;
|
||
// Three passes, keeping the fastest: a slower pass is scheduling noise, not the prelude.
|
||
for _ in 0..3 {
|
||
// The warm-up checks as it goes, at every time the measured loop uses: that loop drops
|
||
// its answer by design, so a case that had started failing would be timed on its error
|
||
// path and still print a number.
|
||
for i in 0..50 {
|
||
assert!(f.ev(f.a, path, text, f64::from(i % 10) * 0.1).is_ok(), "{label}");
|
||
}
|
||
let start = std::time::Instant::now();
|
||
for i in 0..iters {
|
||
let _ = f.ev(f.a, path, text, f64::from(i % 10) * 0.1);
|
||
}
|
||
best = best.min(start.elapsed().as_nanos() / u128::from(iters));
|
||
}
|
||
println!("BENCH {best:>9} ns/eval {label} (min of 3 x {iters})");
|
||
}
|
||
}
|