Compare commits

..

326 Commits

Author SHA1 Message Date
bflatastic e95780ca14 Import: Interpret Footage has Start Timecode, as in After Effects (#297)
After Effects' Interpret Footage has Start Timecode: Use Source File
Timecode, or Override Start, which changes the timecode the footage
shows and leaves its frames alone. The Start Frame field that trimmed or
padded a sequence's head isn't in After Effects, so it is replaced.

Footage.start_timecode (Override Start, in frames at the footage's
rate; None = Use Source, 0 as file timecode isn't read) relabels source
time: the Footage panel's current time and footage.info's `timecode`
count from it. file.interpretFootage takes `overrideStart` and
`startTimecode` (timecode, read at the footage's rate after any frame
rate change, or a frame number, 0 to 1e9). It applies to every footage
with time, not only sequences, and Reload keeps it. A sequence's frames
always run from its first file's number to its last.

Test: start_timecode_only_relabels_source_time (project),
interpret_footage_start_timecode_relabels_source_time (engine: the
reply, the Footage panel's timecode, frame numbers, Use Source, the
footage's rate, bad values, undo), Reload keeps it, and the Interpret
Footage fields in ui_drag_drop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:50:13 +07:00
bflatastic 77af6d1a78 Import: missing sequence frames show a placeholder, as in After Effects (#297)
After Effects shows a missing frame of an image sequence as a
placeholder (colour bars) and has no Hold Previous Frame or Skip choice
in Interpret Footage, so EffectCraft doesn't either.

Interpret Footage loses its Missing Frames field, file.interpretFootage
its missingFrames parameter, and Footage its missing_frames setting. A
gap in a numbered sequence's numbering always shows colour bars. A
sequence imported with Force Alphabetical Order still plays its files
one after another (its numbering doesn't count), as in After Effects;
that is now an internal Footage.alphabetical flag, set on import and
kept by Reload. The Interpret Footage note lists the missing frames.

Projects saved before this have no such field and open with the After
Effects behaviour: a sequence with gaps shows placeholders where it
used to close them up.

Test: project sequence unit tests (placeholders, alphabetical order,
projects saved without the flag), tests_sequence (Start Frame instead
of Hold / Skip), sequence_gaps_show_placeholders (media) and the
Interpret Footage fields in ui_drag_drop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:54:00 +07:00
bflatastic 736fa4ac3c Import: image sequences with a Sequence option, ranges, folders and missing frames (#297)
Image sequences were found only by the desktop media prober: one picked
numbered still brought its whole run, with no way to import just that
frame, picking every frame made one sequence item per frame, the browser
couldn't make sequences at all, and a gap in the numbering closed up
silently.

The engine now groups numbered stills itself (engine/src/sequence.rs):
one picked file brings its run from its folder, several picked files of
one run import as that range, a picked or dropped folder imports its
files with each run as a sequence, and Force Alphabetical Order takes
every image of that type in the folder by name. `file.import` takes
`sequence` (default on), `alphabetical` and `frameRate`; Replace Footage,
Reload, proxies and render outputs probe the same way. The media layer
probes one file; the browser lists its file table for the run and
registers every frame with its media pool.

File > Import > File... asks, like After Effects' "PNG Sequence"
checkbox, whether picked numbered stills import as a sequence, with
Force Alphabetical Order and the frame rate. Sequences are named
`shot_[0001-0250].png`.

Interpret Footage gains Missing Frames (Show Placeholder, colour bars as
in After Effects, the new default; Hold Previous Frame; Skip) and Start
Frame (the frame number at the footage's first frame: trims or pads the
head). Saved projects keep Skip, so their sequences play as before.

Form notes keep a readable width and the form fits its rows, so OK stays
in view. docs/footage.md explains importing footage and sequences.

Test: tests_sequence (engine: run, picked range, every frame, Sequence
off, folder, alphabetical, Interpret Footage Missing Frames / Start
Frame, Replace Footage and Reload), project sequence unit tests,
sequence_gaps_show_placeholders_hold_or_close_up (media),
import_dialog_offers_the_image_sequence_option and
dropped_frames_import_as_one_sequence_with_sequence_interpretation (UI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:53:44 +07:00
BFlat d5f78f3fc0
Merge pull request #391 from storytold/fix/279-275-284-ui
Menu-bar keyboard, comp marker bin, UI Scale and Settings scroll (#279, #275, #284)
2026-10-09 18:44:55 +07:00
bflatastic 65d2c34e54 Menus: the Project panel's footage menu passes the keyboard navigator (#279, #290)
The footage item's context menu from #383 builds its entries with
menu_nodes, which #391 gave the menu bar's keyboard navigator and
depth; like the other context menus it passes a fresh one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:38:39 +07:00
bflatastic 7a9c459e42 Settings: Appearance > UI Scale sizes the whole interface (#284)
Settings > Appearance > UI Scale (appearance.uiScale, 75-200 %, default 100)
scales the whole interface on top of the display's own scaling, live as it is
chosen; Cancel puts it back, OK saves it and a saved scale applies from the
first frame. The Settings dialog's choice rows show a number setting with
choices as its text. Automation id: settings.appearance.uiScale.

egui's own Ctrl+= / Ctrl+- / Ctrl+0 zoom is off now: Ctrl+0 (Window > Project)
also reset the zoom behind the app's back, and the scale is a setting.

Test: engine tests_settings ui_scale_is_a_percentage_from_75_to_200; ui_panels
ui_scale_setting_sizes_the_interface.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:34:27 +07:00
bflatastic 8e4d9988af Settings: the dialog no longer scrolls sideways (#284)
The page column had a fixed 590 pt width, a few points more than the dialog had
beside the page list, so the whole Settings dialog scrolled sideways a little.
It takes the rest of the dialog's width now.

Test: ui_scrolling settings_dialog_does_not_scroll_sideways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:34:27 +07:00
bflatastic 7056801eb2 Timeline: the composition marker bin adds comp markers where they are dropped (#275)
As in After Effects, the right end of the Timeline's time ruler has a
composition marker bin (an original icon drawn in code). Dragging out of it
shows a marker following the pointer along the ruler's marker strip, snapping
like a dragged marker (current time, work area, other markers); releasing over
the time ruler's width adds a composition marker there with comp.addMarker (one
"Add Marker" undo step). Dropped back on the bin, nothing is added. The time
graph ends 18 px before the panel edge to leave the bin a column above the
layers' scroll bar. Automation id: timeline.markerBin.

Numpad * (or * typed with Shift+8), the keymap's Add Marker, now works in the
window too: egui has no key for it, so it comes as text.

The marker glyph and colour are shared helpers now (markers, nested markers,
the dragged marker).

Test: ui_timeline_zoom dragging_out_of_the_marker_bin_adds_a_comp_marker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:34:27 +07:00
bflatastic 6bb9bd8dab Menus: arrow keys and Enter drive the menu bar; shortcuts wait while a menu is open (#279)
The in-window menu bar now works from the keyboard as After Effects' does on
Windows: Alt pressed and released on its own focuses the bar, Left / Right move
between its menus and Down, Enter or Space opens one. In an open menu Up / Down
move the highlight (skipping disabled entries, wrapping around), Right opens the
highlighted submenu and Left closes it (on a plain entry they open the next /
previous menu), Enter or Space chooses the entry and Escape closes the menus.
Hovering an entry moves the highlight there, and a highlighted entry scrolls
into view in long menus.

While a menu, context menu or popup list is open, or the bar has the keyboard,
keys go to the menus only: the app's shortcuts don't run (Space started the
preview behind an open menu) and the panels don't see the keys (Enter on an
entry no longer reaches the Project panel's rename).

Tall menus already scroll inside the window since #277 (ui_scrolling's
a_menu_taller_than_the_window_scrolls). F10 stays View > Switch 3D View > Front,
as in After Effects.

Test: ui_menus (shortcuts_wait_while_a_menu_is_open,
arrow_keys_drive_the_menu_bar, enter_chooses_the_highlighted_entry).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:34:27 +07:00
BFlat 4aaadd0ed3
Merge pull request #387 from storytold/fix/295-extractor-layers
Effects: EXtractoR picks layers and channels of multi-layer EXRs (#295)
2026-10-09 17:58:37 +07:00
bflatastic 27cabcb36b Effects: EXtractoR picks layers and channels of multi-layer EXRs (#295)
EXtractoR's Red, Green, Blue and Alpha are text parameters naming
channels, and Effect Controls drew nothing for text parameters: there
was no way to choose a layer or channel. And a multi-layer OpenEXR file
whose colour is only in named layers (`diffuse.R`, Blender's
`ViewLayer.Combined.R`) didn't import at all: the image decoder wants an
unnamed RGB layer ("does not contain non-deep rgb channels").

- Effect Controls: EXtractoR gets a Layer popup listing the footage's
  OpenEXR layers. Picking one sets red, green, blue and alpha to its
  channels in one undo step, as After Effects' EXtractoR dialog does (a
  one-channel layer such as depth goes in all three colours). Red /
  Green / Blue / Alpha are popups of the layer's own R, G, B, A, every
  channel of the file, and (none). The lists are read only while open,
  so playing an EXR sequence doesn't parse files on the UI thread.
- `layer.channels {layer}` lists a layer's EXR layers and channels and
  what each layer shows in red, green, blue and alpha, for agents.
- Media: an EXR without an unnamed RGB layer imports and shows its
  colour layer (one named combined, beauty or rgba first); its channels
  stay available to EXtractoR and the 3D Channel effects.
- Raster: AuxChannels::layers / layer_rgba and split_channel.

Test: extractor_shows_the_chosen_layer_and_channels_of_a_layered_exr
(host: a layered EXR written with the exr crate imports, layer.channels,
EXtractoR renders spec and depth; it failed to import before),
extractor_offers_the_exr_layers_and_channels (UI: Layer popup, one undo
step, channel popup), layered_exr_without_an_unnamed_rgb_layer_imports
(media).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:32:09 +07:00
BFlat a6c72acf94
Merge pull request #383 from storytold/fix/290-tweaks
Tweaks from #290: label colours, nudging, motion-path handles, Time Remap, masks, context menus
2026-10-09 17:28:27 +07:00
bflatastic a8bf9a5b88 Docs: gaps.md records the #290 fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:21:13 +07:00
bflatastic 1afeb9d5c6 Timeline: right-clicking the work area offers Trim Comp to Work Area, Lift and Extract (#290)
The work area bar had no context menu. Right-clicking it now offers the
work area's commands from the menu bar: Trim Comp to Work Area, Lift
Work Area and Extract Work Area (After Effects).

Test: work_area_context_menu_trims_the_comp (ui_timeline_zoom).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:21:13 +07:00
bflatastic 0f44588bbd Project panel: footage items' menu has Interpret Footage, Replace and Reload Footage (#290)
Right-clicking imported footage offered no way to change its
interpretation. As in After Effects, right-clicking an item now selects
it, and footage items' menu has the File menu's footage commands:
Interpret Footage (Main, Proxy, Remember / Apply Interpretation),
Replace Footage, Reload Footage and Reveal in Explorer / Finder, with
the menu bar's labels, shortcuts and enabled state
(menus::submenu_at / entry_for). The panel's Interpret Footage button,
which only ever said to select footage, opens the dialog for selected
footage.

Test: footage_context_menu_interprets_the_footage (ui_project_rows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:21:13 +07:00
bflatastic 2f3a507f8e Labels: label menus show each label's colour (#290)
The label menus listed the labels' names only, so a label couldn't be
picked by its colour. Every place a label is chosen now shows its
colour swatch before its name, as After Effects does: the Timeline's
label swatch (which now opens its menu on a click too, not only a
right-click), the Project panel's label swatch and a new Label submenu
in its item menu, Edit > Label, keyframe Label menus and the marker
dialog. One shared widget (widgets::label_entry / label_popup) draws
them; keyframe labels now show the names set in Settings > Labels.

Tests: label_swatch_opens_the_label_menu (ui_project_rows),
the_label_swatch_menu_shows_the_label_colours (ui_timeline_switches),
edit_label_menu_shows_the_label_colours (ui_panels).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:21:12 +07:00
bflatastic 3174652ce4 Viewer: double-clicking a mask point selects the whole mask (#290)
Double-clicking a mask or shape path's point (or segment) put its points
in a free-transform box but left only the clicked point selected, so
once the box was gone a drag moved that one point. As in After Effects,
the double-click now selects all the path's points too: dragging inside
the box, or afterwards any of the points, moves the whole mask.

The test also draws a mask with the Pen, moves a point and undoes: each
undo takes back one action (the move, the closing, the last point).

Test: pen_mask_undoes_step_by_step_and_double_click_moves_the_whole_mask
(ui_viewer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:20:43 +07:00
bflatastic ed5ad56eb5 Viewer: a straight motion path has Bezier handles to curve it (#290)
The Position motion path drew a key's spatial handles only where its
tangent wasn't zero, and auto-Bezier keys have none at the path's ends,
so a two-key path (the usual start) had no handles to pull it into a
curve. Auto-Bezier keys now show a handle on such a side too, pointing
where the path leaves the key, a third of the way to the next key;
dragging it makes the key Bezier with that tangent. Linear keys still
show none.

Keyframe Interpolation > Spatial Bezier copied those zero tangents, so
the keys stayed straight with no handles (and read back as Linear); it
now gives them the same handles. The path keeps its shape and timing.

The handles come from the new keyframe::spatial_handles.

Tests: spatial_bezier_gives_a_straight_path_handles (engine),
a_straight_motion_path_has_handles_to_curve_it (ui_viewer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:20:43 +07:00
bflatastic 5b05a5ae80 Viewer: arrow keys nudge the selected layers 1 pixel at the current magnification (#290)
The arrow keys did nothing to a selected layer, so it couldn't be lined
up precisely. As in After Effects, over the Composition panel or the
Timeline an arrow key moves the selected layers 1 pixel at the viewer's
magnification (half a comp pixel at 200 %, so sub-pixel steps come from
zooming in) and Shift+arrow 10, one undo step per press; holding the
key repeats. Not while typing or with mask points selected.

The move is the new engine command layer.nudge {x, y (comp pixels),
layers?}: through each layer's parents into Position (or X/Y Position),
a key at the current time when Position is animated, locked layers left
alone. It shares the Align panel's move.

Tests: nudge_moves_layers_by_comp_pixels (engine),
arrow_keys_nudge_the_selected_layer_at_the_viewer_magnification
(ui_viewer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:20:43 +07:00
bflatastic e6fd1461d7 Timeline: the bar past a layer's source is striped; time remapping it ends at the source's end (#290)
A footage or precomp layer dragged out past its source's end holds its
last frame, but its bar looked the same all the way, so the source's
end couldn't be seen. That part of the bar (and any part before the
source's start) is now darkened and striped in the label colour, with
an automation id (timeline.layer.<id>.bar.afterSource / beforeSource);
with time remapping on, the remap decides what shows there, so it isn't.

Enable Time Remapping on such a layer put its end key at the extended
out point; it now ends at the source's end, where the last frame starts
to hold (After Effects). Trimmed and reversed layers keep their keys.

Tests: time_remap_on_an_extended_layer_ends_at_the_source_end (engine),
the_bar_past_the_source_end_is_striped (ui_timeline_switches).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:20:42 +07:00
bflatastic 567a947fd8 Timeline: deleting Time Remap turns time remapping off (#290)
Selecting Time Remap in the Timeline (its name, which selects all its
keys) and pressing Delete removed the keys, and a second Delete then
deleted the whole layer. As in After Effects, Edit > Clear with Time
Remap selected now turns time remapping off (Layer > Time > Enable Time
Remapping off), in one undo step, and the layer stays; one selected
Time Remap key on its own is still deleted as a key.

Test: deleting_time_remap_turns_time_remapping_off (engine).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:12:22 +07:00
bflatastic 56b374800d Masks: an open mask leaves its layer whole until it is closed (#290)
While a mask was being drawn with the Pen, its open path was filled as
if closed, so the layer went black except inside the points placed so
far, which made rotoscoping by eye impossible. As in After Effects, an
open mask makes no transparency: the layer shows whole until the path
is closed. Effects that take a mask as a path (Stroke and the others)
still get open masks.

Test: open_masks_make_no_transparency (render).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:12:22 +07:00
BFlat 735482d7ed
Merge pull request #381 from storytold/fix/284-timeline-keys-tools
Timeline, keyframes, Graph Editor, Puppet and pick whip fixes from #284
2026-10-09 17:11:47 +07:00
bflatastic bf09a4fcad Expressions: the pick whip inserts at the cursor while editing (#284)
Dragging an expression's pick whip always replaced the whole
expression, also while it was being edited (and the editor then wrote
its old text back over the reference). As in After Effects, a pick
whip pressed while the expression is being edited now puts the
reference at the cursor, replacing the selected text, and the editing
goes on with the cursor after the reference. Without an edit in
progress it replaces the expression as before.

`prop.pickWhip` takes the text being edited (`expression`) and the
selection in it (`range`, in characters) to insert there.

Tests: the_expression_pick_whip_inserts_at_the_cursor_while_editing
(ui_inline_edit), pick_whip_inserts_into_the_expression_being_edited
(engine).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:59:54 +07:00
bflatastic 02bc2b8f9a Puppet: the tools stay on the rigged layer and pins go on the deformed mesh (#284)
With a Puppet tool, a click off the pins picked the topmost layer under
the pointer by its own bounds, and selected it. So a click where the
deformation had stretched the mesh past the layer's bounds went to the
layer behind (selecting it and giving it a Puppet effect), and any
click on another layer switched to it.

While selected layers have a Puppet effect, a click (or a press that
might start a pin marquee) now works only on them, as in After
Effects: on the layer whose mesh, as deformed now, is under the
pointer (the pin lands on the deformed shape), else the one whose own
art is (a new mesh); elsewhere nothing happens and the selection
stays. Without a rigged layer selected, the tools pick and rig the
layer under the pointer as before.

`puppet::contains` tests a point against a deformed mesh; `puppet.addPin`
uses it too (its own test only held for one triangle winding).

Test: puppet_pins_go_on_the_deformed_mesh_and_the_tool_stays_on_the_rigged_layer
(ui_viewer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:59:53 +07:00
bflatastic 065f0c91e7 Viewer: the eyedropper samples under the pointer in a region of interest (#284)
Effect eyedroppers (other than keyers', which pick from the effect's
input) sample the composite the viewer shows; a test covers that on
main. But they, and the Info panel, read the shown frame as if it
covered the whole comp. With a Region of Interest, or the Extended
Viewer of a 3D view, the frame covers only that area, so they read a
pixel somewhere else. They now map the point through the area the
frame covers (`shown_region`, `frame_pixel`).

Tests: the_eyedropper_samples_under_the_pointer_in_a_region_of_interest
(ui_effect_controls), eyedropper_samples_straight_colour (fx_widgets).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:59:53 +07:00
bflatastic cf7263d6c6 Graph Editor: a handle drag moves the handles of every selected key (#284)
Dragging a Bezier handle in the Graph Editor changed only that key's
ease. As in After Effects, the same side's handles of every selected
key now move with it, by as much as the pointer moved (time and value
or speed alike), in one undo step. Each handle keeps its offset from
where the drag began, so a handle stopped at a limit (100 % influence)
doesn't drag the others on.

Test: a_graph_handle_drag_moves_the_handles_of_every_selected_key
(ui_keyframes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:59:53 +07:00
bflatastic 04ccf6f906 Timeline: keyframes without a dark outline (#284)
Each half of a keyframe glyph had a 1 px near-black border, so keys
that overlap read as a tangle of borders. Keys are now flat, as in
After Effects: each half is filled and edged in the key's own colour
(the hairline closes the seam between the halves), so overlapping keys
read as one shape. The Light theme's keyframe grey is darker so
unselected keys still stand out on its light time graph without the
border.

Test: keyframes_are_drawn_without_a_dark_outline (icons).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:59:53 +07:00
bflatastic 59c55e8a26 Timeline: dragging over stopwatches sets them all (#284)
As in After Effects, pressing a property's stopwatch toggles it, and
dragging on over the stopwatches above or below gives every property
the pointer passes the state the first one got (animated or not),
whatever its own, in one undo step. Alt+click still adds an
expression.

The gesture is the layer switches' press-and-drag from #238, moved
into `switch_gesture` and shared by both. `prop.toggleAnimation` takes
`merge` so the drag folds into one undo step.

Tests: dragging_over_stopwatches_sets_them_all (ui_timeline_switches),
stopwatch_toggles_with_a_merge_key_are_one_undo_step (engine).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:59:53 +07:00
bflatastic 2eb56cff1c Timeline: a test that the cached frames show in green (#284)
#284 reported no cache indication in the Timeline. Main has one: a
green line under the work area bar for the frames cached at the
viewer's resolution, view and options (blue for disk-cached frames).
This test pins it down: once the viewer's frame has rendered, the
Timeline paints the green line from the start of the work area.

Test: the_timeline_shows_the_cached_frames_in_green (ui_ram_preview).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:59:53 +07:00
bflatastic 976784b0a6 Timeline: Shift-clicking layer bars selects the range (#284)
Shift-clicking a layer's name selected the layers from the last one
clicked to it, but Shift-clicking a layer bar in the time graph only
added that layer. Bars now select the same range (shared with the
name column: `shift_range`), and a click on a bar becomes where the next
range starts, as a click on a name does.

Test: timeline_shift_click_on_layer_bars_selects_the_range
(ui_workflow_selection).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:59:53 +07:00
bflatastic f9570d783a Viewer: scrubbing drops the viewer frames still waiting (#284)
Scrubbing asks for the viewer frame under the pointer at every step.
Each newer request only demoted the earlier ones to prefetch, so every
frame the pointer passed was still rendered, oldest first, holding the
frame threads the frame under the pointer needed. A newer viewer frame
of a comp now drops that comp's viewer frames that haven't started
(and releases them, so they can be asked for again). Frames already
rendering finish into the cache (rendering can't be interrupted
mid-frame), and the viewer keeps its last frame until the new one is
in. Prefetch frames and other comps' viewer frames stay queued.

Test: a_newer_viewer_frame_drops_the_ones_still_waiting (frames).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:59:53 +07:00
bflatastic 2419605cc4 Timeline: a press in the time ruler moves the current time (#284)
The ruler moved the current-time indicator only once the pointer had
dragged past egui's drag threshold, or on release for a click. As in
After Effects, the time now jumps to the pointer as soon as the button
goes down in the ruler, then follows the drag.

Test: a_press_in_the_ruler_moves_the_current_time (ui_timeline_zoom).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:59:53 +07:00
BFlat 5742a5c567
Merge pull request #380 from storytold/fix/293-render-names-prores-alpha
Render Queue: output files end in their format's extension once (#293)
2026-10-09 16:59:34 +07:00
bflatastic be926adb92 Render Queue: output files end in their format's extension once (#293)
Renders on macOS came out as `Comp 1.mov.ecproj`, and an image
sequence's every frame as `Comp 1_00001.png.ecproj`; another output
was `Comp 2.mov.mov`.

Cause: the save dialogs for anything but a project went through the
project dialog hook, which filters to `.ecproj`; macOS's save panel
appends the allowed extension to any other name. 0.4.0's Render Queue
Choose File… used it (#192 moved Output To to its own dialog since),
and Save Frame As, Save Current Preview, Export Lottie, Save a Copy As
XML, Collect Files, Watch Folder and the Premiere export form's Browse
still did. Nothing then fixed the name: Output To kept `.ecproj` (no
format claims it), and changing the format replaced only the last
extension (`Comp 2.mov.ecproj` → `Comp 2.mov.mov`). `[compName]` of a
comp named `Comp 2.mov` also doubled it.

Fix: `Hooks::save_dialog` asks the dialog for the default name's own
extension (`pick_save_file`), the project dialog only for projects or
on hosts without another (the web); the desktop dialog has no filter
for a name without an extension (folders). `OutputFormat::with_extension`
makes an output path end in the format's extension exactly once
(project extensions and repeats of the format's dropped, a missing one
added; `logo.png.mov` kept), used by Output To, format changes and
`resolve_output`, so existing projects and templates render to the
right name too. Output To picks the file type from the extension
before a stray `.ecproj`.

Test: output_names_end_in_the_format_extension_once (project),
output_to_ends_in_the_format_extension_once (engine) and
save_dialogs_offer_the_file_extension (ui); the engine and ui tests
fail with the fix reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:51:12 +07:00
BFlat a62a298f0c
Merge pull request #382 from storytold/fix/274-zero-rate-guard
Media: resampling footage audio guards a zero mix rate (#274)
2026-10-09 16:50:04 +07:00
bflatastic fc5771f00f Media: resampling footage audio guards a zero mix rate (#274)
A requested rate of 0 would divide by zero in the resampler; it now
reads through the plain path, which returns silence for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:43:21 +07:00
Brandon Thomas cae67546dd Media: checked offset and as_chunks_mut in the stereo interleave (#305 follow-up)
clippy 1.99 flags chunks_exact_mut with a constant size; the skip offset
was also an unchecked slice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:45:44 -04:00
Jonas Neubauer 22ce74ea0d Fix NVIDIA Dust & Scratches threshold rounding (#211)
Squashed from #211 without its before/after PNG (we don't commit binary images).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:22:05 -04:00
Brandon Thomas 48dc1ec4e5 Merge pull request #200 from EthanSuquet/fix/text-scale-continuous-raster
Render: text layers are always continuously rasterised
2026-10-09 00:21:56 -04:00
Brandon Thomas 7434cfb798 Merge pull request #305 from storytold/fix/274-audio-stutter
Media: footage audio at another sample rate no longer stutters (#274)
2026-10-09 00:21:54 -04:00
Brandon Thomas e4d1a4494f G3: audio scrub test waits on the clock, not a step count (flaky under load)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:17:53 -04:00
Brandon Thomas 742833bfa4 i18n: rustfmt the language table (#309 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:15:20 -04:00
Brandon Thomas 1f5218378c Merge pull request #298 from midasdf/fix/vertical-japanese-punctuation
M9: place Japanese vertical punctuation using font metrics
2026-10-09 00:04:52 -04:00
Brandon Thomas f43b956382 Merge pull request #310 from DWAA1660/fix/285-empty-glyph-fallback
Text: fallback skips a font whose glyph for the character is empty (#285)
2026-10-09 00:04:50 -04:00
Brandon Thomas 2a968c4d0e Merge pull request #309 from FeiLiuEM/i18n/zh-hans
i18n: add Simplified and Traditional Chinese (zh-hans, zh-hant) UI translation
2026-10-09 00:04:49 -04:00
Brandon Thomas 5cb3ee5b8a Docs: headless pitfalls point at mcp --autosave (#266 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:04:47 -04:00
Brandon Thomas 459c8987fe MCP: add the autosave _meta without IndexMut (#280 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:04:47 -04:00
Brandon Thomas 11848da0a1 App: only a held lock means another launch is starting (#283 follow-up)
A filesystem without lock support returned an error from try_lock, which
read as 'held' and disabled crash detection for good.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:04:47 -04:00
Brandon Thomas 5cce817d4d Merge pull request #266 from pat-the-wizard/docs/agent-pitfalls
Docs: pitfalls for agents driving EffectCraft over MCP
2026-10-08 23:51:24 -04:00
Brandon Thomas 5d5da086c8 Merge pull request #283 from prekabreki/fix/launch-guard-concurrent-and-gl-stuck
App: a second copy opening, or OpenGL that fails, no longer strands Window Graphics
2026-10-08 23:51:22 -04:00
Brandon Thomas 6c838325fa Merge pull request #280 from jonassneu/fix/mcp-autosave
MCP: preserve unsaved work across server restarts (#258)

# Conflicts:
#	apps/effectcraft-cli/tests/cli.rs
#	contributors/people.toml
2026-10-08 23:51:21 -04:00
Brandon Thomas db4f6df89d Merge pull request #213 from midasdf/fix/type-tool-long-press
M9: open grouped tool flyouts on long press

# Conflicts:
#	ROADMAP.md
2026-10-08 23:50:55 -04:00
Brandon Thomas 12689aa288 Merge pull request #294 from pat-the-wizard/docs/261-environment-light
3D: layer.newLight lists the Environment light and how to set its source; parity notes reflections (#261)
2026-10-08 23:50:36 -04:00
Brandon Thomas cb2779255b Merge pull request #256 from switch87/readme-gentoo
README: install on Gentoo from the ::snakebyte overlay
2026-10-08 23:50:33 -04:00
Brandon Thomas a3c5c1ef0d Merge pull request #323 from Oldrich333/fix/300-render-out-cwd
CLI: render --out is relative to the working directory (#300)
2026-10-08 23:50:31 -04:00
Brandon Thomas 254192be57 G3: GL adapters pass the WebGL2-limits decline test (FreeBSD CI)
On FreeBSD the only adapter is llvmpipe over GL, which GpuContext::new
declines for its backend before checking limits; accept either reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:19:24 -04:00
FeiLiuEM d2f74c13e9 i18n: adopt the Chinese wording Adobe's own documentation settles
Reviewed #321 (the alternative Simplified Chinese catalog) row by row against Adobe's
Simplified Chinese After Effects documentation and kept what the documentation confirms:

- Adobe's "混合模式和图层样式" page names the blend modes verbatim, so 相乘 (Multiply), 相加
  (Add), 相减 (Subtract), 相除 (Divide), 动态抖动溶解 (Dancing Dissolve), 模板 Alpha /
  模板亮度 / 轮廓 Alpha / 轮廓亮度 (Stencil / Silhouette) and 冷光预乘 (Luminescent Premul)
  come from it. 滤色, 深色, 浅色, 实色混合 and 明度 stay, because that page gives those names too.
- "设置、选择和删除关键帧" gives 指数比例 (Exponential Scale), 消隐 (Shy), 对齐 (Snapping) and
  the 选定 wording; "预合成、嵌套和预渲染" gives 合成微型流程图; the motion-graphics templates
  page uses 基本图形 for the panel but 动态图形模板 for the template; the keyboard-shortcuts
  page gives 双立方 (Bicubic sampling), 灰度系数 and 主要.
- The 16 label colours now carry the 色 suffix the Chinese edition uses (浅绿色, 淡紫色, 桃红色,
  紫红色, 海泡沫, 深绿色), plus a few Adobe-standard phrasings: 置于顶层 / 置于底层, 与图层相同,
  色彩管理, Internet 标准 RGB, 另存为模板, 时间伸缩, 减少项目, 单独尺寸, 倾斜, 关.
- Kept mine where those pages contradict #321 (屏幕 for Screen, 较深的颜色, 较浅的颜色,
  纯色混合, 发光度, 资源管理器 for Finder, 整理工程 for Dependencies, 素材 for Material), where
  they drop the spaces Adobe's own pages use around Latin text (3D 图层, RPF 摄像机导入,
  在 Finder 中显示), and where the sibling apps' catalogs agree with this file.

`TRADITIONAL_CHINESE` mirrors the same changes (37 rows). Credit to @cjcwwww for #321, where
these rows come from.
2026-10-09 08:51:23 +08:00
Oldrich333 a560830b64 CLI: render --out is relative to the working directory (#300)
`effectcraft-cli render --out proj/out/d.mp4 --project proj/x.ecproj` wrote
`proj/proj/out/d.mp4`: the Render Queue resolves a relative output against its own base, while
`--project` and every other CLI path are relative to the working directory. The CLI now makes
`--out` absolute against the working directory before queueing it, so the file lands where the
shell user expects; an absolute path is unchanged, and `rendered[].output` in the JSON result is
the absolute path. Render Queue outputs set inside a project (and `render --queue`) resolve as
before: tokens expanded, then `export.defaultOutputFolder`, the project's folder, the working
directory.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 20:32:51 +00:00
FeiLiuEM 2b494b9cfc i18n: add Traditional Chinese (zh-hant) UI translation
- New TRADITIONAL_CHINESE catalog: the same (command, English label) keys as JAPANESE and
  SIMPLIFIED_CHINESE, one entry per fixed menu row (618 rows). The wording follows the
  Traditional Chinese catalogs PhotoCraft, PdfCraft, VectorCraft and LightCraft already ship
  (`檔案`, `開啟`, `儲存`, `圖層`, `遮色片`, `影格`, `預設集`, `快速鍵`, …).
- Match System maps a Traditional Chinese locale (`zh-TW`, `zh-HK`, `zh-Hant`, `zh_MO`) to
  `zh-hant` instead of English, now that there is a catalog for it.
- `simplified()` and `traditional()` replace the Simplified-only `chinese()`; the macOS Services
  item is 服务 in Simplified and 服務 in Traditional.
- `general.language` gains 繁體中文; `docs/preferences.md` documents the locale mapping.
- Tests: all three catalogs must cover the same fixed TREE entries; the shape check covers both
  Chinese catalogs; label/undo/panel-title/submenu/native-menu behaviour in zh-hant with file and
  layer names kept verbatim; the Traditional locale mapping; the preference round-trips.
2026-10-09 01:08:52 +08:00
DWAA1660 8cb120d0ec Text: fallback skips a font whose glyph for the character is empty (#285)
Face::has_char counted any non-zero cmap entry as coverage. Some fonts map
whole scripts to empty glyphs (a decorative Latin font installed on a Linux
machine maps あ, 水, ア and 。 to glyphs with no contours), so once such a
face was loaded, fallback_for could pick it and Japanese text rendered as
nothing; cjk_falls_back_to_a_system_family failed whenever another test had
loaded that face first.

has_char (and file_covers, used for faces not yet loaded) now also require
the glyph to draw something: an outline with at least one segment, a colour
glyph, or a bitmap in any strike. Characters that are blank by nature
(whitespace, and the default-ignorable code points such as soft hyphen,
joiners and variation selectors) still count as covered by any mapping.
Face::glyph is unchanged, so shaping with the chosen face is as before.

Test: an_empty_glyph_does_not_cover_its_character builds a minimal TrueType
font in code (no font file added to the repo) mapping U+E000 to an empty
glyph and U+E001 to a triangle, and checks has_char and fallback_for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3vVft4VXvvy8SpbbUceTW
2026-10-08 16:48:13 +00:00
FeiLiuEM ed0a724c93 i18n: Simplified Chinese (zh-hans) UI translation
- The i18n module now selects a catalog by language code instead of testing for Japanese:
  `language(app)` returns the code, `lookup` is shared by every catalog, and the undo/redo
  and workspace / 3D-view titles are formatted per language.
- New SIMPLIFIED_CHINESE catalog: the same (command, English label) keys as JAPANESE, one
  entry per fixed menu row of the engine tree (618 rows).
- `general.language` gains 简体中文 (`zh-hans`). Match System maps a Simplified Chinese locale
  (zh, zh-CN, zh_CN.UTF-8, zh-Hans-CN, zh-SG) to it and a Traditional one (zh-TW, zh-HK,
  zh-Hant) to English, which has no catalog of its own yet.
- The macOS Services menu item is 服务 in Chinese.
- Tests: both catalogs must cover the same fixed TREE entries; `...`, brackets and spacing
  keep the English shape; label/undo/panel-title/submenu/native-menu behaviour in zh-hans
  with file and layer names kept verbatim; the locale mapping; the preference round-trips
  through prefs.json.
2026-10-09 00:42:28 +08:00
bflatastic ec892ef145 Media: footage audio at another sample rate no longer stutters (#274)
AAC audio in an MP4/MOV whose sample rate differs from the mix rate
(a 44.1 kHz clip in the 48 kHz preview or export, or the reverse)
stuttered and sounded low-pitched in previews and in exported movies.

Cause: FilmCraft's MP4 reader, asked for audio at a rate other than
the file's, applied the edit list's priming offset (1024 samples for
AAC) twice when it resampled: each read started about 1024 source
samples early and extrapolated a ramp over its first ~940 samples
(peaks near 100 against a 0.5 tone). Export and the preview mix ask
for audio a frame or a device buffer at a time, so the ramp repeated
dozens of times a second. Same-rate reads take FilmCraft's copy path
and were exact. FilmCraft fixed this upstream in 5bf2203, 87 commits
past the pinned revision.

Fix: MediaPool reads footage audio at the source's own rate and
resamples it itself (linear, by absolute sample position, so a range
comes out the same however it is split into reads), for every
container.

Test: resampled_audio_read_in_frame_pieces_matches_ffmpeg (44.1 kHz
AAC read at 48 kHz and 48 kHz at 44.1 kHz in 29.97 fps frame pieces,
against ffmpeg's decode: max error 44 before, 0.002 after) and
movies_carry_resampled_footage_audio (a 29.97 fps comp with 44.1 kHz
AAC footage exported to MP4/AAC and MOV/PCM, decoded by ffmpeg: max
error 8.7 before, 0.03 / 0.002 after).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:16:35 +07:00
BFlat d60cb71e29
Merge pull request #210 from jonassneu/fix/blank-panel-context-menus
G2: add context menus to blank Project and Timeline areas
2026-10-08 22:11:11 +07:00
midasdf f224678541 M9: place Japanese vertical punctuation using font metrics
Co-Authored-By: Codex <noreply@openai.com>
2026-10-08 23:36:26 +09:00
BFlat 929a9c0f01
Merge pull request #278 from storytold/fix/259-fractal-soft-linear
Effects: Fractal Noise's Soft Linear has no lattice creases (#259)
2026-10-08 21:19:54 +07:00
BFlat e283b25efc
Merge pull request #296 from storytold/fix/model-import-alpha
Import: a 3D model doesn't ask how to interpret its alpha (#264)
2026-10-08 21:19:24 +07:00
bflatastic 69dc1b26fb Import: a 3D model doesn't ask how to interpret its alpha (#264)
Importing a glTF / OBJ model opened Interpret Footage: the probe
reports straight alpha and the file has no alpha label, so with
Settings ▸ Import ▸ Interpret Unlabeled Alpha As at Ask User (the
default) the model was listed as unlabeled. Only image footage
(stills, sequences, video) can have unlabeled alpha; a model renders
its own, and audio and data have none.

Test: importing_a_model_does_not_ask_about_alpha (engine).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:18:52 +07:00
Brandon Thomas 6943872cf6 Bump version to 0.6.0 (from 0.5.0) for release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:02:02 -04:00
BFlat 6ffbfe7cea
Merge pull request #292 from storytold/fix/229-wasm-warning
i18n: the browser build's Match System uses supported() too (#229)
2026-10-08 21:01:21 +07:00
bflatastic 646a6842cd i18n: the browser build's Match System uses supported() too (#229)
`supported` was only called on desktop builds, so the wasm32 check
warned that it was never used. The browser build, which has no system
locale here, now resolves Match System through supported(None)
(English) instead of a literal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:00:55 +07:00
bflatastic 07c66e0be3 Test: Fractal Noise's Soft Linear shows no lattice (#259)
Two measurements on Turbulent Basic: with one 16 px layer whose lattice
lines run through pixel centres, the slope across the lines relative to
the average slope (0.019 with the quintic fade, 0.75 now; must be over
0.5); and at the issue's settings, the gradient along the lattice axes
against the diagonals (1.21 before, 1.03 now; must be under 1.15).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:00:42 +07:00
bflatastic fe0d11c5cc Effects: Fractal Noise's Soft Linear has no lattice creases (#259)
Soft Linear was value noise with a quintic fade between two lattice
values per axis. That fade has zero slope across every lattice line and
at every lattice point, so the noise's contours met the lattice at right
angles and flattened around its points. The turbulent fold (1 - |s|) and
high contrast turned that into a visible square grid at each layer's cell
size (Turbulent Basic, Contrast 140, Brightness -35: gradients 1.3x as
strong along the axes as along the diagonals; Spline 1.1x).

Soft Linear is now linear interpolation softened by a one-cell box
filter, i.e. the uniform quadratic B-spline over the 3 x 3 lattice values
around the nearest one (evolution still fades between two slices): smooth
everywhere, with half the neighbours' difference as its slope on lattice
lines, and cheaper than Spline (9 taps instead of 16). Averaging
lowers the noise's spread, so a fixed gain about mid-grey restores the
quintic fade's variance and existing projects keep their contrast and
brightness (issue settings: mean 0.356 -> 0.362, std 0.147 -> 0.152,
axis bias 1.28 -> 1.05).

Only Fractal / Turbulent Noise's Soft Linear changes: generate's
value_noise and its other callers are untouched. noise3 now shares
generate's lattice hash and fade instead of copying them. The GPU
kernels (pointwise Fractal Noise and fxn_fractal) call the same
soft_linear_noise. No golden hashes pin these cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:00:42 +07:00
BFlat 1ab26e1119
Merge pull request #286 from storytold/fix/264-model-layers-cpu
Engine: adding a 3D model switches a Classic 3D comp to Advanced 3D (#264)
2026-10-08 20:59:53 +07:00
Brandon Thomas 0f2814de8c
README Downloads: where to get it first, then builds with Windows before macOS (#291)
Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:50:59 -04:00
Brandon Thomas e2a5c99a26
Release: FreeBSD x86_64, Flatpak x86_64 + aarch64, AppImage zsync updates; README Downloads (#287)
- release.yml: `freebsd` job (FreeBSD 14.3 VM, the freebsd.yml packages) running the new
  packaging/freebsd/package.sh; `flatpak` matrix (x86_64 on ubuntu-24.04, aarch64 on
  ubuntu-24.04-arm) repackaging the Linux tarball with packaging/linux/flatpak-bundle.sh and the
  new ai.storyteller.effectcraft.bundle.yml; both added to the draft release's needs.
- AppImages embed gh-releases-zsync update information and ship a .zsync beside them; the Linux
  job installs zsync and checks both.
- version, Linux and web jobs drop `environment: release` (they read no secrets), so a dispatch on
  a branch is a full dry run of every unsigned job; the draft release still needs the signed jobs.
- Flatpak finish-args documented and shared by the bundle and Flathub manifests (lint checks they
  match); audio, GPU, portals, xdg-pictures/videos/documents, network for the optional model
  downloads. The Flathub manifest now also installs effectcraft-cli.
- packaging-lint runs the FreeBSD layout dry run.
- README: Downloads section (every file a release ships) and nav entry; docs/releasing.md updated.

Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:48:00 -04:00
bflatastic 3cc9520685 Test: an imported GLB shows in a new composition in software (#264)
The host test writes a binary glTF cube (built in the test), imports it
with the real media layer, makes New Comp from Selection and renders
with no accelerator (the CPU path an Intel HD Graphics 5500 most likely
gets): the comp is Advanced 3D and the red cube covers part of the
frame. Before the fix the comp stayed Classic 3D and drew nothing. The
engine test checks the switch, its single undo step, the toasts, that a
second model doesn't switch again, and primitives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:45:07 +07:00
bflatastic 6d20cc2151 Engine: adding a 3D model switches a Classic 3D comp to Advanced 3D (#264)
Imported GLB/glTF models were invisible in the Composition viewer while
text showed. Only the Advanced 3D renderer draws model and primitive
layers, and new compositions (and New Comp from Selection) are Classic
3D by default, but adding a model layer left the comp's renderer alone,
so the model drew nothing on every backend. It wasn't the reporter's
GPU: on this machine the viewer showed nothing for a GLB in a new comp
with the GPU compositor (Vulkan) and with Mercury Software Only, and
the model in both once the comp used Advanced 3D.

As After Effects documents ("Advanced 3D gets automatically selected
when you add a 3D model to the composition"), layer.newModel /
layer.addItem of a model and layer.newPrimitive now switch a Classic
3D comp to Advanced 3D inside the same edit (one undo step, Geometry
Options added to its text and shape layers like Composition Settings
does) and toast that the comp changed renderer. Switching a comp that
has model layers back to Classic 3D toasts why they disappear.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:45:07 +07:00
BFlat b0fa9f1943
Merge pull request #282 from storytold/fix/270-273-import-puppet-keys
Puppet pin keys show in the Timeline; imports show an Importing card (#273, #270)
2026-10-08 20:44:26 +07:00
bflatastic 3318cb0da1 Import: dropped and picked files show an Importing card (#270)
Files dropped on the window or picked in the Import dialog were probed
one after the other on the UI thread: the app froze while a video was
read, nothing said what was being imported or how many files were
left, and only the last new item was selected. People ended up
importing the same file again and again.

file.import takes `background`: the files are probed in a background
job (Window > Progress, jobs.list / jobs.cancel) that names the file
being read and how many there are ("b.png (2 of 3)"); then they are
added in one undo step, all selected (the Project panel scrolls to
them, a file already in the project arrives again as in After
Effects) and a toast reports the count ("Imported 3 items", with the
first error). Drops and the Import dialog use it, and an Importing
card at the bottom left shows the job with its progress bar and
Cancel. Agents and scripts still import at once by default. The
toast registers an automation id.

Test: background_imports_report_progress_and_select_what_arrived
(engine), dropped_files_show_import_progress_then_the_items_they_made
(ui_drag_drop).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:05:07 +07:00
bflatastic 285297ed93 Timeline: Puppet pin keyframes show in the Timeline (#273)
Moving a puppet pin keys its Position, but the Timeline didn't show
those keys where people look for them. U revealed every pin's Position
as one more flat "Position" row under the layer, indistinguishable from
the layer's own Position and from each other, and the reveal was a
snapshot: a pin placed or moved after U (or with the layer twirled open
and Effects closed) got keys that no row showed.

As in After Effects, revealed properties inside an effect now show
under the effect and its groups (Puppet > Mesh 1 > Deform > Puppet
Pin 1 > Position), twirled open by the reveal and closable like any
group; Transform and mask properties stay as they were. Placing, moving
or recording a pin in the viewer keeps its Position in view: on a
twirled-open layer its groups open, and a U reveal takes it in. A
collapsed layer stays collapsed. The keys select, move and delete like
any others.

Test: puppet_pin_keys_show_under_their_pins_in_the_timeline,
placing_a_pin_opens_its_groups_on_a_twirled_open_layer (ui_viewer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:05:07 +07:00
prekabreki 384c8cc094 App: a second copy opening, or OpenGL that fails, no longer strands Window Graphics
Two copies opened close together made the second take the first's
launch-pending marker for a crash and switch Window Graphics to OpenGL.
Where OpenGL can't open a window (NVIDIA on X11: "gl not compatible with
provided surface"), every later launch then failed, with no way to reach
the setting.

The marker is now locked while its launch runs, so a locked marker is a
launch still opening, not a crash. And a window that fails to open on
OpenGL puts Window Graphics back to Automatic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:03:19 +02:00
jonassneu 9d9f8ecbb4 Fix #258: preserve unsaved headless MCP work with opt-in checkpoints 2026-10-08 14:38:24 +02:00
BFlat fbc4080f53
Merge pull request #277 from storytold/fix/269-271-272-ui
UI: long menus scroll, panels get scroll bars, Learn closes when leaving it (#269, #271, #272)
2026-10-08 19:15:07 +07:00
Brandon Thomas 348fa2a09a Bump version to 0.5.0 (from 0.4.0) for release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:13:07 -04:00
pat-the-wizard 676dc1ac14
Docs: drop the GPU pitfall, main now documents mcp --gpu and renderer.why (#262)
#276 documents ["mcp", "--gpu"] and get_project's renderer.active / why in
the Headless setup notes of the same file, so the pitfall only repeated them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1RGTt6AE3ysiaMQ7X6Nea
2026-10-08 11:59:48 +00:00
pat-the-wizard 84fba2a434
3D: layer.newLight lists the Environment light and how to set its source; parity notes reflections (#261)
The Environment light existed (LightKind::Environment) but layer.newLight's help
and the kind errors of layer.newLight and layer.lightSettings listed only
Parallel|Spot|Point|Ambient, so agents could not find it. The help now lists it
and says how to give it an equirectangular image: prop.set
lightOptions/source to that layer's id, or leave it empty for the comp's
Environment Layer (layer.environment). layer.lightSettings documents its kind
values too.

docs/parity.md (3D) notes that Advanced 3D surfaces reflect only the
Environment light and that Appears in Reflections is stored but has no visible
effect yet. Object-to-object reflections stay tracked in #261.

Test: tests_3d::environment_light_is_listed_and_takes_a_source_layer (help and
both kind errors name every LightKind; an Environment light takes a source
layer by id, with undo).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1RGTt6AE3ysiaMQ7X6Nea
2026-10-08 11:57:55 +00:00
bflatastic 02c75fcaf1 Workspaces: leaving Learn closes its tutorials (#272)
The Learn workspace opens the Home screen on its Learn tab, but no other workspace closed it, so
after Learn the tutorials stayed over Default, Review and the rest until a project was created or
Home was toggled. It wasn't about clicking quickly: any switch away from Learn kept them. Leaving
Learn for another workspace now closes the Home screen it opened.

Test: ui_panels leaving_the_learn_workspace_closes_its_tutorials (clicks through the workspace
tabs: the Home screen and the tutorials show only on Learn).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:40:05 +07:00
bflatastic 11f3736025 Panels: every scrolling panel has a scroll bar to drag (#271)
Most panels lay out their own rows and scrolled only with the mouse wheel: Effect Controls,
Effects & Presets, the Media Browser, Ease Presets and the Timeline's layer rows had no scroll
bar, Properties and Preview a thin indicator that couldn't be dragged, and Render Queue, History,
Markers, Character, Paragraph and Info didn't scroll at all, so a panel shorter than its content
cut it off. Without a wheel or a working touchpad there was no way down. The Project panel had
the only real scroll bars, hand-rolled, and its vertical one was hit-tested under the rows, so it
only dragged where no row was.

- widgets::scroll_bar: the Project panel's bars generalised (horizontal or vertical, drag the
  thumb or press the track to move it there, automation id with "scroll/max"); the Project
  panel's two bars and the Timeline's column bar use it, drawn over the rows.
- widgets::PanelScroll: the wheel offset, its clamping and the bar for a panel's own rows, used by
  Effect Controls, Properties, Effects & Presets, Media Browser, Ease Presets, Preview, Render
  Queue, History (which still follows the current state), Markers, Character, Paragraph and Info.
  The Timeline's layer rows get a bar on the right edge.
- Effect Controls: an animated vector's values (a puppet pin's Position) ran under the keyframe
  navigator in a narrow panel. They now end before it, moving left over the name, which is cut
  short.

Test: ui_scrolling panels_taller_than_their_space_have_a_scroll_bar_to_drag (eleven panels in a
short window: a press on the track and a thumb drag scroll them),
effect_values_stay_clear_of_the_keyframe_navigator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:40:05 +07:00
bflatastic 6d2ef5a830 Menus: a long dropdown scrolls instead of running off the window (#269)
The menu bar's own menus (Edit has 30 entries) had no scroll area, only their submenus did, so
on a short screen Edit ran past the bottom of the window. The font menu was capped at 420
points and never moved up, the Preview panel's and the viewer's popup lists neither scrolled nor
moved, and the Timeline's Blending Mode and Expression Language submenus didn't scroll. egui's
scroll bars also only appeared while the pointer was over the list, so a list could only be
scrolled with a wheel or touchpad.

- widgets::popup_list: the one popup list behind popup_menu, the font menu, the Preview panel's
  dropdowns and the viewer's popups: as tall as the window allows, scrolling beyond, moved up to
  fit the window.
- widgets::menu_scroll: the same for menus and submenus (the menu bar's, Blending Mode, the
  Expression Language menu, the viewer's Select menu).
- Scroll bars always show (solid scroll style), so they can be dragged.
- A first window that doesn't fit the screen is maximized: the 1680 x 1020 default on a smaller
  screen is only shrunk to the monitor's size, so with its title bar it ran under the taskbar and
  cut menus off at the bottom of the screen even when they fitted the window.

Test: ui_scrolling a_menu_taller_than_the_window_scrolls (Edit in a 520-point window scrolls
its last entry into view and it takes the click); overflows_screen unit test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:40:05 +07:00
BFlat 52ab38fcff
Merge pull request #276 from storytold/fix/262-gpu-why
Render: render.backend says why it renders on the CPU; mcp --gpu documented (#262)
2026-10-08 18:09:33 +07:00
bflatastic 4eb69e7577 Render: render.backend says why it renders on the CPU; mcp --gpu documented (#262)
get_project / render.backend reported "gpuAcceleration": true with
"active": "cpu" and no reason, which read as a broken GPU. The
headless MCP server only attaches the GPU compositor when started with
--gpu, which docs/agents.md didn't mention, and a failed --gpu said
only "no usable GPU adapter" (the adapter's error went to a log no
one sees). The session now keeps the host's reason there is no GPU
compositor (accel_note): the CLI's "headless: renders on the CPU
unless started with --gpu", or the desktop app's compositor setup or
retirement error. The status reports it as `why` whenever it renders
on the CPU (or that the project's renderer is Software Only). --gpu
failures include the adapter's error (Gpu::try_headless).
docs/agents.md documents `mcp --gpu`.

Test: render_backend_says_why_it_renders_on_the_cpu (engine).
Checked with effectcraft-cli mcp and mcp --gpu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:09:01 +07:00
BFlat 0d3b5ed286
Merge pull request #267 from storytold/feat/254-ease-presets
Keyframes: ease presets apply saved curves to keyframe pairs (#254)
2026-10-08 17:44:02 +07:00
bflatastic db5adfa62a Docs: parity.md and gaps.md G8 describe ease presets (#254)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:25:00 +07:00
bflatastic 1c22615553 UI: Window > Ease Presets panel draws, edits and applies ease curves (#254)
A dockable panel (our own design) for the keys.easePreset commands. A
value graph shows the working curve with its two handles, which drag
to edit it; Apply eases the selected keyframes with it and From Keys
loads the curve between the first selected pair. Every preset is a
thumbnail drawn in code; a click loads it and applies it to the
selected keys. Save Current keeps the working curve under the name in
the field, and Rename and Delete act on the selected user preset.
Wide panels put the presets beside the graph; narrow ones below it,
the graph shrinking to leave a row of presets, scrolled with the wheel.

The panel state (working curve, selection, name) is serde in the UI
state, and every control registers an automation id (easePresets.*).
widgets::text_field is the search field without its magnifier.

Test: clicking_an_ease_preset_eases_the_selected_keys_and_handles_edit_the_curve
(ui_keyframes); every_panel_registers_automation_ids_for_its_widgets
covers the new panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:25:00 +07:00
bflatastic 0717f12246 Keyframes: ease presets apply a saved curve to keyframe pairs (#254)
keys.easePreset.apply {preset | curve} eases every pair of neighbouring
selected keyframes of each selected property in one undo step: the out
side of the earlier key and the in side of the later one. A curve is
given as {outInfluence, outSpeed, inInfluence, inSpeed} or as handles
[x1, y1, x2, y2]. keys.easePreset.capture reads the curve between the
first selected pair; keys.easePreset.save {name, curve?} keeps it (or a
given curve), and list, rename and delete manage the user presets.

Twelve built-in curves are our own numbers (Linear, Smooth, Ease In-Out
Soft / plain / Strong, Accelerate and Decelerate with Strong variants,
Expo In-Out / Accelerate / Decelerate) and can't be renamed, deleted or
overwritten. User presets live in the config store (ease_presets.json),
loaded with the other settings. A file that isn't JSON gives no user
presets, and entries that don't parse, have no usable name or repeat a
name are skipped and logged. A failed write leaves the presets as they
were.

Test: a_preset_eases_every_selected_pair_in_one_undo_step,
a_curve_scales_to_each_dimension_and_follows_motion_paths,
apply_needs_neighbouring_keys_and_a_known_curve,
user_presets_save_rename_delete_and_persist,
a_corrupt_presets_file_loads_what_it_can (effectcraft-engine).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:25:00 +07:00
bflatastic cc594a7385 Keyframes: ease curves measure and apply the ease of a keyframe pair (#254)
An EaseCurve is the temporal ease of one segment independent of its
duration and values: the out side of the first key and the in side of
the second, as influence (%) and speed relative to the segment's
average speed (1 = linear, 0 = at rest). Its handles in the normalised
value graph are cubic-bezier points. apply() eases a pair of keys per
dimension, along the motion path for spatial values and as overall
progress for paths and gradients, the way Easy Ease shapes them; an
Auto Bezier key keeps its other side as it plays. measure() reads the
curve a segment plays back. Non-finite numbers are rejected and
influences and speeds clamped.

Test: handles_round_trip_and_reject_non_finite_numbers,
applied_curve_scales_to_each_dimension_and_measures_back,
spatial_curve_follows_the_motion_path,
linear_and_auto_bezier_sides_measure_as_they_play (effectcraft-keyframe).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:25:00 +07:00
BFlat 6fa047c2ee
Merge pull request #265 from storytold/fix/253-snapping
Snapping: layers, scale handles and the work area snap as in After Effects (#253, #252)
2026-10-08 17:24:10 +07:00
pat-the-wizard d88f509bfd
Docs: pitfalls for agents driving EffectCraft over MCP
Adds a short Pitfalls section to docs/agents.md: layer time vs comp time
for keyframe tools (#257), layer.timing to set a start time, app.run()
in scripts and undo groups, saving early in headless mode (#258),
`mcp --gpu` and renderer.active (#262), and the Environment light with
lightOptions/source (#261).

Docs only; every statement checked against main and a 0.4.0 build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1RGTt6AE3ysiaMQ7X6Nea
2026-10-08 10:14:48 +00:00
bflatastic 25e5d0f9c0 Viewer: wrap the snap() doc comment (#253)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:14:18 +07:00
bflatastic 631c069557 Viewer: scale handles snap to the comp's and other layers' edges (#252)
Scaling a layer by a bounding-box handle didn't snap, so it couldn't
be scaled exactly to the comp. With snapping on (Cmd/Ctrl toggles it
during the drag), the dragged handle now snaps like a dragged layer's
feature: to the comp's edges, corners and centre and to other layers'
edges, corners, centres and anchor points, as the Snapping options
allow. A corner snaps both axes, an edge its own axis; with Shift the
corner slides along its line from the anchor point to the nearer
snapped edge, so the proportions stay and the layer lands on, say, the
comp's size.

Test: handle_drags_snap_to_the_comp_edges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:14:05 +07:00
bflatastic 0ac5f9fb9b Timeline: Shift-dragging the work area snaps to the current time (#252)
Shift-dragging the work area's begin or end handle now snaps it to the
current-time indicator, keyframes, layer in and out points and markers
within 8 px, with the snap candidates the CTI and keyframe drags use.
The work area bar itself drags too, as in After Effects: it moves the
work area and keeps its length, and with Shift the nearer end snaps.

Test: shift_dragging_the_work_area_snaps_to_the_current_time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:14:05 +07:00
bflatastic 41be200749 Viewer: Snapping options pick which layer features snap (#253)
Dragging a layer already snapped its feature nearest the pointer to
other layers' edges, corners, centres, anchor points and path points
and to the comp's edges and centre, and Cmd/Ctrl held during the drag
already turned snapping on or off against the checkbox. What was
missing were After Effects' Snapping options beside the Tools bar's
checkbox. The new menu turns Snap Edges Extended off (a layer's edges
then snap only along the layer, not across the comp) and each feature
family (edges, corners, centres, anchor points, mask and shape path
points) on or off, for the dragged layer's snap features and for the
targets alike. Agents set them with view.snappingOptions.

Layers whose eye is off (or that a solo hides) are no longer snap
targets, mask and shape path points of the dragged layer snap too, and
while snapping is on the feature that snaps is boxed during the drag.

Test: layer_drag_snaps_to_other_layers_edges_ctrl_toggles_and_options_apply
(UI), snapping_options_pick_the_targets and
snapping_options_command_sets_and_toggles (engine).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:14:05 +07:00
Gert Pellin 8a468ce52d Contributors: credit switch87 as Gert Pellin 2026-10-08 11:27:34 +02:00
Gert Pellin 270bd7f144 Docs: install on Gentoo from the ::snakebyte overlay 2026-10-08 11:25:55 +02:00
BFlat c486130a9b
Merge pull request #255 from storytold/fix/252-key-selection-props
Timeline: a selected key selects its property and layer (#252)
2026-10-08 16:20:15 +07:00
bflatastic 8d5e4ae0ef Timeline: a selected key selects its property and layer (#252)
Clicking (or marquee-selecting) keyframes in the Timeline selected only
the keys. Their property and layer stayed unselected, so the Graph
Editor, which shows the selected properties or else the animated
properties of the selected layers, came up empty until the property
was selected separately. As in After Effects, the Timeline's key
selection now selects the keys' properties and layers too (Shift adds
to them). keys.select takes this as `selectProperties`; the Graph
Editor's own key clicks leave what it shows alone.

Test: a_key_clicked_in_the_timeline_shows_in_the_graph_editor
(ui_keyframes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:19:53 +07:00
BFlat 72a2d47131
Merge pull request #249 from storytold/docs/248-py-aep-g4
Docs: gaps.md G4 lists py-aep as a candidate for .aep import (#248)
2026-10-08 14:40:58 +07:00
bflatastic 9fc4407a37 Docs: gaps.md G4 lists py-aep as a candidate for .aep import (#248)
A user suggested py-aep (MIT, Python), which reads and writes .aep
files. G4 now records it for the clean-room decision, with the two
ways it could be used: as an external converter, or ported, which
means reading another implementation of the format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:40:30 +07:00
BFlat 55defcb167
Merge pull request #247 from storytold/fix/243-gl-fallback
App: a launch whose window never drew makes the next use OpenGL (#243)
2026-10-08 13:59:08 +07:00
bflatastic d554724aa4 App: a launch whose window never drew makes the next use OpenGL (#243)
On some older Intel integrated GPUs (UHD 600, UHD with 2020 drivers)
the graphics driver crashes EffectCraft as its window's DirectX 12 or
Vulkan device is created (exit code 0xC0000005): the process
disappears with no message, and only WGPU_BACKEND=gl helped. A driver
crash can't be caught, so each launch now leaves a marker in the
settings folder until its window draws. A launch that finds the
previous one's marker switches the new Settings ▸ Startup & Repair ▸
Window Graphics (Automatic / OpenGL) to OpenGL and says so in the
status bar. A window that fails to open says the next launch will try
OpenGL. WGPU_BACKEND still wins; macOS has no OpenGL backend, so it
never switches there.

Tests: a_launch_that_never_drew_switches_the_next_to_opengl (app),
window_graphics_is_validated (engine). Checked end to end with a
leftover marker: the app opened on OpenGL with the notice and cleared
the marker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:57:21 +07:00
bflatastic fdb655e78e GPU: the compositor declines OpenGL adapters at once (#243)
On OpenGL the compositor translated its compute kernels for tens of
seconds (22 s on an RTX 4090) before one failed validation and it fell
back to CPU compositing, all before the window's first frame. It now
declines GL adapters up front, as it declines adapters without compute
shaders. OpenGL is what Window Graphics falls back to for drivers that
crash with DirectX 12 or Vulkan.

Test: opengl_adapters_get_cpu_compositing_at_once (gpu tests_init;
skips without an OpenGL adapter).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:57:08 +07:00
BFlat 4d5765f158
Merge pull request #233 from ulanch/fix-variable-font-axis-test
Fix variable-font animator test axis selection and endpoint delta
2026-10-08 13:38:03 +07:00
BFlat 06aa138c94
Merge pull request #245 from storytold/fix/232-golden-libm-variant
Effects: the sim golden test accepts macOS 27's libm hash for Mr. Mercury (#232)
2026-10-08 13:36:30 +07:00
bflatastic e61f0e5560 Effects: the sim golden test accepts macOS 27's libm hash for Mr. Mercury (#232)
On macOS 27.2 (rustc 1.98) one of the 404 pinned simulation hashes,
CC Mr. Mercury at non-default settings, 32 bpc, t = 2.2 s, came out
different. The reporter rendered every case at the commit that pinned
the hashes and at current main on that machine, and the frames were
byte-identical. So the code didn't change: the platform's libm did
(the hashes are exact f32 bits through sin, cos and powf). The test
now also accepts listed per-case libm variants, with that hash as the
first, and every other case stays exact. Its docs say how to tell a
libm difference from a code change.

Test: libm_variants_are_accepted_for_their_case_only (sim_golden; runs
on every platform).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:36:09 +07:00
BFlat f4319c1917
Merge pull request #244 from storytold/fix/234-startup-logging
Logging: RUST_LOG prints to stderr, and start-up logs its milestones (#234)
2026-10-08 13:32:44 +07:00
bflatastic 468ed0b297 Logging: RUST_LOG prints to stderr, and start-up logs its milestones (#234)
The logger kept warnings in memory and wrote to a file only after Help
▸ Enable Logging, and it ignored RUST_LOG. An app that never opens its
window (macOS 27 on Apple Silicon, #234) therefore left no trace. Now
RUST_LOG (a level, or env_logger-style `target=level` directives)
also prints what it lets through to stderr, including eframe, winit
and wgpu. The desktop app logs its start-up milestones: opening the
window, the graphics adapter, the app created, the first frame drawn,
the window being asked to close, and the window closed.

Test: rust_log_filters_by_level_and_target (engine logging). Checked
by running the app with RUST_LOG=info and quitting it over the control
channel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:32:16 +07:00
BFlat 5a7119d5fa
Merge pull request #242 from storytold/docs/gaps-reports-8-oct
Docs: gaps.md records the user reports of 8 October
2026-10-08 13:11:12 +07:00
bflatastic 6a8d339316 Docs: gaps.md records the user reports of 8 October
The reports of 8 October (#225-#227, #229-#231), their fixes (#235,
#237-#241), what remains of #227 (multi-monitor support, the Project
panel's empty-area menu, effect points on shape and text layers) and
the regression tests for each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:10:57 +07:00
BFlat 49af6472bc
Merge pull request #240 from storytold/fix/227-shape-contents
Shapes: draw into, copy, paste and duplicate shape contents (#227)
2026-10-08 13:09:50 +07:00
bflatastic 25c01aa5a0 Docs: gaps.md lists the regression tests for the #227 shape fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:03:46 +07:00
bflatastic 89e075d1b1 Shapes: Tools bar Tool Creates Shape / Mask and Fill / Stroke Options (#227)
The Tools bar had Fill and Stroke swatches and a Stroke Width, kept in
the interface only, so agents couldn't set them and new shapes could
only be a solid fill with a solid stroke. As in After Effects:

- With a shape layer selected and a shape tool or the Pen active, Tool
  Creates Shape / Tool Creates Mask choose whether drawing adds shapes
  to the layer or masks on it (Fill and Stroke hide while drawing
  masks).
- Clicking the word "Fill" or "Stroke" opens Fill Options / Stroke
  Options: None, Solid Color, Linear Gradient or Radial Gradient, the
  blend mode and the opacity. The swatches show the kind; Alt-click
  steps through the kinds.
- New shapes and Pen paths are painted with them: a Gradient Fill or
  Gradient Stroke across the shape for the gradient kinds.

The options are engine state (EditorState.shape_tool) set with the new
shape.toolOptions command; shape.newShape and shape.newPath use them
unless given paint parameters (which now include fillType, fillBlend,
fillOpacity and their stroke twins; layer.newShape takes them too). A
Pen path without paint parameters now takes the Tools bar's stroke
(none by default) instead of a fixed 2 px white one. layer.addMask also
draws rounded rectangle, polygon and star masks, so every shape tool
makes a mask on a selected footage or solid layer (rounded rectangles,
polygons and stars made a new shape layer).

Test: shape_tool_options_paint_new_shapes,
masks_of_every_shape_tool_kind,
tool_creates_mask_draws_a_mask_on_the_selected_shape_layer,
fill_options_paint_the_next_shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:03:38 +07:00
bflatastic 0048569682 Shapes: Duplicate duplicates the selected shape items, not the layer (#227)
Edit > Duplicate (Ctrl+D) with a shape group or other shape item
selected in the Timeline duplicated the whole shape layer. As in After
Effects it now duplicates the selected items inside their group, each
copy above its original and numbered on ("Rectangle 1" gives
"Rectangle 2"), and selects the copies. With only layers selected the
layers are duplicated as before.

Test: duplicate_with_shape_items_selected_duplicates_them_in_place,
ctrl_d_duplicates_the_selected_shape_group_in_its_layer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:03:38 +07:00
bflatastic 95bb346c89 Shapes: Copy, Cut and Paste shape items between shape layers (#227)
Edit > Copy with a shape group (or a path, fill, stroke or path
operation) selected in a shape layer's Contents copied the whole layer,
and Paste made a new layer. As in After Effects, Copy now copies the
selected items, Paste puts them into each selected shape layer's
Contents (above its selected item, in that item's group, else on top,
with names kept unique there: "Rectangle 2"), and Cut removes them, so
Cut + Paste moves them. Pasting with no shape layer selected is an
error rather than a new layer. Copying anything else clears the shape
item clipboard, as it does the other clipboards.

Test: shape_contents_copy_cut_and_paste_between_shape_layers,
ctrl_c_and_ctrl_v_copy_a_shape_group_into_another_shape_layer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:03:38 +07:00
bflatastic 9968950b71 Shapes: drawing with a shape tool adds to the selected shape layer (#227)
With a shape layer selected, the Rectangle, Rounded Rectangle, Ellipse,
Polygon and Star tools made a new shape layer for every shape. After
Effects draws into the selected shape layer instead: the shape becomes a
new group on top of its Contents ("Rectangle 2", ...), placed by the
group's Transform. The new shape.newShape command does that (in the
layer's space, so a moved or scaled layer still gets the shape where it
was drawn), and makes a new shape layer only when no unlocked shape
layer is selected. The viewer's shape tools use it; masks on other
selected layers are unchanged. layer.newShape and the Pen's
shape.newPath share its layer and Contents helpers.

Test: shape_tools_draw_into_the_selected_shape_layer,
shape_tool_draws_into_the_selected_shape_layer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:03:38 +07:00
BFlat 7e4a7cd863
Merge pull request #238 from storytold/fix/227-interface
Spacebar hand tool, Effect Controls menu, New Comp drop, switch drags (#227)
2026-10-08 13:02:49 +07:00
bflatastic 764eee409b Gaps: regression evidence for the #227 interface fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:59:40 +07:00
bflatastic c4c28682eb Timeline: dragging over layer switches sets them all (#227)
Each layer switch toggled on its own click only, so hiding or locking
many layers took a click per layer. As in After Effects, pressing a
switch in the A/V Features or Switches column now sets it at once, and
dragging up or down gives the same switch of every layer the pointer
passes the state the first one got, whatever their own state was
(rows passed in a quick drag included). The whole drag is one undo
step: layer.setSwitch takes a `merge` key like other gesture edits.
A single click still changes only that layer, so clicking the eye of
one selected layer doesn't change the others (Layer > Switches still
sets all selected layers).

Test: dragging_over_layer_switches_sets_them_all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:59:40 +07:00
bflatastic 6b95b7a182 Project panel: items dropped on New Composition make a comp (#227)
Dragging Project items onto the Create a new Composition button at the
bottom of the Project panel did nothing. As in After Effects, the
button now takes the drop and runs File > New Comp from Selection with
the dragged items (the selection when the dragged item is in it): one
item becomes a comp of its size, frame rate and duration holding it,
and several open the New Composition from Selection dialog. The button
is outlined while items are dragged over it.

Test: project_items_dropped_on_new_comp_make_a_composition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:59:40 +07:00
bflatastic fab6dedd7c Effect Controls: right-click shows the Effect menu (#227)
Right-clicking the Effect Controls panel did nothing outside an
effect's header. As in After Effects, a right-click anywhere a control
has no menu of its own (the empty area, property names, buttons such
as Reset) now opens the Effect menu: Effect Controls, Last Effect,
Remove All, Manage Effects and every effect category, applying the
chosen effect to the selected layers. It is the menu bar's own Effect
menu tree and drawing code; an effect header keeps its own menu.

Test: right_click_in_effect_controls_shows_the_effect_menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:59:40 +07:00
bflatastic e008085a93 Spacebar: a tap previews on release, held it is the Hand tool (#227)
Spacebar started or stopped the preview on its press, so holding it to
pan the Composition panel (Space+drag already panned there) also
toggled playback. As in After Effects, plain Spacebar now runs its
shortcut when it is released, and only if no mouse button went down
while it was held and the press didn't go to a text field; key
auto-repeat toggles nothing more. Shift+Spacebar and the numpad
preview shortcuts still run on the press.

Holding Spacebar over the Timeline's ruler and time graph is the Hand
tool there too: dragging scrolls the time graph (when zoomed in, never
before the comp's start or past its end) instead of moving bars, keys
or markers.

Test: spacebar_taps_preview_and_held_spacebar_pans_the_viewer,
spacebar_drag_scrolls_the_time_graph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:59:40 +07:00
BFlat e1062ab81b
Merge pull request #241 from storytold/feat/229-system-language
Settings: Language ▸ Match System follows the system language (#229)
2026-10-08 12:58:55 +07:00
bflatastic fd572949f3 Settings: Language ▸ Match System follows the system language (#229)
The interface language was English unless Japanese was chosen in
Settings ▸ General ▸ Language. Language now has a Match System choice
(`general.language` = `system`), the new default: the operating
system's interface language where EffectCraft has a translation
(Japanese), else English, as After Effects installs in the system's
language. English and Japanese can still be chosen; a language chosen
before stays. The browser build stays in English with Match System,
since it has no Japanese font of its own.

The system language comes from sys-locale (MIT OR Apache-2.0, no
dependencies on the desktop platforms), on desktop builds only.

Tests: match_system_uses_the_system_language_where_there_is_a_catalog
(i18n), interface_language_is_validated_persisted_and_backward_compatible
(engine).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:58:11 +07:00
BFlat bb3e63e8f3
Merge pull request #239 from storytold/fix/230-viewer-selected-layer-first
Viewer: a press on a selected layer behind others acts on it (#230)
2026-10-08 12:50:11 +07:00
bflatastic 2c7eca7ef2 Viewer: a press on a selected layer behind others acts on it (#230)
A press in the Composition panel always acted on the topmost layer
under the pointer, so a selected layer could not be dragged where
another layer covered it: the press selected the layer in front. As in
After Effects, a selected layer under the pointer now comes first: it
stays selected and a drag moves it. With no selected layer there the
topmost layer is picked as before, and Shift still toggles the topmost
one. The context menu's list of layers under the pointer shares the
same hit test.

Test: viewer_press_prefers_a_selected_layer_under_the_pointer
(ui_viewer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:47:30 +07:00
BFlat 5d1b818fa1
Merge pull request #237 from storytold/fix/225-226-231-web
Web: short Project panel, unreadable picked files, lost graphics device (#231, #226, #225)
2026-10-08 12:46:13 +07:00
bflatastic db3b34b134 Web: a lost graphics device says so and offers a reload (#225)
When the page's WebGPU device was lost, the canvas kept showing its
last frame: edits still went through and rendered frames were right,
but the editor never showed them and nothing said why (the status
message about it is drawn in the canvas that can't present any more).
The page now covers the canvas with a notice that offers Save Project
(a download) and Reload. Reload first writes pending changes to
browser storage, so the session snapshot brings the project and its
unsaved changes back; if that write fails it says so and offers
Reload Anyway. Without a kept session (?empty, memory storage) the
notice says to save first.

Test: apps/effectcraft-web/tests/page.mjs (Node, fake DOM).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:38:11 +07:00
bflatastic 82a1862fa1 Web: Media Browser reports a picked file it can't read (#226)
Add Files... and Upload Folder... read every picked file in one go and
rejected on the first file the browser couldn't read; the Media
Browser dropped that rejection, so nothing was added and nothing said
why. Each file is now read on its own: the readable ones are added and
the status bar names the unreadable ones with the browser's reason and
suggests picking them again. Cancelling the picker is not reported.

Test: apps/effectcraft-web/tests/page.mjs (Node, fake DOM).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:38:11 +07:00
bflatastic 7612b220ef Project panel: a panel too short for the scroll bar draws (#231)
The Project panel's vertical scroll bar clamped its thumb between 16
points and the track's height. When the track was shorter than 16
points (an empty project in a 1280 x 800 browser window at device pixel
ratio 2, or any short panel), that clamp panicked on the first frame:
the browser canvas went blank and the app stopped responding. The
thumb is now at most the track's length, and both scroll bars share
the calculation.

Test: a_short_project_panel_draws (ui_project_rows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:38:10 +07:00
BFlat 543bff29dc
Merge pull request #235 from storytold/fix/227-turbulent-displace
Effects: Turbulent Displace displaces the whole layer, not one quadrant (#227)
2026-10-08 12:36:29 +07:00
bflatastic 754e80bdf9 Effects: Turbulent Displace displaces the whole layer, not one quadrant (#227)
Edge pinning (Turbulent Displace's default Pin All, Wave Warp's
Pinning, Bulge's Pin All Edges) holds the layer bounds still: a
comp-sized rectangle for shape and text layers. That rectangle started
at the layer's origin, but a shape layer's content surrounds its origin
(anchor 0,0), so everything left of or above the origin counted as
outside the layer and was pinned: only the bottom-right quadrant of a
centred shape moved.

The renderer now passes the bounds' top-left in layer coordinates
(EffectEnv::bounds_origin): (-w/2, -h/2) for layers without a source
rectangle, (0, 0) otherwise. util::pin_rect places the bounds there and
the three effects use it on the CPU and the GPU. Effect point
parameters keep their current layer coordinates.

Test: edge_pinning_displaces_all_of_a_centred_shape_layer,
edge_pinning_on_a_shape_layer (GPU vs CPU).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:20:10 +07:00
Alex Ulanch d506a80639 Fix variable-font animator test axis and endpoint selection 2026-10-07 21:38:19 -07:00
Brandon Thomas 172b7cfcc0 Release CI: macOS job on macos-15-xlarge
Custom macOS larger runners in a runner group do not pick up jobs; the
built-in M2 Pro label does. Only release.yml uses it, and the
actions_macos_xl budget caps the monthly spend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:08:03 -04:00
Brandon Thomas 904c0671b1 Release CI: macOS job on the org macos-release runner
The standard macOS runners are shared with all CI and queue for an hour or
more. The macos-release runner (GitHub-hosted M2 Pro) is in a runner group
usable only by release.yml on the release branch, with a monthly spend cap
on the actions_macos_xl SKU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:54:58 -04:00
Brandon Thomas ab5ebfbae1
About: contributor and model credits compiled in (grab bag + table, self-submitted names) (#228)
* About: contributors compiled in from contributors.json (grab bag + table, sortable, name toggle)

About EffectCraft now has tabs About · Contributors · Models, following the craftrules standard
(standards/contributors.md): contributors/contributors.json is generated by
craftrules/scripts/contributors.py and baked into the binary by crates/ui-egui/build.rs as static
tables. Each contributor line: @username, opt-in display/real name, PRs, commits, lines +/−/Δ,
binary assets +/−, first/last commit. Sortable by every column; alphabetical ignores case and
the leading @. Every control registers an automation id (about.tab.*, about.credits.*).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Contributors: self-submitted names in contributors/people.toml; AGENTS.md credit-yourself section

build.rs reads contributors/people.toml next to the generated contributors.json and shows a name only
for entries the generator verified as written by that person (craftrules standards/contributors.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Contributors: regenerate (verified people.toml entries)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:16:47 -04:00
Jonas Neubauer 8af8a11500 G2: merge main and preserve blank panel menus and selection 2026-10-08 02:06:56 +02:00
BFlat e38a92e792
Merge pull request #224 from storytold/fix/215-216-218-web-workers
Web: replaced files reach reused workers, failed worker starts fail their job, failed storage writes retry (#215, #216, #218)
2026-10-08 05:33:59 +07:00
Claude 1ca78821b9
Web: a failed storage write is retried and flush() rejects (#215)
A write to browser storage that failed (quota exceeded) was dropped:
effectcraft.flush() resolved anyway, nothing retried it, and a reload
restored the project from before the change. The failed key now stays
pending and is written again after 2 s (doubling to a minute while it
keeps failing; a newer change of the key goes with it), and flush()
rejects with the error.

Test: failed_writes_retry (store). The flush itself runs only in the
browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f
2026-10-07 22:33:04 +00:00
Claude 72b935fe26
Web: a job worker that fails to start fails its job (#216)
worker.js left a rejected start (its async init) unhandled, which a
page's Worker error event doesn't see: the page waited for "ready"
forever and the render stayed pending until cancelled. The failed start
is now re-raised where the page's onerror sees it, like the other
worker failures; the job fails with the error and the worker is
terminated.

Test: apps/effectcraft-web/tests/workers.mjs (worker.js with a stub
module whose init rejects; host.js with a worker whose start fails).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f
2026-10-07 22:33:04 +00:00
Claude a2f22bb35b
Web: a replaced file goes to reused workers again, even at the same size (#218)
Job workers remembered the files they were sent by path and byte
length, so footage replaced by different bytes of the same length at
the same path was never resent: a Render Queue export through a reused
worker rendered the old image while the page showed the new one.
Frame workers remembered paths only, so they never got a replacement
at all.

Every write of a stored file now gets a version, and both kinds of
worker remember the version they were sent.

Tests: versions_change_with_every_write (store) and
apps/effectcraft-web/tests/workers.mjs (Node, fake Worker: the
replacement is sent once, an unchanged file not again).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f
2026-10-07 22:33:04 +00:00
BFlat da6cb73142
Merge pull request #223 from storytold/docs/gaps-reports-7-oct
Docs: gaps.md records the user reports of 7 October
2026-10-08 05:17:25 +07:00
Claude 603a9bfdb4
Docs: gaps.md records the user reports of 7 October
#198, #201, #203–#206, #208, #209 and #212, with their regression tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f
2026-10-07 22:17:13 +00:00
BFlat 2d40171789
Merge pull request #222 from storytold/fix/212-puppet-input-cache
Puppet: dragging pins keeps the layer under the Puppet cached (#212)
2026-10-08 05:17:06 +07:00
BFlat ff163d42a0
Merge pull request #221 from storytold/fix/203-205-206-timeline-shapes
Timeline, shapes and masks: Add menu, Change Renderer, toolbar Fill / Stroke, Alt+Shift+P reveal, mask selection, linked feather, Project selection box (#203, #205, #206)
2026-10-08 05:16:48 +07:00
BFlat 0e6d37061a
Merge pull request #220 from storytold/fix/198-201-204-app-startup
App: starts with an empty project, opens on older GPUs, shows comps wider than the texture limit (#198, #201, #204)
2026-10-08 05:16:25 +07:00
BFlat a3468f6781
Merge pull request #219 from storytold/fix/208-209-audio-preview
Preview: audio visualisers follow time in the viewer; sound starts once rendering keeps up (#208, #209)
2026-10-08 05:16:00 +07:00
Claude a831d12975
Puppet: dragging pins keeps the layer under the Puppet cached (#212)
Every pin move re-rendered the Puppet's input (source, masks and the
effects before it) at full size on the UI thread and deep-copied it
for the mesh overlay: the cache key of a layer's input hashed all of
its effects, so moving a pin of the Puppet changed the key of its own
input. The input key now covers only the effects that run before the
Puppet, and the overlay shares the cached buffer.

Test: input_keys_ignore_later_effects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f
2026-10-07 22:13:19 +00:00
Claude b4d2d58eeb
Masks and Project panel: selecting a mask selects its points; linked Mask Feather; selection box (#203)
- Selecting a mask in the Timeline (its row or Mask Path), or a shape's
  Path, selects all its points, so dragging one in the viewer moves the
  whole mask, as in After Effects. Delete still removes the mask, and
  selecting another property deselects the points.
- Mask Feather has the chain link (Constrain Proportions, on by
  default, Alt edits one value) and no longer takes negative values;
  Scale's link shows as a toggle too.
- Dragging in the Project panel's empty area draws a selection box
  (Shift / Cmd adds); a click there deselects.

Tests: selecting_a_mask_selects_its_points,
mask_feather_values_are_linked_and_not_negative,
dragging_in_the_empty_area_box_selects_items.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f
2026-10-07 22:13:19 +00:00
Claude c357a53e5e
Viewer: frames wider than the GPU's texture limit are shown (#201)
An 11000 x 2200 comp at Full resolution renders on the CPU, and the
frame went to egui as one texture over the device's 8192 px limit:
the upload failed every frame (a panic in debug builds) and the window
stopped drawing. Creating a comp that size at Full did the same.

Frames, snapshots, the channel / exposure view, other viewers, the
Layer and Footage panels and the Roto Brush overlay now go up averaged
down by the smallest whole factor that fits. The full frame stays
readable for pixel readouts.

Tests: frames_over_the_texture_limit_are_averaged_down and
full_resolution_frames_wider_than_the_texture_limit_fit (panicked
before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f
2026-10-07 22:13:19 +00:00
Claude 6e31677718
Preview: sound starts once rendering keeps real time (#208)
Since #103, a preview with audio stayed silent until every frame from
the playhead to the end of the preview (or what fits in the RAM
preview) was cached. The prefetch only renders a few frames ahead, so
on a normal work area the sound came near the end or on the second
loop, even when the comp rendered in real time.

The held sound now also starts once the silent preview has kept real
time for a quarter second with the prefetch window ahead cached. A
comp that can't render in real time still waits for the cache, as #103
intended; a stall clears the measured rate so the sound doesn't cut in
and out.

Test: preview_with_audio_sounds_once_rendering_keeps_up (a 30 s comp
sounds within 2 s, without skipping a frame first).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f
2026-10-07 22:13:19 +00:00
Claude b26f55a775
Shapes and timeline: toolbar Fill / Stroke edit selected shape layers; Alt+Shift+P reveals; click below layers deselects (#205)
- With a shape layer selected, the toolbar shows its Fill, Stroke and
  Stroke Width (with any tool) and editing them changes the layer's
  fills and strokes, as in After Effects (new shape.fillStroke, one
  undo step per picker or drag). Shapes drawn without a stroke get one
  when a stroke colour or width is set, so changing the stroke now works.
- Alt+Shift+P (A, S, R, T) reveals the property it keyed on each layer.
- A click in the time graph below the last layer deselects the layers
  (it only deselected keyframes), so a shape tool draws a new shape
  layer instead of a mask on the selected image.

Tests: toolbar_fill_and_stroke_paint_selected_shape_layers,
toolbar_stroke_edits_the_selected_shape_layer,
add_position_key_reveals_position,
clicking_below_the_layers_deselects_them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f
2026-10-07 22:13:19 +00:00
Claude 188cc06d2b
App: the window's GPU device asks for what the adapter has (#198)
eframe's default device request asks for wgpu's default limits (8
storage buffers per stage and more). An older GPU such as Intel HD
Graphics 5500 can't meet them, so the device request failed before
EffectCraft ran, and the release build on Windows quit without a word.

The device now asks for the adapter's own limits (textures capped at
16384 px like the headless renderer). The compositor already checks
what it needs and composites on the CPU when the device can't run it.
If the window still can't open, a message dialog says why instead of
the app vanishing.

Test: device_limits_fit_the_adapter. Not checked on that GPU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f
2026-10-07 22:13:19 +00:00
Claude b8b13b1cec
Timeline: shape layers get the Contents "Add:" menu; Classic 3D shows Change Renderer (#206)
Trim Paths, Merge Paths, Repeater and the other path operations
existed (layer.addShapeItem) but no menu added them to a shape layer.
Like After Effects, a shape layer's Contents row now has "Add:" with
Group, Rectangle, Ellipse, Polystar, Path, Fill, Stroke, Gradient Fill,
Gradient Stroke and the path operations; with a shape group selected
they go into it. layer.addShapeItem gains the empty Path and Gradient
Stroke kinds.

A 3D text or shape layer in a Classic 3D comp now shows Geometry
Options with "Change Renderer…", which opens Composition Settings on
its 3D Renderer tab (Advanced 3D comps show the extrusion options).

Popup menus are as tall as the window allows and open upward near its
bottom: the 20-entry Add menu was cut at 420 px, hiding Trim Paths.

Tests: shape_contents_add_menu_adds_path_operations,
classic_3d_shape_layers_offer_change_renderer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f
2026-10-07 22:13:19 +00:00
Claude 24aed7f219
App: launching opens an empty project, not the demo (#204)
The desktop app opened the demo project whenever it wasn't given a
project file, so File ▸ New Composition on the Home screen added "Comp
4" to the three demo comps. As in After Effects, a launch now starts
with an empty Untitled Project. --demo opens the demo (Help ▸ Open
Demo Project and the Home screen tile still do); --empty is kept.

The arguments are parsed by a function now, with a test
(no_arguments_start_an_empty_project).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f
2026-10-07 22:13:19 +00:00
Claude e87e1b763e
Effects: Audio Spectrum and Audio Waveform follow time in the viewer (#209)
Both visualisers read their Audio Layer's samples around the frame
time, but they weren't in TIME_DEPENDENT, so the viewer's layer cache
gave the layer one key for every frame and showed the first frame's
spectrum for the whole preview. Export doesn't use that cache, which is
why it rendered correctly.

Test: audio_spectrum_follows_time_through_the_layer_cache renders a
tone that starts at 0.5 s through a shared layer cache after t = 0 and
compares it with an uncached render (it showed the stale baseline).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f
2026-10-07 22:13:18 +00:00
midasdf a5f0d71e9d M9: open grouped tool flyouts on long press 2026-10-08 05:39:48 +09:00
Jonas Neubauer a3de99c5e5 G2: add context menus to blank Project and Timeline areas 2026-10-07 21:41:00 +02:00
Brandon Thomas 3af4649429
Rename PrintCraft to PdfCraft (storytold/pdfcraft) (#207)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:37:26 -04:00
EthanSuquet 9b26c268b9 Render: text layers are always continuously rasterised (#194)
A text layer was drawn at its font size and the bitmap resampled by the
layer's Scale unless its Continuously Rasterize switch was on, so text
grown with Scale (the usual way to animate it) went soft. Text layers now
always rasterise at their on-screen scale, as they would with the switch;
shape layers and vector footage still follow the switch.

Text at 10 px scaled 800% now renders identically to text set at 80 px
(795 partially covered pixels each, maximum difference 0; it was 5854
against 795 before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:06:42 -04:00
BFlat 608d1b077b
Merge pull request #197 from storytold/fix/192-output-to-dialog
Render Queue: clicking the Output To file name opens a save dialog (#192)
2026-10-07 20:30:13 +07:00
BFlat f2b2fa811c Render Queue: clicking the Output To file name opens a save dialog (#192)
Clicking an item's Output To file name opened an inline editor on the
name template (`[compName].[fileExtension]`), so there was no way to
pick a folder from there. Choose File… in the menu beside it did open
a dialog, but the project save dialog: filtered to .ecproj and opening
wherever the OS chose.

As After Effects' Output Movie To, clicking the file name now opens a
save dialog at the current output (its folder and name) for the output
format's extension, and the chosen file becomes the output. Choose
File… opens the same dialog. Alt-click still edits the template in
place, and hosts without a save dialog (the web) keep the inline
editor. The desktop `pick_save_file` hook opens in the folder of a
default path.

Test: clicking_the_output_file_name_opens_the_save_dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:29:29 +07:00
BFlat 2bd6eeb647
Merge pull request #196 from storytold/fix/191-saved-workspaces-menu
Window: saved workspaces are listed in Window ▸ Workspace (#191)
2026-10-07 20:21:19 +07:00
BFlat 1db3698719 Window: saved workspaces are listed in Window ▸ Workspace (#191)
Save as New Workspace stored the layout and `window.workspace` could
switch to it, but the Workspace submenu was a fixed list of the
built-in workspaces, so a saved one never appeared and could not be
chosen from the menu.

The submenu now has a `savedWorkspaces` dynamic node after the
built-ins (DynCtx carries the saved names), in the in-window and native
menus, with a check mark on the current one as the built-ins have.

The in-window submenu was also too short to show it. egui lays out a
new submenu in a default 400 pt area, and the scroll area inside took
that height and kept it, so Window ▸ Workspace (514 pt) opened with its
last rows (Undocked Panels and below, where saved workspaces go)
scrolled out of view. Submenus now open at their full height and scroll
only when taller than the window.

Tests: saved_workspace_is_listed_in_the_workspace_menu (picks it from
the menu bar), saved_workspaces_are_in_the_native_workspace_menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:17:28 +07:00
BFlat f5ebe5f6c5
Merge pull request #189 from storytold/pr/release-0.4.0
Release: EffectCraft v0.4.0
2026-10-07 16:02:50 +07:00
bflatastic d64b1e546e Release: EffectCraft v0.4.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:02:25 +07:00
BFlat 17d844e969
Merge pull request #188 from storytold/fix/163-179-cli-mcp-engine
CLI, MCP and engine fixes (#163, #166, #167, #168, #175, #176, #177, #179)
2026-10-07 16:00:52 +07:00
bflatastic c1e13b1f66 Export: AV1 WebM refuses an explicit RGB + Alpha request (#166)
apply_output checked `channels: rgba` against the module before it
applied `webmCodec`, and choosing AV1 then quietly dropped the channels
to RGB. So `render --format webm --webm-codec av1 --channels rgba` (one
renderQueue.add) succeeded and wrote an opaque AV1 stream.

The codec options now apply before Channels, so the existing "has no
alpha channel" error (how an unsupported channels request is already
reported) refuses the combination; for WebM it names the codec: "AV1
WebM has no alpha channel (VP9 WebM has)". Choosing AV1 alone on an
RGBA VP9 item still switches it to RGB, as changing the format does.

Test: av1_webm_refuses_requested_alpha.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:48:17 +07:00
bflatastic 247187872d Render Queue: Output To an .mp4 file keeps an HEVC or AV1 module (#179)
apply_output switched the format whenever the Output To extension mapped
to a different format, and `.mp4` maps to H.264 only, so
renderQueue.setOutput with an .mp4 path (even the item's own default
name) turned an HEVC or AV1 module into H.264 and the render wrote an
H.264 stream.

The extension now changes the format only when the current format
doesn't write it (OutputFormat::extension): HEVC, AV1 and H.264 all write
.mp4, so they stay; `.mov` on H.264 still picks ProRes and `.mp4` on
ProRes still picks H.264.

Test: output_to_keeps_a_format_that_writes_the_extension.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:48:17 +07:00
bflatastic 5d2083b64c Comp: comp.new / comp.settings no longer advertise an `advanced3D` key (#177)
Their params docs wrote the renderer as `renderer? classic3D|advanced3D`.
Without a `:`, the doc parser reads `|` as "or another key", so
`advanced3D` became an accepted key in the schemas and the unknown-key
check: `comp.new {"advanced3D": true}` succeeded and made a Classic 3D
comp.

The docs now use the value-list form the other commands use,
`renderer?: classic3D|advanced3D`. The key is removed rather than
implemented: it would only duplicate `renderer` (and could contradict
it). A scan of every command's schema found no other key that came from
a value list.

Test: renderer_values_are_not_parameter_keys (not in the schema, rejected
as unknown, `renderer` still works on both commands).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:48:17 +07:00
bflatastic d416d9a01f MCP: add_effect is one undo step and leaves nothing applied when it fails (#175)
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>
2026-10-07 15:48:17 +07:00
bflatastic 8421489167 Expressions: prop.get reports a syntax error that disabled the expression (#163)
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>
2026-10-07 15:48:17 +07:00
bflatastic 3aaa840471 Scripting: script.run reports .jsxbin scripts as unsupported (#176)
`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>
2026-10-07 15:48:17 +07:00
bflatastic 50f26e6a47 CLI: unknown options are usage errors (#168)
Args::parse kept any unrecognised `--option` as a valueless flag nobody
read, so `exec comp.new --bogus` ran with defaults and exit 0, and a
misspelt valued option left its value behind as a positional:
`--saveas z.ecproj` opened z.ecproj as the project and failed with an
unrelated "cannot read" error.

Options are now checked against the declared lists (VALUED and the new
FLAGS) before anything runs; unknown ones are a usage error naming them
(exit 2), and `--flag=value` on a flag says it takes no value.
render-frame's `--transparent` (the MCP render_frame option) was one of
the silently ignored options; it now keeps the frame's alpha.

Tests: unknown_options_are_refused, every_option_read_is_declared (every
option the code reads is declared, so none is refused),
unknown_options_are_usage_errors (exit 2, nothing runs or renders, and
--transparent renders alpha).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:48:17 +07:00
bflatastic 2dda81c9bc CLI: a reader that closes stdout early is not a crash (#167)
The CLI printed with println!/eprintln!, which panic when the stream is
gone, so `exec --list | head` (or any output larger than the pipe buffer)
ended in a broken-pipe panic with exit status 101.

Output now goes through say!/note!, which never panic. Once stdout fails,
later output is dropped but the command still finishes its work (a
`run ... --save` sequence isn't cut short); a closed pipe is quiet and
exits 0, any other stdout error is reported and makes the exit status 1.
The MCP server ends quietly when its client closes stdout.

Test: a_closed_stdout_is_not_a_crash (info --json, exec --list, a saving
run sequence and mcp with stdout closed before they write).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:48:17 +07:00
BFlat 38e4c52d45
Merge pull request #187 from storytold/fix/170-171-inline-edit-stack-gaps
UI: inline fields select their value on click (#170); stacked panels resize (#171)
2026-10-07 14:30:38 +07:00
bflatastic 7387049ed4 Dock: drag the gaps between stacked panels to resize them (#171)
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>
2026-10-07 14:23:18 +07:00
bflatastic bcf5f32d9b UI: clicking into a value or inline text field selects all of it (#170)
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>
2026-10-07 14:23:18 +07:00
BFlat 6e2f0d9682
Merge pull request #186 from storytold/fix/172-long-audio
Media: conformed audio keeps its sound past 12:36 (#172)
2026-10-07 14:21:04 +07:00
bflatastic 86b6a8b6b9 Media: conformed audio keeps its sound past 12:36 (#172)
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>
2026-10-07 14:20:47 +07:00
BFlat 34e0e2d389
Merge pull request #185 from storytold/fix/161-vp9
VP9: frames never end in a byte that reads as a superframe marker (#161)
2026-10-07 14:15:51 +07:00
bflatastic e90f81850c VP9: frames never end in a byte that reads as a superframe marker (#161)
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>
2026-10-07 14:15:33 +07:00
BFlat 0946660c1c
Merge pull request #184 from storytold/docs/164-165-169-174-180-web-packaging
Docs and packaging: web hosting guide matches the build; Linux packages describe a compositor (#164, #165, #169, #174, #180)
2026-10-07 13:58:59 +07:00
bflatastic 0aa8402756 Packaging: Linux packages describe EffectCraft as a compositor (#169)
The Debian and RPM description (nfpm.yaml) was PhotoCraft's image-editor text. It now
describes the motion graphics and visual effects compositor; the RPM Summary is its first
line. The AppStream description, which read "EffectCraft is a motion graphics editor. Motion
graphics and visual effects compositor", gets the same wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:56:25 +07:00
bflatastic 120c7ec4e1 Docs: the web hosting guide matches what the build ships (#164, #165, #174, #180)
- #174: the file names are fixed (effectcraft_web.js, effectcraft_web_bg.wasm, worker.js,
  sw.js…), not content-hashed, so the guide's year-long immutable caching kept returning
  visitors on the old release after an in-place upgrade. The guide, nginx example, _headers
  and .htaccess now revalidate (no-cache) the .wasm, .js and index.html, and suggest a
  versioned path for longer caching. The file table lists the real files.
- #180: the .wasm is about 58 MB (17 MB gzip, 10 MB Brotli) for v0.3.1, not 13 MB / 5 MB;
  docs/web.md's 38 MB figure is updated too.
- #165: preferences live in the origin's browser storage (OPFS, IndexedDB or memory), not
  localStorage.
- #164: relative URLs come from the page itself (and package.sh's check), not a Trunk.toml
  that doesn't exist.
The iframe example's title no longer says image editor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:56:23 +07:00
BFlat b09f06f417
Merge pull request #183 from storytold/fix/147-156-162-viewer
Viewer: Pan Behind snapping and modifiers, locked viewers keep their comp (#147, #156, #162)
2026-10-07 13:51:53 +07:00
bflatastic 254e895316 Viewer: a locked viewer keeps its comp when the Project panel opens another (#156)
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>
2026-10-07 13:44:46 +07:00
bflatastic 72b039c6d5 Viewer: Pan Behind Alt-drag, Shift-drag and Ctrl+double-click to center (#162)
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>
2026-10-07 13:44:46 +07:00
bflatastic 83f17dda37 Viewer: Pan Behind snaps the anchor to its own layer (#147)
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>
2026-10-07 13:44:46 +07:00
BFlat 21641d5dee
Merge pull request #182 from storytold/fix/146-151-152-153-project-text
Project and text: row clicks, item labels, tracking alignment, Delete on groups (#146, #151–#153)
2026-10-07 13:43:48 +07:00
bflatastic 31fc517baa Edit: Delete removes selected animators, masks and shape groups (#151)
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>
2026-10-07 13:32:40 +07:00
bflatastic 2448deeb40 Text: tracking grows lines from their paragraph alignment (#146)
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>
2026-10-07 13:32:40 +07:00
bflatastic 1edf858180 Project: new layers take their item's label colour (#153)
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>
2026-10-07 13:32:40 +07:00
bflatastic 472521954d Project: folder twirls and label swatches take clicks (#152)
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>
2026-10-07 13:32:40 +07:00
BFlat 6cac35a144
Merge pull request #181 from storytold/fix/157-160-timeline
Timeline: per-layer reveals, Time Remap reveal, full zoom-out, navigator handles (#157–#160)
2026-10-07 13:30:32 +07:00
bflatastic 72e70f5360 Tests: ui_angles reveals Rotation through the per-layer filter (#160)
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>
2026-10-07 13:21:08 +07:00
bflatastic bfd1e974ea Timeline: the Time Navigator's ends drag to zoom; ; toggles frame level (#159)
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>
2026-10-07 13:13:26 +07:00
bflatastic f936b1ff9c Timeline: Alt+wheel zooms out to the whole comp (#158)
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>
2026-10-07 13:09:18 +07:00
bflatastic 76761bd2ef Timeline: enabling time remapping reveals Time Remap (#157)
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>
2026-10-07 13:04:12 +07:00
bflatastic 6b8200ff78 Timeline: reveal shortcuts leave unselected layers alone (#160)
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>
2026-10-07 13:02:01 +07:00
BFlat 8c4b988a65
Merge pull request #178 from storytold/fix/154-155-cli
CLI: --format hevc / av1 write HEVC / AV1; an unknown comp is named (#154, #155)
2026-10-07 12:28:57 +07:00
bflatastic c4f8e50201 CLI: --format hevc / av1 write HEVC / AV1; an unknown comp is named (#154, #155)
#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>
2026-10-07 12:28:12 +07:00
BFlat 21c0d33597
Merge pull request #173 from storytold/docs/136-137-149-releasing-tools-fonts
Docs and tests: releasing guide, MCP tools table, font-list test (#136, #137, #149)
2026-10-07 12:02:00 +07:00
bflatastic f9b6edb4e8 Tests: list_fonts checks the query's contract, not a count of one (#149)
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>
2026-10-07 12:01:30 +07:00
bflatastic eb5e72cdfa Docs: agents.md lists every MCP tool and argument, checked by a test (#137)
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>
2026-10-07 12:00:47 +07:00
bflatastic fd533fdb6d Docs: releasing.md, the release procedure release.yml points to (#136)
How a release is cut (version bump, the gate, pushing main to release,
the draft, publishing), test runs, what gets built where, the Linux
glibc baseline behind the ubuntu-22.04 runners, and the optional signing
secrets. release.yml's two pointers now resolve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:53:50 +07:00
BFlat 2522bbe7f0
Merge pull request #148 from coygeek/docs/add-star-history
docs: add star history chart to README
2026-10-07 11:51:29 +07:00
BFlat eafcdea587
Merge pull request #150 from Luskish/fix/control-close-on-malformed-input
Control: close the connection on input that isn't a request
2026-10-07 11:51:25 +07:00
Luskish 2fc7da7d29 Control: close the connection on input that isn't a request
A line that is not a JSON object with a string `method` (an HTTP request
line, other non-JSON text, a JSON array, invalid UTF-8, a line over 4 MiB)
now gets one error reply and the connection is closed, so a browser's
cross-origin fetch can't smuggle a command in its body. Connections are
capped at 16. Same transport as LightCraft (storytold/lightcraft#129) and
FilmCraft (storytold/filmcraft#123).
2026-10-06 22:54:21 -04:00
coygeek fc678ca6cd docs: add star history chart to README
Show GitHub star growth with a linked date-based Star History chart at the end of the README.
2026-10-06 19:36:22 -07:00
BFlat a6e6e2938c
Merge pull request #145 from storytold/docs/community-fixes-7-oct
Docs: gaps.md records the community fixes of 7 October
2026-10-07 07:54:46 +07:00
bflatastic f0b7b11393 Docs: gaps.md records the community fixes of 7 October (#135, #139, #141, #142)
The ui_angles evidence (#141) moves next to G2's other regression
evidence; the Stability row notes the parent-chain hang, the label colour
panic and the contained GPU failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:54:28 +07:00
BFlat 578f837fa4
Merge pull request #144 from storytold/fix/139-readback-wait
GPU: land #139 (contain GPU initialization errors) with a readback latency fix
2026-10-07 07:53:36 +07:00
bflatastic 80dff0fb0e GPU: bounded readbacks block on the GPU instead of sleeping between polls
#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>
2026-10-07 07:52:53 +07:00
bflatastic bfabc72be1 Merge #139 from dexterlabs1: contain GPU initialization errors and retire failed compositor work 2026-10-07 07:52:46 +07:00
BFlat a36eabee5a
Merge pull request #142 from dexterlabs1/contribution/parent-cycle-current-review
Prevent parenting hangs on malformed loaded ancestor chains
2026-10-07 07:26:44 +07:00
BFlat d0bf941522
Merge pull request #141 from jonassneu/fix/angle-input-validation
UI: cancel numeric edits on Escape and reject non-finite values
2026-10-07 07:24:12 +07:00
BFlat 2a817d8c72
Merge pull request #135 from storybored379/fix/unicode-label-colors
Settings: handle invalid Unicode label colours gracefully
2026-10-07 07:19:40 +07:00
BFlat 7c0cbc5a5c
Merge pull request #128 from EthanSuquet/chore/untrack-nested-target
Untrack crates/hevcenc/target (36 MB of build output) and ignore nested target/
2026-10-07 07:16:33 +07:00
Dexter f5b98f8199 fix(engine): bound parenting validation on malformed ancestor chains 2026-10-06 18:54:13 -05:00
Jonas Neubauer a6685a1eb7 G2: cancel numeric edits on Escape and reject non-finite values 2026-10-07 01:43:57 +02:00
Dexter 94dbccf69f fix: contain GPU initialization and retire failed readbacks 2026-10-06 16:40:12 -05:00
Dexter 2e02523fe9 test: tolerate installed variants of bundled font families 2026-10-06 16:40:12 -05:00
BFlat 2fadd14f09
Merge pull request #134 from storytold/docs/issues-85-117
Docs: user issues #85–#117 in gaps, parity, ROADMAP and README
2026-10-07 02:34:38 +07:00
bflatastic 751d6a5c1a Docs: user issues #85–#117 in gaps, parity, ROADMAP and README
gaps.md records the seven reports of 6 October (#85, #88, #89, #93, #103,
#106, #117), what they found in features marked done, their fixes of 7
October and the regression tests; parity.md's Interface, Animation and
Preview rows, the ROADMAP's reliability note and the README's panels
paragraph (drag and drop between panels) follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:34:15 +07:00
bflatastic 63a7483a48 Comments: describe the new drop, angle and cache code without product names
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:34:14 +07:00
BFlat 1ea493f257
Merge pull request #133 from storytold/fix/85-88-viewer-drop
Viewer: drop Project items, files and effects onto the comp (#85, #88)
2026-10-07 02:31:41 +07:00
bflatastic 8ef1a28bc4 Viewer: drop Project items, files and effects onto the comp (#85, #88)
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>
2026-10-07 02:31:13 +07:00
BFlat 78f49e09ab
Merge pull request #131 from storytold/fix/89-timeline-drop
Timeline: Project items land where they are dropped (#89)
2026-10-07 02:19:04 +07:00
bflatastic 1a0940d50a Timeline: Project items land where they are dropped (#89)
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>
2026-10-07 02:18:38 +07:00
BFlat 9e619d2893
Merge pull request #129 from storytold/fix/106-gpu-out-of-memory
GPU: running out of video memory falls back to the CPU instead of crashing (#106)
2026-10-07 02:06:46 +07:00
bflatastic e64076e432 GPU: running out of video memory falls back to the CPU instead of crashing (#106)
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>
2026-10-07 02:06:15 +07:00
EthanSuquet 0f583853ad Untrack crates/hevcenc/target (36 MB of build output) and ignore nested target/
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>
2026-10-06 15:02:20 -04:00
BFlat 0fac647928
Merge pull request #127 from storytold/fix/103-preview-audio-cache
Preview: Audio, Lock and Shy keep the cache; playback with audio waits for frames (#103)
2026-10-07 01:47:01 +07:00
bflatastic 93856382e7 Preview: Audio, Lock and Shy keep the cache; playback with audio waits for frames (#103)
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>
2026-10-07 01:46:35 +07:00
BFlat 4a401a4785
Merge pull request #123 from storytold/fix/93-angle-revolutions
Angles: the revolutions ("0x") scrub and take typing (#93)
2026-10-07 01:29:27 +07:00
bflatastic 27b0eec216 Angles: the revolutions ("0x") scrub and take typing (#93)
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>
2026-10-07 01:29:03 +07:00
BFlat 1a9288f83c
Merge pull request #122 from storytold/claude/issue-117-render-settings-templates
Render Queue: Render Settings templates apply from the menu (#117)
2026-10-07 01:20:01 +07:00
bflatastic 8a415e03ca Render Queue: Render Settings templates apply from the menu (#117)
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>
2026-10-07 01:19:25 +07:00
BFlat ea25e402b1
Merge pull request #119 from storytold/claude/issue-94-windows-arm64-docs
Docs: Windows ARM64 builds in the README (#94)
2026-10-07 00:44:23 +07:00
Claude fa25545d8b
Docs: Windows ARM64 builds in the README (#94)
#94 asked for native Windows on ARM builds and for their availability and limits to be
documented. The builds landed in #101 (an arm64 entry in the release matrix, the PE checks in
package.ps1 and the windows-arm64 workflow that installs the MSI on ARM64 hardware), but the
README never said they exist. Get started now lists the installers on the Releases page for
each platform, says the Windows ARM64 build runs without x64 emulation, and says what CI checks
on ARM64 hardware and what it doesn't yet (the app's window and a native test run).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iLqXiCVkUULoN4eWTD7cB
2026-10-06 17:44:05 +00:00
BFlat c750b299cf
Merge pull request #118 from storytold/claude/system-fonts-rmnw4m
G2: font menus list the installed fonts
2026-10-07 00:37:13 +07:00
storybored379 202417dab3 Settings: handle invalid Unicode label colours gracefully
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>
2026-10-06 09:58:59 -07:00
Brandon Thomas 588d269d29 Effects: adding an effect brings up Effect Controls (#87)
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)
2026-10-06 11:34:27 -04:00
Brandon Thomas aa31ffc2fb Project: opening a comp brings up its Composition panel (#90)
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)
2026-10-06 11:34:27 -04:00
Claude 0ffa2470b3
Docs: installed fonts in the font menus (gaps, parity, ROADMAP, README, architecture)
gaps.md records the Windows report and its fix under real-user experience and G2; parity.md's
Text row, the ROADMAP's reliability note and the README's Text bullet say text can use any
installed font; architecture.md says when the system font scan runs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EjTYdt76hTG4Kh2CJFnCr
2026-10-06 15:14:55 +00:00
Claude dda9961889
G2: font menus list the installed fonts
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
2026-10-06 15:14:55 +00:00
Brandon Thomas a46ebf4dc7 Puppet: selected pins are filled and ringed, unselected hollow (#96)
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)
2026-10-06 11:09:41 -04:00
Brandon Thomas 7002c92632 Timeline: selected effect rows highlight (#91, #97)
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)
2026-10-06 11:09:41 -04:00
Brandon Thomas 4b9e701223 Merge remote-tracking branch 'origin/main' into land-prs 2026-10-06 10:33:00 -04:00
Brandon Thomas d4e310b252 Merge fixes for #95 + #71 on current main
- 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>
2026-10-06 10:22:15 -04:00
Brandon Thomas 0e27c845f7
Merge pull request #101 from storytold/pr/windows-arm64
Windows on ARM64: release builds, PE header check, install test on ARM64 hardware
2026-10-06 10:21:02 -04:00
Brandon Thomas 7e1c2d0329 Windows ARM64 workflow: pin third-party actions by commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:51:09 -04:00
Brandon Thomas 370a7a5131 Windows on ARM64: release builds, PE header check, install test on ARM64 hardware
Release builds add an arm64 MSI and portable zip, cross-compiled on the x64
runner (signing and WiX as for x64). package.ps1 checks each binary's PE
header before packaging: Machine must match -Arch, the app must be a GUI
program and the CLI a console program. It skips the CLI smoke run when an
ARM64 build can't run on the host. A new Windows ARM64 workflow packages the
ARM64 MSI, installs it on a Windows 11 ARM64 runner, checks both installed
exes are ARM64, runs the CLI, and uninstalls. Ported from printcraft#66.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:38:55 -04:00
Brandon Thomas dc96725725 audio: allow the deprecated fetch_update while rust-version is 1.95
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>
2026-10-06 08:46:22 -04:00
Brandon Thomas 633fbcfaeb Merge PR #71: Persist Japanese menu language in General settings 2026-10-06 08:42:00 -04:00
Brandon Thomas dfd8aac431 keyframe: never-crash deny lints (craftrules standards/never-crash.md)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 2232354b902238827b2e80a29ef266c2e5bde272)
(cherry picked from commit 95da7943454ca5b2575993d11f9ba046822ce255)
2026-10-06 08:41:33 -04:00
Brandon Thomas 98872a4690 Merge PR #95: Improve AE-style editing, easing fidelity and Windows GPU rendering 2026-10-06 08:41:33 -04:00
Brandon Thomas e6c817bb3f
Merge pull request #99 from storytold/pr/release-0.3.1
Release: EffectCraft v0.3.1
2026-10-06 08:04:15 -04:00
Brandon Thomas e1e0a78be5 Release: EffectCraft v0.3.1
Fixes the Windows installer: the Start Menu shortcut and Add/Remove Programs icon were blank. The shortcut description was wrong. Release builds no longer open a console window behind the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:04:07 -04:00
Brandon Thomas 722d7d673c
Merge pull request #98 from storytold/pr/windows-packaging
Windows packaging: Start Menu/Add-Remove icon, no console window, correct description
2026-10-06 07:54:09 -04:00
Brandon Thomas 17b17bcce3 Windows packaging: Start Menu/Add-Remove icon, no console window, correct description
The MSI Icon id had no .ico/.exe extension, which Windows Installer needs for
advertised shortcuts and ARPPRODUCTICON, so both showed a blank icon. Release
builds of the desktop app are now GUI-subsystem (no console window; the CLI stays
a console program), the shortcut/summary description names what EffectCraft does, and
packaging-lint rejects an Icon id without an extension. Found in printcraft#57.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:51:06 -04:00
midasdf 25233abbcb G9: persist Japanese menu language without bundled fonts 2026-10-06 16:22:23 +09:00
Dexter 39e6907b5f G2: improve editing reliability and Windows rendering fidelity 2026-10-06 00:00:31 -05:00
BFlat 80c246d843
Merge pull request #92 from storytold/m3-delete-confirm
M3.16: deleting Project items in use asks first (After Effects parity)
2026-10-06 11:36:27 +07:00
bflatastic 9624024dc1 M3.16: deleting Project items that compositions use asks first
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>
2026-10-06 11:35:57 +07:00
BFlat b46be83ab4
Merge pull request #84 from fortesnow/jp-font-fallback
text: script-aware system font fallback; accept PostScript / full font names
2026-10-06 10:57:36 +07:00
BFlat 86ae2fa218
Merge pull request #72 from jonassneu/fix/project-delete-shortcuts
Project: delete selected items with Delete and Backspace
2026-10-06 10:56:43 +07:00
bflatastic f0578a5940 Merge branch 'main' into pr72-land
# Conflicts:
#	docs/gaps.md
2026-10-06 10:55:43 +07:00
BFlat 42401e7672
Merge pull request #70 from jonassneu/fix/viewer-visible-layer-selection
Viewer: respect solo and visibility in layer selection
2026-10-06 10:54:56 +07:00
fortesnow c8e9efb962 text: script-aware system font fallback and PostScript / full font names
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.
2026-10-06 10:41:15 +09:00
BFlat 877afeb25a
Merge pull request #83 from storytold/pr/release-gate
Release gate: ui_agent_audit builds in release mode
2026-10-06 08:37:17 +07:00
bflatastic f25b75486b Release gate: the agent-audit test builds in release mode
`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>
2026-10-06 08:37:01 +07:00
BFlat 857340036b
Merge pull request #82 from storytold/docs/issue-status
Docs: user issues #41–#47 and #63–#68 in gaps.md
2026-10-06 00:39:08 +07:00
bflatastic 25f353fb2c Docs: gaps.md records the user issues of 5 October and what became of them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:38:43 +07:00
BFlat dd56ae5c00
Merge pull request #81 from storytold/fix/44-project-import
Fix #44: double-click the Project panel's empty area to import
2026-10-06 00:37:36 +07:00
bflatastic df12664b8e Fix #44: double-click the Project panel's empty area to import
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>
2026-10-06 00:37:05 +07:00
BFlat fdfbca5faf
Merge pull request #80 from storytold/fix/43-wayland-icon
Fix #43: the AppImage's window shows the EffectCraft icon on Wayland
2026-10-06 00:31:29 +07:00
bflatastic b424c321dc Fix #43: the AppImage's window shows the EffectCraft icon on Wayland
Wayland compositors take a window's icon from the desktop entry named by its app ID; the .deb,
.rpm and Flatpak install it, an AppImage installs nothing, so its window got the generic icon.
Started from an AppImage ($APPIMAGE), the app now writes a hidden (NoDisplay) desktop entry
pointing at it, and its 256 px icon, under $XDG_DATA_HOME (~/.local/share) before the window
opens. Nothing is written when an installed package or the user's own entry provides the ID;
the entry follows the AppImage when it moves; failures are only logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:31:06 +07:00
BFlat 72e60dc6fa
Merge pull request #79 from storytold/fix/45-project-details
Fix #45: the Project panel's details stay inside the panel
2026-10-06 00:26:17 +07:00
bflatastic e6d4a64775 Fix #45: the Project panel's details stay inside the panel
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>
2026-10-06 00:25:57 +07:00
BFlat 83cd5d0f45
Merge pull request #78 from storytold/fix/46-docking
Test #46: docking by the panel menu button and resizing by the gutters
2026-10-06 00:18:37 +07:00
bflatastic efcbc2e412 Test #46: panels dock by a drag from the panel menu button; gutters resize
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>
2026-10-06 00:18:11 +07:00
BFlat bb7b5f51de
Merge pull request #77 from storytold/fix/65-drag-refresh
Fix #65: the viewer keeps up while a layer is dragged
2026-10-06 00:16:25 +07:00
bflatastic 1b8780f6da Fix #65: the viewer keeps up while a layer is dragged
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>
2026-10-06 00:16:04 +07:00
BFlat 75640044b8
Merge pull request #76 from storytold/fix/64-double-axes
Fix #64: one 3D compass in the viewer
2026-10-05 23:49:03 +07:00
bflatastic 77c056a8db Fix #64: one 3D compass in the viewer
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>
2026-10-05 23:48:41 +07:00
BFlat 0dcbe325ab
Merge pull request #74 from storytold/fix/63-viewer-scale
Fix #63: scaling a layer by its handles follows the pointer
2026-10-05 23:44:30 +07:00
bflatastic 782a67f228 Fix #63: scaling a layer by its handles follows the pointer
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>
2026-10-05 23:43:50 +07:00
BFlat 4373c24962
Merge pull request #73 from storytold/fix/68-open-links
Fix #68: Discord buttons and other links open the browser again
2026-10-05 23:27:45 +07:00
bflatastic 0286a49f0a Fix #68: open links in the system browser (Discord buttons, Help menu, Settings)
The desktop app built eframe without its `links` feature, so egui's open_url was a no-op
on native: the header and Home Discord buttons, Help menu links and Settings links did nothing.
Enabling it links the `webbrowser` crate (MIT OR Apache-2.0), which opens URLs with the
system browser on macOS, Windows and Linux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:26:29 +07:00
Jonas Neubauer 41f5ee3aec G2 (#67): delete Project items with Delete and Backspace 2026-10-05 18:24:14 +02:00
Jonas Neubauer a0c1b60d61 G2 (#66): respect solo and visibility in viewer selection 2026-10-05 18:01:41 +02:00
Brandon Thomas e5c58600fd
Merge pull request #60 from storytold/pr/freebsd
FreeBSD: system info reports memory, CPU model and OS version
2026-10-05 11:33:17 -04:00
Brandon Thomas b70c808d60 FreeBSD: system info reports memory, CPU model and OS version
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>
2026-10-05 11:33:06 -04:00
Brandon Thomas 7f3caeea70
Merge pull request #69 from storytold/pr/gpu-gl-fallback
GPU on wgpu's OpenGL backend (FreeBSD / Linux without Vulkan)
2026-10-05 11:29:02 -04:00
Brandon Thomas 3767332efd GPU compositor declines devices whose limits its pipelines exceed
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>
2026-10-05 10:52:04 -04:00
Brandon Thomas 6eedf8e2c5 GPU on wgpu's OpenGL backend: renderable depth target, portable shaders, serialised tests
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>
2026-10-05 10:08:21 -04:00
BFlat 24fcbdf3d2
Merge pull request #62 from storytold/pr/release-0.3.0
Release: EffectCraft v0.3.0
2026-10-05 20:42:35 +07:00
bflatastic 044fc32a9c Release: EffectCraft v0.3.0
Bump the workspace version 0.2.0 -> 0.3.0 (cargo xtask version set 0.3.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:42:11 +07:00
BFlat b9ffd889bc
Merge pull request #61 from storytold/m13-face-ml
M13.36: face tracking trained model (MediaPipe Face Landmarker, pure Rust)
2026-10-05 20:40:31 +07:00
bflatastic e43bf66775 M13.36: face tracking trained model — MediaPipe Face Landmarker on a pure-Rust TFLite interpreter
- 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>
2026-10-05 20:39:45 +07:00
Brandon Thomas e2f027f374
docs: point agents to the shared test corpora (#59)
Real-file test oracles live in storytold/photocraft-corpus, explained in
craftrules standards/test-corpora.md. Never commit large binary fixtures
here; fetch them pinned and sha256-verified.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:24:13 -04:00
BFlat 4624c00308
Merge pull request #58 from storytold/m13-roto-ml
M13.35: Roto Brush trained model — swappable segment module, MobileSAM in pure Rust
2026-10-05 17:57:36 +07:00
bflatastic 174ab2b6af Merge main (gap assessment) into M13.35: keep parity numbers, record G7's decision and evidence
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:57:09 +07:00
bflatastic 7579de21c6 M13.35: Roto Brush trained model — swappable segment module, MobileSAM in pure Rust
- 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>
2026-10-05 17:55:02 +07:00
Brandon Thomas aa4656b543 Docs: honest gap assessment (docs/gaps.md), roadmap workstreams G1–G9, README status
parity.md's ≈99% measures breadth only. docs/gaps.md assesses real-world
readiness by dimension (fidelity, .aep interop, stability, platform
reliability, performance, media, AI tools, maturity) with evidence, and
sets prioritised workstreams with done criteria. ROADMAP, README, parity.md,
AGENTS.md and CLAUDE.md point agents at it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:32:19 -04:00
BFlat 11d4885764
Merge pull request #57 from storytold/m13-audio-scrub
M13.34: audio scrubbing (Ctrl/Cmd-drag the current time)
2026-10-05 15:19:12 +07:00
bflatastic 5afc25d0d8 M13.34: audio scrubbing (Ctrl/Cmd-drag the current time)
- 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>
2026-10-05 15:18:54 +07:00
BFlat 9607be471b
Merge pull request #56 from storytold/m13-nested-markers
M13.33: nested comp markers on the precomp layer bar
2026-10-05 15:12:22 +07:00
bflatastic f013bdd8d7 M13.33: nested comp markers on the precomp layer bar
- 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>
2026-10-05 15:12:06 +07:00
BFlat 1ac55c33a3
Merge pull request #55 from storytold/m13-new-viewer
M13.32: View ▸ New Viewer — several Composition viewers
2026-10-05 15:00:30 +07:00
bflatastic 97390f15fb M13.32: View ▸ New Viewer — several Composition viewers
- 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>
2026-10-05 15:00:11 +07:00
BFlat 6a5abad753
Merge pull request #54 from storytold/m4-content-keyed-frames
M4.14: content-keyed RAM preview frames (M4 done); proxies in the disk cache key
2026-10-05 14:39:33 +07:00
bflatastic 26c714db7f M4.14: content-keyed RAM preview frames; proxies in the disk cache's content key
- 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>
2026-10-05 14:39:17 +07:00
bflatastic 4f7723a7b6 M13.31: Home size column test uses an unambiguous size (12.5 KB rounds to even)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:33:44 +07:00
BFlat 0f1cad8bbd
Merge pull request #53 from storytold/ci-packaging-lint
CI: fix Packaging lint (SC2015 in release.yml)
2026-10-05 13:52:21 +07:00
559 changed files with 31865 additions and 3435 deletions

48
.github/workflows/freebsd.yml vendored Normal file
View File

@ -0,0 +1,48 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
name: FreeBSD
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- .github/workflows/freebsd.yml
workflow_dispatch:
concurrency:
group: freebsd-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
freebsd:
name: FreeBSD 14 (build + test)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
prepare: |
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked

View File

@ -47,7 +47,22 @@ jobs:
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/effectcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
if grep -oE '<Icon Id="[^"]*"' packaging/windows/effectcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
fi
desktop-file-validate packaging/linux/ai.storyteller.effectcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.effectcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.effectcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.effectcraft.metainfo.xml"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.effectcraft.yml')); assert m['id'] == 'ai.storyteller.effectcraft' and m['command'] == 'effectcraft'"
python3 - <<'EOF'
import yaml
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.effectcraft.yml"))
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.effectcraft.bundle.yml"))
for m in (src, bundle):
assert m["id"] == "ai.storyteller.effectcraft" and m["command"] == "effectcraft", m["id"]
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
print("Flatpak manifests ok")
EOF
- name: FreeBSD package layout (stub binaries)
run: bash packaging/freebsd/package.sh --dry-run

View File

@ -1,9 +1,12 @@
# Release pipeline: every push to `release` builds signed installers for every platform and
# creates or updates the draft GitHub Release "EffectCraft v<version>". See docs/releasing.md.
#
# All jobs run in the `release` environment (restricted to the `release` branch), which holds
# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a
# warning, never a failed build.
# The jobs that sign (macOS, Windows) and the one that publishes the draft run in the `release`
# environment (restricted to the `release` branch), which holds the signing secrets. Every secret
# is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The
# jobs that sign nothing (version, Linux, Flatpak, FreeBSD, web) run without it, so they get no
# secrets and a dispatch on any branch builds and checks them as a dry run; there the signed jobs
# are refused by the environment's branch rule and the draft release job is skipped.
name: Release
on:
@ -35,7 +38,7 @@ jobs:
version:
name: version
runs-on: ubuntu-latest
environment: release
# No `environment: release`: it reads Cargo.toml only (see the header).
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
@ -63,7 +66,7 @@ jobs:
macos:
name: macOS universal
needs: version
runs-on: macos-15
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
environment: release
env:
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
@ -112,6 +115,9 @@ jobs:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@ -154,7 +160,7 @@ jobs:
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
environment: release
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
@ -172,7 +178,7 @@ jobs:
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libasound2-dev \
desktop-file-utils appstream python3-yaml
desktop-file-utils appstream python3-yaml zsync
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
@ -194,6 +200,9 @@ jobs:
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
# Update information is embedded, and the .zsync for delta updates sits beside it.
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@v4
with:
@ -201,11 +210,93 @@ jobs:
path: dist/release/*
if-no-files-found: error
flatpak:
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Install flatpak-builder
run: |
sudo apt-get update
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
# `appstreamcli compose`, which renders the scalable icon and fails without it.
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/effectcraft-$EFFECTCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@v4
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
# .github/workflows/freebsd.yml.
runs-on: ubuntu-latest
timeout-minutes: 240
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Build and package in a FreeBSD VM (tar.gz)
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Passed into the VM by name; never templated into the script below.
envs: "EFFECTCRAFT_VERSION EFFECTCRAFT_BUILD_DATE EFFECTCRAFT_BUILD_SHA INPUT_VERSION"
# The FreeBSD CI packages, plus bash for the packaging script.
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
set -e
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM (copying the
# target dir back fails on large builds). 4 jobs: more runs the VM out of memory.
export CARGO_TARGET_DIR=/var/tmp/effectcraft-target CARGO_BUILD_JOBS=4
if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$EFFECTCRAFT_VERSION"; fi
bash packaging/freebsd/package.sh
tar -tzf dist/release/effectcraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/effectcraft$|/bin/effectcraft-cli$|/share/applications/ai\.storyteller\.effectcraft\.desktop$|/share/doc/effectcraft/LICENSE-MIT$'
- uses: actions/upload-artifact@v4
with:
name: release-freebsd-x86_64
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
environment: release
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@ -238,7 +329,8 @@ jobs:
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, web]
# Needs the signed jobs, so a dry run on a branch (where they are refused) never gets here.
needs: [version, macos, windows, linux, flatpak, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:

95
.github/workflows/windows-arm64.yml vendored Normal file
View File

@ -0,0 +1,95 @@
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
workflow_dispatch:
concurrency:
group: windows-arm64-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
package:
name: package (cross-compiled on x64)
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v4
with:
name: effectcraft-windows-arm64
path: dist/arm64/*
if-no-files-found: error
retention-days: 7
install:
name: install and run on ARM64
needs: package
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@v4
with:
name: effectcraft-windows-arm64
path: dist
- name: Install the MSI, check it, run it, uninstall it
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
$dir = Join-Path $env:ProgramFiles 'EffectCraft'
foreach ($exe in 'effectcraft.exe', 'effectcraft-cli.exe') {
$path = Join-Path $dir $exe
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
$bytes = [System.IO.File]::ReadAllBytes($path)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
Write-Output "ok $path (ARM64)"
}
# The installed CLI runs natively on ARM64.
$cli = Join-Path $dir 'effectcraft-cli.exe'
$out = & $cli --version 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "effectcraft-cli --version exited $LASTEXITCODE" }
Write-Output "ok effectcraft-cli --version: $($out.Trim())"
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path (Join-Path $dir 'effectcraft.exe')) { throw 'uninstall left effectcraft.exe behind' }
Write-Output 'ok uninstalled'

2
.gitignore vendored
View File

@ -1,6 +1,6 @@
*~
plan/
/target
target/
/dist
/log
.DS_Store

View File

@ -105,11 +105,38 @@ reproduce an image (for example, point lists traced from someone else's icon).
See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is
a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit.
Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in
[`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in
[craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md).
Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified,
as PhotoCraft does with `cargo xtask corpus`.
## See also
- [docs/gaps.md](docs/gaps.md): where EffectCraft falls short of After Effects and the prioritised workstreams (G1–G9); read it before choosing work
- [CONTRIBUTING.md](CONTRIBUTING.md): setup, gates, commits, how to add things
- [docs/architecture.md](docs/architecture.md): layers, data model, commands, pipeline
- [docs/testing.md](docs/testing.md): oracle tests, criteria, benchmarks
- [docs/agents.md](docs/agents.md): driving EffectCraft over MCP / the control channel, and the agent work loop
- [docs/control-protocol.md](docs/control-protocol.md): control-channel method reference
- [docs/contributors.md](docs/contributors.md): the About window's contributor and model credits
- [ATTRIBUTION.md](ATTRIBUTION.md): asset index
## Contributor credits (About window)
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
themselves). See `docs/contributors.md`.
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).

View File

@ -70,6 +70,34 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall
(sidecars `.attribution`).
- Example effect plug-in: `examples/plugins/posterize-bands` (plug-in API v1, WebAssembly).
## Trained models (optional downloads, not bundled)
The repository and the installers contain no model weights. These models are downloaded only when
a user asks (Settings ▸ Roto Brush, Settings ▸ Face Tracking, or the `roto.model.*` /
`face.model.*` commands), unmodified from their authors' official location, checked against a
pinned SHA-256 and stored in the user's `models` folder with a `<file>.NOTICE.txt` naming their
authors, licence and source. EffectCraft runs them with its own Rust implementation and is not
affiliated with or endorsed by their authors.
| Model | Authors | Source | Licence |
|---|---|---|---|
| MobileSAM (`mobile_sam.pt`) | Chaoning Zhang et al., building on Meta AI's Segment Anything and Microsoft's TinyViT | https://github.com/ChaoningZhang/MobileSAM | Apache-2.0 |
| MediaPipe Face Landmarker (`face_landmarker.task`: BlazeFace short-range detector, Face Mesh V2, blendshapes) | Google LLC | https://developers.google.com/edge/mediapipe/solutions/vision/face_landmarker (model cards: https://storage.googleapis.com/mediapipe-assets/MediaPipe%20BlazeFace%20Model%20Card%20(Short%20Range).pdf, https://storage.googleapis.com/mediapipe-assets/Model%20Card%20MediaPipe%20Face%20Mesh%20V2.pdf) | Apache-2.0 |
## Formats and parameters from other open-source projects
Written in Rust for EffectCraft; no code is copied. They follow published specifications and
configuration from these Apache-2.0 projects:
- `crates/segment/src/tflite.rs` reads TensorFlow Lite files using the field layout of TensorFlow
Lite's `schema.fbs` (The TensorFlow Authors, https://github.com/tensorflow/tensorflow,
Apache-2.0).
- `crates/segment/src/mediapipe.rs` follows MediaPipe's published face landmarker graph: the SSD
anchor layout, detection decoding, crop sizes and the face-mesh point indices it uses (Google
LLC, https://github.com/google-ai-edge/mediapipe, Apache-2.0).
- `crates/segment/src/mobilesam.rs` follows the MobileSAM, TinyViT and Segment Anything
architectures and their Apache-2.0 reference code.
## First-party brand marks
The ArtCraft name, wordmark and logos are trademarks of the ArtCraft Team. They are not open source and are not

View File

@ -1,12 +1,13 @@
# EffectCraft — instructions for agents
EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual effects compositor targeting Adobe After Effects parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../photocraft` (Photoshop), `../printcraft` (Acrobat), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom) and `../designcraft` (InDesign), with the same conventions.
EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual effects compositor targeting Adobe After Effects parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom) and `../designcraft` (InDesign), with the same conventions.
## Start every session here
`plan/` is maintainer-local (gitignored). Public equivalents: [`ROADMAP.md`](ROADMAP.md), [`docs/`](docs/).
1. Read `plan/STATUS.md` (current milestone, next task, running agents, blockers).
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the crate README/docs you touch. Feature ids: `plan/aftereffects/feature-catalog.md`; effects: `plan/aftereffects/effects-list.md`; UI reference: `plan/aftereffects/README.md`.
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask.
3. Read [`docs/gaps.md`](docs/gaps.md): the honest assessment of where we fall short and the prioritised workstreams. Fidelity, reliability and stability (G1–G3) come before new features.
4. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask.
## Non-negotiables
**Read [`AGENTS.md`](AGENTS.md) first; its rules override everything here.** No Adobe assets, every asset licensed + attributed (`cargo xtask assets`).

View File

@ -8,7 +8,7 @@ work apply to everyone, people and AI agents alike, and they are not negotiable.
You need [Rust](https://rustup.rs/) 1.95 or newer.
```sh
cargo run -p effectcraft # the desktop app, with the demo project
cargo run -p effectcraft -- --demo # the desktop app, with the demo project
cargo test --workspace
cargo xtask ci # what every commit must pass
```

188
Cargo.lock generated
View File

@ -1134,7 +1134,7 @@ dependencies = [
[[package]]
name = "effectcraft"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"cpal",
"effectcraft-engine",
@ -1155,7 +1155,7 @@ dependencies = [
[[package]]
name = "effectcraft-automation"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
@ -1167,14 +1167,14 @@ dependencies = [
[[package]]
name = "effectcraft-av1enc"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"filmcraft-av1",
]
[[package]]
name = "effectcraft-cli"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"cpu-time",
"effectcraft-automation",
@ -1190,7 +1190,7 @@ dependencies = [
[[package]]
name = "effectcraft-color"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"proptest",
"serde",
@ -1198,13 +1198,14 @@ dependencies = [
[[package]]
name = "effectcraft-effects"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
"effectcraft-keyframe",
"effectcraft-project",
"effectcraft-raster",
"effectcraft-segment",
"effectcraft-track",
"log",
"rayon",
@ -1214,7 +1215,7 @@ dependencies = [
[[package]]
name = "effectcraft-engine"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@ -1229,6 +1230,7 @@ dependencies = [
"effectcraft-psd",
"effectcraft-raster",
"effectcraft-render",
"effectcraft-segment",
"effectcraft-svg",
"effectcraft-text",
"effectcraft-time",
@ -1246,7 +1248,7 @@ dependencies = [
[[package]]
name = "effectcraft-export"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"effectcraft-av1enc",
"effectcraft-color",
@ -1275,7 +1277,7 @@ dependencies = [
[[package]]
name = "effectcraft-expr"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"boa_engine",
"effectcraft-color",
@ -1292,7 +1294,7 @@ dependencies = [
[[package]]
name = "effectcraft-geom"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"proptest",
"serde",
@ -1300,7 +1302,7 @@ dependencies = [
[[package]]
name = "effectcraft-gpu"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"bytemuck",
"effectcraft-color",
@ -1312,21 +1314,24 @@ dependencies = [
"effectcraft-render",
"effectcraft-text",
"effectcraft-time",
"js-sys",
"log",
"pollster 0.4.0",
"wasm-bindgen",
"web-time",
"wgpu",
]
[[package]]
name = "effectcraft-hevcenc"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"filmcraft-hevc",
]
[[package]]
name = "effectcraft-host"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@ -1341,6 +1346,7 @@ dependencies = [
"effectcraft-script",
"effectcraft-svg",
"effectcraft-time",
"exr",
"image",
"rayon",
"serde_json",
@ -1348,7 +1354,7 @@ dependencies = [
[[package]]
name = "effectcraft-interchange"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"effectcraft-color",
"effectcraft-keyframe",
@ -1367,7 +1373,7 @@ dependencies = [
[[package]]
name = "effectcraft-keyframe"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1379,7 +1385,7 @@ dependencies = [
[[package]]
name = "effectcraft-lottie"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@ -1396,7 +1402,7 @@ dependencies = [
[[package]]
name = "effectcraft-media"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"effectcraft-model",
"effectcraft-pdf",
@ -1421,7 +1427,7 @@ dependencies = [
[[package]]
name = "effectcraft-model"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"effectcraft-geom",
"image",
@ -1432,14 +1438,14 @@ dependencies = [
[[package]]
name = "effectcraft-opusenc"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"filmcraft-opus",
]
[[package]]
name = "effectcraft-path"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
@ -1451,7 +1457,7 @@ dependencies = [
[[package]]
name = "effectcraft-pdf"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"effectcraft-raster",
"effectcraft-svg",
@ -1465,7 +1471,7 @@ dependencies = [
[[package]]
name = "effectcraft-plugin"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
@ -1477,7 +1483,7 @@ dependencies = [
[[package]]
name = "effectcraft-project"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1490,14 +1496,14 @@ dependencies = [
[[package]]
name = "effectcraft-psd"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"miniz_oxide 0.8.9",
]
[[package]]
name = "effectcraft-raster"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1506,7 +1512,7 @@ dependencies = [
[[package]]
name = "effectcraft-render"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@ -1528,7 +1534,7 @@ dependencies = [
[[package]]
name = "effectcraft-script"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"boa_engine",
"effectcraft-engine",
@ -1541,9 +1547,18 @@ dependencies = [
"serde_json",
]
[[package]]
name = "effectcraft-segment"
version = "0.6.0"
dependencies = [
"miniz_oxide 0.8.9",
"ndarray",
"rayon",
]
[[package]]
name = "effectcraft-svg"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"effectcraft-geom",
"effectcraft-path",
@ -1555,7 +1570,7 @@ dependencies = [
[[package]]
name = "effectcraft-text"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"effectcraft-keyframe",
"harfrust",
@ -1567,7 +1582,7 @@ dependencies = [
[[package]]
name = "effectcraft-time"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"proptest",
"serde",
@ -1575,9 +1590,10 @@ dependencies = [
[[package]]
name = "effectcraft-track"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"effectcraft-raster",
"effectcraft-segment",
"rayon",
"serde",
"serde_json",
@ -1586,7 +1602,7 @@ dependencies = [
[[package]]
name = "effectcraft-ui-egui"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"effectcraft-color",
"effectcraft-engine",
@ -1602,6 +1618,7 @@ dependencies = [
"eframe",
"egui",
"egui_kittest",
"exr",
"image",
"kurbo",
"log",
@ -1609,16 +1626,18 @@ dependencies = [
"rayon",
"serde",
"serde_json",
"sys-locale",
"toml",
"web-time",
]
[[package]]
name = "effectcraft-vp9enc"
version = "0.2.0"
version = "0.6.0"
[[package]]
name = "effectcraft-web"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@ -1732,6 +1751,7 @@ dependencies = [
"raw-window-handle",
"smithay-clipboard",
"web-time",
"webbrowser",
"winit",
]
@ -2313,6 +2333,15 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures-concurrency"
version = "7.7.1"
@ -2719,6 +2748,27 @@ dependencies = [
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
@ -3050,6 +3100,16 @@ dependencies = [
"libc",
]
[[package]]
name = "matrixmultiply"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
dependencies = [
"autocfg",
"rawpointer",
]
[[package]]
name = "memchr"
version = "2.8.3"
@ -3167,6 +3227,21 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "ndarray"
version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
dependencies = [
"matrixmultiply",
"num-complex",
"num-integer",
"num-traits",
"portable-atomic",
"portable-atomic-util",
"rawpointer",
]
[[package]]
name = "ndk"
version = "0.9.0"
@ -4169,6 +4244,12 @@ dependencies = [
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "rawpointer"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]]
name = "rayon"
version = "1.12.0"
@ -4871,6 +4952,15 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]]
name = "tag_ptr"
version = "0.1.0"
@ -5187,6 +5277,18 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "utf16_iter"
version = "1.0.5"
@ -5575,6 +5677,22 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webbrowser"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94"
dependencies = [
"jni 0.22.4",
"log",
"ndk-context",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"url",
"web-sys",
]
[[package]]
name = "weezl"
version = "0.1.12"
@ -6318,7 +6436,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.2.0"
version = "0.6.0"
dependencies = [
"serde_json",
]

View File

@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*"]
[workspace.package]
version = "0.2.0"
version = "0.6.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@ -22,6 +22,7 @@ effectcraft-project = { path = "crates/project" }
effectcraft-text = { path = "crates/text" }
effectcraft-effects = { path = "crates/effects" }
effectcraft-track = { path = "crates/track" }
effectcraft-segment = { path = "crates/segment" }
effectcraft-model = { path = "crates/model" }
effectcraft-render = { path = "crates/render" }
effectcraft-gpu = { path = "crates/gpu" }
@ -102,6 +103,8 @@ eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
cpal = "0.16"
# The operating system's interface language (MIT/Apache-2.0, no dependencies on desktop).
sys-locale = "0.3"
# Local co-development against a FilmCraft checkout next to this repo: uncomment.
# [patch."https://github.com/storytold/filmcraft"]

111
README.md
View File

@ -56,6 +56,7 @@
<a href="#get-started">Get started</a> ·
<a href="#where-it-stands">Status</a> ·
<a href="#how-its-made">How it's made</a> ·
<a href="#downloads">Downloads</a> ·
<a href="#the-crafting-apps">The Crafting Apps</a> ·
<a href="#license-and-credits">License</a>
</p>
@ -85,7 +86,9 @@ then to go further in a few places where it matters to us:
The panels, menus and shortcuts follow After Effects, so your muscle memory carries over:
Project, Composition, Timeline, Effect Controls, Properties, Effects & Presets, Character,
Paragraph, Align, Info, Preview, Audio and the Render Queue, docked the way you expect.
Paragraph, Align, Info, Preview, Audio and the Render Queue, docked the way you expect. Drag
footage, comps and effects between them: onto the comp viewer, where they land under the pointer,
or into the Timeline, between layers and at the time you point to.
- **Layers of every kind:** solids, shapes, text, footage, nested compositions, nulls,
adjustment layers, cameras and lights; parenting, track mattes, all 38 blend modes, motion blur.
@ -97,8 +100,8 @@ Paragraph, Align, Info, Preview, Audio and the Render Queue, docked the way you
- **Shapes and masks:** shape layers with trim paths, repeaters, round corners, offset, zig zag,
twist, wiggle, merge paths and gradient strokes; masks drawn with the pen tool, with modes,
feather, expansion and vertex editing in the viewer.
- **Text:** point and paragraph text with real shaping, the Character and Paragraph panels, and
text animators with range selectors.
- **Text:** point and paragraph text with real shaping in any font installed on your machine, the
Character and Paragraph panels, and text animators with range selectors.
- **Expressions:** JavaScript with the After Effects object model (`wiggle`, `loopOut`,
`thisComp.layer("…")`, vector maths on arrays), an inline editor and the pick-whip.
- **Timeline like you know it:** twirl layers open to Transform, masks, effects and the rest; drag
@ -179,29 +182,62 @@ See [docs/agents.md](docs/agents.md) and [docs/control-protocol.md](docs/control
## Get started
You need [Rust](https://rustup.rs/) 1.95 or newer.
Installers for each version are on the [Releases](https://github.com/storytold/effectcraft/releases)
page: a universal macOS app; Windows MSIs and portable zips for x64, x86 and ARM64; Linux
AppImage, Flatpak, deb, rpm and tar.gz for x86_64 and aarch64; a FreeBSD x86_64 tarball; and the
web build. [Downloads](#downloads) lists every file. The Windows ARM64 build runs natively on
Windows on ARM, without x64 emulation. CI installs that MSI on Windows 11 ARM64 hardware, checks
both programs are ARM64 and runs the command-line tool there, but it doesn't open the app's window
or run the test suite natively on ARM64 yet, so please report anything that behaves differently.
On Gentoo, the community [::snakebyte overlay](https://github.com/switch87/snakebyte-overlay) packages
the Linux release as `media-video/effectcraft-bin` (not maintained by the EffectCraft team):
```sh
eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git
emaint sync -r snakebyte
echo 'media-video/effectcraft-bin ~amd64' >> /etc/portage/package.accept_keywords/effectcraft
emerge --ask media-video/effectcraft-bin
```
To build it yourself you need [Rust](https://rustup.rs/) 1.95 or newer.
```sh
git clone https://github.com/storytold/effectcraft
cd effectcraft
cargo run --release -p effectcraft # the app, with the demo project open
cargo run --release -p effectcraft # the app (add `-- --demo` to open the demo project)
cargo run --release -p effectcraft-cli -- render --out intro.mp4 # render the demo headless
cargo xtask web --serve 8765 # the browser build on http://127.0.0.1:8765/ (docs/web.md)
```
New to it? [docs/footage.md](docs/footage.md) covers importing footage and image sequences and
Interpret Footage.
To work on it: `cargo test --workspace`, and `cargo xtask ci` before every commit (format, lints,
tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING.md](CONTRIBUTING.md),
[docs/architecture.md](docs/architecture.md) and [docs/testing.md](docs/testing.md).
## Where it stands
EffectCraft is young. The core of After Effects is in place and gets deeper every week: what is
done and what comes next is in the [ROADMAP](ROADMAP.md). Still to come: learned models for
Roto Brush and face tracking (both use classical methods today), GPU versions of the remaining
CPU-only effects, the Advanced 3D Extended Viewer, and shared-memory threads in the web build. The
web build ([docs/web.md](docs/web.md)) runs the full app in the browser, with renders, analyses
and viewer frames in Web Workers. Projects are saved as `.ecproj`, readable versioned JSON; After
Effects `.aep` files cannot be opened.
EffectCraft is young: its first commit was on 1 October 2026. Nearly every After Effects feature
exists, including all of its effects, but existing is not the same as behaving exactly like After
Effects, and we haven't measured that yet. Today it's a good place to try things and to tell us
what breaks. It isn't yet a replacement for After Effects on client work. In particular:
- **After Effects projects can't be opened.** Projects are saved as `.ecproj`, readable versioned
JSON, but EffectCraft can't open `.aep` / `.aepx` files, and After Effects plug-ins don't run.
- **Behaviour still differs from After Effects in places.** Nothing yet compares our renders with
After Effects automatically, so please report differences.
- **macOS is the most tested platform.** Linux and Windows users have hit basic interaction
bugs, which we're fixing first.
- **The trained models are new.** Roto Brush can use MobileSAM (an optional 40 MB download in
Settings ▸ Roto Brush) and face tracking can use Google's MediaPipe Face Landmarker (an optional
3.8 MB download in Settings ▸ Face Tracking). Neither is compared with After Effects yet.
The [ROADMAP](ROADMAP.md) has the plan and [docs/gaps.md](docs/gaps.md) the full, honest
assessment. The web build ([docs/web.md](docs/web.md)) runs the full app in the browser.
[Bug reports](https://github.com/storytold/effectcraft/issues) are the most useful thing you can
send us right now.
## How it's made
@ -214,6 +250,51 @@ Effects `.aep` files cannot be opened.
- **One family.** EffectCraft shares its time model and text engine design with FilmCraft, and
gets its video and audio codecs from it.
## Downloads
**New to EffectCraft?** Download it from the [EffectCraft page on getartcraft.com](https://getartcraft.com/apps/effectcraft). That's the easiest way to install it.
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/effectcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/effectcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
### Windows
| Build | Installer | Portable |
|---|---|---|
| x64 (64-bit Intel/AMD) | `effectcraft-<ver>-windows-x64.msi` | `effectcraft-<ver>-windows-x64-portable.zip` |
| arm64 (Snapdragon and other ARM PCs) | `effectcraft-<ver>-windows-arm64.msi` | `effectcraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `effectcraft-<ver>-windows-x86.msi` | `effectcraft-<ver>-windows-x86-portable.zip` |
Installers and executables are code-signed.
### macOS
| Build | File | Notes |
|---|---|---|
| App, universal (Apple silicon + Intel) | `effectcraft-<ver>-macos-universal.dmg` | Signed and notarized |
| Command-line tool, universal | `effectcraft-cli-<ver>-macos-universal.zip` | Signed and notarized |
### Linux
| Format | x86_64 | aarch64 (ARM64) | Notes |
|---|---|---|---|
| AppImage | `effectcraft-<ver>-linux-x86_64.AppImage` | `effectcraft-<ver>-linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
| Flatpak | `effectcraft-<ver>-linux-x86_64.flatpak` | `effectcraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` |
| Debian/Ubuntu | `effectcraft-<ver>-linux-x86_64.deb` | `effectcraft-<ver>-linux-aarch64.deb` | |
| Fedora/RHEL/openSUSE | `effectcraft-<ver>-linux-x86_64.rpm` | `effectcraft-<ver>-linux-aarch64.rpm` | |
| Tarball | `effectcraft-<ver>-linux-x86_64.tar.gz` | `effectcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
### FreeBSD
| Build | File |
|---|---|
| x86_64 | `effectcraft-<ver>-freebsd-x86_64.tar.gz` |
### Web (WebAssembly)
| Build | File | Notes |
|---|---|---|
| Static site | `effectcraft-web-<ver>.zip` | Runs in a modern browser; host it on any static server |
## The Crafting Apps
EffectCraft is one of the **Crafting Apps**: free, open-source creative tools from the
@ -226,7 +307,7 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | **Motion graphics and visual effects · you are here** | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
@ -273,3 +354,7 @@ Forks and modified versions must remove them.
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>
## Star history
[![Star History Chart](https://api.star-history.com/svg?repos=storytold/effectcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Feffectcraft&type=date&legend=top-left)

View File

@ -1,5 +1,10 @@
# Roadmap
- 2026-10-08: `effectcraft-cli render --out` resolves a relative path against the working directory, like `--project` (it used the project's folder, which doubled a path that already named it). Render Queue outputs set in a project are unchanged.
- 2026-10-08: Long-press a grouped toolbar button to choose its tools, including Horizontal/Vertical Type; releasing the hold keeps the menu open. Existing point/paragraph text creation is unchanged.
- 2026-10-06: Settings ▸ General ▸ Language persists English/Japanese menu labels (`general.language`); native UI reuses installed Japanese font fallback, with no bundled CJK font. Dialog and panel contents remain English.
EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written
from scratch in Rust. This is where it stands. The milestones overlap; several are worked on at
once.
@ -10,7 +15,7 @@ once.
| M1 | Keyframes: temporal and spatial interpolation, Easy Ease, roving, velocity | Done |
| M2 | Compositing: 38 blend modes, track mattes, parenting, adjustment layers | Done |
| M3 | Project operations: settings dialogs, layer commands, `.ecproj`, undo, After Effects menu bar | Done (unsaved-changes prompts, Solid / Layer Settings, lock-aware layer commands, safe project files; View ▸ New Viewer still opens only the one locked viewer) |
| M4 | Preview: precomps, motion blur, cached playback | Mostly done (Preserve frame rate / resolution when nested, one motion blur gate, RAM preview fixes, Cache Frames When Idle, motion blur of collapsed precomps and of animated shape / text content; left: content-keyed RAM preview frames) |
| M4 | Preview: precomps, motion blur, cached playback | Done (Preserve frame rate / resolution when nested, one motion blur gate, RAM preview fixes, Cache Frames When Idle, motion blur of collapsed precomps and of animated shape / text content, content-keyed RAM preview frames) |
| M5 | Timeline depth: graph editor, keyframe clipboard and dialogs, time remapping, pick-whips, expression editor | Done |
| M6 | Shapes, masks and footage: shape operators, masks and the pen tool, video and image import | Done |
| M7 | 3D: 3D layers, cameras, lights, shadows, depth of field, 3D views and camera tools | In review |
@ -19,20 +24,48 @@ once.
| M10 | Export: render queue, H.264, ProRes, image sequences, GIF, audio | Done |
| M11 | Animation tools: audio playback, meters and waveforms, Lottie import and export, presets, Motion Sketch, Wiggler, Smoother | Done |
| M12 | Performance: layer cache, parallel and GPU compositing, disk cache, motion tracking (done); GPU versions of the remaining CPU-only effects | Mostly done |
| M13 | Puppet tools, paint, Roto Brush, motion tracking, a plug-in API, Timeline depth | Done (Roto Brush and face tracking use classical models; learned models are still to come) |
| M13 | Puppet tools, paint, Roto Brush, motion tracking, a plug-in API, Timeline depth | Done (trained models, open source, downloaded on demand, in a swappable model module: MobileSAM for Roto Brush 2.0 / 3.0, MediaPipe Face Landmarker for face tracking) |
| M15 | The web app (WebAssembly, WebGPU) | Done: browser storage, Web Audio, renders and analyses in Web Workers, viewer frames and GPU effects in frame workers with their own WebGPU devices, the disk cache in the Origin Private File System, a storage manager, offline install ([docs/web.md](docs/web.md)) |
| M14 | Built for agents: MCP server, command-line tool, control channel; Settings, keyboard shortcut editor, auto-save and crash recovery ([docs/preferences.md](docs/preferences.md)) | Done |
## How far from full parity
Measured feature by feature in [docs/parity.md](docs/parity.md) (4 October 2026):
There are two honest answers, and they are far apart. The full assessment, with the evidence, is
in [docs/gaps.md](docs/gaps.md).
- **≈ 99% of After Effects' features, weighted by importance**, counting partial features as half
done (≈ 99.8% with per-feature fractions): 89 of 92 features done, 3 partial, none missing; every
essential (P0) feature done; all 306 effects implemented in full, 280 of them on the GPU.
- **≈ 8.5 agent-hours of work remain**: about **2–3 hours of wall-clock time** with five Claude
Opus 5.5 agents in parallel, ≈ 1 hour without learned models for Roto Brush and face tracking.
- What is left: GPU kernels for the remaining visual effects, and learned-model quality for Roto
Brush and face tracking; in previews, content-keyed RAM preview frames ([parity.md](docs/parity.md#highest-value-gaps-in-order)).
- **Breadth: ≈ 99%.** Almost every After Effects feature exists: 89 of 92 catalogued features
done and 3 partial, all 306 effects implemented, 280 of them on the GPU
([docs/parity.md](docs/parity.md)). This is our own checklist, graded by the agents that built
the features.
- **Real use: ≈ 30–50% (estimated, not yet measured).** Whether someone who uses After Effects for
a living can do client work in EffectCraft. What holds it back:
- **Fidelity is unmeasured.** No test compares our output with After Effects itself, and users
are still finding behaviour bugs in features marked done.
- **After Effects projects can't be opened.** EffectCraft can't read `.aep` / `.aepx` files,
and third-party After Effects plug-ins can't run.
- **Reliability is uneven across platforms.** Early Linux users hit basic problems: viewer
panning, panel docking, drag-and-drop import. A Windows user found the font menus offered only
the bundled fonts (fixed 6 October: they list every installed font). Reports of 6 October
covered drag and drop between panels, a Render Queue menu that ignored clicks, angle
revolutions, and preview with audio and with little video memory (all fixed 7 October).
- **AI tools are new.** Roto Brush's trained model (MobileSAM, M13.35) and face tracking's
(MediaPipe Face Landmarker, M13.36) are optional downloads, not yet compared with After
Effects.
## Where we're going
In priority order (details and "done" criteria in [docs/gaps.md](docs/gaps.md)):
| | Workstream | Why |
|---|---|---|
| G1 | Measure fidelity against After Effects: a corpus of test projects rendered and sampled in both apps, scored per feature | Turns every estimate into a measurement; a feature that behaves differently is not done |
| G2 | Real-user reliability on macOS, Windows and Linux; fix every open user issue | Basic workflows must work everywhere before more features matter |
| G3 | Stability: a green gate on the current toolchain, fuzzing of every input | A crash loses people's work |
| G4 | Open After Effects projects (`.aep` / `.aepx`, pending an owner decision on clean-room scope); relinking moved footage | The biggest barrier to switching |
| G5 | Performance benchmarks at 1080p and 4K on real-world-sized projects | Unknown today |
| G6 | Media depth: encoder efficiency, camera and phone formats | Files are larger than from mature encoders; camera formats are unverified |
| G7 | Learned models: Roto Brush (M13.35: MobileSAM) and face tracking (M13.36: MediaPipe Face Landmarker), open source, optional downloads, swappable model module; next, measure them | Quality against After Effects is unmeasured |
| G8 | Plug-in ecosystem on our WebAssembly plug-in API | After Effects plug-ins can't run here |
| G9 | Localisation, accessibility, user documentation | Reach beyond English-speaking power users |
Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).

View File

@ -9,19 +9,20 @@
//! effectcraft-cli props <comp> <layer> [--flat] [--time S] a layer's property tree (with paths)
//! effectcraft-cli get <comp> <layer> <path> [--time S] read a property
//! effectcraft-cli set <comp> <layer> <path> <value> [--time S] [--expression E]
//! effectcraft-cli render-frame [--comp C] [--time S|--frame N] [--max-side PX|--scale K] [--out F.png]
//! effectcraft-cli render-frame [--comp C] [--time S|--frame N] [--max-side PX|--scale K] [--out F.png] [--transparent]
//! effectcraft-cli render [--comp C] --out FILE [--format h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff] [--start S] [--end S]
//! [--work-area] [--fps N] [--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
//! [--jpeg-quality N] [--bitrate KBPS] [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off]
//! [--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality 1-100]
//! [--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice]
//! (a relative --out is relative to the working directory; --queue outputs follow the project's own settings)
//! effectcraft-cli render F.ecproj --queue render the project's Render Queue
//! effectcraft-cli bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu [--adv3d]] render timings
//! (--gpu: CPU vs GPU ms/frame for every comp at Full and Half)
//! effectcraft-cli bench --ops [--small] [--layers N] [--comps N] [--footage N] everyday-operation timings
//! on a large generated project (open, save, auto-save, undo/redo, timeline, Project panel)
//! effectcraft-cli script FILE.jsx [F.ecproj] | --eval CODE run an After Effects-style script
//! effectcraft-cli mcp [--bridge PORT] MCP server on stdio
//! effectcraft-cli mcp [--autosave | --bridge PORT] MCP server on stdio
//!
//! Project: --project F.ecproj (or a positional *.ecproj) | --demo | --empty (default: demo; mcp: empty)
//! Saving: --save (back to --project) | --save-as F.ecproj
@ -36,10 +37,54 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::io::Write;
use effectcraft_automation::tools::{self, Reply};
use effectcraft_automation::{Backend, McpServer};
use serde_json::{Value, json};
/// `println!` that never panics: see [`write_line`].
macro_rules! say {
($($t:tt)*) => {
write_line(format_args!($($t)*))
};
}
/// `eprintln!` that never panics: a diagnostic that can't be written is dropped.
macro_rules! note {
($($t:tt)*) => {{
let _ = writeln!(std::io::stderr(), $($t)*);
}};
}
/// Why stdout stopped taking output, once it has.
static STDOUT_FAILED: std::sync::OnceLock<std::io::ErrorKind> = std::sync::OnceLock::new();
/// Write a line to stdout. Once that fails, later output is dropped but the command still
/// finishes its work (a `run … --save` sequence isn't cut short). A reader that closes stdout
/// early (`info --json | head`) has read what it wanted; any other failure is reported and makes
/// the exit status 1 ([`stdout_broken`]).
fn write_line(text: std::fmt::Arguments) {
if STDOUT_FAILED.get().is_some() {
return;
}
let r = {
let mut out = std::io::stdout().lock();
out.write_fmt(text).and_then(|()| out.write_all(b"\n"))
};
if let Err(e) = r {
let _ = STDOUT_FAILED.set(e.kind());
if e.kind() != std::io::ErrorKind::BrokenPipe {
note!("effectcraft-cli: cannot write to stdout: {e}");
}
}
}
/// Whether stdout failed for a reason other than its reader closing it early.
fn stdout_broken() -> bool {
STDOUT_FAILED.get().is_some_and(|k| *k != std::io::ErrorKind::BrokenPipe)
}
const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|set|render-frame|render|script|mcp> [args] [--json]
info project + engine summary
commands [--filter TEXT] [--enabled] list engine commands
@ -48,7 +93,7 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
props <comp> <layer> [--flat] [--time S] a layer's property tree with paths
get <comp> <layer> <path> [--time S] read a property
set <comp> <layer> <path> <value> [--time S] [--expression E]
render-frame [--comp C] [--time S | --frame N] [--max-side PX | --scale K] [--out F.png]
render-frame [--comp C] [--time S | --frame N] [--max-side PX | --scale K] [--out F.png] [--transparent]
render [--comp C] --out FILE [--format F] [--start S] [--end S] [--work-area] [--fps N]
[--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
[--jpeg-quality N] [--bitrate KBPS] [--prores PROFILE] [--audio auto|on|off]
@ -56,6 +101,7 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
[--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice] | --queue
(formats h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff;
--profile..--keyint: HEVC / AV1, --audio-bitrate/--opus-app: WebM Opus)
a relative --out is relative to the working directory
bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu [--adv3d]] per-layer/effect render timings;
--play N renders N consecutive frames with/without the layer cache;
--gpu compares CPU and GPU ms/frame for every comp at Full and Half
@ -65,7 +111,7 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
script FILE.jsx [F.ecproj] | --eval CODE run JavaScript with the After Effects-style object model
(app.project, comps, layers, properties…); prints writeLn
output and the result; errors exit 1 with file:line:col
mcp [--bridge PORT] MCP server (JSON-RPC over stdio)
mcp [--autosave | --bridge PORT] MCP server (JSON-RPC over stdio); --autosave preserves unsaved headless work
options: --project F.ecproj | --demo | --empty --save | --save-as F --bridge PORT --json --gpu
<comp>: id or name, '-' = active comp; <layer>: id, '#n' or name; <value>: JSON or bare string";
@ -112,6 +158,26 @@ const VALUED: &[&str] = &[
"--opus-app",
];
/// Options without a value. Any other `--option` is a usage error.
const FLAGS: &[&str] = &[
"--json",
"--save",
"--demo",
"--empty",
"--gpu",
"--list",
"--schemas",
"--enabled",
"--flat",
"--transparent",
"--work-area",
"--queue",
"--ops",
"--small",
"--adv3d",
"--autosave",
];
struct Args {
pos: Vec<String>,
opts: Vec<(String, Option<String>)>,
@ -121,7 +187,7 @@ struct Args {
impl Args {
fn parse(raw: Vec<String>) -> Result<Args, String> {
let (mut pos, mut opts) = (vec![], vec![]);
let (mut pos, mut opts, mut unknown) = (vec![], vec![], vec![]);
let mut it = raw.into_iter();
while let Some(a) = it.next() {
if a.starts_with("--") && a.len() > 2 {
@ -132,6 +198,10 @@ impl Args {
if VALUED.contains(&k.as_str()) {
let v = inline.or_else(|| it.next()).ok_or_else(|| format!("{k} needs a value"))?;
opts.push((k, Some(v)));
} else if !FLAGS.contains(&k.as_str()) {
unknown.push(format!("`{k}`"));
} else if inline.is_some() {
return Err(format!("{k} takes no value"));
} else {
opts.push((k, None));
}
@ -139,6 +209,12 @@ impl Args {
pos.push(a);
}
}
// Before anything runs: a misspelt option must not run the command with defaults, or
// leave its value behind as a positional (`--saveas x.ecproj` opening x.ecproj).
if !unknown.is_empty() {
let s = if unknown.len() == 1 { "" } else { "s" };
return Err(format!("unknown option{s} {}", unknown.join(", ")));
}
let mut a = Args { pos, opts, project: None };
a.project = match a.opt("--project") {
Some(p) => Some(p.to_string()),
@ -173,11 +249,11 @@ fn value_arg(s: &str) -> Value {
fn main() {
let raw: Vec<String> = std::env::args().skip(1).collect();
if matches!(raw.first().map(String::as_str), Some("--version" | "-V" | "version")) {
println!("effectcraft-cli {}", env!("CARGO_PKG_VERSION"));
say!("effectcraft-cli {}", env!("CARGO_PKG_VERSION"));
return;
}
if raw.is_empty() || raw.iter().any(|a| a == "-h" || a == "--help") {
println!("{USAGE}");
say!("{USAGE}");
std::process::exit(if raw.is_empty() { 2 } else { 0 });
}
let mut args = match Args::parse(raw) {
@ -190,20 +266,21 @@ fn main() {
}
let cmd = args.pos.remove(0);
match run(&cmd, &args, json_out) {
Ok(()) if stdout_broken() => std::process::exit(1),
Ok(()) => {}
Err(Failure::Usage(e)) => fail_usage(&e),
Err(Failure::Error(e)) => {
if json_out {
println!("{}", json!({"error": e}));
say!("{}", json!({"error": e}));
}
eprintln!("effectcraft-cli {cmd}: {e}");
note!("effectcraft-cli {cmd}: {e}");
std::process::exit(1);
}
}
}
fn fail_usage(e: &str) -> ! {
eprintln!("effectcraft-cli: {e}\n{USAGE}");
note!("effectcraft-cli: {e}\n{USAGE}");
std::process::exit(2)
}
@ -247,9 +324,22 @@ fn backend(args: &Args, default_demo: bool) -> Result<Backend, Failure> {
/// renderer setting, Mercury GPU Acceleration by default).
fn session(args: &Args) -> Result<effectcraft_engine::Session, Failure> {
let mut s = effectcraft_host::session();
if args.flag("--autosave") {
let dir = effectcraft_host::config_dir().ok_or_else(|| Failure::Error("--autosave: no config directory; set EFFECTCRAFT_CONFIG_DIR".into()))?;
// Read only auto-save settings. Opting in must not change headless rendering or load
// the desktop's models, shortcuts, disk cache or recovery sentinel.
use effectcraft_engine::config::ConfigStore;
let config = effectcraft_engine::config::DirConfig::new(dir);
if let Some(text) = config.read(effectcraft_engine::prefs::PREFS_FILE) {
s.prefs.auto_save = effectcraft_engine::prefs::Prefs::from_json(&text).auto_save;
}
s.prefs.normalize();
}
if args.flag("--gpu") {
let g = effectcraft_gpu::Gpu::headless().ok_or_else(|| Failure::Error("--gpu: no usable GPU adapter".into()))?;
let g = effectcraft_gpu::Gpu::try_headless().map_err(|e| Failure::Error(format!("--gpu: no usable GPU adapter ({e})")))?;
s.accel = Some(std::sync::Arc::new(g));
} else {
s.accel_note = Some("headless: renders on the CPU unless started with --gpu".into());
}
Ok(s)
}
@ -292,13 +382,13 @@ fn script_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
emit(&o, true);
} else {
if let Some(out) = r["output"].as_str().filter(|o| !o.is_empty()) {
println!("{out}");
say!("{out}");
}
if !r["result"].is_null() && r["error"].is_null() {
println!("{}", r["result"]);
say!("{}", r["result"]);
}
if let Some(p) = saved {
eprintln!("saved {p}");
note!("saved {p}");
}
}
if let Some(e) = r.get("error").filter(|e| !e.is_null()) {
@ -319,13 +409,16 @@ fn script_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
/// Print a result: compact JSON with `--json`, else pretty JSON.
fn emit(v: &Value, json_out: bool) {
if json_out {
println!("{v}");
say!("{v}");
} else {
println!("{}", serde_json::to_string_pretty(v).unwrap_or_default());
say!("{}", serde_json::to_string_pretty(v).unwrap_or_default());
}
}
fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
if args.flag("--autosave") && cmd != "mcp" {
return usage_err("--autosave is only supported by mcp");
}
match cmd {
"render" => render(args, json_out)?,
"bench" => bench_cmd(args)?,
@ -344,19 +437,19 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
if json_out {
emit(&info, true);
} else {
println!(
say!(
"EffectCraft {} ({}), {} commands, {} effects",
info["version"].as_str().unwrap_or(""),
info["mode"].as_str().unwrap_or(""),
info["commands"],
info["effects"]
);
println!("project: {}", p["path"].as_str().unwrap_or("(unsaved)"));
say!("project: {}", p["path"].as_str().unwrap_or("(unsaved)"));
for i in p["items"].as_array().into_iter().flatten() {
println!(" item {:>3} {:<12} {}", i["id"], i["type"].as_str().unwrap_or(""), i["name"].as_str().unwrap_or(""));
say!(" item {:>3} {:<12} {}", i["id"], i["type"].as_str().unwrap_or(""), i["name"].as_str().unwrap_or(""));
}
if let Some(c) = comp {
println!(
say!(
"active comp {} \"{}\" {}x{} @ {} fps, {} s",
c["id"],
c["name"].as_str().unwrap_or(""),
@ -366,7 +459,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
c["duration"]
);
for l in c["layers"].as_array().into_iter().flatten() {
println!(" #{:<3} id {:<4} {:<10} {}", l["index"], l["id"], l["type"].as_str().unwrap_or(""), l["name"].as_str().unwrap_or(""));
say!(" #{:<3} id {:<4} {:<10} {}", l["index"], l["id"], l["type"].as_str().unwrap_or(""), l["name"].as_str().unwrap_or(""));
}
}
}
@ -422,7 +515,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
if json_out {
emit(&v, true);
} else {
println!("layer {} \"{}\" ({}) at {} s", v["id"], v["name"].as_str().unwrap_or(""), v["type"].as_str().unwrap_or(""), v["time"]);
say!("layer {} \"{}\" ({}) at {} s", v["id"], v["name"].as_str().unwrap_or(""), v["type"].as_str().unwrap_or(""), v["time"]);
for p in v["properties"].as_array().into_iter().flatten() {
let mut extra = String::new();
if let Some(k) = p.get("keys") {
@ -431,7 +524,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
if let Some(e) = p.get("expression").and_then(Value::as_str) {
extra += &format!(" expr: {e}");
}
println!(" {:<44} {:<8} {}{extra}", p["path"].as_str().unwrap_or(""), p["type"].as_str().unwrap_or(""), p["value"]);
say!(" {:<44} {:<8} {}{extra}", p["path"].as_str().unwrap_or(""), p["type"].as_str().unwrap_or(""), p["value"]);
}
}
}
@ -474,19 +567,34 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
}
let out = args.opt("--out").unwrap_or("frame.png").to_string();
let t0 = std::time::Instant::now();
let f = b.render(comp.as_ref(), time, max_side)?;
let f = b.render_with(comp.as_ref(), time, max_side, args.flag("--transparent"))?;
let ms = t0.elapsed().as_secs_f64() * 1000.0;
std::fs::write(&out, &f.png).map_err(|e| format!("cannot write {out}: {e}"))?;
let info = json!({"path": out, "comp": f.comp, "time": f.time, "width": f.width, "height": f.height, "ms": (ms * 10.0).round() / 10.0});
if json_out {
emit(&info, true);
} else {
eprintln!("rendered {}x{} at {:.3}s in {ms:.1} ms -> {out}", f.width, f.height, f.time);
note!("rendered {}x{} at {:.3}s in {ms:.1} ms -> {out}", f.width, f.height, f.time);
}
}
"mcp" => {
if args.flag("--autosave") && args.opt("--bridge").is_some() {
return usage_err("--autosave is for headless MCP; the bridged app owns its auto-saves");
}
let b = backend(args, false)?;
McpServer::new(b).serve_stdio().map_err(|e| e.to_string())?;
let server = McpServer::new(b);
let mut server = if args.flag("--autosave") {
let root =
effectcraft_host::config_dir().ok_or_else(|| Failure::Error("--autosave: no config directory; set EFFECTCRAFT_CONFIG_DIR".into()))?;
server.with_autosave(&root)?
} else {
server
};
// A client that closes its end of stdout has ended the session.
match server.serve_stdio() {
Err(e) if e.kind() != std::io::ErrorKind::BrokenPipe => return Err(e.to_string().into()),
_ => {}
}
}
other => return usage_err(&format!("unknown subcommand `{other}`")),
}
@ -507,6 +615,12 @@ fn with_saved(v: Value, saved: Option<String>) -> Value {
}
}
/// A relative `--out` is relative to the working directory, like `--project` and every other CLI
/// path. The Render Queue alone would resolve it against the project's folder.
fn from_cwd(path: &str) -> Result<String, Failure> {
std::path::absolute(path).map(|p| p.to_string_lossy().into_owned()).map_err(|e| Failure::Error(format!("render: cannot resolve --out {path}: {e}")))
}
/// `render`: queue `--comp` (or the active comp) with the given settings unless `--queue`, then
/// render the queue with a progress line on stderr. Fails if any item fails.
fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
@ -522,6 +636,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
let err = |e: effectcraft_engine::EngineError| Failure::Error(e.to_string());
if !args.flag("--queue") {
let Some(out) = args.opt("--out") else { return usage_err("render: --out FILE is required (or --queue)") };
let out = &from_cwd(out)?;
let mut p = json!({"output": out});
if let Some(c) = args.opt("--comp") {
p["comp"] = json!(c);
@ -577,7 +692,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
let _ = s.execute("renderQueue.setRender", json!({"index": k + 1, "render": false}));
}
let r = s.execute("renderQueue.add", p).map_err(err)?;
eprintln!(
note!(
"{} → {} ({}×{}, {} frames, {})",
r["compName"].as_str().unwrap_or("?"),
r["outputPath"].as_str().unwrap_or("?"),
@ -593,7 +708,8 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
std::thread::sleep(std::time::Duration::from_millis(100));
if tty && let Some(p) = s.render_progress() {
let left = p.remaining.map(|r| format!(", ~{r:.1}s left")).unwrap_or_default();
eprint!(
let _ = write!(
std::io::stderr(),
"
[{}/{}] frame {}/{} {:.1}s{left}\x1b[K",
(p.items_done + 1).min(p.items_total),
@ -607,7 +723,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
}
s.poll_render();
if tty {
eprintln!();
note!();
}
let mut results = vec![];
let mut failed = vec![];
@ -615,7 +731,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
let name = s.project.item(it.comp).map(|i| i.name.clone()).unwrap_or_else(|| "?".into());
match &it.status {
RenderStatus::Done => {
eprintln!("done: {name} → {} in {:.2}s", it.last_output.as_deref().unwrap_or("?"), it.render_time.unwrap_or(0.0));
note!("done: {name} → {} in {:.2}s", it.last_output.as_deref().unwrap_or("?"), it.render_time.unwrap_or(0.0));
results.push(json!({"comp": name, "output": it.last_output, "seconds": it.render_time}));
}
RenderStatus::Failed(e) => failed.push(format!("{name}: {e}")),
@ -640,7 +756,7 @@ fn list_commands_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
emit(&v, true);
} else {
for c in v.as_array().into_iter().flatten() {
println!(
say!(
"{:<36} {:<36} {:<16} {}",
c["id"].as_str().unwrap_or(""),
c["label"].as_str().unwrap_or(""),
@ -708,7 +824,7 @@ fn bench_ops(args: &Args) -> Result<(), Failure> {
m.extend(effectcraft_ui_egui::bench::ui_ops(|| {
let mut s = effectcraft_host::session();
if let Err(e) = s.execute("file.open", json!({"path": path})) {
eprintln!("bench --ops: {e}");
note!("bench --ops: {e}");
}
s
}));
@ -716,9 +832,9 @@ fn bench_ops(args: &Args) -> Result<(), Failure> {
if args.flag("--json") {
emit(&json!({"spec": format!("{spec:?}"), "measures": m.iter().map(perf::Measure::json).collect::<Vec<_>>()}), true);
} else {
println!("bench --ops: {} comps, {} layers in Main, {} footage items", spec.comps, spec.main_layers, spec.footage);
say!("bench --ops: {} comps, {} layers in Main, {} footage items", spec.comps, spec.main_layers, spec.footage);
for x in &m {
println!(" {:<22} {:>10.2} ms {}", x.name, x.ms, x.note);
say!(" {:<22} {:>10.2} ms {}", x.name, x.ms, x.note);
}
}
Ok(())
@ -788,7 +904,7 @@ fn bench(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
}
}
}
eprintln!(
note!(
"bench (no cache): {n} runs at scale {}: wall min {:.2} ms, median {:.2} ms; CPU (all threads) min {:.2} ms",
opts.scale,
min(&totals),
@ -796,9 +912,9 @@ fn bench(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
min(&cpus)
);
for (d, name, p, c, fx) in per_layer {
eprintln!(" {:indent$}{name:<28} process {:8.2} ms composite {:8.2} ms (min)", "", min(&p), min(&c), indent = d * 2);
note!(" {:indent$}{name:<28} process {:8.2} ms composite {:8.2} ms (min)", "", min(&p), min(&c), indent = d * 2);
for (id, v) in fx {
eprintln!(" {:indent$} fx {id:<30} {:8.2} ms", "", min(&v), indent = d * 2);
note!(" {:indent$} fx {id:<30} {:8.2} ms", "", min(&v), indent = d * 2);
}
}
}
@ -819,7 +935,7 @@ fn bench_play(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
let mut v: Vec<f64> = runs.iter().map(|r| r.0).collect();
let mean = v.iter().sum::<f64>() / n as f64;
let cpu = runs.iter().map(|r| r.1).sum::<f64>() / n as f64;
eprintln!(
note!(
"bench-play ({label}): {n} frames at scale {}: wall mean {mean:.2} ms, median {:.2} ms, min {:.2} ms; CPU mean {cpu:.2} ms",
opts.scale,
median(&mut v),
@ -827,7 +943,7 @@ fn bench_play(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
);
}
let st = cache.stats();
eprintln!(" layer cache: {} hits, {} misses, {} entries, {:.1} MB", st.hits, st.misses, st.entries, st.bytes as f64 / 1e6);
note!(" layer cache: {} hits, {} misses, {} entries, {:.1} MB", st.hits, st.misses, st.entries, st.bytes as f64 / 1e6);
}
/// Median wall time (ms) of `n` runs of `f` after one warm-up run.
@ -990,7 +1106,7 @@ fn adv3d_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option<Item
/// warm; "up/dn MB" are the GPU's uploads / readbacks per warm GPU frame.
fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
use effectcraft_render::Backend;
let gpu = effectcraft_gpu::Gpu::headless().ok_or_else(|| Failure::Error("--gpu: no usable GPU adapter".into()))?;
let gpu = effectcraft_gpu::Gpu::try_headless().map_err(|e| Failure::Error(format!("--gpu: no usable GPU adapter ({e})")))?;
let n = args.num("--n")?.unwrap_or(10.0).max(1.0) as usize;
// Every comp, plus an adjustment-layer comp built for the benchmark (the main comp under a
// full-frame adjustment layer with a GPU effect stack) and an Advanced 3D comp (`--adv3d`:
@ -1008,10 +1124,21 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
}
};
let project = &project;
eprintln!("GPU: {} — median of {n} runs (ms/frame)", effectcraft_render::Accelerator::name(&gpu));
eprintln!(
note!("GPU: {} — median of {n} runs (ms/frame)", effectcraft_render::Accelerator::name(&gpu));
note!(
"{:<28} {:>5} {:>10} {:>9} {:>9} {:>9} {:>9} {:>9} {:>8} {:>9} {:>9} {:>11}",
"comp", "res", "size", "cpu cold", "gpu cold", "cpu warm", "gpu warm", "gpu view", "speedup", "auto", "gpu≠cpu", "up/dn MB"
"comp",
"res",
"size",
"cpu cold",
"gpu cold",
"cpu warm",
"gpu warm",
"gpu view",
"speedup",
"auto",
"gpu≠cpu",
"up/dn MB"
);
for cid in comps {
let Some(comp) = project.comp(cid) else { continue };
@ -1077,10 +1204,46 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
.count();
let pct = 100.0 * off as f64 / a.data.len().max(1) as f64;
let speedup = cpu_warm / gpu_warm.max(1e-9);
eprintln!(
note!(
"{name:<28} {label:>5} {size:>10} {cpu_cold:>9.2} {gpu_cold:>9.2} {cpu_warm:>9.2} {gpu_warm:>9.2} {view:>9.2} {speedup:>7.2}x {auto:>9.2} {pct:>8.3}% {traffic:>11}"
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Result<Args, String> {
Args::parse(args.iter().map(|a| a.to_string()).collect())
}
/// Unknown options are usage errors naming them, not ignored (#168).
#[test]
fn unknown_options_are_refused() {
let err = |args: &[&str]| parse(args).err().unwrap();
assert_eq!(err(&["exec", "comp.new", "--bogus", "--empty", "--saveas", "z.ecproj"]), "unknown options `--bogus`, `--saveas`");
assert_eq!(err(&["render", "--bogus=1"]), "unknown option `--bogus`");
assert_eq!(err(&["info", "--json=yes"]), "--json takes no value");
let a = parse(&["render", "t.ecproj", "--comp=Main", "--work-area", "--out", "x.mp4", "--json"]).unwrap();
assert_eq!((a.project.as_deref(), a.opt("--comp"), a.opt("--out")), (Some("t.ecproj"), Some("Main"), Some("x.mp4")));
assert!(a.flag("--work-area") && a.flag("--json"));
}
/// Every `--option` the CLI reads is declared in VALUED or FLAGS, so none is refused.
#[test]
fn every_option_read_is_declared() {
let code = include_str!("main.rs").split("#[cfg(test)]").next().unwrap();
for rest in code.split("\"--").skip(1) {
let name: String = rest.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '-').collect();
let opt = format!("--{name}");
// `--help` and `--version` are handled before parsing.
if name.is_empty() || !rest[name.len()..].starts_with('"') || ["--help", "--version"].contains(&opt.as_str()) {
continue;
}
assert!(VALUED.contains(&opt.as_str()) || FLAGS.contains(&opt.as_str()), "{opt} is read but not declared");
}
}
}

View File

@ -1,6 +1,6 @@
//! End-to-end tests of the agent CLI: JSON output, project round-trips, rendering and MCP over stdio.
use std::io::Write;
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use std::process::{Command, Stdio};
@ -130,6 +130,60 @@ fn errors_are_json() {
assert_eq!(out.status.code(), Some(2));
}
/// An unknown option is a usage error (exit 2) naming it, before anything runs: the command
/// doesn't run with defaults, and a misspelt option's value isn't opened as the project (#168).
#[test]
fn unknown_options_are_usage_errors() {
let png = tmp("bogus.png");
let o = png.to_str().unwrap();
for (args, named) in [
(&["exec", "comp.new", "--bogus", "--empty", "--json"][..], "`--bogus`"),
(&["render-frame", "--bogusflag", "--out", o, "--json"], "`--bogusflag`"),
(&["exec", "comp.new", "--empty", "--saveas", "z.ecproj", "--json"], "`--saveas`"),
] {
let out = bin().args(args).output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "{args:?}: {err}");
assert!(err.contains(&format!("unknown option {named}")), "{args:?}: {err}");
assert!(out.stdout.is_empty(), "{args:?}: nothing ran");
}
assert!(!png.exists(), "render-frame didn't render");
// `--transparent` (silently ignored before) keeps the frame's alpha.
let proj = tmp("transparent.ecproj");
let p = proj.to_str().unwrap();
ok_json(&["run", "comp.new", r#"{"name":"T","width":32,"height":32}"#, "layer.newSolid", r#"{"width":8,"height":8}"#, "--empty", "--save-as", p]);
for (flag, alpha) in [(None, 255), (Some("--transparent"), 0)] {
ok_json(&[&["render-frame", p, "--out", o][..], flag.as_slice()].concat());
assert_eq!(image::open(&png).unwrap().to_rgba8().get_pixel(0, 0)[3], alpha, "{flag:?}");
}
}
/// A reader that closes stdout before the CLI writes (`| head`) is not a crash: the command
/// still does its work and exits 0, without a panic (#167).
#[test]
fn a_closed_stdout_is_not_a_crash() {
let proj = tmp("closed-stdout.ecproj");
let p = proj.to_str().unwrap();
for args in [&["info", "--json"][..], &["exec", "--list"], &["run", "comp.new", r#"{"name":"Piped"}"#, "project.summary", "--empty", "--save-as", p]] {
let mut child = bin().args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
drop(child.stdout.take());
let out = child.wait_with_output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(0), "{args:?}: {err}");
assert!(!err.contains("panicked"), "{args:?}: {err}");
}
assert!(std::fs::read_to_string(&proj).unwrap().contains("Piped"), "the sequence ran to the end and saved");
// The MCP server ends quietly when its client closes stdout.
let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
drop(child.stdout.take());
{
let mut stdin = child.stdin.take().unwrap();
let _ = writeln!(stdin, "{}", json!({"jsonrpc": "2.0", "id": 1, "method": "ping"}));
}
let out = child.wait_with_output().unwrap();
assert_eq!(out.status.code(), Some(0), "{}", String::from_utf8_lossy(&out.stderr));
}
#[test]
fn mcp_over_stdio() {
let mut child = bin().args(["mcp", "--demo"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
@ -185,4 +239,218 @@ fn script_file_and_eval() {
let (code, v) = run_json(&["script", "--eval", "\nnope()"]);
assert_eq!(code, 1);
assert_eq!(v["error"]["line"], json!(2));
// A compiled .jsxbin script is reported as unsupported, not as a SyntaxError (#176).
let bin_script = tmp("compiled.jsxbin");
std::fs::write(&bin_script, "@JSXBIN@ES@2.0@MyBbyBn0ABJAnAEjzFjBjMjFjSjUBfRBFeFjIjFjMjMjPff0DzACByB\n").unwrap();
let out = bin().args(["script", bin_script.to_str().unwrap()]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains(".jsxbin scripts are not supported: run the .jsx source") && !err.contains("SyntaxError"), "{err}");
}
/// `render --out` (#300): a relative path is relative to the working directory like `--project`,
/// not to the project's folder (which doubled a path that already named it).
#[test]
fn render_out_is_relative_to_the_working_directory() {
let root = tmp("render-out");
let _ = std::fs::remove_dir_all(&root);
let (proj, elsewhere) = (root.join("proj"), root.join("elsewhere"));
std::fs::create_dir_all(&proj).unwrap();
std::fs::create_dir_all(&elsewhere).unwrap();
let project = proj.join("x.ecproj");
let p = project.to_str().unwrap();
ok_json(&[
"run",
"comp.new",
r#"{"name":"T","width":64,"height":36,"duration":1}"#,
"layer.newSolid",
r#"{"width":64,"height":36}"#,
"--empty",
"--save-as",
p,
]);
assert!(project.exists());
let render = |cwd: &std::path::Path, project: &str, out: &str| {
let o = bin()
.current_dir(cwd)
.args(["render", "--project", project, "--format", "gif", "--start", "0", "--end", "0.1", "--resolution", "quarter", "--out", out, "--json"])
.output()
.unwrap();
assert!(o.status.success(), "{out}: {}", String::from_utf8_lossy(&o.stderr));
let v: Value = serde_json::from_slice(&o.stdout).unwrap();
PathBuf::from(v["rendered"][0]["output"].as_str().unwrap())
};
// A path that already names the project folder is not doubled.
let done = render(&root, "proj/x.ecproj", "proj/out/d.gif");
assert_eq!(done, root.join("proj/out/d.gif"));
assert!(done.exists() && !proj.join("proj").exists());
// Another working directory: the file lands there, not beside the project.
let done = render(&elsewhere, project.to_str().unwrap(), "out/card.gif");
assert_eq!(done, elsewhere.join("out/card.gif"));
assert!(done.exists() && !proj.join("out/card.gif").exists());
// `./f.gif` too.
assert_eq!(render(&elsewhere, project.to_str().unwrap(), "./f.gif"), elsewhere.join("./f.gif"));
// An absolute path is unchanged.
let abs = root.join("abs.gif");
assert_eq!(render(&elsewhere, project.to_str().unwrap(), abs.to_str().unwrap()), abs);
assert!(abs.exists());
let _ = std::fs::remove_dir_all(&root);
}
/// A real stdio client: each edit's reply is read before disconnecting or killing the server.
struct McpChild {
child: std::process::Child,
output: BufReader<std::process::ChildStdout>,
}
impl McpChild {
fn start(config: &std::path::Path, autosave: bool) -> Self {
let mut command = bin();
command.arg("mcp").env("EFFECTCRAFT_CONFIG_DIR", config).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
if autosave {
command.arg("--autosave");
}
let mut child = command.spawn().unwrap();
let output = BufReader::new(child.stdout.take().unwrap());
Self { child, output }
}
fn rpc(&mut self, method: &str, params: Value) -> Value {
writeln!(self.child.stdin.as_mut().unwrap(), "{}", json!({"jsonrpc":"2.0", "id":1, "method":method, "params":params})).unwrap();
let mut line = String::new();
assert!(self.output.read_line(&mut line).unwrap() > 0, "server exited without replying");
let reply: Value = serde_json::from_str(&line).unwrap();
assert!(reply.get("error").is_none(), "{reply}");
reply["result"].clone()
}
fn tool(&mut self, name: &str, arguments: Value) -> Value {
let result = self.rpc("tools/call", json!({"name":name,"arguments":arguments}));
assert_eq!(result["isError"], false, "{result}");
serde_json::from_str(result["content"][0]["text"].as_str().unwrap()).unwrap()
}
fn kill(&mut self) {
self.child.kill().unwrap();
self.child.wait().unwrap();
}
fn eof(&mut self) {
drop(self.child.stdin.take());
assert!(self.child.wait().unwrap().success());
}
}
impl Drop for McpChild {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn mcp_config(name: &str) -> PathBuf {
let dir = tmp(name);
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn mcp_autosave_survives_kill_and_restart_without_touching_desktop_settings() {
let config = mcp_config("mcp-kill");
let prefs = r#"{"autoSave":{"enabled":false,"maxVersions":2,"intervalMinutes":20}}"#;
std::fs::write(config.join("prefs.json"), prefs).unwrap();
std::fs::write(config.join("shortcuts.json"), "{}").unwrap();
std::fs::write(config.join("session.lock"), "desktop sentinel").unwrap();
let mut first = McpChild::start(&config, true);
first.rpc("initialize", json!({}));
first.tool("execute_command", json!({"command":"comp.new","params":{"name":"Survives restart","width":320,"height":180}}));
first.tool("execute_command", json!({"command":"layer.newText","params":{"text":"Unsaved title"}}));
assert_eq!(first.tool("execute_command", json!({"command":"prefs.get","params":{"key":"autoSave.enabled"}})), false);
let status = first.tool("get_project", json!({}))["autosave"].clone();
let path = status["latestPath"].as_str().unwrap();
first.kill(); // SIGKILL: there is no opportunity to save at EOF.
let folder = std::path::Path::new(status["folder"].as_str().unwrap());
assert_eq!(effectcraft_engine::autosave::existing(folder, "Untitled Project").len(), 2, "loaded maxVersions is respected");
let manifest: Value = serde_json::from_str(&std::fs::read_to_string(folder.join("session.json")).unwrap()).unwrap();
assert_eq!(manifest["dirty"], true);
assert_eq!(manifest["ended"], false);
let mut restarted = McpChild::start(&config, true);
let init = restarted.rpc("initialize", json!({}));
assert!(init["instructions"].as_str().unwrap().contains(path));
assert_eq!(init["_meta"]["effectcraftAutoSave"]["previousSessions"][0]["autosave"], path);
restarted.tool("open_project", json!({"path":path}));
let comp = restarted.tool("get_comp", json!({"comp":"Survives restart"}));
assert_eq!(comp["layers"].as_array().unwrap().len(), 1);
let layer = restarted.tool("get_layer", json!({"layer":"#1"}));
assert!(layer.to_string().contains("Unsaved title"), "{layer}");
restarted.tool("save_project", json!({"path":config.join("Recovered.ecproj")}));
restarted.eof();
assert_eq!(std::fs::read_to_string(config.join("prefs.json")).unwrap(), prefs);
assert_eq!(std::fs::read_to_string(config.join("shortcuts.json")).unwrap(), "{}");
assert_eq!(std::fs::read_to_string(config.join("session.lock")).unwrap(), "desktop sentinel");
std::fs::remove_dir_all(config).unwrap();
}
#[test]
fn mcp_autosave_eof_preserves_titled_edits_without_overwriting_project() {
let config = mcp_config("mcp-eof");
let original = config.join("Original.ecproj");
let mut first = McpChild::start(&config, true);
first.tool("execute_command", json!({"command":"comp.new","params":{"name":"Original"}}));
first.tool("save_project", json!({"path":original}));
let original_bytes = std::fs::read(&original).unwrap();
first.tool("execute_command", json!({"command":"layer.newText","params":{"text":"Not manually saved"}}));
let status = first.tool("get_project", json!({}))["autosave"].clone();
first.eof();
assert_eq!(std::fs::read(original).unwrap(), original_bytes);
assert!(std::fs::read_to_string(status["latestPath"].as_str().unwrap()).unwrap().contains("Not manually saved"));
let folder = std::path::Path::new(status["folder"].as_str().unwrap());
let manifest: Value = serde_json::from_str(&std::fs::read_to_string(folder.join("session.json")).unwrap()).unwrap();
assert_eq!(manifest["ended"], true);
assert_eq!(manifest["dirty"], true);
std::fs::remove_dir_all(config).unwrap();
}
#[test]
fn mcp_autosave_preserves_work_when_stdout_is_closed() {
let config = mcp_config("mcp-broken-pipe");
let mut child = bin()
.args(["mcp", "--autosave"])
.env("EFFECTCRAFT_CONFIG_DIR", &config)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
drop(child.stdout.take());
{
let mut input = child.stdin.take().unwrap();
writeln!(input, "{}", json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_command","arguments":{"command":"comp.new","params":{"name":"Closed stdout"}}}})).unwrap();
}
let out = child.wait_with_output().unwrap();
assert_eq!(out.status.code(), Some(0), "{}", String::from_utf8_lossy(&out.stderr));
let base = config.join(effectcraft_engine::autosave::AUTOSAVE_FOLDER).join("MCP");
let folder = std::fs::read_dir(base).unwrap().next().unwrap().unwrap().path();
let manifest: Value = serde_json::from_str(&std::fs::read_to_string(folder.join("session.json")).unwrap()).unwrap();
assert!(std::fs::read_to_string(manifest["autosave"].as_str().unwrap()).unwrap().contains("Closed stdout"));
assert_eq!(manifest["ended"], true);
std::fs::remove_dir_all(config).unwrap();
}
#[test]
fn mcp_autosave_is_opt_in_and_rejects_invalid_combinations() {
let config = mcp_config("mcp-opt-in");
let mut client = McpChild::start(&config, false);
client.tool("execute_command", json!({"command":"comp.new","params":{"name":"Ephemeral"}}));
client.eof();
assert!(std::fs::read_dir(&config).unwrap().next().is_none());
for args in [["mcp", "--autosave", "--bridge", "1"].as_slice(), ["info", "--autosave"].as_slice()] {
let out = bin().args(args).env("EFFECTCRAFT_CONFIG_DIR", &config).output().unwrap();
assert_eq!(out.status.code(), Some(2));
assert!(String::from_utf8_lossy(&out.stderr).contains("--autosave"));
}
let blocked = config.join("file-not-directory");
std::fs::write(&blocked, "file").unwrap();
let out = bin().args(["mcp", "--autosave"]).env("EFFECTCRAFT_CONFIG_DIR", blocked).output().unwrap();
assert_eq!(out.status.code(), Some(1));
assert!(String::from_utf8_lossy(&out.stderr).contains("MCP auto-save"));
assert!(!String::from_utf8_lossy(&out.stderr).contains("panicked"));
std::fs::remove_dir_all(config).unwrap();
}

View File

@ -278,8 +278,8 @@ export function audioStop() {
// ------------------------------------------------------------------ workers
// Idle workers are reused; each keeps the files it was sent (path → size) so a file goes to a
// worker once.
// Idle workers are reused; each keeps the files it was sent (path → version) so a file goes to a
// worker once, and again when it was replaced (even by one of the same size, #218).
const idle = [];
const running = new Map(); // job id → {worker, done}
// Job workers open a WebGPU device of their own (unless `?nogpuworkers`): their jobs' frames
@ -310,7 +310,8 @@ function spawn(base) {
return w;
}
/// Run a job in a worker. `files`: [[path, Uint8Array]] the job reads; `onMessage(type, json,
/// Run a job in a worker. `files`: [[path, Uint8Array, version]] the job reads (`version`
/// changes with every write of the file); `onMessage(type, json,
/// path, bytes)` receives "reply" (json), "file" (path, bytes: a rendered file to download),
/// "store" (path, bytes: a file to keep in browser storage) and "error" (json = message).
export function workerRun(id, json, files, base, onMessage) {
@ -339,14 +340,17 @@ export function workerRun(id, json, files, base, onMessage) {
onMessage("error", String(e.message || "worker error"), null, null);
w.worker.terminate();
};
for (const [path, bytes] of files) {
if (w.files.get(path) === bytes.length) continue;
w.files.set(path, bytes.length);
for (const [path, bytes, version] of files) {
const tag = version ?? bytes.length;
if (w.files.get(path) === tag) continue;
w.files.set(path, tag);
w.worker.postMessage({ type: "file", path, bytes });
}
w.worker.postMessage({ type: "job", json });
}, (e) => {
// The worker didn't start (#216): the job fails and the worker goes.
running.delete(id);
w.worker.terminate();
onMessage("error", String(e.message || e), null, null);
});
}
@ -611,22 +615,72 @@ export async function browseRead(path) {
}
/// The fallback where folders can't be opened: pick files (or a whole folder, `directory`) with a
/// plain file input; resolves with [{path, bytes}] (path relative to the picked folder).
/// plain file input; resolves with [{path, bytes}] (path relative to the picked folder). A file
/// that can't be read is listed as {path, error} instead (#226); cancelling resolves with [].
export function browsePickFiles(directory) {
return new Promise((resolve, reject) => {
return new Promise((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
if (directory) input.webkitdirectory = true;
input.oncancel = () => resolve([]);
input.onchange = async () => {
try {
const out = [];
for (const f of input.files) {
out.push({ path: (directory && f.webkitRelativePath) || f.name, bytes: new Uint8Array(await f.arrayBuffer()) });
const out = [];
for (const f of input.files) {
const path = (directory && f.webkitRelativePath) || f.name;
try {
out.push({ path, bytes: new Uint8Array(await f.arrayBuffer()) });
} catch (e) {
out.push({ path, error: String((e && (e.message || e.name)) || e) });
}
resolve(out);
} catch (e) { reject(e); }
}
resolve(out);
};
input.click();
});
}
/// The page's WebGPU device was lost (#225): the canvas can't show anything new while the project
/// stays open and editable. Say so over the stale picture, and offer to save the project (a
/// download) and to reload; the reload first writes pending changes to browser storage, from
/// which the session comes back when it is `kept` there.
export function showDeviceLost(message, kept) {
if (document.getElementById("device-lost")) return;
const box = document.createElement("div");
box.id = "device-lost";
box.setAttribute("role", "alertdialog");
const title = document.createElement("strong");
title.textContent = "The graphics device was lost";
const text = document.createElement("p");
text.textContent =
"EffectCraft can't update the display any more. Your project is still open. " +
(kept ? "Reload to continue: the project and its unsaved changes come back." : "This session isn't kept in browser storage: save the project before you reload.");
const detail = document.createElement("small");
detail.textContent = message;
const save = document.createElement("button");
save.textContent = "Save Project";
save.onclick = () =>
self.effectcraft
.execute("file.save", {})
.catch(() => self.effectcraft.execute("file.saveAs", { path: "/Untitled Project.ecproj" }))
.catch((e) => (text.textContent = `Saving failed: ${(e && e.message) || e}`));
const reload = document.createElement("button");
reload.textContent = "Reload";
reload.onclick = async () => {
reload.disabled = true;
try {
await self.effectcraft.flush();
} catch (e) {
text.textContent = `Writing to browser storage failed (${(e && e.message) || e}). Save the project, or reload anyway and lose the changes.`;
reload.textContent = "Reload Anyway";
reload.disabled = false;
reload.onclick = () => location.reload();
return;
}
location.reload();
};
const buttons = document.createElement("div");
buttons.append(save, reload);
box.append(title, text, detail, buttons);
document.body.append(box);
}

View File

@ -148,7 +148,8 @@ pub fn remove_stored(path: String) -> bool {
crate::files::STORE.remove_file(&path)
}
/// Resolves once every change is written to browser storage.
/// Resolves once every change is written to browser storage; rejects when a write failed (it is
/// tried again).
#[wasm_bindgen]
pub fn flush() -> js_sys::Promise {
crate::persist::flushed()

View File

@ -11,6 +11,12 @@ use effectcraft_ui_egui::EffectcraftApp;
use serde_json::json;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "/js/host.js")]
extern "C" {
#[wasm_bindgen(js_name = showDeviceLost)]
fn show_device_lost(message: &str, kept: bool);
}
/// Work for the UI thread with access to the app (posted by async tasks: picked files…).
type Action = Box<dyn FnOnce(&mut EffectcraftApp)>;
@ -204,6 +210,27 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
eframe::WebOptions::default(),
Box::new(move |cc| {
CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone()));
// The page owns this shared presentation device; worker devices retain their
// separate owned-device handlers. No browser error-scope future is blocked on.
let gpu_failures = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&cc.egui_ctx);
if let Some(rs) = &cc.wgpu_render_state {
let errors = gpu_failures.clone();
rs.device.on_uncaptured_error(Arc::new(move |error| {
let message = format!("uncaptured GPU error: {error}");
if errors.report(&message, false) {
log::error!("{message}");
}
}));
// The canvas can't present any more (#225): the page says so over it and
// offers a reload, which brings the session back when browser storage keeps it.
let lost = gpu_failures.clone();
let kept = !empty && persist::backend() != "memory";
rs.device.set_device_lost_callback(move |reason, message| {
let message = format!("GPU device lost ({reason:?}): {message}");
lost.report(&message, true);
show_device_lost(&message, kept);
});
}
let mut session = session();
session.load_settings();
let recovery = session.begin_recovery();
@ -215,6 +242,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
}
api::set_info("restored", json!(restored));
let mut app = EffectcraftApp::new(session);
app.set_gpu_failure_bridge(gpu_failures);
app.ui.start_screen = home;
if let Some(r) = recovery.filter(|r| !restored && r.autosave.is_some()) {
app.offer_recovery(r);

View File

@ -105,7 +105,7 @@ fn spawn(p: js_sys::Promise, what: &'static str, then: impl FnOnce(&mut effectcr
crate::post(move |app| then(app, v));
}
Err(e) => {
let msg = e.as_string().or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_default();
let msg = crate::persist::js_err(e);
// Cancelling a picker is not an error.
if !msg.contains("abort") && !msg.contains("AbortError") {
crate::post(move |app| app.ui.status = format!("{what}: {msg}"));
@ -234,20 +234,42 @@ impl Browser for WebBrowser {
Ok(json!({"pending": true}))
}
"addFiles" | "uploadFolder" => {
let what = if id == "uploadFolder" { "Upload Folder…" } else { "Add Files…" };
let p = pick_files(id == "uploadFolder");
wasm_bindgen_futures::spawn_local(async move {
let Ok(list) = wasm_bindgen_futures::JsFuture::from(p).await else { return };
let mut n = 0;
for f in js_sys::Array::from(&list).iter() {
let path = js_sys::Reflect::get(&f, &"path".into()).ok().and_then(|v| v.as_string());
let bytes = js_sys::Reflect::get(&f, &"bytes".into()).ok().and_then(|b| b.dyn_into::<js_sys::Uint8Array>().ok());
if let (Some(path), Some(bytes)) = (path, bytes) {
crate::files::put(&format!("/{}", path.trim_start_matches('/')), bytes.to_vec().into());
n += 1;
let list = match wasm_bindgen_futures::JsFuture::from(p).await {
Ok(list) => js_sys::Array::from(&list),
Err(e) => {
let msg = crate::persist::js_err(e);
crate::post(move |app| app.ui.status = format!("{what}: {msg}"));
return;
}
};
// Files that can't be read are reported, not dropped silently (#226).
let (mut n, mut unread) = (0, vec![]);
for f in list.iter() {
let get = |key: &str| js_sys::Reflect::get(&f, &key.into()).ok();
let Some(path) = get("path").and_then(|v| v.as_string()) else { continue };
match get("bytes").and_then(|b| b.dyn_into::<js_sys::Uint8Array>().ok()) {
Some(bytes) => {
crate::files::put(&format!("/{}", path.trim_start_matches('/')), bytes.to_vec().into());
n += 1;
}
None => unread.push(match get("error").and_then(|e| e.as_string()).filter(|e| !e.is_empty()) {
Some(why) => format!("{path} ({why})"),
None => path,
}),
}
}
// Cancelled: nothing to report.
if n == 0 && unread.is_empty() {
return;
}
crate::post(move |app| {
app.ui.status = format!("{n} file(s) added to Browser Storage");
if !unread.is_empty() {
app.ui.status += &format!(". Could not read {}: try {what} again", unread.join(", "));
}
go(app, STORAGE);
});
});

View File

@ -35,6 +35,12 @@ pub fn get(path: &str) -> Option<Arc<[u8]>> {
STORE.file(path)
}
/// A file's bytes and version (new with every write: workers resend a file whose version they
/// haven't seen).
pub fn get_versioned(path: &str) -> Option<(Arc<[u8]>, u64)> {
STORE.file_versioned(path)
}
/// `(path, size)` of every file in the table.
pub fn list() -> Vec<(String, usize)> {
STORE.files().into_iter().map(|(p, n, _, _)| (p, n)).collect()
@ -132,6 +138,15 @@ impl Services for WebServices {
}
Ok(())
}
/// The table's files in `dir` (picked and dropped files sit in `/`): picking or dropping a
/// numbered file finds the other frames of its image sequence loaded before.
fn list_dir(&self, dir: &str) -> Vec<String> {
let dir = dir.trim_end_matches('/');
list().into_iter().map(|(p, _)| p).filter(|p| p.rsplit_once('/').is_some_and(|(d, _)| d == dir)).collect()
}
fn is_dir(&self, _path: &str) -> bool {
false
}
}
/// Media import from the table (the bytes are also handed to the media pool, which decodes them).
@ -148,6 +163,13 @@ impl effectcraft_engine::Importer for WebImporter {
fn register(&self, path: &str, data: &[u8]) {
self.pool.add_bytes(path, get(path).unwrap_or_else(|| data.into()));
}
fn add_frames(&self, paths: &[String]) {
for p in paths {
if let Some(bytes) = get(p) {
self.pool.add_bytes(p, bytes);
}
}
}
}
/// Render Queue output: files land in the table and are downloaded after the render

View File

@ -19,7 +19,7 @@
//! frame times. Without WebGPU, frames render on the worker's CPU.
use std::cell::RefCell;
use std::collections::HashSet;
use std::collections::HashMap;
use std::sync::Arc;
use effectcraft_engine::offload::footage_files;
@ -45,7 +45,8 @@ extern "C" {
struct Fw {
index: u32,
mirror: Mirror,
files: HashSet<String>,
/// The footage files it was sent, with their versions (a replaced file goes again).
files: HashMap<String, u64>,
busy: Option<(u64, RemoteDone)>,
alive: bool,
/// Its GPU adapter (after it reported), or why it has none.
@ -99,7 +100,7 @@ impl WebFrames {
let mut s = s.borrow_mut();
for _ in 0..n {
let index = frame_worker_start(&crate::page_url(""), on.as_ref().unchecked_ref(), gpu);
s.workers.push(Fw { index, mirror: Mirror::default(), files: HashSet::new(), busy: None, alive: true, gpu: None });
s.workers.push(Fw { index, mirror: Mirror::default(), files: HashMap::new(), busy: None, alive: true, gpu: None });
}
});
HANDLER.with(|h| *h.borrow_mut() = Some(on));
@ -116,14 +117,13 @@ impl WebFrames {
// Footage the worker hasn't got yet.
let w = &mut s.workers[k];
for p in footage_files(&job.project) {
if w.files.contains(&p) {
let Some((d, version)) = crate::files::get_versioned(&p) else { continue };
if w.files.get(&p) == Some(&version) {
continue;
}
if let Some(d) = crate::files::get(&p) {
let bytes = js_sys::Uint8Array::from(&d[..]);
frame_worker_post(w.index, &obj(&[("type", "file".into()), ("path", p.as_str().into()), ("bytes", bytes.into())]), &js_sys::Array::new());
w.files.insert(p);
}
let bytes = js_sys::Uint8Array::from(&d[..]);
frame_worker_post(w.index, &obj(&[("type", "file".into()), ("path", p.as_str().into()), ("bytes", bytes.into())]), &js_sys::Array::new());
w.files.insert(p, version);
}
let sync = w.mirror.sync(job.revision, &job.project);
let index = w.index;

View File

@ -3,7 +3,8 @@
//!
//! - [`load`] (before the app starts) opens the storage and reads every entry into the mirror.
//! - Changes flush in the background ([`schedule_flush`], wired as the store's change hook):
//! one write per changed key, in order, with nothing written twice concurrently.
//! one write per changed key, in order, with nothing written twice concurrently. A write that
//! fails (quota exceeded…) is kept and tried again, after 2 s, then longer, up to a minute.
//! - The session snapshot ([`snapshot`]) records the open project and editor state every second
//! or so while it changes, so a reload (or a crash) comes back to where the user was
//! ([`restore`]).
@ -37,11 +38,18 @@ thread_local! {
static FLUSHING: Cell<bool> = const { Cell::new(false) };
static BACKEND: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
static LAST_ERROR: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
static FLUSH_WAITERS: std::cell::RefCell<Vec<js_sys::Function>> = const { std::cell::RefCell::new(Vec::new()) };
/// [`flushed`] promises waiting for the running flush: (resolve, reject).
static FLUSH_WAITERS: std::cell::RefCell<Vec<(js_sys::Function, js_sys::Function)>> = const { std::cell::RefCell::new(Vec::new()) };
/// Delay before retrying failed writes (ms; doubles while they keep failing).
static RETRY_MS: Cell<i32> = const { Cell::new(RETRY_FIRST_MS) };
static RETRY_PENDING: Cell<bool> = const { Cell::new(false) };
static SNAP: Cell<(u64, f64)> = const { Cell::new((u64::MAX, 0.0)) };
static LAST_META: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
}
const RETRY_FIRST_MS: i32 = 2_000;
const RETRY_MAX_MS: i32 = 60_000;
/// The storage in use: `opfs`, `indexeddb` or `memory`.
pub fn backend() -> String {
BACKEND.with(|b| b.borrow().clone())
@ -52,7 +60,8 @@ pub fn last_error() -> Option<String> {
LAST_ERROR.with(|e| e.borrow().clone())
}
fn js_err(e: JsValue) -> String {
/// A JavaScript error (a rejected promise's reason) as text.
pub(crate) fn js_err(e: JsValue) -> String {
e.as_string().or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_else(|| format!("{e:?}"))
}
@ -86,8 +95,11 @@ pub fn schedule_flush() {
return; // the running flush picks the new changes up
}
wasm_bindgen_futures::spawn_local(async {
// Keys whose write failed in this flush, and the last failure.
let mut failed: Vec<String> = vec![];
let mut error = None;
loop {
let pending = STORE.lock().take_pending();
let pending: Vec<_> = STORE.lock().take_pending().into_iter().filter(|p| !failed.contains(&p.key)).collect();
if pending.is_empty() {
break;
}
@ -99,24 +111,68 @@ pub fn schedule_flush() {
if let Err(e) = r {
let msg = format!("saving {} to browser storage failed: {}", p.key, js_err(e));
log::warn!("{msg}");
LAST_ERROR.with(|l| *l.borrow_mut() = Some(msg));
LAST_ERROR.with(|l| *l.borrow_mut() = Some(msg.clone()));
error = Some(msg);
failed.push(p.key);
}
}
}
// Failed writes stay pending (#215): flushed later, and the waiters hear of the failure
// instead of being told the changes are stored.
{
let mut m = STORE.lock();
for k in &failed {
m.retry(k);
}
}
FLUSHING.with(|f| f.set(false));
for w in FLUSH_WAITERS.with(|w| std::mem::take(&mut *w.borrow_mut())) {
let _ = w.call0(&JsValue::NULL);
let waiters = FLUSH_WAITERS.with(|w| std::mem::take(&mut *w.borrow_mut()));
match &error {
None => {
RETRY_MS.with(|r| r.set(RETRY_FIRST_MS));
for (resolve, _) in waiters {
let _ = resolve.call0(&JsValue::NULL);
}
}
Some(msg) => {
let e: JsValue = js_sys::Error::new(msg).into();
for (_, reject) in waiters {
let _ = reject.call1(&JsValue::NULL, &e);
}
retry_later();
}
}
});
}
/// A promise that resolves once every change so far is in the browser storage.
/// Flush again after the retry delay (once; the delay doubles up to a minute while writes keep
/// failing).
fn retry_later() {
if RETRY_PENDING.with(|r| r.replace(true)) {
return;
}
let ms = RETRY_MS.with(|r| {
let ms = r.get();
r.set(ms.saturating_mul(2).min(RETRY_MAX_MS));
ms
});
let again = Closure::once_into_js(|| {
RETRY_PENDING.with(|r| r.set(false));
schedule_flush();
});
if let Some(w) = web_sys::window() {
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(again.unchecked_ref(), ms);
}
}
/// A promise that resolves once every change so far is in the browser storage, and rejects
/// when a write failed (the change stays pending and is tried again).
pub fn flushed() -> js_sys::Promise {
js_sys::Promise::new(&mut |resolve, _| {
js_sys::Promise::new(&mut |resolve, reject| {
if !FLUSHING.with(|f| f.get()) && !STORE.lock().has_pending() {
let _ = resolve.call0(&JsValue::NULL);
} else {
FLUSH_WAITERS.with(|w| w.borrow_mut().push(resolve));
FLUSH_WAITERS.with(|w| w.borrow_mut().push((resolve, reject)));
schedule_flush();
}
})

View File

@ -42,6 +42,9 @@ pub struct Entry {
pub modified: f64,
/// Written to the browser's storage (render outputs are not).
pub persist: bool,
/// Which write this is: new with every write of the key, so a file replaced by another of
/// the same size still reads as changed (workers are sent it again, #218).
pub version: u64,
}
/// A queued write for the browser storage: `data` = `None` deletes the key.
@ -58,17 +61,21 @@ pub struct Mirror {
entries: BTreeMap<String, Entry>,
/// Keys changed since the last [`Mirror::take_pending`], in first-change order.
dirty: Vec<String>,
/// The last [`Entry::version`] given.
version: u64,
}
impl Mirror {
/// An entry read from the browser's storage at startup (nothing to flush).
pub fn load(&mut self, key: &str, data: Arc<[u8]>, modified: f64) {
self.entries.insert(key.to_string(), Entry { data, modified, persist: true });
let version = self.next_version();
self.entries.insert(key.to_string(), Entry { data, modified, persist: true, version });
}
pub fn put(&mut self, key: &str, data: Arc<[u8]>, modified: f64, persist: bool) {
let was_persisted = self.entries.get(key).is_some_and(|e| e.persist);
self.entries.insert(key.to_string(), Entry { data, modified, persist });
let version = self.next_version();
self.entries.insert(key.to_string(), Entry { data, modified, persist, version });
if persist || was_persisted {
self.touch(key);
}
@ -86,6 +93,16 @@ impl Mirror {
}
}
fn next_version(&mut self) -> u64 {
self.version = self.version.wrapping_add(1);
self.version
}
/// A write of `key` that failed: the next flush writes the key's latest state again (#215).
pub fn retry(&mut self, key: &str) {
self.touch(key);
}
fn touch(&mut self, key: &str) {
if !self.dirty.iter().any(|k| k == key) {
self.dirty.push(key.to_string());
@ -172,6 +189,11 @@ impl Store {
self.lock().get(&file_key(path)).map(|e| e.data.clone())
}
/// The file under `path` with its [`Entry::version`].
pub fn file_versioned(&self, path: &str) -> Option<(Arc<[u8]>, u64)> {
self.lock().get(&file_key(path)).map(|e| (e.data.clone(), e.version))
}
pub fn remove_file(&self, path: &str) -> bool {
let r = self.lock().remove(&file_key(path));
self.changed();
@ -412,6 +434,38 @@ mod tests {
assert_eq!(m.list("files").len(), 1);
}
/// A file replaced by different bytes of the same size gets a new version, so workers are
/// sent it again (#218).
#[test]
fn versions_change_with_every_write() {
let s = store();
s.put_file("/collision.png", Arc::from(&b"red!"[..]), true);
let (_, a) = s.file_versioned("/collision.png").unwrap();
s.put_file("/collision.png", Arc::from(&b"blue"[..]), true);
let (data, b) = s.file_versioned("/collision.png").unwrap();
assert_ne!(a, b);
assert_eq!(&data[..], b"blue");
assert_eq!(s.file_versioned("/collision.png").map(|f| f.1), Some(b), "reading doesn't change it");
}
/// A write that failed is flushed again with the key's latest state (#215).
#[test]
fn failed_writes_retry() {
let s = store();
s.put_file("/collision.png", Arc::from(&b"blue"[..]), true);
let mut m = s.lock();
let p = m.take_pending();
assert_eq!(p.len(), 1);
assert!(!m.has_pending());
m.retry(&p[0].key);
assert_eq!(m.take_pending(), p, "the same write again");
// A newer change wins over the failed one.
m.retry("files/collision.png");
m.put("files/collision.png", Arc::from(&b"gold"[..]), 2.0, true);
let again = m.take_pending();
assert_eq!((again.len(), again[0].data.as_deref()), (1, Some(&b"gold"[..])));
}
#[test]
fn keys_and_paths_round_trip() {
assert_eq!(file_key("/a b/c.mov"), "files/a b/c.mov");
@ -477,8 +531,8 @@ mod tests {
a.execute("comp.new", json!({"name": "C"})).unwrap();
// Untitled: the auto-save lands in the store's file table.
let p = a.autosave_now().unwrap();
assert_eq!(p, "/EffectCraft Auto-Save/Untitled Project auto-save 1.ecproj");
assert!(s.file(&p).is_some());
assert_eq!(Path::new(&p), Path::new("/EffectCraft Auto-Save/Untitled Project auto-save 1.ecproj"));
assert!(s.file(&path_str(Path::new(&p))).is_some());
// The tab closes without a clean exit: the next visit offers the auto-save.
let mut b = Session { config: Some(Arc::new(WebConfig::new(s.clone()))), ..Default::default() };
b.load_settings();

View File

@ -60,8 +60,8 @@ impl Offload for WorkerOffload {
let id = req.id;
let files = js_sys::Array::new();
for p in &req.files {
if let Some(d) = crate::files::get(p) {
files.push(&js_sys::Array::of2(&JsValue::from_str(p), &js_sys::Uint8Array::from(&d[..])));
if let Some((d, version)) = crate::files::get_versioned(p) {
files.push(&js_sys::Array::of3(&JsValue::from_str(p), &js_sys::Uint8Array::from(&d[..]), &JsValue::from_f64(version as f64)));
}
}
let json = serde_json::to_string(&req).map_err(|e| e.to_string())?;

View File

@ -0,0 +1,129 @@
// Page-side helpers of the web app (js/host.js) under Node with a fake DOM, no browser or Wasm
// build needed:
//
// node apps/effectcraft-web/tests/page.mjs
//
// - Add Files… / Upload Folder…: a picked file that can't be read is listed with its error
// instead of failing the whole pick silently, and cancelling picks nothing (#226).
// - A lost WebGPU device: the page says so over the canvas and offers Save Project and Reload;
// the reload writes pending changes to browser storage first, and a failed write is reported
// instead of reloading (#225).
// Exits non-zero on failure.
import assert from "node:assert/strict";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const tick = () => new Promise((r) => setTimeout(r, 0));
globalThis.self = globalThis;
class FakeElement {
constructor(tag) {
this.tag = tag;
this.children = [];
this.attributes = {};
this.textContent = "";
}
setAttribute(k, v) {
this.attributes[k] = v;
}
append(...c) {
this.children.push(...c);
}
click() {
this.onclick?.();
}
*all() {
yield this;
for (const c of this.children) yield* c.all();
}
}
let picker;
globalThis.document = {
body: new FakeElement("body"),
createElement(tag) {
const el = new FakeElement(tag);
if (tag === "input") {
picker = el;
el.click = () => {};
}
return el;
},
getElementById(id) {
return [...this.body.all()].find((e) => e.id === id) ?? null;
},
};
let reloads = 0;
globalThis.location = { reload: () => reloads++ };
const host = await import(pathToFileURL(join(here, "../js/host.js")));
// #226: one readable and one unreadable file.
const file = (name, read) => ({ name, webkitRelativePath: "", arrayBuffer: read });
const picked = host.browsePickFiles(false);
picker.files = [
file("ok.png", async () => new Uint8Array([1, 2, 3]).buffer),
file("red-corner.png", async () => {
throw new DOMException("Generated unreadable selected file", "NotReadableError");
}),
];
await picker.onchange();
const out = await picked;
assert.equal(out.length, 2);
assert.deepEqual([out[0].path, [...out[0].bytes]], ["ok.png", [1, 2, 3]]);
assert.deepEqual(out[1], { path: "red-corner.png", error: "Generated unreadable selected file" });
const cancelled = host.browsePickFiles(false);
picker.oncancel();
assert.deepEqual(await cancelled, [], "cancelling picks nothing");
// #225: the device-lost notice.
const calls = [];
let flushFails = false;
self.effectcraft = {
flush: async () => {
calls.push("flush");
if (flushFails) throw new Error("quota exceeded");
},
execute: async (command, params) => {
calls.push([command, params]);
if (command === "file.save") throw new Error("no path: use file.saveAs {path}");
},
};
const notice = () => document.getElementById("device-lost");
const buttons = () => [...notice().all()].filter((e) => e.tag === "button");
const text = () => [...notice().all()].find((e) => e.tag === "p").textContent;
host.showDeviceLost("GPU device lost (Destroyed): destroyed", true);
host.showDeviceLost("again", true);
assert.equal(document.body.children.length, 1, "one notice");
assert.equal([...notice().all()].find((e) => e.tag === "small").textContent, "GPU device lost (Destroyed): destroyed");
assert.match(text(), /Reload to continue/);
assert.deepEqual(
buttons().map((b) => b.textContent),
["Save Project", "Reload"],
);
// Save Project: Save, or Save As for an untitled project (a download).
await buttons()[0].onclick();
assert.deepEqual(calls.splice(0), [
["file.save", {}],
["file.saveAs", { path: "/Untitled Project.ecproj" }],
]);
// Reload writes pending changes first.
await buttons()[1].onclick();
assert.deepEqual(calls.splice(0), ["flush"]);
assert.equal(reloads, 1);
// A failed write is reported instead of reloading; Reload Anyway reloads.
document.body.children = [];
flushFails = true;
host.showDeviceLost("GPU device lost (Unknown): gone", false);
assert.match(text(), /isn't kept in browser storage/);
await buttons()[1].onclick();
await tick();
assert.equal(reloads, 1, "no reload after a failed write");
assert.match(text(), /quota exceeded/);
assert.equal(buttons()[1].textContent, "Reload Anyway");
buttons()[1].onclick();
assert.equal(reloads, 2);
console.log("page: ok");

View File

@ -0,0 +1,101 @@
// Job-worker plumbing of the web app (js/host.js, web/worker.js) under Node with a fake Worker,
// no browser or Wasm build needed:
//
// node apps/effectcraft-web/tests/workers.mjs
//
// - A reused worker is sent a file again when it was replaced, even by one of the same size
// (#218), and not when it is unchanged.
// - A worker whose start fails fails its job and is terminated, instead of the job waiting for
// "ready" forever (#216): host.js on the worker's error, worker.js re-raising the failed start.
// Exits non-zero on failure.
import assert from "node:assert/strict";
import { copyFileSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const tick = () => new Promise((r) => setTimeout(r, 0));
globalThis.self = globalThis;
// A Worker that starts (or fails to) on "init" and finishes each job at once.
let failStart = false;
const workers = [];
class FakeWorker {
constructor() {
this.posted = [];
this.terminated = false;
workers.push(this);
}
postMessage(m) {
this.posted.push(m);
setTimeout(() => {
if (m.type === "init") {
if (failStart) this.onerror?.({ message: "worker failed to start" });
else this.onmessage?.({ data: { type: "ready" } });
} else if (m.type === "job") {
this.onmessage?.({ data: { type: "reply", json: "{}", done: true } });
}
});
}
terminate() {
this.terminated = true;
}
}
globalThis.Worker = FakeWorker;
const host = await import(pathToFileURL(join(here, "../js/host.js")));
const run = (id, files) =>
new Promise((resolve) => host.workerRun(id, "{}", files, "http://localhost/", (kind, json) => kind !== "file" && resolve({ kind, json })));
const sent = (w) => w.posted.filter((m) => m.type === "file").map((m) => m.bytes[0]);
// #218: equal-length replacement.
const red = new Uint8Array(512).fill(1);
const blue = new Uint8Array(512).fill(2);
assert.equal((await run(1, [["/collision.png", red, 1]])).kind, "reply");
assert.equal((await run(2, [["/collision.png", blue, 2]])).kind, "reply");
assert.equal((await run(3, [["/collision.png", blue, 2]])).kind, "reply");
assert.equal(workers.length, 1, "the worker is reused");
assert.deepEqual(sent(workers[0]), [1, 2], "the replacement is sent once, the unchanged file not again");
// #216: a worker that fails to start.
failStart = true;
const slow = run(4, []); // the idle worker takes this one
const failed = await run(5, []);
await slow;
assert.equal(failed.kind, "error");
assert.ok(workers[1].terminated, "the failed worker is terminated");
assert.equal(host.workerStats().running, 0);
// #216, worker side: a rejected start is re-raised where the page's `onerror` sees it.
const dir = mkdtempSync(join(tmpdir(), "ec-worker-"));
copyFileSync(join(here, "../web/worker.js"), join(dir, "worker.js"));
writeFileSync(
join(dir, "effectcraft_web.js"),
`export default async function init() {}
export function workerInit() {}
export async function workerJobInit() { throw new Error("no device"); }
export async function workerFrameInit() {}`,
);
const posted = [];
globalThis.postMessage = (m) => posted.push(m);
// A browser's Worker `error` event only sees thrown errors, not unhandled rejections (Node would
// turn one into an uncaught exception unless it is listened to).
const unhandled = [];
process.on("unhandledRejection", (e) => unhandled.push(e));
const raised = new Promise((resolve) => {
process.once("uncaughtException", resolve);
setTimeout(() => resolve(new Error("nothing was raised")), 1000);
});
await import(pathToFileURL(join(dir, "worker.js")));
self.onmessage({ data: { type: "init", base: "http://localhost/" } });
const err = await raised;
await tick();
// (So a failed assertion below fails the run.)
process.removeAllListeners("unhandledRejection");
process.removeAllListeners("uncaughtException");
assert.deepEqual(unhandled, [], "the failed start is handled, not left as an unhandled rejection");
assert.equal(err.message, "no device");
assert.ok(!posted.some((m) => m.type === "ready"), "no ready after a failed start");
console.log("workers: ok");

View File

@ -20,6 +20,23 @@
}
#loading img { width: 56px; height: 56px; }
#loading .err { color: #ff8a7a; white-space: pre-wrap; max-width: 70ch; pointer-events: auto; }
#device-lost {
position: absolute; inset: 0; display: flex; flex-direction: column; gap: 10px;
align-items: center; justify-content: center; padding: 16px; text-align: center;
background: rgb(18 18 18 / 0.85); color: #c8c8c8; font: 14px/1.4 system-ui, sans-serif;
}
#device-lost strong { color: #e3e3e3; font-size: 16px; }
#device-lost p { margin: 0; max-width: 60ch; }
#device-lost small { color: #8c8c8c; font-size: 12px; }
#device-lost div { display: flex; gap: 8px; }
#device-lost button {
padding: 6px 14px; border: 1px solid #3a3a3a; border-radius: 4px;
background: #232323; color: #e3e3e3; font: inherit; cursor: pointer;
}
#device-lost button:hover { background: #303030; }
#device-lost button:last-child { background: #2d8ceb; border-color: #2d8ceb; }
#device-lost button:last-child:hover { background: #4aa0f5; }
#device-lost button:disabled { opacity: 0.6; cursor: default; }
</style>
</head>
<body>

View File

@ -30,6 +30,9 @@ self.onmessage = (e) => {
else await ec.workerJobInit(!!m.gpu);
self.postMessage({ type: "ready" });
})();
// A failed start is re-raised outside the promise so the page's `onerror` sees it: its job
// fails instead of waiting for "ready" forever (#216).
ready.catch((err) => setTimeout(() => { throw err; }));
return;
}
// A failure (a panic) is re-raised outside the queue so the page's `onerror` sees it, as

View File

@ -14,7 +14,8 @@ effectcraft-engine = { workspace = true }
effectcraft-host = { workspace = true }
effectcraft-ui-egui = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
# `links`: links (Help menu, Discord, Settings) open in the system browser.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }
serde_json = { workspace = true }
rfd = { workspace = true }
cpal = { workspace = true }

View File

@ -0,0 +1,132 @@
//! Desktop integration for the AppImage. Wayland compositors (GNOME, KDE…) show a window's icon
//! from the desktop entry named by its app ID (`ai.storyteller.effectcraft.desktop`). The .deb,
//! .rpm and Flatpak packages install one; an AppImage installs nothing, so its window got the
//! generic icon. Started from an AppImage (`$APPIMAGE`), the app adds a hidden entry
//! (`NoDisplay=true`: no menu item) pointing at it and its icon under the user's data folder
//! (`$XDG_DATA_HOME`, else `~/.local/share`). Nothing is written when another entry already
//! provides the ID (an installed package, or the user's own), and an entry it wrote is kept up to
//! date when the AppImage moves. Failures are only logged: the icon is cosmetic.
use std::path::{Path, PathBuf};
pub const APP_ID: &str = "ai.storyteller.effectcraft";
/// Marks entries this module wrote (it never touches others).
const MARKER: &str = "X-EffectCraft-AppImage=true";
/// A path as one `Exec` argument (desktop entry spec: quoted, with `"`, `` ` ``, `$` and `\`
/// escaped; `%` doubled so it isn't a field code).
pub fn exec_arg(path: &str) -> String {
let mut s = String::with_capacity(path.len() + 2);
s.push('"');
for c in path.chars() {
// The quoting escape is a backslash, which the key file's string syntax escapes again:
// `"` is written `\\"`, a backslash `\\\\`.
match c {
'"' | '`' | '$' => {
s.push_str("\\\\");
s.push(c);
}
'\\' => s.push_str("\\\\\\\\"),
'%' => s.push_str("%%"),
'\n' | '\r' => {}
c => s.push(c),
}
}
s.push('"');
s
}
/// The hidden desktop entry for the AppImage at `appimage`.
pub fn entry(appimage: &str) -> String {
format!(
"[Desktop Entry]\nType=Application\nName=EffectCraft\nComment=Motion graphics and visual effects compositor\nExec={} %F\nIcon={APP_ID}\nTerminal=false\nStartupWMClass={APP_ID}\nNoDisplay=true\n{MARKER}\n",
exec_arg(appimage)
)
}
/// Write the entry and `icon` (a 256 px PNG) under `data_home`, unless one of `data_dirs` (or
/// `data_home` itself, with an entry we didn't write) already has the ID's entry. Returns whether
/// anything was written.
pub fn integrate(appimage: &str, data_home: &Path, data_dirs: &[PathBuf], icon: &[u8]) -> std::io::Result<bool> {
let name = format!("{APP_ID}.desktop");
if data_dirs.iter().any(|d| d.join("applications").join(&name).exists()) {
return Ok(false);
}
let path = data_home.join("applications").join(&name);
let text = entry(appimage);
let icon_path = data_home.join("icons/hicolor/256x256/apps").join(format!("{APP_ID}.png"));
match std::fs::read_to_string(&path) {
Ok(old) if !old.contains(MARKER) => return Ok(false),
Ok(old) if old == text && icon_path.exists() => return Ok(false),
_ => {}
}
write_atomic(&icon_path, icon)?;
write_atomic(&path, text.as_bytes())?;
Ok(true)
}
fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("part");
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, path)
}
/// [`integrate`] for this process: when started from an AppImage, with the XDG data folders of
/// the environment.
pub fn integrate_from_env(icon: &[u8]) {
let Some(appimage) = std::env::var_os("APPIMAGE").map(|a| a.to_string_lossy().into_owned()) else { return };
let env_path = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from);
let Some(data_home) = env_path("XDG_DATA_HOME").or_else(|| env_path("HOME").map(|h| h.join(".local/share"))) else { return };
let dirs = std::env::var("XDG_DATA_DIRS").ok().filter(|v| !v.is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into());
let data_dirs: Vec<PathBuf> = dirs.split(':').filter(|d| !d.is_empty()).map(PathBuf::from).collect();
match integrate(&appimage, &data_home, &data_dirs, icon) {
Ok(true) => log::info!("AppImage: desktop entry and icon written to {}", data_home.display()),
Ok(false) => {}
Err(e) => log::warn!("AppImage: could not write the desktop entry: {e}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exec_arguments_are_quoted_and_escaped() {
assert_eq!(exec_arg("/home/a/EffectCraft.AppImage"), "\"/home/a/EffectCraft.AppImage\"");
assert_eq!(exec_arg("/a b/$x\"y`z\\w%"), "\"/a b/\\\\$x\\\\\"y\\\\`z\\\\\\\\w%%\"");
let e = entry("/opt/EffectCraft.AppImage");
assert!(e.contains("Exec=\"/opt/EffectCraft.AppImage\" %F\n") && e.contains("NoDisplay=true\n") && e.contains("Icon=ai.storyteller.effectcraft\n"));
}
#[test]
fn writes_once_follows_moves_and_leaves_other_entries_alone() {
let root = std::env::temp_dir().join(format!("ec-appimage-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let (home, system) = (root.join("home"), root.join("system"));
let entry_path = home.join("applications/ai.storyteller.effectcraft.desktop");
let icon_path = home.join("icons/hicolor/256x256/apps/ai.storyteller.effectcraft.png");
// First start: entry and icon written; again: nothing to do.
assert!(integrate("/a/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap());
assert_eq!(std::fs::read(&icon_path).unwrap(), b"png");
assert!(std::fs::read_to_string(&entry_path).unwrap().contains("Exec=\"/a/EffectCraft.AppImage\""));
assert!(!integrate("/a/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap());
// The AppImage moved: the entry follows it.
assert!(integrate("/b/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap());
assert!(std::fs::read_to_string(&entry_path).unwrap().contains("Exec=\"/b/EffectCraft.AppImage\""));
// The user's own entry is never replaced.
std::fs::write(&entry_path, "[Desktop Entry]\nName=Mine\n").unwrap();
assert!(!integrate("/c/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap());
assert_eq!(std::fs::read_to_string(&entry_path).unwrap(), "[Desktop Entry]\nName=Mine\n");
// An installed package provides the entry: nothing is written.
let _ = std::fs::remove_dir_all(&home);
std::fs::create_dir_all(system.join("applications")).unwrap();
std::fs::write(system.join("applications/ai.storyteller.effectcraft.desktop"), "x").unwrap();
assert!(!integrate("/a/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap());
assert!(!entry_path.exists());
let _ = std::fs::remove_dir_all(&root);
}
}

View File

@ -76,7 +76,7 @@ fn run<T: SizedSample + FromSample<f32>>(
let frames = out.len() / ch.max(1);
stereo.resize(frames * 2, 0.0);
feed.pull(&mut stereo);
for (f, s) in out.chunks_mut(ch.max(1)).zip(stereo.chunks_exact(2)) {
for (f, s) in out.chunks_mut(ch.max(1)).zip(stereo.as_chunks::<2>().0.iter()) {
for (c, o) in f.iter_mut().enumerate() {
// Mono devices, or left and right mapped to the same channel, get the mix.
let v = if ch == 1 || (c == left && c == right) {

View File

@ -1,13 +1,27 @@
//! Localhost JSON-lines control server (one request per line, one reply per line). Loopback only.
//!
//! The server only ever reads requests: a line that is not a JSON object with a string `method`
//! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets
//! one error reply and the connection is closed, so nothing after it is executed. Lines are
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
//! never reaches the UI thread. See `docs/control-protocol.md`.
use std::io::{BufRead, BufReader, Write};
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use effectcraft_ui_egui::ControlRequest;
use serde_json::{Value, json};
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
/// anything longer is refused and the connection closed.
pub const MAX_LINE: usize = 4 * 1024 * 1024;
/// Connections served at once; further ones get an error line and are closed.
pub const MAX_CONNECTIONS: usize = 16;
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
let listener = match TcpListener::bind(("127.0.0.1", port)) {
@ -18,44 +32,243 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
}
};
eprintln!("effectcraft: control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
let ctx = ctx.clone();
std::thread::spawn(move || serve(stream, tx, ctx));
}
});
std::thread::spawn(move || accept_loop(listener, tx, ctx));
rx
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
for line in BufReader::new(read).lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
let open = Arc::new(AtomicUsize::new(0));
for mut stream in listener.incoming().flatten() {
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
open.fetch_sub(1, Ordering::SeqCst);
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
let (req, rrx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
r
}
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
};
if writeln!(out, "{reply}").is_err() {
break;
}
let tx = tx.clone();
let ctx = ctx.clone();
let open = Arc::clone(&open);
std::thread::spawn(move || {
serve(stream, &tx, &ctx);
open.fetch_sub(1, Ordering::SeqCst);
});
}
}
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
let Ok(read) = stream.try_clone() else { return };
serve_lines(read, stream, |method, params| {
let (req, rrx) = ControlRequest::new(method, params);
tx.send(req).ok()?;
ctx.request_repaint();
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
});
}
/// One parsed line.
enum Line {
Request {
id: Value,
method: String,
params: Value,
},
Blank,
/// Not a request: reply with this error and close the connection.
Reject(String),
}
fn parse_line(bytes: &[u8]) -> Line {
let Ok(text) = std::str::from_utf8(bytes) else {
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
};
let text = text.trim();
if text.is_empty() {
return Line::Blank;
}
let msg = match serde_json::from_str::<Value>(text) {
Ok(v) => v,
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
};
let Some(obj) = msg.as_object() else {
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
};
let Some(method) = obj.get("method").and_then(Value::as_str) else {
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
};
Line::Request { id: obj.get("id").cloned().unwrap_or(Value::Null), method: method.to_string(), params: obj.get("params").cloned().unwrap_or(json!({})) }
}
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
buf.clear();
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
if n == 0 {
return Ok(false);
}
if buf.last() == Some(&b'\n') {
buf.pop();
if buf.last() == Some(&b'\r') {
buf.pop();
}
return Ok(true);
}
if buf.len() > max {
return Err(format!("request line longer than {max} bytes; closing the connection"));
}
// Last line without a newline, then end of stream.
Ok(true)
}
/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is
/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and
/// returns the reply object (the request's `id` is added here).
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
let mut reader = BufReader::new(read);
let mut buf = Vec::new();
loop {
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
Ok(false) => return,
Err(e) => e,
Ok(true) => match parse_line(&buf) {
Line::Blank => continue,
Line::Reject(e) => e,
Line::Request { id, method, params } => {
let Some(mut r) = handle(method, params) else { return };
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
if writeln!(out, "{r}").and_then(|()| out.flush()).is_err() {
return;
}
continue;
}
},
};
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})).and_then(|()| out.flush());
return;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
let mut out = Vec::new();
let mut calls = Vec::new();
serve_lines(input, &mut out, |m, _p| {
calls.push(m);
Some(json!({"ok": true, "result": null}))
});
let replies = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str::<Value>(l).unwrap()).collect();
(replies, calls)
}
#[test]
fn requests_are_answered_in_order_with_ids() {
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
assert_eq!(calls, ["a", "b", "c"]);
assert_eq!(r.len(), 3);
assert_eq!(r[0]["id"], 1);
assert_eq!(r[1]["id"], 2);
assert_eq!(r[2]["id"], Value::Null);
}
#[test]
fn http_request_closes_before_the_body_runs() {
// A browser's cross-origin `fetch` POST: the request line is rejected and the
// connection closed, so the JSON body is never executed.
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:9877\r\nOrigin: https://attacker.example\r\nContent-Type: text/plain\r\n\r\n\
{\"id\":7,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.new\",\"params\":{}}}\n";
let (r, calls) = run(http);
assert!(calls.is_empty(), "nothing may run: {calls:?}");
assert_eq!(r.len(), 1);
assert_eq!(r[0]["ok"], false);
assert!(r[0]["error"].as_str().unwrap().contains("closing the connection"));
}
#[test]
fn non_request_json_closes_the_connection() {
for junk in [
&b"[1,2]\n{\"method\":\"a\"}\n"[..],
b"{\"id\":1}\n{\"method\":\"a\"}\n",
b"{\"method\":5}\n{\"method\":\"a\"}\n",
b"\xff\xfe\n{\"method\":\"a\"}\n",
] {
let (r, calls) = run(junk);
assert!(calls.is_empty(), "{calls:?}");
assert_eq!(r.len(), 1);
assert_eq!(r[0]["ok"], false);
}
}
#[test]
fn a_request_before_junk_still_runs() {
let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n");
assert_eq!(calls, ["a"]);
assert_eq!(r.len(), 2);
assert_eq!(r[1]["ok"], false);
}
#[test]
fn overlong_line_is_refused() {
// One byte over the limit, then a valid request: neither runs.
let mut input = vec![b'x'; MAX_LINE + 1];
input.extend_from_slice(b"\n{\"method\":\"a\"}\n");
let (r, calls) = run(&input);
assert!(calls.is_empty(), "{calls:?}");
assert_eq!(r.len(), 1);
assert!(r[0]["error"].as_str().unwrap().contains("longer than"));
}
#[test]
fn line_at_the_limit_is_accepted() {
let mut buf = Vec::new();
let line = vec![b'a'; 10];
let mut input = line.clone();
input.push(b'\n');
assert_eq!(read_bounded_line(&mut &input[..], &mut buf, 10), Ok(true));
assert_eq!(buf, line);
assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err());
}
#[test]
fn tcp_connection_is_closed_after_junk() {
// End to end over a real loopback socket (the transport the app uses).
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
let read = s.try_clone().unwrap();
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
});
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
let mut text = String::new();
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
server.join().unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "{text}");
assert!(lines[0].contains("\"id\":7"));
assert!(lines[1].contains("closing the connection"));
}
#[test]
fn extra_connections_are_refused() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, _rx) = channel::<ControlRequest>();
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
// Idle connections are accepted in order and held open.
let held: Vec<TcpStream> = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect();
let mut extra = TcpStream::connect(addr).unwrap();
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let mut line = String::new();
BufReader::new(&mut extra).read_line(&mut line).unwrap();
assert!(line.contains("too many control connections"), "{line}");
drop(held);
}
}

View File

@ -0,0 +1,151 @@
//! Settings ▸ Startup & Repair ▸ Window Graphics, and the launch guard behind it.
//!
//! Some graphics drivers crash EffectCraft as its window's device is created (an access
//! violation in an older Intel UHD driver with DirectX 12 / Vulkan, #243). A crash in a driver
//! can't be caught, so each launch leaves a marker in the settings folder until its window has
//! drawn a frame. A launch that finds the previous one's marker switches Window Graphics to
//! OpenGL, which those drivers run, and says so once the window is up. `WGPU_BACKEND` (wgpu's
//! own override) still wins over both. The marker is locked while its launch runs, so a second
//! copy opened meanwhile isn't taken for a crash; and a window OpenGL can't open either puts
//! Window Graphics back to Automatic ([`opengl_failed`]) rather than failing every launch.
use std::fs::File;
use std::path::{Path, PathBuf};
use effectcraft_engine::prefs::{PREFS_FILE, Prefs};
use eframe::wgpu::Backends;
/// Left in the settings folder from a launch until its window draws.
const MARKER: &str = "launch-pending";
/// This launch's window graphics.
#[derive(Debug, Default)]
pub struct Launch {
/// The graphics APIs the window may use (`None`: eframe's default).
pub backends: Option<Backends>,
/// Switched to OpenGL because the previous launch never drew: the message to show.
pub notice: Option<String>,
marker: Option<PathBuf>,
/// Holds the marker's lock until the window draws (or the process ends).
lock: Option<File>,
}
impl Launch {
/// Read Window Graphics in the settings folder `dir`, switch it to OpenGL when the previous
/// launch never drew (where OpenGL exists: not macOS), and leave this launch's marker.
/// `env_override`: `WGPU_BACKEND` is set.
pub fn begin(dir: Option<&Path>, env_override: bool) -> Launch {
let Some(dir) = dir.filter(|_| !env_override) else { return Launch::default() };
let prefs_path = dir.join(PREFS_FILE);
let mut prefs = std::fs::read_to_string(&prefs_path).map(|t| Prefs::from_json(&t)).unwrap_or_default();
let marker = dir.join(MARKER);
// Locked by another launch that is still opening its window: not a crash.
let held = File::open(&marker).is_ok_and(|f| matches!(f.try_lock(), Err(std::fs::TryLockError::WouldBlock)));
let mut notice = None;
if marker.exists() && !held && prefs.startup.window_graphics == "auto" && cfg!(not(target_os = "macos")) {
prefs.startup.window_graphics = "gl".into();
match std::fs::create_dir_all(dir).and_then(|()| std::fs::write(&prefs_path, prefs.to_json())) {
Ok(()) => log::warn!("the last launch never drew its window: switching Window Graphics to OpenGL"),
Err(e) => log::warn!("the last launch never drew its window; saving Window Graphics failed: {e}"),
}
notice = Some(
"EffectCraft's last launch stopped before its window opened, so it now draws with OpenGL \
(Settings ▸ Startup & Repair ▸ Window Graphics). Updating the graphics driver may let it use \
Automatic again."
.to_string(),
);
}
let backends = (prefs.startup.window_graphics == "gl").then_some(Backends::GL);
let (marker, lock) = match std::fs::create_dir_all(dir).and_then(|()| File::create(&marker)) {
// Where locks aren't available the guard works as before.
Ok(f) => (Some(marker), f.try_lock().is_ok().then_some(f)),
Err(e) => {
log::warn!("launch marker: {e}");
(None, None)
}
};
Launch { backends, notice, marker, lock }
}
/// The window drew its first frame: the launch made it.
pub fn drawn(&mut self) {
self.lock = None;
if let Some(m) = self.marker.take()
&& let Err(e) = std::fs::remove_file(&m)
{
log::warn!("launch marker: {e}");
}
}
}
/// The window couldn't open with OpenGL (an error, not a crash): back to Automatic, and no marker.
pub fn opengl_failed(dir: Option<&Path>) {
let Some(dir) = dir else { return };
let prefs_path = dir.join(PREFS_FILE);
let mut prefs = std::fs::read_to_string(&prefs_path).map(|t| Prefs::from_json(&t)).unwrap_or_default();
prefs.startup.window_graphics = "auto".into();
if let Err(e) = std::fs::write(&prefs_path, prefs.to_json()) {
log::warn!("saving Window Graphics failed: {e}");
}
let _ = std::fs::remove_file(dir.join(MARKER));
}
#[cfg(test)]
mod tests {
use super::*;
fn window_graphics(dir: &Path) -> String {
Prefs::from_json(&std::fs::read_to_string(dir.join(PREFS_FILE)).unwrap_or_default()).startup.window_graphics
}
/// A launch that never drew switches the next one to OpenGL, which then stays (#243).
#[test]
fn a_launch_that_never_drew_switches_the_next_to_opengl() {
let dir = std::env::temp_dir().join(format!("ec-launch-guard-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
// A first launch that draws: nothing changes.
let mut l = Launch::begin(Some(&dir), false);
assert_eq!((l.backends, l.notice.is_some()), (None, false));
assert!(dir.join(MARKER).exists());
l.drawn();
assert!(!dir.join(MARKER).exists());
// A launch that crashes before drawing (it never calls `drawn`)…
drop(Launch::begin(Some(&dir), false));
// …makes the next one use OpenGL and say so, once.
let mut l = Launch::begin(Some(&dir), false);
if cfg!(target_os = "macos") {
assert_eq!((l.backends, l.notice.is_some()), (None, false), "no OpenGL on macOS");
} else {
assert_eq!(l.backends, Some(Backends::GL));
assert!(l.notice.is_some());
assert_eq!(window_graphics(&dir), "gl", "saved in Settings");
l.drawn();
let l = Launch::begin(Some(&dir), false);
assert_eq!((l.backends, l.notice.is_some()), (Some(Backends::GL), false), "it stays, without the notice");
}
// `WGPU_BACKEND` wins, and leaves no marker.
let _ = std::fs::remove_file(dir.join(MARKER));
assert_eq!(Launch::begin(Some(&dir), true).backends, None);
assert!(!dir.join(MARKER).exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// A second copy opened while the first is still opening isn't a crash; OpenGL that can't
/// open a window either goes back to Automatic instead of failing every launch.
#[test]
fn a_second_copy_or_failed_opengl_doesnt_strand_window_graphics() {
let dir = std::env::temp_dir().join(format!("ec-launch-guard-2-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let _first = Launch::begin(Some(&dir), false);
assert_eq!(Launch::begin(Some(&dir), false).backends, None);
assert_eq!(window_graphics(&dir), "auto");
drop(_first);
if cfg!(not(target_os = "macos")) {
assert_eq!(Launch::begin(Some(&dir), false).backends, Some(Backends::GL), "a real crash still switches");
opengl_failed(Some(&dir));
assert_eq!(window_graphics(&dir), "auto");
assert_eq!(Launch::begin(Some(&dir), false).backends, None);
}
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -2,13 +2,20 @@
//!
//! Usage: `effectcraft [--control <port>] [--demo|--empty|--home] [project.ecproj | media files…]`
//!
//! Without a project the app starts with an empty Untitled Project, as After Effects does;
//! `--demo` opens the demo project instead (also Help ▸ Open Demo Project).
//!
//! `--control <port>` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
//! see `effectcraft_ui_egui::control` for the methods.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
// Built everywhere so its tests run on every platform; only Linux AppImages use it.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
mod appimage;
mod audio_out;
mod control_server;
mod launch_guard;
#[cfg(target_os = "macos")]
mod native_menu;
@ -17,36 +24,65 @@ use serde_json::json;
/// The app icon for the window, taskbar and Dock (assets/app-icon, regenerated by
/// `packaging/icons.sh`). macOS gets the padded variant so it sits on Apple's icon grid in the Dock.
/// The 256 px app icon (Windows, Linux; the AppImage's desktop integration installs it too).
#[cfg(not(target_os = "macos"))]
const ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png");
fn app_icon() -> egui::IconData {
#[cfg(target_os = "macos")]
let png: &[u8] = include_bytes!("../../../assets/app-icon/effectcraft-macos-512.png");
#[cfg(not(target_os = "macos"))]
let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png");
let png: &[u8] = ICON_PNG;
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
}
/// The command line.
#[derive(Debug, Default, PartialEq)]
struct Args {
control_port: Option<u16>,
files: Vec<String>,
/// Open the demo project when no project is given (`--demo`).
demo: bool,
/// Show or skip the Home screen (`--home`), else the Startup preference decides.
home: Option<bool>,
}
/// Parse the arguments after the program name; `None` for `--version`. `env_port` is
/// `EFFECTCRAFT_CONTROL_PORT`.
fn parse_args(args: impl IntoIterator<Item = String>, env_port: Option<String>) -> Option<Args> {
let mut out = Args { control_port: env_port.and_then(|p| p.parse().ok()), ..Default::default() };
let mut args = args.into_iter();
while let Some(a) = args.next() {
match a.as_str() {
"--control" => out.control_port = args.next().and_then(|p| p.parse().ok()),
"--demo" => out.demo = true,
"--empty" => out.demo = false,
"--home" => out.home = Some(true),
"--version" => return None,
_ => out.files.push(a),
}
}
Some(out)
}
fn main() -> eframe::Result {
// Help ▸ Enable Logging writes through this logger; warnings feed the compatibility report.
effectcraft_engine::logging::install();
effectcraft_engine::logging::install_panic_hook();
let mut control_port: Option<u16> = std::env::var("EFFECTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut demo = true;
let mut home: Option<bool> = None;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--demo" => demo = true,
"--empty" => demo = false,
"--home" => home = Some(true),
"--version" => {
println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
_ => files.push(a),
}
}
let Some(Args { control_port, files, demo, home }) = parse_args(std::env::args().skip(1), std::env::var("EFFECTCRAFT_CONTROL_PORT").ok()) else {
println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
};
// The font menus list the installed fonts: read their names while the window opens.
effectcraft_engine::text::fonts::scan_system_in_background();
// Wayland shows the window's icon from its desktop entry: an AppImage brings its own.
#[cfg(target_os = "linux")]
appimage::integrate_from_env(ICON_PNG);
// Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch's
// window never drew (a crashing graphics driver, #243).
let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), std::env::var_os("WGPU_BACKEND").is_some());
let on_gl = launch.backends == Some(eframe::wgpu::Backends::GL);
let notice = launch.notice.take();
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("EffectCraft")
@ -57,14 +93,39 @@ fn main() -> eframe::Result {
.with_active(control_port.is_none())
.with_icon(app_icon())
// Wayland matches the window to `ai.storyteller.effectcraft.desktop` by this ID.
.with_app_id("ai.storyteller.effectcraft"),
.with_app_id(appimage::APP_ID),
event_loop_builder: agent_event_loop(control_port.is_some()),
wgpu_options: wgpu_options(launch.backends),
..Default::default()
};
eframe::run_native(
// Start-up milestones: with `RUST_LOG=info` they go to stderr, so a window that never
// appears shows how far start-up got (#234).
log::info!("effectcraft {}: opening the window", env!("CARGO_PKG_VERSION"));
let result = eframe::run_native(
"EffectCraft",
options,
Box::new(move |cc| {
match &cc.wgpu_render_state {
Some(rs) => log::info!("graphics: {:?}", rs.adapter.get_info()),
None => log::info!("graphics: no wgpu device"),
}
// This executable owns the shared device's handlers. Install them before
// EffectCraft's compositor pipelines are built, without changing handlers from
// inside Gpu::new. eframe has already constructed its presentation renderer here.
let gpu_failures = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&cc.egui_ctx);
if let Some(rs) = &cc.wgpu_render_state {
let errors = gpu_failures.clone();
rs.device.on_uncaptured_error(std::sync::Arc::new(move |error| {
let message = format!("uncaptured GPU error: {error}");
if errors.report(&message, false) {
log::error!("{message}");
}
}));
let lost = gpu_failures.clone();
rs.device.set_device_lost_callback(move |reason, message| {
lost.report(&format!("GPU device lost ({reason:?}): {message}"), true);
});
}
let mut session = effectcraft_host::session();
// Lazy open and non-blocking auto-save: footage is checked and auto-saves are
// written on background threads (M13.14).
@ -72,7 +133,7 @@ fn main() -> eframe::Result {
session.autosave.background = true;
// Settings, shortcut presets and the crash-recovery sentinel live in the platform
// config directory. Agent-driven runs (`--control`) skip crash recovery.
if let Some(dir) = config_dir() {
if let Some(dir) = effectcraft_host::config_dir() {
// WebAssembly effect plug-ins in <config>/Plug-ins load before the menus are built.
let plugins = dir.join("Plug-ins");
if plugins.is_dir() {
@ -98,7 +159,11 @@ fn main() -> eframe::Result {
}
let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none());
let mut app = EffectcraftApp::new(session);
app.set_gpu_failure_bridge(gpu_failures);
app.ui.start_screen = show_home;
if let Some(n) = notice {
app.ui.status = n;
}
if let Some(r) = recovery {
app.offer_recovery(r);
}
@ -115,7 +180,15 @@ fn main() -> eframe::Result {
app.hooks.audio_devices = Some(Box::new(audio_out::devices));
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
app.hooks.pick_save_file = Some(Box::new(|name: &str, ext: &str| {
rfd::FileDialog::new().add_filter(ext, &[ext]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
// A default with a folder that exists opens the dialog there.
let path = std::path::Path::new(name);
// (No filter for a name without an extension, such as a folder to create.)
let mut d = if ext.is_empty() { rfd::FileDialog::new() } else { rfd::FileDialog::new().add_filter(ext, &[ext]) };
if let Some(dir) = path.parent().filter(|d| d.is_dir()) {
d = d.set_directory(dir);
}
let file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
d.set_file_name(file).save_file().map(|p| p.to_string_lossy().to_string())
}));
#[cfg(target_os = "macos")]
{
@ -127,13 +200,70 @@ fn main() -> eframe::Result {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
log::info!("app created; the window shows after its first frame");
Ok(Box::new(Desktop {
#[cfg(target_os = "macos")]
menu: native_menu::NativeBar::new(&cc.egui_ctx),
app,
launch,
drawn: false,
}))
}),
)
);
match &result {
Ok(()) => log::info!("the window closed"),
Err(e) => {
if on_gl {
launch_guard::opengl_failed(effectcraft_host::config_dir().as_deref());
}
startup_failed(e, !on_gl && cfg!(not(target_os = "macos")));
}
}
result
}
/// The window's wgpu device asks for the adapter's own limits (textures up to 16384 px)
/// instead of eframe's fixed set: an older GPU that can't meet those (Intel HD Graphics 5500,
/// #198) still opens the app, and the compositor then checks what it needs and falls back to
/// the CPU when the device can't run it. `backends`: Window Graphics' choice, when it makes one.
fn wgpu_options(backends: Option<eframe::wgpu::Backends>) -> eframe::WgpuConfiguration {
use eframe::egui_wgpu::WgpuSetup;
let mut config = eframe::WgpuConfiguration::default();
if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup {
if let Some(b) = backends {
setup.instance_descriptor.backends = b;
}
setup.device_descriptor = std::sync::Arc::new(|adapter| eframe::wgpu::DeviceDescriptor {
label: Some("EffectCraft device"),
required_limits: device_limits(adapter.limits()),
..Default::default()
});
}
config
}
/// The limits to request from an adapter that offers `adapter`: all of them, with textures
/// capped at 16384 px like the headless renderer's device.
fn device_limits(adapter: eframe::wgpu::Limits) -> eframe::wgpu::Limits {
eframe::wgpu::Limits { max_texture_dimension_2d: adapter.max_texture_dimension_2d.min(16384), ..adapter }
}
/// The window couldn't open (no usable graphics adapter or device): say so instead of quitting
/// silently, since release builds on Windows have no console. `gl_next`: the launch guard has
/// the next launch try OpenGL.
fn startup_failed(e: &eframe::Error, gl_next: bool) {
let next = if gl_next { "The next launch will try OpenGL instead. " } else { "" };
let message = format!(
"EffectCraft couldn't start its window: {e}\n\n{next}Updating the graphics driver often helps. If it keeps happening, please report it at https://github.com/storytold/effectcraft/issues with this message."
);
log::error!("{message}");
eprintln!("effectcraft: {message}");
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title("EffectCraft")
.set_description(message)
.set_buttons(rfd::MessageButtons::Ok)
.show();
}
/// The desktop app: the egui app plus, on macOS, the system menu bar.
@ -141,6 +271,9 @@ struct Desktop {
app: EffectcraftApp,
#[cfg(target_os = "macos")]
menu: native_menu::NativeBar,
launch: launch_guard::Launch,
/// The first frame was drawn (logged once).
drawn: bool,
}
impl eframe::App for Desktop {
@ -152,6 +285,11 @@ impl eframe::App for Desktop {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.app.ui(ui, frame);
if !self.drawn {
self.drawn = true;
self.launch.drawn();
log::info!("first frame drawn");
}
}
fn on_exit(&mut self) {
@ -163,24 +301,6 @@ impl eframe::App for Desktop {
}
}
/// The platform config directory for EffectCraft (`EFFECTCRAFT_CONFIG_DIR` overrides):
/// `~/Library/Application Support/EffectCraft` (macOS), `%APPDATA%\EffectCraft` (Windows),
/// `$XDG_CONFIG_HOME/effectcraft` or `~/.config/effectcraft` (Linux and others).
fn config_dir() -> Option<std::path::PathBuf> {
use std::path::PathBuf;
if let Some(d) = std::env::var_os("EFFECTCRAFT_CONFIG_DIR") {
return Some(PathBuf::from(d));
}
let home = || std::env::var_os("HOME").map(PathBuf::from);
if cfg!(target_os = "macos") {
return home().map(|h| h.join("Library/Application Support/EffectCraft"));
}
if cfg!(target_os = "windows") {
return std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("EffectCraft"));
}
std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| home().map(|h| h.join(".config"))).map(|c| c.join("effectcraft"))
}
/// Driven by an agent (`--control`): don't activate the app on launch, so the user's keyboard
/// focus stays where it is (macOS; winit activates ignoring other apps by default).
fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {
@ -208,3 +328,37 @@ fn disable_app_nap() {
std::mem::forget(info.beginActivityWithOptions_reason(opts, &reason));
}
}
#[cfg(test)]
mod tests {
use super::{Args, parse_args};
fn parse(args: &[&str]) -> Option<Args> {
parse_args(args.iter().map(|a| a.to_string()), None)
}
/// The window's device never asks for more than the adapter offers (#198).
#[test]
fn device_limits_fit_the_adapter() {
use eframe::wgpu::Limits;
for adapter in [Limits::downlevel_defaults(), Limits::downlevel_webgl2_defaults(), Limits::default()] {
let l = super::device_limits(adapter.clone());
assert!(l.check_limits(&adapter), "{l:?}");
assert_eq!(l.max_storage_buffers_per_shader_stage, adapter.max_storage_buffers_per_shader_stage);
}
let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() };
assert_eq!(super::device_limits(big).max_texture_dimension_2d, 16384);
}
/// Launching without a project opens an empty project, not the demo (#204).
#[test]
fn no_arguments_start_an_empty_project() {
assert_eq!(parse(&[]), Some(Args::default()));
assert!(parse(&["--demo"]).is_some_and(|a| a.demo));
assert!(parse(&["--demo", "--empty"]).is_some_and(|a| !a.demo));
let a = parse(&["--control", "9877", "--home", "a.ecproj"]).unwrap_or_default();
assert_eq!((a.control_port, a.home, a.files), (Some(9877), Some(true), vec!["a.ecproj".to_string()]));
assert_eq!(parse(&["--version"]), None);
assert_eq!(parse_args(Vec::new(), Some("9000".into())).map(|a| a.control_port), Some(Some(9000)));
}
}

View File

@ -94,8 +94,8 @@ impl NativeBar {
let sub = Submenu::new(label, true);
self.fill(&sub, children);
match label.as_str() {
"Window" => sub.set_as_windows_menu_for_nsapp(),
"Help" => sub.set_as_help_menu_for_nsapp(),
"Window" | "ウィンドウ" => sub.set_as_windows_menu_for_nsapp(),
"Help" | "ヘルプ" => sub.set_as_help_menu_for_nsapp(),
_ => {}
}
if let Err(e) = menu.append(&sub) {

View File

@ -0,0 +1,179 @@
{
"schema": 1,
"repo": "storytold/effectcraft",
"branch": "main",
"head": "e38a92e792c07f1c57c5dcfcab37d91932b9e2b8",
"head_date": "2026-10-07T22:33:04Z",
"commits": 493,
"unattributed_commits": 0,
"contributors": [
{
"login": "echelon",
"people_entry": true,
"github_name": null,
"prs": 32,
"commits": 349,
"lines_added": 326318,
"lines_deleted": 14226,
"binary_added": 96,
"binary_deleted": 0,
"first_commit": "2026-10-01T16:48:39Z",
"last_commit": "2026-10-07T19:37:26Z"
},
{
"login": "storybored379",
"people_entry": false,
"github_name": null,
"prs": 6,
"commits": 6,
"lines_added": 102,
"lines_deleted": 6,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-03T16:31:41Z",
"last_commit": "2026-10-06T16:58:59Z"
},
{
"login": "bflatastic",
"people_entry": false,
"github_name": null,
"prs": 63,
"commits": 110,
"lines_added": 17161,
"lines_deleted": 2309,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-03T17:19:22Z",
"last_commit": "2026-10-07T13:29:29Z"
},
{
"login": "jonassneu",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 3,
"lines_added": 463,
"lines_deleted": 19,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-05T16:01:41Z",
"last_commit": "2026-10-06T23:43:57Z"
},
{
"login": "fortesnow",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 258,
"lines_deleted": 7,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T01:36:48Z",
"last_commit": "2026-10-06T01:36:48Z"
},
{
"login": "dexterlabs1",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 4,
"lines_added": 3693,
"lines_deleted": 691,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T05:00:31Z",
"last_commit": "2026-10-06T23:54:13Z"
},
{
"login": "midasdf",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 868,
"lines_deleted": 9,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T07:22:23Z",
"last_commit": "2026-10-06T07:22:23Z"
},
{
"login": "EthanSuquet",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 1,
"lines_deleted": 538,
"binary_added": 0,
"binary_deleted": 67,
"first_commit": "2026-10-06T19:02:20Z",
"last_commit": "2026-10-06T19:02:20Z"
},
{
"login": "coygeek",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 4,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T02:36:22Z",
"last_commit": "2026-10-07T02:36:22Z"
},
{
"login": "Luskish",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 252,
"lines_deleted": 32,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T02:51:59Z",
"last_commit": "2026-10-07T02:51:59Z"
}
],
"models": [
{
"id": "anthropic/claude-opus/5.5",
"company": "Anthropic",
"model": "Claude Opus",
"version": "5.5",
"commits": 468,
"lines_added": 341908,
"lines_deleted": 17067
},
{
"id": "anthropic/claude",
"company": "Anthropic",
"model": "Claude",
"version": "",
"commits": 16,
"lines_added": 1577,
"lines_deleted": 146
},
{
"id": "anthropic/claude-opus/4.8",
"company": "Anthropic",
"model": "Claude Opus",
"version": "4.8",
"commits": 1,
"lines_added": 1632,
"lines_deleted": 3
},
{
"id": "openai/codex",
"company": "OpenAI",
"model": "Codex",
"version": "",
"commits": 1,
"lines_added": 14,
"lines_deleted": 1
}
]
}

31
contributors/people.toml Normal file
View File

@ -0,0 +1,31 @@
# Contributor names for the About window (craftrules standards/contributors.md).
#
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
# maintainer may add a person only when that person asked, recording where in consent_source.
#
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
#
# Or copy this block, replacing your-github-username (every field is optional):
#
# [people.your-github-username]
# real_name = "Your Name" # shown when the viewer picks "Real name"
# display_name = "Nickname" # shown when the viewer picks "Display name"
# sync_github_name = true # or: use the name on your public GitHub profile as display name
#
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.echelon]
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
sync_github_name = false
[people.jonassneu]
sync_github_name = true

View File

@ -0,0 +1,136 @@
//! Durable MCP checkpoints, isolated from the desktop and other headless sessions.
use std::path::{Path, PathBuf};
use effectcraft_engine::config::{FileOps, StdFiles};
use effectcraft_engine::{Session, autosave, prefs::Prefs};
use serde_json::{Value, json};
pub(crate) struct AutoSave {
folder: PathBuf,
latest: Option<String>,
error: Option<String>,
previous: Vec<Value>,
manifest: Option<Value>,
}
impl AutoSave {
pub fn new(root: &Path, prefs: &Prefs) -> std::io::Result<Self> {
let base = if prefs.auto_save.location == "custom" && !prefs.auto_save.folder.trim().is_empty() {
PathBuf::from(prefs.auto_save.folder.trim()).join("MCP")
} else {
root.join(autosave::AUTOSAVE_FOLDER).join("MCP")
};
if base.to_str().is_none() {
return Err(std::io::Error::other("the MCP auto-save folder must be valid UTF-8; set EFFECTCRAFT_CONFIG_DIR to a UTF-8 path"));
}
std::fs::create_dir_all(&base)?;
let previous = previous_sessions(&base);
let folder = unique_folder(&base)?;
Ok(Self { folder, latest: None, error: None, previous, manifest: None })
}
pub fn folder(&self) -> &Path {
&self.folder
}
pub fn info(&self) -> Value {
json!({
"enabled": true, "folder": self.folder.to_string_lossy(), "latestPath": self.latest,
"error": self.error, "previousSessions": self.previous,
"recovery": "Recover with open_project {path: latestPath}, then save_project {path: a normal project file}. Previous sessions may still be running; they are never opened automatically.",
})
}
pub fn checkpoint(&mut self, s: &mut Session, ended: bool) -> std::io::Result<()> {
let result = self.write(s, ended);
if let Err(e) = &result {
self.error = Some(format!("MCP auto-save in {} failed: {e}", self.folder.display()));
}
result.map_err(|e| std::io::Error::other(format!("MCP auto-save in {} failed: {e}", self.folder.display())))
}
fn write(&mut self, s: &mut Session, ended: bool) -> std::io::Result<()> {
if s.is_dirty() && s.autosave.saved_revision != Some(s.revision) {
s.autosave.background = false;
s.autosave_now().map_err(std::io::Error::other)?;
}
s.autosave_wait().map_err(std::io::Error::other)?;
// Explicit file.autoSave and save-on-render-start use the same override. Keep their
// successful latest path too, even when the revision was already checkpointed.
if let Some(path) = &s.autosave.last_path {
self.latest = Some(path.clone());
}
let record = json!({
"pid": autosave::pid(), "project": s.path, "autosave": self.latest,
"dirty": s.is_dirty(), "revision": s.revision, "ended": ended,
});
// Queries don't rewrite the same manifest or rotate project slots.
if self.manifest.as_ref() != Some(&record) && self.latest.is_some() {
StdFiles.write(&self.folder.join("session.json"), record.to_string().as_bytes())?;
self.manifest = Some(record);
}
self.error = None;
if ended && self.latest.is_none() {
let _ = std::fs::remove_dir(&self.folder);
}
Ok(())
}
}
fn unique_folder(base: &Path) -> std::io::Result<PathBuf> {
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(0);
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
for _ in 0..100 {
let folder = base.join(format!("{}-{stamp}-{}", autosave::pid(), NEXT.fetch_add(1, Ordering::Relaxed)));
match std::fs::create_dir(&folder) {
Ok(()) => return Ok(folder),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(std::io::Error::other("cannot reserve a unique MCP auto-save folder"))
}
fn previous_sessions(base: &Path) -> Vec<Value> {
// Scan directory metadata with bounded memory, then read only the newest 200 small
// manifests. Limiting read_dir before sorting could hide a newly killed session once
// older folders accumulated. Recovery is explicit, so running sessions are safe to list.
let Ok(entries) = std::fs::read_dir(base) else { return vec![] };
let mut records = std::collections::BTreeSet::new();
for entry in entries.flatten() {
let path = entry.path().join("session.json");
let Ok(meta) = path.metadata() else { continue };
if !meta.is_file() || meta.len() > 64 * 1024 {
continue;
}
records.insert((meta.modified().ok(), path));
if records.len() > 200 {
records.pop_first();
}
}
records
.into_iter()
.rev()
.filter_map(|(_, path)| {
use std::io::Read;
let mut bytes = Vec::new();
std::fs::File::open(path).ok()?.take(64 * 1024 + 1).read_to_end(&mut bytes).ok()?;
if bytes.len() > 64 * 1024 {
return None;
}
let record: Value = serde_json::from_slice(&bytes).ok()?;
let saved = record.get("autosave")?.as_str()?;
if saved.len() > 4096 || record.get("dirty") != Some(&Value::Bool(true)) || !Path::new(saved).is_file() {
return None;
}
let project = record.get("project").and_then(Value::as_str).filter(|p| p.len() <= 4096);
Some(json!({"autosave": saved, "project": project, "dirty": true,
"ended": record.get("ended").and_then(Value::as_bool).unwrap_or(false),
"pid": record.get("pid").and_then(Value::as_u64),
}))
})
.take(5)
.collect()
}

View File

@ -15,6 +15,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod autosave;
pub mod backend;
pub mod base64;
pub mod bridge;
@ -60,8 +61,8 @@ pub fn encode_png(w: u32, h: u32, rgba: Vec<u8>, max_side: u32) -> Result<Vec<u8
let mut out = Vec::new();
let enc = PngEncoder::new_with_quality(&mut out, CompressionType::Fast, FilterType::Adaptive);
let raw = img.into_raw();
let r = if raw.chunks_exact(4).all(|p| p[3] == 255) {
let rgb: Vec<u8> = raw.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect();
let r = if raw.as_chunks::<4>().0.iter().all(|p| p[3] == 255) {
let rgb: Vec<u8> = raw.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
enc.write_image(&rgb, w, h, image::ExtendedColorType::Rgb8)
} else {
enc.write_image(&raw, w, h, image::ExtendedColorType::Rgba8)

View File

@ -2,9 +2,11 @@
//! `initialize`, `ping`, `tools/list` and `tools/call`; notifications are accepted and ignored.
use std::io::{BufRead, Write};
use std::path::Path;
use serde_json::{Value, json};
use crate::autosave::AutoSave;
use crate::tools::{self, Reply};
use crate::{Backend, Error, base64};
@ -21,11 +23,34 @@ const INVALID_PARAMS: i64 = -32602;
pub struct McpServer {
backend: Backend,
autosave: Option<AutoSave>,
}
impl McpServer {
pub fn new(backend: Backend) -> Self {
Self { backend }
Self { backend, autosave: None }
}
/// Opt in to durable headless checkpoints in a separate folder per server.
/// `root` is the platform config directory; settings are read by the caller.
pub fn with_autosave(mut self, root: &Path) -> crate::Result<Self> {
let s = self.backend.session().ok_or_else(|| crate::Error::BadArgs("--autosave is for headless MCP; the bridged app owns its auto-saves".into()))?;
s.autosave.background = false;
let autosave = AutoSave::new(root, &s.prefs).map_err(|e| crate::Error::Other(format!("MCP auto-save: {e}")))?;
s.autosave_folder_override = Some(autosave.folder().to_path_buf());
self.autosave = Some(autosave);
Ok(self)
}
fn autosave_info(&self) -> Value {
self.autosave.as_ref().map(AutoSave::info).unwrap_or_else(|| json!({"enabled": false}))
}
fn checkpoint(&mut self, finish: bool) -> std::io::Result<()> {
match (&mut self.autosave, self.backend.session()) {
(Some(a), Some(s)) => a.checkpoint(s, finish),
_ => Ok(()),
}
}
pub fn backend(&mut self) -> &mut Backend {
@ -73,14 +98,21 @@ impl McpServer {
/// Serve until EOF on `input`.
pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
for line in input.lines() {
if let Some(reply) = self.handle_line(&line?) {
output.write_all(reply.as_bytes())?;
output.write_all(b"\n")?;
output.flush()?;
let served = (|| {
for line in input.lines() {
if let Some(reply) = self.handle_line(&line?) {
output.write_all(reply.as_bytes())?;
output.write_all(b"\n")?;
output.flush()?;
}
}
Ok(())
})();
match (served, self.checkpoint(true)) {
(r, Ok(())) => r,
(Ok(()), Err(e)) => Err(e),
(Err(transport), Err(save)) => Err(std::io::Error::other(format!("{transport}; {save}"))),
}
Ok(())
}
/// Serve on stdin/stdout (the MCP stdio transport).
@ -94,11 +126,17 @@ impl McpServer {
let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or("");
let version = PROTOCOL_VERSIONS.iter().find(|v| **v == asked).copied().unwrap_or(PROTOCOL_VERSIONS[0]);
let mode = if self.backend.is_bridge() { "bridge" } else { "headless" };
let instructions = if self.autosave.is_some() {
format!("{INSTRUCTIONS} Headless auto-save and recovery: {}", self.autosave_info())
} else {
INSTRUCTIONS.to_string()
};
Ok(json!({
"protocolVersion": version,
"capabilities": {"tools": {"listChanged": false}},
"serverInfo": {"name": "effectcraft", "title": format!("EffectCraft ({mode})"), "version": env!("CARGO_PKG_VERSION")},
"instructions": INSTRUCTIONS,
"instructions": instructions,
"_meta": {"effectcraftAutoSave": self.autosave_info()},
}))
}
"ping" => Ok(json!({})),
@ -113,7 +151,35 @@ impl McpServer {
return Err((INVALID_PARAMS, format!("unknown tool `{name}`{hint}")));
}
let args = params.get("arguments").cloned().unwrap_or(json!({}));
Ok(call_result(tools::run(&mut self.backend, name, &args)))
let mut result = call_result(tools::run(&mut self.backend, name, &args));
// Also checkpoint a partially successful tool that returned an error. The
// project is durable before the client receives its reply (even if it kills
// the process immediately afterwards).
if let Err(e) = self.checkpoint(false) {
let _ = writeln!(std::io::stderr(), "{e}");
if let Some(content) = result.get_mut("content").and_then(Value::as_array_mut) {
content.push(json!({"type": "text", "text": format!("Warning: {e}. Use save_project with a writable path before disconnecting.")}));
}
}
if self.autosave.is_some()
&& let Some(obj) = result.as_object_mut()
{
let meta = obj.entry("_meta").or_insert_with(|| json!({}));
if let Some(meta) = meta.as_object_mut() {
meta.insert("effectcraftAutoSave".into(), self.autosave_info());
}
}
if self.autosave.is_some()
&& name == "get_project"
&& result["isError"] == false
&& let Some(text) = result.get_mut("content").and_then(Value::as_array_mut).and_then(|c| c.first_mut()).and_then(|c| c.get_mut("text"))
&& let Some(summary) = text.as_str().and_then(|t| serde_json::from_str::<Value>(t).ok())
{
let mut summary = summary;
summary["autosave"] = self.autosave_info();
*text = Value::String(summary.to_string());
}
Ok(result)
}
// Advertised as absent, but answer politely for clients that probe anyway.
"resources/list" => Ok(json!({"resources": []})),

View File

@ -177,6 +177,28 @@ fn headless_workflow() {
let _ = std::fs::remove_dir_all(dir);
}
/// An expression with a syntax error is kept but disabled; set_property and get_property report
/// its error, and `evaluated` is the static value that renders (#163).
#[test]
fn expression_syntax_errors_reach_the_reply() {
let mut s = McpServer::new(Backend::headless(effectcraft_host::session()));
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "A", "width": 64, "height": 64}}));
call_json(&mut s, "execute_command", json!({"command": "layer.newSolid", "params": {"name": "S", "color": "#ff0000", "width": 32, "height": 8}}));
let at = |expr: &str| json!({"layer": "S", "path": "transform/rotation", "expression": expr});
call_json(&mut s, "set_property", json!({"layer": "S", "path": "transform/rotation", "value": 30}));
let set = call_json(&mut s, "set_property", at("thisIsBroken("));
let got = call_json(&mut s, "get_property", json!({"layer": "S", "path": "transform/rotation"}));
for r in [&set, &got] {
assert_eq!((&r["expression"], &r["evaluated"]), (&json!("thisIsBroken("), &json!(30.0)), "{r}");
assert!(r["expressionError"].as_str().unwrap().contains("SyntaxError"), "{r}");
}
// A runtime error and a valid expression report as before.
let r = call_json(&mut s, "set_property", at("nope*2"));
assert!(r["expressionError"].as_str().unwrap().contains("nope is not defined"), "{r}");
let r = call_json(&mut s, "set_property", at("60"));
assert_eq!((&r["evaluated"], r.get("expressionError")), (&json!(60.0), None), "{r}");
}
#[test]
fn stdio_loop() {
let mut s = server();
@ -196,6 +218,167 @@ fn stdio_loop() {
assert_eq!(lines[1]["result"]["isError"], false);
}
/// Regression for #258: a dirty headless project must survive an MCP disconnect.
#[test]
fn headless_autosave_on_eof_preserves_unsaved_project() {
let dir = std::env::temp_dir().join(format!("ec-mcp-autosave-eof-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut s = server().with_autosave(&dir).unwrap();
let input = [
json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "execute_command", "arguments": {"command": "comp.new", "params": {"name": "Keep my work", "width": 320, "height": 180}}}}),
json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "execute_command", "arguments": {"command": "layer.newText", "params": {"text": "Unsaved title"}}}}),
].iter().map(|v| v.to_string() + "\n").collect::<String>();
let mut output = Vec::new();
s.serve(input.as_bytes(), &mut output).unwrap();
let path = s.backend().session().unwrap().autosave.last_path.clone();
assert!(path.is_some(), "disconnect discarded the dirty project without an auto-save");
let path = path.unwrap();
let mut fresh = server();
call_json(&mut fresh, "open_project", json!({"path": path}));
let comp = call_json(&mut fresh, "get_comp", json!({"comp": "Keep my work"}));
assert_eq!(comp["layers"].as_array().unwrap().len(), 1);
assert!(s.backend().session().unwrap().is_dirty(), "an auto-save must not mark the original project saved");
std::fs::remove_dir_all(dir).unwrap();
}
fn autosave_test_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("ec-mcp-autosave-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn autosave_status(s: &mut McpServer) -> Value {
rpc(s, 1, "initialize", json!({}))["_meta"]["effectcraftAutoSave"].clone()
}
#[test]
fn autosave_is_durable_before_reply_and_queries_do_not_rotate() {
let dir = autosave_test_dir("durable");
let mut s = server().with_autosave(&dir).unwrap();
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Durable"}}));
let first = autosave_status(&mut s);
let path = first["latestPath"].as_str().unwrap();
assert!(std::fs::read_to_string(path).unwrap().contains("Durable"));
let summary = call_json(&mut s, "get_project", json!({}));
assert_eq!(summary["autosave"]["latestPath"], path);
assert_eq!(summary["path"], Value::Null);
assert_eq!(summary["dirty"], true);
assert_eq!(autosave_status(&mut s)["latestPath"], path);
assert!(s.backend().session().unwrap().config.is_none());
drop(s); // Abrupt process loss has no EOF cleanup; the checkpoint already exists.
let mut restarted = server().with_autosave(&dir).unwrap();
let init = rpc(&mut restarted, 1, "initialize", json!({}));
assert!(init["instructions"].as_str().unwrap().contains(path));
assert_eq!(init["_meta"]["effectcraftAutoSave"]["previousSessions"][0]["autosave"], path);
call_json(&mut restarted, "open_project", json!({"path": path}));
assert_eq!(call_json(&mut restarted, "get_project", json!({}))["items"][0]["name"], "Durable");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn autosave_errors_preserve_tool_success_retry_and_fail_shutdown() {
let dir = autosave_test_dir("write-error");
let mut s = server().with_autosave(&dir).unwrap();
let folder = std::path::PathBuf::from(autosave_status(&mut s)["folder"].as_str().unwrap());
std::fs::remove_dir(&folder).unwrap();
std::fs::write(&folder, "blocked by file").unwrap();
let (content, failed) = call(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Still in memory"}}));
assert!(!failed, "the edit succeeded even though its checkpoint failed");
assert!(content.iter().any(|c| c["text"].as_str().unwrap_or("").contains("Warning:")));
assert!(s.backend().session().unwrap().is_dirty());
assert!(autosave_status(&mut s)["error"].as_str().unwrap().contains("failed"));
let error = s.serve(&b""[..], Vec::new()).unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::Other);
assert!(error.to_string().contains("MCP auto-save"));
std::fs::remove_file(&folder).unwrap();
// Even a query retries the outstanding dirty revision.
let summary = call_json(&mut s, "get_project", json!({}));
assert_eq!(summary["autosave"]["error"], Value::Null);
let path = summary["autosave"]["latestPath"].as_str().unwrap();
assert!(std::fs::read_to_string(path).unwrap().contains("Still in memory"));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn autosave_keeps_sessions_and_desktop_recovery_separate() {
let dir = autosave_test_dir("isolated");
let desktop_save = dir.join(effectcraft_engine::autosave::AUTOSAVE_FOLDER).join("Untitled Project auto-save 1.ecproj");
std::fs::create_dir_all(desktop_save.parent().unwrap()).unwrap();
std::fs::write(&desktop_save, "desktop backup").unwrap();
std::fs::write(dir.join("session.lock"), "desktop sentinel").unwrap();
let mut a = server().with_autosave(&dir).unwrap();
let mut b = server().with_autosave(&dir).unwrap();
assert_ne!(autosave_status(&mut a)["folder"], autosave_status(&mut b)["folder"]);
for index in 0..7 {
for (s, name) in [(&mut a, "A"), (&mut b, "B")] {
call_json(s, "execute_command", json!({"command": "comp.new", "params": {"name": format!("{name}{index}")}}));
}
}
for (s, mine, other) in [(&mut a, "A6", "B6"), (&mut b, "B6", "A6")] {
let status = autosave_status(s);
let json = std::fs::read_to_string(status["latestPath"].as_str().unwrap()).unwrap();
assert!(json.contains(mine));
assert!(!json.contains(other));
let folder = std::path::Path::new(status["folder"].as_str().unwrap());
assert_eq!(effectcraft_engine::autosave::existing(folder, "Untitled Project").len(), 5);
}
assert_eq!(std::fs::read_to_string(desktop_save).unwrap(), "desktop backup");
assert_eq!(std::fs::read_to_string(dir.join("session.lock")).unwrap(), "desktop sentinel");
assert!(!dir.join("prefs.json").exists());
assert!(!dir.join("shortcuts.json").exists());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn autosave_marks_saved_or_undone_work_clean_without_losing_its_path() {
let dir = autosave_test_dir("clean");
let mut s = server().with_autosave(&dir).unwrap();
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Undo me"}}));
let status = autosave_status(&mut s);
let folder = std::path::Path::new(status["folder"].as_str().unwrap());
call_json(&mut s, "undo", json!({}));
let manifest: Value = serde_json::from_str(&std::fs::read_to_string(folder.join("session.json")).unwrap()).unwrap();
assert_eq!(manifest["dirty"], false);
call_json(&mut s, "redo", json!({}));
let path = autosave_status(&mut s)["latestPath"].clone();
call_json(&mut s, "save_project", json!({"path": dir.join("Saved.ecproj")}));
assert_eq!(autosave_status(&mut s)["latestPath"], path, "save resets the engine bookkeeping but the server remembers the recovery file");
let manifest: Value = serde_json::from_str(&std::fs::read_to_string(folder.join("session.json")).unwrap()).unwrap();
assert_eq!(manifest["dirty"], false);
let mut next = server().with_autosave(&dir).unwrap();
assert!(autosave_status(&mut next)["previousSessions"].as_array().unwrap().is_empty());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn autosave_flushes_on_read_errors_and_broken_output() {
struct Broken;
impl Write for Broken {
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
Err(std::io::ErrorKind::BrokenPipe.into())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let dir = autosave_test_dir("transports");
for invalid_input in [true, false] {
let mut s = server().with_autosave(&dir).unwrap();
// Simulate an edit whose client went away before it received a tool response.
s.backend().exec("comp.new", json!({"name": "Transport failure"})).unwrap();
let error = if invalid_input {
s.serve(&b"\xff\n"[..], Vec::new()).unwrap_err()
} else {
s.serve(&b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}\n"[..], Broken).unwrap_err()
};
assert_eq!(error.kind(), if invalid_input { std::io::ErrorKind::InvalidData } else { std::io::ErrorKind::BrokenPipe });
let status = autosave_status(&mut s);
assert!(std::fs::read_to_string(status["latestPath"].as_str().unwrap()).unwrap().contains("Transport failure"));
}
std::fs::remove_dir_all(dir).unwrap();
}
/// A fake desktop control channel: answers `engine.execute` from a real session, and canned
/// `render.frame` / `ui.screenshot` replies.
fn fake_app() -> u16 {
@ -393,3 +576,161 @@ fn high_level_tools_add_effect_and_report_state() {
let (c, err) = call(&mut s, "add_effect", json!({"layer": l, "effect": "No Such Effect"}));
assert!(err && c[0]["text"].as_str().unwrap().contains("unknown effect"), "{c:?}");
}
/// add_effect is one undo step, and a call that fails leaves the layer and the history as they
/// were (#175).
#[test]
fn add_effect_is_one_undo_step_and_all_or_nothing() {
let mut s = server();
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "A", "width": 64, "height": 64}}));
call_json(&mut s, "execute_command", json!({"command": "layer.newSolid", "params": {"name": "S", "color": "#ff0000"}}));
let undo = |s: &mut McpServer| call_json(s, "get_project", json!({}))["undo"].as_array().unwrap().clone();
let effects = |s: &mut McpServer| {
let l = call_json(s, "get_layer", json!({"layer": "S", "flat": true}));
l["properties"].as_array().unwrap().iter().filter(|p| p["path"].as_str().unwrap().starts_with("effects/")).count()
};
let before = undo(&mut s);
let (c, err) = call(&mut s, "add_effect", json!({"layer": "S", "effect": "Gaussian Blur", "values": {"blurriness": 8, "repeatEdgePixels": true}}));
assert!(err && c[0]["text"].as_str().unwrap().contains("effects/#1/repeatEdgePixels"), "{c:?}");
assert_eq!((undo(&mut s), effects(&mut s)), (before.clone(), 0), "nothing applied");
// The corrected retry lands at #1, in one undo step named like effect.apply's.
let r = call_json(&mut s, "add_effect", json!({"layer": "S", "effect": "Gaussian Blur", "values": {"blurriness": 8, "repeatEdge": true}}));
assert_eq!((&r["path"], &r["set"]), (&json!("effects/#1"), &json!(["effects/#1/blurriness", "effects/#1/repeatEdge"])), "{r}");
let after = undo(&mut s);
assert_eq!(after.len(), before.len() + 1, "{after:?}");
assert_eq!(after.last(), Some(&json!("Apply Gaussian Blur")));
call_json(&mut s, "undo", json!({}));
assert_eq!((undo(&mut s), effects(&mut s)), (before, 0), "one undo removes the effect and its values");
}
#[test]
fn list_fonts_lists_families_with_styles() {
let mut s = server();
let all = call_json(&mut s, "list_fonts", json!({}));
let fams = all["families"].as_array().unwrap();
assert!(fams.iter().any(|f| f["family"] == "Inter" && f["origin"] == "bundled"), "{all}");
// The query filters by substring: the bundled JetBrains Mono, and any installed family whose
// name contains it (JetBrainsMono Nerd Font…) (#149).
let one = call_json(&mut s, "list_fonts", json!({"query": "jetbrains"}));
let matched = one["families"].as_array().unwrap();
assert_eq!(one["count"].as_u64(), Some(matched.len() as u64), "{one}");
assert!(matched.iter().all(|f| f["family"].as_str().unwrap().to_lowercase().contains("jetbrains")), "{one}");
assert!(matched.len() < fams.len(), "the query filters: {one}");
let mono = matched.iter().find(|f| f["family"] == "JetBrains Mono").unwrap_or_else(|| panic!("{one}"));
assert!(mono["styles"].as_array().unwrap().contains(&json!("Regular")), "{one}");
}
/// Trained models over MCP (Roto Brush and face tracking): listed with their authors and
/// licence, chosen, refused when unknown.
#[test]
fn trained_models_over_mcp() {
let mut s = server();
for (prefix, id) in [("roto", "mobilesam"), ("face", "mediapipe-face")] {
let list = call_json(&mut s, "execute_command", json!({"command": format!("{prefix}.models"), "params": {}}));
let models = list["models"].as_array().unwrap();
assert!(models.iter().any(|m| m["id"] == id && m["licence"] == "Apache-2.0" && m["authors"].is_string() && m["installed"] == false), "{list}");
let r = call_json(&mut s, "execute_command", json!({"command": format!("{prefix}.model.select"), "params": {"id": id}}));
assert_eq!(r["model"], id);
let (c, err) = call(&mut s, "execute_command", json!({"command": format!("{prefix}.model.select"), "params": {"id": "nope"}}));
assert!(err && c[0]["text"].as_str().unwrap().contains("unknown model"), "{c:?}");
}
}
/// docs/agents.md's Tools table lists every tool, and an entry that lists arguments lists every
/// argument the tool's input schema accepts (#137).
#[test]
fn agents_doc_tools_table_is_current() {
// (Checkouts may have CRLF line endings.)
let doc = include_str!("../../../docs/agents.md").replace("\r\n", "\n");
let table: String = doc.split("### Tools").nth(1).and_then(|t| t.split("\n\n").nth(1)).unwrap_or_default().to_string();
assert!(table.starts_with("| Tool |"), "no Tools table");
let mut missing = vec![];
for t in crate::tools::catalogue() {
// `name` (no arguments) or `name {a, b?, …}`.
let Some(at) = table.find(&format!("`{}", t.name)).filter(|i| {
let rest = &table[i + 1 + t.name.len()..];
rest.starts_with('`') || rest.starts_with(" {")
}) else {
missing.push(format!("{}: no entry", t.name));
continue;
};
let rest = &table[at + 1 + t.name.len()..];
if !rest.starts_with(" {") {
continue;
}
let args = rest.split("}`").next().unwrap_or_default();
let schema = t.input_schema();
for prop in schema["properties"].as_object().into_iter().flat_map(|p| p.keys()) {
if !args.split(|c: char| !(c.is_alphanumeric() || c == '_')).any(|w| w == prop) {
missing.push(format!("{}: argument `{prop}`", t.name));
}
}
}
assert!(missing.is_empty(), "docs/agents.md's Tools table is missing: {missing:#?}");
}
#[test]
fn autosave_checkpoints_batch_and_partially_failed_tools() {
let dir = autosave_test_dir("partial");
let mut s = server().with_autosave(&dir).unwrap();
let msgs = json!([
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_command","arguments":{"command":"comp.new","params":{"name":"Batch"}}}},
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"execute_command","arguments":{"command":"layer.newText","params":{"text":"Batch title"}}}}
]);
let reply = s.handle(&msgs).unwrap();
assert_eq!(reply.as_array().unwrap().len(), 2);
let (_, failed) = call(&mut s, "add_keyframe", json!({"layer":"#1","path":"transform/opacity","keys":[{"time":0,"value":37}],"interpolation":"invalid"}));
assert!(failed, "the key was added, then the interpolation failed");
let status = autosave_status(&mut s);
let mut recovered = server();
call_json(&mut recovered, "open_project", json!({"path":status["latestPath"]}));
let prop = call_json(&mut recovered, "get_property", json!({"layer":"#1","path":"transform/opacity"}));
assert_eq!(prop["keys"].as_array().unwrap().len(), 1);
assert_eq!(prop["keys"][0]["value"], 37.0);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn autosave_isolation_also_applies_to_engine_autosaves_and_preference_changes() {
let dir = autosave_test_dir("engine-save");
let desktop = dir.join("DesktopCustom");
std::fs::create_dir_all(&desktop).unwrap();
std::fs::write(desktop.join("Untitled Project auto-save 1.ecproj"), "desktop backup").unwrap();
let mut session = Session::default();
session.prefs.auto_save.location = "custom".into();
session.prefs.auto_save.folder = desktop.to_string_lossy().into_owned();
let mut s = McpServer::new(Backend::headless(session)).with_autosave(&dir).unwrap();
call_json(&mut s, "execute_command", json!({"command":"comp.new","params":{"name":"Isolated"}}));
let status = autosave_status(&mut s);
let explicit = call_json(&mut s, "execute_command", json!({"command":"file.autoSave"}));
assert!(std::path::Path::new(explicit["path"].as_str().unwrap()).starts_with(status["folder"].as_str().unwrap()));
assert_eq!(autosave_status(&mut s)["latestPath"], explicit["path"]);
// The host override survives preference edits even within a single batch tool call.
call_json(
&mut s,
"batch",
json!({"steps":[
{"command":"prefs.set","params":{"values":{"autoSave.location":"custom","autoSave.folder":desktop.to_string_lossy()}}},
{"command":"file.autoSave","params":{}}
]}),
);
call_json(&mut s, "save_project", json!({"path":dir.join("Named.ecproj")}));
call_json(&mut s, "execute_command", json!({"command":"layer.newText","params":{"text":"Latest isolated edit"}}));
let current = autosave_status(&mut s);
assert!(std::path::Path::new(current["latestPath"].as_str().unwrap()).starts_with(status["folder"].as_str().unwrap()));
assert!(std::fs::read_to_string(current["latestPath"].as_str().unwrap()).unwrap().contains("Latest isolated edit"));
let recovery = call_json(&mut s, "execute_command", json!({"command":"file.recoveryInfo"}));
assert_eq!(recovery["folder"], status["folder"]);
assert_eq!(std::fs::read_to_string(desktop.join("Untitled Project auto-save 1.ecproj")).unwrap(), "desktop backup");
assert_eq!(std::fs::read_dir(&desktop).unwrap().count(), 2, "only the original desktop file and the MCP subfolder");
std::fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn autosave_non_utf8_path_returns_an_error_instead_of_panicking() {
use std::os::unix::ffi::OsStringExt;
let root = std::path::PathBuf::from(std::ffi::OsString::from_vec(b"/tmp/ec-mcp-\xff".to_vec()));
let result = server().with_autosave(&root);
assert!(matches!(result, Err(crate::Error::Other(message)) if message.contains("UTF-8")));
}

View File

@ -150,7 +150,12 @@ fn list_effects(b: &mut Backend, a: &Value) -> Result<Reply> {
json_reply(b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?)
}
/// `effect.apply` on one layer, then set parameters on the new instance by param id.
fn list_fonts(b: &mut Backend, a: &Value) -> Result<Reply> {
json_reply(b.exec("text.fonts", obj(&[("query", get(a, "query")), ("rescan", get(a, "rescan"))]))?)
}
/// `effect.apply` on one layer, then set parameters on the new instance by param id: one batch,
/// so one undo step, and a failing value leaves nothing applied.
fn add_effect(b: &mut Backend, a: &Value) -> Result<Reply> {
let (layer, effect, comp) = (need(a, "layer")?, need(a, "effect")?, get(a, "comp"));
let count = |t: &Value| effects_group(t).and_then(|g| g.get("children")).and_then(Value::as_array).map_or(0, Vec::len);
@ -158,16 +163,22 @@ fn add_effect(b: &mut Backend, a: &Value) -> Result<Reply> {
let index = count(&before) + 1;
let mut p = obj(&[("effect", Some(effect)), ("comp", comp)]);
p["layers"] = json!([layer]);
b.exec("effect.apply", p)?;
let mut steps = vec![json!({"command": "effect.apply", "params": p})];
let prefix = format!("effects/#{index}");
let mut set = vec![];
if let Some(vals) = get(a, "values").and_then(Value::as_object) {
for (k, v) in vals {
let path = format!("{prefix}/{k}");
b.exec("prop.set", obj(&[("layer", Some(layer)), ("comp", comp), ("path", Some(&json!(path))), ("value", Some(v))]))?;
steps.push(
json!({"command": "prop.set", "params": obj(&[("layer", Some(layer)), ("comp", comp), ("path", Some(&json!(path))), ("value", Some(v))])}),
);
set.push(path);
}
}
// The undo step is named like effect.apply's own ("Apply Gaussian Blur").
let name = effect.as_str().map(|e| effectcraft_engine::effects::lookup(e).map_or(e, |s| s.name));
let label = name.map(|n| json!(format!("Apply {n}")));
b.exec("engine.batch", obj(&[("steps", Some(&json!(steps))), ("label", label.as_ref())]))?;
let tree = b.exec("layer.tree", obj(&[("layer", Some(layer)), ("comp", comp)]))?;
let inst = effects_group(&tree).and_then(|g| g.get("children")).and_then(Value::as_array).and_then(|c| c.get(index - 1)).cloned();
let mut params = vec![];
@ -586,7 +597,7 @@ static TOOLS: &[ToolDef] = &[
},
ToolDef {
name: "add_effect",
description: "Apply an effect to a layer and optionally set its parameters in one undoable call. `effect` is an id (`ec.blur.gaussian`) or After Effects name (`Gaussian Blur`; see list_effects); `values` maps parameter ids to values, e.g. {\"blurriness\": 12}. Returns the instance `path` (`effects/#n`, for set_property / add_keyframe) and its parameters with their paths and current values.",
description: "Apply an effect to a layer and optionally set its parameters in one undoable call. `effect` is an id (`ec.blur.gaussian`) or After Effects name (`Gaussian Blur`; see list_effects); `values` maps parameter ids to values, e.g. {\"blurriness\": 12}. One undo step; if a value fails, nothing is applied. Returns the instance `path` (`effects/#n`, for set_property / add_keyframe) and its parameters with their paths and current values.",
bridge_only: false,
schema: || {
schema(
@ -607,6 +618,21 @@ static TOOLS: &[ToolDef] = &[
schema: || schema(json!({"filter": {"type": "string", "description": "Only effects whose id, name or category contains this text."}}), &[]),
run: list_effects,
},
ToolDef {
name: "list_fonts",
description: "The font families text layers can use, bundled and installed on this machine: family, styles, origin (bundled / system / user) and the name in the font's own language. Narrow with `query`; `rescan` picks up fonts installed since launch. Set one with execute_command `layer.setText {\"layer\", \"font\", \"style\"}`.",
bridge_only: false,
schema: || {
schema(
json!({
"query": {"type": "string", "description": "Only families whose name (English or own-language) contains this text."},
"rescan": {"type": "boolean", "description": "Look for fonts installed since launch first."}
}),
&[],
)
},
run: list_fonts,
},
ToolDef {
name: "get_state",
description: "Where things stand: the editor state (active comp, current time, selected layers/keys/items, open comps, tool, work area) plus what this build can do (`app`: version, command and effect counts, GPU effects, import/export formats, After Effects parity summary). Cheap; call it to re-orient after a series of edits.",

View File

@ -75,7 +75,7 @@ fn check(name: &str, cfg: EncoderConfig, pics: &[Pic]) -> Vec<u32> {
let raw = std::fs::read(&output).expect("decoded yuv");
let _ = std::fs::remove_dir_all(&dir);
let samples: Vec<u16> =
if bd == 8 { raw.iter().map(|&b| b as u16).collect() } else { raw.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect() };
if bd == 8 { raw.iter().map(|&b| b as u16).collect() } else { raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes([c[0], c[1]])).collect() };
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let fsize = w * h + 2 * cw * ch;
assert_eq!(samples.len(), fsize * pics.len(), "{name}: decoded frame count");

View File

@ -222,7 +222,7 @@ fn description(t: Option<&[u8]>) -> Option<String> {
// The first record: UTF-16BE.
let len = be32(t, 20)? as usize;
let off = be32(t, 24)? as usize;
let u: Vec<u16> = t.get(off..off + len)?.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
let u: Vec<u16> = t.get(off..off + len)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect();
Some(String::from_utf16_lossy(&u).trim_end_matches('\0').to_string())
}
_ => None,

View File

@ -15,6 +15,7 @@ effectcraft-color = { workspace = true }
effectcraft-keyframe = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-raster = { workspace = true }
effectcraft-segment = { workspace = true }
effectcraft-track = { workspace = true }
rayon = { workspace = true }
serde = { workspace = true }

View File

@ -247,7 +247,7 @@ pub fn process(id: &str, params: &Params, buf: &mut [f32], rate: u32, start: f64
match id {
"ec.audio.backwards" => {
if params.b("swapChannels") {
buf.chunks_exact_mut(2).for_each(|f| f.swap(0, 1));
buf.as_chunks_mut::<2>().0.iter_mut().for_each(|f| f.swap(0, 1));
}
}
"ec.audio.basstreble" => {
@ -471,7 +471,7 @@ fn compressor(params: &Params, buf: &mut [f32], sr: f64) {
let limit = db_to_lin(params.f("outputLimit").min(0.0)) as f32;
// Smoothed gain change in dB: attack while the reduction grows, release as it shrinks.
let (mut g, mut gs) = (0.0f64, 0.0f64);
for f in buf.chunks_exact_mut(2) {
for f in buf.as_chunks_mut::<2>().0.iter_mut() {
let peak = f[0].abs().max(f[1].abs()) as f64;
let target = compressor_gain_db(lin_to_db(peak), th, ratio, knee);
let k = if target < g { att } else { rel };
@ -497,7 +497,7 @@ fn gate(params: &Params, buf: &mut [f32], sr: f64) {
// Openness 0..1 (closed..open) is the gain: a closed gate mutes.
let mut g = 0.0f64;
let mut held = 0usize;
for f in buf.chunks_exact_mut(2) {
for f in buf.as_chunks_mut::<2>().0.iter_mut() {
let peak = f[0].abs().max(f[1].abs()) as f64;
let open = if peak >= th {
held = hold;
@ -560,7 +560,7 @@ fn distortion(params: &Params, buf: &mut [f32], sr: f64, start: f64) {
// Sample-and-hold on absolute sample positions, so blocks agree with one long run.
let first = (start * sr).round() as i64;
let mut held = [0.0f32; 2];
for (i, f) in buf.chunks_exact_mut(2).enumerate() {
for (i, f) in buf.as_chunks_mut::<2>().0.iter_mut().enumerate() {
let take = i == 0 || (first + i as i64).rem_euclid(down) == 0;
for ch in 0..2 {
let x = f[ch];
@ -696,7 +696,7 @@ fn stereo_mixer(params: &Params, buf: &mut [f32]) {
let (l_to_l, l_to_r) = pan(params.f("leftPan"));
let (r_to_l, r_to_r) = pan(params.f("rightPan"));
let inv = if params.b("invertPhase") { -1.0 } else { 1.0 };
for f in buf.chunks_exact_mut(2) {
for f in buf.as_chunks_mut::<2>().0.iter_mut() {
let (l, r) = (f[0] * ll, f[1] * rl);
f[0] = (l * l_to_l + r * r_to_l) * inv;
f[1] = (l * l_to_r + r * r_to_r) * inv;

View File

@ -173,7 +173,8 @@ mod tests {
std::fs::write(path, &want).unwrap();
return;
}
let have = std::fs::read_to_string(path).unwrap_or_default();
// A Windows checkout may use CRLF; catalog content must still match exactly.
let have = std::fs::read_to_string(path).unwrap_or_default().replace("\r\n", "\n");
assert!(have == want, "docs/effects.md is stale: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current");
}

View File

@ -6,6 +6,7 @@ use effectcraft_project::ParamUi;
use effectcraft_raster::Image;
use rayon::prelude::*;
use crate::util::pin_rect;
use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
@ -67,8 +68,7 @@ fn bulge(ctx: &EffectCtx, mut b: Buf) -> Buf {
// Taper Radius: higher values make the sides of the bulge shallower (a more peaked profile).
let taper = 1.0 + ctx.params.f("taperRadius").max(0.0) / 100.0 * 3.0;
let pin = ctx.params.b("pinAllEdges");
let (lx, ly) = (b.offset[0], b.offset[1]);
let (lw, lh) = (b.layer_w(ctx) * b.scale, b.layer_h(ctx) * b.scale);
let (lx, ly, lw, lh) = pin_rect(ctx, b.offset, b.scale);
let edge = (lw.min(lh) * 0.1).max(1.0);
b.img = remap(&b.img, false, |x, y| {
let (dx, dy) = ((x - c.0) / rx, (y - c.1) / ry);
@ -168,7 +168,8 @@ fn wave_warp(ctx: &EffectCtx, mut b: Buf) -> Buf {
if !ctx.adjustment {
b.pad(h.abs().ceil() as u32 + 1);
}
let rect = (b.offset[0], b.offset[1], (b.layer_w(ctx) * b.scale).max(1.0), (b.layer_h(ctx) * b.scale).max(1.0));
let (x0, y0, lw, lh) = pin_rect(ctx, b.offset, b.scale);
let rect = (x0, y0, lw.max(1.0), lh.max(1.0));
b.img = remap(&b.img, false, |x, y| {
// Waves travel along `dir`; displacement is perpendicular to it.
let along = x * dx + y * dy;

View File

@ -20,7 +20,7 @@ use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::generate::value_noise;
use crate::util::{SRC_NAMES, Src, fit_layer, layer_rect, lerp4, map_xy, pick, premul, remap, smoothstep, src_at, unpremul};
use crate::util::{SRC_NAMES, Src, fit_layer, layer_rect, lerp4, map_xy, pick, pin_rect, premul, remap, smoothstep, src_at, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
@ -103,7 +103,7 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf {
let locked = pin_raw >= 8;
let pin = if locked { pin_raw - 7 } else { pin_raw };
let falloff = if (3..=5).contains(&kind) { 0.3 } else { 0.5 };
let (lx, ly, lw, lh) = layer_rect(ctx, &b);
let (lx, ly, lw, lh) = pin_rect(ctx, b.offset, b.scale);
let edge = (lw.min(lh) * if locked { 0.25 } else { 0.1 }).max(1.0);
let seed = (ctx.seed ^ 0x7d15).wrapping_add((ctx.params.f("evolutionOptions/randomSeed") as i64 as u32).wrapping_mul(0x9e37_79b9));
let n = |u: f64, v: f64, s: u32| {

View File

@ -262,7 +262,7 @@ fn circle(ctx: &EffectCtx, mut b: Buf) -> Buf {
// ---- value noise / fBm (our own lattice noise with quintic fade) ----
#[inline]
fn lattice(ix: i32, iy: i32, iz: i32, seed: u32) -> f32 {
pub(crate) fn lattice(ix: i32, iy: i32, iz: i32, seed: u32) -> f32 {
let mut h =
(ix as u32).wrapping_mul(0x27d4_eb2d) ^ (iy as u32).wrapping_mul(0x1656_67b1) ^ (iz as u32).wrapping_mul(0x9e37_79b9) ^ seed.wrapping_mul(0x85eb_ca6b);
h ^= h >> 15;
@ -274,7 +274,7 @@ fn lattice(ix: i32, iy: i32, iz: i32, seed: u32) -> f32 {
}
#[inline]
fn fade(t: f32) -> f32 {
pub(crate) fn fade(t: f32) -> f32 {
t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
}

View File

@ -347,6 +347,11 @@ pub struct EffectEnv<'a> {
/// comp's and the layer's motion blur switches are on (Transform's Use Composition's
/// Shutter Angle).
pub shutter: Option<(f64, f64, u32)>,
/// Top-left corner of the layer bounds ([`EffectCtx::layer_size`]) in layer coordinates:
/// (0, 0) for layers with a source rectangle; shape and text layers have comp-sized bounds
/// centred on their origin (−w/2, −h/2). Edge pinning measures from these bounds
/// ([`util::pin_rect`]).
pub bounds_origin: [f64; 2],
}
/// What an effect gets to render with.
@ -1107,6 +1112,9 @@ pub const TIME_DEPENDENT: &[&str] = &[
"ec.obsolete.lightning",
"ec.text.timecode",
"ec.text.numbers",
// The audio visualisers read the Audio Layer's samples around the frame time.
"ec.generate.audiospectrum",
"ec.generate.audiowaveform",
// Path Text's jitter changes every frame.
"ec.obsolete.pathtext",
// Time effects read neighbouring frames of the layer.

View File

@ -4,7 +4,7 @@
//!
//! Each "noise layer" (octave) is 3D value noise (x, y, evolution) on an integer lattice. The
//! Noise Type picks the interpolation between lattice values (Block = nearest, Linear,
//! Soft Linear = quintic fade, Spline = Catmull-Rom). Successive layers are scaled by Sub
//! Soft Linear = quadratic B-spline, Spline = Catmull-Rom). Successive layers are scaled by Sub
//! Scaling, weighted by Sub Influence, rotated by Sub Rotation and shifted by Sub Offset, and the
//! Fractal Type shapes each layer (turbulent types fold the signed noise; dynamic types warp the
//! domain; Max keeps the strongest layer; Strings keeps thin lines). Contrast / Brightness /
@ -17,7 +17,7 @@ use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use rayon::prelude::*;
use crate::generate::value_noise;
use crate::generate::{fade, lattice};
use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, num, p, popup, slider};
/// Fractal Type options (shared by both effects).
@ -120,36 +120,36 @@ pub fn specs() -> Vec<EffectSpec> {
// ---------------------------------------------------------------- noise kernels
fn lattice(ix: i32, iy: i32, iz: i32, seed: u32) -> f32 {
// The same hash as `generate::value_noise`, so Soft Linear matches it bit for bit.
let mut h =
(ix as u32).wrapping_mul(0x27d4_eb2d) ^ (iy as u32).wrapping_mul(0x1656_67b1) ^ (iz as u32).wrapping_mul(0x9e37_79b9) ^ seed.wrapping_mul(0x85eb_ca6b);
h ^= h >> 15;
h = h.wrapping_mul(0x2c1b_3c6d);
h ^= h >> 12;
h = h.wrapping_mul(0x297a_2d39);
h ^= h >> 15;
(h & 0xffff) as f32 / 65535.0
}
#[inline]
fn fade(t: f32) -> f32 {
t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
}
/// Soft Linear's gain about mid-grey. Averaging 3 × 3 lattice values leaves the B-spline 0.55² of
/// their variance, against 0.784² (181/231 per axis) for the quintic fade Soft Linear used before
/// (#259); the gain keeps the noise's contrast.
const SOFT_LINEAR_GAIN: f32 = (181.0 / 231.0) / 0.55;
#[inline]
fn catmull(p0: f32, p1: f32, p2: f32, p3: f32, t: f32) -> f32 {
0.5 * (2.0 * p1 + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t * t + (3.0 * p1 - p0 - 3.0 * p2 + p3) * t * t * t)
}
/// Uniform quadratic B-spline weights of the lattice values before, at and after the nearest one,
/// for `t` = position − nearest + 0.5 in 0..1.
#[inline]
fn bspline(t: f32) -> [f32; 3] {
let s = 1.0 - t;
[0.5 * s * s, 0.5 + t * s, 0.5 * t * t]
}
/// 3D value noise in about 0..1 with the given [`NOISE_TYPES`] interpolation in x and y (the
/// evolution axis z always fades smoothly).
///
/// Soft Linear is linear interpolation softened by a one-cell box filter, which is the quadratic
/// B-spline: smooth like Spline from 9 lattice values instead of 16. A fade between two values
/// has zero slope across every lattice line, which the turbulent types and high contrast turned
/// into a visible grid (#259); the B-spline's slope there is half the neighbours' difference.
pub fn typed_noise(x: f32, y: f32, z: f32, seed: u32, noise_type: u32) -> f32 {
if noise_type == NOISE_SOFT_LINEAR {
return value_noise(x, y, z, seed);
}
let (x0, y0, z0) = (x.floor(), y.floor(), z.floor());
let (tx, ty, fz) = (x - x0, y - y0, fade(z - z0));
// Soft Linear's taps are centred on the nearest lattice point, the others start at the cell's.
let h = if noise_type == NOISE_SOFT_LINEAR { 0.5 } else { 0.0 };
let (x0, y0, z0) = ((x + h).floor(), (y + h).floor(), z.floor());
let (tx, ty, fz) = (x + h - x0, y + h - y0, fade(z - z0));
let (ix, iy, iz) = (x0 as i32, y0 as i32, z0 as i32);
let l = |a: f32, b: f32, t: f32| a + (b - a) * t;
let slice = |dz: i32| -> f32 {
@ -157,13 +157,19 @@ pub fn typed_noise(x: f32, y: f32, z: f32, seed: u32, noise_type: u32) -> f32 {
match noise_type {
0 => c(0, 0),
1 => l(l(c(0, 0), c(1, 0), tx), l(c(0, 1), c(1, 1), tx), ty),
NOISE_SOFT_LINEAR => {
let (wx, wy) = (bspline(tx), bspline(ty));
let row = |dy: i32| wx[0] * c(-1, dy) + wx[1] * c(0, dy) + wx[2] * c(1, dy);
wy[0] * row(-1) + wy[1] * row(0) + wy[2] * row(1)
}
_ => {
let row = |dy: i32| catmull(c(-1, dy), c(0, dy), c(1, dy), c(2, dy), tx);
catmull(row(-1), row(0), row(1), row(2), ty)
}
}
};
l(slice(0), slice(1), fz)
let n = l(slice(0), slice(1), fz);
if noise_type == NOISE_SOFT_LINEAR { 0.5 + (n - 0.5) * SOFT_LINEAR_GAIN } else { n }
}
/// Everything the per-pixel evaluation needs, resolved from the parameters.
@ -587,4 +593,53 @@ mod tests {
let t1 = run("ec.noise.turbulent", &[("evolution", num(200.0)), ("evolutionOptions/turbulenceFactor", num(2.0))]);
assert_ne!(t0, t1);
}
/// Turbulent Basic Fractal Noise drawn alone at `w` × `h`.
fn turbulent_basic(w: u32, h: u32, vals: &[(&str, Value)]) -> Image {
let mut all = vec![("fractalType", Value::Enum(2)), ("blendingMode", Value::Enum(BLEND_NONE))];
all.extend_from_slice(vals);
run_fx("ec.noise.fractal", &all, Image::filled(w, h, [0.0, 0.0, 0.0, 1.0]), 0.0, EffectEnv::default()).img
}
/// (x, y, ∂/∂x, ∂/∂y) of the grey value at every inner pixel (central differences).
fn gradients(img: &Image) -> Vec<(i64, i64, f64, f64)> {
let v = |x: i64, y: i64| img.get(x, y)[0] as f64;
let (w, h) = (img.width as i64, img.height as i64);
(1..h - 1).flat_map(|y| (1..w - 1).map(move |x| (x, y, (v(x + 1, y) - v(x - 1, y)) / 2.0, (v(x, y + 1) - v(x, y - 1)) / 2.0))).collect()
}
/// #259: Soft Linear faded between two lattice values with zero slope across every lattice
/// line, which Turbulent Basic and high contrast showed as a square grid.
#[test]
fn soft_linear_has_no_lattice_creases() {
// One 16 px layer whose lattice lines run through the pixel centres x, y = 16k: the slope
// across them is about the average slope (with the fade it was 2% of it).
let one = turbulent_basic(160, 96, &[("complexity", num(1.0)), ("transform/scale", num(16.0)), ("transform/offset", Value::Vec2([0.5, 0.5]))]);
let (mut on, mut n_on, mut all, mut n_all) = (0.0, 0.0, 0.0, 0.0);
for (x, y, gx, gy) in gradients(&one) {
for (k, g) in [(x, gx), (y, gy)] {
all += g.abs();
n_all += 1.0;
if k % 16 == 0 {
on += g.abs();
n_on += 1.0;
}
}
}
let across = (on / n_on) / (all / n_all);
assert!(across > 0.5, "slope across lattice lines / average slope: {across}");
// The issue's settings at a quarter size: gradients run along the lattice axes about as
// strongly as along the diagonals (with the fade, 1.2× as strongly).
let img = turbulent_basic(480, 270, &[("contrast", num(140.0)), ("brightness", num(-35.0)), ("transform/scale", num(105.0))]);
let (mut axis, mut diag) = (0.0, 0.0);
for (_, _, gx, gy) in gradients(&img) {
let a = gy.atan2(gx).to_degrees().rem_euclid(90.0);
if !(15.0..=75.0).contains(&a) {
axis += gx.hypot(gy);
} else if (30.0..=60.0).contains(&a) {
diag += gx.hypot(gy);
}
}
assert!(axis / diag < 1.15, "gradient along the axes / along the diagonals: {}", axis / diag);
}
}

View File

@ -387,6 +387,14 @@ pub fn unmap(mesh: &Mesh, deformed: &[[f64; 2]], p: [f64; 2]) -> [f64; 2] {
}
}
/// Whether `p` (layer space) is on the mesh as `deformed` places its vertices.
pub fn contains(mesh: &Mesh, deformed: &[[f64; 2]], p: [f64; 2]) -> bool {
mesh.tris.iter().any(|t| match (deformed.get(t[0]), deformed.get(t[1]), deformed.get(t[2])) {
(Some(a), Some(b), Some(c)) => bary(*a, *b, *c, p).is_some_and(|l| l.iter().all(|x| *x >= -1e-9)),
_ => false,
})
}
#[inline]
fn bary(a: [f64; 2], b: [f64; 2], c: [f64; 2], p: [f64; 2]) -> Option<[f64; 3]> {
let d = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]);

View File

@ -16,15 +16,19 @@
//! decontamination of the edge colours. Outside the segmentation span the layer is transparent;
//! without strokes the effect passes its input through.
//!
//! *Version* is stored but all versions use the same (classic, graph-cut) engine; *Quality ▸
//! Best* segments at a higher working resolution with more iterations.
//! *Version*: 1.0 is the classic graph-cut engine; 2.0 and 3.0 also use the trained model chosen
//! in Settings ▸ Roto Brush ([`set_model`], an `effectcraft_segment::MaskModel` such as
//! MobileSAM), falling back to the classic engine when none is installed. The model's id is part
//! of the chain seed, so choosing another recomputes the mattes. *Quality ▸ Best* segments at a
//! higher working resolution with more iterations.
use std::collections::{BTreeMap, HashMap, VecDeque};
use std::sync::{Arc, Mutex, OnceLock};
use std::sync::{Arc, Mutex, OnceLock, RwLock};
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::Image;
use effectcraft_segment::MaskModel;
use effectcraft_track::roto::refine::{self, MatteParams};
use effectcraft_track::roto::{self as rb, FrameSeg, RotoData, SegOpts, rle};
use rayon::prelude::*;
@ -173,8 +177,29 @@ pub fn matte_params(params: &Params) -> MatteParams {
}
}
fn model_slot() -> &'static RwLock<Option<Arc<dyn MaskModel>>> {
static M: OnceLock<RwLock<Option<Arc<dyn MaskModel>>>> = OnceLock::new();
M.get_or_init(|| RwLock::new(None))
}
/// The trained model Roto Brush 2.0 / 3.0 use (Settings ▸ Roto Brush; `None`: the classic
/// engine for every version).
pub fn set_model(m: Option<Arc<dyn MaskModel>>) {
if let Ok(mut slot) = model_slot().write() {
*slot = m;
}
}
/// The model an instance segments with: its version's choice of the installed one.
pub fn model_for(params: &Params) -> Option<Arc<dyn MaskModel>> {
if e(params, "version") == 0 {
return None;
}
model_slot().read().ok().and_then(|m| m.clone())
}
/// Seed of the chain keys: what the frames are (the engine-maintained Input Key), the layer
/// size, the frame rate and the segmentation settings.
/// size, the frame rate, the segmentation settings and the model.
pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64) -> u64 {
let mut h = 0xcbf2_9ce4_8422_2325;
fnv(&mut h, s(params, INPUT_KEY).as_bytes());
@ -189,6 +214,7 @@ pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64) -> u64 {
fnv(&mut h, &v.to_bits().to_le_bytes());
}
fnv(&mut h, &[e(params, "quality") as u8, e(params, "version") as u8]);
fnv(&mut h, model_for(params).map_or(effectcraft_segment::CLASSICAL, |m| m.info().id).as_bytes());
h
}
@ -275,13 +301,15 @@ pub struct Chain {
pub keys: BTreeMap<i64, u64>,
pub opts: SegOpts,
pub refine_radius: f64,
/// The trained model (Version 2.0 / 3.0 with one installed).
pub model: Option<Arc<dyn MaskModel>>,
}
impl Chain {
pub fn new(params: &Params, layer_size: [f64; 2], fps: f64, scale: f64) -> Chain {
let data = data(params);
let keys = data.chain_keys(seed(params, layer_size, fps));
Chain { refine_radius: data.refine_radius(), data, keys, opts: seg_opts(params, scale) }
Chain { refine_radius: data.refine_radius(), data, keys, opts: seg_opts(params, scale), model: model_for(params) }
}
/// Frame `f`'s segmentation at this chain's scale: cached, or computed from the nearest
@ -331,7 +359,7 @@ impl Chain {
let img = frame(g)?;
let strokes = self.data.strokes_at(g);
let seg = match (&cur, g == base) {
(_, true) | (None, _) => rb::segment(&img, &strokes, None, &self.opts, self.refine_radius),
(_, true) | (None, _) => rb::segment_with(&img, &strokes, None, &self.opts, self.refine_radius, self.model.as_deref()),
(Some(p), false) => {
let src = self.data.source_of(g)?;
let pimg = match prev_img.take() {
@ -341,7 +369,7 @@ impl Chain {
if pimg.width != img.width || pimg.height != img.height {
return None;
}
rb::propagate(&pimg, p, &img, &strokes, &self.opts, self.refine_radius)
rb::propagate_with(&pimg, p, &img, &strokes, &self.opts, self.refine_radius, self.model.as_deref())
}
};
let seg = Arc::new(seg);

View File

@ -427,6 +427,15 @@ pub fn layer_rect(ctx: &crate::EffectCtx, b: &crate::Buf) -> (f64, f64, f64, f64
(b.offset[0], b.offset[1], ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale)
}
/// The layer bounds edge pinning holds in place, in the pixels of a buffer with `offset` and
/// `scale` (see [`crate::Buf`]): (x0, y0, width, height). Unlike [`layer_rect`] it starts at
/// [`crate::EffectEnv::bounds_origin`], so a shape layer's content around its origin lies
/// inside it.
pub fn pin_rect(ctx: &crate::EffectCtx, offset: [f64; 2], scale: f64) -> (f64, f64, f64, f64) {
let o = ctx.env.bounds_origin;
(offset[0] + o[0] * scale, offset[1] + o[1] * scale, ctx.layer_size[0] * scale, ctx.layer_size[1] * scale)
}
/// Resample another layer (a layer parameter) into `b`'s pixel grid. With `stretch` the other
/// layer is scaled to this layer's size ("Stretch to Fit"); otherwise it is centred.
pub fn fit_layer(ctx: &crate::EffectCtx, b: &crate::Buf, other: &crate::LayerPixels, stretch: bool) -> Image {

View File

@ -10,12 +10,16 @@
//! `f32` bits) and compared with the pinned hashes below: any change to the CPU output of these
//! effects, however small, fails the test.
//!
//! The hashes are exact `f32` bit patterns, so they depend on the platform's libm; they are
//! pinned for aarch64 macOS (where the gates run). Elsewhere the test only checks that every
//! case renders deterministically.
//! The hashes are exact `f32` bit patterns, so they depend on the platform's libm (`sin`, `cos`,
//! `powf`…); they are pinned for aarch64 macOS (where the gates run). Elsewhere the test only
//! checks that every case renders deterministically. A case whose frame differs between macOS
//! releases only through libm also accepts that release's hash ([`LIBM_VARIANTS`]).
//!
//! `SIM_GOLDEN_PRINT=1` prints the table (to re-pin after an intended change);
//! `SIM_GOLDEN_OUT=<dir>` also writes every frame's raw pixels (`f32` little-endian RGBA) there.
//! To tell a libm difference from a change in the code, render the cases with `SIM_GOLDEN_OUT`
//! here and at the commit that pinned the hash, and compare the frames: identical frames mean
//! the code didn't change, and the new hash goes into [`LIBM_VARIANTS`].
use effectcraft_effects::{Buf, EffectCtx, EffectEnv, EffectHost, LayerPixels, Params, apply, default_value, find};
use effectcraft_keyframe::Value;
@ -316,17 +320,41 @@ fn sim_effects_golden_hashes() {
// Deterministic: a second pass (warm simulation caches) renders the same frames.
assert_eq!(got, hashes(), "simulation effects render deterministically");
if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
let bad: Vec<String> = got
.iter()
.zip(GOLDEN)
.filter(|((n, h), (gn, gh))| n != gn || h != gh)
.map(|((n, h), (_, gh))| format!("{n}: 0x{h:016x} (pinned 0x{gh:016x})"))
.collect();
assert_eq!(got.len(), GOLDEN.len(), "case count");
assert!(bad.is_empty(), "CPU output changed:\n{}", bad.join("\n"));
let bad = mismatches(&got, GOLDEN, LIBM_VARIANTS);
assert!(bad.is_empty(), "CPU output changed (if it may be libm, see the module docs):\n{}", bad.join("\n"));
}
}
/// The cases whose hash is neither the pinned one nor one of the case's [`LIBM_VARIANTS`].
fn mismatches(got: &[(String, u64)], golden: &[(&str, u64)], variants: &[(&str, u64, &str)]) -> Vec<String> {
got.iter()
.zip(golden)
.filter(|((n, h), (gn, gh))| n != gn || (h != gh && !variants.iter().any(|(vn, vh, _)| vn == n && vh == h)))
.map(|((n, h), (_, gh))| format!("{n}: 0x{h:016x} (pinned 0x{gh:016x})"))
.collect()
}
/// A libm variant passes for its own case only (#232).
#[test]
fn libm_variants_are_accepted_for_their_case_only() {
let golden = [("a", 1), ("b", 2)];
let variants = [("a", 9, "another libm")];
let got = |a: u64, b: u64| vec![("a".to_string(), a), ("b".to_string(), b)];
assert!(mismatches(&got(1, 2), &golden, &variants).is_empty());
assert!(mismatches(&got(9, 2), &golden, &variants).is_empty(), "the variant");
assert_eq!(mismatches(&got(9, 9), &golden, &variants), ["b: 0x0000000000000009 (pinned 0x0000000000000002)"], "not another case's");
assert_eq!(mismatches(&got(3, 2), &golden, &variants).len(), 1);
assert!(LIBM_VARIANTS.iter().all(|(n, ..)| GOLDEN.iter().any(|(g, _)| g == n)), "every variant names a pinned case");
}
/// Hashes also accepted for a case: the same frame rendered by the same code with another macOS
/// release's libm (case, hash, where it was seen).
const LIBM_VARIANTS: &[(&str, u64, &str)] = &[
// #232: the pinning commit (ce6ece4) renders byte-identical frames there.
("ec.sim.ccmrmercury/1/32bpc/t2.2", 0x3823bd73a3cdab0d, "macOS 27.2 (26B5091g), rustc 1.98.0"),
];
/// Pinned hashes (aarch64 macOS).
const GOLDEN: &[(&str, u64)] = &[
("ec.sim.ccrainfall/0/8bpc/t0.5", 0xc9f1eb02dd83841c),

View File

@ -20,6 +20,7 @@ effectcraft-path = { workspace = true }
effectcraft-text = { workspace = true }
effectcraft-effects = { workspace = true }
effectcraft-render = { workspace = true }
effectcraft-segment = { workspace = true }
effectcraft-track = { workspace = true }
effectcraft-model = { workspace = true }
effectcraft-lottie = { workspace = true }

View File

@ -1,4 +1,5 @@
//! Align panel: `layer.align {edge, to}` and `layer.distribute {mode}`.
//! Align panel: `layer.align {edge, to}` and `layer.distribute {mode}`; the arrow keys'
//! `layer.nudge {x, y}`.
//!
//! Layers are aligned by their content bounds in comp space (source size, text glyphs, shape
//! contents through the layer's transform and its parents'). Each layer moves by a comp-space
@ -8,11 +9,11 @@
use effectcraft_geom::{Mat4, Vec3};
use effectcraft_keyframe::Value as KValue;
use effectcraft_project::{ItemId, LayerId};
use effectcraft_project::{ItemId, Layer, LayerId};
use effectcraft_render::EvalCtx;
use serde_json::{Value, json};
use super::{CommandSpec, bad, has_layers, layer_mut, layers_p, str_p};
use super::{CommandSpec, bad, f_p, has_layers, layer_mut, layers_p, merge_p, str_p};
use crate::{EngineError, Result, Session, cmd};
/// A layer's comp-space bounds and how a comp-space offset maps into its Position.
@ -43,27 +44,32 @@ fn placed(s: &Session, cid: ItemId, ids: &[LayerId]) -> Result<Vec<Placed>> {
x1 = x1.max(p.x);
y1 = y1.max(p.y);
}
// Parent chain: world(layer) = local(pn) ⋯ local(p1) · local(layer).
let mut par = Mat4::IDENTITY;
let mut cur = l.parent;
let mut guard = 0;
while let Some(pid) = cur {
guard += 1;
let Some(p) = comp.layer(pid).filter(|_| guard < 64) else { break };
par = ctx.local_matrix(p) * par;
cur = p.parent;
}
let to_parent = par.inverse().unwrap_or(Mat4::IDENTITY);
out.push(Placed { id, b: [x0, y0, x1, y1], to_parent });
out.push(Placed { id, b: [x0, y0, x1, y1], to_parent: to_parent(&ctx, l) });
}
Ok(out)
}
/// Move layers by comp-space offsets, as one undo step.
fn apply_moves(s: &mut Session, cid: ItemId, label: &str, moves: Vec<(LayerId, Mat4, [f64; 2])>) -> Result<Value> {
/// Comp space → the space of `l`'s Position (its parent's).
fn to_parent(ctx: &EvalCtx, l: &Layer) -> Mat4 {
// Parent chain: world(layer) = local(pn) ⋯ local(p1) · local(layer).
let mut par = Mat4::IDENTITY;
let mut cur = l.parent;
let mut guard = 0;
while let Some(pid) = cur {
guard += 1;
let Some(p) = ctx.comp.layer(pid).filter(|_| guard < 64) else { break };
par = ctx.local_matrix(p) * par;
cur = p.parent;
}
par.inverse().unwrap_or(Mat4::IDENTITY)
}
/// Move layers by comp-space offsets, as one undo step (merged into the last one with the same
/// `merge` key).
fn apply_moves(s: &mut Session, cid: ItemId, label: &str, moves: Vec<(LayerId, Mat4, [f64; 2])>, merge: Option<&str>) -> Result<Value> {
let t = s.time();
let moved: Vec<u64> = moves.iter().filter(|m| m.2[0].abs() > 1e-9 || m.2[1].abs() > 1e-9).map(|m| m.0.0).collect();
s.edit(label, None, |proj, _| {
s.edit(label, merge, |proj, _| {
for (lid, to_parent, d) in &moves {
if d[0].abs() <= 1e-9 && d[1].abs() <= 1e-9 {
continue;
@ -144,7 +150,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
(it.id, it.to_parent, d)
})
.collect();
apply_moves(s, cid, "Align Layers", moves)
apply_moves(s, cid, "Align Layers", moves, None)
}
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
@ -170,11 +176,29 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
(it.id, it.to_parent, if horiz { [dv, 0.0] } else { [0.0, dv] })
})
.collect();
apply_moves(s, cid, "Distribute Layers", moves)
apply_moves(s, cid, "Distribute Layers", moves, None)
}
/// The arrow keys in the Composition panel: move the selected (unlocked) layers by `x`, `y` comp
/// pixels; After Effects nudges 1 pixel at the panel's magnification, 10 with Shift. Each call
/// is one undo step.
fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
let c = "layer.nudge";
let d = [f_p(p, "x").unwrap_or(0.0), f_p(p, "y").unwrap_or(0.0)];
if !d.iter().all(|v| v.is_finite()) {
return Err(bad(c, "`x` and `y` must be numbers"));
}
let (cid, ids) = layers_p(s, p)?;
let ids = super::unlocked(s, cid, ids, c)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let ctx = EvalCtx { footage: Some(s.footage.as_ref()), expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, s.time()) };
let moves = ids.iter().filter_map(|id| comp.layer(*id)).filter(|l| l.transform().is_some()).map(|l| (l.id, to_parent(&ctx, l), d)).collect();
apply_moves(s, cid, "Nudge", moves, merge_p(p))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("layer.nudge", "Nudge Layers", [], None, "{x?, y? (comp pixels), layers?, merge?}", has_layers, nudge),
cmd!(
"layer.align",
"Align Layers",
@ -282,4 +306,38 @@ mod tests {
s.execute("layer.align", json!({"edge": "right", "layers": [a]})).unwrap();
assert!((bounds(&s, a)[2] - 400.0).abs() < 1e-6);
}
/// The arrow keys' nudge (#290): comp pixels through a parent's scale, sub-pixel steps, one
/// undo step each, a key at the current time when Position is animated; locked layers stay.
#[test]
fn nudge_moves_layers_by_comp_pixels() {
let mut s = session();
let pos = |s: &Session, id: u64| s.active_comp().unwrap().layer(LayerId(id)).unwrap().props.prop("transform/position").unwrap().value.as_vec3();
let a = solid(&mut s, "A", 40, 40, [100.0, 100.0]);
let parent = solid(&mut s, "P", 10, 10, [0.0, 0.0]);
s.execute("prop.set", json!({"layer": parent, "path": "transform/scale", "value": [200.0, 200.0, 100.0]})).unwrap();
let child = solid(&mut s, "C", 10, 10, [10.0, 10.0]);
s.execute("layer.setParent", json!({"layers": [child], "parent": parent})).unwrap();
let undo = s.history.undo.len();
s.execute("layer.nudge", json!({"layers": [a, child], "x": 1.0})).unwrap();
s.execute("layer.nudge", json!({"layers": [a, child], "y": -0.25})).unwrap();
assert_eq!(pos(&s, a), [101.0, 99.75, 0.0]);
assert_eq!(pos(&s, child), [10.5, 9.875, 0.0], "half as far under a 200 % parent");
assert_eq!(s.history.undo.len(), undo + 2, "one undo step per nudge");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(pos(&s, a), [101.0, 100.0, 0.0]);
// Animated Position gets a key at the current time.
s.execute("prop.addKey", json!({"layer": a, "path": "transform/position", "time": 0.0, "value": [0, 0, 0]})).unwrap();
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("layer.nudge", json!({"layers": [a], "x": 10.0})).unwrap();
let keys = s.active_comp().unwrap().layer(LayerId(a)).unwrap().props.prop("transform/position").unwrap().keys.clone();
assert_eq!(
keys.iter().map(|k| (k.time, k.value.as_vec3())).collect::<Vec<_>>(),
vec![(effectcraft_time::Tick::ZERO, [0.0; 3]), (s.time(), [10.0, 0.0, 0.0])]
);
// A locked layer doesn't move.
s.execute("layer.setSwitch", json!({"layers": [child], "switch": "lock", "value": true})).unwrap();
assert!(s.execute("layer.nudge", json!({"layers": [child], "x": 1.0})).is_err());
assert_eq!(pos(&s, child), [10.5, 10.0, 0.0]);
}
}

View File

@ -338,7 +338,8 @@ fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
out.extend(found.into_iter().map(|u| json!({"layer": lid.0, "prop": u})));
}
}
frontend(s, "timeline.revealProps", &json!({"props": out, "kind": kind}))?;
let layers: Vec<u64> = layers.iter().map(|l| l.0).collect();
frontend(s, "timeline.revealProps", &json!({"props": out, "kind": kind, "layers": layers}))?;
Ok(json!({"props": out}))
}

View File

@ -268,7 +268,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Composition...",
["Composition"],
Some("Cmd+N"),
"{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool, preserveResolution?: bool, renderer? classic3D|advanced3D, anchor?, open?}",
"{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool, preserveResolution?: bool, renderer?: classic3D|advanced3D, anchor?, open?}",
always,
new_comp
),
@ -277,7 +277,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Composition Settings...",
["Composition"],
Some("Cmd+K"),
"{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool (nested or in the render queue it shows only its own frames), preserveResolution?: bool (nested, it renders at full size), pixelAspect?, renderer? classic3D|advanced3D}",
"{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool (nested or in the render queue it shows only its own frames), preserveResolution?: bool (nested, it renders at full size), pixelAspect?, renderer?: classic3D|advanced3D}",
has_comp,
settings
),

View File

@ -20,7 +20,12 @@ fn has_clip(s: &Session) -> std::result::Result<(), String> {
// Text editing pastes the system clipboard's text (passed as `text`) or copied text.
return Ok(());
}
if s.state.clipboard.is_empty() && s.state.key_clipboard.is_empty() && s.state.effect_clipboard.is_empty() && s.state.link_clipboard.is_none() {
if s.state.clipboard.is_empty()
&& s.state.key_clipboard.is_empty()
&& s.state.effect_clipboard.is_empty()
&& s.state.contents_clipboard.is_empty()
&& s.state.link_clipboard.is_none()
{
Err("the clipboard is empty".into())
} else {
Ok(())
@ -104,6 +109,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
}
return Ok(json!({"effects": out}));
}
// Shape items selected → duplicate them inside their layers.
let items = super::prop_groups::selected_contents(s);
if !items.is_empty() {
return super::prop_groups::duplicate_contents(s, &items);
}
}
let (cid, ids) = layers_p(s, p)?;
let new = s.edit("Duplicate", None, |proj, st| {
@ -132,11 +142,24 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
if s.state.text_edit.is_some() && p.get("layers").is_none() {
return s.execute("text.delete", json!({}));
}
// Time Remap selected (its name, which selects all its keys) → time remapping off, not the
// layer (After Effects).
if p.get("layers").is_none() {
let remapped = super::layer_time::selected_time_remap(s);
if !remapped.is_empty() {
return super::layer_time::disable_time_remap(s, &remapped);
}
}
// Keyframes selected → delete keys; mask vertices → delete them; else layers.
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
return s.execute("keys.delete", json!({}));
}
if !s.state.selected_vertices.is_empty() && p.get("layers").is_none() {
// (Points selected with their mask, which selecting a mask does: the mask goes, below.)
let comp = s.active_comp();
let loose = s.state.selected_vertices.iter().any(|v| {
!s.state.selected_props.contains(&(v.layer, v.mask)) && comp.and_then(|c| c.layer(v.layer)).is_some_and(|l| l.props.find_group(v.mask).is_some())
});
if loose && p.get("layers").is_none() {
return s.execute("mask.deleteVertices", json!({}));
}
// Puppet pins selected → delete the pins (not their layer).
@ -147,6 +170,12 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("layers").is_none() && !super::effect::selected_effects(s).is_empty() {
return s.execute("effect.remove", json!({}));
}
// Masks, shape items, text animators or trackers selected → remove them.
if p.get("layers").is_none()
&& let Some(n) = super::prop_groups::remove_selected(s)?
{
return Ok(n);
}
let (cid, ids) = layers_p(s, p)?;
let ids = super::unlocked(s, cid, ids, "edit.clear")?;
// Children of deleted layers are unparented where they are (like After Effects).
@ -179,6 +208,7 @@ fn clear_clipboards(s: &mut Session) {
s.state.clipboard.clear();
s.state.key_clipboard.clear();
s.state.effect_clipboard.clear();
s.state.contents_clipboard.clear();
s.state.link_clipboard = None;
s.state.clip_is_keys = false;
}
@ -192,12 +222,22 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
s.state.link_clipboard = None;
s.state.effect_clipboard.clear();
s.state.contents_clipboard.clear();
return s.execute("keys.copy", json!({}));
}
// Effects selected (Effect Controls / timeline) → copy the effects.
if p.get("layers").is_none() && !super::effect::selected_effects(s).is_empty() {
return s.execute("effect.copy", json!({}));
}
// Shape items selected in a shape layer's Contents → copy them (not their layer).
if p.get("layers").is_none() {
let items = super::prop_groups::copy_contents(s);
if !items.is_empty() {
clear_clipboards(s);
s.state.contents_clipboard = items;
return Ok(json!({"contents": s.state.contents_clipboard.len()}));
}
}
let (cid, ids) = layers_p(s, p)?;
s.state.clip_is_keys = false;
let comp = s.project.comp(cid).ok_or(crate::EngineError::NoComp)?;
@ -333,6 +373,14 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> {
s.execute("keys.copy", json!({}))?;
return s.execute("keys.delete", json!({}));
}
// Shape items selected → copied, then removed from their layers (Paste moves them).
if p.get("layers").is_none() && super::effect::selected_effects(s).is_empty() {
let items = super::prop_groups::selected_contents(s);
if !items.is_empty() {
copy(s, p)?;
return super::prop_groups::cut_contents(s, &items);
}
}
copy(s, p)?;
delete(s, p)
}
@ -350,6 +398,9 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
if !s.state.effect_clipboard.is_empty() {
return s.execute("effect.paste", p.clone());
}
if !s.state.contents_clipboard.is_empty() {
return super::prop_groups::paste_contents(s, p);
}
let cid = super::comp_id(s, p)?;
let clip = s.state.clipboard.clone();
let new = s.edit("Paste", None, |proj, st| {
@ -631,8 +682,8 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{layers?} (keyframes when keys are selected)", layers_or_keys, cut),
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{layers?} (keyframes when keys are selected)", layers_or_keys, copy),
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{layers?} (keyframes, effects or shape items when they are selected)", layers_or_keys, cut),
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{layers?} (keyframes, effects or shape items when they are selected)", layers_or_keys, copy),
cmd!(
"edit.copyWithPropertyLinks",
"Copy with Property Links",
@ -652,10 +703,18 @@ pub fn specs() -> Vec<CommandSpec> {
|s, p| copy_links(s, p, true)
),
cmd!("edit.copyExpressionOnly", "Copy Expression Only", ["Edit"], None, "{}", has_props_or_layers, copy_expression_only),
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{} (layers, keyframes at the CTI, or property links / expressions)", has_clip, paste),
cmd!(
"edit.paste",
"Paste",
["Edit"],
Some("Cmd+V"),
"{} (layers, keyframes at the CTI, effects, shape items into the selected shape layers, or property links / expressions)",
has_clip,
paste
),
cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{layers?, prop?|path?, time?}", has_key_clip, paste_reversed),
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?}", has_layers, duplicate),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?} (effects or shape items when they are selected)", has_layers, duplicate),
cmd!("edit.splitLayer", "Split Layer", ["Edit"], Some("Cmd+Shift+D"), "{layers?}", has_layers, split),
cmd!("edit.liftWorkArea", "Lift Work Area", ["Edit"], None, "{layers?}", has_comp, lift),
cmd!("edit.extractWorkArea", "Extract Work Area", ["Edit"], None, "{layers?}", has_comp, extract),

View File

@ -301,6 +301,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
fx.iter().filter_map(|(l, u)| comp.layer(*l).and_then(|l| l.effects()).and_then(|e| e.groups().find(|g| g.uid == *u)).cloned()).collect();
s.state.clipboard.clear();
s.state.key_clipboard.clear();
s.state.contents_clipboard.clear();
s.state.link_clipboard = None;
s.state.clip_is_keys = false;
let n = groups.len();
@ -414,9 +415,36 @@ fn list(_: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(v))
}
impl Session {
/// The named channels of layer `lid` in the active comp at the current time: its
/// multi-layer OpenEXR footage's layers and channels (`diffuse.R`, `depth.Z`, …), which
/// EXtractoR and the 3D Channel effects read. `None` for other layers.
pub fn layer_channels(&self, lid: LayerId) -> Option<std::sync::Arc<effectcraft_raster::AuxChannels>> {
let layer = self.active_comp()?.layer(lid)?;
let effectcraft_project::LayerSource::Footage { item } = layer.source else { return None };
let effectcraft_project::ItemKind::Footage(f) = &self.project.item(item)?.kind else { return None };
self.footage.aux(item, f, layer.layer_time(self.time()))
}
}
/// `layer.channels`: the layers and channels of a layer's multi-layer OpenEXR footage, and
/// what each layer shows in red, green, blue and alpha (EXtractoR's popups).
fn channels(s: &mut Session, p: &Value) -> Result<Value> {
let (_, lid) = super::layer_p(s, p, "layer.channels")?;
let Some(aux) = s.layer_channels(lid) else { return Ok(json!({"channels": [], "layers": []})) };
let layers: Vec<Value> = aux.layers().into_iter().map(|l| json!({"name": l, "rgba": aux.layer_rgba(l)})).collect();
Ok(json!({"channels": aux.names(), "layers": layers}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("effect.apply", "Apply Effect", [], None, "{effect: id|name (e.g. Gaussian Blur), layers?}", has_layers, apply),
crate::query!(
"layer.channels",
"Layer Channels",
"{layer?} → {channels: [name], layers: [{name, rgba: [r, g, b, a channel]}]} — a multi-layer OpenEXR layer's channels (EXtractoR's red / green / blue / alpha take these names)",
channels
),
cmd!(
"effect.pickColor",
"Pick Effect Colour",

View File

@ -1,10 +1,11 @@
//! File menu.
use effectcraft_color::Label;
use effectcraft_project::{FootageKind, ItemKind, Project};
use effectcraft_project::{FootageKind, ItemId, ItemKind, LayerSource, Project};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, str_p};
use super::{CommandSpec, always, b_p, bad, str_p};
use crate::sequence::{SequenceOptions, Source};
use crate::{EngineError, Result, Session, cmd};
fn has_path(s: &Session) -> std::result::Result<(), String> {
@ -101,7 +102,215 @@ fn revert(s: &mut Session, _: &Value) -> Result<Value> {
open(s, &json!({"path": path}))
}
/// File ▸ Import; with `addToComp` (files dropped on the Timeline or the Composition viewer) they
/// also become layers of the active comp ([`add_to_comp`]).
///
/// With `background` (files dropped on the window or picked in the Import dialog) the files are
/// probed on a background thread, so decoding a slow video doesn't freeze the app: the job shows
/// the file being read and how many are left (Window ▸ Progress, `jobs.list`), then adds them in
/// one undo step, selects them and reports how many arrived ("Imported 3 items").
fn import_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let target = s.active_comp_id();
let mut pending = prepare_import(s, p)?;
if b_p(p, "background").unwrap_or(false) && !pending.sources.is_empty() {
let sources = std::mem::take(&mut pending.sources);
let total = sources.len();
let label = match sources.first() {
Some(one) if total == 1 => format!("Importing {}", one.name()),
_ => format!("Importing {total} files"),
};
let p = p.clone();
return s.spawn_task("import", label, false, move |ctl| {
let mut probed = Vec::with_capacity(total);
for (k, src) in sources.iter().enumerate() {
ctl.message(format!("{} ({} of {total})", src.name(), k + 1));
if !ctl.progress(k as u64, total as u64) {
return Err(crate::render_queue::CANCELLED.into());
}
probed.push(pending.prober.probe(src));
}
ctl.progress(total as u64, total as u64);
let apply: crate::jobs::Apply = Box::new(move |s: &mut Session| {
let made = pending.comps.len() + probed.iter().filter(|r| r.is_ok()).count();
let mut r = add_footage(s, pending, probed)?;
finish_import(s, &mut r, target, &p);
let mut toast = format!("Imported {made} item{}", if made == 1 { "" } else { "s" });
let errors: Vec<&str> = r["errors"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
match errors.as_slice() {
[] => {}
[one] => toast = format!("{toast}. {one}"),
[first, rest @ ..] => toast = format!("{toast}. {first} (and {} more)", rest.len()),
}
r["toast"] = json!(toast);
Ok(r)
});
Ok(apply)
});
}
let probed = pending.sources.iter().map(|src| pending.prober.probe(src)).collect();
let mut r = add_footage(s, pending, probed)?;
finish_import(s, &mut r, target, p);
Ok(r)
}
/// After an import (its result `r`): with `addToComp`, the new items become layers of `target`;
/// what couldn't be added joins the result's errors.
fn finish_import(s: &mut Session, r: &mut Value, target: Option<ItemId>, p: &Value) {
let errors = add_to_comp(s, r, target, p);
if !errors.is_empty()
&& let Some(e) = r.get_mut("errors").and_then(Value::as_array_mut)
{
e.extend(errors.into_iter().map(Value::from));
}
}
/// With `addToComp`, put what an import (its result `r`) made into `target`, the comp that was
/// active before it, one layer under the other, at the drop's `time`, `index` and `position`
/// (as in `layer.addItem`): the comps of layered files (their layers' footage stays in the
/// Project panel) and the other footage. `target` stays open. Returns what couldn't be added.
pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option<ItemId>, p: &Value) -> Vec<String> {
let Some(cid) = target.filter(|_| p.get("addToComp").and_then(Value::as_bool).unwrap_or(false)) else { return vec![] };
let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::<Vec<u64>>();
let comps = ids("comps");
let in_comps: std::collections::HashSet<u64> = comps
.iter()
.filter_map(|c| s.project.comp(ItemId(*c)))
.flat_map(|c| c.layers.iter())
.filter_map(|l| match l.source {
LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } => Some(item.0),
_ => None,
})
.collect();
let add: Vec<u64> = comps.iter().copied().chain(ids("items").into_iter().filter(|i| !in_comps.contains(i))).collect();
let mut errors = vec![];
for (k, item) in add.into_iter().enumerate() {
let mut params = json!({"comp": cid.0, "item": item, "time": p.get("time"), "position": p.get("position")});
if let Some(i) = p.get("index").and_then(Value::as_u64) {
params["index"] = json!(i.saturating_add(k as u64));
}
if let Err(e) = s.execute("layer.addItem", params) {
errors.push(e.to_string());
}
}
if s.active_comp_id() != Some(cid) {
s.open_comp(cid);
}
errors
}
/// File ▸ Import, all in one go (scripts and commands built on it); see [`import_cmd`].
pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
let pending = prepare_import(s, p)?;
let probed = pending.sources.iter().map(|src| pending.prober.probe(src)).collect();
add_footage(s, pending, probed)
}
/// An import whose layered files are already compositions: the files and image sequences left
/// to probe as footage, how, and what the import made so far.
struct PendingImport {
sources: Vec<Source>,
prober: Prober,
comps: Vec<u64>,
items: Vec<u64>,
errors: Vec<String>,
}
/// Everything probing a file needs, apart from the session (it can run on a background thread).
struct Prober {
importer: Option<std::sync::Arc<dyn crate::Importer>>,
services: std::sync::Arc<dyn crate::Services>,
/// Settings ▸ Import ▸ Report Missing Frames.
report_missing_frames: bool,
/// Settings ▸ Import ▸ Interpret Unlabeled Alpha As, for stills and for movies.
still_alpha: Option<effectcraft_project::AlphaMode>,
movie_alpha: Option<effectcraft_project::AlphaMode>,
/// Image sequences' frames per second: the import's `frameRate`, or Settings ▸ Import ▸
/// Sequence Footage.
sequence_rate: effectcraft_time::FrameRate,
/// PDF / Illustrator page (0-based).
page: u32,
/// Choose Layer: one layer of a Photoshop document.
psd_layer: Option<Value>,
}
/// A file (or image sequence) probed as footage.
struct Probed {
/// The Project panel name.
name: String,
footage: effectcraft_project::Footage,
/// Its alpha is unlabeled and Settings say Ask User: open Interpret Footage.
ask_alpha: bool,
warning: Option<String>,
}
impl Prober {
/// Probe a file or image sequence as footage; the error names the file.
fn probe(&self, src: &Source) -> std::result::Result<Probed, String> {
let path = src.path();
let name = src.name();
let fail = |e: String| format!("{path}: {e}");
// Data files (JSON, CSV, TSV) for data-driven animation: kept as text in the project.
if let Source::File(_) = src
&& let Some(f) = data_footage(self.services.as_ref(), path)
{
return f.map(|footage| Probed { name, footage, ask_alpha: false, warning: None }).map_err(fail);
}
let importer = self.importer.as_ref().ok_or_else(|| fail("media import is not available in this build".into()))?;
let mut f = crate::sequence::probe(importer.as_ref(), src, self.sequence_rate).map_err(fail)?;
let mut warning = None;
// Settings ▸ Import ▸ Report Missing Frames (numbered sequences).
if f.kind == FootageKind::Sequence
&& !f.alphabetical
&& self.report_missing_frames
&& let Some(gaps) = crate::sequence::missing_report(&f.sequence)
{
warning = Some(format!("{name}: {gaps} (shown as placeholders)"));
}
// Settings ▸ Import ▸ Interpret Unlabeled Alpha As: images only (a 3D model renders its
// own alpha, and audio and data have none).
let mut ask_alpha = false;
if matches!(f.kind, FootageKind::Still | FootageKind::Sequence | FootageKind::Video)
&& f.alpha != effectcraft_project::AlphaMode::Ignore
&& !alpha_is_labeled(path, &f.codec)
{
let setting = if f.kind == FootageKind::Video { self.movie_alpha } else { self.still_alpha };
match setting {
Some(a) => f.alpha = a,
None => ask_alpha = true,
}
}
// Page: another page of a PDF / Illustrator file.
if self.page > 0 && matches!(f.codec.as_str(), "PDF" | "AI") {
let doc = self
.services
.read_file(path)
.map_err(|e| e.to_string())
.and_then(|b| effectcraft_pdf::parse_page(&b, self.page as usize).map_err(|e| e.to_string()))
.map_err(fail)?;
(f.width, f.height) = doc.pixel_size();
f.page = self.page;
}
// Choose Layer: one layer of a Photoshop document (document-sized).
if let Some(sel) = &self.psd_layer
&& f.codec == "PSD"
{
let (index, name) = self
.services
.read_file(path)
.ok()
.and_then(|b| effectcraft_psd::Psd::parse(b).ok())
.and_then(|d| find_psd_layer(&d, sel))
.ok_or_else(|| fail(format!("no layer {sel}")))?;
f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false, embedded: None, placed: false });
}
Ok(Probed { name, footage: f, ask_alpha, warning })
}
}
/// The first part of an import: read the parameters, note the files as recent footage, and
/// import layered files (Photoshop, PDF, Illustrator, EPS) as compositions when `importAs` (or,
/// for dropped files, Default Drag Import As) asks for that. The other files are left to probe.
fn prepare_import(s: &mut Session, p: &Value) -> Result<PendingImport> {
let paths: Vec<String> = match p.get("paths").or(p.get("path")) {
Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
Some(Value::String(x)) => vec![x.clone()],
@ -130,8 +339,8 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
},
};
let mut paths = paths;
let mut out_comps = vec![];
let mut ids = vec![];
let mut comps = vec![];
let mut items = vec![];
let mut errors = vec![];
if let Some(retain) = retain {
let mut rest = vec![];
@ -141,9 +350,9 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
Ok(b) if effectcraft_pdf::sniff(&b).is_some() && !path.to_ascii_lowercase().ends_with(".svg") => {
// PDF / Illustrator / EPS: one layer per file layer.
match import_vector_comp(s, &path, &b, page) {
Ok((comp, items)) => {
out_comps.push(comp);
ids.extend(items);
Ok((comp, made)) => {
comps.push(comp);
items.extend(made);
}
Err(e) => errors.push(e.to_string()),
}
@ -155,127 +364,84 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
}
};
match import_psd_comp(s, &path, bytes, retain) {
Ok((comp, items, warnings)) => {
out_comps.push(comp);
ids.extend(items);
Ok((comp, made, warnings)) => {
comps.push(comp);
items.extend(made);
errors.extend(warnings);
}
Err(e) => errors.push(format!("{path}: {e}")),
}
}
paths = rest;
if paths.is_empty() {
if let Some(c) = out_comps.first() {
s.open_comp(effectcraft_project::ItemId(*c));
}
return Ok(json!({"items": ids, "comps": out_comps, "errors": errors}));
}
}
let mut probed = vec![];
let psd_layer = p.get("layer").cloned();
let seq_rate = effectcraft_time::FrameRate::from_f64(s.prefs.import.sequence_fps);
let mut ask_alpha = vec![];
// Numbered stills as image sequences (the Import dialog's "<format> Sequence" and Force
// Alphabetical Order), at `frameRate` or Settings ▸ Import ▸ Sequence Footage.
let opts = SequenceOptions::from_params(p);
let fps = match p.get("frameRate") {
None | Some(Value::Null) => s.prefs.import.sequence_fps,
Some(v) => v.as_f64().filter(|x| *x > 0.0 && *x <= 999.0).ok_or_else(|| bad("file.import", "frameRate: frames per second, up to 999"))?,
};
let sources = crate::sequence::group(s.services.as_ref(), &paths, opts);
let prober = Prober {
importer: s.importer.clone(),
services: s.services.clone(),
report_missing_frames: s.prefs.import.report_missing_frames,
still_alpha: s.prefs.unlabeled_alpha(false),
movie_alpha: s.prefs.unlabeled_alpha(true),
sequence_rate: effectcraft_time::FrameRate::from_f64(fps),
page,
psd_layer: p.get("layer").cloned(),
};
Ok(PendingImport { sources, prober, comps, items, errors })
}
/// The last part of an import: add the probed files to the project (one undo step) and select
/// everything the import made, so the Project panel shows it.
fn add_footage(s: &mut Session, pending: PendingImport, probed: Vec<std::result::Result<Probed, String>>) -> Result<Value> {
let PendingImport { comps, mut items, mut errors, .. } = pending;
let mut found = vec![];
let mut warnings = vec![];
for path in &paths {
// Data files (JSON, CSV, TSV) for data-driven animation: kept as text in the project.
if let Some(f) = data_footage(s, path) {
match f {
Ok(f) => probed.push((path.clone(), f)),
Err(e) => errors.push(format!("{path}: {e}")),
for r in probed {
match r {
Ok(f) => {
warnings.extend(f.warning.clone());
found.push(f);
}
continue;
}
let Some(importer) = s.importer.clone() else {
errors.push(format!("{path}: media import is not available in this build"));
continue;
};
match importer.probe(path) {
Ok(mut f) => {
// Settings ▸ Import ▸ Report Missing Frames.
if f.kind == FootageKind::Sequence
&& s.prefs.import.report_missing_frames
&& let Some(gaps) = missing_frames(&f.sequence)
{
warnings.push(format!("{path}: {gaps}"));
}
// Settings ▸ Import ▸ Interpret Unlabeled Alpha As.
if f.alpha != effectcraft_project::AlphaMode::Ignore && !alpha_is_labeled(path, &f.codec) {
match s.prefs.unlabeled_alpha(f.kind == FootageKind::Video) {
Some(a) => f.alpha = a,
None => ask_alpha.push(paths.iter().position(|x| x == path).unwrap_or(0)),
}
}
// Settings ▸ Import ▸ Sequence Footage frames per second.
if f.kind == FootageKind::Sequence {
let r = seq_rate;
let frames = f.frame_rate.frame_at(f.duration);
f.frame_rate = r;
f.duration = r.tick_of(frames.max(1));
}
// Page: another page of a PDF / Illustrator file.
if page > 0 && matches!(f.codec.as_str(), "PDF" | "AI") {
match s
.services
.read_file(path)
.map_err(|e| e.to_string())
.and_then(|b| effectcraft_pdf::parse_page(&b, page as usize).map_err(|e| e.to_string()))
{
Ok(doc) => {
(f.width, f.height) = doc.pixel_size();
f.page = page;
}
Err(e) => {
errors.push(format!("{path}: {e}"));
continue;
}
}
}
// Choose Layer: one layer of a Photoshop document (document-sized).
if let Some(sel) = &psd_layer
&& f.codec == "PSD"
{
match s.services.read_file(path).ok().and_then(|b| effectcraft_psd::Psd::parse(b).ok()).and_then(|d| find_psd_layer(&d, sel)) {
Some((index, name)) => {
f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false, embedded: None, placed: false })
}
None => {
errors.push(format!("{path}: no layer {sel}"));
continue;
}
}
}
probed.push((path.clone(), f))
}
Err(e) => errors.push(format!("{path}: {e}")),
Err(e) => errors.push(e),
}
}
let mut asked: Vec<u64> = vec![];
s.edit("Import", None, |proj, st| {
for (path, f) in probed {
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone());
let name = match &f.layer {
Some(l) => format!("{}/{name}", l.name),
None if f.page > 0 => format!("{name} (Page {})", f.page + 1),
None => name,
};
let label = match f.kind {
FootageKind::Still | FootageKind::Sequence => Label::Lavender,
FootageKind::Audio => Label::SeaFoam,
FootageKind::Video | FootageKind::Model => Label::Aqua,
FootageKind::Data => Label::Sandstone,
};
let ask = ask_alpha.contains(&paths.iter().position(|x| *x == path).unwrap_or(usize::MAX));
let id = proj.add_item(&name, label, None, ItemKind::Footage(f));
ids.push(id.0);
if ask {
asked.push(id.0);
let made: Vec<ItemId> = comps.iter().map(|c| ItemId(*c)).collect();
if !found.is_empty() {
s.edit("Import", None, |proj, st| {
let mut selection = made;
for f in found {
let name = f.name;
let footage = f.footage;
let name = match &footage.layer {
Some(l) => format!("{}/{name}", l.name),
None if footage.page > 0 => format!("{name} (Page {})", footage.page + 1),
None => name,
};
let label = match footage.kind {
FootageKind::Still | FootageKind::Sequence => Label::Lavender,
FootageKind::Audio => Label::SeaFoam,
FootageKind::Video | FootageKind::Model => Label::Aqua,
FootageKind::Data => Label::Sandstone,
};
let id = proj.add_item(&name, label, None, ItemKind::Footage(footage));
items.push(id.0);
if f.ask_alpha {
asked.push(id.0);
}
selection.push(id);
}
st.project_selection = vec![id];
}
Ok(())
})?;
if let Some(c) = out_comps.first() {
s.open_comp(effectcraft_project::ItemId(*c));
st.project_selection = selection;
Ok(())
})?;
}
if let Some(c) = comps.first() {
s.open_comp(ItemId(*c));
}
for w in &warnings {
s.events.push(crate::Event::Toast { message: w.clone(), error: true });
@ -284,7 +450,7 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(first) = asked.first() {
s.events.push(crate::Event::Frontend { command: "file.interpretFootage".into(), params: json!({"item": first, "reason": "unlabeledAlpha"}) });
}
Ok(json!({"items": ids, "comps": out_comps, "errors": errors, "warnings": warnings, "unlabeledAlpha": asked}))
Ok(json!({"items": items, "comps": comps, "errors": errors, "warnings": warnings, "unlabeledAlpha": asked}))
}
/// Whether a file format states how its alpha is stored (PNG, GIF, WebP and PSD are straight by
@ -294,29 +460,6 @@ fn alpha_is_labeled(path: &str, codec: &str) -> bool {
matches!(ext.as_str(), "png" | "gif" | "webp" | "psd" | "psb" | "exr" | "svg" | "jpg" | "jpeg" | "ai" | "pdf" | "eps") || codec.eq_ignore_ascii_case("PSD")
}
/// Gaps in an image sequence's frame numbers ("missing frames 4–6, 9"), from the digit run at the
/// end of each file name. `None` when the numbering is continuous (or there is none).
pub(crate) fn missing_frames(files: &[String]) -> Option<String> {
let num = |f: &String| -> Option<i64> {
let stem = std::path::Path::new(f).file_stem()?.to_string_lossy().to_string();
let digits: String = stem.chars().rev().take_while(char::is_ascii_digit).collect::<Vec<_>>().into_iter().rev().collect();
digits.parse().ok()
};
let mut ns: Vec<i64> = files.iter().filter_map(num).collect();
ns.sort_unstable();
ns.dedup();
let mut gaps = vec![];
let mut total = 0;
for w in ns.windows(2) {
if w[1] - w[0] > 1 {
let (a, b) = (w[0] + 1, w[1] - 1);
total += b - a + 1;
gaps.push(if a == b { a.to_string() } else { format!("{a}–{b}") });
}
}
(!gaps.is_empty()).then(|| format!("{total} missing frame{} ({})", if total == 1 { "" } else { "s" }, gaps.join(", ")))
}
/// A Photoshop layer by index or name (pixel layers only).
fn find_psd_layer(d: &effectcraft_psd::Psd, sel: &Value) -> Option<(usize, String)> {
let l = match sel {
@ -368,13 +511,13 @@ fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32) -> R
pub const DATA_EXTENSIONS: &[&str] = &["json", "csv", "tsv"];
/// A data footage item for `path` (JSON / CSV / TSV), `None` for other files.
pub(crate) fn data_footage(s: &Session, path: &str) -> Option<std::result::Result<effectcraft_project::Footage, String>> {
pub(crate) fn data_footage(services: &dyn crate::Services, path: &str) -> Option<std::result::Result<effectcraft_project::Footage, String>> {
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
if !DATA_EXTENSIONS.contains(&ext.as_str()) {
return None;
}
Some((|| {
let bytes = s.services.read_file(path).map_err(|e| e.to_string())?;
let bytes = services.read_file(path).map_err(|e| e.to_string())?;
let text = String::from_utf8(bytes).map_err(|_| "data files must be UTF-8 text".to_string())?;
if ext == "json" {
serde_json::from_str::<Value>(text.trim_start_matches('\u{feff}')).map_err(|e| format!("invalid JSON: {e}"))?;
@ -498,11 +641,20 @@ fn render_backend(s: &mut Session, p: &Value) -> Result<Value> {
pub fn backend_status(s: &Session) -> Value {
let adapter = s.accel.as_ref().map(|a| a.name());
let gpu = s.project.settings.gpu_acceleration;
// Why it renders on the CPU (#262).
let why = if !gpu {
Some("the project's renderer is Mercury Software Only".to_string())
} else if adapter.is_none() {
Some(s.accel_note.clone().unwrap_or_else(|| "no GPU compositor is attached".into()))
} else {
None
};
json!({
"renderer": if gpu { "Mercury GPU Acceleration" } else { "Mercury Software Only" },
"gpuAcceleration": gpu,
"adapter": adapter,
"active": if gpu && adapter.is_some() { "gpu" } else { "cpu" },
"why": why,
})
}
@ -528,9 +680,9 @@ pub fn specs() -> Vec<CommandSpec> {
"File...",
["File", "Import"],
Some("Cmd+I"),
"{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As)}",
"{paths: [string] (files or folders), sequence?: bool (default true: numbered stills of one run import as one image sequence; one picked file brings its whole run, several picked files that range), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}",
always,
import
import_cmd
),
cmd!(
"file.projectSettings",

View File

@ -10,6 +10,7 @@ use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
use super::{CommandSpec, always, b_p, bad, f_p, has_comp, has_project_selection, str_p};
use crate::sequence::SequenceOptions;
use crate::{EngineError, Result, Session, cmd};
fn has_footage_selection(s: &Session) -> std::result::Result<(), String> {
@ -438,8 +439,8 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
&& p.get("steps").is_none()
&& is_js_script(path)
{
if path.to_ascii_lowercase().ends_with(".jsxbin") {
return Err(bad("file.runScript", "binary .jsxbin scripts are not supported: run the .jsx source"));
if is_jsxbin(path) {
return Err(bad("file.runScript", JSXBIN));
}
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
let code = String::from_utf8_lossy(&bytes).to_string();
@ -487,7 +488,14 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
/// command scripts.
fn is_js_script(path: &str) -> bool {
let l = path.to_ascii_lowercase();
l.ends_with(".jsx") || l.ends_with(".js") || l.ends_with(".jsxbin")
l.ends_with(".jsx") || l.ends_with(".js") || is_jsxbin(path)
}
/// Compiled `.jsxbin` scripts are an undocumented binary encoding, not JavaScript.
const JSXBIN: &str = "binary .jsxbin scripts are not supported: run the .jsx source";
fn is_jsxbin(name: &str) -> bool {
name.to_ascii_lowercase().ends_with(".jsxbin")
}
/// A script error becomes a toast and a command error.
@ -519,6 +527,11 @@ fn run_js(s: &mut Session, code: &str, name: &str, console: bool) -> Result<Valu
fn script_run(s: &mut Session, p: &Value) -> Result<Value> {
let code = str_p(p, "code").ok_or_else(|| bad("script.run", "missing `code` (JavaScript)"))?.to_string();
let name = str_p(p, "name").unwrap_or("script").to_string();
// A compiled script (named `.jsxbin`, or its text starts with the format's header) would only
// fail to parse at 1:1.
if is_jsxbin(&name) || code.trim_start_matches('\u{feff}').trim_start().starts_with("@JSXBIN@") {
return Err(bad("script.run", JSXBIN));
}
run_js(s, &code, &name, super::b_p(p, "console").unwrap_or(false))
}
@ -540,6 +553,38 @@ pub(crate) struct Interpretation {
fields: Option<effectcraft_project::FieldOrder>,
profile: Option<Option<effectcraft_project::ColorSpace>>,
linear: Option<bool>,
/// Start Timecode.
start: Option<StartTimecode>,
}
/// Interpret Footage ▸ Start Timecode.
#[derive(Clone, Debug)]
enum StartTimecode {
/// Use Source File Timecode.
Source,
/// Override Start: a frame number.
Frame(i64),
/// Override Start: timecode, read at the footage's frame rate.
Timecode(String),
}
impl StartTimecode {
/// The highest Override Start, in frames.
const MAX: i64 = 1_000_000_000;
/// The frame (at `rate`) to store, `Some(None)` for Use Source; `None` when the timecode
/// doesn't fit at `rate`.
fn frame(&self, rate: FrameRate) -> Option<Option<i64>> {
match self {
StartTimecode::Source => Some(None),
StartTimecode::Frame(n) => Some(Some(*n)),
StartTimecode::Timecode(t) => Self::read(t, rate).map(Some),
}
}
fn read(t: &str, rate: FrameRate) -> Option<i64> {
effectcraft_time::parse_timecode(t, rate, false, 0).ok().filter(|n| (0..=Self::MAX).contains(n))
}
}
impl Interpretation {
@ -586,12 +631,32 @@ impl Interpretation {
None => None,
};
it.linear = super::b_p(p, "linearLight");
// Start Timecode: Use Source File Timecode (`overrideStart: false`), or Override Start
// (timecode or a frame number).
let start_err = || bad(cmd, "startTimecode: timecode (0:00:41:20) or a frame number, 0 to 1e9");
it.start = match (super::b_p(p, "overrideStart"), p.get("startTimecode")) {
(Some(false), _) => Some(StartTimecode::Source),
(_, None | Some(Value::Null)) => None,
(_, Some(Value::String(t))) => {
// Read at 30 fps to check it; it's read again at the footage's rate.
StartTimecode::read(t, FrameRate::FPS_30).ok_or_else(start_err)?;
Some(StartTimecode::Timecode(t.clone()))
}
(_, Some(v)) => Some(StartTimecode::Frame(
v.as_f64().filter(|x| x.is_finite() && (0.0..=StartTimecode::MAX as f64).contains(x)).map(|x| x.round() as i64).ok_or_else(start_err)?,
)),
};
Ok(it)
}
pub(crate) fn apply(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) {
let before = f.frame_rate;
self.apply_settings(f, guessed);
// A sequence's length is its frames.
if f.kind == FootageKind::Sequence && !f.sequence.is_empty() {
f.sync_sequence_duration();
return;
}
// Conforming keeps the frames and changes how long they last (12 frames at 30 fps are
// 0.4 s; conformed to 12 fps, 1 s). Stills have no frame rate of their own.
let (old, new) = (before.as_f64(), f.frame_rate.as_f64());
@ -640,6 +705,10 @@ impl Interpretation {
if let Some(v) = self.linear {
f.linear_light = v;
}
// After the frame rate: timecode is read at the footage's rate.
if let Some(start) = self.start.as_ref().and_then(|t| t.frame(f.frame_rate)) {
f.start_timecode = start;
}
}
}
@ -716,7 +785,8 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
Some(ItemKind::Footage(f)) => Some(json!({
"item": i.0, "alpha": format!("{:?}", f.alpha), "matteColor": f.premul_color, "invertAlpha": f.invert_alpha,
"fields": f.fields.label(), "pixelAspect": f.pixel_aspect, "loop": f.loop_count, "frameRate": f.frame_rate.as_f64(),
"linearLight": f.linear_light,
"linearLight": f.linear_light, "startTimecode": f.timecode(0), "overrideStart": f.start_timecode.is_some(),
"frames": f.sequence_frames(),
})),
_ => None,
})
@ -763,8 +833,11 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("file.replaceFootage", "missing `path`"))?.to_string();
let item = *items_p(s, p).first().ok_or_else(|| bad("file.replaceFootage", "select footage"))?;
let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?;
let f = importer.probe(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone());
// A numbered still brings its image sequence, as on import (`sequence: false` for one frame).
let opts = SequenceOptions::from_params(p);
let src = crate::sequence::source_of(s.services.as_ref(), &path, opts);
let f = crate::sequence::probe(importer.as_ref(), &src, s.sequence_rate()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let name = src.name();
s.edit("Replace Footage", None, |proj, _| {
let it = proj.item_mut(item).ok_or_else(|| bad("file.replaceFootage", "no such item"))?;
it.name = name;
@ -831,11 +904,14 @@ fn replace_with_solid(s: &mut Session, p: &Value) -> Result<Value> {
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
let items = items_p(s, p);
let Some(importer) = s.importer.clone() else { return Err(EngineError::Other("media import is not available in this build".into())) };
if s.importer.is_none() {
return Err(EngineError::Other("media import is not available in this build".into()));
}
let mut updates = vec![];
for i in &items {
if let Some(ItemKind::Footage(f)) = s.project.item(*i).map(|x| &x.kind) {
let mut nf = match importer.probe(&f.path) {
// An image sequence picks up frames added to (or removed from) its run.
let mut nf = match s.probe_footage(&f.path) {
Ok(n) => n,
Err(_) => Footage { missing: true, ..f.clone() },
};
@ -843,6 +919,13 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
nf.alpha = f.alpha;
nf.loop_count = f.loop_count;
nf.pixel_aspect = f.pixel_aspect;
nf.start_timecode = f.start_timecode;
if nf.kind == FootageKind::Sequence && f.kind == FootageKind::Sequence {
nf.frame_rate = f.frame_rate;
nf.native_rate = f.native_rate;
nf.alphabetical = f.alphabetical;
nf.sync_sequence_duration();
}
updates.push((*i, nf));
}
}
@ -942,7 +1025,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Main...",
["File", "Interpret Footage"],
Some("Cmd+Alt+G"),
"{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?}",
"{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}",
has_footage_selection,
interpret
),
@ -964,7 +1047,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_interpretation,
apply_interpretation
),
cmd!("file.replaceFootage", "File...", ["File", "Replace Footage"], Some("Cmd+H"), "{path, item?}", has_footage_selection, replace_footage),
cmd!(
"file.replaceFootage",
"File...",
["File", "Replace Footage"],
Some("Cmd+H"),
"{path, item?, sequence?: bool (default true: a numbered still brings its image sequence), alphabetical?: bool}",
has_footage_selection,
replace_footage
),
cmd!(
"file.replaceWithPlaceholder",
"Placeholder...",

View File

@ -47,6 +47,15 @@ fn source_timing(s: &Session, item: ItemId) -> (Option<Tick>, effectcraft_time::
}
}
/// The Footage panel's current time: footage shows its timecode (Start Timecode), anything else
/// time from 0.
pub fn timecode(item: Option<&effectcraft_project::Item>, fr: effectcraft_time::FrameRate, frame: i64) -> String {
match item.map(|i| &i.kind) {
Some(ItemKind::Footage(f)) => f.timecode(frame),
_ => effectcraft_time::format_timecode_ae(frame, fr, false),
}
}
fn source_time(s: &Session, p: &Value, item: ItemId) -> Option<Tick> {
let (_, fr) = source_timing(s, item);
if let Some(t) = f_p(p, "time") {
@ -194,9 +203,11 @@ fn can_edit(s: &Session) -> std::result::Result<(), String> {
fn info(s: &mut Session, _: &Value) -> Result<Value> {
let Some(v) = &s.state.footage_panel else { return Ok(Value::Null) };
let (dur, fr) = source_timing(s, v.item);
let name = s.project.item(v.item).map(|i| i.name.clone()).unwrap_or_default();
let item = s.project.item(v.item);
let name = item.map(|i| i.name.clone()).unwrap_or_default();
let frame = fr.frame_at(v.time);
Ok(
json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": fr.frame_at(v.time), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64()}),
json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": frame, "timecode": timecode(item, fr, frame), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64()}),
)
}

View File

@ -46,6 +46,14 @@ pub fn specs() -> Vec<CommandSpec> {
),
fe!("playback.cacheWhenIdle", "Cache Frames When Idle", ["Composition", "Preview"], None, "{value?}", always),
fe!("playback.audio", "Audio", ["Composition", "Preview"], None, "{value?: include audio in previews}", always),
fe!(
"playback.scrubAudio",
"Scrub Audio",
[],
None,
"{time? (s, default the current time)} — plays one frame of the comp's audio there (Ctrl/Cmd-drag the current time)",
has_comp
),
// Viewer.
fe!("view.zoomIn", "Zoom In", ["View"], Some("."), "{}", has_comp),
fe!("view.zoomOut", "Zoom Out", ["View"], Some(","), "{}", has_comp),
@ -64,13 +72,21 @@ pub fn specs() -> Vec<CommandSpec> {
fe!("view.options", "View Options...", ["View"], Some("Cmd+Alt+U"), "{}", has_comp),
fe!("view.layerControls", "Show Layer Controls", ["View"], Some("Cmd+Shift+H"), "{value?}", always),
fe!("view.fullScreen", "Enter Full Screen", ["View"], Some("Ctrl+Cmd+F"), "{}", always),
fe!(
"view.newViewer",
"New Viewer",
["View"],
Some("Alt+Shift+N"),
"{} — another Composition viewer of the active comp; the one in use is locked",
has_comp
),
// Panels and workspaces.
fe!(
"window.panel",
"Show Panel",
[],
None,
"{panel: project|effectControls|composition|layer|timeline|info|audio|preview|effectsPresets|properties|character|paragraph|align|tracker|wiggler|smoother|motionSketch|paint|brushes|renderQueue|flowchart|history|markers|tools|lumetriScopes|footage|mediaBrowser|metadata|progress|contentAwareFill|createNullsFromPaths|vrCompEditor}",
"{panel: project|effectControls|composition|layer|timeline|info|audio|preview|effectsPresets|properties|character|paragraph|align|tracker|wiggler|smoother|motionSketch|paint|brushes|renderQueue|flowchart|history|markers|tools|lumetriScopes|footage|mediaBrowser|metadata|progress|contentAwareFill|createNullsFromPaths|vrCompEditor|easePresets}",
always
),
fe!(

View File

@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Other ArtCraft Apps",
[],
None,
"{app: photocraft|vectorcraft|filmcraft|lightcraft|printcraft|designcraft, kind?: page|github}",
"{app: photocraft|vectorcraft|filmcraft|lightcraft|pdfcraft|designcraft, kind?: page|github}",
always,
sibling
),

View File

@ -58,7 +58,7 @@ pub(crate) fn label_keys(s: &mut Session, p: &Value) -> Result<Value> {
pub fn amplitudes(buf: &[f32]) -> (f64, f64, f64) {
let n = (buf.len() / 2).max(1) as f64;
let (mut l, mut r) = (0.0f64, 0.0f64);
for c in buf.chunks_exact(2) {
for c in buf.as_chunks::<2>().0 {
l += c[0].abs() as f64;
r += c[1].abs() as f64;
}

View File

@ -1,7 +1,7 @@
//! Layer menu.
use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::{Justify, ShapePath, TextDoc, Value as KV};
use effectcraft_keyframe::{Justify, TextDoc, Value as KV};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{
Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Project, PropGroup, Quality, Solid, TrackMatte,
@ -24,13 +24,58 @@ pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> {
}
/// Insert a new layer above the selection (or at the top) and select it.
pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, mut layer: Layer) -> Result<LayerId> {
pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result<LayerId> {
insert_layer_at(proj, st, cid, layer, None)
}
/// `index`: an `index` parameter, the layer's 1-based place in the stack (1 = top; past the
/// bottom = the bottom), as 0-based.
pub(crate) fn index_p(p: &Value, cmd: &str) -> Result<Option<usize>> {
match p.get("index") {
None | Some(Value::Null) => Ok(None),
Some(v) => {
v.as_u64().filter(|i| *i >= 1).map(|i| Some(usize::try_from(i - 1).unwrap_or(usize::MAX))).ok_or_else(|| bad(cmd, "index: 1-based stack position"))
}
}
}
/// A `position` parameter: `[x, y]` in comp pixels (finite).
pub(crate) fn position_p(p: &Value, cmd: &str) -> Result<Option<[f64; 2]>> {
match p.get("position") {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_array()
.and_then(|a| Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]))
.filter(|c| c.iter().all(|x| x.is_finite()))
.map(Some)
.ok_or_else(|| bad(cmd, "position: [x, y] in comp pixels")),
}
}
/// Put a new layer's Position at `pos` (comp pixels).
pub(crate) fn place(l: &mut Layer, pos: Option<[f64; 2]>) {
if let Some([x, y]) = pos
&& let Some(pr) = l.props.prop_mut("transform/position")
{
let z = match pr.value {
KV::Vec3(v) => v[2],
_ => 0.0,
};
pr.value = KV::Vec3([x, y, z]);
}
}
/// [`insert_layer`] at stack position `at` (0 = top) instead of above the selected layer.
pub(crate) fn insert_layer_at(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, mut layer: Layer, at: Option<usize>) -> Result<LayerId> {
// Text and shape layers in Advanced 3D comps get Geometry Options (extrusion, bevels).
if proj.comp(cid).is_some_and(|c| c.renderer == effectcraft_project::Renderer::Advanced3D) {
super::model3d::add_geometry_options(&mut proj.next_id, &mut layer);
}
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let at = st.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)).unwrap_or(0);
let at = match at {
Some(i) => i.min(comp.layers.len()),
None => st.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)).unwrap_or(0),
};
let id = layer.id;
comp.layers.insert(at, layer);
st.selected_layers = vec![id];
@ -160,30 +205,6 @@ fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"layer": id.0}))
}
/// Contents for a new shape (one group with path + fill + stroke).
fn shape_contents(ids: &mut Ids, kind: &str, size: [f64; 2], fill: Option<[f64; 4]>, stroke: Option<([f64; 4], f64)>) -> Option<PropGroup> {
let (path, gname) = match kind {
"rect" | "rectangle" => (build::shape_rect(ids, size, [0.0, 0.0], 0.0), "Rectangle 1"),
"rounded" | "roundedRect" => (build::shape_rect(ids, size, [0.0, 0.0], size[0].min(size[1]) * 0.15), "Rectangle 1"),
"ellipse" => (build::shape_ellipse(ids, size, [0.0, 0.0]), "Ellipse 1"),
"star" => (build::shape_star(ids, true, 5.0, [0.0, 0.0], size[0] / 2.0, size[0] / 4.0), "Polystar 1"),
"polygon" => (build::shape_star(ids, false, 6.0, [0.0, 0.0], size[0] / 2.0, 0.0), "Polystar 1"),
_ => return None,
};
let mut items = vec![path];
if let Some((c, w)) = stroke {
items.push(build::shape_stroke(ids, c, w));
}
if let Some(c) = fill {
items.push(build::shape_fill(ids, c));
}
Some(build::shape_group(ids, gname, items))
}
fn c4(c: Option<[f32; 3]>, d: [f64; 4]) -> [f64; 4] {
c.map(|c| [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]).unwrap_or(d)
}
fn new_shape(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
@ -191,27 +212,24 @@ fn new_shape(s: &mut Session, p: &Value) -> Result<Value> {
let size = p
.get("size")
.and_then(Value::as_array)
.map(|a| [a[0].as_f64().unwrap_or(200.0), a.get(1).and_then(Value::as_f64).unwrap_or(200.0)])
.map(|a| [a.first().and_then(Value::as_f64).unwrap_or(200.0), a.get(1).and_then(Value::as_f64).unwrap_or(200.0)])
.unwrap_or([300.0, 300.0]);
let fill = Some(c4(color_p(p, "fill"), [0.25, 0.55, 1.0, 1.0]));
let stroke = (f_p(p, "strokeWidth").unwrap_or(0.0) > 0.0).then(|| (c4(color_p(p, "stroke"), [1.0, 1.0, 1.0, 1.0]), f_p(p, "strokeWidth").unwrap_or(2.0)));
let pos = p.get("position").and_then(|v| v.as_array()).map(|a| [a[0].as_f64().unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]);
// A fill, and a stroke only with a width (not the Tools bar's paint).
let paint = super::shape_tool::paint_p(p, &Default::default(), "layer.newShape")?;
let pos = p
.get("position")
.and_then(|v| v.as_array())
.map(|a| [a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]);
let name = str_p(p, "name").unwrap_or("Shape Layer 1").to_string();
let id = s.edit("New Shape Layer", None, |proj, st| {
let mut l = build::layer(proj, &comp, &name, LayerSource::Shape, (comp.width, comp.height), None);
let lid = super::shape_tool::new_shape_layer(proj, st, &comp, cid, Some(&name), pos)?;
let mut next = proj.next_id;
if let Some(g) = shape_contents(&mut Ids(&mut next), &kind, size, fill, stroke)
&& let Some(c) = l.props.sub_mut("contents")
{
c.children.push(g.into());
}
let g = super::shape_tool::shape_group(&mut Ids(&mut next), &kind, size, &paint);
proj.next_id = next;
if let Some(pos) = pos
&& let Some(pr) = l.props.prop_mut("transform/position")
{
pr.value = KV::Vec3([pos[0], pos[1], 0.0]);
if let Some(g) = g {
super::shape_tool::add_to_contents(proj, cid, lid, g, [0.0, 0.0], "layer.newShape")?;
}
insert_layer(proj, st, cid, l)
Ok(lid)
})?;
Ok(json!({"layer": id.0}))
}
@ -258,12 +276,17 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("layer.addItem", "data files can't be layers; read them in expressions with footage(\"name\").sourceData"));
}
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Model => {
return super::model3d::new_model(s, &serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time")}));
return super::model3d::new_model(
s,
&serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time"), "index": p.get("index"), "position": p.get("position")}),
);
}
ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), Some(f.duration)),
ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height), None),
ItemKind::Folder => return Err(bad("layer.addItem", "folders can't be layers")),
};
let index = index_p(p, "layer.addItem")?;
let position = position_p(p, "layer.addItem")?;
let fr = comp.frame_rate;
// Settings ▸ General ▸ Create Layers at Composition Start Time (off: at the current time).
let default_start = if s.prefs.general.create_layers_at_comp_start || s.active_comp_id() != Some(cid) { Tick::ZERO } else { s.time() };
@ -273,7 +296,8 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
l.start_time = start;
l.in_point = start;
l.out_point = fr.snap_nearest((start + dur.unwrap_or(comp.duration)).min(comp.duration)).max(start + fr.frame_duration());
insert_layer(proj, st, cid, l)
place(&mut l, position);
insert_layer_at(proj, st, cid, l, index)
})?;
Ok(json!({"layer": id.0}))
}
@ -353,7 +377,8 @@ fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "switch").ok_or_else(|| bad("layer.setSwitch", "missing `switch`"))?.to_string();
let v = b_p(p, "value");
let label = format!("Layer Switch ({name})");
let r = s.edit(&label, None, |proj, _| {
// `merge`: a switch dragged over several layers in the Timeline is one undo step.
let r = s.edit(&label, merge_p(p), |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let mut last = false;
// Toggle relative to the first layer so a multi-selection ends up consistent.
@ -612,6 +637,45 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op
.collect()
}
/// Validate the proposed ancestor chain before any authored edit, including when compensation
/// is disabled. Bound both unique ancestors and linear lookup/selection work on loaded graphs.
fn check_parent_chain(comp: &Comp, ids: &[LayerId], parent: Option<LayerId>, max_visits: usize, max_work: usize) -> Result<()> {
let mut seen = std::collections::HashSet::new();
let mut work = 0usize;
let mut cur = parent;
while let Some(id) = cur {
if seen.len() >= max_visits {
return Err(bad("layer.setParent", "parent chain exceeds the safety visit limit"));
}
if !seen.insert(id) {
return Err(bad("layer.setParent", "parent chain contains an existing cycle"));
}
for selected in ids {
if work >= max_work {
return Err(bad("layer.setParent", "parent chain exceeds the safety work limit"));
}
work += 1;
if *selected == id {
return Err(bad("layer.setParent", "parenting would create a cycle"));
}
}
let mut ancestor = None;
for layer in &comp.layers {
if work >= max_work {
return Err(bad("layer.setParent", "parent chain exceeds the safety work limit"));
}
work += 1;
if layer.id == id {
ancestor = Some(layer);
break;
}
}
let layer = ancestor.ok_or_else(|| bad("layer.setParent", "parent chain refers to a missing layer"))?;
cur = layer.parent;
}
Ok(())
}
fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
@ -619,16 +683,9 @@ fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
None | Some(Value::Null) => None,
Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad("layer.setParent", "no such parent layer"))?),
};
// Cycle check.
if let Some(par) = parent {
let mut cur = Some(par);
while let Some(c) = cur {
if ids.contains(&c) {
return Err(bad("layer.setParent", "parenting would create a cycle"));
}
cur = comp.layer(c).and_then(|l| l.parent);
}
}
// A loaded cycle need not include a selected child. Validate before compensation or edit;
// do not impose the renderer's 64-ancestor truncation as a new ordinary hierarchy limit.
check_parent_chain(comp, &ids, parent, 4096, 1_048_576)?;
// Like AE's pick-whip, parenting keeps the layer where it is: its transform is re-expressed
// in the new parent's space (Position, Rotation, Scale), unless `compensate: false`.
let compensate = b_p(p, "compensate").unwrap_or(true);
@ -912,10 +969,9 @@ fn add_mask(s: &mut Session, p: &Value) -> Result<Value> {
Some([x, y, rw, rh]) => (x + rw / 2.0, y + rh / 2.0, rw, rh),
None => (if w > 0.0 { w / 2.0 } else { 0.0 }, h / 2.0, w * 0.6, h * 0.6),
};
let path = match shape {
"ellipse" => ShapePath::ellipse([cx, cy], rw, rh),
_ => ShapePath::rect([cx, cy], rw, rh),
};
// The shape tools' masks: rectangle, ellipse, rounded rectangle, polygon or star.
let path = super::shape_tool::mask_path(shape, [cx, cy], [rw, rh])
.ok_or_else(|| bad("layer.addMask", format!("unknown shape `{shape}`; one of rect, ellipse, rounded, polygon, star")))?;
let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add);
let cycle = s.prefs.appearance.cycle_mask_colors;
let uid = s.edit("New Mask", None, |proj, _| {
@ -992,7 +1048,9 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
"polygon" => build::shape_star(&mut ids, false, 5.0, [0.0, 0.0], 100.0, 0.0),
"fill" => build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0]),
"stroke" => build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 2.0),
"path" => build::shape_path(&mut ids, Default::default()),
"gfill" | "gradientFill" => build::shape_gradient_fill(&mut ids, false, [-100.0, 0.0], [100.0, 0.0], Default::default()),
"gstroke" | "gradientStroke" => build::shape_gradient_stroke(&mut ids, false, [-100.0, 0.0], [100.0, 0.0], Default::default(), 2.0),
"trim" | "trimPaths" => build::shape_trim(&mut ids, 0.0, 100.0, 0.0),
"repeater" => build::shape_repeater(&mut ids, 3.0, [100.0, 0.0]),
k => build::shape_simple_op(&mut ids, k).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{k}`")))?,
@ -1228,7 +1286,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Layer",
["Layer", "New"],
None,
"{kind?: rect|rounded|ellipse|star|polygon|none, name?, size?, fill?, stroke?, strokeWidth?, position?}",
"{kind?: rect|rounded|ellipse|star|polygon|none, name?, size?, fill?, fillType?, fillBlend?, fillOpacity?, stroke?, strokeType?, strokeBlend?, strokeOpacity?, strokeWidth?, position?}",
has_comp,
new_shape
),
@ -1242,7 +1300,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_layer_settings,
layer_settings
),
cmd!("layer.addItem", "Add Footage to Comp", ["File"], Some("Cmd+/"), "{item: id|name, time?, duration? (s, for a still)}", has_comp, add_item),
cmd!(
"layer.addItem",
"Add Footage to Comp",
["File"],
Some("Cmd+/"),
"{item: id|name, time? (s, the In point), index? (1-based stack position; default above the selected layer), position? ([x, y] comp px; default the centre), duration? (s, for a still)}",
has_comp,
add_item
),
cmd!("layer.select", "Select Layers", [], None, "{layers: [id|name|#n], add?, toggle?}", has_comp, select),
cmd!("layer.selectNext", "Select Next Layer", [], Some("Cmd+ArrowDown"), "{add?}", has_comp, select_next),
cmd!("layer.selectPrevious", "Select Previous Layer", [], Some("Cmd+ArrowUp"), "{add?}", has_comp, select_prev),
@ -1251,7 +1317,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Layer Switch",
[],
None,
"{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?}",
"{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?, merge?}",
has_layers,
set_switch
),
@ -1303,7 +1369,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Mask",
["Layer", "Mask"],
Some("Cmd+Shift+N"),
"{layer?, shape?: rect|ellipse, rect? [x,y,w,h], mode?}",
"{layer?, shape?: rect|ellipse|rounded|polygon|star, rect? [x,y,w,h] (layer space; a polygon or star fits its width), mode?}",
has_layers,
add_mask
),
@ -1313,7 +1379,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add (Shape)",
[],
None,
"{layer?, kind: group|rect|ellipse|star|polygon|fill|stroke|gfill|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge, group?: uid|path} → {uid, path}",
"{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge, group?: uid|path} → {uid, path}",
has_layers,
add_shape_item
),
@ -1340,3 +1406,120 @@ pub fn specs() -> Vec<CommandSpec> {
#[allow(dead_code)]
fn unused(_: &Comp) {}
#[cfg(test)]
mod parent_chain_tests {
use super::*;
use std::sync::Arc;
fn fixture() -> (Session, u64, u64, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name":"Parent safety","width":16,"height":16,"duration":1})).unwrap();
let a = s.execute("layer.newSolid", json!({"name":"A"})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.newSolid", json!({"name":"B"})).unwrap()["layer"].as_u64().unwrap();
let c = s.execute("layer.newSolid", json!({"name":"C"})).unwrap()["layer"].as_u64().unwrap();
for id in [a, b, c] {
s.execute("prop.set", json!({"layer":id,"path":"transform/anchor","value":[0,0,0]})).unwrap();
}
s.execute("prop.set", json!({"layer":a,"path":"transform/position","value":[100,200,0]})).unwrap();
s.execute("prop.set", json!({"layer":c,"path":"transform/position","value":[10,20,0]})).unwrap();
s.history.undo.clear();
(s, a, b, c)
}
fn malformed_parent(s: &mut Session, child: u64, parent: u64) {
let cid = s.active_comp_id().unwrap();
Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(child)).unwrap().parent = Some(LayerId(parent));
}
fn assert_atomic_error(s: &mut Session, child: u64, parent: u64, compensate: bool, message: &str) {
let before = s.project.clone();
let revision = s.revision;
let undo = s.history.undo.len();
let err = s.execute("layer.setParent", json!({"layers":[child],"parent":parent,"compensate":compensate})).unwrap_err();
assert!(err.to_string().contains(message), "{err}");
assert!(Arc::ptr_eq(&before, &s.project));
assert_eq!(s.revision, revision);
assert_eq!(s.history.undo.len(), undo);
}
#[test]
fn loaded_cycle_excluding_selected_child_is_rejected_atomically() {
for compensate in [true, false] {
let (mut s, a, b, c) = fixture();
// Simulate a malformed loaded project. Never execute this on the unguarded baseline.
malformed_parent(&mut s, a, b);
malformed_parent(&mut s, b, a);
assert_atomic_error(&mut s, c, a, compensate, "existing cycle");
}
}
#[test]
fn dangling_loaded_parent_is_rejected_atomically() {
for compensate in [true, false] {
let (mut s, a, _, c) = fixture();
malformed_parent(&mut s, a, u64::MAX);
assert_atomic_error(&mut s, c, a, compensate, "missing layer");
}
}
#[test]
fn selected_descendant_and_direct_self_are_rejected() {
let (mut s, a, _, c) = fixture();
assert_atomic_error(&mut s, c, c, false, "create a cycle");
s.execute("layer.setParent", json!({"layers":[a],"parent":c,"compensate":false})).unwrap();
assert_atomic_error(&mut s, c, a, true, "create a cycle");
}
#[test]
fn ordinary_parent_and_unparent_keep_explicit_false_and_undo_semantics() {
for compensate in [true, false] {
let (mut s, a, _, c) = fixture();
let old = s.project.clone();
let world = |s: &Session| {
let cid = s.active_comp_id().unwrap();
let comp = s.active_comp().unwrap();
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time());
ctx.world_matrix(comp.layer(LayerId(c)).unwrap()).apply(effectcraft_geom::Vec3::ZERO)
};
let before = world(&s);
s.execute("layer.setParent", json!({"layers":[c],"parent":a,"compensate":compensate})).unwrap();
let expected = if compensate { before } else { before + effectcraft_geom::vec3(100.0, 200.0, 0.0) };
assert!((world(&s) - expected).length() < 1e-9);
assert_eq!(s.active_comp().unwrap().layer(LayerId(c)).unwrap().parent, Some(LayerId(a)));
assert_eq!(s.history.undo.len(), 1);
let parented = s.project.clone();
assert!(s.undo());
assert!(Arc::ptr_eq(&s.project, &old));
assert!(s.redo());
assert!(Arc::ptr_eq(&s.project, &parented));
s.execute("layer.setParent", json!({"layers":[c],"parent":null,"compensate":compensate})).unwrap();
assert_eq!(s.active_comp().unwrap().layer(LayerId(c)).unwrap().parent, None);
assert!(s.undo());
assert!(Arc::ptr_eq(&s.project, &parented));
}
}
#[test]
fn ancestor_and_lookup_work_budgets_fail_closed() {
let (mut s, a, b, c) = fixture();
malformed_parent(&mut s, a, b);
let comp = s.active_comp().unwrap();
// New solids are inserted above the selection: C,B,A. Walking A->B scans
// three then two layers, and compares the one selected child twice: seven work units.
assert_eq!(comp.layers.iter().map(|l| l.id).collect::<Vec<_>>(), vec![LayerId(c), LayerId(b), LayerId(a)]);
assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 2, 7).is_ok());
assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 2, 6).unwrap_err().to_string().contains("work limit"));
assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 2, 256).is_ok());
assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 1, 256).unwrap_err().to_string().contains("visit limit"));
assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 2, 1).unwrap_err().to_string().contains("work limit"));
assert!(check_parent_chain(comp, &[LayerId(c)], None, 0, 0).is_ok(), "unparenting does not traverse ancestors");
}
#[test]
fn well_formed_chain_beyond_renderer_depth_is_not_unnecessarily_rejected() {
let (s, _, _, c) = fixture();
let mut comp = s.active_comp().unwrap().clone();
let template = comp.layers.first().unwrap().clone();
for n in 0..80 {
let mut layer = template.clone();
layer.id = LayerId(10_000 + n);
layer.parent = (n < 79).then_some(LayerId(10_001 + n));
comp.layers.push(layer);
}
assert!(check_parent_chain(&comp, &[LayerId(c)], Some(LayerId(10_000)), 4096, 1_048_576).is_ok());
}
}

View File

@ -6,12 +6,12 @@
use effectcraft_keyframe::{Keyframe, Value as KV};
use effectcraft_project::build::Ids;
use effectcraft_project::{Layer, LayerSource, Node, ParamUi, Property};
use effectcraft_project::{Layer, LayerId, LayerSource, Node, ParamUi, Property};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, f_p, has_layers, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd};
use crate::{EngineError, KeyRef, Result, Session, cmd};
/// Layers whose source has its own time (precomps and footage) can be remapped.
fn remappable(l: &Layer) -> bool {
@ -86,6 +86,10 @@ fn enable_time_remap(s: &mut Session, p: &Value) -> Result<Value> {
if !comp.layers.iter().filter(|l| ids.contains(&l.id)).any(remappable) {
return Err(bad("layer.enableTimeRemap", "time remapping needs a footage or composition layer"));
}
// Each layer's source duration (stills have none): a layer extended past it ends its remap
// at the source's end, where its last frame starts to hold (After Effects).
let src_end: Vec<(LayerId, Tick)> =
comp.layers.iter().filter(|l| ids.contains(&l.id)).filter_map(|l| Some((l.id, l.source.item().and_then(|i| s.project.item(i))?.duration()?))).collect();
let on = s.edit("Enable Time Remapping", None, |proj, _| {
let mut next = proj.next_id;
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
@ -97,7 +101,13 @@ fn enable_time_remap(s: &mut Session, p: &Value) -> Result<Value> {
if target && !has {
let mut pr = remap_prop(&mut Ids(&mut next));
let (lin, lout) = (l.layer_time(l.in_point), l.layer_time(l.out_point));
let (a, b) = (lin.min(lout), lin.max(lout));
let (a, mut b) = (lin.min(lout), lin.max(lout));
if let Some((_, end)) = src_end.iter().find(|(id, _)| *id == l.id)
&& lin <= lout
&& *end > a
{
b = b.min(*end);
}
pr.value = KV::Scalar(source_secs(l, l.in_point));
pr.keys = vec![Keyframe::new(a, KV::Scalar(a.seconds())), Keyframe::new(b, KV::Scalar(b.seconds()))];
if lin > lout {
@ -116,6 +126,40 @@ fn enable_time_remap(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(on))
}
/// Layers of the active comp whose Time Remap property is selected with all of its keys (clicking
/// its name selects them all, and a key selects its property): Edit ▸ Clear turns their time
/// remapping off instead of deleting the keys or the layer.
pub(crate) fn selected_time_remap(s: &Session) -> Vec<LayerId> {
let Some(comp) = s.active_comp() else { return vec![] };
let st = &s.state;
st.selected_props
.iter()
.filter(|(lid, uid)| {
comp.layer(*lid)
.and_then(|l| l.props.get("timeRemap"))
.filter(|pr| pr.uid == *uid)
.is_some_and(|pr| pr.keys.iter().all(|k| st.selected_keys.contains(&KeyRef { layer: *lid, prop: *uid, time: k.time })))
})
.map(|(lid, _)| *lid)
.collect()
}
/// Turn time remapping off on `layers` (one undo step) and drop the removed property and its
/// keys from the selection.
pub(crate) fn disable_time_remap(s: &mut Session, layers: &[LayerId]) -> Result<Value> {
let ids: Vec<u64> = layers.iter().map(|l| l.0).collect();
let r = s.execute("layer.enableTimeRemap", json!({"layers": ids, "value": false}))?;
let comp = s.active_comp();
let gone = |lid: LayerId, uid: u64| layers.contains(&lid) && comp.and_then(|c| c.layer(lid)).is_none_or(|l| l.props.find(uid).is_none());
let (props, keys): (Vec<_>, Vec<_>) = (
s.state.selected_props.iter().copied().filter(|(l, u)| !gone(*l, *u)).collect(),
s.state.selected_keys.iter().copied().filter(|k| !gone(k.layer, k.prop)).collect(),
);
s.state.selected_props = props;
s.state.selected_keys = keys;
Ok(r)
}
/// Freeze Frame: time remapping holding the current source frame for the whole layer.
fn freeze(s: &mut Session, p: &Value, last: bool) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;

View File

@ -6,7 +6,7 @@ use effectcraft_time::{TICKS_PER_SECOND, Tick};
use serde_json::{Value, json};
use super::prop::prop_ref;
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layer_p};
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layer_p, str_p};
use crate::{EngineError, Result, Session, cmd};
const AXES: [(&str, &str); 3] = [("positionX", "X Position"), ("positionY", "Y Position"), ("positionZ", "Z Position")];
@ -164,6 +164,23 @@ fn pick_whip(s: &mut Session, p: &Value) -> Result<Value> {
let r = reference(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?;
let (me, them) = (from.props.find(uid).ok_or(EngineError::NoComp)?, target.props.find(tu).ok_or(EngineError::NoComp)?);
let (dm, dt) = (dims(from, me), dims(target, them));
// While the expression is being edited the reference goes in at the cursor, replacing the
// selected text (After Effects): `expression` is the text being edited, `range` the
// selection [start, end] in characters.
if let Some(base) = str_p(p, "expression") {
let range: Option<Vec<u64>> = p.get("range").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect());
let n = base.chars().count() as u64;
let (a, b) = match range.as_deref() {
Some(&[a, b]) => (a.min(b).min(n), a.max(b).min(n)),
None => (n, n),
Some(_) => return Err(bad("prop.pickWhip", "`range` must be [start, end] (characters)")),
};
let head: String = base.chars().take(a as usize).collect();
let tail: String = base.chars().skip(b as usize).collect();
let text = format!("{head}{r}{tail}");
s.execute("prop.setExpression", json!({"layer": lid.0, "prop": uid, "expression": text}))?;
return Ok(json!(text));
}
let text = if dt == 1 && dm > 1 {
format!("temp = {r};\n[{}]", vec!["temp"; dm].join(", "))
} else if dt > 1 && dm == 1 {
@ -183,7 +200,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Pick Whip (Link Property)",
[],
None,
"{layer?, path|prop, target: {layer, path|prop}} → sets an AE reference expression",
"{layer?, path|prop, target: {layer, path|prop}, expression?, range?: [start, end]} → sets an AE reference expression (with `expression`: inserts it there, replacing the characters in `range`, default the end)",
has_layers,
pick_whip
),

View File

@ -5,7 +5,7 @@
//! comp seconds; layer markers are stored in layer time. All commands are undoable.
use effectcraft_color::Label;
use effectcraft_project::{Comp, CuePoint, ItemId, LayerId, LayerSource, Marker};
use effectcraft_project::{Comp, CuePoint, ItemId, Layer, LayerId, LayerSource, Marker, Project};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@ -85,6 +85,56 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Array(ms.iter().enumerate().map(|(i, m)| marker_json(c, o, i, m)).collect()))
}
/// The nested comp's markers on a precomp layer, at their times in the containing comp `comp`
/// and within the layer's In–Out range, as After Effects shows them on the precomp layer's bar:
/// `(comp time, index in the nested comp, marker)`. The layer's start time and stretch map them;
/// with time remapping, or a stretch over protected regions (Responsive Design), a marker sits at
/// the first frame the layer shows its time.
pub fn nested_markers<'a>(project: &'a Project, comp_id: ItemId, comp: &Comp, layer: &Layer) -> Vec<(Tick, usize, &'a Marker)> {
let LayerSource::Comp { item } = layer.source else { return vec![] };
let Some(nc) = project.comp(item) else { return vec![] };
if nc.markers.is_empty() || item == comp_id {
return vec![];
}
let shown = |t: Tick| t >= layer.in_point && t < layer.out_point;
let stretched_regions = (layer.stretch - 100.0).abs() > 1e-9 && nc.markers.iter().any(|m| m.protected);
if layer.props.get("timeRemap").is_none() && !stretched_regions {
return nc.markers.iter().enumerate().map(|(i, m)| (layer.comp_time(m.time), i, m)).filter(|(t, _, _)| shown(*t)).collect();
}
// The layer's source time at every frame of its range; each marker at the first frame that
// reaches it (within rounding: a thousandth of a nested frame).
let fr = comp.frame_rate;
let (f0, f1) = (fr.frame_at(layer.in_point), fr.frame_at(layer.out_point));
let frames: Vec<(Tick, Tick)> = (f0..f1.min(f0.saturating_add(100_000)))
.map(|f| {
let t = fr.tick_of(f);
(t, effectcraft_render::EvalCtx::new(project, comp_id, comp, t).source_time(layer))
})
.collect();
let eps = nc.frame_duration().0 / 1000;
nc.markers
.iter()
.enumerate()
.filter_map(|(i, m)| frames.iter().find(|(_, src)| src.0.saturating_add(eps) >= m.time.0).map(|(t, _)| (*t, i, m)))
.filter(|(t, _, _)| shown(*t))
.collect()
}
/// `markers.nested`: the nested comp's markers on a precomp layer (see [`nested_markers`]).
fn nested(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "markers.nested";
let cid = comp_id(s, p)?;
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let lid = p.get("layer").and_then(|v| resolve_layer(c, v)).ok_or_else(|| bad(cmd, "missing `layer` (a precomp layer)"))?;
let l = c.layer(lid).ok_or_else(|| bad(cmd, "no such layer"))?;
let LayerSource::Comp { item } = l.source else { return Err(bad(cmd, "not a precomp layer")) };
let v: Vec<Value> = nested_markers(&s.project, cid, c, l)
.into_iter()
.map(|(t, i, m)| json!({"time": t.seconds(), "comp": item.0, "index": i, "nestedTime": m.time.seconds(), "duration": m.duration.seconds(), "comment": m.comment, "label": m.label.name()}))
.collect();
Ok(json!(v))
}
fn locked(c: &Comp, o: Owner) -> bool {
matches!(o, Owner::Layer(l) if c.layer(l).is_some_and(|l| l.markers_locked))
}
@ -299,6 +349,12 @@ fn has_precomp_layer(s: &Session) -> std::result::Result<(), String> {
pub fn specs() -> Vec<CommandSpec> {
vec![
query!("markers.list", "List Markers", "{layer? (omit for composition markers)}", list),
query!(
"markers.nested",
"Nested Comp Markers",
"{layer: a precomp layer, comp?} — its comp's markers at their times in this comp (as on the layer bar)",
nested
),
cmd!(
"markers.set",
"Marker Settings",

View File

@ -187,6 +187,7 @@ fn track_mask(s: &mut Session, p: &Value) -> Result<Value> {
path,
method,
times: times.clone(),
face_model: if method.is_face() { s.models.face() } else { None },
};
s.state.mask_track_method = method;
let wait = b_p(p, "wait").unwrap_or(false);
@ -195,6 +196,8 @@ fn track_mask(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({
"mask": uid,
"method": method.id(),
// The engine face tracking tries first (the classic one when the model finds no face).
"faceModel": method.is_face().then(|| s.models.face().map_or(effectcraft_segment::CLASSICAL, |m| m.info().id)),
"frames": times.len() - 1,
"start": times[0].seconds(),
"end": times.last().map(|t| t.seconds()),

View File

@ -34,7 +34,7 @@ mod layer_time;
pub(crate) mod link;
mod liquify;
mod lottie;
mod markers;
pub mod markers;
mod mask;
pub mod mask_interp;
pub(crate) mod model3d;
@ -54,7 +54,8 @@ pub mod roto_cmds;
mod scene_detect;
pub mod scripts;
mod settings;
mod shape_stroke;
pub mod shape_stroke;
pub mod shape_tool;
mod stubs;
mod styles;
mod text_anim;
@ -73,8 +74,6 @@ pub(crate) mod watch_folder;
pub(crate) use app_more::report_text as report_text_for_tests;
pub(crate) use app_more::view_command;
#[cfg(test)]
pub(crate) use file::missing_frames as missing_frames_for_tests;
#[cfg(test)]
pub(crate) use keys_more::amplitudes as amplitudes_for_tests;
#[cfg(test)]
pub(crate) use mask::split_segment as split_segment_for_tests;
@ -133,6 +132,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
SPECS.get_or_init(|| {
let mut v = Vec::new();
v.extend(file::specs());
v.extend(crate::models::specs());
v.extend(edit::specs());
v.extend(comp::specs());
v.extend(layer::specs());
@ -164,6 +164,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(view::specs());
v.extend(viewer_cmds::specs());
v.extend(shape_stroke::specs());
v.extend(shape_tool::specs());
v.extend(focus::specs());
v.extend(key_labels::specs());
v.extend(key_transform::specs());
@ -203,6 +204,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(crate::storage::specs());
v.extend(crate::learn::specs());
v.extend(crate::templates::specs());
v.extend(crate::ease_presets::specs());
v.extend(crate::preview::specs());
v
})
@ -441,9 +443,10 @@ pub(crate) fn merge_p(p: &Value) -> Option<&str> {
/// `comp`: item id or name; default the active comp.
pub(crate) fn comp_id(s: &Session, p: &Value) -> Result<ItemId> {
let missing = |v: &Value| EngineError::NoSuchComp(v.to_string());
match p.get("comp") {
Some(Value::Number(n)) => n.as_u64().map(ItemId).filter(|id| s.project.comp(*id).is_some()).ok_or(EngineError::NoComp),
Some(Value::String(name)) => s.project.items.values().find(|i| &i.name == name && i.as_comp().is_some()).map(|i| i.id).ok_or(EngineError::NoComp),
Some(v @ Value::Number(n)) => n.as_u64().map(ItemId).filter(|id| s.project.comp(*id).is_some()).ok_or_else(|| missing(v)),
Some(v @ Value::String(name)) => s.project.items.values().find(|i| &i.name == name && i.as_comp().is_some()).map(|i| i.id).ok_or_else(|| missing(v)),
_ => s.active_comp_id().ok_or(EngineError::NoComp),
}
}

View File

@ -8,7 +8,7 @@ use effectcraft_project::{FootageKind, ItemId, ItemKind, Layer, LayerSource, Nod
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::layer::{color_p, insert_layer};
use super::layer::{color_p, index_p, insert_layer, insert_layer_at, place, position_p};
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd};
@ -61,6 +61,9 @@ fn comp_renderer(s: &mut Session, p: &Value) -> Result<Value> {
sync_geometry_options(proj, cid);
Ok(())
})?;
if r == Renderer::Classic3D && s.project.comp(cid).is_some_and(|c| c.layers.iter().any(|l| l.source.is_model())) {
s.toast("Classic 3D doesn't draw 3D model layers: they show with the Advanced 3D renderer");
}
Ok(json!({"renderer": renderer_id(r)}))
}
@ -71,6 +74,25 @@ fn renderer_id(r: Renderer) -> &'static str {
}
}
/// Only the Advanced 3D renderer draws model and primitive layers, so adding one switches a
/// Classic 3D comp to it, like After Effects (inside the adding edit: one undo step). Whether
/// the renderer changed.
fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Result<bool> {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let switched = c.renderer != Renderer::Advanced3D;
c.renderer = Renderer::Advanced3D;
sync_geometry_options(proj, cid);
Ok(switched)
}
/// Tell the user when [`use_advanced_3d`] changed the comp's renderer.
fn report_switch(s: &mut Session, cid: ItemId, switched: bool) {
if switched {
let name = s.project.item(cid).map_or("the composition", |i| i.name.as_str()).to_string();
s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, which draws 3D models"));
}
}
// ---------------------------------------------------------------- model layers
/// Set a property's value at comp time `t` (adds a key when it is animated).
@ -119,7 +141,9 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "name").unwrap_or(&it.name).to_string();
let fr = comp.frame_rate;
let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO));
let id = s.edit("New 3D Model Layer", None, |proj, st| {
let index = index_p(p, "layer.newModel")?;
let position = position_p(p, "layer.newModel")?;
let (id, switched) = s.edit("New 3D Model Layer", None, |proj, st| {
let mut l = build::layer(proj, &comp, &name, LayerSource::Model { item }, (comp.width, comp.height), None);
let g = build::model_geometry_options(&mut Ids(&mut proj.next_id), scale, &clips);
if let Some(slot) = l.props.children.iter_mut().find(|c| c.match_id() == "geometryOptions") {
@ -128,9 +152,12 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result<Value> {
l.start_time = start;
l.in_point = start;
l.out_point = comp.duration.max(start + fr.frame_duration());
insert_layer(proj, st, cid, l)
place(&mut l, position);
let id = insert_layer_at(proj, st, cid, l, index)?;
Ok((id, use_advanced_3d(proj, cid)?))
})?;
Ok(json!({"layer": id.0, "item": item.0}))
report_switch(s, cid, switched);
Ok(json!({"layer": id.0, "item": item.0, "advanced3d": true}))
}
fn kind_p(p: &Value, cmd: &str) -> Result<PrimitiveKind> {
@ -184,7 +211,7 @@ fn new_primitive(s: &mut Session, p: &Value) -> Result<Value> {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| kind.label().to_string());
let t = super::time_p(s, p, Some(&comp));
let id = s.edit(&format!("New {}", kind.label()), None, |proj, st| {
let (id, switched) = s.edit(&format!("New {}", kind.label()), None, |proj, st| {
let mut l = build::layer(proj, &comp, &name, LayerSource::Primitive { kind }, (comp.width, comp.height), None);
for k in ["width", "height", "depth", "radius", "tubeRadius", "segments", "rings"] {
if let Some(v) = f_p(p, k) {
@ -196,11 +223,11 @@ fn new_primitive(s: &mut Session, p: &Value) -> Result<Value> {
set_at(&mut l, "transform/position", t, KV::Vec3([g(0), g(1), g(2)]));
}
apply_material(&mut l, p, t, "layer.newPrimitive")?;
insert_layer(proj, st, cid, l)
let id = insert_layer(proj, st, cid, l)?;
Ok((id, use_advanced_3d(proj, cid)?))
})?;
// Primitives are drawn by the Advanced 3D renderer.
let advanced = s.project.comp(cid).is_some_and(|c| c.renderer == Renderer::Advanced3D);
Ok(json!({"layer": id.0, "kind": kind.label(), "advanced3d": advanced}))
report_switch(s, cid, switched);
Ok(json!({"layer": id.0, "kind": kind.label(), "advanced3d": true}))
}
// ---------------------------------------------------------------- environment layer
@ -349,7 +376,7 @@ pub fn specs() -> Vec<CommandSpec> {
"3D Model Layer",
[],
None,
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s)}",
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}; switches a Classic 3D comp to Advanced 3D",
has_comp,
new_model
),
@ -358,7 +385,7 @@ pub fn specs() -> Vec<CommandSpec> {
"3D Primitive",
[],
None,
"{kind: cube|sphere|plane|torus|cone|cylinder, name?, width?, height?, depth?, radius?, tubeRadius?, segments?, rings?, position? [x,y,z], baseColor?, metallic?, roughness?, emissive?, castsShadows?, acceptsShadows?, acceptsLights?}",
"{kind: cube|sphere|plane|torus|cone|cylinder, name?, width?, height?, depth?, radius?, tubeRadius?, segments?, rings?, position? [x,y,z], baseColor?, metallic?, roughness?, emissive?, castsShadows?, acceptsShadows?, acceptsLights?}; switches a Classic 3D comp to Advanced 3D",
has_comp,
new_primitive
),
@ -367,7 +394,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New 3D Primitive",
[],
None,
"{kind: cube|sphere|plane|torus|cone|cylinder, name?, width?, height?, depth?, radius?, tubeRadius?, segments?, rings?, position? [x,y,z], baseColor?, metallic?, roughness?, emissive?, castsShadows?, acceptsShadows?, acceptsLights?}",
"{kind: cube|sphere|plane|torus|cone|cylinder, name?, width?, height?, depth?, radius?, tubeRadius?, segments?, rings?, position? [x,y,z], baseColor?, metallic?, roughness?, emissive?, castsShadows?, acceptsShadows?, acceptsLights?}; switches a Classic 3D comp to Advanced 3D",
has_comp,
new_primitive
),

View File

@ -104,15 +104,14 @@ fn browser_import(s: &mut Session, p: &Value) -> Result<Value> {
if !browser(s)?.fetch(&list, p) {
return Ok(json!({"pending": true, "paths": list}));
}
let r = s.execute("file.import", json!({"paths": paths}))?;
// Add to the active comp (drag into the timeline).
if b_p(p, "addToComp").unwrap_or(false)
&& s.active_comp_id().is_some()
&& let Some(items) = r.get("items").and_then(Value::as_array)
let target = s.active_comp_id();
let mut r = s.execute("file.import", json!({"paths": paths}))?;
// Dragged into the Timeline or the Composition viewer: into the comp, where they were dropped.
let errors = super::file::add_to_comp(s, &r, target, p);
if !errors.is_empty()
&& let Some(e) = r.get_mut("errors").and_then(Value::as_array_mut)
{
for it in items {
s.execute("layer.addItem", json!({"item": it}))?;
}
e.extend(errors.into_iter().map(Value::from));
}
Ok(r)
}
@ -121,9 +120,8 @@ fn file_info(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("mediaBrowser.fileInfo", "missing `path`"))?;
let (created, modified, size) = mb::file_dates(path);
let mut v = json!({"path": path, "kind": mb::kind_of(path), "size": size, "created": created.map(mb::iso_date), "modified": modified.map(mb::iso_date)});
if let Some(imp) = s.importer.clone()
&& matches!(mb::kind_of(path), Some("video" | "image" | "audio" | "model"))
&& let Ok(f) = imp.probe(path)
if matches!(mb::kind_of(path), Some("video" | "image" | "audio" | "model"))
&& let Ok(f) = s.probe_footage(path)
{
v["footage"] = mb::footage_metadata(&f);
}
@ -233,7 +231,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("mediaBrowser.go", "Go to Folder", [], None, "{path?: folder | \"..\", importableOnly?}", browser_enabled, browser_go),
cmd!("mediaBrowser.addFavorite", "Add to Favorites", [], None, "{path?}", browser_enabled, |s, p| browser_favorite(s, p, true)),
cmd!("mediaBrowser.removeFavorite", "Remove from Favorites", [], None, "{path?}", browser_enabled, |s, p| browser_favorite(s, p, false)),
cmd!("mediaBrowser.import", "Import", [], None, "{paths, addToComp?}", browser_enabled, browser_import),
cmd!(
"mediaBrowser.import",
"Import",
[],
None,
"{paths, addToComp?, time?, index?, position? (where the layers go, as in layer.addItem)}",
browser_enabled,
browser_import
),
cmd!(
"mediaBrowser.action",
"Media Browser Action",

View File

@ -5,7 +5,7 @@ use effectcraft_project::{ItemId, Project};
use serde_json::{Value, json};
use super::{CommandSpec, always, b_p, bad, str_p};
use crate::{EngineError, Result, Session, cmd};
use crate::{EngineError, Result, Session, cmd, query};
/// `item` (id or name) → id.
fn item_ref(s: &Session, v: &Value, cmd: &str) -> Result<ItemId> {
@ -140,17 +140,40 @@ fn set_comment(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
/// The items deleting `ids` removes: them and, for folders, everything inside.
fn doomed_items(project: &Project, ids: &[ItemId]) -> Vec<ItemId> {
project.items.keys().copied().filter(|id| ids.iter().any(|d| is_within(project, *id, *d))).collect()
}
/// What deleting `items` takes with it: (layers using them, in how many comps), counting only
/// the comps that stay.
fn usage_of(project: &Project, items: &[ItemId]) -> (usize, usize) {
let mut layers = 0;
let mut comps = 0;
for (_, comp) in project.comps().filter(|(cid, _)| !items.contains(cid)) {
let n = comp.layers.iter().filter(|l| l.source.item().is_some_and(|i| items.contains(&i))).count();
layers += n;
comps += usize::from(n > 0);
}
(layers, comps)
}
/// What deleting the items (default: selected) would delete: the items (with folder contents)
/// and the layers in other compositions that use them. The Project panel asks before deleting
/// items in use, as After Effects does.
fn usage(s: &mut Session, p: &Value) -> Result<Value> {
let ids = items_p(s, p, "project.usage")?;
let doomed = doomed_items(&s.project, &ids);
let (layers, comps) = usage_of(&s.project, &doomed);
Ok(json!({"items": doomed.len(), "layers": layers, "comps": comps}))
}
/// Delete project items (folders with their contents). Layers that use a deleted item and Render
/// Queue items of deleted comps go too, like After Effects' Edit ▸ Clear in the Project panel.
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let c = "project.delete";
let ids = items_p(s, p, c)?;
let mut doomed: Vec<ItemId> = vec![];
for id in s.project.items.keys() {
if ids.iter().any(|d| is_within(&s.project, *id, *d)) {
doomed.push(*id);
}
}
let doomed = doomed_items(&s.project, &ids);
let n = doomed.len();
s.edit("Delete Items", None, |proj, st| {
for id in &doomed {
@ -242,6 +265,7 @@ pub fn specs() -> Vec<CommandSpec> {
always,
delete
),
query!("project.usage", "Project Item Usage", "{items?: [id|name] (default: selected)} → {items, layers, comps} that deleting them removes", usage),
cmd!("project.duplicate", "Duplicate Project Items", [], None, "{items?: [id|name] (default: selected)} → {items}", always, duplicate),
]
}

View File

@ -1,12 +1,12 @@
//! Properties, keyframes and expressions (Animation menu + timeline/Effect Controls gestures).
use effectcraft_keyframe::{Ease, Interp, Keyframe, easy_ease, key_at, set_key};
use effectcraft_project::{ItemId, LayerId, Property, Uid};
use effectcraft_project::{GroupKind, ItemId, Layer, LayerId, Property, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, has_layers, layer_mut, layer_p, layers_p, merge_p, str_p};
use crate::{EngineError, KeyRef, Result, Session, cmd, query};
use crate::{EngineError, KeyRef, Result, Session, VertexRef, cmd, query};
/// Resolve `{layer, path}` (or `{layer, prop: uid}`) to (comp, layer, prop uid).
pub(crate) fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> {
@ -118,8 +118,14 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
"keys": keys, "expression": pr.expr.as_ref().map(|e| e.text.clone()),
});
// With an expression, `value` is the keyframed (pre-expression) value; also report what the
// expression makes of it (what renders), or why it fails.
if pr.has_expression()
// expression makes of it (what renders), or why it fails. A disabled one renders `value`; one
// disabled by its syntax error (prop.setExpression) reports that error.
if let Some(e) = pr.expr.as_ref().filter(|e| !e.enabled && !e.text.trim().is_empty()) {
out["evaluated"] = raw.to_json();
if let Some(err) = s.expr_check.and_then(|check| check(&e.text).err()) {
out["expressionError"] = json!(err);
}
} else if pr.has_expression()
&& let (Some(h), Some(comp)) = (s.expr.as_deref(), s.project.comp(cid))
{
let mut ctx = crate::render::EvalCtx::new(&s.project, cid, comp, t);
@ -138,7 +144,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
fn toggle_anim(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "prop.toggleAnimation")?;
let on = b_p(p, "value");
with_prop(s, "Toggle Animation", None, cid, lid, uid, |pr, lt| {
with_prop(s, "Toggle Animation", merge_p(p), cid, lid, uid, |pr, lt| {
if pr.static_only {
return Err(bad("prop.toggleAnimation", "this property can't be animated"));
}
@ -287,11 +293,19 @@ fn select_prop(s: &mut Session, p: &Value) -> Result<Value> {
let add = b_p(p, "add").unwrap_or(false);
if !add {
s.state.selected_props.clear();
s.state.selected_vertices.clear();
}
if !s.state.selected_layers.contains(&lid) {
s.state.selected_layers = vec![lid];
}
s.state.selected_props.push((lid, uid));
// Selecting a mask (or its Mask Path, or a shape's Path) selects all its points, so the
// viewer drags the whole mask (as in AE, #203).
let t = s.time();
if let Some((mask, n)) = s.active_comp().and_then(|c| c.layer(lid)).and_then(|l| path_points(l, uid, l.layer_time(t))) {
s.state.selected_vertices.retain(|v| !(v.layer == lid && v.mask == mask));
s.state.selected_vertices.extend((0..n).map(|index| VertexRef { layer: lid, mask, index }));
}
// Selecting a property selects all its keys (as in AE).
if b_p(p, "selectKeys").unwrap_or(true)
&& let Some(pr) = s.active_comp().and_then(|c| c.layer(lid)).and_then(|l| l.props.find(uid))
@ -305,6 +319,23 @@ fn select_prop(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
/// The mask or shape Path item that `uid` (the group, or its Path property) is, with its number
/// of points at layer time `lt`.
fn path_points(l: &Layer, uid: Uid, lt: Tick) -> Option<(Uid, usize)> {
let owner = match l.props.find_group(uid) {
Some(g) => g,
None => l.props.parent_of(uid).filter(|g| g.get("path").is_some_and(|p| p.uid == uid))?,
};
let in_contents = owner.match_id == "path" && l.props.sub("contents").is_some_and(|c| c.find_group(owner.uid).is_some());
if !matches!(owner.kind, GroupKind::Mask { .. }) && !in_contents {
return None;
}
match owner.get("path")?.value_at(lt) {
effectcraft_keyframe::Value::Path(p) => Some((owner.uid, p.vertices.len())),
_ => None,
}
}
// ---------------------------------------------------------------- keyframes
fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
@ -356,6 +387,24 @@ fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
} else {
s.state.selected_keys = sel;
}
// From the Timeline, selected keys select their properties and layers too, as in After
// Effects, so the Graph Editor shows them (#252).
if b_p(p, "selectProperties").unwrap_or(false) && !s.state.selected_keys.is_empty() {
let extend = b_p(p, "add").unwrap_or(false) || b_p(p, "toggle").unwrap_or(false);
let st = &mut s.state;
if !extend {
st.selected_props.clear();
st.selected_layers.clear();
}
for k in &st.selected_keys {
if !st.selected_props.contains(&(k.layer, k.prop)) {
st.selected_props.push((k.layer, k.prop));
}
if !st.selected_layers.contains(&k.layer) {
st.selected_layers.push(k.layer);
}
}
}
Ok(json!(s.state.selected_keys.len()))
}
@ -540,7 +589,8 @@ fn interpolation(s: &mut Session, p: &Value) -> Result<Value> {
let roving = b_p(p, "roving");
edit_keys(s, "Keyframe Interpolation", None, move |keys, i, _| {
if let Some(sp) = spatial.as_deref() {
let (tin, tout) = effectcraft_keyframe::spatial_tangents(keys, i);
// (Bezier keeps the current handles, with handles where the path left straight.)
let (tin, tout) = effectcraft_keyframe::spatial_handles(keys, i);
let k = &mut keys[i];
match sp {
"linear" => {
@ -720,7 +770,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
query!("prop.get", "Get Property", "{layer?, path|prop, time? (comp s)}", get),
cmd!("prop.set", "Set Property Value", [], None, "{layer?, path|prop, value, time?, merge?}", has_layers, set),
cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?}", has_layers, toggle_anim),
cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?, merge?}", has_layers, toggle_anim),
cmd!("prop.addKey", "Add Keyframe", [], None, "{layer?, path|prop, time?, value?}", has_layers, add_key),
cmd!("prop.toggleKey", "Add or Remove Keyframe at Current Time", [], None, "{layer?, path|prop}", has_layers, toggle_key),
cmd!(
@ -749,7 +799,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Select Keyframes",
[],
None,
"{keys: [{layer, prop: uid | path (or `path`), time (layer s)}], add?, toggle?: bool (Shift+click: in or out of the selection)}",
"{keys: [{layer, prop: uid | path (or `path`), time (layer s)}], add?, toggle?: bool (Shift+click: in or out of the selection), selectProperties?: bool (their properties and layers too, as a Timeline click does)}",
has_comp,
select_keys
),

View File

@ -2,10 +2,10 @@
//! effect, mask, shape item or text animator (what scripting's `PropertyGroup.name`, `enabled`,
//! `remove()`, `duplicate()` and `moveTo()` do, and what agents need without a UI selection).
use effectcraft_project::{GroupKind, ItemId, LayerId, Node, PropGroup, Uid};
use effectcraft_project::{GroupKind, ItemId, LayerId, LayerSource, Node, PropGroup, Uid};
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layer_p, str_p};
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layer_p, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd};
/// Groups that can be renamed, removed, duplicated and reordered: instances in indexed lists.
@ -51,17 +51,153 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result<Value> {
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let c = "prop.removeGroup";
let (cid, lid, uid) = group_ref(s, p, c, true)?;
s.edit("Delete", None, |proj, st| {
let l = layer_mut(proj, cid, lid)?;
let parent = l.props.parent_of_mut(uid).ok_or_else(|| bad(c, "group vanished"))?;
parent.children.retain(|n| n.uid() != uid);
st.selected_props.retain(|(_, u)| *u != uid);
Ok(Value::Null)
let (cid, lid, uid) = group_ref(s, p, "prop.removeGroup", true)?;
remove_groups(s, cid, &[(lid, uid)], "Delete")?;
Ok(Value::Null)
}
/// Remove groups of comp `cid` in one undo step.
fn remove_groups(s: &mut Session, cid: ItemId, groups: &[(LayerId, Uid)], label: &str) -> Result<()> {
s.edit(label, None, |proj, st| {
for (lid, uid) in groups {
// A group inside one removed before it went with it.
if let Some(parent) = layer_mut(proj, cid, *lid)?.props.parent_of_mut(*uid) {
parent.children.retain(|n| n.uid() != *uid);
}
}
st.selected_props.retain(|(_, u)| !groups.iter().any(|(_, g)| g == u));
Ok(())
})
}
/// Edit ▸ Clear with masks, shape items, text animators or trackers selected in the active comp:
/// removes those groups (not their layers) in one undo step. None when none are selected.
pub(crate) fn remove_selected(s: &mut Session) -> Result<Option<Value>> {
let (Some(cid), Some(c)) = (s.active_comp_id(), s.active_comp()) else { return Ok(None) };
let groups: Vec<(LayerId, Uid)> =
s.state.selected_props.iter().filter(|(lid, uid)| c.layer(*lid).and_then(|l| l.props.find_group(*uid)).is_some_and(is_instance)).copied().collect();
if groups.is_empty() {
return Ok(None);
}
remove_groups(s, cid, &groups, "Delete")?;
Ok(Some(json!(groups.len())))
}
/// Shape items (groups, paths, paints and path operations) selected in the Contents of the
/// active comp's shape layers, top to bottom. An item inside another selected one goes with it,
/// so it isn't listed.
pub(crate) fn selected_contents(s: &Session) -> Vec<(LayerId, Uid)> {
let mut out = vec![];
let Some(c) = s.active_comp().filter(|_| !s.state.selected_props.is_empty()) else { return out };
for l in c.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)) {
if let Some(contents) = l.props.sub("contents") {
let selected = |u: Uid| s.state.selected_props.contains(&(l.id, u));
let mut uids = vec![];
collect_selected(contents, &selected, &mut uids, 0);
out.extend(uids.into_iter().map(|u| (l.id, u)));
}
}
out
}
fn collect_selected(g: &PropGroup, selected: &dyn Fn(Uid) -> bool, out: &mut Vec<Uid>, depth: usize) {
if depth > super::shape_stroke::MAX_DEPTH {
return;
}
for c in g.groups() {
if is_instance(c) && selected(c.uid) {
out.push(c.uid);
} else {
collect_selected(c, selected, out, depth + 1);
}
}
}
/// Copies of the selected shape items (Edit ▸ Copy), top to bottom.
pub(crate) fn copy_contents(s: &Session) -> Vec<PropGroup> {
let Some(c) = s.active_comp() else { return vec![] };
selected_contents(s).into_iter().filter_map(|(lid, uid)| c.layer(lid)?.props.find_group(uid).cloned()).collect()
}
/// Edit ▸ Cut's removal of the selected shape items (once copied).
pub(crate) fn cut_contents(s: &mut Session, items: &[(LayerId, Uid)]) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
remove_groups(s, cid, items, "Cut")?;
Ok(json!({"contents": items.len()}))
}
/// Edit ▸ Paste with shape items on the clipboard: copies go into each selected shape layer's
/// Contents, above its topmost selected item (in that item's group), else on top. Names stay
/// unique in their group ("Rectangle 2"…), and the copies are selected.
pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result<Value> {
let c = "edit.paste";
let clip = s.state.contents_clipboard.clone();
let (cid, ids) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let targets: Vec<LayerId> =
ids.into_iter().filter(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked)).collect();
if targets.is_empty() {
return Err(bad(c, "select a shape layer to paste shape items into"));
}
let sel = selected_contents(s);
let label = match clip.as_slice() {
[g] => format!("Paste {}", g.name),
_ => "Paste".to_string(),
};
let pasted = s.edit(&label, None, |proj, st| {
let mut next = proj.next_id;
let mut out = vec![];
for lid in &targets {
let l = layer_mut(proj, cid, *lid)?;
// Above the layer's topmost selected item, in its group.
let spot = sel.iter().find(|(sl, _)| sl == lid).and_then(|(_, u)| {
let parent = l.props.parent_of(*u)?;
Some((parent.uid, parent.children.iter().position(|n| n.uid() == *u)?))
});
let (parent, at) = match spot {
Some((pu, i)) => (l.props.find_group_mut(pu), i),
None => (l.props.sub_mut("contents"), 0),
};
let parent = parent.ok_or_else(|| bad(c, "the layer has no contents"))?;
for (k, g) in clip.iter().enumerate() {
let mut g = g.clone();
g.reassign_uids(&mut next);
g.name = super::effect::unique_name(parent, &g.name);
out.push((*lid, g.uid));
parent.children.insert((at + k).min(parent.children.len()), g.into());
}
}
proj.next_id = next + 1;
st.selected_props = out.clone();
Ok(out)
})?;
Ok(json!({"contents": pasted.iter().map(|(_, u)| *u).collect::<Vec<_>>()}))
}
/// Edit ▸ Duplicate with shape items selected: each is copied in place, above the original and
/// named as After Effects does ("Rectangle 1" → "Rectangle 2"), and the copies are selected.
pub(crate) fn duplicate_contents(s: &mut Session, items: &[(LayerId, Uid)]) -> Result<Value> {
let c = "edit.duplicate";
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let copies = s.edit("Duplicate", None, |proj, st| {
let mut next = proj.next_id;
let mut out = vec![];
for (lid, uid) in items {
let parent = layer_mut(proj, cid, *lid)?.props.parent_of_mut(*uid).ok_or_else(|| bad(c, "the shape item is gone"))?;
let Some((i, Node::Group(g))) = parent.children.iter().enumerate().find(|(_, n)| n.uid() == *uid) else { continue };
let mut g = g.clone();
g.reassign_uids(&mut next);
g.name = super::effect::unique_name(parent, &g.name);
out.push((*lid, g.uid));
parent.children.insert(i, Node::Group(g));
}
proj.next_id = next + 1;
st.selected_props = out.clone();
Ok(out)
})?;
Ok(json!({"contents": copies.iter().map(|(_, u)| *u).collect::<Vec<_>>()}))
}
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let c = "prop.duplicateGroup";
let (cid, lid, uid) = group_ref(s, p, c, true)?;

View File

@ -40,8 +40,11 @@ fn has_proxy(s: &Session) -> std::result::Result<(), String> {
/// Probe a proxy file through the importer.
fn probe(s: &Session, path: &str, cmd: &str) -> Result<Footage> {
let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?;
let f = importer.probe(path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
if s.importer.is_none() {
return Err(EngineError::Other("media import is not available in this build".into()));
}
// The first frame of a proxy sequence (Create Proxy ▸ Movie renders one) brings the rest.
let f = s.probe_footage(path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
if !f.has_video {
return Err(bad(cmd, format!("{path} has no video to use as a proxy")));
}

View File

@ -26,8 +26,9 @@ fn pt(v: Option<&Value>) -> Option<[f64; 2]> {
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?])
}
/// The Puppet effect's input pixels and evaluated parameters at comp time `t`.
pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick) -> Option<(Buf, Params)> {
/// The Puppet effect's input pixels and evaluated parameters at comp time `t`. The input comes
/// from the layer cache, shared: moving pins doesn't change it (#212).
pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick) -> Option<(std::sync::Arc<Buf>, Params)> {
let comp = s.project.comp(cid)?;
let layer = comp.layer(lid)?;
let fx = layer.effects()?;
@ -37,7 +38,7 @@ pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick)
let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default());
r.expr = s.expr.as_deref();
r.cache = Some(&s.layer_cache);
let buf = (*r.layer_input(&ctx, layer, index)?).clone();
let buf = r.layer_input(&ctx, layer, index)?;
let params = effectcraft_effects::flatten_params(g, &mut |p| ctx.value(layer, p));
Some((buf, params))
}
@ -86,11 +87,7 @@ fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
if want_mesh.is_some_and(|w| w != *uid) || mesh.tris.is_empty() {
continue;
}
let inside = mesh.tris.iter().any(|tr| {
let (a, b, c) = (def[tr[0]], def[tr[1]], def[tr[2]]);
let cr = |o: [f64; 2], u: [f64; 2], v: [f64; 2]| (u[0] - o[0]) * (v[1] - o[1]) - (u[1] - o[1]) * (v[0] - o[0]);
cr(a, b, at) >= 0.0 && cr(b, c, at) >= 0.0 && cr(c, a, at) >= 0.0
});
let inside = puppet::contains(mesh, def, at);
let near = def.iter().map(|v| (v[0] - at[0]).hypot(v[1] - at[1])).fold(f64::MAX, f64::min) < 12.0;
if inside || near || want_mesh.is_some() {
target = Some((*uid, puppet::unmap(mesh, def, at)));

View File

@ -8,7 +8,7 @@
use effectcraft_project::render_queue::{
AlphaMode, AudioFormat, AudioOutput, Channels, ColorDepth, CurrentOrOff, DEFAULT_SEQUENCE_TEMPLATE, DEFAULT_TEMPLATE, EffectsMode, FieldRender,
OutputFormat, OutputModule, PostRenderAction, ProResProfile, Pulldown, RESIZE_PRESETS, RenderLog, RenderQuality, RenderQueueItem, RenderSettings,
RenderStatus, ResizeQuality, SwitchOverride, TimeSpan, post_render_parse,
RenderStatus, ResizeQuality, SwitchOverride, TimeSpan, post_render_parse, without_project_extension,
};
use effectcraft_project::render_templates::{RenderTemplates, TemplateKind, TemplateSlot};
use effectcraft_time::{FrameRate, Tick};
@ -19,7 +19,7 @@ use crate::{EngineError, Result, Session, cmd, query};
fn can_add(s: &Session) -> std::result::Result<(), String> {
not_rendering(s)?;
if s.comp_for_queue(&Value::Null).is_some() { Ok(()) } else { Err("select or open a composition".into()) }
if s.comp_for_queue(&Value::Null).is_ok() { Ok(()) } else { Err("select or open a composition".into()) }
}
fn not_rendering(s: &Session) -> std::result::Result<(), String> {
if s.is_rendering() { Err("the render queue is rendering".into()) } else { Ok(()) }
@ -202,6 +202,9 @@ fn apply_om_template(om: &mut OutputModule, t: &OutputModule) {
om.name = t.name.clone();
}
/// The `format` parameter's values.
const FORMATS: &str = "format: h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff";
/// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest
/// (captured by `crop.useRoi`).
fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result<bool> {
@ -213,9 +216,12 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
}
let before = om.clone();
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, "format: h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, FORMATS))?;
om.set_format(f);
}
// Before Channels: the WebM codec decides whether alpha can be written, so an explicit
// `channels: rgba` with `webmCodec: av1` is refused rather than dropped (#166).
any |= apply_codec_options(om, p, cmd)?;
if let Some(c) = str_p(p, "channels") {
om.channels = match c.to_ascii_lowercase().replace([' ', '+'], "").as_str() {
"rgb" => Channels::Rgb,
@ -224,7 +230,11 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
_ => return Err(bad(cmd, "channels: rgb|rgba|alpha")),
};
if om.channels == Channels::Rgba && !om.supports_alpha() {
return Err(bad(cmd, format!("{} has no alpha channel", om.format.label())));
let what = match om.format {
OutputFormat::WebM => format!("{} WebM has no alpha channel (VP9 WebM has)", om.webm_codec.label()),
f => format!("{} has no alpha channel", f.label()),
};
return Err(bad(cmd, what));
}
}
if let Some(c) = str_p(p, "color").or(str_p(p, "alphaMode")) {
@ -234,7 +244,6 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
_ => return Err(bad(cmd, "color: straight|premultiplied")),
};
}
any |= apply_codec_options(om, p, cmd)?;
if let Some(q) = f_p(p, "quality") {
om.quality = q.clamp(1.0, 100.0) as u8;
}
@ -355,16 +364,24 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
if let Some(l) = b_p(p, "includeProjectLink") {
om.include_project_link = l;
}
if let Some(o) = str_p(p, "output").or(str_p(p, "path")) {
// (Without the `.ecproj` a save dialog filtered to projects appended, #293.)
if let Some(o) = str_p(p, "output").or(str_p(p, "path")).map(without_project_extension) {
om.output = o.to_string();
if p.get("format").is_none()
&& let Some(f) = OutputFormat::from_path(o).filter(|f| *f != om.format && !o.contains("[fileExtension]"))
{
// `out.mov` with an H.264 module → ProRes, like picking a file type in Output To.
let keep = om.output.clone();
om.set_format(f);
om.output = keep;
// Only an extension the format doesn't write changes it: `.mp4` is H.264's, HEVC's and AV1's.
if let Some(f) = OutputFormat::from_path(o).filter(|f| f.extension() != om.format.extension() && !o.contains("[fileExtension]")) {
if p.get("format").is_some() {
// An explicit format wins and the extension follows it, as choosing a format in
// the Output Module renames Output To (#154: `--format hevc --out x.mp4`).
om.set_format(om.format);
} else {
// `out.mov` with an H.264 module → ProRes, like picking a file type in Output To.
let keep = om.output.clone();
om.set_format(f);
om.output = keep;
}
}
// The format's extension exactly once (`Comp 2.mov.mov` → `Comp 2.mov`).
om.output = om.format.with_extension(&om.output);
}
if *om != before {
// Output To alone doesn't make the module custom.
@ -496,24 +513,20 @@ fn item_json(s: &Session, it: &RenderQueueItem, index: usize) -> Value {
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let cid = s.comp_for_queue(p).ok_or(EngineError::NoComp)?;
let cid = s.comp_for_queue(p)?;
let mut it = RenderQueueItem::new(0, cid);
// New items start from the Movie Default templates (Edit ▸ Templates).
let templates = s.project.render_templates.clone();
it.settings = templates.default_render_settings(TemplateSlot::Movie);
it.output = templates.default_output_module(TemplateSlot::Movie);
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad("renderQueue.add", "format: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
let f = OutputFormat::from_name(f).ok_or_else(|| bad("renderQueue.add", FORMATS))?;
if f != it.output.format {
it.output = OutputModule::for_format(f);
}
}
apply_settings(&mut it.settings, &templates, p, "renderQueue.add")?;
let mut op = p.clone();
if let Some(o) = op.as_object_mut() {
o.remove("format");
}
apply_output(&mut it.output, &templates, s.state.region_of_interest, &op, "renderQueue.add")?;
apply_output(&mut it.output, &templates, s.state.region_of_interest, p, "renderQueue.add")?;
if let Some(l) = enum_p(p, "log", "renderQueue.add", RenderLog::parse, "errorsOnly|plusSettings|plusPerFrameInfo")? {
it.log = l;
}
@ -704,7 +717,7 @@ fn add_output_module(s: &mut Session, p: &Value) -> Result<Value> {
let n = it.extra_outputs.len() + 2;
let mut om = it.output.clone();
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad("render.addOutputModule", "format: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
let f = OutputFormat::from_name(f).ok_or_else(|| bad("render.addOutputModule", FORMATS))?;
om = OutputModule::for_format(f);
}
if str_p(p, "output").is_none() && str_p(p, "path").is_none() {
@ -714,11 +727,7 @@ fn add_output_module(s: &mut Session, p: &Value) -> Result<Value> {
None => format!("{}_{n}", om.output),
};
}
let mut op = p.clone();
if let Some(o) = op.as_object_mut() {
o.remove("format");
}
apply_output(&mut om, &s.project.render_templates, s.state.region_of_interest, &op, "render.addOutputModule")?;
apply_output(&mut om, &s.project.render_templates, s.state.region_of_interest, p, "render.addOutputModule")?;
s.edit("Add Output Module", None, |proj, _| {
let it = &mut proj.render_queue[i];
it.extra_outputs.push(om);
@ -733,7 +742,7 @@ fn add_output_module(s: &mut Session, p: &Value) -> Result<Value> {
/// Composition ▸ Pre-render…: queue the comp with a lossless-with-alpha module whose post-render
/// action imports the result and replaces the comp's uses.
fn pre_render(s: &mut Session, p: &Value) -> Result<Value> {
let cid = s.comp_for_queue(p).ok_or(EngineError::NoComp)?;
let cid = s.comp_for_queue(p)?;
let mut it = RenderQueueItem::new(0, cid);
let templates = s.project.render_templates.clone();
it.settings = templates.default_render_settings(TemplateSlot::PreRender);

View File

@ -239,11 +239,12 @@ fn auto_save(s: &mut Session, _: &Value) -> Result<Value> {
fn recovery_info(s: &mut Session, _: &Value) -> Result<Value> {
let root = s.default_autosave_root();
let latest = crate::autosave::latest_in(s.file_ops(), &s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string());
let prefs = s.autosave_prefs();
let latest = crate::autosave::latest_in(s.file_ops(), &prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string());
Ok(json!({
"lastAutoSave": s.autosave.last_path,
"latestOnDisk": latest,
"folder": crate::autosave::folder(&s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string()),
"folder": crate::autosave::folder(&prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string()),
"recentProjects": s.prefs.recent_projects,
}))
}

View File

@ -1,14 +1,16 @@
//! Shape Stroke options: the Dashes "+" / "−" buttons (Dash 2 / Gap 2, Dash 3 / Gap 3), Taper and
//! Wave. Taper and Wave are ordinary properties of the stroke (`…/stroke/taper/startLength`); the
//! commands here set several of them in one undo step and add the groups to strokes saved before
//! they existed.
//! they existed. Also the toolbar's Fill / Stroke options on selected shape layers.
use effectcraft_keyframe::Value as KV;
use effectcraft_project::build::{self, EXTRA_DASHES, Ids};
use effectcraft_project::{ItemId, LayerId, PropGroup, Uid};
use effectcraft_project::{ItemId, Layer, LayerId, LayerSource, Node, PropGroup, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, bad, has_layers, layer_mut, layer_p, merge_p};
use super::layer::color_p;
use super::{CommandSpec, bad, f_p, has_layers, layer_mut, layer_p, layers_p, merge_p};
use crate::{EngineError, Result, Session, cmd};
fn is_stroke(g: &PropGroup) -> bool {
@ -195,8 +197,116 @@ fn wave(s: &mut Session, p: &Value) -> Result<Value> {
)
}
/// Path items a Fill or Stroke paints in their group.
const PATHS: [&str; 4] = ["rect", "ellipse", "star", "path"];
/// Shape groups nest; deeper contents are left alone (never-crash recursion bound).
pub(crate) const MAX_DEPTH: usize = 64;
/// The first Fill colour, Stroke colour and Stroke Width in `layer`'s contents at layer time
/// `lt` (what the toolbar shows for a selected shape layer).
pub fn first_paint(layer: &Layer, lt: Tick) -> (Option<[f64; 4]>, Option<[f64; 4]>, Option<f64>) {
let (mut fill, mut stroke, mut width) = (None, None, None);
if let Some(c) = layer.props.sub("contents") {
walk_groups(c, &mut |g| {
let at = |id: &str| g.get(id).map(|p| p.value_at(lt));
let rgba = |v: KV| v.as_color().map(f64::from);
match g.match_id.as_str() {
"fill" if fill.is_none() => fill = at("color").map(rgba),
"stroke" if stroke.is_none() => {
stroke = at("color").map(rgba);
width = at("width").map(|v| v.as_f64());
}
_ => {}
}
});
}
(fill, stroke, width)
}
/// Paint one contents group and its sub-groups: Fills take `fill`, Strokes `stroke`, Strokes and
/// Gradient Strokes `width`. With `add_stroke`, a group with a path and no stroke gets one (before
/// its fills, as new shapes have it).
#[allow(clippy::too_many_arguments)]
fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, stroke: Option<[f64; 4]>, width: Option<f64>, add_stroke: bool, depth: usize) {
if depth > MAX_DEPTH {
return;
}
if add_stroke && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) && !c.groups().any(is_stroke) {
let at = c.children.iter().position(|n| matches!(n, Node::Group(g) if matches!(g.match_id.as_str(), "fill" | "gfill"))).unwrap_or(c.children.len());
c.children.insert(at, build::shape_stroke(ids, stroke.unwrap_or([1.0; 4]), width.unwrap_or(2.0)).into());
}
for n in &mut c.children {
let Node::Group(g) = n else { continue };
let set = |g: &mut PropGroup, id: &str, v: Option<KV>| {
if let (Some(v), Some(p)) = (v, g.get_mut(id)) {
p.set_value_at(lt, v);
}
};
match g.match_id.as_str() {
"fill" => set(g, "color", fill.map(KV::Color)),
"stroke" => {
set(g, "color", stroke.map(KV::Color));
set(g, "width", width.map(KV::Scalar));
}
"gstroke" => set(g, "width", width.map(KV::Scalar)),
"group" => {
if let Some(sub) = g.sub_mut("contents") {
paint(sub, ids, lt, fill, stroke, width, add_stroke, depth + 1);
}
}
_ => {}
}
}
}
/// The toolbar's Fill and Stroke options with shape layers selected (as in After Effects, they
/// change the layers' fills and strokes): `fill` sets every Fill's colour, `stroke` every Stroke's,
/// `strokeWidth` every stroke's width. A stroke colour or a width above 0 gives the paths that
/// have no stroke one. Without values, reports the first layer's paint.
fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let c = "shape.fillStroke";
let (cid, lids) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let shapes: Vec<LayerId> = lids.into_iter().filter(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape))).collect();
if shapes.is_empty() {
return Err(bad(c, "select a shape layer"));
}
let rgba = |k: &str| color_p(p, k).map(|c| [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]);
let (fill, stroke) = (rgba("fill"), rgba("stroke"));
let width = f_p(p, "strokeWidth").filter(|w| w.is_finite()).map(|w| w.max(0.0));
if fill.is_some() || stroke.is_some() || width.is_some() {
let add_stroke = width.map_or(stroke.is_some(), |w| w > 0.0);
let t = s.time();
s.edit("Fill and Stroke", merge_p(p), |proj, _| {
let mut next = proj.next_id;
for lid in &shapes {
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
if let Some(contents) = l.props.sub_mut("contents") {
paint(contents, &mut Ids(&mut next), lt, fill, stroke, width, add_stroke, 0);
}
}
proj.next_id = next;
Ok(())
})?;
}
let l = shapes.first().and_then(|id| s.project.comp(cid)?.layer(*id)).ok_or(EngineError::NoComp)?;
let (fill, stroke, width) = first_paint(l, l.layer_time(s.time()));
Ok(json!({"fill": fill, "stroke": stroke, "strokeWidth": width}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"shape.fillStroke",
"Fill and Stroke",
[],
None,
"{layers?, fill?: color, stroke?: color, strokeWidth?, merge?} → {fill, stroke, strokeWidth} (first layer)",
has_layers,
fill_stroke
),
cmd!("shape.dashes.add", "Add Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_add),
cmd!("shape.dashes.remove", "Remove Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_remove),
cmd!(

View File

@ -0,0 +1,368 @@
//! The shape tools (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star) and the Pen's options
//! in the Tools bar (Tool Creates Shape / Tool Creates Mask, Fill and Stroke with their Fill
//! Options / Stroke Options), and drawing with them: a drawn shape goes into the selected shape
//! layer's Contents as a new group, as in After Effects, or into a new shape layer.
use effectcraft_color::BlendMode;
use effectcraft_geom::vec2;
use effectcraft_keyframe::{Gradient, ShapePath, Value as KV};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, LayerId, LayerSource, Project, PropGroup};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::layer::{color_p, insert_layer, place, position_p};
use super::{CommandSpec, always, b_p, bad, comp_id, has_comp, layer_mut, str_p};
use crate::{EditorState, EngineError, Result, Session, cmd};
/// The shape tools' kinds, as `shape.newShape` / `layer.newShape` name them.
const KINDS: [&str; 5] = ["rect", "rounded", "ellipse", "polygon", "star"];
/// Fill Options / Stroke Options: what new shapes are painted with.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PaintKind {
None,
#[default]
Solid,
Linear,
Radial,
}
impl PaintKind {
pub const ALL: [PaintKind; 4] = [PaintKind::None, PaintKind::Solid, PaintKind::Linear, PaintKind::Radial];
/// The `fillType` / `strokeType` value.
pub fn id(self) -> &'static str {
match self {
PaintKind::None => "none",
PaintKind::Solid => "solid",
PaintKind::Linear => "linear",
PaintKind::Radial => "radial",
}
}
/// After Effects' name in Fill Options / Stroke Options.
pub fn label(self) -> &'static str {
match self {
PaintKind::None => "None",
PaintKind::Solid => "Solid Color",
PaintKind::Linear => "Linear Gradient",
PaintKind::Radial => "Radial Gradient",
}
}
/// An id or label, in any case and spacing (`radial`, `Radial Gradient`).
pub fn from_name(s: &str) -> Option<PaintKind> {
let k: String = s.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_ascii_lowercase();
PaintKind::ALL.into_iter().find(|m| m.id() == k || m.label().replace(' ', "").to_ascii_lowercase() == k)
}
}
/// The Fill or the Stroke of the Tools bar: its kind, colour (for Solid Color), blend mode and
/// opacity (%).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ToolPaint {
pub kind: PaintKind,
pub color: [f32; 3],
pub blend: BlendMode,
pub opacity: f64,
}
impl Default for ToolPaint {
fn default() -> Self {
ToolPaint { kind: PaintKind::Solid, color: [1.0, 1.0, 1.0], blend: BlendMode::Normal, opacity: 100.0 }
}
}
/// The shape tools' and the Pen's options: Tool Creates Mask (with a shape layer selected they
/// draw masks on it instead of shapes) and the Fill, Stroke and Stroke Width new shapes get.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ShapeTool {
pub creates_mask: bool,
pub fill: ToolPaint,
pub stroke: ToolPaint,
/// 0 draws no stroke.
pub stroke_width: f64,
}
impl Default for ShapeTool {
fn default() -> Self {
ShapeTool { creates_mask: false, fill: ToolPaint { color: [0.25, 0.55, 1.0], ..ToolPaint::default() }, stroke: ToolPaint::default(), stroke_width: 0.0 }
}
}
/// `p`'s paint keys over `base`: `fill` / `stroke` (a colour, which makes a None paint Solid
/// Color; `null` or `false` for None), `fillType` / `strokeType` (none|solid|linear|radial),
/// `fillBlend` / `strokeBlend` (a blend mode), `fillOpacity` / `strokeOpacity` (%) and
/// `strokeWidth` (0 draws no stroke).
pub(crate) fn paint_p(p: &Value, base: &ShapeTool, cmd: &str) -> Result<ShapeTool> {
let mut o = base.clone();
for (key, paint) in [("fill", &mut o.fill), ("stroke", &mut o.stroke)] {
match p.get(key) {
None => {}
Some(Value::Null | Value::Bool(false)) => paint.kind = PaintKind::None,
Some(_) => {
paint.color = color_p(p, key).ok_or_else(|| bad(cmd, format!("`{key}`: a colour [r, g, b] or #hex, or false for none")))?;
if paint.kind == PaintKind::None {
paint.kind = PaintKind::Solid;
}
}
}
if let Some(v) = p.get(format!("{key}Type").as_str()) {
paint.kind = v.as_str().and_then(PaintKind::from_name).ok_or_else(|| bad(cmd, format!("`{key}Type`: none|solid|linear|radial")))?;
}
if let Some(v) = p.get(format!("{key}Blend").as_str()) {
paint.blend = v.as_str().and_then(BlendMode::from_name).ok_or_else(|| bad(cmd, format!("`{key}Blend`: a blend mode name (normal, multiply…)")))?;
}
if let Some(v) = p.get(format!("{key}Opacity").as_str()) {
paint.opacity = v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}Opacity`: a percentage")))?.clamp(0.0, 100.0);
}
}
if let Some(v) = p.get("strokeWidth") {
o.stroke_width = v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, "`strokeWidth`: pixels"))?.max(0.0);
}
Ok(o)
}
/// A Fill or Stroke item with the paint's blend mode and opacity.
fn blended(mut g: PropGroup, paint: &ToolPaint) -> PropGroup {
let blend = BlendMode::ALL.iter().position(|m| *m == paint.blend).unwrap_or(0) as u32;
if let Some(pr) = g.get_mut("blend") {
pr.value = KV::Enum(blend);
}
if let Some(pr) = g.get_mut("opacity") {
pr.value = KV::Scalar(paint.opacity);
}
g
}
/// The Stroke and Fill a new shape gets from `paint`; gradients run across `bounds` (`[x, y, w,
/// h]` in the group's space): left to right, or out from the centre.
pub(crate) fn paint_items(ids: &mut Ids, paint: &ShapeTool, bounds: [f64; 4]) -> Vec<PropGroup> {
let [x, y, w, h] = bounds;
let mid = [x + w / 2.0, y + h / 2.0];
let ends = |radial: bool| if radial { (mid, [mid[0] + w.max(h).max(2.0) / 2.0, mid[1]]) } else { ([x, mid[1]], [x + w.max(1.0), mid[1]]) };
let mut items = vec![];
let (s, width) = (&paint.stroke, paint.stroke_width);
let stroke = match s.kind {
_ if width <= 0.0 => None,
PaintKind::None => None,
PaintKind::Solid => Some(build::shape_stroke(ids, rgba(s.color), width)),
k => {
let (a, b) = ends(k == PaintKind::Radial);
Some(build::shape_gradient_stroke(ids, k == PaintKind::Radial, a, b, Gradient::default(), width))
}
};
items.extend(stroke.map(|g| blended(g, s)));
let f = &paint.fill;
let fill = match f.kind {
PaintKind::None => None,
PaintKind::Solid => Some(build::shape_fill(ids, rgba(f.color))),
k => {
let (a, b) = ends(k == PaintKind::Radial);
Some(build::shape_gradient_fill(ids, k == PaintKind::Radial, a, b, Gradient::default()))
}
};
items.extend(fill.map(|g| blended(g, f)));
items
}
fn rgba(c: [f32; 3]) -> [f64; 4] {
[c[0] as f64, c[1] as f64, c[2] as f64, 1.0]
}
/// The Rounded Rectangle tool's corner roundness for `size`.
fn roundness(size: [f64; 2]) -> f64 {
size[0].min(size[1]) * 0.15
}
/// The Polygon and Star tools' Polystar for `size`: (star, points, outer radius, inner radius).
fn polystar_of(kind: &str, size: [f64; 2]) -> Option<(bool, f64, f64, f64)> {
match kind {
"star" => Some((true, 5.0, size[0] / 2.0, size[0] / 4.0)),
"polygon" => Some((false, 6.0, size[0] / 2.0, 0.0)),
_ => None,
}
}
/// A new shape group for a shape tool `kind` of `size`, centred on the group's origin, painted
/// with `paint` ("Rectangle 1", "Ellipse 1" or "Polystar 1"). None for another kind.
pub(crate) fn shape_group(ids: &mut Ids, kind: &str, size: [f64; 2], paint: &ShapeTool) -> Option<PropGroup> {
let (path, gname) = match kind {
"rect" | "rectangle" => (build::shape_rect(ids, size, [0.0, 0.0], 0.0), "Rectangle 1"),
"rounded" | "roundedRect" => (build::shape_rect(ids, size, [0.0, 0.0], roundness(size)), "Rectangle 1"),
"ellipse" => (build::shape_ellipse(ids, size, [0.0, 0.0]), "Ellipse 1"),
k => {
let (star, points, outer, inner) = polystar_of(k, size)?;
(build::shape_star(ids, star, points, [0.0, 0.0], outer, inner), "Polystar 1")
}
};
let mut items = vec![path];
items.extend(paint_items(ids, paint, [-size[0] / 2.0, -size[1] / 2.0, size[0], size[1]]));
Some(build::shape_group(ids, gname, items))
}
/// The mask a shape tool draws: `kind` of `size` centred at `centre` (layer space).
pub(crate) fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option<ShapePath> {
let bez = match kind {
"rect" | "rectangle" => return Some(ShapePath::rect(centre, size[0], size[1])),
"ellipse" => return Some(ShapePath::ellipse(centre, size[0], size[1])),
"rounded" | "roundedRect" => effectcraft_path::rect(size, centre, roundness(size)),
k => {
let (star, points, outer, inner) = polystar_of(k, size)?;
effectcraft_path::polystar(star, points, centre, 0.0, inner, outer, 0.0, 0.0)
}
};
effectcraft_path::from_kurbo(&bez).into_iter().next()
}
/// The shape layer a shape tool or the Pen draws into: `layer` (which must be a shape layer),
/// else the first selected unlocked shape layer, else none (a new shape layer).
pub(crate) fn draw_target(s: &Session, comp: &Comp, p: &Value, cmd: &str) -> Result<Option<LayerId>> {
let target = match p.get("layer") {
Some(l) => Some(super::resolve_layer(comp, l).ok_or_else(|| bad(cmd, format!("no layer {l}")))?),
None => s.state.selected_layers.iter().copied().find(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked)),
};
if target.and_then(|l| comp.layer(l)).is_some_and(|l| !matches!(l.source, LayerSource::Shape)) {
return Err(bad(cmd, "the layer is not a shape layer"));
}
Ok(target)
}
/// A new empty shape layer ("Shape Layer n" unless `name`) with its Position at `position` (comp
/// pixels; else the comp centre), above the selected layer and selected.
pub(crate) fn new_shape_layer(
proj: &mut Project,
st: &mut EditorState,
comp: &Comp,
cid: ItemId,
name: Option<&str>,
position: Option<[f64; 2]>,
) -> Result<LayerId> {
let count = comp.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count();
let name = name.map(str::to_string).unwrap_or_else(|| format!("Shape Layer {}", count + 1));
let mut l = build::layer(proj, comp, &name, LayerSource::Shape, (comp.width, comp.height), None);
place(&mut l, position);
insert_layer(proj, st, cid, l)
}
/// Put `g` on top of the shape layer's Contents with a name unique there ("Rectangle 2"…) and
/// its Transform's Position at `position` (layer space). Returns its uid.
pub(crate) fn add_to_contents(
proj: &mut Project,
cid: ItemId,
lid: LayerId,
mut g: PropGroup,
position: [f64; 2],
cmd: &str,
) -> Result<effectcraft_project::Uid> {
let contents = layer_mut(proj, cid, lid)?.props.sub_mut("contents").ok_or_else(|| bad(cmd, "the layer has no contents"))?;
g.name = super::effect::unique_name(contents, &g.name);
if let Some(pr) = g.sub_mut("transform").and_then(|t| t.get_mut("position")) {
pr.value = KV::Vec2(position);
}
let uid = g.uid;
contents.children.insert(0, g.into());
Ok(uid)
}
/// A finite `[w, h]` / `[x, y]` parameter.
fn pair_p(p: &Value, k: &str) -> Option<[f64; 2]> {
let a = p.get(k)?.as_array()?;
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]).filter(|v| v.iter().all(|x| x.is_finite()))
}
/// The shape tools: a rectangle, rounded rectangle, ellipse, polygon or star of `size`, centred
/// at `position`, painted with the Tools bar's Fill and Stroke unless given. With a shape layer
/// given or selected it goes on top of that layer's Contents as a new group ("Rectangle 2"…)
/// placed by the group's Transform, as After Effects draws into the selected shape layer;
/// otherwise a new shape layer is centred on it. `position` and `size` are in comp pixels, or
/// the layer's space with `space: "layer"`.
fn new_shape(s: &mut Session, p: &Value) -> Result<Value> {
let c = "shape.newShape";
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let kind = str_p(p, "kind").unwrap_or("rect");
if !KINDS.contains(&kind) {
return Err(bad(c, format!("unknown kind `{kind}`; one of: {}", KINDS.join(", "))));
}
let size = match p.get("size") {
None => [200.0, 200.0],
Some(_) => pair_p(p, "size").filter(|s| s.iter().all(|v| *v >= 0.0)).ok_or_else(|| bad(c, "size: [w, h], not negative"))?,
};
let pos = position_p(p, c)?.unwrap_or([comp.width as f64 / 2.0, comp.height as f64 / 2.0]);
let paint = paint_p(p, &s.state.shape_tool, c)?;
let target = draw_target(s, &comp, p, c)?;
// In the target layer's space: the centre maps through the layer's transform, the size by
// its scale.
let (centre, size) = match target.and_then(|l| comp.layer(l)) {
Some(l) if str_p(p, "space") != Some("layer") => {
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, s.time_of(cid));
let inv = ctx.layer_to_comp(l).0.inverse().ok_or_else(|| bad(c, "the layer is scaled to nothing"))?;
let q = inv.apply(vec2(pos[0], pos[1]));
([q.x, q.y], [inv.apply_vec(vec2(size[0], 0.0)).length(), inv.apply_vec(vec2(0.0, size[1])).length()])
}
Some(_) => (pos, size),
None => ([0.0, 0.0], size),
};
let label = match kind {
"rounded" => "Rounded Rectangle Tool",
"ellipse" => "Ellipse Tool",
"polygon" => "Polygon Tool",
"star" => "Star Tool",
_ => "Rectangle Tool",
};
let name = str_p(p, "name");
let (lid, group) = s.edit(label, None, |proj, st| {
let lid = match target {
Some(l) => l,
None => new_shape_layer(proj, st, &comp, cid, name, Some(pos))?,
};
let mut next = proj.next_id;
let g = shape_group(&mut Ids(&mut next), kind, size, &paint).ok_or_else(|| bad(c, "unknown kind"))?;
proj.next_id = next;
let group = add_to_contents(proj, cid, lid, g, centre, c)?;
st.selected_layers = vec![lid];
Ok((lid, group))
})?;
Ok(json!({"layer": lid.0, "group": group}))
}
/// The Tools bar's options for the shape tools and the Pen: Tool Creates Shape / Tool Creates
/// Mask (`createsMask`) and the Fill and Stroke new shapes get (the keys of [`paint_p`]);
/// `reset` restores the defaults first. Reports the options.
fn tool_options(s: &mut Session, p: &Value) -> Result<Value> {
let base = if b_p(p, "reset") == Some(true) { ShapeTool::default() } else { s.state.shape_tool.clone() };
let mut o = paint_p(p, &base, "shape.toolOptions")?;
if let Some(m) = b_p(p, "createsMask") {
o.creates_mask = m;
}
s.state.shape_tool = o;
Ok(serde_json::to_value(&s.state.shape_tool).unwrap_or(Value::Null))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"shape.newShape",
"Shape Tool",
[],
None,
"{layer?, kind?: rect|rounded|ellipse|polygon|star, size? [w,h], position? [x,y] (the centre), space?: comp|layer, name? (a new layer's), fill?, fillType?, fillBlend?, fillOpacity?, stroke?, strokeType?, strokeBlend?, strokeOpacity?, strokeWidth? (default the Tools bar's, shape.toolOptions)} → {layer, group}",
has_comp,
new_shape
),
cmd!(
"shape.toolOptions",
"Shape Tool Options",
[],
None,
"{createsMask?: bool (Tool Creates Mask with a shape layer selected), fill?: [r,g,b]|#hex|false, fillType?: none|solid|linear|radial, fillBlend?: blend mode, fillOpacity? %, stroke?, strokeType?, strokeBlend?, strokeOpacity?, strokeWidth? px, reset?} → the options",
always,
tool_options
),
]
}

View File

@ -498,6 +498,33 @@ pub fn font_features(s: &mut Session, p: &Value) -> Result<Value> {
}))
}
/// The installed font families (bundled and system), with their styles, where each comes from
/// and its name in the font's own language. `query` keeps the families whose English or native
/// name contains it (case-insensitive); `rescan` first looks for fonts installed since the app
/// started.
pub fn fonts(_s: &mut Session, p: &Value) -> Result<Value> {
use effectcraft_text::fonts;
let added = if p.get("rescan").and_then(Value::as_bool).unwrap_or(false) { fonts::rescan_system() } else { 0 };
let query = p.get("query").and_then(Value::as_str).map(str::to_lowercase).unwrap_or_default();
let native = fonts::native_families();
let mut origin: std::collections::BTreeMap<String, &'static str> = Default::default();
for f in fonts::all_faces() {
origin.entry(f.info.family.clone()).or_insert(f.info.origin);
}
let list: Vec<Value> = effectcraft_text::families()
.into_iter()
.filter(|(f, _)| query.is_empty() || f.to_lowercase().contains(&query) || native.get(f).is_some_and(|n| n.to_lowercase().contains(&query)))
.map(|(f, styles)| {
let mut v = json!({"family": f, "styles": styles, "origin": origin.get(&f).copied().unwrap_or("system")});
if let Some(n) = native.get(&f) {
v["nativeName"] = json!(n);
}
v
})
.collect();
Ok(json!({"count": list.len(), "families": list, "added": added}))
}
fn always_ok(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
@ -527,6 +554,15 @@ pub fn specs() -> Vec<CommandSpec> {
always_ok,
font_features
),
cmd!(
"text.fonts",
"List Fonts",
[],
None,
"{query?, rescan?} → {count, families: [{family, styles, origin: bundled|system|user, nativeName?}], added} (rescan picks up fonts installed since launch)",
always_ok,
fonts
),
cmd!(
"text.setSelection",
"Set Text Selection",

View File

@ -220,7 +220,9 @@ fn new_light(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let kind = match p.get("kind").or_else(|| p.get("type")) {
Some(_) => light_kind_p(p, "kind").or_else(|| light_kind_p(p, "type")).ok_or_else(|| bad("layer.newLight", "kind: Parallel|Spot|Point|Ambient"))?,
Some(_) => {
light_kind_p(p, "kind").or_else(|| light_kind_p(p, "type")).ok_or_else(|| bad("layer.newLight", "kind: Parallel|Spot|Point|Ambient|Environment"))?
}
None => LightKind::Spot,
};
let name = str_p(p, "name").unwrap_or("Light 1").to_string();
@ -239,9 +241,11 @@ fn light_settings(s: &mut Session, p: &Value) -> Result<Value> {
let t = time_p(s, p, Some(&comp));
let name = str_p(p, "name").map(str::to_string);
let kind = match p.get("kind").or_else(|| p.get("type")) {
Some(_) => {
Some(light_kind_p(p, "kind").or_else(|| light_kind_p(p, "type")).ok_or_else(|| bad("layer.lightSettings", "kind: Parallel|Spot|Point|Ambient"))?)
}
Some(_) => Some(
light_kind_p(p, "kind")
.or_else(|| light_kind_p(p, "type"))
.ok_or_else(|| bad("layer.lightSettings", "kind: Parallel|Spot|Point|Ambient|Environment"))?,
),
None => None,
};
s.edit("Light Settings", None, |proj, _| {
@ -662,7 +666,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Light...",
["Layer", "New"],
Some("Cmd+Alt+Shift+L"),
"{kind?: Parallel|Spot|Point|Ambient (default Spot), name?, color?, intensity?, coneAngle?, coneFeather?, falloff?: None|Smooth|Inverse Square Clamped, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position? [x,y,z], poi? [x,y,z]}",
"{kind?: Parallel|Spot|Point|Ambient|Environment (default Spot; Environment lights and reflects from an equirectangular image layer: prop.set its lightOptions/source to that layer's id, or leave it empty for the comp's Environment Layer, see layer.environment), name?, color?, intensity?, coneAngle?, coneFeather?, falloff?: None|Smooth|Inverse Square Clamped, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position? [x,y,z], poi? [x,y,z]}",
has_comp,
new_light
),
@ -689,7 +693,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Light Settings...",
[],
None,
"{layer?, name?, kind?, color?, intensity?, coneAngle?, coneFeather?, falloff?, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position?, poi?}",
"{layer?, name?, kind?: Parallel|Spot|Point|Ambient|Environment, color?, intensity?, coneAngle?, coneFeather?, falloff?, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position?, poi?}",
has_comp,
light_settings
),

View File

@ -40,11 +40,12 @@ fn import_timeline(s: &mut Session, p: &Value) -> Result<Value> {
};
let importer = s.importer.clone();
let services = s.services.clone();
let rate = s.sequence_rate();
let mut probe = move |m: &str| -> Option<effectcraft_project::Footage> {
if !services.exists(m) {
return None;
}
importer.as_ref()?.probe(m).ok()
crate::sequence::probe_path(importer.as_deref()?, services.as_ref(), m, rate).ok()
};
let res = s.edit("Import Timeline", None, |proj, st| {
let r = effectcraft_interchange::import(proj, &bytes, format, &opts, &mut probe).map_err(|e| EngineError::Other(e.to_string()))?;

View File

@ -3,14 +3,13 @@
//! shape layers (`shape.newPath`).
use effectcraft_keyframe::ShapePath;
use effectcraft_project::LayerSource;
use effectcraft_project::build::{self, Ids};
use effectcraft_render::RenderOpts;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, merge_p, str_p};
use crate::viewer::{Channel, CustomRgb, SimProfile, Simulation, Snapshot};
use crate::viewer::{Channel, CustomRgb, SimProfile, Simulation, SnapFeatures, Snapshot};
use crate::{EngineError, Result, Session, VertexRef, cmd, query};
/// Composition ▸ Preview ▸ Fast Previews modes.
@ -110,6 +109,21 @@ fn snapping(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(s.state.snapping))
}
/// Tools bar ▸ Snapping options: sets the options passed (`toggle` flips one by key).
fn snapping_options(s: &mut Session, p: &Value) -> Result<Value> {
let f = &mut s.state.snap_features;
if let Some(k) = str_p(p, "toggle") {
let v = f.option_mut(k).ok_or_else(|| bad("view.snappingOptions", format!("unknown option `{k}`")))?;
*v = !*v;
}
for (k, _) in SnapFeatures::OPTIONS {
if let (Some(v), Some(o)) = (b_p(p, k), f.option_mut(k)) {
*o = v;
}
}
Ok(json!(s.state.snap_features))
}
fn channel(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "channel").unwrap_or("rgb");
let c = Channel::from_name(name).ok_or_else(|| bad("view.channel", format!("unknown channel `{name}` (rgb|red|green|blue|alpha|rgbStraight)")))?;
@ -200,20 +214,10 @@ fn pts(v: Option<&Value>) -> Vec<[f64; 2]> {
v.and_then(Value::as_array).map(|a| a.iter().filter_map(|p| Some([p.get(0)?.as_f64()?, p.get(1)?.as_f64()?])).collect()).unwrap_or_default()
}
fn rgba(v: Option<&Value>, d: [f64; 4]) -> [f64; 4] {
match v {
Some(Value::Array(a)) => {
let g = |i: usize, d: f64| a.get(i).and_then(Value::as_f64).unwrap_or(d);
[g(0, d[0]), g(1, d[1]), g(2, d[2]), g(3, 1.0)]
}
Some(Value::String(h)) => effectcraft_color::Rgba::from_hex(h).map(|c| [c.r as f64, c.g as f64, c.b as f64, 1.0]).unwrap_or(d),
_ => d,
}
}
/// Pen tool on a shape layer: a new Shape group ("Shape n" with Path 1, Stroke 1 and Fill 1) on
/// the given / selected shape layer, or on a new shape layer when none is given. Vertices are
/// in the layer's space, or comp space (`space: "comp"`, the default for a new layer).
/// Pen tool on a shape layer: a new Shape group ("Shape n" with Path 1 and the Tools bar's
/// Stroke and Fill unless given) on the given / selected shape layer, or on a new shape layer
/// when none is given. Vertices are in the layer's space, or comp space (`space: "comp"`, the
/// default for a new layer).
fn new_path(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
@ -226,15 +230,7 @@ fn new_path(s: &mut Session, p: &Value) -> Result<Value> {
let mut outs = pts(p.get("outTangents"));
ins.resize(n, [0.0; 2]);
outs.resize(n, [0.0; 2]);
let target = match p.get("layer") {
Some(l) => Some(super::resolve_layer(&comp, l).ok_or_else(|| bad("shape.newPath", format!("no layer {l}")))?),
None => s.state.selected_layers.iter().copied().find(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked)),
};
if let Some(l) = target.and_then(|l| comp.layer(l))
&& !matches!(l.source, LayerSource::Shape)
{
return Err(bad("shape.newPath", "the layer is not a shape layer"));
}
let target = super::shape_tool::draw_target(s, &comp, p, "shape.newPath")?;
let comp_space = str_p(p, "space").map(|s| s == "comp").unwrap_or(target.is_none());
if comp_space {
// Comp → layer space of the target (a new layer sits at the comp centre, unrotated).
@ -255,38 +251,26 @@ fn new_path(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false), feather: Vec::new() };
let fill = (!matches!(p.get("fill"), Some(Value::Null) | Some(Value::Bool(false)))).then(|| rgba(p.get("fill"), [0.25, 0.55, 1.0, 1.0]));
let width = f_p(p, "strokeWidth").unwrap_or(2.0);
let stroke = (width > 0.0).then(|| rgba(p.get("stroke"), [1.0, 1.0, 1.0, 1.0]));
let paint = super::shape_tool::paint_p(p, &s.state.shape_tool, "shape.newPath")?;
// Gradients run across the path's points.
let (lo, hi) =
path.vertices.iter().fold(([f64::MAX; 2], [f64::MIN; 2]), |(lo, hi), v| ([lo[0].min(v[0]), lo[1].min(v[1])], [hi[0].max(v[0]), hi[1].max(v[1])]));
let bounds = [lo[0], lo[1], hi[0] - lo[0], hi[1] - lo[1]];
let name = str_p(p, "name").map(str::to_string);
let (lid, group, path_uid) = s.edit("Pen Tool", None, |proj, st| {
let lid = match target {
Some(l) => l,
None => {
let count = comp.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count();
let lname = name.clone().unwrap_or_else(|| format!("Shape Layer {}", count + 1));
let l = build::layer(proj, &comp, &lname, LayerSource::Shape, (comp.width, comp.height), None);
super::layer::insert_layer(proj, st, cid, l)?
}
None => super::shape_tool::new_shape_layer(proj, st, &comp, cid, name.as_deref(), None)?,
};
let mut next = proj.next_id;
let l = super::layer_mut(proj, cid, lid)?;
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("shape.newPath", "the layer has no contents"))?;
let k = contents.groups().filter(|g| g.match_id == "group").count();
let mut ids = Ids(&mut next);
let pg = build::shape_path(&mut ids, path);
let path_uid = pg.uid;
let mut items = vec![pg];
if let Some(c) = stroke {
items.push(build::shape_stroke(&mut ids, c, width));
}
if let Some(c) = fill {
items.push(build::shape_fill(&mut ids, c));
}
let g = build::shape_group(&mut ids, &format!("Shape {}", k + 1), items);
let group = g.uid;
contents.children.insert(0, g.into());
items.extend(super::shape_tool::paint_items(&mut ids, &paint, bounds));
let g = build::shape_group(&mut ids, "Shape 1", items);
proj.next_id = next;
let group = super::shape_tool::add_to_contents(proj, cid, lid, g, [0.0, 0.0], "shape.newPath")?;
st.selected_layers = vec![lid];
st.selected_vertices = vec![VertexRef { layer: lid, mask: path_uid, index: n - 1 }];
Ok((lid, group, path_uid))
@ -478,6 +462,15 @@ fn display_color_state(s: &mut Session, _: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("view.snapping", "Snapping", ["View"], None, "{value?}", always, snapping),
cmd!(
"view.snappingOptions",
"Snapping Options",
[],
None,
"{edgesExtended?, edges?, corners?, centers?, anchorPoints?, paths?: bool, toggle?: one of those keys} → the options",
always,
snapping_options
),
cmd!("view.displayColorManagement", "Use Display Color Management", ["View"], None, "{value?}", always, display_cm),
cmd!(
"view.simulateOutput",
@ -518,7 +511,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Pen Tool (Shape Path)",
[],
None,
"{layer?, vertices: [[x,y]…], inTangents?, outTangents?, closed?, space?: comp|layer, fill?: [r,g,b]|#hex|false, stroke?, strokeWidth?, name?}",
"{layer?, vertices: [[x,y]…], inTangents?, outTangents?, closed?, space?: comp|layer, fill?: [r,g,b]|#hex|false, fillType?, fillBlend?, fillOpacity?, stroke?, strokeType?, strokeBlend?, strokeOpacity?, strokeWidth? (default the Tools bar's, shape.toolOptions), name?}",
shape_layer_or_none,
new_path
),

View File

@ -0,0 +1,272 @@
//! Ease presets (Window ▸ Ease Presets): easing curves kept by name and applied to pairs of
//! keyframes. A few original built-in curves ship with EffectCraft; user presets live in the config
//! store (`ease_presets.json`), read leniently: a corrupt file or entry is skipped, never fatal.
//!
//! Commands: `keys.easePreset.apply {preset | curve}` eases every pair of neighbouring selected
//! keyframes of each property (one undo step), `keys.easePreset.capture` reads the curve between
//! the first selected pair, `keys.easePreset.save {name, curve?}`, `keys.easePreset.list`,
//! `keys.easePreset.rename {name, newName}` and `keys.easePreset.delete {name}`.
use std::collections::BTreeMap;
use effectcraft_keyframe::{EaseCurve, key_at};
use effectcraft_project::{LayerId, Uid};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::commands::{CommandSpec, always, bad, has_keys, str_p};
use crate::{EngineError, Result, Session, cmd, query};
/// User presets in the config store.
pub const EASE_PRESETS_FILE: &str = "ease_presets.json";
/// Longest preset name, in characters.
const MAX_NAME: usize = 64;
const NEED_PAIR: &str = "select two or more neighbouring keyframes of a property";
/// A user preset.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct EasePreset {
pub name: String,
pub curve: EaseCurve,
}
const fn curve(out_influence: f64, out_speed: f64, in_influence: f64, in_speed: f64) -> EaseCurve {
EaseCurve { out_influence, out_speed, in_influence, in_speed }
}
/// Built-in presets (our own values). "Accelerate" starts slowly and "Decelerate" settles slowly.
const BUILT_IN: [(&str, EaseCurve); 12] = [
("Linear", EaseCurve::LINEAR),
("Smooth", curve(40.0, 0.25, 40.0, 0.25)),
("Ease In-Out Soft", curve(25.0, 0.0, 25.0, 0.0)),
("Ease In-Out", curve(50.0, 0.0, 50.0, 0.0)),
("Ease In-Out Strong", curve(75.0, 0.0, 75.0, 0.0)),
("Accelerate", curve(50.0, 0.0, 20.0, 1.5)),
("Accelerate Strong", curve(80.0, 0.0, 15.0, 2.5)),
("Decelerate", curve(20.0, 1.5, 50.0, 0.0)),
("Decelerate Strong", curve(15.0, 2.5, 80.0, 0.0)),
("Expo In-Out", curve(88.0, 0.0, 88.0, 0.0)),
("Expo Accelerate", curve(75.0, 0.0, 12.0, 7.0)),
("Expo Decelerate", curve(12.0, 7.0, 75.0, 0.0)),
];
/// Every preset: (name, curve, built in), the built-in ones first.
pub fn presets(s: &Session) -> impl Iterator<Item = (&str, EaseCurve, bool)> {
BUILT_IN.iter().map(|(n, c)| (*n, *c, true)).chain(s.ease_presets.iter().map(|p| (p.name.as_str(), p.curve, false)))
}
/// A preset's curve by name (any case).
pub fn find(s: &Session, name: &str) -> Option<EaseCurve> {
presets(s).find(|(n, ..)| n.eq_ignore_ascii_case(name)).map(|(_, c, _)| c)
}
fn is_built_in(name: &str) -> bool {
BUILT_IN.iter().any(|(n, _)| n.eq_ignore_ascii_case(name))
}
/// Pairs of neighbouring selected keyframes of the active comp: (layer, property, index of the
/// earlier key), by property and time.
pub fn selected_pairs(s: &Session) -> Vec<(LayerId, Uid, usize)> {
let Some(comp) = s.active_comp() else { return vec![] };
let mut groups: BTreeMap<(LayerId, Uid), Vec<usize>> = BTreeMap::new();
for k in &s.state.selected_keys {
let Some(pr) = comp.layer(k.layer).and_then(|l| l.props.find(k.prop)).filter(|pr| pr.value.interpolates()) else { continue };
if let Some(i) = key_at(&pr.keys, k.time) {
groups.entry((k.layer, k.prop)).or_default().push(i);
}
}
let mut out = vec![];
for ((lid, uid), mut idx) in groups {
idx.sort_unstable();
idx.dedup();
out.extend(idx.windows(2).filter_map(|w| match *w {
[a, b] if b == a + 1 => Some((lid, uid, a)),
_ => None,
}));
}
out
}
/// Read user presets: entries that don't parse, have no usable name or repeat a name are
/// skipped (and logged); a file that isn't JSON gives none.
pub fn parse(text: &str) -> Vec<EasePreset> {
let doc: Value = serde_json::from_str(text).unwrap_or_else(|e| {
log::warn!("{EASE_PRESETS_FILE} is not valid JSON ({e}); no user ease presets loaded");
Value::Null
});
let mut out: Vec<EasePreset> = vec![];
for entry in doc.get("presets").and_then(Value::as_array).into_iter().flatten() {
let preset = serde_json::from_value::<EasePreset>(entry.clone())
.ok()
.and_then(|p| Some(EasePreset { name: clean_name(&p.name)?, curve: p.curve.sanitized()? }))
.filter(|p| !is_built_in(&p.name) && !out.iter().any(|q| q.name.eq_ignore_ascii_case(&p.name)));
match preset {
Some(p) => out.push(p),
None => log::warn!("{EASE_PRESETS_FILE}: skipped the unusable entry {entry}"),
}
}
out
}
/// The file as written.
#[derive(Serialize)]
struct PresetFile<'a> {
version: u32,
presets: &'a [EasePreset],
}
/// Write `list` to the config store, then make it the session's user presets.
fn store(s: &mut Session, list: Vec<EasePreset>) -> Result<()> {
if let Some(cfg) = &s.config {
let text = serde_json::to_string_pretty(&PresetFile { version: 1, presets: &list }).map_err(|e| EngineError::Other(format!("ease presets: {e}")))?;
cfg.write(EASE_PRESETS_FILE, &text).map_err(|e| EngineError::Other(format!("cannot save ease presets: {e}")))?;
}
s.ease_presets = list;
Ok(())
}
fn clean_name(name: &str) -> Option<String> {
let name = name.trim();
(!name.is_empty() && name.chars().count() <= MAX_NAME).then(|| name.to_string())
}
fn name_p(p: &Value, key: &str, c: &str) -> Result<String> {
let raw = str_p(p, key).ok_or_else(|| bad(c, format!("missing `{key}`")))?;
clean_name(raw).ok_or_else(|| bad(c, format!("`{key}` must be 1–{MAX_NAME} characters")))
}
/// `curve`: `{outInfluence, outSpeed, inInfluence, inSpeed}` or handles `[x1, y1, x2, y2]`.
fn curve_v(v: &Value, c: &str) -> Result<EaseCurve> {
let parsed = match v.as_array() {
Some(a) => {
let h: Option<Vec<f64>> = a.iter().map(Value::as_f64).collect();
let h: [f64; 4] = h.and_then(|h| h.try_into().ok()).ok_or_else(|| bad(c, "`curve` handles are four numbers [x1, y1, x2, y2]"))?;
EaseCurve::from_handles(h)
}
None => serde_json::from_value::<EaseCurve>(v.clone()).map_err(|e| bad(c, format!("`curve`: {e}")))?.sanitized(),
};
parsed.ok_or_else(|| bad(c, "`curve` numbers must be finite"))
}
/// The curve between the first selected pair of keyframes.
fn captured(s: &Session, c: &str) -> Result<EaseCurve> {
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
let (lid, uid, i) = selected_pairs(s).into_iter().next().ok_or_else(|| bad(c, NEED_PAIR))?;
let pr = comp.layer(lid).and_then(|l| l.props.find(uid)).ok_or_else(|| bad(c, "no property"))?;
EaseCurve::measure(&pr.keys, i, pr.spatial).ok_or_else(|| bad(c, "the selected keyframes have no curve to keep (a hold, or no change in value)"))
}
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "keys.easePreset.apply";
let curve = match (str_p(p, "preset"), p.get("curve")) {
(Some(name), _) => find(s, name).ok_or_else(|| bad(C, format!("no ease preset `{name}` (see keys.easePreset.list)")))?,
(None, Some(v)) => curve_v(v, C)?,
(None, None) => return Err(bad(C, "pass `preset` (a name) or `curve`")),
};
let pairs = selected_pairs(s);
if pairs.is_empty() {
return Err(bad(C, NEED_PAIR));
}
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let n = s.edit("Apply Ease Preset", None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let mut n = 0;
for (lid, uid, i) in &pairs {
if let Some(pr) = comp.layer_mut(*lid).and_then(|l| l.props.find_mut(*uid))
&& curve.apply(&mut pr.keys, *i, pr.spatial)
{
n += 1;
}
}
Ok(n)
})?;
Ok(json!({"pairs": n, "curve": curve}))
}
fn capture(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!(captured(s, "keys.easePreset.capture")?))
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "keys.easePreset.save";
let name = name_p(p, "name", C)?;
if is_built_in(&name) {
return Err(bad(C, format!("`{name}` is a built-in preset; choose another name")));
}
let curve = match p.get("curve") {
Some(v) => curve_v(v, C)?,
None => captured(s, C)?,
};
let preset = EasePreset { name, curve };
let mut list = s.ease_presets.clone();
// Saving under an existing name replaces that preset.
match list.iter_mut().find(|q| q.name.eq_ignore_ascii_case(&preset.name)) {
Some(q) => *q = preset.clone(),
None => list.push(preset.clone()),
}
store(s, list)?;
Ok(json!(preset))
}
/// Index of the user preset `name`; built-in presets can't be changed.
fn user_preset(s: &Session, name: &str, c: &str) -> Result<usize> {
s.ease_presets.iter().position(|q| q.name.eq_ignore_ascii_case(name)).ok_or_else(|| {
if is_built_in(name) { bad(c, format!("`{name}` is a built-in preset and can't be changed")) } else { bad(c, format!("no user ease preset `{name}`")) }
})
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "keys.easePreset.rename";
let (name, new_name) = (name_p(p, "name", C)?, name_p(p, "newName", C)?);
let i = user_preset(s, &name, C)?;
if is_built_in(&new_name) || s.ease_presets.iter().enumerate().any(|(j, q)| j != i && q.name.eq_ignore_ascii_case(&new_name)) {
return Err(bad(C, format!("an ease preset named `{new_name}` exists")));
}
let mut list = s.ease_presets.clone();
if let Some(q) = list.get_mut(i) {
q.name = new_name.clone();
}
store(s, list)?;
Ok(json!({"name": new_name}))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "keys.easePreset.delete";
let name = name_p(p, "name", C)?;
user_preset(s, &name, C)?;
let mut list = s.ease_presets.clone();
list.retain(|q| !q.name.eq_ignore_ascii_case(&name));
store(s, list)?;
Ok(json!({"deleted": name}))
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
Ok(presets(s).map(|(name, curve, built_in)| json!({"name": name, "builtIn": built_in, "curve": curve})).collect())
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"keys.easePreset.apply",
"Apply Ease Preset",
[],
None,
"{preset (name) | curve: {outInfluence (%), outSpeed, inInfluence (%), inSpeed} | [x1, y1, x2, y2]} — eases every pair of neighbouring selected keyframes; speeds are relative to the segment's average speed",
has_keys,
apply
),
cmd!(
"keys.easePreset.save",
"Save Ease Preset",
[],
None,
"{name, curve? (as for apply; default: the curve between the first selected pair of keyframes)} — replaces a user preset of that name",
always,
save
),
cmd!("keys.easePreset.rename", "Rename Ease Preset", [], None, "{name, newName}", always, rename),
cmd!("keys.easePreset.delete", "Delete Ease Preset", [], None, "{name}", always, delete),
query!("keys.easePreset.list", "Ease Presets", "{} — [{name, builtIn, curve}]", list),
query!("keys.easePreset.capture", "Ease Curve of Selected Keyframes", "{} — the curve between the first selected pair of keyframes", capture),
]
}

View File

@ -51,6 +51,7 @@ pub fn start(s: &mut Session, items: Option<Vec<ItemId>>, wait: bool) -> crate::
}
let services = s.services.clone();
let importer = s.importer.clone();
let rate = s.sequence_rate();
let n = jobs.len();
s.spawn_task("footageCheck", format!("Checking footage ({n} items)"), wait, move |ctl| {
let mut found: Vec<(ItemId, Found)> = Vec::with_capacity(jobs.len());
@ -69,7 +70,23 @@ pub fn start(s: &mut Session, items: Option<Vec<ItemId>>, wait: bool) -> crate::
};
// Saved without metadata (a hand-written or converted project): probe it now.
let needs_probe = present && f.width == 0 && f.height == 0 && f.kind != FootageKind::Audio;
match importer.as_ref().filter(|_| needs_probe).map(|imp| imp.probe(&f.path)) {
let probed = importer.as_ref().filter(|_| needs_probe).map(|imp| match f.kind {
// A sequence keeps its files; its first one gives the metadata.
FootageKind::Sequence if !f.sequence.is_empty() => imp.probe(&f.path).map(|one| {
let mut nf = Footage {
width: one.width,
height: one.height,
pixel_aspect: one.pixel_aspect,
has_video: one.has_video,
codec: one.codec,
..f.clone()
};
nf.sync_sequence_duration();
nf
}),
_ => crate::sequence::probe_path(imp.as_ref(), services.as_ref(), &f.path, rate),
});
match probed {
Some(Ok(mut nf)) => {
nf.alpha = f.alpha;
nf.loop_count = f.loop_count;

View File

@ -1,6 +1,7 @@
//! Background jobs: one list (Window ▸ Progress, `jobs.list`, `jobs.cancel`) over the render
//! queue, the analyses (tracker, mask tracker, Warp Stabilizer, 3D Camera Tracker, Roto Brush) and
//! the generic background tasks started here (Content-Aware Fill, Scene Edit Detection).
//! the generic background tasks started here (Content-Aware Fill, Scene Edit Detection, footage
//! checks and imports).
//!
//! A generic task runs a closure on a background thread (inline on wasm32 or when the caller
//! waits). The closure reports progress through [`TaskCtl`] and returns an [`Apply`]: a function
@ -162,7 +163,8 @@ impl Session {
let cancelled = t.ctl.cancelled();
let (status, message, result) = match r {
Some(Ok(apply)) if !cancelled => match apply(self) {
Ok(v) => ("done", String::new(), v),
// A task may say how it went (`toast`: "Imported 3 items").
Ok(v) => ("done", v.get("toast").and_then(Value::as_str).unwrap_or_default().to_string(), v),
Err(e) => ("failed", e.to_string(), Value::Null),
},
Some(Ok(_)) => ("cancelled", String::new(), Value::Null),
@ -180,6 +182,7 @@ impl Session {
seconds: t.started.elapsed().as_secs_f64(),
};
let toast = match status {
"done" if !message.is_empty() => message.clone(),
"done" => format!("{} finished", t.label),
"cancelled" => format!("{} cancelled", t.label),
_ => format!("{} failed: {message}", t.label),

View File

@ -15,6 +15,7 @@ pub mod camera_track;
pub mod commands;
pub mod config;
pub mod demo;
pub mod ease_presets;
pub mod footage_check;
pub mod guard;
pub mod history;
@ -26,6 +27,7 @@ pub mod mask_track;
pub mod media_browser;
pub mod media_cache;
pub mod menus;
pub mod models;
pub mod offload;
pub mod perf;
pub mod prefs;
@ -35,6 +37,7 @@ pub mod remote;
pub mod render_queue;
pub mod roto;
pub mod scriptui;
pub mod sequence;
mod session_settings;
pub mod shortcuts;
pub mod storage;
@ -63,6 +66,7 @@ pub use effectcraft_keyframe as keyframe;
pub use effectcraft_project as project;
pub use effectcraft_raster as raster;
pub use effectcraft_render as render;
pub use effectcraft_segment as segment;
pub use effectcraft_text as text;
pub use effectcraft_time as time;
pub use effectcraft_track as track;
@ -79,6 +83,9 @@ pub enum EngineError {
BadParams { cmd: String, msg: String },
#[error("no active composition")]
NoComp,
/// A `comp` reference (name or id) that names no composition.
#[error("no composition {0}")]
NoSuchComp(String),
#[error("{0}")]
Project(#[from] effectcraft_project::ProjectError),
#[error("{0}")]
@ -104,6 +111,19 @@ pub trait Services: Send + Sync {
}
self.write_file(path, data)
}
/// The files in folder `dir` (full paths; no folders, no hidden files), for image-sequence
/// detection on import. Empty when it can't be read.
fn list_dir(&self, dir: &str) -> Vec<String> {
let Ok(rd) = std::fs::read_dir(if dir.is_empty() { "." } else { dir }) else { return vec![] };
rd.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_ok_and(|t| t.is_file()) && !e.file_name().to_string_lossy().starts_with('.'))
.map(|e| e.path().to_string_lossy().to_string())
.collect()
}
/// Whether `path` is a folder (dropped or picked folders import their files).
fn is_dir(&self, path: &str) -> bool {
std::path::Path::new(path).is_dir()
}
}
/// Native filesystem.
@ -119,11 +139,15 @@ impl Services for FsServices {
/// Imports media files into the project (implemented by the media layer).
pub trait Importer: Send + Sync {
/// Probe a file and return footage metadata.
/// Probe one file and return footage metadata. Image sequences are the engine's
/// ([`sequence`]): it groups numbered stills and probes their first file here.
fn probe(&self, path: &str) -> std::result::Result<effectcraft_project::Footage, String>;
/// A footage file's bytes are now at `path` (extracted from a template): hosts whose media
/// layer reads from memory (the browser) register them; file-based ones need nothing.
fn register(&self, _path: &str, _data: &[u8]) {}
/// An image sequence's other frames (its first was probed) are footage now: hosts whose
/// media layer reads from memory (the browser) register them; file-based ones need nothing.
fn add_frames(&self, _paths: &[String]) {}
}
/// A keyframe reference: layer, property uid, key time (layer time).
@ -189,10 +213,17 @@ pub struct EditorState {
/// selected layers.
#[serde(skip)]
pub effect_clipboard: Vec<effectcraft_project::PropGroup>,
/// Copied shape items (Edit ▸ Copy with groups, paths, paints or path operations selected in
/// a shape layer's Contents); Paste adds them to the selected shape layers.
#[serde(skip)]
pub contents_clipboard: Vec<effectcraft_project::PropGroup>,
/// Selected mask vertices (viewer Selection tool / pen).
#[serde(default)]
pub selected_vertices: Vec<VertexRef>,
pub snapping: bool,
/// Tools bar ▸ Snapping options (which features snap, Snap Edges Extended).
#[serde(default)]
pub snap_features: viewer::SnapFeatures,
/// Last applied effect id (Effect ▸ last effect).
pub last_effect: Option<String>,
/// Viewer region of interest `[x, y, w, h]` in comp pixels (Composition ▸ Crop Comp to Region
@ -229,6 +260,9 @@ pub struct EditorState {
/// Puppet tool options for new meshes.
#[serde(default)]
pub puppet: commands::puppet::PuppetOptions,
/// The shape tools' and the Pen's options (Tool Creates Shape / Mask, Fill and Stroke).
#[serde(default)]
pub shape_tool: commands::shape_tool::ShapeTool,
/// View ▸ Switch View Layout: 1, 2 or 4 views side by side in the Composition viewer.
#[serde(default = "one_view")]
pub view_layout: u8,
@ -324,6 +358,9 @@ pub struct Session {
/// that ask for [`effectcraft_render::Backend::Gpu`]/`Auto`, and by Render Queue exports
/// when the project's renderer is the GPU.
pub accel: Option<Arc<dyn effectcraft_render::Accelerator>>,
/// Why there is no [`Session::accel`] (`render.backend`'s `why`): the host's reason, e.g. a
/// headless start without `--gpu` or the compositor's setup error.
pub accel_note: Option<String>,
/// Expression syntax checker (set by the host that links the expression engine).
pub expr_check: Option<fn(&str) -> std::result::Result<(), String>>,
pub importer: Option<Arc<dyn Importer>>,
@ -347,6 +384,10 @@ pub struct Session {
pub camera_pending: Vec<(ItemId, LayerId, Uid)>,
/// The running (or finished, not yet polled) Roto Brush propagation / Freeze.
pub roto_job: Option<roto::RotoJob>,
/// Trained models for Roto Brush and face tracking (Settings ▸ Roto Brush / Face Tracking).
pub models: models::Models,
/// Where installed models live (hosts set it; else `models` next to the settings).
pub models_dir: Option<std::path::PathBuf>,
/// Roto Brush instances edited since their last propagation: (comp, layer, effect uid).
pub roto_pending: Vec<(ItemId, LayerId, Uid)>,
pub events: Vec<Event>,
@ -365,12 +406,17 @@ pub struct Session {
pub config: Option<Arc<dyn config::ConfigStore>>,
/// Keyboard shortcut presets.
pub keymaps: shortcuts::Keymaps,
/// User ease presets (Window ▸ Ease Presets; the built-in ones are in [`ease_presets`]).
pub ease_presets: Vec<ease_presets::EasePreset>,
/// Frontend-only commands offered for binding.
pub ui_commands: Vec<shortcuts::UiCommand>,
/// Cache of the resolved active preset (read it with [`Session::shortcuts`]).
pub shortcut_table: std::sync::OnceLock<shortcuts::ShortcutTable>,
/// Auto-save bookkeeping.
pub autosave: autosave::AutoSaveState,
/// Host-owned isolation for all auto-saves (e.g. one MCP session). Independent of settings
/// and project bookkeeping, so opening/saving a project or changing preferences keeps it.
pub autosave_folder_override: Option<std::path::PathBuf>,
/// The viewer snapshot (Take Snapshot / Show Snapshot).
pub snapshot: Option<viewer::Snapshot>,
/// The JavaScript scripting engine (set by the host that links `effectcraft-script`):
@ -451,6 +497,7 @@ impl Default for Session {
footage: Arc::new(NoFootage),
expr: None,
accel: None,
accel_note: None,
expr_check: None,
importer: None,
exporter: None,
@ -463,6 +510,8 @@ impl Default for Session {
camera_job: None,
camera_pending: vec![],
roto_job: None,
models: Default::default(),
models_dir: None,
roto_pending: vec![],
events: vec![],
journal: vec![],
@ -472,9 +521,11 @@ impl Default for Session {
prefs_revision: 0,
config: None,
keymaps: shortcuts::Keymaps::default(),
ease_presets: vec![],
ui_commands: vec![],
shortcut_table: std::sync::OnceLock::new(),
autosave: autosave::AutoSaveState::default(),
autosave_folder_override: None,
snapshot: None,
script: None,
plugin_loader: None,
@ -942,6 +993,8 @@ mod tests_color_view;
#[cfg(test)]
mod tests_disk_cache;
#[cfg(test)]
mod tests_ease_presets;
#[cfg(test)]
mod tests_effects;
#[cfg(test)]
mod tests_essential;
@ -978,6 +1031,8 @@ mod tests_proxy;
#[cfg(test)]
mod tests_rig3d;
#[cfg(test)]
mod tests_sequence;
#[cfg(test)]
mod tests_settings;
#[cfg(test)]
mod tests_stubs;
@ -1010,6 +1065,17 @@ pub fn text_families() -> Vec<String> {
effectcraft_text::families().into_iter().map(|(f, _)| f).collect()
}
/// The styles a family offers (its own faces, in weight order), for the style menus. A family
/// that isn't installed offers the usual styles, which resolve to Inter's.
pub fn font_styles(family: &str) -> Vec<String> {
effectcraft_text::families()
.into_iter()
.find(|(f, _)| f.eq_ignore_ascii_case(family))
.map(|(_, st)| st)
.filter(|st| !st.is_empty())
.unwrap_or_else(|| ["Regular", "Medium", "SemiBold", "Bold", "Italic"].iter().map(|s| s.to_string()).collect())
}
/// The OpenType feature tags (GSUB / GPOS) of the face a family + style resolves to.
pub fn font_features(family: &str, style: &str) -> Vec<String> {
effectcraft_text::fonts::face(effectcraft_text::resolve(family, style).face).features()
@ -1079,6 +1145,8 @@ pub fn font_menu(prefs: &prefs::Prefs) -> Vec<FontRow> {
#[cfg(test)]
mod tests_history;
#[cfg(test)]
mod tests_models;
#[cfg(test)]
mod tests_paint;
#[cfg(test)]
mod tests_panels;

View File

@ -15,7 +15,7 @@ pub const SIBLINGS: &[(&str, &str)] = &[
("VectorCraft", "vectorcraft"),
("FilmCraft", "filmcraft"),
("LightCraft", "lightcraft"),
("PrintCraft", "printcraft"),
("PdfCraft", "pdfcraft"),
("DesignCraft", "designcraft"),
];

View File

@ -4,15 +4,21 @@
//! Compatibility Report.
//!
//! Frontends call [`install`] once at startup; [`set_enabled`] (the `help.enableLogging`
//! command) turns file logging on and off.
//! command) turns file logging on and off. `RUST_LOG` (a level such as `debug`, or per target:
//! `effectcraft=debug,wgpu=warn`) also prints what it lets through to stderr, so a start-up that
//! fails before the window opens leaves a trace.
use std::collections::VecDeque;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, OnceLock};
use log::LevelFilter;
static ENABLED: AtomicBool = AtomicBool::new(false);
/// The `RUST_LOG` filter for stderr: (target prefix or every target, level).
static STDERR: OnceLock<Vec<(Option<String>, LevelFilter)>> = OnceLock::new();
static STATE: Mutex<State> = Mutex::new(State { file: None, recent: VecDeque::new() });
struct State {
@ -26,13 +32,16 @@ const RECENT: usize = 300;
impl log::Log for Logger {
fn enabled(&self, m: &log::Metadata) -> bool {
m.level() <= log::Level::Warn || (ENABLED.load(Ordering::Relaxed) && m.target().starts_with("effectcraft"))
m.level() <= log::Level::Warn || (ENABLED.load(Ordering::Relaxed) && m.target().starts_with("effectcraft")) || to_stderr(m)
}
fn log(&self, r: &log::Record) {
if !self.enabled(r.metadata()) {
return;
}
let line = format!("{} {:<5} {}: {}", unix_secs(), r.level(), r.target(), r.args());
if to_stderr(r.metadata()) {
eprintln!("{line}");
}
let Ok(mut st) = STATE.lock() else { return };
if r.level() <= log::Level::Warn {
st.recent.push_back(line.clone());
@ -58,10 +67,39 @@ fn unix_secs() -> u64 {
pub fn install() {
static LOGGER: Logger = Logger;
if log::set_logger(&LOGGER).is_ok() {
log::set_max_level(log::LevelFilter::Info);
let stderr = STDERR.get_or_init(|| std::env::var("RUST_LOG").map(|s| parse_filter(&s)).unwrap_or_default());
log::set_max_level(stderr.iter().map(|d| d.1).fold(LevelFilter::Info, Ord::max));
}
}
/// Whether `RUST_LOG` lets a record through to stderr.
fn to_stderr(m: &log::Metadata) -> bool {
STDERR.get().is_some_and(|f| m.level() <= level_for(f, m.target()))
}
/// `RUST_LOG`'s directives, as env_logger reads them: `level`, `target=level` or a bare target
/// (every level); unknown levels are skipped.
fn parse_filter(spec: &str) -> Vec<(Option<String>, LevelFilter)> {
spec.split(',')
.map(str::trim)
.filter(|d| !d.is_empty())
.filter_map(|d| match d.split_once('=') {
Some((t, l)) => Some((Some(t.trim().to_string()), l.trim().parse().ok()?)),
None => Some(d.parse().map_or_else(|_| (Some(d.to_string()), LevelFilter::Trace), |l| (None, l))),
})
.collect()
}
/// The level of the directive whose target is the longest prefix of `target` (a directive
/// without a target matches every target); off when none does.
fn level_for(filter: &[(Option<String>, LevelFilter)], target: &str) -> LevelFilter {
filter
.iter()
.filter(|(t, _)| t.as_deref().is_none_or(|t| target.starts_with(t)))
.max_by_key(|(t, _)| t.as_ref().map_or(0, String::len))
.map_or(LevelFilter::Off, |d| d.1)
}
/// Log every panic (message and location) through the logger, then run the previous hook
/// (which prints it). Panics in commands are then caught by [`crate::guard::guarded`].
pub fn install_panic_hook() {
@ -107,3 +145,21 @@ pub fn current_file() -> Option<PathBuf> {
pub fn recent() -> Vec<String> {
STATE.lock().map(|s| s.recent.iter().cloned().collect()).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::{LevelFilter, level_for, parse_filter};
/// `RUST_LOG` picks what start-up prints to stderr (#234).
#[test]
fn rust_log_filters_by_level_and_target() {
let f = parse_filter("info, wgpu=warn,wgpu_core=debug, effectcraft , bogus=loud");
assert_eq!(level_for(&f, "eframe::native"), LevelFilter::Info);
assert_eq!(level_for(&f, "wgpu_hal::metal"), LevelFilter::Warn);
assert_eq!(level_for(&f, "wgpu_core::device"), LevelFilter::Debug, "the longest matching target wins");
assert_eq!(level_for(&f, "effectcraft_ui_egui"), LevelFilter::Trace, "a bare target: every level");
assert_eq!(f.len(), 4, "an unknown level is skipped");
assert_eq!(level_for(&parse_filter("wgpu=debug"), "winit"), LevelFilter::Off);
assert!(parse_filter("").is_empty());
}
}

View File

@ -198,6 +198,8 @@ pub(crate) struct MaskWork {
pub method: MaskMethod,
/// Comp times; the first is the start frame.
pub times: Vec<Tick>,
/// Face tracking's trained model (Settings ▸ Face Tracking), if one is in use.
pub face_model: Option<Arc<dyn effectcraft_segment::face::FaceModel>>,
}
async fn run_work(w: MaskWork, shared: &MaskTrackShared) {
@ -275,7 +277,7 @@ async fn run_face<F: std::future::Future<Output = Option<(Arc<effectcraft_raster
) {
let detailed = w.method == MaskMethod::FaceDetailed;
let b = trk::mask::bounds(&flatten(&w.path, 8));
let Some((mut tracker, fit)) = trk::face::FaceTracker::new(&trk::Frame { img: &first.0, offset: first.1 }, b) else {
let Some((mut tracker, fit)) = trk::face::FaceTracker::new_with(&trk::Frame { img: &first.0, offset: first.1 }, b, w.face_model.clone()) else {
let mut s = lock(&shared.state);
s.error = Some("no face found inside the mask (draw the mask around a face)".into());
s.finished = true;

View File

@ -24,7 +24,7 @@ pub fn load_peaks(file: &Path) -> Option<Vec<[f32; 4]>> {
return None;
}
let f = |c: &[u8]| f32::from_le_bytes([c[0], c[1], c[2], c[3]]);
Some(b.chunks_exact(16).map(|c| [f(&c[0..4]), f(&c[4..8]), f(&c[8..12]), f(&c[12..16])]).collect())
Some(b.as_chunks::<16>().0.iter().map(|c| [f(&c[0..4]), f(&c[4..8]), f(&c[8..12]), f(&c[12..16])]).collect())
}
/// Store a summary (atomically: written beside, then renamed).

View File

@ -52,6 +52,8 @@ pub enum MenuNode {
pub struct DynCtx<'a> {
/// The current workspace (Window ▸ Assign Shortcut to "…" Workspace).
pub workspace: Option<&'a str>,
/// Workspaces the user saved (Save as New Workspace), listed in Window ▸ Workspace.
pub saved_workspaces: &'a [String],
}
fn file_name(p: &str) -> String {
@ -70,8 +72,9 @@ fn holder_of(s: &Session, keys: &str) -> Option<String> {
/// The entries of a `@dynamic:<name>` node, and the disabled placeholder shown when there are
/// none: `recentProjects`, `recentFootage`, `recentPresets`, `history` (undo steps, newest
/// first), `view3dShortcuts`, `workspaceShortcuts` and `openViewers` (open compositions other
/// than the active one), `scripts` (File ▸ Scripts) and `scriptPanels` (ScriptUI panels).
/// first), `view3dShortcuts`, `workspaceShortcuts`, `savedWorkspaces` (Window ▸ Workspace) and
/// `openViewers` (open compositions other than the active one), `scripts` (File ▸ Scripts) and
/// `scriptPanels` (ScriptUI panels).
pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuEntry>, Option<&'static str>) {
use serde_json::json;
let indexed = |list: &[String], cmd: &str, stem: bool| -> Vec<MenuEntry> {
@ -122,6 +125,7 @@ pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuEntry>, Option<
.collect(),
None,
),
"savedWorkspaces" => (cx.saved_workspaces.iter().map(|w| dyn_entry(w.clone(), "window.workspace", json!({"name": w}))).collect(), None),
"openViewers" => (
s.state
.open_comps
@ -1046,6 +1050,7 @@ Animation
Reveal Properties with Animation | anim.reveal {"kind":"animation"}
Reveal All Modified Properties | anim.reveal {"kind":"modified"}
View
New Viewer | view.newViewer
Split with New Locked Viewer | view.splitLockedViewer
---
Zoom In | view.zoomIn
@ -1150,6 +1155,7 @@ Window
Paint | window.workspace {"name":"Paint"}
Text | window.workspace {"name":"Text"}
Undocked Panels | window.workspace {"name":"Undocked Panels"}
@dynamic:savedWorkspaces
---
Reset to Saved Layout | window.resetWorkspace
Save Changes to this Workspace | window.saveWorkspace
@ -1194,6 +1200,7 @@ Window
@dynamic:openViewers
---
Create Nulls From Paths | window.panel {"panel":"createNullsFromPaths"}
Ease Presets | window.panel {"panel":"easePresets"}
VR Comp Editor | window.panel {"panel":"vrCompEditor"}
@dynamic:scriptPanels
Help

463
crates/engine/src/models.rs Normal file
View File

@ -0,0 +1,463 @@
//! Trained models (Settings ▸ Roto Brush and Settings ▸ Face Tracking; `roto.model*` and
//! `face.model*` commands): which models the registry offers (`effectcraft_segment::MODELS`, all
//! open source), which are installed (in the `models` folder), installing one from a file or
//! downloading the official weights (verified against the registry's SHA-256 either way, and kept
//! with a notice naming their authors and licence), choosing one per task, and loading it in the
//! background: into the Roto Brush effect (`effects::roto::set_model`) or the face tracker
//! ([`Models::face`]).
//!
//! Weights are never bundled: the install stays small and nothing is fetched unless asked for.
//! Downloads use the system's `curl` (shipped with Windows 10+, macOS and most Linux systems),
//! so no networking code is linked into EffectCraft; without it, the file can be downloaded in a
//! browser and installed with Install from File. The browser build uses the built-in engines
//! only, for now.
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use effectcraft_segment::face::FaceModel;
use effectcraft_segment::{self as seg, Loaded, ModelInfo, Task};
use serde_json::{Value, json};
use crate::commands::{CommandSpec, always, b_p, bad, str_p};
use crate::{EngineError, Result, Session, cmd, query};
/// What a task's background worker reports.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Status {
/// What is happening now ("Downloading MobileSAM…").
pub busy: Option<String>,
/// The model in use.
pub active: Option<String>,
/// The last failure, for the Settings page.
pub error: Option<String>,
/// A finished job's message and whether it failed, for a toast (taken by
/// [`Session::poll_models`]).
pub done: Option<(String, bool)>,
}
/// The session's trained models: per task, the worker's status; and the face model in use.
#[derive(Default)]
pub struct Models {
roto: Arc<Mutex<Status>>,
face: Arc<Mutex<Status>>,
face_model: FaceSlot,
}
impl Models {
fn lane(&self, task: Task) -> &Arc<Mutex<Status>> {
match task {
Task::Mask => &self.roto,
Task::Face => &self.face,
}
}
pub fn status(&self, task: Task) -> Status {
self.lane(task).lock().map(|s| s.clone()).unwrap_or_default()
}
/// The face model face tracking uses (`None`: the classic tracker).
pub fn face(&self) -> Option<Arc<dyn FaceModel>> {
self.face_model.lock().ok().and_then(|m| m.clone())
}
fn set_active(&self, task: Task, model: Option<Loaded>) {
activate(self.lane(task), &self.face_model, task, model);
}
}
type FaceSlot = Arc<Mutex<Option<Arc<dyn FaceModel>>>>;
/// Put a loaded model to use (or drop `task`'s): Roto Brush's lives in the effect, the face
/// tracker's in `face`.
fn activate(lane: &Arc<Mutex<Status>>, face: &FaceSlot, task: Task, model: Option<Loaded>) {
let id = model.as_ref().map(|m| match m {
Loaded::Mask(m) => m.info().id.to_string(),
Loaded::Face(m) => m.info().id.to_string(),
});
match (task, model) {
(Task::Mask, Some(Loaded::Mask(m))) => crate::effects::roto::set_model(Some(m)),
(Task::Mask, _) => crate::effects::roto::set_model(None),
(Task::Face, m) => {
if let Ok(mut slot) = face.lock() {
*slot = match m {
Some(Loaded::Face(f)) => Some(f),
_ => None,
};
}
}
}
set(lane, |s| s.active = id);
}
fn set(status: &Arc<Mutex<Status>>, f: impl FnOnce(&mut Status)) {
if let Ok(mut s) = status.lock() {
f(&mut s);
}
}
/// How a task shows in Settings, toasts and the command list.
struct Kind {
/// Command prefix and settings key section.
prefix: &'static str,
label: &'static str,
classic_name: &'static str,
classic_description: &'static str,
}
const ROTO: Kind = Kind {
prefix: "roto",
label: "Roto Brush",
classic_name: "Classic (graph cut)",
classic_description: "Built in: colour models and graph cuts, propagated by optical flow. Always available.",
};
const FACE: Kind = Kind {
prefix: "face",
label: "Face Tracking",
classic_name: "Classic (shape model)",
classic_description: "Built in: a skin colour model, facial feature shapes and a fitted face shape model. Best on front-facing, evenly lit faces. Always available.",
};
fn kind(task: Task) -> &'static Kind {
match task {
Task::Mask => &ROTO,
Task::Face => &FACE,
}
}
impl Session {
/// The folder installed models live in: the host's, else `models` next to the settings.
pub fn models_dir(&self) -> Option<PathBuf> {
self.models_dir.clone().or_else(|| Some(self.config.as_ref()?.dir()?.join("models")))
}
/// Where `m` is installed, if it is (a file of the published size).
pub fn model_path(&self, m: &ModelInfo) -> Option<PathBuf> {
let p = self.models_dir()?.join(m.file_name);
std::fs::metadata(&p).ok().filter(|md| md.len() == m.size).map(|_| p)
}
/// The model chosen for `task` (Settings).
pub fn chosen_model(&self, task: Task) -> &str {
match task {
Task::Mask => &self.prefs.roto.model,
Task::Face => &self.prefs.face.model,
}
}
fn choose_model(&mut self, task: Task, id: &str) {
let slot = match task {
Task::Mask => &mut self.prefs.roto.model,
Task::Face => &mut self.prefs.face.model,
};
*slot = id.to_string();
}
/// Load (or drop) the models Settings chooses; called whenever settings change.
pub fn apply_models(&mut self) {
for task in [Task::Mask, Task::Face] {
self.apply_model(task, false);
}
}
/// Load (or drop) `task`'s chosen model; `wait`: before returning (scripts and agents).
fn apply_model(&mut self, task: Task, wait: bool) {
let want = self.chosen_model(task).to_string();
let lane = self.models.lane(task).clone();
let st = self.models.status(task);
let info = seg::info(&want).filter(|m| m.task == task);
let Some(info) = info else {
if st.active.is_some() {
self.models.set_active(task, None);
}
return;
};
if st.active.as_deref() == Some(info.id) || st.busy.is_some() {
return;
}
let Some(path) = self.model_path(info) else {
self.models.set_active(task, None);
return;
};
let (face_slot, label) = (self.models.face_model.clone(), kind(task).label);
spawn(&lane, label, format!("Loading {}…", info.name), wait, move |status| {
let m = load_file(&path, info.id)?;
activate(status, &face_slot, task, Some(m));
Ok(format!("{label}: {} loaded", info.name))
});
}
/// Finish what the model workers did: a toast, and loading a model that just arrived.
pub fn poll_models(&mut self) {
for task in [Task::Mask, Task::Face] {
let done = self.models.lane(task).lock().ok().and_then(|mut s| s.done.take());
if let Some((message, error)) = done {
self.events.push(crate::Event::Toast { message, error });
self.apply_model(task, false);
}
}
}
}
fn load_file(path: &Path, id: &str) -> std::result::Result<Loaded, String> {
let bytes = std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
seg::load(id, &bytes)
}
/// Run `job` on a worker thread (`wait`: right here), reporting through `status` (desktop; the
/// browser build has no trained models yet).
fn spawn(
status: &Arc<Mutex<Status>>,
label: &'static str,
what: String,
wait: bool,
job: impl FnOnce(&Arc<Mutex<Status>>) -> std::result::Result<String, String> + Send + 'static,
) {
if cfg!(target_arch = "wasm32") {
set(status, |s| s.error = Some("trained models are available in the desktop app".into()));
return;
}
set(status, |s| {
s.busy = Some(what);
s.error = None;
});
let st = status.clone();
let run = move || {
let r = job(&st);
set(&st, |s| {
s.busy = None;
match r {
Ok(msg) => s.done = Some((msg, false)),
Err(e) => {
s.done = Some((format!("{label} model: {e}"), true));
s.error = Some(e);
}
}
});
};
if wait {
run();
} else if let Err(e) = std::thread::Builder::new().name("ec-model".into()).spawn(run) {
set(status, |s| {
s.busy = None;
s.error = Some(format!("cannot start: {e}"));
});
}
}
/// The notice kept next to `m`'s weights.
fn notice_path(dir: &Path, m: &ModelInfo) -> PathBuf {
dir.join(format!("{}.NOTICE.txt", m.file_name))
}
/// Copy verified weights into the models folder (atomically), with their notice.
fn install_bytes(dir: &Path, m: &ModelInfo, bytes: &[u8]) -> std::result::Result<PathBuf, String> {
if bytes.len() as u64 != m.size || seg::sha256::hex(bytes) != m.sha256 {
return Err(format!("this is not the published {} file (size or SHA-256 differs)", m.name));
}
use crate::config::FileOps;
let path = dir.join(m.file_name);
let files = crate::config::StdFiles;
files.write(&path, bytes).map_err(|e| format!("cannot write {}: {e}", path.display()))?;
files.write(&notice_path(dir, m), seg::notice(m).as_bytes()).map_err(|e| format!("cannot write the notice: {e}"))?;
Ok(path)
}
fn model_p(p: &Value, cmd: &str, task: Task) -> Result<&'static ModelInfo> {
let id = str_p(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
seg::info(id).filter(|m| m.task == task).ok_or_else(|| bad(cmd, format!("unknown model `{id}` (see {}.models)", kind(task).prefix)))
}
fn list(s: &mut Session, task: Task) -> Result<Value> {
let k = kind(task);
let st = s.models.status(task);
let chosen = s.chosen_model(task).to_string();
let mut v = vec![json!({
"id": seg::CLASSICAL,
"name": k.classic_name,
"description": k.classic_description,
"licence": "MIT OR Apache-2.0",
"installed": true,
"selected": chosen == seg::CLASSICAL,
"active": st.active.is_none(),
})];
for m in seg::models(task) {
v.push(json!({
"id": m.id,
"name": m.name,
"description": m.description,
"authors": m.authors,
"licence": m.licence,
"licenceUrl": m.licence_url,
"homepage": m.homepage,
"url": m.url,
"size": m.size,
"sha256": m.sha256,
"installed": s.model_path(m).is_some(),
"selected": chosen == m.id,
"active": st.active.as_deref() == Some(m.id),
}));
}
Ok(json!({"models": v, "busy": st.busy, "error": st.error, "folder": s.models_dir().map(|d| d.display().to_string())}))
}
/// After a `wait`: the job's failure, as the command's.
fn waited(s: &Session, task: Task, cmd: &str) -> Result<()> {
match s.models.status(task).error {
Some(e) => Err(bad(cmd, e)),
None => Ok(()),
}
}
fn select(s: &mut Session, p: &Value, task: Task) -> Result<Value> {
let cmd = format!("{}.model.select", kind(task).prefix);
let id = str_p(p, "id").ok_or_else(|| bad(&cmd, "missing `id`"))?;
if id != seg::CLASSICAL {
model_p(p, &cmd, task)?;
}
let wait = b_p(p, "wait").unwrap_or(false);
s.choose_model(task, id);
if wait {
s.apply_model(task, true);
waited(s, task, &cmd)?;
}
s.save_prefs();
s.prefs_changed();
Ok(json!({"model": id, "active": s.models.status(task).active}))
}
fn install(s: &mut Session, p: &Value, task: Task) -> Result<Value> {
let cmd = format!("{}.model.install", kind(task).prefix);
let path = str_p(p, "path").ok_or_else(|| bad(&cmd, "missing `path` (the downloaded weights file)"))?;
let dir = s.models_dir().ok_or_else(|| bad(&cmd, "no settings folder to install into"))?;
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
// Which model it is: the one asked for, or the one whose checksum it has.
let m = match str_p(p, "id") {
Some(_) => model_p(p, &cmd, task)?,
None => {
let h = seg::sha256::hex(&bytes);
seg::models(task).find(|m| m.sha256 == h).ok_or_else(|| bad(&cmd, "this file is not a published model in the registry (SHA-256 differs)"))?
}
};
let dest = install_bytes(&dir, m, &bytes).map_err(|e| bad(&cmd, e))?;
let wait = b_p(p, "wait").unwrap_or(false);
s.apply_model(task, wait);
if wait {
waited(s, task, &cmd)?;
}
Ok(json!({"id": m.id, "path": dest.display().to_string()}))
}
fn download(s: &mut Session, p: &Value, task: Task) -> Result<Value> {
let k = kind(task);
let cmd = format!("{}.model.download", k.prefix);
let m = model_p(p, &cmd, task)?;
let dir = s.models_dir().ok_or_else(|| bad(&cmd, "no settings folder to install into"))?;
if s.models.status(task).busy.is_some() {
return Err(bad(&cmd, "a model is already downloading or loading"));
}
let size = if m.size >= 10_000_000 { format!("{} MB", m.size / 1_000_000) } else { format!("{:.1} MB", m.size as f64 / 1e6) };
let wait = b_p(p, "wait").unwrap_or(false);
spawn(s.models.lane(task), k.label, format!("Downloading {} ({size})…", m.name), wait, move |_| {
std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
let tmp = dir.join(format!("{}.part", m.file_name));
let out = std::process::Command::new("curl")
.args(["-fsSL", "--retry", "2", "--connect-timeout", "20", "-o"])
.arg(&tmp)
.arg(m.url)
.output()
.map_err(|e| format!("downloading needs curl ({e}); or download {} in a browser and use Install from File", m.url))?;
if !out.status.success() {
let _ = std::fs::remove_file(&tmp);
return Err(format!("download failed: {}", String::from_utf8_lossy(&out.stderr).trim()));
}
let bytes = std::fs::read(&tmp).map_err(|e| format!("cannot read the download: {e}"))?;
let _ = std::fs::remove_file(&tmp);
install_bytes(&dir, m, &bytes)?;
Ok(format!("{}: {} downloaded and verified", k.label, m.name))
});
if wait {
waited(s, task, &cmd)?;
s.apply_model(task, true);
waited(s, task, &cmd)?;
return Ok(json!({"id": m.id, "installed": true}));
}
Ok(json!({"id": m.id, "downloading": true}))
}
fn remove(s: &mut Session, p: &Value, task: Task) -> Result<Value> {
let m = model_p(p, &format!("{}.model.remove", kind(task).prefix), task)?;
let Some(path) = s.model_path(m) else { return Ok(json!({"removed": false})) };
if s.models.status(task).active.as_deref() == Some(m.id) {
s.models.set_active(task, None);
}
std::fs::remove_file(&path).map_err(|e| EngineError::Other(format!("cannot remove {}: {e}", path.display())))?;
if let Some(dir) = path.parent() {
let _ = std::fs::remove_file(notice_path(dir, m));
}
Ok(json!({"removed": true}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
query!("roto.models", "Roto Brush Models", "{}", |s, _| list(s, Task::Mask)),
cmd!(
"roto.model.select",
"Use Roto Brush Model",
[],
None,
"{id: classical|mobilesam, wait?} (Roto Brush 2.0 / 3.0 use it; wait: loaded before returning)",
always,
|s, p| select(s, p, Task::Mask)
),
cmd!(
"roto.model.install",
"Install Roto Brush Model",
[],
None,
"{path: weights file, id?, wait?} — verified against the registry's SHA-256",
always,
|s, p| install(s, p, Task::Mask)
),
cmd!(
"roto.model.download",
"Download Roto Brush Model",
[],
None,
"{id, wait?} — the official weights, verified (desktop; uses the system curl)",
always,
|s, p| download(s, p, Task::Mask)
),
cmd!("roto.model.remove", "Remove Roto Brush Model", [], None, "{id}", always, |s, p| remove(s, p, Task::Mask)),
query!("face.models", "Face Tracking Models", "{}", |s, _| list(s, Task::Face)),
cmd!(
"face.model.select",
"Use Face Tracking Model",
[],
None,
"{id: classical|mediapipe-face, wait?} (face tracking uses it; wait: loaded before returning)",
always,
|s, p| select(s, p, Task::Face)
),
cmd!(
"face.model.install",
"Install Face Tracking Model",
[],
None,
"{path: model file, id?, wait?} — verified against the registry's SHA-256",
always,
|s, p| install(s, p, Task::Face)
),
cmd!(
"face.model.download",
"Download Face Tracking Model",
[],
None,
"{id, wait?} — the official model, verified (desktop; uses the system curl)",
always,
|s, p| download(s, p, Task::Face)
),
cmd!("face.model.remove", "Remove Face Tracking Model", [], None, "{id}", always, |s, p| remove(s, p, Task::Face)),
]
}

View File

@ -519,6 +519,7 @@ async fn run_job(s: &mut Session, job: WorkerJob, post: Post) -> Result<(), Stri
path,
method,
times,
face_model: if method.is_face() { s.models.face() } else { None },
};
s.start_mask_track(work, mask, direction, true)
})

Some files were not shown because too many files have changed in this diff Show More