Two German menu labels described a different command:
- `layer.timeReverse` was "Ebenenreihenfolge umkehren", which reads as reversing
the stacking order rather than time -- now "Ebene zeitlich umkehren", matching
`keys.timeReverse`;
- `layer.style.convertToEditable` was "In editierbare Formate umwandeln", and
"Formate" are formats, not layer styles -- now "In bearbeitbare Stile
umwandeln".
Where the same English term had more than one German rendering, the one already
in the majority wins, so this settles rather than re-translates:
- "Expression" is "Ausdruck" in seven entries and "Expressions" in the two
`layer.expressions` ones, which now read "Ausdrücke aktivieren/deaktivieren";
- "Reveal" is "anzeigen" in eleven of twelve entries, so `layer.revealInFinder`
joins `file.revealInFinder` at "Im Finder anzeigen" -- in every other
catalogue both commands already share one string;
- "Track" as a verb is "verfolgen" in the other four entries, so `track.mask`
is "Maske verfolgen" rather than "Maske tracken";
- one spelling of "nach vorne", matching the neighbouring arrange entry.
German also had no platform variants for the file manager. The engine puts the
platform's name into the label before the catalogue sees it, so on Windows the
source is "Reveal in Explorer" and on Linux "Reveal in File Manager"; only
Ukrainian and Czech special-cased those, and a German UI showed the English
string. Added, with a test mirroring `czech_…` and `ukrainian_…`.
Two of the reported six are left alone: "Essential Graphics" is a 3-3 split and
a panel's proper name, and "Null" partly mirrors the English, which itself says
both "Null Object" and "Nulls". Both are translator's choices with no majority
to follow, not corrections.
Phones and messaging apps store frame times rounded to whole milliseconds
(33/34 ms at 30 fps, 16/17 ms at 60 fps). FilmCraft's reader takes the
median duration, so such clips imported at 30.303 or 58.824 fps, and New
Comp from Selection and renders followed that rate. When the median is no
standard rate, the durations near it are averaged and snapped to the
standard rate within 0.5 %; exact and variable rates keep the reader's
estimate.
draw_bounds padded every stroke by width × miter limit / 2 on each side, joins or
not. A straight 1080 px wide wipe used as a track matte became a 4326×6366 layer
buffer (~440 MB as RGBA f32), made anew every frame while its trim animates, then
cached on the CPU and uploaded to the GPU. With 12 frames exported in parallel a
16 GB Mac ran out of memory and froze.
Now the miter limit applies only to miter-joined paths with a join (two or more
segments, or closed); square caps reach √2 half widths, other strokes one; Taper
and Wave keep the widest padding. Rendered pixels are unchanged.
A full-res frame of the affected ad: 1.7 GB → 0.7 GB peak.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`resolve_output` takes the project's folder from `Path::new(path).parent()`.
For a project saved under a bare filename that is the empty path, which the
following `filter` discarded, so a saved project fell through to the
unsaved-project default and relative outputs went to ~/Movies (the user profile
folder on Windows) instead of next to the project.
--project proj.ecproj ~/Movies/clip.mp4
--project ./proj.ecproj ./clip.mp4
Both spellings name a project in the working directory, so both now resolve
there, as the doc comment on `resolve_output` already describes.
Closes#1427
Follow up on #1210: remove the fixed menu index from automation and click handling, and move the preset-folder documentation into the Effects & Presets section.
The keyed Birth Rate comparison is shared (keyed_agree) and now runs on
CC Particle Systems II too. The same two histories bear 90 particles by
0.9 s at different steps: the GPU's particles match the CPU's, and each
state really came from the GPU.
Through the renderer, a keyed burst draws as the constant rate during it,
still draws after the rate drops to 0 (unlike a rate of 0 throughout),
and draws as nothing born once Longevity has run out. A Birth Rate
expression on any of the three stepped particle systems keeps the layer
out of the layer cache; without one it is cached.
They now read their Birth Rate at earlier times too, so
effects::reads_param_history names them along with CC Particle World. A
layer key can't see what an expression returned then.
For both: a burst lives on after Birth Rate drops to 0, and is drawn
(particles, blobs) where a constant 0 draws nothing. Its particles move
and age, and are gone after their Longevity. Seeking matches a
simulation from 0, and moving an earlier key with the same current rate
and cache key isn't served from stale checkpoints. A constant rate gives
exactly the constant-rate simulation's particles, with or without a
host.
Both simulate from layer time 0, as CC Particle World does, and used
the Birth Rate of the rendered frame for every step. A rate keyed down
to 0 emptied them the frame it got there, although their particles (and
Mercury's blobs) were within their Longevity.
CC Particle World's simulation path becomes Stepped: Birth Rate sampled
at every step (rate_history), a cache key without it, and checkpoints
resumed only with the same rate history. Particle World, Particle
Systems II (60 particles a second per unit, CPU or GPU) and Mr. Mercury
(10 blobs a second per unit, CPU) now all use it. A constant Birth Rate
gives exactly the particles it did.
The constant-rate step, key and simulation (Phys::step, sim_key,
simulate) are now only the tests' reference. Mr. Mercury's physics move
into mercury_phys.
The two histories now bear the same 180 particles by 0.9 s at different
steps (3/s from 0 to 0.5 s, and from 0.25 to 0.75 s). So a checkpoint
check by birth count alone fails the test, not only no check. A
test-only dispatch counter asserts each GPU state really came from the
GPU rather than the CPU fallback.
A Hold cut at 0.5 s reads 3 just before and 0 at 0.5 s under 33.3 %,
133.33 % and 200 % stretch, and the frame matches the unstretched one.
A Birth Rate expression reading a Controller layer's Opacity before
0.5 s: changing the Controller changes the 0.9 s frame, and the cached
render matches the uncached one.
The simulation checks its checkpoints against the Birth Rate history,
but a cached layer is returned before the effect runs. The layer key
sees the layer's keyframes and its current values, not what an
expression returned at earlier times: a Birth Rate expression reading
another layer could change the history while the current rate, 0, stayed
the same.
effects::reads_param_history names CC Particle World. The renderer
already declines to cache layers whose time effects (ec.time.*) meet
expressions. It now treats Particle World the same way, in the layer key,
the input key (only for the effects that input runs) and the dependency
walk.
FxHost::param_at mapped the requested layer time to comp time, and
EvalCtx::value mapped it back. Under a fractional stretch (33.3 %,
133.33 %) that round trip truncates a tick early, so a Hold key at
0.5 s read its previous value at the step starting there: a Birth Rate
cut to 0 at 0.5 s bore one more step of particles (six).
EvalCtx::value_at_layer_time reads the keyframes at the given layer
time exactly, and still runs an expression at the comp time.
EvalCtx::value now uses it, and param_at reads through it.
EffectHost::param_is_static says when a parameter has no keyframes and
no expression. CC Particle World then skips sampling its Birth Rate at
every step, since it's the same throughout.
A property or expression pick whip dropped with Alt held, onto the
Timeline or Effect Controls, now sends byIndex: an effect's property is
referred to by its index, as in After Effects.
Test: an_alt_pick_whip_refers_to_an_effect_property_by_index (Alt and
plain drops onto Effect Controls and the Timeline, into the expression
being edited with a dimension, one undo step each).
In After Effects, holding Alt while dropping the expression pick whip
refers to an effect's property by its index, effect("Color Control")(1),
instead of its name, ("Color").
prop.pickWhip takes byIndex: the effect keeps its name and each
property inside it is selected by its one-based place among its group's
children (nested groups too), which is what an expression's (1) reads.
Other properties, dimensions ([1]), insertion at the cursor and match
names (Compact English off) are as before. An index still reads a
renamed property, but another one if properties are reordered.
Tests: pick_whip_by_index_selects_effect_properties_by_their_index
(engine, the references); effectcraft-host pick_whip (by index and by
name read the same values, nested and per dimension).
With a Birth Rate keyed into a burst, the GPU's particles match the CPU
simulation through the burst and after it. A second history with the
same system key, whose burst starts later (so the same ids are born at
other steps), isn't resumed from the first history's GPU checkpoints.
The issue's scene: a Birth Rate keyed 3 until 0.5 s then 0 matches the
constant rate during the burst, still draws the burst at 0.9 s with no
new births, and is empty once Longevity has run out. Moving the earlier
key changes the 0.9 s frame through the layer cache. Layers offset by
0.5 s or stretched 200 % sample their own layer times.
A burst lives on after the rate drops to 0: as a constant rate during
it, only its particles afterwards, still moving, gone after their
Longevity. Another effect's history isn't used. A ramp bears what each
moment asked for. Seeking backward and forward matches a simulation
from 0. Moving an earlier key isn't served from stale checkpoints with
the same current rate. A constant rate is unchanged. The birth schedule
matches the CPU's ids.
The simulation runs from layer time 0, but it used the Birth Rate of the
rendered frame for every step. A rate keyed from 3 down to 0 simulated
no births at all once it reached 0, so the burst's particles vanished
that frame although their Longevity had not run out. A ramped rate was
simulated as if its current value had always applied.
Each step now samples Birth Rate at its own layer time through
EffectHost::param_at (with the effect's own index). Without such a host
the current value applies throughout, as before. Particles already alive
keep moving and ageing whatever the rate. The carry and particle ids
continue across rate changes.
Birth Rate is left out of the simulation cache key, so an animated rate
no longer restarts the simulation every frame. In its place:
- The CPU state keeps a hash of the rates it was stepped with (hist). A
checkpoint is resumed only when that matches the current history, so
moving an earlier key is never served from a stale checkpoint.
- The GPU gets the birth schedule of the sampled rates (births_at). Its
checkpoints record which births they hold, and are resumed only when
the request's births before that step are the same.
A constant Birth Rate gives exactly the particles it did. CC Particle
Systems II and CC Mr. Mercury keep their constant-rate simulation.
psim::births_at builds the GPU's birth schedule (the birth step of every
particle id) from the rate of each step, with the CPU step's carry
arithmetic. births() for a constant rate now uses it. It stops as soon
as the cap would be passed, before adding a step's births, so a huge
rate can't build a huge list first.
SimCache::run_where takes a check on each checkpoint (its step and
state). A simulation whose steps depend on inputs outside its key, such
as a parameter's values over time, resumes only from a checkpoint
reached with the same inputs. A fresh checkpoint now replaces one at the
same step, so a changed history doesn't leave stale checkpoints behind.
run() is run_where with no check.
EffectHost::param_at(effect_index, id, layer_time) evaluates a single
parameter (by its flattened id, such as birthRate or group/param) of the
effect at that index in the layer's stack. A simulation can then sample
one parameter at every step without flattening the whole effect each
time.
The effect index is passed in, rather than read from the renderer's
running-effect index as params_at does: GPU-batched effects never update
that index, so a batched effect would read another effect's parameters.
The renderer's implementation walks the effect group by match id and
evaluates just that property (keyframes and expressions) at the comp
time of the layer time.
A layer selected by another layer's effect (Displacement Map, Blend...) had
its structure, values, matrix and time hashed into the owner's key, but not
the comp camera/lights its own scene-reading effects (Caustics, CC Particle
World...) see. The owner's key included the scene only when the owner itself
ran such an effect, and a cached owner never runs the selected layer's
effects, so moving the camera or a light reused stale owner pixels.
Dependencies::layer now hashes comp_scene for every selected (or nested)
layer that reads it, with the same policy as owner keys.
Fixes#1252
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh
The 1 GiB array limit counted wire bytes, but arrays are kept as f64/i64,
so boolean arrays grew 8x and 32-bit arrays 2x past it, and nothing bounded
the total across arrays. Every Model linked to a Geometry also built its own
copy of the mesh.
- A per-import Budget (2 GiB) is charged 8 bytes per array element before
the array is decompressed or widened, ASCII files are charged for their
parsed values, and each built mesh is charged for its vertex and index
data. Going past it fails the import with a clear error instead of an
allocation abort. parse_within/read_within take a lower limit for tests.
- Models instancing the same geometry with the same materials share one
mesh; a different material mapping still gets its own.
Fixes#1234
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh
Accessor counts sized the f32/u32 output before the buffer view was looked
at, and offsets, strides and count * components used unchecked arithmetic,
so a few hundred bytes of JSON could request a multi-gigabyte allocation
(aborting the process) or overflow.
- Doc::layout checks count * components, adds the view and accessor offsets,
rejects a byteStride smaller than an element, and checks that the last
element ends inside the buffer, all with checked arithmetic, before
floats() or ints() allocate anything.
- Accessors without a buffer view (all zeros) are capped at 2^24 components.
- idx() and node index lists use usize::try_from instead of 'as usize'; an
image bufferView's end and a GLB chunk's end are computed with checked_add.
Fixes#1235
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh
Depth-limited <use> expansion still let a few kilobytes of branching
references expand to 2^N elements. The parser now counts every element it
converts, <use> copies included, against a budget of max(500 000, twice the
file's own elements) and fails the import with Error::Xml once it is spent.
Targets currently being expanded are tracked, so mutual references stop at
the first repeat instead of nesting to the depth limit.
Fixes#1236
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh
Project::comp_contains recursed through precomp layers with no visited
set or depth limit. A damaged project with a precomp cycle that doesn't
pass the queried comp (A and B nesting each other, C unrelated) recursed
until the stack ran out, which aborts the process ("stack overflow,
aborting"): it can't be caught as a command error, so unsaved work is
lost. layer.addItem checks it before adding a precomp, and the renderer
calls it for every nested comp and its audio. A long chain used the
stack the same way, and comps shared by many paths were walked once per
path (exponential for nested diamonds).
It is now an explicit stack with a visited set: same answers (a comp
contains itself; a target reached through a cycle is found), each comp
visited once, no recursion. The renderer already skips a precomp that
contains the comp being rendered, so a cyclic project now renders
instead of crashing; nothing is repaired on load.
Tests: project comp_contains_ends_on_cycles_long_chains_and_shared_
precomps (cycle with an unrelated target, self-nesting, a target through
a cycle, 40 levels of diamonds, a 20 000-comp chain); engine
a_project_with_a_precomp_cycle_opens_adds_and_renders_without_running_
out_of_stack (file.open, layer.addItem both ways, render). On main the
engine test aborts with a stack overflow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every socket operation locked one global table and held it through the
blocking write_all/flush, and streams had no write timeout (accepted
ones were explicitly blocking). A peer that stopped reading kept the
script blocked for good, and every other socket call, close included,
waited behind it.
- The table maps handles to Arc<Entry>, each with its own Mutex<Sock>.
The table is locked only to look a socket up, add or remove one,
never during network I/O, so one stalled socket doesn't hold up the
others.
- `write` sets a write timeout from the socket's `timeout` (10 s by
default, as reads) and reports "write timed out … the other side
isn't reading" instead of blocking. Failures to set the read or write
timeout, or to make an accepted stream blocking, are reported instead
of ignored.
- `close` removes the socket and shuts its stream down through a second
handle, so a call blocked on it fails at once.
Tests (socket.rs, native only): a 64 MB write to a loopback peer that
never reads fails with "timed out" after a 1 s timeout; while one socket
is blocked writing, another answers at once, and closing the blocked one
returns at once and ends its write. Both fail on main (the write never
gives up; the second socket waits on the table lock).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With Warn User When Executing Files on, file.executeFile asks the
frontend first, and the frontend's answer re-runs it with
`confirmed: true`. That flag was taken from the parameters whoever
called, so a script could pass it itself (app.run('file.executeFile',
{path, confirmed: true}), also inside engine.batch) and the file opened
without the question, as long as the user had allowed file and network
access.
`confirmed` now counts only when the command isn't running for a script
(Session::running_for_script, which #590 uses for the same reason and
which stays set through nested batches). A script's request always gets
the question; the user's answer from the frontend's form still opens
the file; with the warning off nothing changes. The permission gate
itself is unchanged.
Tests: engine a_script_cannot_confirm_executing_a_file_for_itself
(execute_for_script directly and through engine.batch; the frontend's
confirmation; warning off) and the same through real JavaScript in the
script crate (app.run, app.run('engine.batch'), File.execute()). Both
fail on main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
essential.templateInfo was missing from confine.rs's READS, so its
`path` got no named check, and the fallback only scans absolute-looking
strings: a relative path (or one through `..`) escaped the read roots
and the command parsed and returned a template outside them.
An audit of every registered engine command with a parameter named
like a file found the same gap in more commands, now classified:
- READS: essential.templateInfo, file.importProject,
file.watchFolder.poll, footage.relink, layer.newModel,
mediaBrowser.fileInfo / list / import / removeFavorite,
face.model.install, roto.model.install, templates.create (`path`).
- WRITES: file.exportTimeline, file.createProxy (`path` / `output`,
which may be a file-name template, as for render.preRender).
- MIXED_WRITES: templates.create writes `projectPath` and `footageDir`.
Tests (confine.rs):
- every_command_with_file_parameters_is_classified fails when a
command takes a path, paths, folder, file, output... parameter and is
neither confined, refused, checked by a lookup, a Render Queue output
(checked when the queue renders) nor listed in NOT_FILES (property
paths and the like). New file commands can't slip through.
- relative_and_outside_paths_of_newly_listed_commands_are_refused:
relative, `..` and outside paths are refused and in-root ones allowed,
directly and in a batch; writes go only to write roots; createProxy
accepts a name template. It fails on main (templateInfo with
`t.ectemplate` passes).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A ReferenceError's first use was searched across the whole file, so a mention
earlier in a comment, a string or another function could report the wrong line.
The search now starts at the innermost script frame's line, and covers the whole
file only when there is no frame. Follow-up to storytold/effectcraft#1161.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>