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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
`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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
- #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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
#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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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).
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 <[email protected]>
#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 <[email protected]>