add_effect ran effect.apply and then one prop.set per `values` entry as
separate commands: each became its own undo step, and a bad value (an
unknown parameter id) returned an error but left the effect, and any
values set before it, on the layer, so a corrected retry landed at
effects/#2.
It now runs them as one engine.batch, named like effect.apply's own undo
step ("Apply Gaussian Blur"): one undo removes the effect with its values,
and a failing step rolls the whole call back. The reply is unchanged.
Test: add_effect_is_one_undo_step_and_all_or_nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
prop.setExpression keeps an expression with a syntax error but disables
it, and says why only in its own reply. MCP set_property answers with
prop.get instead, and prop.get reported `evaluated` / `expressionError`
only for enabled expressions, so set_property, get_property and prop.get
all showed the broken expression as if it were installed.
prop.get now reports a disabled expression too: `evaluated` is the
static value that renders, and `expressionError` is its syntax error.
Test: expression_syntax_errors_reach_the_reply (set_property and
get_property carry the SyntaxError; runtime errors and valid expressions
report as before).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`effectcraft-cli script FILE` passes the file's text to script.run, which
ran a compiled .jsxbin script as JavaScript and failed with "SyntaxError:
unexpected '@'" at 1:1, while file.runScript already says .jsxbin isn't
supported.
script.run now refuses a script named `.jsxbin` or whose text starts with
the `@JSXBIN@` header, with file.runScript's message (one shared
constant). The check is in the command, so the CLI with or without
--bridge, MCP run_script and the Script Console all report it.
Tests: engine_entry_points (by header and by name), script_file_and_eval
(the CLI exits 1 with the message and no SyntaxError).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cause: a `DockNode::Stack` laid its panels out from fixed heights (Preview
46 pt) with the rest shared, but only split gutters were interactive, so
the right column's panels could be expanded or collapsed and never resized.
Fix: the gap below a stacked panel drags the nearest open panels above and
below it against each other, neither going below 40 pt. The new sizes are
stored as the entries' heights, so they persist in the layout like split
sizes. The last panel without a height keeps sharing the rest, and when no
panel takes the rest, or space runs short, the heights scale so the stack
fills its column. Split and stack gaps share one `gutter` helper.
Automation ids: `dock.gutter.<path>s<i>` for the gap below stacked entry i.
Test: ui_tabs `stacked_panel_gaps_resize_the_panels` (drag, persistence,
collapse/expand, minimum); dock unit tests `stacked_panels_fill_their_column`
and `dragging_a_stack_gap_trades_height_between_its_neighbours`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cause: the click-to-edit fields (hot numbers and revolutions, the Timeline
time display and layer comments, Project rename/comment, the Render Queue
output path, Essential Graphics control names) opened a text edit with
egui's default caret at the end of the text, so typing appended to the old
value. The Timeline time display, layer comment and Essential Graphics name
editors also re-requested focus before checking `lost_focus()`, which hid
the loss, so Enter, Escape and clicking away never closed them.
Fix: a shared `widgets::keep_focus` keeps an open inline editor focused and
selects its whole text on the frame it opens (`widgets::select_all`, which
the layer rename now reuses). Those editors check for a lost focus first,
and Escape now cancels in the time display and the Essential Graphics
name editor.
Test: ui_angles `typing_replaces_the_clicked_value` (degrees and revolutions
in Effect Controls, Timeline and Properties, typed without Ctrl+A);
ui_inline_edit `project_rename_replaces_the_name` and
`timecode_field_replaces_commits_and_cancels`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conforming a footage file's audio (Settings ▸ Disk ▸ Conformed Audio
Folder, which preview playback reads) timed each 65,536-frame chunk as
`frame index × ticks per second / rate` in i64, which overflows past
36,310,516 frames: 12:36.47 at 48 kHz (25:13 at 24 kHz, hence the
workaround). Chunks after the overflow decoded at a wrapped, negative
time and came out silent, so preview audio stopped at 12:37.76 (the end
of the last chunk that started in time) in every long file.
Chunk times now come from Tick::from_units (exact, 128-bit). The
conformed files' names include a format version, so files written by
earlier builds are ignored and conformed again.
Test: conform_chunks_keep_their_times_on_long_files (every chunk start
of a 2-hour file at 24–96 kHz); it failed before (multiply overflow).
Checked end to end: a 13-minute file's conformed audio has sound at 760 s
and 790 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A frame whose last byte happened to match the superframe marker pattern
(0b110xxxxx, Annex B) was taken by decoders for a superframe with an
index at its end: ffmpeg rejected it ("Invalid le value at
frame_sizes[i]"), libvpx reported a corrupt frame, and later frames lost
their reference. In the reported WebM, 3 of 60 frames (10, 22, 24) ended
in 0xc8, 0xc0 and 0xca. The render still reported success.
Such frames now get a zero byte after the last tile; the last tile's
bool-coded data may end in zero padding (9.2.3).
Test: frames_never_end_in_a_superframe_marker encodes sequences until a
frame needs the padding, checks no frame ends in a marker byte, and that
the padded frames decode exactly with FilmCraft's decoder and with
ffmpeg (clean error log). It failed before ("96x64 q40 frame 22 ends in
0xde"). The ffmpeg IVF oracle is shared with the existing inter test.
Checked with the reporter's steps: 60/60 frames, no errors from ffmpeg's
vp9 or libvpx, RGB and RGB + Alpha.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Project panel (like other panels) runs comp.open while it draws, so the
new comp became the active comp mid-frame. Later in the same frame the
active, locked viewer drew and recorded the active comp as its own. On the
next frame on_open_comp compared that record with the opened comp, found
them equal and skipped the lock, so the locked viewer switched comps.
The active viewer no longer records its comp while an OpenComp event is
pending (it asks for a repaint instead); on_open_comp then sees the comp
the locked viewer really shows and routes the new one to an unlocked or
new viewer.
The viewers test's click helper now releases a frame after the press, as
a hand does: a press in a tab strip focuses the shown panel first, and a
same-frame press and release refocused it after the tab's own click.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Pan Behind gesture ignored modifiers other than Ctrl (snapping): it
always moved Anchor Point and Position together and never constrained the
move, and the tool's button had no double-click action.
- Alt held as the drag begins moves the Anchor Point alone (Position is
left as it is, so the layer shifts).
- Shift keeps the move to the axis dragged along most; Move and Pan
Behind now share that rule (one_axis).
- Ctrl+double-clicking the Pan Behind tool's button runs
layer.centerAnchor (Center Anchor Point in Layer Content).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Pan Behind drag passed its layer to the snapper's exclude list, which
dropped all of that layer's targets: its corners, edge midpoints and centre
as well as the anchor being dragged. The layer's box doesn't move in comp
space while the anchor pans (Position compensates), so those points are
valid targets.
The engine's per-layer snap targets are now one function, layer_targets,
which can leave out the anchor point. The gesture records its layer's
targets without the anchor as the drag begins and passes them to the
snapper (snap_with) as extra targets.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Selecting a property group also selects its layer, and edit.clear only
looked for selected keys, mask vertices, puppet pins and effects before
deleting the selected layers, so Delete with a text animator, mask or
shape group selected deleted the whole layer.
edit.clear now removes the selected removable groups (text animators,
masks, shape items, trackers) of the active comp in one undo step before
falling back to layers. It shares the removal with prop.removeGroup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The per-line tracking compensation in text_geom used only the animators'
Line Anchor, which defaults to 0% (left) when no animator has one, so
tracking on centred or right-aligned text pushed the line to the right
instead of spreading it around its alignment point.
Without a Line Anchor, a line now grows from where its own paragraph's
alignment pins it: 50% for centred, 100% for right-aligned (a justified
paragraph by its last line), read per paragraph rather than from the
document. The Line Anchor property still overrides it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
build::default_label picked a new layer's label from its type alone
(comps Sandstone, images Lavender, ...), ignoring the label of the Project
item it was made from.
A layer made from an item (footage, comp, solid, 3D model) now copies the
item's label when it is created; every path that adds an item to a comp
(layer.addItem from the menus, Project panel, Timeline and viewer drops,
models, precomps, imports) builds its layer there. Layers keep their own
label when the item's changes later. Layers without an item keep the type
defaults.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each Project panel row registered its click / drag area after the twirl,
proxy indicator, label swatch and comment cell drawn on it, so the row sat
on top and took their clicks: a folder's arrow selected the folder instead
of opening it, and the swatch never opened the label menu.
The row's area now goes in first and those widgets sit on top of it. A
single click on the arrow twirls the folder; a click on the swatch opens
the label menu (project.setLabel). Clicks, double-clicks and drags on the
rest of the row work as before; a single click on the comment cell still
selects the row, and a double-click there now edits the comment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its setup wrote the Timeline's old global reveal field, which no longer
filters on its own since reveals are kept per layer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The navigator drew handles on both ends but the whole bar was one drag
area that scrolled. Each end is now a handle (timeline.navigator.start /
.end) that moves that side of the visible span while the other stays:
pulled in, the ruler zooms in; stretched to the track's ends, it shows
the whole comp again. The middle still scrolls at the current zoom.
; (timeline.zoomFrameToggle) zooms in to frame level around the current
time and, pressed again, back out to the whole comp. timeline.zoomFit
stays as the plain fit command (no shortcut). Both go through set_zoom.
Tests: navigator_ends_drag_to_zoom and
semicolon_toggles_frame_level_and_the_whole_comp (real pointer drags).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Alt+wheel's zoom took its lower limit from the current zoom rather than
the comp's fit and kept the ruler's start where the pointer had put it,
so on long comps it stopped zoomed out on a slice from the middle of the
comp (0:05:02–0:10:27 of a 10-minute comp). The = / - zoom used a
different limit again.
Both now go through set_zoom: the limit is the zoom that fits the comp
(fit_pps, the same formula the ruler and the zoom slider use), and
reaching it switches the ruler back to fitting the whole comp from 0.
Test: alt_wheel_zooms_out_until_the_whole_comp_shows (real Alt+wheel
events on a 10-minute comp); it failed before (Some(1.135) px/s, start
still offset).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Layer ▸ Time ▸ Enable Time Remapping (and the layer menu's entry) added
the property and its two keyframes but left the layer twirled closed, so
nothing visibly changed. When the command turns remapping on, the
layers that have it now twirl open on Time Remap, through the same
per-layer reveal as RR. Turning it off reveals nothing.
Test: enabling_time_remapping_reveals_time_remap.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
U (and P, S, R, T, A, E, M, F, L) kept one reveal filter for the whole
Timeline and replaced the set of open layers with the selected ones, so
revealing on one layer closed every other layer and dropped their
reveals.
Each layer now has its own filter (TimelineState::layer_reveal; an open
layer without one shows its whole tree). A shortcut applies to the
selected layers (all layers with none selected) through apply_reveal and
leaves the others as they are; `reveal` stays the last shortcut's filter
for toggling and Shift-adding. U's results replace only the target
layers' earlier picks, so U after UU shows the keyframed properties
again. anim.reveal tells the Timeline which layers it looked at, so a
layer with nothing keyframed closes. Twirling a layer by hand, Ctrl+`
and Collapse All clear the filters they cover.
Test: reveal_shortcuts_leave_unselected_layers_alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#154: renderQueue.add (and render.addOutputModule) applied `format`, then
dropped it before applying the output path, so `--format hevc --out
x.mp4` saw the .mp4 extension and switched the module back to H.264
(and ProRes / VP9 for .mov / .webm). An explicit format now wins and a
mismatched extension follows it (x.webm → x.mp4 for AV1), as choosing a
format in the Output Module renames Output To; an extension that agrees
with the format keeps the name as given. The format error lists hevc and
av1 everywhere (one FORMATS string).
#155: a `comp` that names no composition (name or id) failed with "no
active composition". comp_id now reports it as `no composition "Nope"`
(EngineError::NoSuchComp), and comp_for_queue passes that on instead of
collapsing it.
Tests: an_explicit_format_wins_over_the_output_extension and
an_unknown_comp_is_named_in_the_error (engine). Checked with the CLI:
the files are hevc/hvc1 and av1/av01 and decode cleanly with ffmpeg.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With installed families whose names contain the query (JetBrainsMono
Nerd Font and its variants) the test expected exactly one match and
failed. It now checks that every match contains the query, the count
agrees, the query filters, and the bundled JetBrains Mono is there with
its Regular style.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Tools table gains history and script_ui, get_layer's depth,
add_keyframe's comp and the bridge screenshot's max_side and path.
agents_doc_tools_table_is_current (effectcraft-automation) fails when a
tool or an argument of its input schema is missing from the table, as
effects_doc_is_current guards docs/effects.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#139's bounded read_buffer polled the device without waiting and slept up
to 10 ms between polls, adding up to 10 ms to every readback (the desktop
viewer reads every GPU frame back): the gpu crate's tests took 227 s
against 119 s before. It now waits with PollType::Wait and the remaining
deadline as its timeout (a timeout still retires the context), then takes
the result, giving another thread that polled the same device up to 1 ms
to deliver its callback. The tests take 71–79 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Composition viewer ignored drags from other panels. Now, as in After
Effects:
- A Project item dropped on the viewer becomes a layer centred where it
lands (above the selected layer, like any new layer); while dragging, the
viewer is outlined and the layer's frame follows the pointer.
- Media Browser files do the same; files dropped from the OS file manager
do too when the platform reports where the pointer is during the drag
(otherwise they import as before).
- An effect from Effects & Presets goes on the layer under the pointer,
which is outlined while dragging.
layer.addItem and layer.newModel take `position` ([x, y] comp px).
file.import takes `addToComp` with `time`, `index` and `position`;
mediaBrowser.import shares it (file::add_to_comp): layered files go in as
their comps, the comp they were dropped in stays open, and items that
can't be layers are reported in `errors`. viewer::layer_at is the hit
test pick() and the drop share.
Tests: ui_drag_drop.rs (project item and effect dropped on the viewer)
and imported_files_go_into_the_comp_where_they_were_dropped (engine).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Project item (or Media Browser file) dropped in the Timeline always
went above the selected layer, starting where new layers start.
Now, as in After Effects, a line shows where it goes in the stack (above
the first layer row whose middle is below the pointer, else below the
last), and over the time graph a marker with a timecode tag shows its In
point, which follows the pointer (frame-snapped) or, with Shift, the
current time. The layer reorder drag shares the insertion logic.
layer.addItem and layer.newModel take `index` (1-based stack position);
mediaBrowser.import passes `time` and `index` on, one layer under the
other.
Tests: project_items_land_where_they_are_dropped_in_the_timeline
(ui_drag_drop.rs, real pointer drags: outline, time graph, Shift, below
the last layer) and add_item_at_a_stack_index_and_time (engine).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop app shares egui-wgpu's device, which had no uncaptured-error
handler, so wgpu's default one panicked on an out-of-memory error on
every frame thread; the panicking frame stayed "in progress" forever and
never rendered again. The GPU frame budget was a fixed 1 GiB.
- GpuContext::new logs uncaptured errors once its pipelines are built
(errors while building them still panic there, which the app catches
to fall back to CPU compositing).
- Gpu::within_memory runs GPU work in an out-of-memory error scope; on an
error the result is discarded and uploaded layers and pooled textures
are freed.
- Frame workers render each frame within it; out of memory, the frame
renders on the CPU and the RAM preview keeps half as many GPU frames
(down to 64 MiB), since wgpu can't tell how much video memory there is.
- A frame whose render panics renders again on the CPU, or is released,
so the viewer asks for it again.
Tests: a_frame_whose_render_panics_is_released (failed before),
running_out_of_video_memory_halves_the_gpu_frame_budget and
within_memory_returns_the_work_when_there_is_room.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Commit c9d5e733 (M13.4, hevcenc deblocking) committed 207 files under
crates/hevcenc/target/agent-codec-hevc/: an agent's CARGO_TARGET_DIR with
rlibs, rmetas, build-script outputs and .rustc_info.json (~36 MB). They
were tracked because .gitignore only ignored the root `/target`.
- git rm --cached the directory; nothing references it.
- .gitignore: `/target` -> `target/`, so a per-crate or per-agent target
dir is ignored anywhere in the tree. No tracked file lives under a
directory named `target`, so nothing else becomes ignored.
The blobs stay in history; this keeps them out of every new checkout and
stops the next agent run from committing more.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Toggling a layer's Audio, Lock or Shy switch (or Hide Shy Layers) threw
away every cached frame: the RAM preview identity hashes each comp's
address, which any edit replaces, and the layer and disk cache keys
hashed every switch. None of these change pixels.
- Switches::pixels, Layer::same_pixels and Comp::same_pixels (exhaustive
field lists, so a new field must be considered) and Comp::pixel_form.
- Layer cache keys hash Switches::pixels; the disk cache's comp content
key hashes Comp::pixel_form.
- Frames::identity: when a new revision's comps draw the same pixels as
those of the comp's last new identity, that identity (and the project
holding its comps' addresses) is kept, so the frames stay, also when the
switch is toggled back.
With audio on, playback skipped frames that weren't rendered yet. Now the
sound waits: until the frames ahead (to the end of the preview, or what
fits in the RAM preview) are cached, every frame shows as it renders,
silently, like a preview without audio; then the sound starts in sync.
Reaching an uncached frame stops the sound instead of skipping frames.
Tests: audio_lock_and_shy_switches_keep_the_frames (frames.rs, nested
comps and toggling back), audio_lock_and_shy_keep_cached_layers (engine),
the disk key in tests_disk_cache, and
preview_with_audio_shows_every_frame_and_sounds_once_cached.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An angle's revolutions were painted text: only the degrees could be
dragged or typed, so full turns needed the dial or typing past 360.
fx_widgets::angle_field draws `0x+45.0°` as two hot numbers: the
revolutions (widgets::hot_int_at, ten pixels a turn, fractions adding up
between frames so slow drags still step; a typed value is truncated) and
the degrees, each keeping the other. Effect Controls, the Timeline and
the Properties panel use it, so they read and edit angles alike (the
Timeline showed `-1x-40.0°` and Properties had its own split).
Automation ids: timeline.prop.<uid>.revolutions,
effectControls.prop.<uid>.revolutions, properties.prop.<uid>.revolutions.
Regression test: angle_revolutions_scrub_and_take_typing drags and types
the revolutions in all three panels.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Choosing Draft Settings (or any entry) in a Render Queue item's Render
Settings menu did nothing when the menu had been moved up to fit the
window. egui hit-tests a fixed-position Area where it was asked to go,
not where it was moved, so the popup's "pressed outside" check was true
over its own entries and the menu closed on the press, before the click.
widgets::pressed_outside tests the press against the rect the popup was
drawn at. Every popup with the same check uses it: popup_menu (Render
Queue, Effect Controls, dropdowns), the colour popup, the Preview and
viewer popups, the panel menu, the Flowchart, OpenType and font menus.
Regression test: ui_render_queue.rs picks Draft Settings from a menu
moved up to fit a 1000 px window; it failed before the fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Use checked hex slices so prefs.set follows the existing invalid-colour fallback when a component splits a UTF-8 character. Cover invalid Unicode placements and valid mixed-case colours through the command.
Co-Authored-By: OpenAI Codex <noreply@openai.com>
effect.apply / effect.applyLast run through the menu dispatcher (Effect
menu, Last Effect, an effect dropped on a Timeline layer or on Effect
Controls) now bring up Effect Controls, opened if closed and brought to
the front without taking the focus, on the layer the effect went to
(selecting it if it was dropped on an unselected layer, keeping the new
effect selected). Double-clicking an effect's name in the Timeline opens
it in Effect Controls (layer.select, prop.select, window.panel) instead
of twirling it, as in After Effects.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 04bd305b9dccbb34d8c70fe870513208cdae0b7b)
Opening a comp (Project double-click, Open Composition, a Timeline tab)
now reopens the Composition panel if it was closed and brings it (and
the Timeline) to the front, as in After Effects. A closed Composition
panel also reopens in the centre (with the Layer panel or other viewers,
else above the Timeline) instead of beside Effects & Presets, which also
fixes Window > Composition.
App::raise_panel opens/fronts a panel without moving the focus;
show_panel builds on it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit adf2c6dac78ce426ea0bf6b4199b7238d8ad99dd)
A Windows user reported that the Properties panel's font menu offered only the bundled Inter,
JetBrains Mono and Noto Serif. The system font folders were scanned only when a project asked for
a family the bundled fonts lack, so until then every font menu (Properties, Character, Essential
Graphics) and `file.findMissing {what: fonts}` saw the bundled families alone, on every
platform. Listing the families now scans the system fonts first, and the desktop app starts that
scan on a background thread at launch so the menus are complete when first opened.
The style menus in the Properties and Character panels list the chosen family's own styles
instead of a fixed Regular / Medium / SemiBold / Bold / Italic. Both menus build their lists only
while open.
New query for agents: `text.fonts {query?, rescan?}` (MCP tool `list_fonts`) lists every family
with its styles, origin (bundled / system / user) and own-language name; `rescan` picks up fonts
installed since launch.
Tests: a fresh-process test that the family list includes every family in the platform's font
folders without a prior scan (failed before), and `text.fonts` filtering, rescan and per-family
styles, and the `list_fonts` MCP tool.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EjTYdt76hTG4Kh2CJFnCr
A selected pin was drawn only 1 px bigger and slightly brighter. Now, like
selected and unselected mask vertices in the viewer, a selected pin is
filled with its kind colour and ringed in white, and an unselected pin is
hollow (dark centre, coloured outline). Drawing only; hit-testing is
unchanged.
Regression test: selected_puppet_pins_are_filled_and_unselected_hollow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 103b5c41e9e8ea84008593e05af50b01a2800b3e)
Clicking an effect's name selected it (prop.select with the effect's group
uid) but group rows never painted the selection, so there was no feedback.
Group rows (effects, masks, shape groups...) now fill with row_selected when
selected, exactly like selected property rows.
Regression test: timeline_selected_effect_row_highlights.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 8b140428b39a24c3e1207ffde0430e759e316112)
- Project panel Clear (#95's focus routing) goes through invoke, so deleting
items that compositions use still asks first (M3.16).
- ui_project_delete: let a dialog finish egui::Modal's sizing frame before
clicking its buttons (#95 moved dialogs to egui::Modal).
- #71's i18n / theme tests clear the frame's texture delta; egui asserts on
unhandled deltas in debug builds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rust 1.99 renamed AtomicU32::fetch_update to try_update; try_update is not
available on the workspace's minimum Rust (1.95).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 2232354b902238827b2e80a29ef266c2e5bde272)
(cherry picked from commit 95da7943454ca5b2575993d11f9ba046822ce255)
As in After Effects, the Delete key and the Project panel's trash button ask before deleting
items that layers in other compositions use, saying how many layers in how many compositions go
with them (Delete / Cancel; Escape cancels). Unused items are deleted at once; agents running
project.delete directly are not asked. project.usage {items?} reports the count, sharing the
deletion's item and usage helpers. Follow-up to #72.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japanese (and Chinese, Korean) text in a family that lacks the glyphs, such as the default Inter,
rendered as missing-glyph boxes: `fallback_for` only searched the bundled faces and faces some
other layer had already loaded, so the result depended on render order. Now, after those cheap
candidates, it tries the platform's usual family for the character's writing system (kana and
hangul pick their language; Han ideographs follow the locale, Japanese first when it is unknown,
`EFFECTCRAFT_CJK_LOCALE` overrides) in the preferred face's style, and finally any installed face
that covers the character, reading files without keeping them. Results are cached per 256-character
block and preferred face.
`resolve` also accepts a face's full name (`Yu Gothic Bold`) or PostScript name (`YuGothic-Bold`),
which is how After Effects scripts set `textDocument.font`; the sfnt reader now records name id 6.
Tests: script classification, PostScript / full-name resolution on the bundled Inter, and (native,
skipped when no Japanese family is installed) that kana, ideographs and CJK punctuation in Inter
Bold fall back to an installed Japanese family's bold cut, with the cache returning the same face.
`cargo xtask ci` runs clippy and the tests with --release, where egui has no Style::debug (it
exists only with debug_assertions), so ui_agent_audit.rs failed to compile since M13.14. The
debug option is set only in debug builds, and the test, which needs the widget types egui records
with it, is ignored in release builds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
As in After Effects, a double-click below the items in the Project panel opens File ▸ Import ▸
File... (rows and the scroll bar keep their own clicks). Dropping files on the window already
imported them; the test now covers both. (On Wayland, winit delivers no file drops at all, so
dragging files in works on X11, macOS and Windows.)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The details header (item name, size, duration, codec) and the "Select an item" hint were painted
with no width limit, so in a narrow Project panel they ran past its right edge. They now stop
10 px short of it and end in "…" when they don't fit (widgets::text_fit, a shared helper), and
are registered for automation (project.details.*).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Covers what issue #46 reports (v0.1.1 on Linux): a panel dragged by its hamburger onto another
group's edge docks there as its own group, and dragging the gap between panels resizes them.
Both work on main (tab dragging was reworked in M3.15 for v0.2.0); this keeps them working.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While paused, the app prefetched the frames around the current one as soon as the viewer's
frame finished. During a drag every step changes the project, so those frames were stale at the
next step, and they held the CPU and GPU the next viewer frame needed: on the demo project a
drag step took 102 ms on average to show (up to 413 ms) though a frame renders in 20 ms. Paused
prefetch now waits until edits pause for 0.3 s: 16 ms a step (11 ms render).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two implementations of Show 3D Reference Axes had landed (M13.5 and M14.5) and both drew,
offset by a few pixels: lines, and arrows. Keep the arrowed one (perspective-correct, shown
while a 3D layer is active) and test that only one is drawn.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The handle drag rescaled incrementally from the current scale every frame: errors built up,
edge handles scaled both axes, and once a factor reached ~0 the drag could never bring it
back. It also tested the handle at the point where egui recognised the drag (past the drag
threshold), so a quick grab often missed the handle and moved the layer instead.
Now, as in After Effects, the drag scales about the anchor point relative to the layer as the
drag began, so the grabbed point stays under the pointer: corners scale both axes, edges one,
Shift keeps the proportions, and dragging past the anchor flips. Selection-tool gestures start
at the press point.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sysctl hw.physmem / hw.pagesize / vm free+inactive page counts, hw.model,
and freebsd-version, with a unit test for the sysctl parser. The FreeBSD
CI workflow itself landed with #69.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
egui-wgpu creates its device on GL with WebGL2's limits (no storage buffers), so the desktop
app on FreeBSD built the compositor's pipelines on it: validation errors (only logged in
release builds) and wrong frames. GpuContext::new now checks the limits its pipelines bind
(storage buffers and textures per stage, bind groups, workgroup sizes) and returns Err, which
keeps the app on the CPU. ui_gpu skips its GPU-vs-CPU comparison when that happens on GL.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FreeBSD's CI VM (Mesa llvmpipe over GL) failed 22 effectcraft-gpu tests:
- Advanced 3D's camera-depth target was R32Float, which GLES can't render to without
EXT_color_buffer_float: create_render_pipeline failed validation (release builds only log
that and draw nothing). The target is now R32Uint holding the f32 bits (renderable on every
backend, lossless), and GpuContext checks the adapter's format features (render attachment,
blendable colour) and independent blending up front; when they're missing the render
pipelines are never built and Advanced 3D renders on the CPU.
- Signed `%` in the shaders: naga emits GLSL `%`, undefined for negative operands, which broke
the VR effects' horizontal wrap and Cell Pattern's tiling. A shared `imod` never takes `%` of
a negative number.
- asin/acos: Mesa's are coarse approximations (~1e-4 rad), visible in every lens/sphere/cylinder
mapping. `asin_p`/`acos_p` (Cephes' asinf polynomial) are f32-accurate on every backend.
- "Could not lock adapter context": the GL backend has one context lock per adapter with a
timeout, so tests driving devices concurrently panic. Every gpu test helper that hands out a
device holds one process-wide lock for the calling test; ui_web_job_gpu's two device tests
share a lock too.
- On GL, up to 1 % of pixels may flip across a decision boundary (division through a
reciprocal, unfused fma); the GPU-vs-CPU comparisons allow that there only.
Also adds the FreeBSD workflow (from the FreeBSD CI PR) so this branch is checked there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- effectcraft-segment: a FaceModel interface (find a face in a region, follow it; a Topology
maps the model's points to the tracker's landmarks and outline), a TensorFlow Lite reader and
interpreter (float ops, float16 weights folded; matches ai-edge-litert to ~1e-6), and
MediaPipe Face Landmarker (Google, Apache-2.0: BlazeFace + Face Mesh V2, 3.8 MB, pinned
SHA-256). The registry gains a task and authors per model; installed weights get a NOTICE.
- effectcraft-track: FaceTracker::new_with uses the model, falls back to the classical fitter
when it finds no face; chin and jaw come from the outline either way so measurements agree.
- Engine: roto_models becomes models (both tasks); face.models / face.model.* commands,
Settings ▸ Face Tracking, `wait` on select/install/download for scripts; track.mask reports
faceModel.
- Credits: ATTRIBUTION.md lists the optional models and the Apache-2.0 specs the code follows.
- Docs: README, ROADMAP (G7), gaps, parity (TRK-3 score unchanged pending G1), agents,
preferences, architecture, segment README.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- New L1 crate effectcraft-segment: the MaskModel interface (prompts → foreground probability),
a registry of open-licensed models (licence must be MIT/Apache/BSD/…; URL, size, SHA-256),
a PyTorch checkpoint reader (zip + pickle subset), dependency-free SHA-256, and MobileSAM
(TinyViT-5M + SAM prompt encoder / two-way transformer / mask decoder) on rayon and ndarray's
safe GEMM. Loads the official mobile_sam.pt as published; checked against candle
(embedding 0.3% RMS, 99.9% equal mask pixels); ~0.7 s/frame encoder at 32 threads, cached
per frame, ~0.1 s decoder.
- track::roto: segment_with / propagate_with prompt a model with the strokes or the flow-warped
matte (box, core points, mask); its probability is a strong, unblurred prior for the same
graph cut (strokes stay hard constraints); a failing model or one disagreeing with the flow
(IoU < 0.5) is ignored. Prior gets a weight.
- effects::roto: Version 2.0 / 3.0 use the model chosen in Settings (set_model / model_for);
the model id is part of the chain seed.
- engine: roto_models (roto.models, roto.model.select / install / download / remove), weights in
the models folder (host::config_dir()/models, shared by the app, CLI and MCP), background
loading, downloads through the system curl, verified; Settings ▸ Roto Brush page (prefs
roto.model). host::config_dir moved from the desktop app.
- Tests: segment (kernels vs direct sums, checkpoint reader, SHA-256 vectors, registry), track
(fake model: prior shapes the cut, failing / disagreeing models ignored; real weights with
EFFECTCRAFT_MOBILESAM: base IoU 0.989, propagated ≥ 0.980 vs classic 0.973), engine (registry,
verification, selection; real install + load), MCP.
- Docs: README, ROADMAP, parity (MSK-4 done: 90/2/0), preferences, agents, architecture,
crates/segment/README.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- audio::AudioScrub: an open output that plays a faded snippet (one frame, 30–100 ms) of the
comp's mix at each new frame, replacing what is still queued; holding still repeats nothing.
The mixing is shared with the preview feeder (mix_block); AudioFeed::clear.
- The Timeline ruler: Ctrl/Cmd-drag scrubs audio (EffectcraftApp::scrub_audio opens the output
through the audio device hook on first use). The Audio panel meters follow the snippets; an
idle second closes the output.
- playback.scrubAudio {time?} for agents.
- Tests: ui_audio_scrub (snippet length, fades, same-frame no-op, replace not pile up, silent
comps, Cmd-drag in the ruler, idle close) with a fake device. Docs: parity.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- commands::markers::nested_markers: a precomp layer's comp markers at their times in the
containing comp (start and stretch in closed form; with time remapping or a stretch over
protected regions, the first frame whose source time reaches them), within In–Out.
- markers.nested {layer}: the same list for agents.
- Timeline: drawn on the precomp bar as outlined read-only markers (positions cached per
revision), hover names them, double-click opens the nested comp at the marker; automation id
timeline.layer.<id>.nestedMarker.<i>.
- Tests: engine (start, stretch, trim, time remap, non-precomp error), UI (draw, label, open).
Docs: parity, control protocol.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- PanelKind::Viewer(n): more Composition viewers (panels::viewers). One is active (the full
interactive viewer, showing the active comp); the others show their own comp's frame at that
comp's current time (CPU or GPU frames, their own textures) and become active on a click or
their tab, their comp becoming the active comp.
- view.newViewer (View menu, the Composition panel menu, Alt+Shift+N): a new viewer of the
active comp next to the one in use, which is locked. Opening a comp while the active viewer
is locked shows it in an unlocked viewer, or a new one when every viewer is locked. Closing
the active viewer hands over to another with its comp. Tabs are named after their comp, with
their own lock and swatch; Preview full screen maximizes the active viewer.
- UI state: viewers (comp per viewer) and active_viewer (serde).
- Tests: ui_viewers (lock on New Viewer, routing, click to activate, all locked → new viewer,
close hand-over, tab titles; an ignored snapshot). The bounded-drag frames test asserts what
holds when freed addresses are reused. Docs: parity, control protocol.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- FrameKey's identity is the comp's content (frames::comp_content), not the project revision:
an edit keeps the cached frames of comps it doesn't touch and undo finds the frames of the
state it returns to. The identity hashes the addresses of the comps a comp draws (~0.1 ms on
the 1500-layer test project; the full content hash took ~117 ms), by value the footage, solids,
proxies and project settings; with expressions, every item. Frames::content_of keeps a
project snapshot per identity with frames (or in flight), so Arc::make_mut always copies an
edited comp and its address can't be reused meanwhile; past 64 identities the least recently
used go with their frames (long drags).
- drop_stale is gone (frames of other contents are evicted least-recently-shown first).
- disk_cache::comp_content_key covers items' proxies and Use Proxy (it served stale frames).
- Tests: frames (content keys, edits elsewhere and undo, bounded drags, in-place edit safety),
ui_ram_preview (end to end), the disk cache proxy key. Docs: roadmap (M4 done), parity.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Home covers the whole workspace below the Tools bar (the panels wait behind it) instead of
the Composition panel.
- Left rail: New Project (primary) and Open Project, the Home / Templates / Learn pages with
icons, and the community links and ArtCraft apps at its foot.
- Home page: "Welcome to EffectCraft", quick-start tiles (New Composition, Open Demo Project,
Import Footage, New from Template) and Recent as a table — thumbnail, name and folder,
Opened (relative), Size, Kind — with a filter field, Clear list and an empty state.
- Theme tokens throughout (light themes follow); web_time for "now" (works on the web).
- Tests: formatters (opened / size / kind) and the new automation ids. Docs: parity.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- paint_logo draws docs/brand/artcraft-mark.png (the README's / getartcraft.com's ArtCraft
mark) as supplied, decoded once into a mipmapped texture, instead of a drawn placeholder.
- The "More ArtCraft apps" sibling and help.sibling named DrawCraft (storytold/drawcraft, which
doesn't exist): it is VectorCraft (storytold/vectorcraft); provenance comments in the path
crate follow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Collapsed precomps: the renderer records the precomp layers a nested comp is drawn through;
motion-blur sub-samples re-evaluate their transforms (when their motion blur is on) and map
times down through each one, so the precomp layer's own motion blurs its nested layers. The
containing (outermost) comp's shutter applies to collapsed layers. The GPU compositor gets
this through Renderer::placement.
- Shape and text layers whose content changes within the shutter (compared by its cache key)
draw their source at Samples Per Frame times and average them; the cache key covers every
sub-sample's content.
- Tests: tests_motion_blur_depth (collapsed vs flat, the containing shutter, shape content vs
the same move by Position); each fails without the change.
- Docs: roadmap, parity.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The Timeline shows one tab per open comp (dock DocTab). Clicking a tab shows that comp; ×
or middle-click closes that comp's Timeline (comp.close), not the panel. Opening a comp brings
its viewer and Timeline tab to the front.
- Project panel: a double-click on an item's name opens it (a comp gets its own tab) instead of
renaming; Enter and the context menu rename.
- Tab drags: over a tab strip an insertion mark shows where the tab lands, and it lands there
(Layout::insert_tab), which also reorders a group's own tabs; side zones are unchanged.
- Tests: ui_tabs (double-click, tab switch / close, drag reorder and cross-group insert; an
ignored mid-drag snapshot); the Timeline close test follows the new behaviour.
- Docs: parity, control protocol.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- effect.pickColor {effect, param|prop, x, y (layer px), average?}: the colour of the effect's
input at a point, set on the parameter (one undo step). Keying effects' Effect Controls
eyedroppers use it, since the shown frame is already keyed; Ctrl/Cmd+click averages 5x5.
- effects::aliases / name_matches: Effects & Presets and effect.list (MCP list_effects) find
Key Light by After Effects' name ("Keylight (1.2)").
- End-to-end test: a green-screen plate with an uneven screen, spill and soft edges keyed via
effect.apply "Keylight (1.2)", pick, Clip Black / White and a render; UI eyedropper test.
- Docs: parity, agents (green-screen recipe).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- J / K (Go to Previous / Next Keyframe) and Ctrl+Alt+A (Select All Keyframes) act on what the
Timeline shows, as in After Effects: the keys of its revealed properties, layer and comp
markers and the work area (they used every property of the selected or all layers, revealed
or not). The commands take `visible: [{layer, prop}]`, which the Timeline fills in; agents
calling them without it keep the previous behaviour.
- Graph Editor ▸ Auto-Select Graph Type (an "Auto" button, on by default as in After Effects):
the speed graph when every property shown is spatial (Position, Anchor Point…), else the
value graph; choosing Value or Speed turns it off.
- Docs: parity, agents.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Go to Next / Previous Keyframe (K / J) also stops at the work area's start and end, as in
After Effects.
- With one keyframe selected, the Info panel shows its property, time and value instead of
"1 keyframes selected".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Keys copied from several layers and pasted onto as many selected layers go first to first,
second to second, as in After Effects; every target used to receive every copied property
(the later ones overwriting the earlier).
- Edit ▸ Paste Reversed Keyframes mirrors the clipboard over its span and pastes it through
`keys.paste`, so it now follows the same rules as Paste: the layer's time stretch, the
selected target property, Hold-only properties, value conversion and roving keys (its own
simplified paste ignored them and kept existing keys' easing on a time collision).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Double-clicking a keyframe of a numeric property (or its context menu ▸ Edit Value…) opens
the property's value dialog for that key, as in After Effects (it only moved the current
time); other keys still move the current time to them.
- The keyframe context menu has Select Equal Keyframes, Select Previous Keyframes and Select
Following Keyframes (`keys.selectEqual` / `selectPrevious` / `selectFollowing`).
- Roving keys draw as small dots in the Timeline, like After Effects.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `keys.transform {fromStart}`: each step of a merged drag gives the whole transform since the
drag started and applies to the keys as they were then. Keys squeezed onto one frame on the
way (Alt-drag scaling, the transform box) are no longer merged for good, and a slow
transform-box move, whose per-frame offsets rounded to nothing, now moves the keys.
- Dragging a key in the Graph Editor moves every selected key in time and value (it moved only
the grabbed one), by how far the pointer went from where the button went down (the key no
longer jumps under the pointer); Shift keeps the drag to the time or the value axis.
- Shift+click toggles a key in or out of the selection; Ctrl+click switches Linear ↔ Auto
Bezier and Ctrl+Alt+click toggles Hold, as in the Timeline.
- The transform box and the Timeline's Alt-drag scale send whole-gesture values.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A drag (keys.move steps sharing a merge key) applies its total offset to the keys as they
were before it (`Session::key_move`, `prop::shift_keys`): dragging over another key of the
property no longer deletes it; only the drop position replaces a key, as in After Effects.
- Keys of time-stretched layers move with the pointer: offsets are applied in comp time and
snapped to comp frames (drags and Alt+Arrow nudges; they moved by the layer-time distance).
- Shift+click toggles a key in or out of the selection (`keys.select {toggle}`); Ctrl+click
switches Linear ↔ Auto Bezier and Ctrl+Alt+click toggles Hold.
- Keys of locked layers can't be selected (clicks, marquee, Select All Keyframes), so nothing
edits them.
- Toggle Hold off returns an eased key to Bezier (it always restored Linear).
- Shift-dragging a key snaps to everything the current time snaps to — other keys, layer in /
out points, layer and comp markers, the work area — and a tooltip shows the key's time and how
many frames it moved.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each reading starts PowerShell on Windows (in the background since the previous commit); the
cache budgets only need to follow memory pressure, not track it closely.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The windowing layer delivers Ctrl+C, Ctrl+X and Ctrl+V as clipboard events instead of key
presses, and the shortcut dispatcher only read key presses: in the app, copying and pasting
keyframes, layers and effects with the keyboard did nothing (tests passed because they inject
key presses). Clipboard events now map back to their keys with the modifiers held (so Ctrl+Alt+C
and the other variants work too), except while text is edited in the viewer, which handles them
itself. Ctrl+V sends no event while the system clipboard is empty, so copying now also puts a
line naming what was copied on it ("EffectCraft: 3 keyframes").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dragging a keyframe moved it a frame or two and then let go: the key's widget id includes its
time, so after the first move it was a new widget and egui ended the drag. The Timeline now
follows the whole gesture itself (`KeyDrag`: from where the button went down, by whole frames,
every selected key, one undo step), like After Effects. Holding Shift snaps the grabbed key to
the current time, the work area and composition markers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every 10 seconds the UI thread re-read the system's memory for the cache budgets, and on
Windows that starts PowerShell (`Get-CimInstance Win32_OperatingSystem`) and waits for it:
about a second, sometimes two, during which the whole app froze. The reading now runs on a
background thread (`sysinfo::MemoryWatch`); `Session::memory_tick` takes the last reading and
starts the next one, never waiting. Console programs started for it no longer open a console
window (`CREATE_NO_WINDOW`).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`Project::same_content` ignored `next_id`, so an edit that only took an id was dropped as a
no-op and the id could be handed out again (an edit may keep it beyond the project).
`guard::tests::the_session_survives_a_panicking_edit`, merged after M3.9, failed on main for
this reason. Ids now count; setting a value to what it already is still records nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The web app reopens the last visit's project and, when it had unsaved changes, marked it
modified with `bump()`. Since M3.9 the modified mark follows the saved snapshot, and a bump on
the very project just opened counts as "back at the saved state", so restored changes looked
saved (no `*`, no unsaved-changes prompt). `Session::mark_unsaved` forgets the saved snapshot
instead: the project stays modified until saved, whatever is undone. Regression test included.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>