Compare commits

..

999 Commits

Author SHA1 Message Date
Brandon Thomas 470ea7a3af Release 0.9.2: install the wasm-bindgen CLI for the web release build
The shared release workflow installs trunk, but EffectCraft's web build
runs the wasm-bindgen CLI directly (cargo xtask web), so the Web job
fails with "wasm-bindgen CLI not found". In CI, package.sh now installs
the CLI at xtask's WASM_BINDGEN version, as the per-app release.yml did
before the move to craft-common-actions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:37:47 -04:00
Brandon Thomas e2f314f753 Release 0.9.1: rebuild with the fixed shared release workflow
The previous release build failed in craft-common-actions, not in this
repo: riscv64 packaging got --formats deb,rpm,tar (fixed in
craft-common-actions#3), Linux jobs lacked the ALSA headers
(craft-common-actions#5), and Windows signing needed the AZURE_* secrets
listed in the caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:11:08 -04:00
Brandon Thomas 9efe75330c Release prep: 0.9.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:44:26 -04:00
Brandon Thomas bfea3d1253 i18n: Czech entries for the extrusion Front/Bevel/Side/Back Color submenus
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:30:10 -04:00
Brandon Thomas af671f6997 Merge pull request #987 from Losminek/cs-ui
i18n: add Czech (cs) interface language

# Conflicts:
#	crates/engine/src/prefs.rs
#	crates/ui-egui/src/i18n.rs
#	crates/ui-egui/src/i18n/ui.rs
#	docs/localization-parity.md
2026-10-12 05:22:10 -04:00
Brandon Thomas 84c6281da9 Merge pull request #984 from MarekSpodBiedry/issue-957
Show the Continuously Rasterize switch on vector footage layers (#957)

# Conflicts:
#	crates/ui-egui/src/panels/timeline.rs
2026-10-12 05:21:57 -04:00
Brandon Thomas cb59a959f4 Merge pull request #983 from MarekSpodBiedry/issue-974
Scale the timeline waveform by the layer's Audio Levels (#974)
2026-10-12 05:21:55 -04:00
Brandon Thomas 61aaf83612 Fix tests and two behaviours after the community batch
- Renderer: no cover step for adjustment layers (#686); their buffer is the comp below.
  Restores a single CPU step in split adjustment stacks.
- Effect Controls rename (#953): focus the name field the frame it exists. Focusing an id
  not yet drawn left AccessKit with a focus outside its tree (a panic in the consumer).
  The tests double-click at 60 fps, within egui's double-click time.
- i18n: Russian, French and German entries for Copy Frame to Clipboard and the extrusion
  Front/Bevel/Side/Back Color submenus added by today's other PRs.
- Tests: "fr" is now a valid language; the marker dialog's comment is focused on open
  (#922), so Enter-is-OK is tested with the focus elsewhere; the toolbar gradient tests
  give the selected layer its paint kind, since the toolbar mirrors it (#732).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:08:58 -04:00
Brandon Thomas bbc3a231c7 Merge pull request #928 from storytold/release-pass-azure-secrets
Release workflow: pass the Azure signing secrets
2026-10-12 04:29:35 -04:00
Brandon Thomas d1e6aa2e85 clippy: needless borrows at the context_menu calls
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:28:59 -04:00
Brandon Thomas 6baff9f5fe Integration fixes for the community batch
- Footage sources: the cancellable wrapper (#784) forwards frame_with (#752 float curves)
  and problem (#887); problem's default lives on the trait.
- Media: a called-off decode (#784) is not a footage problem (#887).
- Effect Controls rename (#953): Escape cancels (egui drops focus on Escape the same
  frame), the Rename item is translated; regression test.
- i18n (#873): drop the /out-i18n/ ignore rule and the note about an uncommitted script.
- Tests: text range selector caps (#926) re-borrow the layer; the RAM preview test (#793)
  sets the comp's own Resolution (#462).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:27:59 -04:00
Brandon Thomas ac37493026 Merge pull request #873 from pechnikov2026-star/i18n-ru-fr-de
i18n: add Russian, French and German menu catalogs
2026-10-12 04:24:29 -04:00
Brandon Thomas 5f386cc97c Merge #938 from nanox333/codex/fix-712-character-eyedropper (without the demo screenshot)
Add Character Fill and Stroke eyedroppers
2026-10-12 04:24:28 -04:00
Brandon Thomas 37c4e98502 Character eyedroppers: the sampling hints go through the catalog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:23:58 -04:00
Kristian Myram b6919500ee Character eyedropper test: a compact layout for the demo snapshot 2026-10-12 04:23:51 -04:00
Brandon Thomas f28901c6e3 Merge pull request #953 from Ni-zav/fix/944-rename-expression-control-s20261012T0915JKT
Fix: rename expression control
2026-10-12 04:23:20 -04:00
Brandon Thomas 50a89c48e9 Merge pull request #130 from storytold/fix/synthetic-modifiers
Control: ui.click / ui.drag hold their modifiers for the whole gesture

# Conflicts:
#	crates/ui-egui/src/lib.rs
2026-10-12 04:23:11 -04:00
Brandon Thomas 2ea7220e49 Merge pull request #916 from pravdomil/claude/composition-maximize-zoom-fit-86627e
Viewer: Fit up to 100% follows the panel size

# Conflicts:
#	crates/ui-egui/src/panels/viewer_tools.rs
#	crates/ui-egui/tests/ui_viewer.rs
2026-10-12 04:22:53 -04:00
Brandon Thomas 617412b555 Merge pull request #888 from vangel76/fix-882-viewer-pixel-exact
#882: pixel-exact Composition viewer at 100 % and above

# Conflicts:
#	crates/ui-egui/src/menus.rs
2026-10-12 04:22:01 -04:00
Brandon Thomas 6dae51622f Dialogs: vertical-only modals (#937) on the modal_fit layout
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:21:37 -04:00
Brandon Thomas 6b69881826 Merge pull request #686 from vbasky/fix/659-text-effect-bounds
Effects: text and shape layers run their stack over the whole layer region (#659, #658)
2026-10-12 04:21:15 -04:00
Brandon Thomas b2d05582e0 Merge pull request #462 from storytold/fix/417-viewer-resolution
Viewer: each composition keeps its Resolution in the project, as in After Effects (#417)

# Conflicts:
#	crates/ui-egui/src/control.rs
#	crates/ui-egui/src/menus.rs
#	crates/ui-egui/src/panels/viewer_tools.rs
2026-10-12 04:21:14 -04:00
Brandon Thomas bb7f9b421f Merge pull request #980 from nanox333/codex/fix-978-project-settings-dialog
Fix #978: widen Project Settings dialog

# Conflicts:
#	crates/ui-egui/src/panels/forms.rs
2026-10-12 04:21:13 -04:00
Brandon Thomas fe439f6e9c Merge pull request #979 from nanox333/codex/fix-958-vector-context-menu
Fix #958: add vector conversion to layer context menus

# Conflicts:
#	crates/ui-egui/src/panels/timeline.rs
2026-10-12 04:21:11 -04:00
Brandon Thomas 8dc91891e9 Merge pull request #969 from webdevtodayjason/fix/nested-svg-viewport
SVG: percentages inside a nested <svg> resolve against its own viewport

# Conflicts:
#	contributors/people.toml
2026-10-12 04:21:10 -04:00
Brandon Thomas 6bbdd8cdcc Merge pull request #954 from SteamSmith/fix/file-urls
engine: build well-formed file:// URLs for Edit Original, Reveal and File.execute
2026-10-12 04:21:09 -04:00
Brandon Thomas 7864ea41ae Merge pull request #951 from HEOJUNFO/fix-939-noise-mask
Effects: Fractal and Turbulent Noise stay inside the layer's alpha (#939)
2026-10-12 04:21:08 -04:00
Brandon Thomas 8e6c3e0e68 Merge pull request #950 from Themolx/trim-selected-layers
Timeline: trim all selected layers together

# Conflicts:
#	crates/engine/src/tests_timeline.rs
2026-10-12 04:21:07 -04:00
Brandon Thomas bfb282d47c Merge pull request #937 from nanox333/codex/fix-828-gpu-info-dialog
Keep GPU Information dialog layout steady

# Conflicts:
#	crates/ui-egui/src/panels/dialogs.rs
#	crates/ui-egui/tests/ui_settings_info.rs
2026-10-12 04:21:06 -04:00
Brandon Thomas af374a924d Merge pull request #936 from nanox333/codex/fix-902-essential-graphics-context
Open Essential Graphics from blank Timeline context menu
2026-10-12 04:21:05 -04:00
Brandon Thomas 777b59a391 Merge pull request #935 from aniliashenko/comp-presets
Composition Settings: save and delete your own presets

# Conflicts:
#	crates/engine/src/commands/comp_more.rs
2026-10-12 04:21:04 -04:00
Brandon Thomas fbcb5b44b0 Merge pull request #934 from nanox333/codex/fix-910-fullscreen-shortcut
Fix full-screen shortcut shown on Windows
2026-10-12 04:21:03 -04:00
Brandon Thomas 0d6adb07bf Merge pull request #930 from aniliashenko/text-edit-from-timeline
#637: double-clicking a layer's bar acts like its name; text layers edit their text

# Conflicts:
#	crates/ui-egui/tests/ui_text_edit.rs
2026-10-12 04:21:02 -04:00
Brandon Thomas 9fa0036144 Merge pull request #927 from pravdomil/claude/view-go-to-time-fix-1e3c21
fix: View > Go to Time accepts the dialog's float frame [skip ci]

# Conflicts:
#	crates/engine/src/tests_timeline.rs
2026-10-12 04:21:01 -04:00
Brandon Thomas fa8a1376f9 Merge pull request #926 from MarekSpodBiedry/issue-908
Cap text animator range selector Start, End and Offset (#908)
2026-10-12 04:20:59 -04:00
Brandon Thomas 302da28fcc Merge pull request #925 from MarekSpodBiedry/issue-909
Interpret Footage: apply the Loop (times) value sent as a float (#909)
2026-10-12 04:20:58 -04:00
Brandon Thomas ce3e8f1a99 Merge pull request #924 from MarekSpodBiedry/issue-912
Resume an open mask path when the Pen clicks its last vertex (#912)
2026-10-12 04:20:57 -04:00
Brandon Thomas 6ace9f7b0e Merge pull request #923 from itrycodinganditsbad/rotating-log
Desktop: write log records to standard error and a rotating log file
2026-10-12 04:20:56 -04:00
Brandon Thomas cc45d65204 Merge pull request #922 from pravdomil/claude/dialog-focus-on-open
UI: dialogs open with their first text field focused and selected
2026-10-12 04:20:55 -04:00
Brandon Thomas e2db76e507 Merge pull request #921 from pravdomil/claude/alt-click-layer-toggles-b7a7a3
Timeline: Alt-click an A/V feature switches the other layers
2026-10-12 04:20:54 -04:00
Brandon Thomas 3e34dcb0d6 Merge pull request #919 from pravdomil/claude/unsaved-changes-cmd-backspace-b69a74
UI: keyboard shortcuts for dialog answers
2026-10-12 04:20:53 -04:00
Brandon Thomas 54d68a90a6 Merge pull request #913 from pravdomil/claude/selection-tool-glyph-fix-69364a
UI: draw concave icons as convex pieces (fixes Selection arrow, Speaker, Sparkle glyphs)
2026-10-12 04:20:52 -04:00
Brandon Thomas e0b770a9ea Merge pull request #907 from tylerjenningsw/enhance/toolbar-gradient-swatch
Shapes: the toolbar's gradient swatches open the Gradient Editor (#612)

# Conflicts:
#	docs/gaps.md
2026-10-12 04:20:51 -04:00
Brandon Thomas a1c90b80f2 Merge pull request #906 from itrycodinganditsbad/fix-751
Shape layers: always rasterise at the on-screen scale (#751)
2026-10-12 04:20:50 -04:00
Brandon Thomas 02c134fb41 Merge pull request #904 from vangel76/fix-903-multichannel-downmix
#903: downmix 5.1 and other multichannel audio to stereo

# Conflicts:
#	crates/media/src/lib.rs
2026-10-12 04:20:49 -04:00
Brandon Thomas 2a7170de87 Merge pull request #899 from vangel76/fix-670-per-pane-views
#670: each pane of a 2- or 4-view layout chooses its own view
2026-10-12 04:20:48 -04:00
Brandon Thomas b93e7b79e4 Merge pull request #895 from MarekSpodBiedry/issue-837
Scripting: text range selectors resolve by match name and limit the animator (#837)

# Conflicts:
#	crates/script/src/tests.rs
2026-10-12 04:20:47 -04:00
Brandon Thomas 0ad9b5fc9b Merge pull request #893 from vangel76/fix-878-context-menu-selection
#878: context menus act on the whole selection

# Conflicts:
#	crates/ui-egui/src/panels/timeline.rs
2026-10-12 04:20:46 -04:00
Brandon Thomas 7a03b6fcd1 Merge pull request #892 from tylerjenningsw/fix/text-click-away
Text: stop capturing keyboard input after clicking away (#831)
2026-10-12 04:20:45 -04:00
Brandon Thomas 5dd47132a1 Merge pull request #891 from vangel76/fix-890-tc-dropframe
#890: Timeline time field round-trips its timecode at 29.97/59.94 fps
2026-10-12 04:20:44 -04:00
Brandon Thomas ab12890a9e Merge pull request #887 from vangel76/fix-886-unsupported-video-notice
#886: say why footage the decoders can't read shows no picture

# Conflicts:
#	crates/media/src/pool.rs
#	crates/media/tests/footage.rs
#	crates/render/src/lib.rs
2026-10-12 04:20:43 -04:00
Brandon Thomas ccd7a7c583 Merge pull request #884 from vangel76/fix-881-project-rename-tab
#881: Tab in the Project panel's rename field commits and keeps the keyboard
2026-10-12 04:20:42 -04:00
Brandon Thomas 559bd55a9b Merge pull request #883 from vangel76/fix-880-spatial-nan
#880: motion paths with overflowing or NaN arc length evaluate without panicking
2026-10-12 04:20:41 -04:00
Brandon Thomas debbb1d913 Merge pull request #877 from vangel76/fix-875-aux-view-wireframes
#875: layout views draw camera and light wireframes
2026-10-12 04:20:40 -04:00
Brandon Thomas efa023f693 Merge pull request #876 from vangel76/fix-874-linux-font-dirs
#874: scan Flatpak host, XDG and fontconfig font folders on Linux
2026-10-12 04:20:39 -04:00
Brandon Thomas 34ad8c7e9c Merge pull request #869 from vangel76/fix-864-track-matte-mode
Track Matte: picking a mode without a matte layer takes the layer above (#864)
2026-10-12 04:20:38 -04:00
Brandon Thomas 79b73b307c Merge pull request #868 from vangel76/fix-text-typing-866
#866: Layer ▸ New ▸ Text starts typing into Source Text
2026-10-12 04:20:37 -04:00
Brandon Thomas cafd18eef4 Merge pull request #867 from pravdomil/claude/uu-modified-defaults
Timeline: UU reveals modified options and layer styles, without false positives
2026-10-12 04:20:36 -04:00
Brandon Thomas fae82306b0 Merge pull request #865 from pravdomil/claude/effectcraft-separator-dot-rendering-de5cb5
UI: draw the ▸ menu-path separator instead of a missing-glyph box
2026-10-12 04:20:34 -04:00
Brandon Thomas 3f8af1a3b6 Merge pull request #863 from itrycodinganditsbad/fix-748
Save the active comp and open comp tabs in project files (#748)
2026-10-12 04:20:33 -04:00
Brandon Thomas ac1aca7225 Merge pull request #862 from tylerjenningsw/enhance/gradient-viewer-handles
Shapes: drag gradient start and end points in the Composition viewer (#612)
2026-10-12 04:20:32 -04:00
Brandon Thomas 2e2c8539e4 Merge pull request #861 from pravdomil/claude/window-menu-checkmarks-3aada3
Window menu: show check marks next to visible panels [skip ci]
2026-10-12 04:20:31 -04:00
Brandon Thomas 0b2d85b811 Fix tests after the community merges: GPU Pixel Motion Blur without a host, timeline footage kind, effects doc
- GPU Pixel Motion Blur (#695) declined without a host; it now averages the shutter's
  frames like the CPU effect (tests_fx_time::without_a_host).
- add_item_keeps_footage_longer_than_the_comp_at_full_length (#798) built footage with
  the default kind (Still), which adds at comp length; it is a movie.
- docs/effects.md regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:18:51 -04:00
Brandon Thomas 6d4e721d2c Media: Targa/HDR stills keep their Exif orientation; DPX test accepts a stated transfer
#755 decoded stills of an extension-decided format with load_from_memory_with_format,
bypassing #827's oriented decode: load_oriented now takes the format.
The DPX ffmpeg oracle test expected the bare codec name; ffmpeg builds that write
the transfer characteristic make it "DPX 8-bit RGB, linear".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 03:45:49 -04:00
Brandon Thomas 42ef79a2fd Media: movies opened in place keep their orientation (#796 × #827)
Opening movies in place (#796) skipped the whole-file bytes #827 read the
track matrix from, so the pool and the probe lost the turn for movies on
disk. stream::movie_header reads just the ftyp and moov boxes (bounded,
positional reads) and both in-place paths derive the orientation from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 03:37:34 -04:00
Brandon Thomas f41fdf0680 Merge pull request #857 from pravdomil/claude/u-uu-shortcuts-time-remap-5dd91b
Timeline: U and UU reveal Time Remap
2026-10-12 03:23:30 -04:00
Brandon Thomas 709877ffc6 Merge pull request #854 from pravdomil/claude/context-menu-spacing-41a5b2
UI: leave no gap between the rows of a menu

# Conflicts:
#	crates/ui-egui/src/panels/viewer.rs
2026-10-12 03:23:29 -04:00
Brandon Thomas 4dd74c4f6b Merge pull request #852 from Ni-zav/fix/815-text-path-matchnames-20261011
fix(script): expose After Effects text Path Options matchNames

# Conflicts:
#	crates/script/src/tests.rs
2026-10-12 03:23:27 -04:00
Brandon Thomas 6caa67971b Merge pull request #851 from Ni-zav/fix/811-script-object-identity-20261011
fix(script): preserve item and layer wrapper identity

# Conflicts:
#	crates/script/src/tests.rs
2026-10-12 03:23:26 -04:00
Brandon Thomas 2599237ef5 Merge pull request #850 from Ni-zav/fix/838-script-importas-psd-20261011
fix(script): respect ImportOptions.importAs for layered PSD files

# Conflicts:
#	crates/script/src/tests.rs
2026-10-12 03:23:24 -04:00
Brandon Thomas b7e85034ab Merge pull request #849 from Ni-zav/fix/840-extendscript-substr-20261011
fix(script): implement ExtendScript String.substr compatibility

# Conflicts:
#	crates/script/src/tests.rs
2026-10-12 03:23:23 -04:00
Brandon Thomas 255d888236 Merge pull request #847 from Ni-zav/fix/816-script-transform-shortcuts-s20261011T141200Z-fd2e
fix(script): expose AVLayer transform property shortcuts

# Conflicts:
#	crates/script/src/tests.rs
2026-10-12 03:23:21 -04:00
Brandon Thomas 1c7935b8c2 Merge pull request #846 from Ni-zav/fix/808-script-comments-and-marker-params-s20261011T141200Z-fd2e
fix(script): persist layer comments and unnamed marker parameters

# Conflicts:
#	crates/script/src/tests.rs
2026-10-12 03:23:20 -04:00
Brandon Thomas 61f5b937dc Merge pull request #845 from Ni-zav/fix/814-batch-remove-keys-s20261011T141200Z-fd2e
feat(script): delete keyframe ranges in one undoable command

# Conflicts:
#	crates/script/src/tests.rs
2026-10-12 03:23:18 -04:00
Brandon Thomas d6bd27db8f Merge pull request #844 from Ni-zav/fix/810-bounded-undo-group-s20261011T141200Z-fd2e
fix(script): preserve atomic undo groups beyond the history limit

# Conflicts:
#	crates/script/src/runtime.rs
#	crates/script/src/tests.rs
2026-10-12 03:23:16 -04:00
Brandon Thomas 5237ebf596 Merge pull request #843 from Ni-zav/fix/812-stable-script-undo-id-s20261011T141200Z-fd2e
fix(script): keep Undo command ID 16 stable for scripts

# Conflicts:
#	crates/script/src/tests.rs
2026-10-12 03:23:14 -04:00
Brandon Thomas 80febbd418 Merge pull request #842 from Ni-zav/fix/817-script-binary-file-io-s20261011T141200Z-fd2e
fix(script): preserve raw bytes in BINARY File reads, writes, and copies
2026-10-12 03:23:13 -04:00
Brandon Thomas 6304956628 Merge pull request #835 from pravdomil/claude/cmd-arrow-frame-nav-3a2fc1
Shortcuts: Cmd+Left / Cmd+Right Arrow step a frame
2026-10-12 03:23:12 -04:00
Brandon Thomas a1516b11ad Merge pull request #830 from WladmirJunior/i18n/pt-br-panels
pt-BR: translate the panels below the menu bar
2026-10-12 03:23:10 -04:00
Brandon Thomas 90da5d6724 Merge pull request #829 from pravdomil/claude/window-position-size-persist-99f0d6
Window: remember the window's size and position between launches
2026-10-12 03:23:09 -04:00
Brandon Thomas c11740f461 Merge pull request #827 from pravdomil/claude/import-exif-orientation
Import: stills and movies respect their orientation

# Conflicts:
#	crates/media/src/pool.rs
2026-10-12 03:23:08 -04:00
Brandon Thomas 30f7ce8ae5 Merge pull request #826 from luniiya/feat/theme-favorites
UI: favorite themes and appearance shortcuts
2026-10-12 03:23:06 -04:00
Brandon Thomas d100e8af32 Merge pull request #825 from pravdomil/claude/footage-panel-double-click-e18b22
UI: wrap the Footage panel's empty hint in a narrow panel
2026-10-12 03:23:05 -04:00
Brandon Thomas 03a27f6eab Merge pull request #824 from redonion23/fix/753-layer-viewer-zoom
Layer panel: zoom and pan; View zoom acts on the focused viewer; Roto Brush hint (#753)
2026-10-12 03:23:03 -04:00
Brandon Thomas d1e88ee52b Merge pull request #807 from cdinhoo/fix/nonblocking-expression-diagnostics
Performance: reduce UI stalls while dragging layers
2026-10-12 03:23:02 -04:00
Brandon Thomas ff0ee5a727 Merge pull request #806 from vangel76/gap-fuzz-expressions
#786: fuzz expressions; fix the overflows, hangs and panic it found
2026-10-12 03:23:01 -04:00
Brandon Thomas e8e896e1d6 Merge pull request #799 from MarekSpodBiedry/issue-749
Automation: a layer or comp id sent as a string resolves as an id (#749)
2026-10-12 03:23:00 -04:00
Brandon Thomas 2cf28e5e24 Merge pull request #798 from MarekSpodBiedry/issue-795
Keep footage dropped on the timeline at its full length (#795)

# Conflicts:
#	crates/engine/src/tests_timeline.rs
2026-10-12 03:22:58 -04:00
Brandon Thomas 4e8e6e10b1 Merge pull request #796 from vangel76/fix-stream-video-open
#794: open movies in place instead of reading them whole

# Conflicts:
#	crates/media/src/lib.rs
2026-10-12 03:22:57 -04:00
Brandon Thomas 30523a81a5 Merge pull request #793 from vangel76/fix-595-prefetch-order
#595: RAM preview prefetches nearest the playhead and keeps the frames just ahead
2026-10-12 03:22:56 -04:00
Brandon Thomas 58b09391bd Merge pull request #789 from vangel76/gap-project-size-limits
#788: hold opened projects and size commands to the 30,000 px limits

# Conflicts:
#	crates/engine/src/commands/layer.rs
2026-10-12 03:22:54 -04:00
Brandon Thomas 225bbad013 Merge pull request #787 from vangel76/gap-floating-panel-windows
#486: move a panel into its own native window (multiple monitors)

# Conflicts:
#	crates/ui-egui/src/i18n/ui.rs
#	docs/gaps.md
2026-10-12 03:22:53 -04:00
Brandon Thomas 909572dffd Merge pull request #785 from vangel76/gap-fuzz-project-open
#779: fuzz File > Open; refuse deeply nested XML projects
2026-10-12 03:22:51 -04:00
Brandon Thomas 565fd4118d Merge pull request #784 from vangel76/fix-329-seek-latency
#329: scrubbing a long movie: superseded viewer frames call off their decodes

# Conflicts:
#	crates/media/tests/footage.rs
2026-10-12 03:22:50 -04:00
Brandon Thomas 5be3b8f9b2 Merge pull request #782 from MishynMykhailo/uk-panel-catalog-20261011
Translate the 95 panel rows Ukrainian was missing
2026-10-12 03:22:49 -04:00
Brandon Thomas 1210ba91f4 Merge pull request #768 from vangel76/fix-762-animated-webp
#762: import animated WebP as footage with its frames

# Conflicts:
#	docs/file-format-parity.md
2026-10-12 03:22:30 -04:00
Brandon Thomas 24f98a0242 Merge pull request #778 from vangel76/gap-uncompressed-mov
#372: uncompressed QuickTime (raw RGB / ARGB) output module format

# Conflicts:
#	docs/gaps.md
2026-10-12 03:20:53 -04:00
Brandon Thomas 9ebabccbec Merge pull request #775 from vangel76/script-766-mouse-events
#766: ScriptUI mouse events reach script listeners

# Conflicts:
#	crates/engine/src/scriptui.rs
#	crates/script/src/lib.rs
#	docs/scripting-parity.md
2026-10-12 03:20:52 -04:00
Brandon Thomas be1d9928e0 Merge pull request #774 from vangel76/gap-proportional-scrubbing
#770: Proportional Scrubbing of Timeline values (Ctrl+Alt / Cmd+Opt)

# Conflicts:
#	docs/ui-parity.md
2026-10-12 03:20:51 -04:00
Brandon Thomas dfd6e9300f Merge pull request #772 from vangel76/script-760-evalfile-bridgetalk
#760: $.evalFile and a minimal BridgeTalk host identification

# Conflicts:
#	crates/script/src/runtime.rs
#	docs/scripting-parity.md
2026-10-12 03:20:49 -04:00
Brandon Thomas b2ce1240c6 Merge follow-up: model description and gaps row 17 after the import PRs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 03:20:48 -04:00
Brandon Thomas 78b32b61d4 Merge pull request #769 from vangel76/gap-mxf-import
#767: import MXF (OP1a / OP-Atom) footage

# Conflicts:
#	crates/ui-egui/src/menus.rs
#	docs/gaps.md
2026-10-12 03:20:05 -04:00
Brandon Thomas 3d02e65f2b Merge pull request #765 from vangel76/gap-dpx-import
#756: import DPX and Cineon stills and sequences

# Conflicts:
#	crates/media/tests/footage.rs
#	crates/ui-egui/src/menus.rs
#	docs/file-format-parity.md
#	docs/gaps.md
2026-10-12 03:20:04 -04:00
Brandon Thomas 05f0d9ec62 Merge pull request #755 from vangel76/gap-tga-hdr-import
#745: import Targa (.tga) and Radiance HDR (.hdr) stills and sequences

# Conflicts:
#	crates/ui-egui/src/menus.rs
2026-10-12 03:20:03 -04:00
Brandon Thomas 261a5b125b Merge pull request #754 from vangel76/gap-surface-colors
#315: Front / Bevel / Side / Back Color for extruded shapes and text

# Conflicts:
#	docs/gaps.md
2026-10-12 03:20:01 -04:00
Brandon Thomas d57459a7d0 Merge pull request #742 from vangel76/gap-fbx-import
#736: import FBX 3D models (binary and ASCII)

# Conflicts:
#	crates/engine/src/commands/app_more.rs
#	crates/engine/src/commands/model3d.rs
#	crates/engine/src/media_browser.rs
#	crates/media/src/lib.rs
#	crates/media/src/probe.rs
#	crates/model/Cargo.toml
#	crates/model/src/lib.rs
#	crates/ui-egui/src/menus.rs
#	docs/file-format-parity.md
#	docs/gaps.md
2026-10-12 03:20:00 -04:00
Losminek 50ab738f62 Merge upstream/main into cs-ui
Keep every entry in contributors/people.toml, and translate the new menu
item (Copy Frame to Clipboard) and the five gradient-editor strings into
Czech.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:19:44 +02:00
Losminek 5a836f05fc contributors: add Losminek
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:18:17 +02:00
Losminek 2a8ecfb822 i18n: add Czech (cs) interface language
Czech for all 618 fixed menu entries and the 496 panel, tooltip and dialog
strings of the most complete catalog; settings accept and persist `cs`, Match
System recognises cs, cs-CZ and cs_CZ.UTF-8; native menu, Undo/Redo and
file-manager labels. Czech may lag the Chinese catalogs like Japanese and
Ukrainian (a missing row falls back to English); menu rows use "..." like
the other catalogs. AI-assisted, independently reviewed, not yet proofread
by a native speaker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:18:17 +02:00
Brandon Thomas 7f9aa28c03 fmt: Brightness & Contrast test (#702 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 03:12:04 -04:00
Brandon Thomas d47d3c7b34 Viewer: the empty-state drop highlight paints with its own painter (#709 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 03:09:14 -04:00
Brandon Thomas d9dddb90b7 Merge pull request #781 from vangel76/script-773-repeating-tasks
#773: run repeating app.scheduleTask tasks until cancelTask
2026-10-12 03:03:45 -04:00
Brandon Thomas 15387619ee Merge pull request #780 from vangel76/gap-project-panel-a11y
#776: Project panel arrow-key navigation and accessible names
2026-10-12 03:03:44 -04:00
Brandon Thomas 3e9aa820d1 Merge pull request #771 from vangel76/script-759-include
#759: #include / //@include / #includepath in scripts
2026-10-12 03:03:36 -04:00
Brandon Thomas bcb9ee1fa5 Merge pull request #764 from vangel76/gap-transition-presets
#757: Transitions animation presets in Effects & Presets
2026-10-12 03:03:31 -04:00
Brandon Thomas fef320e9dd Merge pull request #763 from vangel76/script-758-callable-props
#758: layers, property groups and properties are callable
2026-10-12 03:03:29 -04:00
Brandon Thomas c72e47b7dc Merge pull request #761 from vangel76/gap-variable-font-filter
#741: font menu filters to variable fonts
2026-10-12 03:03:28 -04:00
Brandon Thomas 4c9a9a9cfa Merge pull request #752 from vangel76/gap-float-working-gamma
#750: float footage follows Working Gamma and is encoded once
2026-10-12 03:03:23 -04:00
Brandon Thomas 41e8bf48d9 Merge pull request #746 from vangel76/fix-474-exr-decode-peak
#474: decoding a multi-layer EXR no longer holds the file's samples twice
2026-10-12 03:03:22 -04:00
Brandon Thomas b14096c335 Merge pull request #744 from vangel76/fix-732-toolbar-fill-type
#732: toolbar Fill/Stroke Options change the selected shape layers' kind
2026-10-12 03:03:20 -04:00
Brandon Thomas cbaaf654d6 Merge pull request #743 from MarekSpodBiedry/issue-723
Graph Editor: Shift constrains Bezier handle drags to one axis (#723)
2026-10-12 03:03:19 -04:00
Brandon Thomas 25dcdcd2d8 Merge pull request #740 from vangel76/fix-303-mesh-warp-grid
#303: Mesh Warp's grid can be dragged in the Composition viewer
2026-10-12 03:03:16 -04:00
Brandon Thomas 8176c86043 Merge pull request #739 from vangel76
# Conflicts:
#	docs/gaps.md
2026-10-12 03:03:10 -04:00
Brandon Thomas 27e095e60e Merge pull request #702 from vbasky/fix/681-brightness-contrast
Match After Effects non-legacy Brightness & Contrast
2026-10-12 03:03:02 -04:00
Brandon Thomas e98788690c Merge pull request #737 from vangel76/gap-copy-frame-clipboard
#730: Composition ▸ Copy Frame to Clipboard
2026-10-12 03:02:59 -04:00
Brandon Thomas e5ff011296 Merge pull request #735 from vangel76/gap-stl-import
#729: import STL 3D models (binary and ASCII)
2026-10-12 03:02:58 -04:00
Brandon Thomas 4aa2c6ba8b Merge pull request #734 from vangel76/fix-678-psd-drop-dialog
#678: dropped Photoshop files show the Import Photoshop File dialog
2026-10-12 03:02:56 -04:00
Brandon Thomas 64b7088867 Merge pull request #733 from vangel76/fix-473-crypto-quoted-names
#473: Cryptomatte Selection keeps quoted names with commas whole
2026-10-12 03:02:55 -04:00
Brandon Thomas a4a117c1f7 Merge pull request #731 from vangel76/fix-710-ruler-frames
#710: Timeline ruler labels frames when Time Display is Frames
2026-10-12 03:02:54 -04:00
Brandon Thomas adaa8d65bb Merge pull request #728 from vangel76/contributors-vangel76
Contributors: add vangel76 (Valentin Klein)
2026-10-12 03:02:52 -04:00
Brandon Thomas 15d50597dd Merge pull request #716 from storytold/ci-speedup-nightly-platform-checks
CI: FreeBSD and Windows ARM64 nightly and on release, not per PR
2026-10-12 03:02:51 -04:00
Brandon Thomas 0ae711ba3a Merge pull request #713 from mjohnson7cd/fix/2081-rendering-hang
fix(gpu): cap GPU readback staging buffers to prevent memory exhaustion
2026-10-12 03:02:50 -04:00
Brandon Thomas 686280cc5f Merge pull request #711 from Ni-zav/fix/704-cylinder-axis-rotation-s20261011T035800Z-cf39
fix(effects): add XYZ rotation to CC Cylinder on CPU and GPU
2026-10-12 03:02:48 -04:00
Brandon Thomas 196dd80104 Merge pull request #709 from Ni-zav/fix/684-empty-viewer-project-drop-s20261011T035800Z-cf39
fix(viewer): create a composition when dropping footage onto an empty viewer
2026-10-12 03:02:47 -04:00
Brandon Thomas d7fe6d2e56 Merge pull request #705 from tylerjenningsw/enhance/custom-ui-scale
Settings: allow custom UI scale percentages (#682)
2026-10-12 03:02:46 -04:00
Brandon Thomas 4482ad9634 Merge pull request #703 from didpublishing/fix/windows-path-tests
Tests: six engine tests no longer assume the working directory's drive on Windows
2026-10-12 03:02:44 -04:00
Brandon Thomas 162a2e2e01 Merge pull request #699 from didpublishing/fix/596-gpu-information
#596: GPU Information names the adapter, or why the GPU compositor is off
2026-10-12 03:02:43 -04:00
Brandon Thomas b84b5f47ed Merge pull request #695 from vbasky/fix/535-pixel-motion-vectors
Pixel Motion Blur: synthesize from motion vectors, honour Vector Detail (#535)
2026-10-12 03:02:42 -04:00
Brandon Thomas 62dd3ae2ed Merge pull request #694 from tylerjenningsw/enhance/gradient-editor
Shapes: edit gradient color and opacity stops (#612)
2026-10-12 03:02:40 -04:00
Brandon Thomas edfa8ee51c Merge pull request #677 from vbasky/test/651-gif-preview
Tests: an imported animated GIF plays its frames (#651)
2026-10-12 03:02:39 -04:00
Brandon Thomas dff86891d0 Merge pull request #399 from FeiLiuEM/test/variable-font-axis
Text: the variable-font test picks an axis that moves the advance
2026-10-12 03:02:38 -04:00
MarekSpodBiedry 25be439a0b Scale the timeline waveform by the layer's Audio Levels (#974) 2026-10-12 08:39:44 +02:00
MarekSpodBiedry 69d2b53892 Show the Continuously Rasterize switch on vector footage layers (#957) 2026-10-12 08:34:01 +02:00
Kristian Myram e0a23e8763 Fix #958: add vector conversion to layer context menus 2026-10-12 08:17:50 +02:00
MarekSpodBiedry 5df37babc3 Keep Footage panel overlay/insert edits at full length past the comp end (#795) 2026-10-12 08:05:22 +02:00
Kristian Myram c644c5f7e9 Fix #978: widen Project Settings dialog 2026-10-12 08:02:34 +02:00
webdevtodayjason d9f80ea76f SVG: percentages inside a nested <svg> resolve against its own viewport 2026-10-11 22:36:43 -05:00
webdevtodayjason c72a556b89 Contributors: credit webdevtodayjason as Jason Brashear 2026-10-11 22:36:43 -05:00
SteamSmith 011e242b74 engine: build well-formed file:// URLs for Edit Original, Reveal and File.execute
Three places built file URLs by string concatenation: Edit Original
(`file://{path}`), the Reveal commands via folder_url (`file://{dir}`) and
File.execute (`file://` with only spaces replaced by %20). On Windows that
produced `file://C:\Users\...`, and any path containing `#`, `%`, `?` or
non-ASCII characters named the wrong file (or none).

Add layer_menu::file_url: backslashes become `/`, drive paths get the third
slash (`file:///C:/...`), and bytes outside the plain path characters are
percent-encoded (UTF-8 for non-ASCII). folder_url, edit_original and
execute_file use it.

Tests: file_urls_are_well_formed, folder_url_names_the_containing_folder,
edit_original_opens_an_encoded_file_url, execute_file_opens_an_encoded_file_url.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:38:35 -04:00
Nigel Ihza Firdausy cf61eba87a test(ui): cover renaming an expression control header 2026-10-12 09:19:28 +07:00
Nigel Ihza Firdausy 94f74075f9 fix(ui): let users rename effect instances in Effect Controls (#944) 2026-10-12 09:18:57 +07:00
heojunfo 09b3042e20 Effects: Fractal and Turbulent Noise stay inside the layer's alpha (#939)
Masks are applied before effects, but the noise was composited with the
Opacity alpha alone, so a masked solid filled the whole layer. The result
is now multiplied by the original alpha on the CPU and on both GPU paths
(kernels.wgsl for the default settings, fx_noise.wgsl for the rest).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 10:31:31 +09:00
Themolx 91b078777e Timeline: trim all selected layers together
Dragging the in or out edge of a selected layer's bar now trims every
selected layer by the same delta from its own in/out point, as in After
Effects. layer.timing gains relative inDelta/outDelta params (seconds,
frame-snapped), clamped to the comp start/end and to one frame of layer;
the drag stays one undo step via the merge key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 03:16:06 +02:00
Kristian Myram b693a6286b Add Character fill and stroke eyedroppers 2026-10-12 01:49:59 +02:00
Kristian Myram 650ea35c18 Keep GPU information dialog layout steady 2026-10-12 01:30:55 +02:00
Kristian Myram 01c03fa8da Add Essential Graphics to blank Timeline context menu 2026-10-12 01:25:56 +02:00
Anton Iliashenko 14b6b9c242 Composition Settings: save and delete your own presets
As in After Effects, the Preset menu in Composition Settings / New Composition gets a Save
button (asks for the name in a Choose Name window; an existing name of yours is replaced) and a
Delete button for the selected preset of yours. Your presets keep frame size, pixel aspect and
frame rate, are listed above the built-in ones and live in the settings (`compPresets`), so they
survive restarts and Reset All. Commands: `comp.savePreset`, `comp.deletePreset`, `comp.presets`.
A hand-edited settings file loses only its invalid presets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:16:52 +02:00
Kristian Myram 1888f0aaf3 Fix full-screen shortcut shown on Windows 2026-10-12 01:15:23 +02:00
Anton Iliashenko e108f460c4 #637: double-clicking a layer's bar acts like its name; text layers edit their text
Double-clicking a text layer in the Timeline (its name or its bar) edits its text in the
Composition viewer with the whole text selected, as in After Effects; it used to open the rename
field (Enter still renames). The layer bar had no double-click at all: it now opens a precomp,
opens footage and solids in the Layer panel and edits text, like the name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:35:23 +02:00
Brandon Thomas 59bd63c282 Release workflow: pass the Azure signing secrets so Windows builds are signed
Since the release workflow moved to storytold/craft-common-actions, it has
passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads
values from the release environment, but only for names the caller lists, so
the AZURE_* names that Trusted Signing uses came through empty and Windows
builds would ship unsigned. Same fix as storytold/craft-launcher#174.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:02:58 -04:00
pravdomil c8968cee1f fix: View > Go to Time accepts the dialog's float frame [skip ci]
The Go to Time dialog's Frame field sends a float (5.0); time.set read
`frame` with as_i64, got None and failed with "need time, frame or
timecode". Read it as any JSON number and round.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:55:11 +02:00
MarekSpodBiedry 8927ae5781 Cap text animator range selector Start, End and Offset (#908) 2026-10-11 23:39:23 +02:00
MarekSpodBiedry 050bc4723e Resume an open mask path when the Pen clicks its last vertex (#912) 2026-10-11 23:39:07 +02:00
MarekSpodBiedry 189dec0644 Interpret Footage: apply the Loop (times) value sent as a float (#909) 2026-10-11 23:38:07 +02:00
pravdomil ffc481a4e4 UI: numeric forms open with their first number focused and selected
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:36:15 +02:00
pravdomil 50eddc1207 UI: keyboard shortcuts for dialog answers
Unsaved Changes: Enter is Save, Cmd+Backspace is Don't Save.
Revert Project: Cmd+R is Revert (the button is now red).
Delete Items: Cmd+D is Delete.
Crash recovery: Enter is Open Auto-Save.
Marker: Enter is OK, Cmd+Backspace is Delete (from a focused text field too).

The macOS menu bar hands Cmd+D and Cmd+R back to the open prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:35:37 +02:00
pravdomil 4c5d775761 Timeline: Alt-click an A/V feature switches the other layers
Alt-click on a layer's Video, Audio, Solo or Lock switch sets that layer
apart: the other layers go off (or locked), and the next Alt-click brings
them back. Backed by layer.setSwitch {others: true}, one undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:14:39 +02:00
pravdomil 8e1d742a9d UI: dialogs open with their first text field focused and selected
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:13:53 +02:00
Fabio Lussi 16d9d7d30b Desktop: write log records to standard error and a rotating log file
A launch from the Dock, the Start menu or a launcher has no terminal, and
without RUST_LOG nothing reached standard error anyway, so start-up and
GPU warnings were lost unless Help > Enable Logging was already on.

The logger now writes EffectCraft's info records and everyone's warnings
to standard error and to Logs/effectcraft.log in the settings folder.
Each launch keeps the previous two runs' logs; the file is capped at
16 MiB. RUST_LOG still overrides the filter. Help > Reveal Logging File
shows this run's log unless Enable Logging is on.

Ported from storytold/pdfcraft#239.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:37:27 +02:00
pravdomil a78961a346 Viewer: Fit up to 100% follows the panel size
Magnification ▸ Fit up to 100% stored the magnification of the moment it
was picked, so the comp kept that size when the panel was maximized or
resized. It is now a fit mode like Fit, capped at 100%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:21:52 +02:00
pravdomil 373f27d38d UI: draw concave icons as convex pieces (Selection arrow glyph)
The Selection tool's arrow is a concave outline, but icon fills go through
egui's convex-only polygon fill, which fanned slivers across its notches.
The arrow, and the likewise concave Speaker and Sparkle glyphs, are now drawn
as overlapping convex pieces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:17:53 +02:00
vangel76 d7a03ef3a7 #786: make the expression budget tests independent of machine load
fanning_out_reads_stop_at_a_budget expects the run-count error, but under heavy load
the 5 s wall-clock budget could fire first (different error). Add a test-only
per-thread time limit and lift it in that test. Wall-clock assertions in the other
new tests are now only generous hang guards (>=10x), the fuzzer's hang deadline is
24x MAX_TIME and the perf bound is 10x looser.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 20:52:39 +02:00
tylerjenningsw f6d42e2d67 Text: formatting tabs keep the edit; keys after a click away skip it (#831)
Two follow-ups to ending text editing on a click in another panel.

A press anywhere in a panel group focused the group's shown panel, even
a press on another of its tabs. Opening Properties or Character behind
Effects & Presets (as in Undocked Panels) therefore ended the edit on
the press, before the tab's click focused the panel chosen, and later
formatting went to the whole layer instead of the selected characters
(an empty Type-tool layer was even removed). A press on another tab now
waits for that tab's click.

The click away is resolved after the frame's panels, so keys that came
after it in the same frame's input were still typed into the text. The
text keyboard handler now takes the frame's keys in order up to a press
outside the viewer: keys before the click are typed, keys after it are
neither typed nor run as shortcuts, and from the next frame they are
the app's.

Tests: opening_a_formatting_tab_keeps_the_text_selection (a real click
on the Properties tab keeps the edit and its selection; Faux Bold then
styles only "Hello") and keys_after_a_click_away_in_the_same_frame_skip_
the_text ("A", a click on a Timeline row, then "x", Space and Delete in
one frame: only "A" is typed, nothing is played or deleted; Space in the
next frame plays). Both fail on the previous commit.
2026-10-11 14:48:20 -04:00
tylerjenningsw a47995aabb Docs: gaps.md records the toolbar gradient-swatch tests 2026-10-11 14:46:55 -04:00
tylerjenningsw 3d7f928465 Shapes: the toolbar's gradient swatches open the Gradient Editor (#612)
With Fill or Stroke set to Linear or Radial Gradient, clicking its
toolbar swatch opened Fill / Stroke Options, so the gradient's colours
couldn't be edited from the toolbar as in After Effects (reported on
Discord, EffectCraft 0.8.0 on Windows 11). The swatch now opens the
Gradient Editor (prop.editGradient) on the selected shape layer's first
Gradient Fill or Gradient Stroke Colors, in contents order through nested
groups; OK sets that property. With no shape layer selected, or none
with that gradient, the swatch opens the options as before (the kind new
shapes get: the toolbar keeps no gradient stops of its own yet). The
words still open the options, a Solid Color swatch the colour picker,
and Alt-click still steps through the kinds.

Tests (ui_toolbar_gradient): a Linear Fill swatch opens the editor on the
layer's Gradient Fill Colors and OK saves an added stop; a Radial Stroke
swatch edits the Gradient Stroke, not the fill; without a selected
gradient the options open; the words, Solid swatches and Alt-click are
unchanged. The two gradient-swatch tests fail without this change.
2026-10-11 14:46:45 -04:00
Fabio Lussi 183d052c6a Shape layers: always rasterise at the on-screen scale
Shape layers are always continuously rasterised, as in After Effects and
as text layers already are here: the contents are drawn at the layer's
effective scale (own transform and parents) whether or not the
Continuously Rasterize switch is on, so scaled-up shapes stay sharp.

Fixes #751

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:38:17 +02:00
vangel76 64fabbb2fc #903: downmix 5.1 and other multichannel audio to stereo
Problem: footage audio kept only its first two channels, so the centre, LFE and surrounds of a 5.1 file were dropped. AAC 5.1 was worse: FilmCraft's AAC-LC decoder delivers AAC's own order (C L R Ls Rs LFE), so the left output was the centre and front-right was lost.

Fix: crates/media/src/downmix.rs holds one ITU-R BS.775 stereo downmix (centre and surrounds at -3 dB, LFE left out, fixed normalisation by the largest row sum so full scale cannot clip), with channel orders for 3 to 8 channels in WAVE/SMPTE order and AAC's native order, and only the front pair for more than 8. MediaPool::read_stereo applies it, which preview, waveform and export mixdown all read through. Mono and stereo are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 20:36:41 +02:00
vangel76 fa7ffb6d09 #670: each pane of a 2- or 4-view layout chooses its own view
Problem: the extra panes of View > Switch View Layout were fixed to Top, Front and Right, and the 3D View menu only changed the main view.

Fix: every extra pane has a view selector (Active Camera, Default, Front, Left, Top, Back, Right, Bottom, Custom View 1-3). The choice is kept per comp in Views3D::panes (serde default, so older editor states load with Top/Front/Right) and set by the new command view.setPaneView {pane 0-3, view}. The Active Camera pane renders through the comp camera.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 19:46:47 +02:00
MarekSpodBiedry 55f4d163f8 Scripting: text range selectors resolve by match name and limit the animator (#837) 2026-10-11 19:13:39 +02:00
tylerjenningsw 1b7ab07822 Text: stop capturing keyboard input after clicking away (#831)
After typing into a text layer, clicking another panel (a solid in the
Timeline, say) left the edit open, and the viewer took the keyboard back
as soon as nothing had the focus: typed characters went into the text
and Space added a space instead of playing.

A click in any panel other than the viewers and the panels that format
the selected characters (Character, Paragraph, Properties) now ends the
edit with text.endEdit, so the text is kept and an empty layer the Type
tool started is removed, and the viewer's text focus is surrendered. The
editor takes the keyboard back only after a click in the viewer or one
of those panels, no longer merely because nothing has the focus. A click
elsewhere in the viewer still commits, and with the Type tool still
starts new text.

Test: clicking_another_panel_stops_typing_into_text, with the Type tool
and with an edit started by command: Character > Faux Bold keeps the edit,
its selection and typing; clicking the solid's Timeline row ends it,
selects the solid, typed characters leave the text unchanged and Space
plays; an empty Type-tool layer is removed. Fails on main.
2026-10-11 13:01:36 -04:00
vangel76 51501bbd21 #890: the Timeline time field reads its displayed timecode back as the same frame at 29.97/59.94 fps
Problem: the Timeline shows non-drop timecode, but time.set (the current-time field) parsed
typed text as drop-frame at 29.97/59.94 fps, so committing the displayed time, or typing a
time, landed on another frame from minute 1 on (frame 1800 "0:01:00:00" -> 1798).

Fix: typed text is read as drop-frame only when it uses ';' fields; ':' and '.' follow the
displayed non-drop style. Regression test round-trips the display at both rates.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 19:00:25 +02:00
vangel76 8ac8612ace #882: pixel-exact Composition viewer at 100 % and above
Problem: the viewer blurred hard edges even at Full resolution and 100 %
(reported on Discord). The frame rect was centred without snapping to
physical pixels, so bilinear sampling blended neighbouring pixels at 1:1
(worst at 125 %/150 % display scaling); More Accurate also magnified
bilinearly, so 200 %+ and lower resolutions were smoothed; and Zoom In /
Zoom Out stepped in points, so at 125 % scaling they never reached 100 %.

Fix: snap the frame origin (and the ViewerMap overlays use) to the
physical pixel grid; magnify with nearest in both Zoom Quality modes
(CPU texture options and the GPU frame sampler), keeping the averaged
minification below 100 % (#417); the snapshot texture follows the same
options; Zoom In / Out step through screen-pixel magnifications.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:50:53 +02:00
vangel76 74b95b0a9d #886: say why footage the decoders can't read shows no picture
Problem: a movie whose profile the software decoders lack (H.264 High 4:2:2 / 10-bit)
probes fine, then every frame read fails; MediaPool logged the error per frame and returned
None, so the layer rendered black with no sign why in the viewer, Project panel or for agents.

Fix: MediaPool records the reason once per file (codec from the probe plus the decoder's text
without error wrappers; I/O errors stay with missing-footage handling), logs it once, and
exposes it through the new FootageSource::problem. The Composition viewer shows it on the
affected layer, the Project panel marks the item with a warning whose tooltip is the reason,
and project.summary / footage.info carry a "problem" field. Cleared when the file is
forgotten; only set when decoding really failed, so a hardware decoder that handles the file
shows nothing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 18:49:54 +02:00
vangel76 1b7f08198f #878: context menus act on the whole selection
Problem: with several layers selected, right-clicking one of them in the
Timeline (name row or layer bar) and choosing Pre-compose, Delete,
Duplicate, ... re-selected and acted on only the right-clicked layer.
Right-clicking an unselected layer did not select it. In the Project
panel, Move to Project Root moved only the right-clicked item.

Fix: as in After Effects, a right-click on a selected layer keeps the
selection and the menu's commands target every selected layer (in comp
order); a right-click on an unselected layer selects only it. Move to
Project Root uses the same selection targets as Delete and Label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:42:42 +02:00
vangel76 73404a2ad2 #881: Tab in the Project panel's rename field commits and keeps the keyboard
Problem: Tab in a Project panel rename (or comment) field committed the
edit and egui moved keyboard focus to the next widget, the row's label
swatch. While it held focus, Enter no longer renamed (it "clicked" the
swatch), the arrow keys moved between widgets and app shortcuts paused.

Fix: the field keeps Tab through its event filter (arrows as before);
Tab surrenders focus and commits like Enter, so nothing else takes the
keyboard and the next Enter renames again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:41:17 +02:00
vangel76 5d4b644865 #880: motion paths with overflowing or NaN arc length evaluate without panicking
Problem: spatial keys with huge coordinates (the Position field accepts any
finite number) overflow the motion path's arc length to NaN. SpatialSeg::at_length
only returned early for len <= 0.0, so NaN reached s.clamp(0.0, len), which
panics. The viewer's motion-path overlay evaluates on the UI thread, so moving
the current time over such keys crashed the app.

Fix: at_length returns the segment start unless the length is positive and
finite. A seeded fuzz of evaluate/velocity/spatial tangents with NaN, infinite
and +-1.7e308 values (200k tracks) found no other panic or hang.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:34:08 +02:00
vangel76 f19f3a8a39 #875: layout views draw camera and light wireframes
Problem: in a 2- or 4-view layout the Top / Front / Right views drew no
camera or light wireframes, so moving a camera showed nothing there
(reported on Discord).

Fix: draw_rigs takes the view camera explicitly and a pane name for its
automation ids; aux_views draws the wireframes of each extra view through
that view's camera, clipped to the pane, with ids
viewer.view.<view>.camera.<id> / .light.<id>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:24:57 +02:00
vangel76 b58197ff82 #874: scan Flatpak host, XDG and fontconfig font folders on Linux
Problem: on Linux the font list only scanned /usr/share/fonts,
/usr/local/share/fonts, ~/.local/share/fonts and ~/.fonts. The ArtCraft
Launcher installs EffectCraft as a Flatpak, where those folders hold only
the runtime's fonts and the host's fonts live under /run/host, so installed
fonts were missing (25 families instead of 320 on the reporter's setup).
$XDG_DATA_HOME/fonts, $XDG_DATA_DIRS/*/fonts and fontconfig <dir> entries
were never scanned either.

Fix: a new font_dirs module lists the usual folders, the Flatpak host
folders (/run/host/fonts, local-fonts, user-fonts), the XDG data folders
and the <dir>/<remap-dir> entries of the fontconfig configuration
(FONTCONFIG_FILE or /etc/fonts/fonts.conf, /usr/local/etc/fonts/fonts.conf,
and ~/.config/fontconfig), following <include>s with prefix="xdg",
"relative" and ~ expansion. It uses a small tag scanner (no new dependency),
bounds the include depth, the number and size of files read, and reads each
file once. Folders are deduplicated before the walk, and the walk depth
goes from 4 to 8. docs/agents.md lists the folders and how to grant the
Flatpak an extra one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:21:20 +02:00
sanches 826d82007d i18n: add Russian, French and German menu catalogs
Three new 618-entry menu catalogs (ru, fr, de) with the same
(command, English label) keys as the existing six, registered in
CATALOG_CODES and the Settings language registry. Following the
Spanish/Portuguese precedent these are menu-only catalogs: panels keep
English for now.
2026-10-11 19:06:04 +03:00
Dylan M. Taylor 67434e9038
ci: refactor workflows to use storytold/craft-common-actions (#801)
* ci: refactor workflows to use reusable artcraft-common-actions

* ci: point reusable workflows to storytold/craft-common-actions

* ci: pin storytold/craft-common-actions to commit 27a978f and pass secrets explicitly

* ci: use @main (unpinned) for craft-common-actions with explanation comment
2026-10-11 11:51:43 -04:00
tylerjenningsw f4c5015b35 Settings: keep action buttons visible at high UI scales
At 200 % in a 1600x1000 window, Previous / Next / Cancel / OK were cut
off. Settings sized its pages from the window height less a fixed 170,
and the shared modal put everything, buttons included, in one more
scroll area, so a footer that didn't fit scrolled out of the dialog.

The modal helper gets a modal_fit variant without that scroll area: it
hands its body the height left under the title. Settings lays out its
buttons first, from the bottom up, and gives the page list and the page
what they leave; both scroll on their own and the buttons stay put. The
pages are a clipped child, so a wide page can't widen the dialog under
the buttons. Other dialogs keep modal as it was.

Tests: settings_buttons_stay_in_view_at_200_percent opens Settings at
200 % in 1600x1000 and checks that each button is inside the window and
painted unclipped (fails before this change: the scroll area clipped
them at the bottom). It scrolls the page to UI Scale without moving
them, then types 150 and clicks Cancel (200 % again, nothing saved) and
OK (150 % saved). The UI Scale test no longer needs a larger window and
clicks Cancel at 200 %.
2026-10-11 11:48:07 -04:00
vangel76 4a3889270b #864: Track Matte menu: a mode without a matte layer takes the layer above
Picking Alpha / Alpha Inverted / Luma / Luma Inverted in a layer's Timeline
Track Matte menu only dispatched a command when the layer already had a
matte layer, so with "No Track Matte" the click closed the menu and did
nothing. The mode items now run layer.trackMatte (Layer > Track Matte),
which keeps an existing matte and otherwise takes the layer above, or
reports that the top layer has none. The menu ticks the current mode and
the cell's tooltip names the matte layer and the mode (the 104 px cell
has no room for both).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:46:05 +02:00
vangel76 1226576a92 #866: Layer > New > Text starts typing into Source Text
Problem: Layer > New > Text (menu, Ctrl+Alt+Shift+T, Timeline context menus)
created a layer with the placeholder "Text" and no edit session, so typed keys
ran shortcuts; users then renamed the layer (Enter) and the name, not the
Source Text, got their text.

Fix: as in After Effects, the UI dispatch runs layer.newText with an empty
text and an edit session (insertion point in the comp centre); the name
follows the typed text. engine.execute keeps the headless default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:46:01 +02:00
pravdomil ba2770a258 Timeline: UU reveals modified options, layer styles and fixes false positives
The modified reveal compares each layer with a fresh layer of the same kind:
Audio, Material, Geometry, Camera and Light Options and a text layer's Path
and More Options now count when changed. Layer styles count in full, and
overridden Essential Properties are revealed.

Cameras and lights compare their Transform with their own defaults, and
separated X/Y Position with the comp centre, so untouched layers reveal
nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:41:30 +02:00
pravdomil 01400f0a89 UI: draw the ▸ menu-path separator instead of a missing-glyph box
Inter and egui's built-in fallbacks have no glyph for ▸ (U+25B8), so the
command palette's "EffectCraft ▸ Settings... ▸ Appearance..." and every
"File ▸ Import" hint drew a box. JetBrains Mono, already bundled, has it:
register it as a symbol fallback for the non-monospace families.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:37:57 +02:00
Fabio Lussi b0c0c61aa0 Save the active comp and open comp tabs in project files
Project files now record activeComp and openComps next to savedBy, and
file.open restores them, so a project reopens on the comp it was saved
on (desktop app, CLI and MCP all open through file.open). Older files
and ids that no longer name a comp fall back to the first comp; older
versions ignore the new keys.

Fixes #748

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:27:01 +02:00
pravdomil 41c92d3856 Window menu: show check marks next to visible panels
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:25:58 +02:00
tylerjenningsw f45f57da7d Docs: gaps.md records the #612 viewer gradient-handle tests 2026-10-11 11:12:15 -04:00
tylerjenningsw c39f29882c Shapes: drag gradient start and end points in the Composition viewer (#612)
With a shape layer's Gradient Fill or Gradient Stroke (or a property in
it) selected, its Start Point and End Point show in the viewer as
draggable crosshairs with a guide between them, as in After Effects. The
points are in the space of the shape group holding the gradient, so they
are mapped through the enclosing groups' transforms and the layer's
(shapes::item_matrix), and a drag converts back the same way. A drag is
one prop.set merge: one undo step, and on an animated point a keyframe at
the current time. The hook runs after the viewer's own gestures, like the
effect point controls, so a handle takes the press and the layer doesn't
move. They follow View ▸ Show Layer Controls.

Tests (ui_gradient_handles, only pre-existing APIs, all three fail without
this): dragging the End Point moves it by the drag in one undo step without
moving the layer; inside a moved, 200%-scaled group the handle sits where
the group draws the point and a drag converts to group space; no handles
unless the gradient or a property in it is selected, or with layer
controls off. ui_effect_controls still passes.
2026-10-11 11:12:05 -04:00
pravdomil c81a9f1061 Timeline: U and UU reveal Time Remap
anim.reveal only walked the layer's top-level groups, so Time Remap, which
sits at the root of the layer's property tree, was never revealed. Enabled
time remapping counts as a modification in itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:02:10 +02:00
pravdomil afaa4bdeeb UI: leave no gap between the rows of a menu
The app's item spacing left 4 px between two menu entries where neither
highlighted and a click did nothing. Menus (context menus, the menu bar,
menu buttons and their submenus) now use a menu style with no row gap and
rows taller by that gap, so they keep their size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:02:09 +02:00
Nigel Ihza Firdausy 0467e1101b fix(script): expose After Effects text Path Options matchNames (#815) 2026-10-11 21:46:02 +07:00
Nigel Ihza Firdausy 40e9e7786b fix(script): route usedIn composition references through identity cache 2026-10-11 21:45:20 +07:00
Nigel Ihza Firdausy d347afb2cb fix(script): preserve item and layer wrapper identity (#811) 2026-10-11 21:44:02 +07:00
Nigel Ihza Firdausy 8b25dc713f fix(script): forward ImportOptions.importAs for layered PSD imports (#838) 2026-10-11 21:42:43 +07:00
Nigel Ihza Firdausy 3cdc7af9ff fix(script): implement ExtendScript String.substr compatibility (#840) 2026-10-11 21:41:21 +07:00
Nigel Ihza Firdausy 74b842cc45 fix(script): expose AVLayer transform property shortcuts (#816) 2026-10-11 21:30:18 +07:00
Nigel Ihza Firdausy 47972e95a8 fix(script): persist layer comments and unnamed marker parameters (#808) 2026-10-11 21:29:04 +07:00
pravdomil 88bf89ea5d Shortcuts: Cmd+Left / Cmd+Right Arrow step a frame
Previous / Next Frame also answer to Cmd+Left / Cmd+Right Arrow, and Back / Forward
10 Frames to Cmd+Shift+Left / Right, next to the Page keys (After Effects' second
default keys). A command spec can now list several default shortcuts
(`alt_shortcuts`, `cmd!(…, ["PageDown", "Cmd+ArrowRight"], …)`); menus and tooltips
keep showing the first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:26:27 +02:00
Nigel Ihza Firdausy be2c73ece9 feat(script): remove a range of keyframes in one command (#814) 2026-10-11 21:25:13 +07:00
Nigel Ihza Firdausy 6d757e0061 fix(script): keep undo groups atomic beyond history limit (#810) 2026-10-11 21:23:02 +07:00
Nigel Ihza Firdausy a7249ad1a4 fix(script): keep After Effects Undo command ID 16 stable (#812) 2026-10-11 21:19:59 +07:00
Nigel Ihza Firdausy 64ffb404e3 test(script): exercise invalid wide characters in BINARY writes 2026-10-11 21:19:00 +07:00
Nigel Ihza Firdausy d88bfe9f7c fix(script): preserve binary File bytes when reading, writing and copying (#817) 2026-10-11 21:17:46 +07:00
pravdomil 04e087ae58 Window: remember the window's size and position between launches
The window opened at a fixed 1680 × 1020 wherever the system put it. Its size, position
and maximized state are now saved to window.json in the config directory (when they change,
and on exit) and restored on the next launch, moved onto a monitor that is still connected.
The first launch fits the window to its screen. Agent-driven runs (--control) restore the
saved window but don't overwrite it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:54:29 +02:00
Luniiya e1bcaab0a0 UI: favorite themes and appearance shortcuts
Port of storytold/photocraft#2445. Each theme in the appearance picker gets a
star at the right edge of its row. Starred themes are saved with the
preferences and listed in a right-click menu on the header's appearance button,
next to Appearance Settings, so switching between favorites takes one click.
2026-10-11 15:46:18 +02:00
pravdomil 3e0519de5f UI: wrap the Footage panel's empty hint in a narrow panel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:38:55 +02:00
cdinhoo be6134e8ad Performance: reduce UI stalls while dragging layers 2026-10-11 15:49:21 +03:00
redonion23 afdb958199 Layer panel: zoom and pan; View zoom acts on the focused viewer; Roto Brush hint in the Composition panel (#753)
- The Layer panel keeps its own magnification and pan (`ui.layer_zoom`,
  `ui.layer_pan`) with the Composition panel's magnification menu in its
  footer; the wheel / pinch zooms around the pointer, the Hand tool,
  Space-drag and a middle-drag pan, and the Zoom tool clicks zoom in
  (Alt: out). Navigation never paints or makes Roto Brush strokes.
- View > Zoom In / Zoom Out / Fit / Actual Size act on the Layer panel
  while it is focused, else on the Composition viewer as before.
- A Roto Brush click or drag in the Composition panel says the brush
  works in the Layer panel; double-clicking a layer there with a Roto
  Brush tool opens it in the Layer panel (as After Effects).
- Opening another layer resets the Layer panel to Fit.

Tests: crates/ui-egui/tests/ui_layer_panel_zoom.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:41:40 +02:00
vangel76 9da66d9063 #786: fuzz expressions; fix the overflows, hangs and panic it found
Problem: expression text is input we don't control, and nothing fuzzed the
expression parser and evaluator. A seeded mutation fuzzer found stack
overflows (a few hundred `?:`/`=>`/`if`, a few thousand `.`/`<`/`()` links,
75 nested template substitutions or regex groups, a chain inside one
template), hangs (an expression reading its own layer's transform nested
2^16 times; fanning-out reads; 10M-iteration loops re-run per read; loops
calling looping functions), an i64 overflow panic in wiggle noise, and NaN
path vertices tripping the rasterizer's clipping assertion.

Fix: a dependency-free seeded fuzzer (crates/expr/src/tests/fuzz.rs: real
AE-style seeds, char/token mutations, worker thread with a 2 MiB stack,
catch_unwind, deadline, env vars for long runs); a weighted nesting
estimate covering chains without brackets and template/regex insides;
cycle detection per (property, time, text); a per-evaluation budget of
20 000 script runs and 5 s (runs are interrupted through boa's budgeted
evaluation; native only); loop limit 1M; wrapping noise lattice indices;
non-finite path results rejected. Each fix has a regression test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:40:03 +02:00
pravdomil c03b0a7aa7 Import: MP4/MOV movies respect their rotation
A movie shot with a phone held upright stores sideways frames and says so
in its video track header; it imported lying on its side. Probing now
reports the turned size, and decoded frames (full and reduced size) are
turned to match. Only the rotation is used, snapped to a quarter turn;
mirroring, scaling and offsets in the header are ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:30:44 +02:00
tylerjenningsw 3df7e8f77c Shapes: a closed colour picker leaves the next Escape to the dialog (#612)
The picker marked every pass it drew, including the one it closed in, so
for one pass after closing the dialog Escape handler still stepped aside
and a quick second Escape did nothing. The mark is now set only while the
picker stays open and cleared in the pass it closes (OK, Cancel, Escape or
a click outside); the picker also consumes the Escape it handles.

Test: the_escape_after_the_picker_closes_cancels_the_editor presses Escape
to close the picker and again in the very next pass, with no settling
frames: the editor cancels. It fails on the previous marking.
2026-10-11 08:29:54 -04:00
pravdomil 683ec372d9 Import: stills respect their Exif orientation
A photo taken with the camera held sideways (or a mirrored scan) imported
as stored, lying on its side. Probing now reports the size the picture is
seen at, and the still and smart-object decodes apply the rotation or
mirroring to the pixels. A missing or invalid tag decodes as stored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:03:47 +02:00
MarekSpodBiedry bee798c190 Automation: a layer or comp id sent as a string resolves as an id (#749) 2026-10-11 13:54:43 +02:00
MarekSpodBiedry db11dffa18 Keep footage dropped on the timeline at its full length (#795) 2026-10-11 13:53:52 +02:00
vangel76 d648026ffd #794: open movies in place instead of reading them whole
Problem: probe() and MediaPool's movie sources called std::fs::read and then
filmcraft_codecs::open_bytes, so a clip cost its full size in memory, twice
while probing (a 245 MiB MP4 peaked at 494 MiB just to import).

Fix: container footage on disk is opened through a positional-read
ByteReader (stream::FileReader) and FilmCraft's reader openers
(filmcraft_media::reader::open_reader; MP4/MOV, Matroska/WebM, MXF, Ogg,
MPEG-PS/TS), which read the index and then single samples. Probing takes this
path only when nothing probed before containers (still/model extensions,
PSD/SVG/PDF, image magic) claims the file; everything else, whole-file audio
formats, in-memory files (add_bytes) and wasm32 builds keep the byte path.
Regression test: probing and decoding a frame and audio of a movie with a
256 MiB tail grows peak RSS by <64 MiB (512 MiB before), same results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:42:24 +02:00
vangel76 271000b294 #595: RAM preview prefetches nearest the playhead and keeps the frames just ahead
Problem: while a preview played, the RAM preview cache evicted the least
recently used frame. The frames just ahead of the playhead had been rendered
(prefetched) before the frames just shown, so once the cache was full they
were evicted first; prefetch asked for them again, but its queue ran in
request order, so they waited behind every frame further ahead. Playback
ran into a gap right ahead of the playhead while frames far ahead stayed
green, re-rendered the same frames over and over (about four renders per
frame shown when the preview was longer than the cache) and stalled for
seconds. The prefetch window could also reach further ahead than the cache
holds.

Fix: the playing preview tells Frames where it is (Frames::set_playhead).
Evictions then take frames the preview does not reach first (other series,
behind it without looping), least recently used first, then the frame it
reaches last; prefetch renders the frame it reaches first; queued prefetch
frames it no longer reaches are dropped (and leave the in-flight set). The
prefetch window stops at what the cache holds. The viewer puts a CPU frame
that needs no averaging on screen without copying its pixels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:35:14 +02:00
vangel76 765ba157be #788: hold opened projects and size commands to the 30,000 px limits
Problem: comp.new/comp.settings clamp sizes to 30,000 px, frame rates to
0.001-1000 fps and motion-blur samples to 2-64, but reading a project
(.ecproj and .ecprojx, templates, recovery, offload) accepted any value:
a 4-billion-px comp, solid or footage frame, a 0 or 10^18 fps rate
(zero-length frames), a negative duration or 4 billion blur samples all
reached the renderer. New Solid, Layer Settings, Import Solid/Placeholder,
Crop Comp and PSD import set sizes without the upper bound, and comp
sizes past 2^32 wrapped.

Fix: one set of limits in effectcraft_project::limits. Project::from_json
brings every item (and proxy) within them; from_json_repaired also says
what it changed, which file.open returns as `repaired` and shows in a
toast, so the project still opens. Project::add_item applies the same
limits to every new item, and the size-setting commands clamp with the
shared helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:22:59 +02:00
vangel76 e7ada5bd16 #486: move a panel into its own native window (multiple monitors)
Problem: floating panels lived inside the main window only, so on a
two-screen setup no panel could go onto the second monitor (#486).

Fix: panel menu > Move Panel to New Window (and the agent/control command
`window.panelWindow {panel, window?}`) undocks the panel if needed and shows
its floating group in a native egui viewport the user can drag to any screen;
Move Panel to Main Window or closing the window brings it back. The window's
place and size are kept in the workspace (sanitised against hostile values)
and restored on the next start. Browser and test builds, which embed
viewports, show it as an in-app window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:10:12 +02:00
vangel76 b747ffc986 #779: fuzz File > Open; refuse deeply nested XML projects
Problem: nothing fuzzed project files. The hand-written .ecprojx XML reader
recursed once per nested <arr>/<obj> with no limit, so a deeply nested file
overflowed the stack and aborted the app instead of failing to open.

Fix: a dependency-free, deterministic, seeded mutation fuzzer for File > Open
(JSON and XML seeds from the demo, byte and JSON-structure mutations, the real
file.open path, first queries and a thumbnail render; each input under
catch_unwind with a deadline; bounded for cargo test, longer runs via env
vars). The XML reader now stops at the JSON reader's 128-level limit, with a
regression test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:02:40 +02:00
vangel76 a0decc80dd #329: superseded viewer frames call off their footage decodes
Problem: scrubbing a long movie showed the frame under the pointer
seconds after the pointer stopped. Every viewer frame a free frame-pool
thread had started kept running: each seeked the movie's one decoder and
decoded up to a GOP, and the newest frame waited behind all of them on
the decoder's lock (45 min 1080p H.264/HEVC, GOP 250: 1.8-3.6 s for the
last frame of a drag; a single jump is 0.1-0.4 s). FilmCraft's decoder
already polls a cancellation flag, but nothing set one.

Fix: FootageSource::cancellable / called_off and CancellableFootage (a
per-render view whose frame reads run under the render's own flag, so
reads on other threads answer to the right render). MediaPool runs them
under filmcraft_media::cancel::with_cancel. The frame workers render
through it, and a newer viewer frame calls off the comp's running viewer
frames of other times. A called-off render is not cached (frames, disk,
layer cache). Drag latency is now 0.1-0.3 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:01:54 +02:00
vangel76 884cb9987b #776: test that app shortcuts still run after a mouse click on a row
A mouse click gives a Project row no keyboard focus, and the arrow keys
only move keyboard focus when a row already had it, so Delete /
Backspace, Cmd+D and Enter keep working for mouse users, also after an
arrow key moved the selection. Pin that down with a headless test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:58:03 +02:00
vangel76 7085816c98 #773: run repeating app.scheduleTask tasks until cancelTask
Problem: app.scheduleTask(code, delay, true) queued the task and then
skipped it (eval_with_tasks: `if repeat { continue; }`), so repeating
tasks - what ScriptUI panels use for polling and refreshing - never ran.
One-shot tasks scheduled by handlers or in the Script Console never ran
either.

Fix: tasks live per script context (crates/script/src/tasks.rs) with a
due time. Frontends run the due ones every frame through the new
ScriptUi::run_tasks hook (the egui app does; agents and headless hosts
use the `script.runTasks` command). Script-host threads get a Tick
message, inline hosts (Script Console, web) run in their kept context.
A repeating task runs every `delay` ms until app.cancelTask, until it
throws (reported once, then cancelled), or until its owner goes: the
script's last window closing, or - for scripts without windows and the
console - the project being closed or replaced (ScriptUi::project_epoch).
Bounds: at least 50 ms between runs, at most 64 pending tasks per
script, one run per pass (no catch-up bursts), task code nested too
deeply is refused, the engine's loop and recursion limits apply. Ticks
only reach the hosts of the session that scheduled them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:56:09 +02:00
vangel76 132bd21078 #776: Project panel arrow-key navigation and accessible names
Problem: with the Project panel focused the arrow keys did nothing (After
Effects selects the previous / next item, extends with Shift and opens /
closes folders with Right / Left). The custom-drawn rows, sort headers,
twirls, label swatches, proxy switches and footer buttons reached
AccessKit with no name or role, the empty list area and each row's name
cell were unnamed extra Tab stops, a Tab-focused row showed no focus
ring, and the arrows then moved egui's spatial focus instead.

Fix: Up / Down select the previous / next row (Shift extends from the
anchor), Right opens and Left closes the selected folder or selects the
parent; the moved-to row scrolls into view. A row with keyboard focus
keeps the arrows, focus follows the selection and the panel takes focus.
Rows are AccessKit tree items named "<name>, <type>" with selected,
level and expanded state; headers and row / footer buttons are named
buttons; one Tab stop per row and a focus ring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:53:48 +02:00
MishynMykhailo 8763fc0138 Translate the 95 panel rows Ukrainian was missing
The panel catalog (crates/ui-egui/src/i18n/ui.rs) has grown to 496 rows since
Ukrainian last matched it on 9 October, so `uk` fell back to English for the Home
screen, the viewer's toolbar tooltips and popups, the Appearance settings, the
solid dialog and the project, import, export and colour-management forms.

Add those rows, in the Chinese catalogs' order and under their section comments,
so the catalogs stay parallel. `uk` now carries 495 of the 496 rows: `RGB` reads
the same in Ukrainian, so it keeps no row and falls back to the English source,
which is what the catalog's own rule asks for.

Terminology follows the Ukrainian menu catalog in i18n.rs so the two agree:
Full/Half/Third/Quarter as Повна/Половина/Третина/Чверть, Draft as Чорнова,
Wireframe as Каркас, Custom... as Власна..., Region of Interest as Область
інтересу, footage as матеріал, solid as суцільний колір, Default as Стандартний.
Dark and Light take the neuter form the catalog's existing Darker/Lighter rows
(Темніше/Світліше) already use, so one row reads correctly both as an Appearance
Mode and as a theme card option.

`format_ago` picks between one and many, while Ukrainian declines the noun for 1,
for 2–4 and for 5 and up, so the relative times use the invariant unit
abbreviations (хв, год, дн.), which read correctly for every count. A per-language
plural rule would be the fuller fix and is left for its own change.

ROADMAP.md and docs/localization-parity.md carry the new count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-11 13:46:05 +03:00
vangel76 575642a5ed #372: uncompressed QuickTime (raw RGB / ARGB) output module format
Problem: the Render Queue had no single-file uncompressed output, so
handing a render to an external encoder meant a lossy codec, ProRes,
or an image sequence plus a separate WAV.

Fix: add the "QuickTime (Uncompressed)" Output Module format. Frames
are stored as QuickTime 'raw ' samples: 8-bit RGB (depth 24, rows
padded to even bytes) or ARGB with RGB + Alpha (depth 32), written
through FilmCraft's existing QuickTime muxer with PCM audio
(16/24-bit or float) like ProRes. Format name `uncompressed` on the
control channel; disk-space estimate and Sample Format menu included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:43:33 +02:00
vangel76 1c06a793a5 #766: deliver ScriptUI mouse events to script listeners
Problem: ScriptUI controls never sent mouse events to scripts.
addEventListener("mousedown" | "mouseup" | "mousemove" | "mouseover" |
"mouseout" | "click") listeners never ran, so custom-drawn controls
(onDraw + mouse, as DuScriptUI builds them) could not react to the pointer.
docs/plugins.md listed this as missing.

Fix: scriptui.js builds a ScriptUI MouseEvent (clientX/clientY relative to
the target, button, detail = click count, screenX/Y, modifier keys,
getModifierState, stopPropagation, preventDefault) and delivers it in the
capture, target and bubble phases. addEventListener takes the capture flag.
The parent walk is bounded, so a script-made parent cycle cannot hang it.
Each published control lists the mouse events a listener wants
(Widget::mouse, inherited from containers). The new scriptui.mouse
command dispatches one event with clamped, finite values and rejects
unknown types and disabled controls. The egui ScriptUI view hit-tests the
pointer over script windows and docked panels, skipping covered layers and
windows blocked by a modal dialog. It sends mouseover/mouseout,
mousemove, mousedown/mouseup with click counts, and a click carrying the
pointer details in place of the plain click, so a checkbox still toggles
once. Handlers run under the existing script limits; a throwing listener
reports an error and the window keeps working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:39:23 +02:00
vangel76 ea0414e280 #770: Proportional Scrubbing of Timeline values (Ctrl+Alt / Cmd+Opt)
Problem: After Effects 26.2 spreads a Ctrl+Alt (Cmd+Opt) hot-text scrub
over the selected layers by selection order (first unchanged, last the
whole change, the ones between even shares). Our multi-layer scrub
(#491) always moved every selected layer by the same change.

Fix: prop.set takes `proportional` (with `selected` + `offset`); each
participating layer, the scrubbed one included, gets index/(n-1) of the
change in selection order, in one undo step; the first layer is left
alone (no key added). The Timeline value editor sends it while Ctrl+Alt
(Cmd+Opt) is held; that chord no longer fine-scrubs or unlinks Scale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:39:06 +02:00
vangel76 88278ea871 #760: $.evalFile and a minimal BridgeTalk host identification
Problem: `$.evalFile` always threw, and `typeof BridgeTalk` was
`undefined`, so frameworks such as DuAEF failed during initialisation
when they load their modules or ask which application they run in.

Fix: `$.evalFile(file[, timeout])` evaluates a script file in the global
scope and returns its last value. Relative paths resolve against the
folder of the file being evaluated (the running script's for the
outermost call, else the project's); reads follow the same file gate,
16 MiB size cap and 32-deep nesting limit as `#include` (#759, shared
`scriptfile.rs`), and the bracket-depth check runs before parsing.
`BridgeTalk` names the host honestly (`appName` "effectcraft", its
version, `appSpecifier`, `isRunning` / `getSpecifier` / `getStatus` /
`getTargets` for ourselves only); `send()` throws a clear "not
supported" error instead of messaging other applications.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:37:22 +02:00
vangel76 e29f34029c #759: #include / //@include / #includepath in scripts
Problem: ExtendScript's preprocessor directives weren't understood, so a
script (or ScriptUI panel, or library such as DuAEF) failed to parse at
its first `#include`; `#target` / `#targetengine` / `#strict` lines broke
parsing too.

Fix: before a script runs, `include::expand` inlines `#include "f"` and
`//@include "f"` in place of the line, resolved against the including
file's folder, then the folders of the latest `#includepath "a;b"`.
Each file is inlined once (which also breaks cycles), nesting is limited
to 32, files are capped at 16 MiB (64 MiB in all), and reads follow the
scripts' file gate (the script's own folder, the project's folder, or
anywhere with Allow Scripts to Write Files). `#target`, `#targetengine`,
`#strict`, `#script` and `#engine` are dropped. Errors in the expanded
code are mapped back to the included file and its line. Scripts without
directives run unchanged. File resolution, the gate and the limits live
in `scriptfile.rs` for `$.evalFile` (#760) to share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:34:36 +02:00
vangel76 f0bd916543 #767: import MXF (OP1a / OP-Atom) footage
Problem: the Media Browser lists .mxf files, but File > Import's dialog
filtered them out and media::is_importable refused them, although the
pinned FilmCraft codecs behind crates/media already demux MXF (AVC,
MPEG-2 / XDCAM / D-10, VC-3 / DNxHD, ProRes, PCM audio) by content.

Fix: add "mxf" to VIDEO_EXTENSIONS and to the File > Import and Set
Proxy dialog filters. Tests check MPEG-2 4:2:2 long-GOP + 24-bit PCM and
DNxHD 36 MXF against ffmpeg frames (>35 dB, measured 64-67 dB), audio
level, and that the Import dialog offers MXF. AVI stays open: FilmCraft
has no AVI demuxer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:30:51 +02:00
vangel76 5ba0c60a15 #762: import animated WebP as footage with its frames
Problem: an animated WebP (e.g. ffmpeg -c:v libwebp_anim) imported as a
still and showed only its first frame; probe_bytes handed every WebP to
the still path. Found while measuring #719.

Fix: a webp_anim module, following the animated GIF path. Probing reads
the RIFF chunk headers (VP8X animation and alpha flags, one ANMF per
frame) and describes a WebP with two or more frames as Video footage
(codec "WebP", alpha Straight or Ignore from VP8X). Frame delays give the
rate: delays within 1 ms of each other read as the standard rate they
round to (24, 25, 29.97...), else 1000 / gcd of the delays so uneven
timing is exact; delays of 10 ms or less count as 100 ms as in browsers.
Frames are decoded once with the image crate's WebP AnimationDecoder and
cached in the pool's existing GIF slot; gif_anim::Anim now keeps frame
starts as Ticks and serves both formats. Canvas x frames beyond the
existing memory cap is refused before decoding; a damaged tail keeps the
frames before it; a file without frames is a clear decode error. Still
WebP stays a still.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:29:26 +02:00
vangel76 557c0bdbba #756: import DPX and Cineon stills and sequences
Problem: the Media Browser listed .dpx files and After Effects imports
DPX and Cineon scans (stills and numbered sequences), but import
refused them: nothing could decode either format, and .cin was not even
an image to the browser, so its runs never grouped into sequences.

Fix: a small pure-Rust reader written from the public SMPTE 268M and
Kodak Cineon specifications (crates/media/src/dpx.rs): luma, RGB, RGBA
and ABGR at 8/10/12/16 bits, 10-bit filled method A/B, 12-bit in 16-bit
words, both byte orders, lines padded to 32-bit words, unrotated
orientations and the pixel aspect ratio; the codec names the transfer
(log, linear, ...) and code values are shown as stored. RLE, signed,
float, Y'CbCr, packed 10/12-bit and transposed files are refused with a
clear error; every header field is bounds-checked and the data length
verified before allocating. Probe and frame decode use it first;
is_importable, the Media Browser kinds and the import dialog list
.dpx/.cin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:16 +02:00
vangel76 81bec748ec #757: Transitions animation presets in Effects & Presets
Problem: EffectCraft shipped only 8 original text-animator presets against
After Effects' 621; there was no Transitions category (ranked gap #7).

Fix: an original preset library, crates/engine/presets/transitions.json
(23 presets on our own Transition effects and layer opacity: fades,
directional and diagonal wipes, clock and fan reveals, blinds and slats,
pixel/mosaic dissolves, card flips, CC grid, radial scale, line sweep and
jaws wipes; In and Out). New commands anim.applyBuiltinPreset (adds the
effects with values and keyframes from the current time, one undo step,
reports any path that doesn't fit) and anim.builtinPresets. Effects &
Presets lists them under * Animation Presets > Transitions; double-click
applies. Sidecar and ATTRIBUTION.md entry; gaps/effects-parity updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:10 +02:00
vangel76 bfe384e400 #758: layers, property groups and properties are callable
Problem: After Effects scripts call layers and property groups as a
shorthand for .property(nameOrIndex) (layer("ADBE Effect Parade"),
fxGroup(1), effect("Slider Control")("Slider")); Duik and many other
scripts use it throughout. EffectCraft's objects were Proxies around
plain objects, so each such call failed with "TypeError: not a callable
function".

Fix: Layer, PropertyGroup, MaskPropertyGroup and Property objects are
now Proxies around an arrow function carrying the object's state and
prototype (own name/length removed so the object's getters show). The
apply trap returns self.property(arg), so a call matches .property():
null when absent, Property returns null, and a missing name is the same
script error. instanceof, attribute access and script results are
unchanged; new on these objects stays a TypeError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:22:03 +02:00
vangel76 7afe723260 #741: font menu filters to variable fonts
Problem: After Effects 26.3's font menu can show only variable fonts.
EffectCraft's font menus (Character panel, Properties ▸ Text) had no such
filter, and text.fonts / MCP list_fonts didn't say which families are
variable.

Fix: the font scan marks a face variable when its table directory has an
fvar table declaring axes (header read only, no extra file loading). The
font menu gets a "Variable fonts only" checkbox above the results; it
combines with the typed search, keeps the keyboard in the search field and
stays set while the app runs. text.fonts reports `variable` per family and
takes `{variable: true}`; list_fonts passes it through. Catalog rows for
ja, zh-Hans, zh-Hant and uk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:14:50 +02:00
vangel76 fb5a5e9ac0 #315: Front / Bevel / Side / Back Color for extruded shapes and text
Problem: In Advanced 3D, an extruded shape or text layer was one mesh in
its paint colour. After Effects colours the front, bevel, side and back
of an extrusion separately (shape Contents ▸ Add ▸ Front/Bevel/Side/Back,
text Animate ▸ Front/Bevel/Side/Back), and EffectCraft had none of it.

Fix: The extruder can split its mesh by surface (front cap, bevel rings,
walls, back cap). Shape layers get Front / Bevel / Side / Back contents
items (layer.addShapeItem kinds frontColor…backColor, one item per
group, colouring every paint of the group and its nested groups; the
innermost wins). Text animators get Front / Bevel / Side / Back Color,
blended over the character's fill or stroke by the selection. Paints
with a surface colour render as one mesh per surface. Menus, the
Contents Add: pop-up and the menu translations list them. The other
surface material properties (Ambient, Diffuse, Specular, Metal,
Reflection, ...) remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:05:36 +02:00
vangel76 cf8b9aebc9 #750: float footage follows Working Gamma and is encoded once
Problem: with no working space and Working Gamma 2.4, a linear EXR still
converted with the sRGB curve (0.18 -> 0.4627, After Effects 0.4902):
float footage was sRGB-encoded at decode, before the render knew the
project's curve. Interpret As Linear Light on float footage then encoded
a second time (0.18 -> 0.7255).

Fix: FootageSource::frame_with asks for float footage with a FloatCurve
(sRGB, a pure 2.4 power curve, or as stored). The renderer asks for the
Working Gamma's curve when unmanaged, sRGB when managed, and the stored
values when Interpret As Linear Light encodes them afterwards.
MediaPool decodes float values that way (Preserve RGB still reads them
as stored) and caches each curve apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:03:10 +02:00
vangel76 1fdf817cc9 #745: import Targa (.tga) and Radiance HDR (.hdr) stills and sequences
Problem: the Media Browser lists .tga and .hdr files (and groups them into
image sequences), but media::is_importable had neither extension and the
still decoder never tried them, so importing them failed.

Fix: enable the image crate's built-in tga and hdr decoders (no new
dependency), add both to STILL_EXTENSIONS and the File > Import dialog, and
pick a still's format by signature with an extension fallback, since Targa
has no signature. Radiance HDR is linear float light and is sRGB-encoded like
OpenEXR (super-white kept); Targa alpha goes through the existing
unlabeled-alpha interpretation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:57:13 +02:00
vangel76 7aee1d6275 #474: decoding a multi-layer EXR no longer holds the file's samples twice
Problem: `read_exr_channels` kept the exr crate's whole decoded image alive
while it built every f32 plane, and copied each channel through an extra
`Vec` (`values_as_f32().collect()`) before placing it. One decode of a
2560x1440 Blender file with 32 channels briefly held ~320 MB of samples next
to ~470 MB of planes, per concurrently decoded frame.

Fix: consume the decoded image channel by channel, so each channel's samples
are freed (or moved) as its plane is made. A channel that covers exactly the
display window is converted in one pass, and an f32 one (Cryptomatte ranks)
becomes its plane without a copy; other channels are placed into the display
window with bounds-checked indexing, as before. Keeping half channels as f16
and sizing the channel cache by bytes stay open.

Test: a full-window f32 channel's plane is the same allocation; f16 / u32
channels and offset or clipped data windows are placed as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:54:09 +02:00
vangel76 0ae960102c #732: toolbar Fill/Stroke Options change the selected shape layers' kind
Problem: with shape layers selected, the Tools bar's Fill Options / Stroke
Options (None, Solid Color, Linear Gradient, Radial Gradient, and the
swatch's Alt-click cycle) only changed the defaults for new shapes.
shape.fillStroke had no kind, so a selected layer's fill or stroke could
not be removed or turned into a gradient from the toolbar; it had to be
edited in the Timeline. The swatch also kept showing the tool's kind, not
the layer's.

Fix: shape.fillStroke takes fillType / strokeType. None switches the
layers' Fills and Gradient Fills (or Strokes) off; another kind switches
them on and turns them into that kind, keeping the uid and the
properties both kinds share (blend mode, opacity, width, dashes...), and
gives a group with paths and no such item one (gradients span the
group's paths). It reports the first layer's fillType / strokeType, and
the toolbar shows them. The kind picks in the options popup and the
Alt-click cycle apply to the selected shape layers, one undo step each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:49:48 +02:00
vangel76 977532f135 #736: import FBX 3D models (binary and ASCII)
Problem: After Effects 26 imports FBX models into Advanced 3D comps; File > Import rejected
.fbx, so FBX product shots and scenes could not be used.

Fix: a clean-room FBX 7.x reader in effectcraft-model (binary node records with 32/64-bit
offsets and zlib arrays, ASCII syntax) and a scene builder: meshes with normals, UVs and
per-polygon materials, the Model hierarchy with the SDK transform (pivots, offsets, pre/post
rotation, rotation order, geometric transform), Lambert/Phong materials onto
metallic-roughness with embedded or relative textures, and GlobalSettings axes turned to
Y-up. Wired into import, probing, the media browser and the file dialog. Hostile input
(truncation, corruption, huge arrays, deep nesting) fails with an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:46:43 +02:00
vangel76 c733277263 #303: Mesh Warp's grid can be dragged in the Composition viewer
Problem: Mesh Warp keeps its Distortion Mesh in a hidden text parameter,
and the viewer only draws handles for point parameters (Bezier Warp's),
so with Mesh Warp selected there was no grid and nothing to drag: the
effect could not warp anything from the UI.

Fix: with a Mesh Warp effect (or one of its parameters) selected and
layer controls shown, the viewer draws the (rows+1)x(columns+1) grid
over the layer in effect space, with a handle on every vertex
(automation id viewer.meshWarp.<fx>.<row>.<col>). Dragging a vertex
writes its offset to the `mesh` parameter through prop.set, one undo
step per drag; a mesh that does not match Rows/Columns starts from the
undistorted grid, as it renders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:43:38 +02:00
vangel76 d58253cc5a #738: Edit ▸ Paste pastes SVG markup as a shape layer
Problem: After Effects 26.3 pastes copied vector artwork as shape layers. Edit ▸ Paste only
knew the app's own clipboard, so SVG markup on the system clipboard was ignored; turning SVG
into shapes needed an imported footage file and Create Shapes from Vector Layer.

Fix: a new `edit.pasteSvg {svg, name?, comp?}` command builds a shape layer from SVG text with
the same conversion as Create Shapes from Vector Layer (paths, fills, strokes, gradients, clip
masks), above the selected layer with the SVG canvas centred in the comp, as one undo step;
oversized, non-SVG or shapeless text is rejected. In the UI, Ctrl/Cmd+V (and the native Edit ▸
Paste) run it when the system clipboard holds SVG. Copying in the app already replaces the
system clipboard with a note, so SVG there is newer than the app's clipboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:43:18 +02:00
MarekSpodBiedry 238f62606b Graph Editor: Shift constrains Bezier handle drags to one axis (#723) 2026-10-11 11:40:11 +02:00
vangel76 ddcca91d50 #730: Composition ▸ Copy Frame to Clipboard
Problem: After Effects 26.3 copies the current composition frame to the
system clipboard; EffectCraft could only write it to a file (Save Frame As).

Fix: new command comp.copyFrame {comp?, time?, scale?} renders the frame as
Save Frame As ▸ File does and emits Event::CopyImage (pixels not
serialized); the egui frontend puts it on the clipboard after checking the
buffer size. In the Composition menu (translated) and the viewer's context
menu; drivable by id over the control channel / MCP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:33:45 +02:00
vangel76 5661d5111c #729: import STL 3D models (binary and ASCII)
Problem: After Effects 26 imports STL models besides glTF and OBJ; we
refused .stl files, and the Media Browser and import dialog did not list
them as models.

Fix: a pure-Rust STL reader in effectcraft-model (binary and ASCII,
detected from the contents; several ASCII solids and polygon loops;
VisCAM/SolidView and Materialise Magics facet colours as plain
materials; Z-up turned to the model's Y-up; faceted normals from the
winding). Truncated, empty or non-finite input is an error, never a
crash. .stl is now a model extension for import, probing, the Media
Browser, the import dialog and the system report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:32:11 +02:00
vangel76 f9db3afd34 #678: dropped Photoshop files show the Import Photoshop File dialog
Problem: dropping a PSD/PSB file on the window imported it straight away.
The drop handler always passed `importAs` (Settings > Import > Default Drag
Import As) to `file.import`, and the Import Photoshop File form only opens
when `importAs` is missing, so the dialog that File > Import > File...
shows never appeared for drops.

Fix: a drop that includes a Photoshop file leaves `importAs` out, so the
form asks (other drops keep passing the preference). For dropped files the
form's Import Kind starts at Default Drag Import As; File > Import keeps
starting at Composition. The engine already falls back to the preference
for `drag` imports without `importAs`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:28:18 +02:00
vangel76 3b7f1ace28 #473: Cryptomatte Selection keeps quoted names with commas whole
Problem: selection_hashes split the Selection on every comma before
stripping quotes, so a name containing a comma (Blender allows a material
called `Green, Glossy`) was cut into two names that match nothing, quoted
or not; only its `<hex>` hash selected it.

Fix: a small tokenizer splits at commas outside quotes. A name in double
or single quotes stays whole, a backslash takes the next character
literally (`Green\, Glossy`), and unquoted lists, wildcards and `<hex>`
hashes parse as before. The CPU effect and the GPU path share the function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:25:24 +02:00
vangel76 00383eed99 #710: Timeline ruler labels frames when Time Display is Frames
Problem: with the project's Time Display Style set to Frames, the
current-time display showed frame numbers but the Timeline ruler kept
labelling its ticks in seconds:frames (`00:15f`). The label format was
hard-coded and never read the project settings.

Fix: a `ruler_label` helper prints the frame number in Frames mode the
same way the current-time display does (`{:05}`, counted from Start
Numbering Frames At), and keeps seconds:frames for the other styles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:21:30 +02:00
vangel76 b47f02eaba Contributors: add vangel76 (Valentin Klein)
Credit me by real name and my public GitHub profile name in About > Contributors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:11:25 +02:00
Brandon Thomas a482981c57 CI: run FreeBSD and Windows ARM64 checks nightly and on release, not per PR
They ran on every push to main and on any PR touching Cargo.toml/Cargo.lock,
which is nearly every PR. See craftrules release/playbook.md §2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:03:00 -04:00
Mara Johnson c88d2b3a7f fix(render): prevent render hang and system lag (#2081) 2026-10-11 00:36:38 -04:00
Nigel Ihza Firdausy f179b2f2d2 docs(effects): update CC Cylinder parameter count 2026-10-11 11:08:10 +07:00
Nigel Ihza Firdausy 4c81cf04f2 fix(effects): use portable WGSL discriminant guard 2026-10-11 11:07:36 +07:00
Nigel Ihza Firdausy f29436580e fix(effects): implement XYZ axis rotation for CC Cylinder on CPU and GPU 2026-10-11 11:06:51 +07:00
Nigel Ihza Firdausy 31370e6144 fix(viewer): create a composition when footage is dropped on empty viewer 2026-10-11 11:02:26 +07:00
tylerjenningsw 42d8120147 Settings: allow custom UI scale percentages (#682)
Settings > Appearance > UI Scale offered only 75, 100, 125, 150, 175 and
200 %, so a size between 75 and 100 wasn't possible. It is now the shared
numeric field (Kind::Int(75, 200, "%")): type or drag any whole
percentage. The engine already stored the scale as a clamped integer and
the UI applies it with set_zoom_factor, so the range, the default, live
preview, Cancel and saved preferences are unchanged; the preset list
goes.

Numeric settings now apply a typed number once it is entered (Enter or
leaving the field), not at every keystroke: typing 85 used to apply the
partial 8, clamped to 75, and resize the whole interface before the 5.
Dragging still applies as it moves.

Tests: the UI Scale test types 85 a key at a time (nothing applies and
the field keeps the keyboard until Enter), Cancel, 50 and 300 clamp to
75 and 200, 90 with OK, and a new app with the saved preferences starts
at 90 %. The GPU Information transaction test types 125.
2026-10-10 23:47:27 -04:00
Wladmir Júnior 8a9b46ba71
pt-BR: translate the panels below the menu bar 2026-10-11 00:44:05 -03:00
tylerjenningsw 5c1484b6e7 Shapes: the Gradient Editor uses the shared colour picker; Escape closes only the picker (#612)
The stop colour now uses widgets::srgb_color_button, the After Effects-style
picker every colour input opens (#638): hex field, HSB/RGB fields, OK and
Cancel, sRGB as the project stores colours. The stop keeps its alpha.

The dialog Escape handler only stepped aside for egui popups, and this
picker isn't one, so in a modal dialog Escape with the picker open closed
the dialog too. The picker now marks the pass it was open in
(color_picker::open_recently), and the handler leaves Escape to it: Escape
restores the colour the picker opened with and closes only the picker.

Tests: ui_gradient_editor clicks inside the picker's colour field over the
modal editor, types a hex code, closes the picker by clicking outside it
and saves the stop; a new test checks that Escape and the picker's Cancel
restore the stop's colour and keep the editor and its other edits. That
test fails without the Escape change. ui_color_hex still passes.
2026-10-10 23:43:40 -04:00
Vikram Bhaskaran a81c98d33c
Match After Effects non-legacy Brightness & Contrast.
The modern curve used g = 2^(2c), so Contrast 100 crushed 0.25 to about 8/255 while AE is about 40/255. Brightness is now a pin-black-and-white power toward white or black, then a milder S-curve g = 1 + 0.53 c, fitted to kasper9n's AE grey ramps. Legacy linear mode is unchanged. The GPU kernel matches the CPU.
2026-10-11 14:31:10 +11:00
Dennis M. Francis fe5c2b29b0 Tests: six engine tests no longer assume the working directory's drive on Windows
Six effectcraft-engine tests failed on a Windows machine whose working directory
and home folder are on different drives:

- rq_tests::add_configure_render and tests_stubs::{add_output_module_encodes_twice,
  pre_render_imports_and_replaces, post_render_actions_import_and_set_proxy,
  log_notify_and_overflow_settings} used "/tmp/..." output paths. On Windows that
  has no drive letter, so the render queue resolves it against the home folder's
  drive (C:) while std::path::absolute, which the tests compared with, uses the
  working directory's (D:). They only passed where both are on one drive, as on CI.
  They now build their outputs with tests_stubs::tmp_out, a path under the OS temp
  directory that is absolute on every platform and unchanged on Unix.
- footage_check::relative_footage_is_found_next_to_the_project compared strings:
  Windows joins the stored "media/clip.png" onto the project's folder and leaves
  mixed separators. It now compares the paths by components.

Test-only: the render queue stores the path it writes to, so the product is
consistent. effectcraft-engine --lib: 497 passed (6 failed before).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 20:26:20 -07:00
Dennis M. Francis a6945de659 #596: GPU Information names the adapter, or why the GPU compositor is off
Settings > Previews > GPU Information was fixed text ("Compositing: CPU, N
threads... Display: egui on wgpu."): it never named a GPU, even while the GPU
compositor was running, so a card looked undetected. The System Report's GPU
line only said whether the compositor started.

sysinfo::gpu_summary builds the graphics lines from the session: the compositor's
adapter, or the reason compositing is on the CPU, and the adapter the window
itself got. The dialog uses it.

This does not explain why a particular card isn't used for compositing; that
needs the reporter's System Report (Help > System Report).

Tests: engine gpu_summary_names_the_adapter_or_says_why_there_is_none and UI
gpu_information_names_the_adapter_and_why_the_gpu_is_off (fails with the old
text). The six engine tests that fail on this Windows machine (relative footage
and render-queue paths) fail on main too.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 19:33:06 -07:00
Vikram Bhaskaran 9ce7d12721
Pixel Motion Blur: synthesize the blur from motion vectors and honour Vector Detail (#535).
Time > Pixel Motion Blur fetched the layer at its shutter times and averaged the decoded
frames as they were, so fast motion rendered as doubled copies and the accepted Vector
Detail parameter changed nothing. Build a TimewarpPlan instead: every shutter instant is
decoded between the two source frames it falls between along a measured motion field — the
same block-matching and in-between synthesis Time > Timewarp uses, with a search radius
wide enough for full-frame displacements and the vector grid tightened from Vector Detail
0 (one vector per 32 px) to 100 (one per 2 px) — and the instants average into the blur.

CPU and GPU share the plan (fx_time renders it with its Timewarp step loop, declining to
the CPU when there is no host), so the existing GPU parity cases for the effect compare
the new path. Falling back to the old average is kept for layers without a time host.

A render integration test moves a textured block 12 px between source frames, renders
the straddling shutter, and asserts the output is neither the still nor the plain frame
overlay, lays trail coverage along the block's path, and changes with Vector Detail;
without the synthesis it fails at the overlay assertion.
2026-10-11 13:32:13 +11:00
tylerjenningsw ec1bc8cc99 Shapes: edit gradient color and opacity stops (#612)
The Timeline's "Edit Gradient…" on a gradient property (Gradient Fill /
Gradient Stroke ▸ Colors, Gradient Overlay) only painted its label. It now
opens a Gradient Editor (prop.editGradient): opacity stops above a preview
bar and colour stops below. Click to add a stop (it takes that track's
own value there), drag or type a Location to move it, set its opacity or
colour (egui's colour picker), Delete removes it (never the last of a
track, never while typing). The tracks stay in order while editing and the
selection follows its stop.

The editor works on a draft. OK sets the property once with prop.set at
the composition time it opened, snapped to the frame, with
timeBase: "comp" (offset and stretched layers get the key where it was
opened): one undo step, and nothing at all when the draft is unchanged, so
OK adds no keyframe. Cancel and Escape change nothing; Escape with the
picker open closes only the picker. On an animated gradient only the
keyframe at that time changes; when its stop counts no longer match a
neighbouring keyframe's (gradients only blend with equal counts) the
editor says so. The draft is saved in the typed form, so one colour stop
and each stop's RGBA survive.

prop.editGradient checks its target is a gradient property before the UI
opens anything, so agents get an error for a wrong one. New Gradient
color_at / opacity_at sample one track each (sample() multiplies opacity
in, which would apply it twice to a new stop).

Tests: ui_gradient_value (fails on main: the value had no automation id),
ui_gradient_editor (11), gradient_editor and keyframe unit tests.
2026-10-10 22:22:13 -04:00
skylar taylor-barrick 9a7d3e7699
Merge pull request #689 from skylartaylor/codex/ui-suite-integration-20261010
Share docking transactions and preserve viewer lifecycle
2026-10-10 20:10:35 -06:00
skylar taylor-barrick 83ae6218ef UI.9: retain shared docking across upstream workspace changes 2026-10-10 19:25:49 -06:00
Vikram Bhaskaran fa733bfb04
Effects: text and shape layers run their stack over the whole layer region (#659).
An authored layer's effect buffer spanned only its painted content — the glyph
bounding box for text, the drawn bounds for shapes — while the size the stack
measures from (effect_bounds) is the whole comp-sized layer rectangle. Tiling
and displacement effects computed correct output for pixels outside the painted
rectangle and then wrote them into the buffer that was too small, so CC Tiler
repeated only within the text box and CC Smear lost its liquid trail at the
glyph edge (#658). Buf::cover now grows the stack's input to the layer's
rectangle with transparent margins — what a sized source's buffer already is —
applied by run_effect_stack whenever an enabled video effect will run, and
natively on the GPU by reallocating and copying the texture. The placement and
the sized-source paths are unchanged; two regression tests (CC Tiler spans the
region, CC Smear trails past the text bounds) fail without the growth.
2026-10-11 12:25:29 +11:00
Brandon Thomas f953cd2855
Release prep: 0.8.0 and a Linux riscv64 release build (#669)
* Release prep: bump to 0.8.0 and add a Linux riscv64 release build

- release.yml: new `linux-riscv64` job, ported from GridCraft. It cross-compiles on
  ubuntu-24.04 against riscv64 libraries from ports.ubuntu.com (the Linux job's X11/Wayland/GL
  -dev packages plus ALSA), ships effectcraft-<v>-linux-riscv64.tar.gz, and smoke-tests
  effectcraft-cli under qemu-riscv64. The draft-release job now needs it, so its tarball is
  uploaded and covered by SHA256SUMS.txt.
- packaging/linux/package.sh: cross-compiles when CROSS_ARCH/CROSS_TARGET/CROSS_COMPILE are set
  (cargo --target, the cross strip, the EMULATOR smoke test), as GridCraft's does.
- Version 0.7.0 -> 0.8.0 (cargo xtask version set), Cargo.lock refreshed.
- README and docs/releasing.md list the riscv64 tarball.

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

* Fix FreeBSD CI: pointer_wrap test harness, wasm32 target, clippy as_chunks

- pointer_wrap tests: the harnesses dropped run_ui's FullOutput with the font atlas upload still
  in textures_delta, and epaint 0.36's drop check panics on that in debug builds. Every
  pointer_wrap test failed on every platform; FreeBSD is the only CI that runs them. Clear the
  delta, as the other UI tests do.
- freebsd.yml: install wasm32-unknown-unknown in the VM. Without it the host plug-in test's build
  of examples/plugins/posterize-bands failed (E0463, no std) and the test skipped silently; now
  it builds and runs.
- media/convert.rs: chunks_exact(2) -> as_chunks::<2>() (clippy chunks_exact_to_as_chunks,
  new in Rust 1.99); same pairs, same dropped remainder.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:08:18 -04:00
Vikram Bhaskaran 983a2f0ebf
Tests: an imported animated GIF plays its frames in a wired session (#651).
Importing an animated GIF as a movie (bfbdadd) made it preview and export
again, but nothing pinned it: the item must probe as a GIF movie and its
rendered frames must follow the file's own per-frame delays, not hold one
still. Add a host integration test that imports a red/green/blue GIF and
asserts each third of its duration renders the matching colour, so a
regression to still-frame import is caught.
2026-10-11 11:26:26 +11:00
BFlat 4b58cd2eb7
Merge pull request #676 from storytold/claude/community-pr-review-lwtrlr
Issue fixes: drag render pile-up, CC Smear/Tiler past text bounds, relative footage paths, GIF playback test
2026-10-11 07:16:00 +07:00
Claude ceb116d6d2
Issue fixes: #647 nested frame renders while dragging, #659 CC Smear/Tiler past text bounds, #652 relative footage next to the project, #651 GIF playback test 2026-10-10 23:55:23 +00:00
BFlat 2ec87e921f
Merge pull request #675 from storytold/claude/community-pr-review-lwtrlr
Community batch: pointer-wrap test texture deltas, device-lost dialog
2026-10-11 06:55:07 +07:00
Claude f3fe25fd67
Import: test that an animated GIF imported from disk plays (#651)
Animated GIFs came in as one still frame in 0.7.0; bfbdadd3 (after that
release) probes them as movies. This end-to-end test imports a GIF file
through the wired session into a comp and checks the layer shows each of
its frames at its time, the path the viewer and exports share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 23:50:08 +00:00
Claude 8e14c00eaf
Project: footage saved with a relative path is found next to the project file (#652)
file.import stores a path as given, so footage imported by the CLI or an
agent as `clip.mp4` was saved relative, and opening the project looked it
up from the working directory. The CLI, started in the project's folder,
found it; the desktop app, started from the Finder, Explorer or a launcher
in another folder, showed and rendered neither its picture nor its sound,
while the Project panel still listed the saved duration.

On open, a relative footage path that doesn't exist from the working
directory but does next to the project file is resolved there (After
Effects also looks for footage relative to the project). Paths that exist
as they are, and absolute ones, are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 23:41:43 +00:00
Claude cb37c2511c
App: a lost graphics device tells the user why the window closes and where the recovery copy is (#591)
Ported from #673: a native error dialog names the auto-saved recovery copy before the window
closes; agent runs through the control channel skip it. The UI calls Hooks::device_lost once.

Co-Authored-By: Dennis M. Francis <44986763+didpublishing@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 23:36:52 +00:00
Claude 2aefe3e644
Effects: CC Smear and CC Tiler on text and shape layers reach past the content's box (#659)
A text or shape layer's effect bounds are comp-sized (as in After Effects),
but its buffer surrounds only its content, and each effect grows the buffer
itself when it needs room. CC Smear and CC Tiler never did, so a smear was
cut off where the glyphs end and tiles filled only the text's box.

CC Tiler now covers the whole layer rectangle; CC Smear grows by how far it
moves pixels (Reach x From->To), at most to the layer bounds, so footage
layers, whose buffer is their bounds already, are unchanged. CPU and GPU
alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 23:35:47 +00:00
Claude 7fd1d09088
Merge pull request #671 from vbasky/fix/pointer-wrap-textures-delta 2026-10-10 23:31:17 +00:00
Claude d247b98b95
Viewer: dragging a layer no longer piles up frame renders until memory runs out (#647)
Every viewer request spawns a job on the frame pool. A pool thread blocked
inside a render (waiting on a parallel loop's join) runs other pool work
meanwhile, rayon's injected jobs included: the spawn of the next request
started a whole frame render on top of the waiting one. Dragging makes a
new revision, and a new viewer frame, every UI frame, so renders nested
without bound on every thread, each holding its buffers (a large image's
full-resolution copies), until all memory was used. Holding the layer still
makes no new revisions, which is why only moving it did this.

A spawn now drains the queue, and one that lands inside a render on its
thread returns at once; the render below takes the queued jobs when it is
done. Each pool thread renders one frame at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 23:29:44 +00:00
BFlat 21681e9590
Merge pull request #674 from storytold/claude/community-pr-review-lwtrlr
Issue fixes: GPU device loss, motion-path vertex tools, rename focus, macOS sheet dialogs, MCP file confinement, desktop shortcut
2026-10-11 06:25:56 +07:00
Vikram Bhaskaran da53c828dd
Credit Vikram Bhaskaran by real name and display name. 2026-10-11 10:20:10 +11:00
Vikram Bhaskaran e7735f0875
Tests: pointer-wrap frames drain egui's texture deltas instead of dropping them.
The Scrub helper and the real-properties test discarded FullOutput, so the
first pass' font-atlas texture delta panicked on drop in debug builds —
which is how the FreeBSD CI gate failed. Consume the delta the way the
rest of the crate's egui tests do.
2026-10-11 10:20:10 +11:00
skylar taylor-barrick 3dba472943 UI.9: share docking transactions and preserve viewer lifecycle
Preserve legacy selection repair, modifier routing, viewer lifecycle and workspace persistence through the shared docking integration. Qualify the complete workspace gate by refreshing the 18 stale Audio Spectrum hashes for the existing linear renderer, with an independent synthetic-tone regression.
2026-10-10 17:14:12 -06:00
Claude 1ac09c7fa3
Issue fixes: #591 GPU allocation and device loss, #553 motion-path vertex tools, #488 rename focus, #511 macOS sheet dialogs, #500 MCP file confinement, #343 desktop shortcut 2026-10-10 23:06:04 +00:00
BFlat ca89a975c7
Merge pull request #668 from storytold/claude/community-pr-review-lwtrlr
Community batch: font folders, empty-Timeline drop, AE auto-Bezier tangents, clippy 1.95
2026-10-11 06:05:39 +07:00
Claude 8fb4281632
MCP: --automation-read-root / --automation-write-root confine the agent's file access (#500)
`effectcraft-cli mcp` had no way to keep an agent's file access to a folder;
the flags were unknown options. Both now take a folder (repeatable, as in
PhotoCraft): with either given, files are read only under a read or write root
and written only under a write root (none: no writes). Defaults are unchanged.

automation::confine::Confinement checks every engine command the backend runs
(execute_command, command_run, open/save_project, each engine.batch step), in
both headless and bridge mode, before it runs: the file parameters of the
commands that read or write files must be absolute paths inside the roots
(symbolic links resolved through the longest existing ancestor, `..` refused),
as must any other absolute path in a command's parameters. Commands that write
where they aren't told are checked against the target they would write: Save /
Increment and Save the open project's file, renderQueue.render every queued
output (renderQueue.list). Scripts, Execute File, Reveal in Finder, Open Recent
by index, `$N` file-path references in a batch, render_frame/screenshot paths
outside a write root and the bridge's UI-driving tools are refused while
confined. docs/mcp.md describes it.

Test: a confined headless server opens and saves inside its roots and is refused
outside them, for a relative path, `..`, a batch step, a `$N` path, Save to a
read-only root's project, the Render Queue's default output there, a symbolic
link out of the write root, and scripts; the unconfined server still writes
anywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 22:54:57 +00:00
Claude aaf23aa5f8
macOS: File menu dialogs open as sheets, so a drag over the window can't abort the app (#511)
The file dialogs were blocking rfd::FileDialog calls made inside eframe's frame,
that is inside winit's event handler. On macOS the dialog runs a nested modal
event loop there; a file dragged over the window while it was open reached
winit's handler again, which refuses re-entry with a panic across the
Objective-C boundary, aborting the app (Import with Cmd+I, then a drag).

Hooks gains file_dialog_async: a host that sets it shows a command's file dialog
(Import, Open, Save As, Save Frame As, presets, guides, templates, Collect Files,
Relink...) without blocking, and the command runs with the answer on a later
frame (menus::dialog_answered, drained at the start of each frame); a cancelled
dialog runs nothing. The macOS app implements it with rfd::AsyncFileDialog, a
sheet on the window answered when it closes, outside the frame. Other platforms
keep the blocking dialogs (winit queues their re-entrant events), as do the few
panel buttons that pick a file in place (Settings paths, shortcut import/export,
effect file pickers).

Test: with a non-blocking hook, Save As and Save Frame As return at once without
the blocking dialog, the save happens when the answer arrives from another
thread, and a cancelled dialog writes nothing. The macOS sheet itself needs a
manual check (Cmd+I, drag a file over the window, pick or cancel).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 22:54:56 +00:00
Claude d6894249fa
Inline editors: the whole name is selected only as a rename opens; arrows then move the caret (#488)
widgets::keep_focus (Project panel rename and comment, Render Queue, Essential
Graphics and Timeline fields) selected the whole text whenever it saw its field
without the keyboard focus, not just on the frame the editor opened. Anything
that took the focus for a frame (an input method, another handler) then
re-selected the name, so the arrow keys seemed to do nothing and Backspace erased
the whole name, as reported on Linux. The editor now remembers the pass it was
last shown: it selects all only when it opens and otherwise just takes the
keyboard back with the caret where it was.

The harness does not reproduce the report's Linux behaviour itself (arrows reach
the field there); the tests cover the path that did: a unit test drops the focus
for a frame and checks the caret survives (and that reopening selects all), and a
kittest renames a Project item and a Timeline layer with Left / Right / Up /
Down / End and Backspace, editing part of each name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 22:54:56 +00:00
Claude 6a3d5c6703
Viewer: the Pen, Add, Delete and Convert Vertex tools edit motion paths, with pen pointers (#553)
Motion-path hits were only handled for the Selection tool, so the Pen slot's
tools did nothing on a layer's motion path, and every Pen slot tool showed the
same crosshair. As in After Effects, on a motion path:
- Add Vertex (and the Pen) add a Position keyframe at the clicked frame dot,
  with the path's value there (prop.addKey), so the path keeps its shape;
- Delete Vertex removes the clicked keyframe (keys.select + keys.delete);
- Convert Vertex switches it between Linear and Auto Bezier spatial
  interpolation (keys.interpolation {spatial}).
motion_path now reports its frame dots for the hit test. Over the viewer the Pen
slot tools draw their own pen glyph (Pen, Add, Delete, Convert, Mask Feather) at
the pointer in place of the system cursor, and the Pen shows Add Vertex's pen over
a mask, shape or motion path it would add a point to.

Test: ui_viewer clicks a two-key motion path with each tool and checks the added
key, its spatial interpolation both ways, its removal, and the pointer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 22:54:56 +00:00
Claude 3c063b5075
GPU: a failed allocation reported as an internal error, or a lost device, no longer crashes the app (#591)
On Adreno (Windows ARM64) a failed vkAllocateMemory surfaces as an internal
ERROR_INITIALIZATION_FAILED, not out-of-memory. Gpu::within_memory only scoped
OutOfMemory, and wgpu's DeviceExt::create_buffer_init maps the new buffer at
creation and panics ("Failed to get mapped range") when the allocation failed,
taking down a frame thread. Buffers with initial contents are now created unmapped
and filled through queue.write_buffer (context::buffer_init), so a failed
allocation is only an invalid buffer and an error for the caller's scope, and
within_memory scopes Internal errors too, discarding the frame for the CPU path
as it does for out-of-memory.

The device was then lost, and egui-wgpu panicked on the main thread staging the
next frame's index buffer on the dead device. Once the host reports the
presentation device lost, the app draws nothing more (no mesh to stage), auto-saves
unsaved changes, keeps the crash-recovery marker so the next launch offers them,
and closes its window.

Tests: a device with a small buffer limit gets an over-limit and an unaligned
buffer_init without a panic and sees the error in its scope; the UI harness
reports a device loss and checks the next frame has no shapes, closes the window
and wrote the auto-save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 22:54:56 +00:00
Claude d6efb795e4
Keyframes: a straight auto-Bezier segment interpolates its chord exactly (#650 follow-up)
Auto-Bezier end keys now carry handles along the path, so a straight two-key move went through
the arc-length table and landed a rounding error off its keys' midpoint (80.00000000000001).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 22:52:36 +00:00
Claude 1c7fcccef2
Packaging: the Windows installer offers a desktop shortcut, on by default (#343)
The MSI only installed a Start Menu shortcut. The wizard's folder page now has a
"Create a desktop shortcut" box (DESKTOPSHORTCUT, 1 by default; DESKTOPSHORTCUT=0
for silent installs) that adds an EffectCraft shortcut to the Public desktop. The
choice is recorded under HKLM\Software\EffectCraft so an upgrade keeps it, and an
upgrade from a release before this option adds none. test-msi.ps1 checks the
shortcut is created by default, kept by upgrades, removed on uninstall and absent
with DESKTOPSHORTCUT=0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 22:30:58 +00:00
Claude 9d689b1a61
Community follow-ups: dedup font files from overlapping folders (#665); empty-Timeline drop also accepts compositions, rustfmt (#662)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 22:29:04 +00:00
Claude 0b91b465df
Merge pull request #650 from KattKnight/auto-bezier-spatial-tangents
# Conflicts:
#	crates/expr/src/tests.rs
2026-10-10 22:28:40 +00:00
Claude 44e3fe5f6e
Merge pull request #662 from Ni-zav/fix/649-empty-timeline-project-drop-s20261010T2145Z-e7a2 2026-10-10 22:28:36 +00:00
Claude 808513155e
Merge pull request #665 from johnstockertutorial-afk/autocut-font-dirs 2026-10-10 22:28:35 +00:00
Claude c9695a49e6
Merge pull request #646 from tylerjenningsw/fix/clippy-1.95-lints 2026-10-10 22:28:35 +00:00
BFlat d024a16cf0
Merge pull request #667 from storytold/claude/community-pr-review-lwtrlr
Fix community-reported issues: Light theme contrast, macOS Sync with System, Timeline eyedropper, DX12 kernel fallback
2026-10-11 05:21:54 +07:00
Johnstocker b2d8abdd1c fonts: extra font folders from EFFECTCRAFT_FONT_DIRS
A tool can hand EffectCraft its own font library (a path list) without
installing the fonts system-wide; the folders are scanned like the
system ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 05:10:45 +07:00
Claude e615b06256
Merge branch 'fix/f6': community issue fixes
# Conflicts:
#	crates/gpu/src/tests.rs
2026-10-10 21:59:26 +00:00
Nigel Ihza Firdausy 2925d9605a fix(timeline): ignore non-media data items dropped onto empty timeline 2026-10-11 04:57:32 +07:00
Nigel Ihza Firdausy a9f4d92d5a test(timeline): avoid overlapping harness borrows in multi-drop regression 2026-10-11 04:55:57 +07:00
Nigel Ihza Firdausy bd906d3ee9 fix(timeline): create composition when footage is dropped into an empty timeline (#649) 2026-10-11 04:52:24 +07:00
BFlat 62ec101a41
Merge pull request #655 from storytold/claude/community-pr-review-lwtrlr
Fix community-reported issues: multi-layer edits, stacked-panel tabs, 32 bpc blend modes, and more
2026-10-11 04:49:01 +07:00
Claude 9495810ff5
Tests: the near-plane check tests for the flip, and loopOut compares within float tolerance (#572, #456 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 21:48:52 +00:00
Claude ef96419bec
Appearance: the Light theme repaints Effect Controls headers, Timeline wells and the expression field (#644)
Effect Controls painted effect headers with fixed dark fills (#2a2a2a,
#2f3a52 when active) under the theme's text colour, so on Light the effect
names were dark on dark (1.1:1). The Timeline kept the selected layer's
time-graph row, the A/V and switch wells, the time navigator, the work area
bar and the expression field on fixed dark fills too.

Those surfaces are now theme tokens (fx_header, fx_header_active,
tl_graph_selected, switch_well, tl_nav_bar, tl_work_area_bar, expr_bg,
expr_text) with the dark values unchanged and light ones for Light. The
expression editor's syntax colours come from the theme as well: on the
light field a darker set keeps every category, enabled/disabled
expressions, matched brackets and line numbers at 4.5:1 or better
(contrast test, with a negative control showing the dark set fails there).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 21:29:46 +00:00
Claude 82788b7a68
Multi-layer edits: only a real drag offsets the other layers; the Timeline stopwatch stays per property (#355, #491 follow-up)
A typed value committed by clicking elsewhere could be applied as an
offset, because dragged_id() is set on the press that commits it; a scrub
now needs a decided drag. In the Timeline, clicking a stopwatch or the
keyframe button acts on that layer's property only, as in After Effects
(the Properties panel and keyboard shortcuts act on every selected layer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 21:28:25 +00:00
Claude 3f03e025ac
Merge branch 'fix/f7': community issue fixes 2026-10-10 21:27:44 +00:00
Claude 657f34d40b
Layers: with several layers selected, value edits, stopwatches and viewer Scale / Rotate change them all (#355, #491)
With several layers selected, After Effects applies a property edit to the same property of
every selected layer; we changed only the layer whose row was edited (the Properties panel
showed only the first selected layer), so a stopwatch switched on one layer and later moves
of the others had nothing to keyframe (#355), and scaling or rotating a selection changed
one layer (#491).

- Engine: `prop.set`, `prop.toggleAnimation` and `prop.toggleKey` take `selected: true`:
  the change also goes to the same property (by match path, `transform/scale`,
  `effects/<effect>#n/<param>`) of every other selected, unlocked layer whose value has the
  same kind, in one undo step (a scrub keeps folding into it through its `merge` key). With
  `offset: true` (a scrub) each layer changes by the same amount from its own value; a typed
  value sets them all. Position with Separate Dimensions is handled per layer.
- The Timeline, Properties and Effect Controls panels pass `selected` (and `offset` while a
  value is dragged). The Properties panel heads "(N) Selected Objects" (zh-CN / zh-TW / ja /
  uk rows).
- Viewer: a bounding-box handle drag scales every selected layer by the same ratio about its
  own anchor point; the Rotation tool turns them all by the same angle (one undo step).

Tests: engine test (stopwatch, scrub offset, typed value, keyframe button, one undo step,
unselected layers untouched); kittests: the Properties panel with three layers selected
(header, stopwatch on all three undone in one step, a scrub moving all three by the same
amount) and a handle drag scaling two selected layers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 21:23:36 +00:00
Claude 03598229ba
Viewer: 3D layer outlines and the axis gizmo clip at the camera instead of drawing long lines (#572)
The viewer projected a 3D layer's box, its axis gizmo and its Extended Viewer outline
through the camera with a plain perspective divide. A point at or behind the camera
(w ≤ 0) flips through infinity, so a layer turned or moved so that part of it reaches past
the camera drew long lines across the viewer, and its handles and hit area flipped too.

- `layer_quad` (handles, hit tests, marquee) is None for a layer reaching past the near
  plane, so nothing is placed or picked on a flipped quad.
- `layer_outline` clips the box to the part in front of the near plane (Sutherland-Hodgman
  in homogeneous coordinates); the selection outline and the Extended Viewer use it.
- The 3D axis gizmo's arrows are clipped the same way (`project_segment`).

Tests: a box crossing the near plane keeps only its front part, one wholly behind draws
nothing; gizmo segments are cut at the plane and never flip.

Note: the reporter's exact setup (ellipse, X/Y Rotation, Orientation) wasn't reproduced
in a headless probe with the default camera, where the layer stays in front of it; this
fixes the projection fault that draws such lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 21:23:23 +00:00
Claude 56fd5d52b1
Dock: a panel dropped on a stacked panel becomes a tab in its frame, as in After Effects (#302)
A stack (the Default workspace's right column) held one panel per frame. Dropping a panel
onto the middle or the tab strip of a stacked panel inserted a new stacked frame below it,
and dropping on its top or bottom edge split the whole column.

A stacked frame now holds tabs (`StackEntry { panels, active, open, height }`):
- Center / tab strip drops add the panel as a tab of the anchor's frame and show it.
- Top / Bottom drops add a frame of its own above / below the anchor's frame only.
  (Left / Right still split the column: a frame can't hold a side-by-side split.)
- Clicking a frame's shown tab (or its header bar) expands / collapses it; another tab
  shows that panel. Closing a tab keeps the frame; its last tab removes it.
- Saved workspaces from before (`{"panel": …}` per frame) still load.

Tests: dock unit tests (Center / Top / Bottom on a stack, insert_tab, close, old saved
entries); a kittest dragging Effect Controls onto Properties makes a two-tab frame whose
tabs switch the shown panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 21:22:38 +00:00
Claude e2a47495eb
GPU: an effect kernel the shader compiler rejects no longer turns the GPU compositor off (#613)
GpuContext::new built every compute pipeline up front and failed as a
whole when one didn't build. On DirectX 12, FXC (wgpu's default D3D shader
compiler) aborts on the `pointwise` effect kernel, so the whole app fell
back to CPU compositing.

The compositor's own kernels are still required, but the effect kernels
(`pointwise`, the blur/glow/shadow passes and every fx_* kernel) are now
optional: one that fails to build is logged and listed
(GpuContext::unavailable_kernels), and a GPU effect chain that dispatches a
missing kernel reports it (Enc::take_missing) so those effects render on
the CPU while everything else stays on the GPU.

This does not make FXC accept `pointwise` (that needs a Windows/FXC box to
bisect); it limits the damage to the effects using it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 21:17:18 +00:00
Katt 6240ed646e Keyframes: auto-Bezier spatial tangents follow After Effects
Measured against After Effects 26.3 with the original
examples/ae-spatial-tangents.jsx (three paths, ten keys, even and uneven
timing): an auto-Bezier key's out tangent is (next - previous) / 6 and
the in tangent its mirror, whatever the key times; an end key uses its
one neighbour, (neighbour - key) / 6. EffectCraft gave end keys none and
scaled middle tangents by segment length, so paths with auto-Bezier keys
ran up to 28 px from AE's. All ten tangents now match; path samples are
within 0.05 px.

Tests that relied on end keys having no tangent now expect AE's: Spatial
Bezier keeps auto keys' handles (linear keys still get a third), the
motion-path handle is drawn at a sixth, and a straight loopOut path is
compared within 1e-9. docs/fidelity records the method and scores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:44:49 +05:30
Claude 1bcfe6912d
Timeline: colour properties get an eyedropper after their swatch, as in After Effects (#633)
A shape's Fill and Stroke Color (and every other colour property) in the
Timeline only had a swatch that opens the colour picker; After Effects puts
an eyedropper next to it to sample a colour from the Composition panel.
The Timeline now shows that eyedropper (`timeline.prop.<uid>.eyedropper`),
reusing Effect Controls' viewer pick: click it, then click in the
Composition panel, and the property is set to the sampled colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 21:11:53 +00:00
BFlat 3b4036f536
Merge pull request #648 from storytold/claude/community-pr-review-lwtrlr
Land #641: GPU precomp layers, with follow-ups
2026-10-11 04:03:47 +07:00
Claude d3b26f2a65
Appearance: Sync with System keeps following macOS appearance changes after startup (#642)
apply_visuals pinned egui's theme preference to Light or Dark on every
apply. egui syncs that to the native window (ViewportCommand::SetTheme),
winit then gives the NSWindow an explicit Aqua/DarkAqua appearance, and a
window with its own appearance no longer receives the system's
ThemeChanged events, so Sync with System froze on the first frame's
appearance.

Our visuals now go into both of egui's styles (light and dark), and the
preference is System while the appearance mode is Sync with System, so the
window keeps following the OS; fixed Light / Dark modes still pin it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 21:00:37 +00:00
tylerjenningsw 75cc264ccf Lint: clippy's collapsible_match passes on Rust 1.95
cargo +1.95 clippy --workspace --all-targets --release -- -D warnings
stopped on seven `if` blocks that clippy wants folded into their match
arm (psd, text, effects x2, pdf x2, render). Each becomes a match guard.
The behaviour is unchanged: when a guard is false the value falls through
to arms that can't match it and then to `_ => {}`. The one exception is
the PDF PostScript calculator, where a `{` past the nesting limit would
fall through to the operator arm; it keeps its own no-op arm, so it is
still skipped rather than pushed as an operator.
2026-10-10 16:54:16 -04:00
Claude ecb2cf1d32
Blend modes: classic modes fade with opacity and HDR modes keep over-range values, as in After Effects (#494)
Measured against After Effects at 32 bpc:

- Classic Color Dodge, Classic Color Burn and Classic Difference at partial opacity: AE
  fades the source toward the mode's neutral colour (0 for Dodge and Difference, 1 for
  Burn) by the opacity and applies the classic formula at full strength (b / (1 − k·o),
  |b − k·o|, 1 − (1 − b) / (1 − k·(1 − o))). We mixed the full-strength result by opacity:
  up to 45/255 off on ordinary 0–1 footage.
- Overlay, Exclusion, Luminosity, Color Burn and Classic Color Burn keep their plain formula
  for values above 1 (HDR passes). They were missing from `supports_hdr`, so their inputs
  were clamped to 0–1 (Exclusion was up to 208/255 off); Classic Color Burn also clamped
  its result at 0, where AE keeps negative values.

The CPU blend (color::blend_pixel) and the GPU kernel (common.wgsl) change together.

Tests: the reporter's formulas for each case (blend.rs); the CPU/GPU parity test of every
blend mode at every bit depth (over-range values, 70 % opacity) passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 20:51:24 +00:00
Claude 1c9b5f1828
Graph Editor: the speed graph of a curved motion path is smooth, not jagged (#456)
Spatial properties move along their path by arc length: a table of 96 chords per segment,
read back by linear interpolation of the curve parameter. Within each cell that mapping is
linear, so the speed (the Graph Editor's speed graph, a central difference of the
position) jumped at every cell boundary and was off by up to ~2 % on a curved path: a
sawtooth over a constant-speed move, and a slight wobble in the motion itself.

The table now holds Gauss-Legendre arc lengths, and `at_length` refines the cell's linear
guess with Newton steps on the true arc length (|B'(u)| as the derivative), so position and
speed follow the path exactly.

Test: linear keys along a strongly curved path give a constant speed (within 0.1 %) at
every sample; the old mapping was off by 2 %.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 20:42:34 +00:00
Claude faffad3a6e
Media: a multi-layer EXR's picture and its EXtractoR / Cryptomatte channels share one decode (#474)
A multi-layer OpenEXR file without an unnamed RGB layer (Blender's compositor File Output)
was decoded twice per frame: `layered_image` decoded every channel to find the picture,
then `MediaPool::aux` decoded the same bytes again into the aux-channel cache for
EXtractoR and Cryptomatte. The first EXtractoR layer doubled the frame time.

The footage path now reads the picture from the aux-channel cache (`cached_layered_image`),
so the decode it makes is the one EXtractoR and Cryptomatte reuse. (Step 1 of the issue;
reading only the picture's channels and keeping half channels as f16 are still to do.)

Test: after a frame of a layered file is decoded, its aux channels come from the cache
without reading the file again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 20:40:50 +00:00
Claude 8c74fce5fa
GPU: a declined precomp falls back for that layer only; the decline warning is once per comp and reason (#641 follow-up)
A nested comp too deep or too large for a GPU texture now takes the CPU
path for its layer, as before #641, instead of failing the whole frame;
only a device failure does. The warning no longer fires every frame
(which pushed every other warning out of the System Report's log), and
names the no-readback case and regions of interest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 20:29:58 +00:00
Claude c773c26281
Merge pull request #641 from MatejKozlovsky/main 2026-10-10 20:29:22 +00:00
BFlat f8b2552d1c
Merge pull request #645 from storytold/claude/community-pr-review-lwtrlr
Fix 28 community-reported issues (crash, security, AE parity)
2026-10-11 03:25:25 +07:00
Claude 5e2c7560d6
Import: only dropped files default to stills; scripts and agents naming a frame still get its run (#431)
The UI behaves as in After Effects (the Import dialog's Sequence checkbox
starts off, dropped files are stills, dropped folders make sequences), but
file.import without sequence or drag keeps importing a numbered still's
run, so existing scripts and agents keep working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 20:15:24 +00:00
Claude 8b42f970b6
Merge branch 'fix/f4': community issue fixes 2026-10-10 20:00:37 +00:00
BFlat fddca8c6d9
Merge pull request #643 from storytold/claude/community-pr-review-lwtrlr
Land community PRs, batch 3, with follow-ups
2026-10-11 02:55:09 +07:00
Claude 8d32d67f0e
Color: Project Settings ▸ Working Gamma 2.2 / 2.4 with no working space, as in After Effects (#495)
After Effects' `app.project.workingGamma` (2.2 or 2.4, ignored with a working space) sets
the curve an unmanaged project uses: at 2.2 the piecewise sRGB curve, at 2.4 a pure 2.4
power curve. EffectCraft always used sRGB, so linear EXRs (Interpret As Linear Light) from
a 2.4 project came out darker in the shadows (0.18 → 0.461 instead of 0.490).

- `ProjectSettings::working_gamma` (default 2.2, serde default for old projects).
- The colour pipe's unmanaged curve is the Working Gamma's (sRGB, or Rec. 709's pure 2.4
  curve): Interpret As Linear Light encodes untagged footage with it and Blend Colors Using
  1.0 Gamma linearizes with it; it is part of the pipe's cache key. The GPU path takes the
  same conversions.
- `file.projectSettings {workingGamma: 2.2|2.4}` (other values are refused, as in AE),
  `app.project.workingGamma` in scripts and a Working gamma choice in Project Settings.

Test: render test with linear 0.051 / 0.18 / 0.5 footage (0.290 / 0.490 / 0.749 at 2.4,
0.250 / 0.461 / 0.735 at 2.2, the values measured in AE), linear blending at 2.4, a working
space ignoring the setting, and the cache key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 19:54:17 +00:00
Claude a220b63bfb
Help: the System Compatibility Report says why there is no GPU and names the window's adapter (#596)
With no GPU compositor the report only said "GPU: none", though the host had recorded the
reason (`Session::accel_note`: the compositor's setup error, a retired GPU preview, a
headless start) and the window's wgpu adapter was known. Users with a capable GPU (#596, an
RTX 4070) couldn't tell why it wasn't used.

The report now carries `gpuNote` (the reason, when there is no compositor) and
`windowGraphics` (the adapter, backend, device type and driver the window draws with, which
the desktop app records when it sets up the GPU), and the text shows
"GPU: none (CPU compositing): <reason>" and "Window graphics: …"; the issue line names the
reason too.

Test: the compatibility report test sets a reason and an adapter and checks the JSON and text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 19:54:17 +00:00
Claude 3effa9a2ea
Timeline: the view follows the CTI past its edges when scrubbing and during playback (#468)
The ruler scrub set the time under the pointer and never moved the visible span, so with
the timeline zoomed in a scrub couldn't go past the edge, and a preview ran the CTI out of
view. As in After Effects:

- zoomed in, a scrub held past either end of the ruler scrolls the timeline that way,
  faster the further past, with the CTI riding the edge (repainting while held);
- during playback, when the CTI leaves the visible span the view pages to start at it
  (not past the comp's end).

Tests: a unit test of the paging and edge speed, and a kittest that holds a scrub past the
right end (the view scrolls, the CTI stays at the edge), then past the left end, then plays
from beyond the view (it pages to the CTI). (#468's Shift+F and black-layer items need
more information and aren't part of this.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 19:54:17 +00:00
Claude 7b38d4d74e
Dock: a click anywhere on a stacked panel's header bar expands or collapses it (#301)
In the right-hand panel stack only the tab (the name plus its padding) answered clicks, so
the rest of the header bar did nothing, a small target for short names like Audio. As in
After Effects, the rest of the strip now toggles the panel too and focuses it. A stack group
holds one panel, so the bar is unambiguous; the panel menu keeps its own click. The bar
registers the automation id `panel.header.<Panel>`.

Test: a kittest clicks near the right end of Preview's header bar (collapses), its middle
(expands) and its panel menu button (stays open).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 19:54:17 +00:00
Claude f440adbb5a
Dock: dragging a divider past a panel's minimum no longer crashes the app (#306)
A split's first side was sized with `f32::clamp(20.min(avail), (avail - 20).max(0))`.
`f32::clamp` panics when its lower bound exceeds its upper one, which happened for any
split with 0 < avail < 40 pt. Dragging the viewer/Timeline divider up takes the area above
it down to 40 pt, so the splits nested there (the right column's vertical split and
others) got about 36 pt and the layout panicked; no guard covers egui's update, so the app
closed with no dialog.

The split size now goes through `split_first` / `clamp_ordered`, which never panic: when
the room is too small for both minimums the lower bound wins, and non-finite sizes fall
back to it. The gutter drag uses the same clamp.

Tests: a dock unit test lays out every workspace in rects too short and too narrow for its
minimums (it panicked before) and checks `split_first` on hostile sizes; a kittest drags the
divider above the Timeline 2000 pt up and 3000 pt down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 19:53:56 +00:00
Claude 1bea11c9f1
Roto Brush: Cmd/Ctrl-drag resizes the brush, and the tool options show its Diameter (#509)
Cause: the Roto Brush and Refine Edge diameters live in the editor state and
the roto.options command sets them, but the UI never did: the Layer panel
only painted strokes and the bottom bar only set the view mode, so the brush
size could not be changed.

Fix, as in After Effects: Cmd-drag (Ctrl-drag on Windows and Linux) in the
Layer panel resizes the active brush (Roto Brush or Refine Edge) without
painting. The circle stays at the press point, its edge follows the pointer,
and the size is shown while dragging. The tools bar shows a Diameter field
(header.roto.diameter) while a Roto Brush tool is active. Both go through
roto.options, and the next stroke uses the new radius. Diameter is in all
four interface catalogs.

Test: roto_brush_ctrl_drag_resizes_the_brush (a 20 px Cmd/Ctrl-drag makes a
40 px brush and paints nothing, the next stroke's radius is half of it, and
the Diameter field is registered).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
(cherry picked from commit b4183c28eea34ba097d7a16153b84fcf7aeea045)
2026-10-10 19:48:25 +00:00
Claude 3ba43b20c7
Effect Controls: file parameters get Choose…, Apply Color LUT asks for its LUT on apply, and text parameters get a field (#514, #473)
Cause: Apply Color LUT's only parameter, `lut`, was ParamUi::Hidden (set only
through commands), so the effect could not be used from the UI. Effect
Controls also had no widget for any ParamUi::Text parameter (only EXtractoR's
channels had one), so Cryptomatte's Selection, the OCIO effects' config file /
file / config names and Colorama's Output Cycle could not be set either.

Fix: a new ParamUi::File { filters } for paths. Effect Controls shows the
file's name (None when empty) and a Choose... button that opens the host's
file dialog with those extensions and sets the path (prop.set, so agents use
the same command). As in After Effects, applying an effect with an empty file
parameter from the UI (Effect menu, Last Effect, drops, Effects & Presets)
opens the file dialog straight away; cancelling keeps the effect without a
file. Apply Color LUT's LUT (.cube) and the OCIO Config File (.ocio) and File
(.cube/.3dl/.csp/.cc/.ccc/.cdl/.spi1d/.spi3d/.spimtx) use it. Text
parameters get a single-line field that sets the value when it loses the
focus (Esc keeps the old text). Saved projects are upgraded on open: a
Hidden or Text parameter whose spec is now a File parameter takes the File
UI and keeps its path (effects::migrate). docs/effects.md regenerated.

This covers #473's first part (the Selection field); picking mattes in the
viewer, the layer popup from the file's metadata, the GPU path and quoted
commas are separate.

Tests: effects hidden_and_text_paths_become_file_parameters (migration); ui
file_and_text_parameters_have_widgets (the LUT is asked for on apply and
inverts red to cyan, the Choose... button picks another LUT, typing into
Cryptomatte's Selection sets it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
(cherry picked from commit 403863f703999d19d3a83d9976f76bdc7577e682)
2026-10-10 19:48:25 +00:00
Claude 40e87709d5
Import: numbered stills import as stills unless the Sequence option is on; dropped folders make sequences (#431)
Cause: SequenceOptions::from_params defaulted `sequence` to true for
file.import, so any numbered still (IMG_0001.jpg) brought its whole numbered
run from its folder, and several picked or dropped stills with a common
prefix became one sequence. Dropped files never asked, and the Import
dialog's "<format> Sequence" checkbox started ticked.

Fix, matching After Effects: file.import takes picked and dropped files as
stills unless `sequence: true` (the Import dialog's Sequence checkbox, which
now starts unticked; Force Alphabetical Order implies it), while the numbered
stills inside a picked or dropped folder still import as sequences
(SequenceOptions::folders), and `sequence: false` (Alt-drag of a folder)
imports a folder's files one by one. Replace Footage, reload, proxies and
render outputs keep detecting their sequence (SequenceOptions::default and
from_params are unchanged), and mediaBrowser.import keeps bringing a numbered
still's run unless `sequence: false`. docs/footage.md and docs/agents.md
describe the new defaults.

Tests: sequence import_takes_stills_unless_asked_for_a_sequence and engine
numbered_stills_import_as_stills_by_default (one dropped still and two picked
IMG_ stills stay stills, a dropped folder makes one sequence, the checkbox
brings the run); the existing sequence tests pass `sequence: true`; ui
import_dialog_offers_the_image_sequence_option (unticked by default: a still;
ticked: the run) and a_dropped_folder_imports_as_one_sequence_with_sequence_interpretation
(two dropped frames: two stills; the folder: one sequence).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
(cherry picked from commit aa3c31db9ab3a774ee2f8ba867e1bafc1fce7aba)
2026-10-10 19:48:25 +00:00
Claude 86d47dc3fe
Snapping and Align: shape layers meet at their visual edges, with no gap (#352)
Cause: render::content_bounds (used by snapping's targets and source, Align,
Distribute, the viewer's bounding box and handles) took shape layers' bounds
from draw_bounds, the rasteriser's safety rectangle: 1 px of anti-aliasing
padding around fills and width x miter limit / 2 + 1 around strokes. Two
squares snapped edge to edge therefore stayed 2 px apart, or much more with
strokes.

Fix: shapes::content_bounds uses visual bounds, as After Effects' layer bounds
do: the path bounds plus half the stroke width (through the draw's group
scale), with no anti-aliasing or miter padding. Rendering keeps draw_bounds.

Tests: render shape_content_bounds_are_the_visual_edges (a 100 x 100
rectangle is exactly +-50, +-55 with a 10 px stroke); engine
shape_layers_align_by_their_visual_edges (widths 100 / 110, Align Left puts
both edges exactly at 0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
(cherry picked from commit bfd0abd7437ad1d38652e3aef51f9ba5dea9100b)
2026-10-10 19:48:25 +00:00
Claude 9b7f8cf005
Effects: Write-on draws a stroke along the animated brush, honouring Stroke Length and Brush Spacing (#536)
Cause: write_on_plan drew a single dab at the current Brush Position. Stroke
Length, Brush Spacing and the Paint/Brush Time Properties were declared but
unused, so an animated brush showed a moving dot that dropped every earlier
mark.

Fix: as After Effects describes Write-on, a dab is laid every Brush Spacing
seconds from the layer's start up to the current time, each lasting Stroke
Length seconds (0: for ever). Their positions come from the host's parameters
at their times (EffectHost::params_at, which the renderer implements). Paint
Time Properties > Opacity and Brush Time Properties > Size (or Size &
Hardness) keep each dab's own opacity / size; otherwise the current values
apply to all. One frame draws at most 4096 dabs (longer strokes space them
out and join them). Without a host the current dab draws, as before. Colour
and hardness stay one per stroke (the paint plan has one colour and hardness),
so Paint Time Properties > Color and per-dab hardness aren't modelled. The GPU
path uses the same plan.

Test: write_on_accumulates_a_stroke_along_the_animated_brush (the report's
fixture: start and midpoint marks stay at 0.5 s with the end still clear, all
marks at 1 s, Stroke Length 0.25 keeps only the recent trail, Brush Spacing
0.5 gives three separate marks, per-dab opacity).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
(cherry picked from commit c82c9aeb685eab0cb36ca69c0b337781f4bad258)
2026-10-10 19:48:25 +00:00
Claude 77dfd44658
Puppet: a pin added to a bent mesh holds it where it is; nothing jumps (#318)
Cause: puppet::solve binds each moving pin to its nearest mesh vertex and fixes
that vertex at position + (vertex - rest), an offset in rest space with no
rotation or scale. puppet.addPin set a new pin's position to the click, so in
a bent region the bound vertex was pulled to where it would be unrotated, and
the mesh jumped as soon as the pin was added.

Fix: addPin picks the position that keeps the bound vertex at its current
deformed place: the vertex's deformed position minus the rest offset. On an
unbent mesh that is the click itself (round-off is snapped back to it), so
existing pins behave as before.

Test: a_pin_added_to_a_bent_mesh_moves_nothing (two pins, one moved to bend
the layer, then pins added at three places on the bent part: at most a few
edge pixels change from solver round-off; before the fix about 250 did).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
(cherry picked from commit 16151203b304d565b425873fdd33ae082cae483b)
2026-10-10 19:48:25 +00:00
Claude 7e0e641606
Edit: Copy with a property selected copies that property, and Paste sets it on the selected layers (#493)
Cause: edit.copy handled text editing, selected keyframes, effects and shape
items, then fell back to copying the selected layers. Selecting a property
selects its keys, so an animated property copied, but an unanimated one (or
the Transform group, whose keys prop.select doesn't select) copied the whole
layer, and Paste added a duplicate layer instead of setting the property.

Fix: with properties (or the Transform group) selected, Edit > Copy copies, as
After Effects does, each animated property's keys (the selected ones, else all
of them, through keys.copy) and each unanimated property's value into a new
value clipboard keyed by match path. Edit > Paste sets those values on the
same properties of the selected layers (a key at the CTI where the target is
animated) and pastes the keys at the CTI, as one undo step (keys.paste now
honours `merge`). Keys alone still answer with their count, as before.

Test: copy_a_selected_property_pastes_onto_the_same_property (static
Position, then the Transform group with animated Opacity: values and keys
land on layer B, no layer is added, one undo step each).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
(cherry picked from commit 8267519ebe20e889196e538de23b85b04a1fa76a)
2026-10-10 19:48:25 +00:00
Claude 09b3dbaad9
Merge branch 'fix/f3': community issue fixes
# Conflicts:
#	crates/ui-egui/src/widgets.rs
2026-10-10 19:48:24 +00:00
Claude 33dc3e72fa
Merge branch 'fix/f2': community issue fixes 2026-10-10 19:48:18 +00:00
Claude d3e34156e5
Merge branch 'fix/f1': community issue fixes
# Conflicts:
#	crates/ui-egui/src/panels/text_panels.rs
2026-10-10 19:48:18 +00:00
Claude fbe0f7e1ce
Cache bars: cached frames show at 1/3 resolution (#595)
The cache bars count the frames of `shown_series`, which rebuilds the
series from the frame key's scale in thousandths (333 -> 0.333). The key's
`opts` hashed the render options with the f64 scale, and the frames had been
rendered at 0.3333..., so the series never matched and the bars stayed empty
at Third (and any resolution that isn't a whole number of thousandths, such
as Custom 1/7).

`opts_hash` now hashes the scale rounded to the key's thousandths, which the
key carries anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 19:32:18 +00:00
Claude e96638093c
Workspaces: saved workspaces last between sessions (#320)
Window > Workspace > Save as New Workspace and Save Changes to this
Workspace kept the layout only in memory (`UiState::saved_workspaces` /
`saved_floating`): nothing wrote them, so they were gone after a restart.

They are now written to `workspaces.json` in the settings store (the config
folder on desktop, OPFS on the web) when a workspace is saved, renamed or
deleted, and read back when the app starts; a file that doesn't read is
ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 19:32:18 +00:00
Matej Kozlovský ced9e97c4a GPU: log why the GPU compositor declined a frame before the CPU renders it, instead of falling back silently (#640) 2026-10-10 21:17:51 +02:00
Matej Kozlovský 8321d099d2 GPU: precomp layers render their nested comp resident on the GPU instead of on the CPU and uploading it every frame (#640) 2026-10-10 21:17:51 +02:00
Matej Kozlovský 21b732875f Render: Renderer::precomp_source and run_layer_effects_on, so an accelerator can render a precomp layer's nested comp itself (#640) 2026-10-10 21:17:51 +02:00
Claude 485e4bb59d
Interface: Hangul is probed after the other scripts, so a catch-all face never draws Arabic (#616 follow-up)
Without a Korean font the Hangul lookup can land on Unifont, which also
covers Arabic but can't join it; registered first, it took Arabic too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 19:17:26 +00:00
Claude 3bde85dfdb
Timeline: Ctrl/Cmd+wheel and a trackpad pinch zoom time (#428)
egui turns Ctrl/Cmd+wheel and a pinch into a zoom factor (zoom_delta)
rather than a scroll, and the Timeline only zoomed on Alt+wheel, so
both did nothing over the layers. They now zoom time around the
pointer, like Alt+wheel. (B stays Set Work Area Start as in After
Effects; Split Layer is Ctrl/Cmd+Shift+D.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 19:16:48 +00:00
Claude 69bf806ff5
Graph Editor: buttons that don't fit the bar move to a » menu instead of being cut off (#457)
The Graph Editor's bottom bar lays out 17 text buttons in one row
(about 1100 px) on a painter clipped to the graph area, so on a
normal-width Timeline the right-hand ones (keyframe interpolation and
easing) were cut off and unreachable. Buttons that would pass the bar's
right edge now go, in order, to a » menu at its end; picking one there
runs the same action on the next frame. Hidden buttons register as
timeline.graph.more.<id> while the menu is open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 19:16:48 +00:00
Claude e152dfd1d1
Essential Graphics: Scale keeps its Constrain Proportions link, on by default (#338)
A Scale property added to Essential Graphics fell into the generic Point
control: two independent fields with no link, so dragging one value
distorted the layer, unlike After Effects, whose Scale control keeps the
chain link. It now draws the link (on by default) with one field per
axis; linked, an edit scales the others in proportion (Alt edits one),
using the Timeline's constrained() (moved to widgets). The link state is
the property's own, shared with the Timeline's chain link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 19:16:47 +00:00
Claude 76c9abad50
Viewer: the 3D Renderer menu shows the comp's renderer and switches it (#307)
The Composition panel's bottom bar drew a hard-coded "Classic 3D"
dropdown that ignored the comp's renderer and did nothing when clicked,
so an Advanced 3D comp still read Classic 3D. It now shows the comp's
renderer (translated) and, as in After Effects, its menu switches it
through comp.settings (one undo step); the automation label is the
renderer's name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 19:16:36 +00:00
Claude 29d9ba5fbb
i18n: the Home screen's buttons, pages, tiles, columns and empty states go through the interface catalog (#574)
panels/home.rs was in the converted list, but most of its text was drawn
straight from English literals (New Project, Open Project…, the page
tabs, the quick-start tiles, the filter placeholder, the column
headings, the empty states, the Opened/Kind cells and the version line),
so a Chinese interface still showed them in English. They now go through
tr()/tr_args(), with rows in both Chinese catalogs (Traditional's Join
the ArtCraft Discord row was also still English). Automation labels stay
English. The Opened column's plurals are separate rows so each language
words them itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 19:16:28 +00:00
Claude a207b19b12
#336: Ctrl+\ / Cmd+\ maximizes or restores the application window
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 19:16:07 +00:00
Claude dbbc0e2662
#577: font previews are scaled and moved to fit inside their row
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 19:16:03 +00:00
Claude bf5e6c6cec
#581: the Discord pill fits its label and field text sits on the field's middle
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 19:16:03 +00:00
Claude e4916e5ca9
#513: clicking off a mask's path, even on its layer, deselects its points
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 19:16:03 +00:00
Claude ad7df67602
#360: the Project row menu's New Comp from Selection uses the whole selection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 19:16:03 +00:00
Claude 7210b37bb2
Format effect.list's parameter metadata (#631 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 18:51:45 +00:00
Claude 7a95a0d138
Project: a single unselected folder or data-file drag still reports why; index math saturates (#634 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 18:51:45 +00:00
Claude 72b2e9774e
Dock: test the empty-dock reopen with the reporter's Minimal workspace (#629 follow-up, test from #630)
Co-Authored-By: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com>

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 18:51:45 +00:00
Claude 954e89c661
Merge pull request #638 from FrederoxDev/feat/color-hex-input 2026-10-10 18:51:16 +00:00
Claude 1c68166b2e
Merge pull request #634 from Ni-zav/fix/project-multiselect-drag-351-s20261010T1753Z-f4c2 2026-10-10 18:51:15 +00:00
Claude c742145b28
Merge pull request #631 from Ni-zav/fix/effect-parameter-catalog-s20261010T163100Z-6b8a 2026-10-10 18:51:15 +00:00
Claude 1c086fb134
Merge pull request #629 from MarekSpodBiedry/issue-529 2026-10-10 18:51:15 +00:00
Claude 5727754a54
Merge pull request #628 from MarekSpodBiedry/issue-505 2026-10-10 18:51:15 +00:00
Claude 8a65313226
Merge pull request #627 from MarekSpodBiedry/issue-446 2026-10-10 18:51:15 +00:00
Claude 3f840ab37f
Merge pull request #626 from MarekSpodBiedry/issue-467 2026-10-10 18:51:14 +00:00
Claude da06c42de1
Media: OpenEXR files with HTJ2K compression say it isn't supported (#482)
The exr crate reads HTJ2K (OpenEXR 3.4's High-Throughput JPEG 2000) headers
but can't decompress the pixels, and the image paths turned that into a
misleading "no non-deep rgb channels" (multi-layer files) or imported the
file and showed a transparent frame (single-layer files).

`exr_channels::unsupported_compression` reads the headers; import (probe)
and frame decoding fail with "<file>: OpenEXR HTJ2K compression isn't
supported yet: re-save the file with ZIP, PIZ or DWAA compression".
Decoding HTJ2K itself needs a pure-Rust JPEG 2000 Part 15 decoder and is
left for later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 18:51:12 +00:00
Claude 34f2955585
Media: an audio file that fails to decode no longer stays silent for good (#324)
With the Conformed Audio folder on (the default), the conform thread wrote
whatever `decode_audio` returned, and that returned silence when the source
couldn't be opened or a read failed. The conformed file is found by the
source's path, size and date, so one failed decode (an MP3 the decoder
stumbled on once) kept the footage silent in previews and exports in every
later session, while a renamed copy played.

The conform path now decodes with `try_decode_audio`, which fails instead of
returning silence; a failed conform removes its temporary file, writes
nothing and isn't retried this session (reads go to the decoder). Playback
reads still fall back to silence. CONFORM_VERSION goes to 3 so the silent
files earlier builds wrote are ignored and conformed again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 18:51:07 +00:00
Claude 605b7c3a25
Scripting: scripts can't grant themselves file and network access (#590)
A running script reached `prefs.set` through `app.run` and could turn on
Settings > Scripting & Expressions > Allow Scripts to Write Files and Access
Network (or turn off Warn User When Executing Files), which the File, Socket
and system.callSystem gates then read: the permission the user withheld was
the script's to take.

Commands a script issues now go through `Session::execute_for_script`, which
marks the session as running for a script for the command and everything it
runs (engine.batch, ScriptUI callbacks, scheduled tasks). While it is,
`prefs.set` refuses any change that would leave the Application Scripting
settings different (compared on the resulting settings, so dotted keys,
whole-page objects and batches are all covered), and a script's
`prefs.reset` keeps them as the user set them. Command scripts read from a
file (File > Scripts > Run Script File... with .json/.jsonl) count as
scripts too. The UI, CLI, control channel and MCP still change them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 18:50:57 +00:00
Claude 58e91fafbf
Viewer: the locked viewer shares the main viewer's display key (#619 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 18:50:32 +00:00
Claude ff56c5ad24
Audio: the short preview buffer is Linux-only and within the device's range (#614 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 18:50:32 +00:00
Claude 4de11be339
Time: in Frames style a typed number is a frame count (#610 follow-up)
parse_timecode reads bare digits as timecode pairs, so 00100 went to
frame 30 (1 s) instead of frame 100.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 18:50:32 +00:00
Claude a3cc4d04b0
Merge pull request #614 from vbasky/fix/554-linux-audio-preview 2026-10-10 18:49:52 +00:00
Claude 312ece2b5c
Merge pull request #610 from vbasky/fix/530-current-time 2026-10-10 18:49:52 +00:00
Claude a9c15f8bbf
Merge pull request #623 from MarekSpodBiedry/issue-531 2026-10-10 18:49:51 +00:00
Claude 0f626e8d45
Merge pull request #622 from MarekSpodBiedry/issue-532 2026-10-10 18:49:51 +00:00
Claude 49e6e834ae
Merge pull request #621 from MarekSpodBiedry/issue-533 2026-10-10 18:49:51 +00:00
Claude b723503785
Merge pull request #620 from MarekSpodBiedry/issue-534 2026-10-10 18:49:51 +00:00
Claude 6538fb175f
Merge pull request #619 from MarekSpodBiedry/issue-546 2026-10-10 18:49:51 +00:00
Claude ea10c73294
Merge pull request #618 from MarekSpodBiedry/issue-547 2026-10-10 18:49:51 +00:00
Claude 485e691e52
Merge pull request #616 from MarekSpodBiedry/issue-599 2026-10-10 18:49:50 +00:00
BFlat 1b4d47f34f
Merge pull request #639 from storytold/claude/community-pr-review-lwtrlr
Land community PRs, batch 2, with follow-ups
2026-10-11 01:44:49 +07:00
Claude 25adc36e21
Merge remote-tracking branch 'origin/main' into int2 2026-10-10 18:44:27 +00:00
FrederoxDev c8657bf214 UI: one After Effects-style colour picker with a hex field for every colour input
Every colour input opens one picker laid out like After Effects' Color
Picker: a square and slider for the channel chosen with H / S / B /
R / G / B, the new colour over the original, HSB and RGB fields, a hex
field (#RRGGBB, RRGGBB, #RGB) and OK / Cancel. Changes apply live;
Cancel, Escape and the original swatch restore the starting colour.

The 3D light dialog, Essential Graphics and Layer Styles passed sRGB
values to egui's color_edit_button_rgb(a), which reads them as linear,
so picked colours were stored darker (#808080 became #373737). They and
the other dialogs now use the shared sRGB colour button.

The picker and colour helpers live in color_picker.rs; widgets
re-exports them.

Tests: ui_color_hex.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:26:20 +01:00
Brandon Thomas 1e61b4e04b
Progress docs: target-app parity, gaps, alpha gate, readiness by audience (#617)
Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, and feature, format, hardware, UI and localization parity docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:09:10 -04:00
Nigel Ihza Firdausy 1922e621c2 fix(ui): drop all selected Project items into compositions (#351)
Preserve Project multi-selection when dropping onto the Timeline or Composition viewer. Keep stack order and drop position, skip non-layer project items, and add pointer-drag regression coverage.
2026-10-11 00:58:35 +07:00
MarekSpodBiedry 068db1f36b Let pan and zoom work while 3D Camera Tracker points are shown (#505) 2026-10-10 19:31:51 +02:00
MarekSpodBiedry 576d386d55 Render queue: unsaved project outputs go to ~/Movies or home, not the working directory (#446) 2026-10-10 19:28:44 +02:00
MarekSpodBiedry 0dc4ba3812 Reopen panels from the Window menu when the dock is empty (#529) 2026-10-10 19:28:41 +02:00
MarekSpodBiedry 4ea35a63a2 Fix recording a shortcut: the field no longer types the key too, and Cmd++ parses (#467) 2026-10-10 19:26:46 +02:00
MarekSpodBiedry 60a85e97d4 Convert Expression to Keyframes: sort the keys by layer time on a time-reversed layer (#531) 2026-10-10 19:14:28 +02:00
MarekSpodBiedry cbe740dce8 CC Simple Wire Removal: Frame Offset takes the strip from the previous or next frame (#533) 2026-10-10 19:13:16 +02:00
MarekSpodBiedry 9cae69a366 Effects: CC Power Pin Unstretch reverses the pin mapping (#534) 2026-10-10 19:12:45 +02:00
MarekSpodBiedry ad0fc4da09 Locked viewer: re-convert pixels when the display profile or output simulation changes (#546) 2026-10-10 19:12:02 +02:00
MarekSpodBiedry d5f8fb54cb Viewer: passive Composition viewers apply the monitor color conversion (#547) 2026-10-10 19:11:00 +02:00
MarekSpodBiedry bdae2f0fe3 Rove Across Time keeps roving keys off the neighbouring fixed keys' times (#532) 2026-10-10 19:10:42 +02:00
MarekSpodBiedry b28485cb87 Interface: probe Hangul so Korean text isn't drawn as boxes (#599) 2026-10-10 19:09:25 +02:00
Vikram Bhaskaran 139ff9a4e3
Start Linux audio preview after the first mixed samples, on a short device buffer.
Pulse and PipeWire were opening a large default period and playing silence until the feeder caught up, so the picture ran while the sound started late. The stream now waits for queued samples and requests about 10 ms of buffering.
2026-10-11 03:50:22 +11:00
Nigel Ihza Firdausy 4df4d9185b fix(effect): expose parameter metadata in effect.list (#441) 2026-10-10 23:41:07 +07:00
Vikram Bhaskaran 9ae7788f8d
Keep the playhead on the frame shown in the current-time field.
Timecode and Frames entries were parsed as absolute counts without the composition Start Timecode or Start Numbering Frames At offset, so committing the displayed text jumped to another frame.
2026-10-11 03:32:01 +11:00
Claude 362d299642
Fonts: No matching fonts goes through the interface catalog (#593 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 16:30:10 +00:00
Claude 2500719969
UI: an agent's injected drag never warps the real cursor (#592 follow-up)
Control-channel pointer events (ui.drag) scrub numbers like a mouse but
turn off the edge wrap, so an agent never moves the user's cursor; the
TextBox case of the drag test goes (that plug-in isn't in-tree).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 16:30:09 +00:00
Claude 00364546eb
Merge pull request #593 from kikikikier/community/type-in-font-search 2026-10-10 16:29:15 +00:00
Claude 5adf9db227
Merge pull request #592 from kikikikier/community/infinite-drag
# Conflicts:
#	crates/ui-egui/src/panels/fx_widgets.rs
2026-10-10 16:29:14 +00:00
tylerjenningsw 8cb8a416ac
Packaging: the legacy-folder fallback covers every release before this installer change (#466)
v0.7.0 has no App Paths Path value either, so the comments no longer
name a release.

(cherry picked from commit 4ae87f1e8351fca3fb7b2d3395affa30203d838f)
2026-10-10 16:29:06 +00:00
Claude 4de3354806
Merge pull request #589 from JeremyYao/fix/amd-vulkan-launch-guard
The two test-only conflicts keep main's versions, which already carry the
same fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 16:29:06 +00:00
Claude e2b547ae7d
Merge pull request #584 from itrycodinganditsbad/fix-negative-rotation-drag 2026-10-10 16:29:01 +00:00
Claude 1429921e5c
Merge pull request #585 from itrycodinganditsbad/fix-fractal-reset 2026-10-10 16:29:00 +00:00
Claude bb08a6acc9
Work area: an edit never clamps with crossed bounds (#605 follow-up)
A project saved with an inverted work area, or a comp shorter than a
frame, made Tick::clamp panic on the next start or end edit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 16:28:54 +00:00
Claude 359ad52f99
Merge pull request #607 from vbasky/fix/540-separated-position
# Conflicts:
#	crates/engine/src/tests_menu_cmds.rs
2026-10-10 16:28:09 +00:00
Claude c8c0041c41
Merge pull request #606 from vbasky/fix/539-separated-canvas 2026-10-10 16:28:05 +00:00
Claude 943c42659a
Merge pull request #605 from vbasky/fix/538-work-area 2026-10-10 16:28:05 +00:00
Claude cb2044ed67
Merge pull request #597 from sanyamsingh001/fix/history-trim 2026-10-10 16:28:05 +00:00
Claude af1c856948
Merge pull request #608 from vbasky/fix/527-first-mask-mode 2026-10-10 16:28:05 +00:00
Claude 6e591786f1
Merge pull request #578 from KattKnight/footage-reload 2026-10-10 16:28:05 +00:00
BFlat 1446e27d85
Merge pull request #598 from storytold/claude/community-pr-review-lwtrlr
Land 49 community PRs, with follow-ups
2026-10-10 22:46:18 +07:00
Claude 5a8cd76b77
Lint the merged community PRs: no drop of a borrowing closure (#458), json only where drops import (#447)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 15:45:46 +00:00
sanyamsingh001 0a265131ce G3: preserve reachable history during trimming
Co-authored-by: Codex <codex@openai.com>
2026-10-11 01:23:47 +10:00
Claude 616574439e
Integrate community PRs: relink's params doc and menu rows, pt-BR in the language list, MCP docs, test fixes
- file.relinkFootage (#461): close the params doc so checked execution
  (MCP, control channel) rejects unknown keys; translate its menu entry.
- general.language lists Português (Brasil) (#217).
- docs/agents.md: set_property / add_keyframe take timeBase (#522).
- #299's MCP test no longer sends get_property a value.
- The Automatically Reload Footage sequence test uses PNG frames.
- h264_frames_at_reduced_sizes (#507): the bar depends on the fixture's
  encoder; 50 dB with ffmpeg 6.1's x264.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 15:15:11 +00:00
kikikikier 1c2bf2f157 Fix signed angle scrubbing across zero and monitor wraps 2026-10-10 17:26:29 +03:00
kikikikier abd44404f8 UI: add inline font family search in Character and Properties 2026-10-10 17:14:06 +03:00
kikikikier adbe4dff4e UI: support continuous numeric scrubbing across monitor edges 2026-10-10 17:14:06 +03:00
Jeremy Yao 2a404f6226 MCP: the auto-save recovery tests look for the JSON-quoted path
automation autosave_is_durable_before_reply_and_queries_do_not_rotate
and the CLI's mcp_autosave_survives_kill_and_restart_without_touching_
desktop_settings asserted that the initialize instructions contain the
previous session's auto-save path as raw text. The instructions embed
the auto-save info as JSON, so on Windows the path's backslashes are
escaped and the raw match can never succeed (both failed on every
Windows run; the server's reply is right). They now look for the path
as serde_json prints it, which also matches on Linux and macOS.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 09:37:00 -04:00
Jeremy Yao a91952dadc Effects: the .spimtx parser reads its rows with as_chunks
clippy 1.99 (current stable, which CI tracks unpinned) fails the
workspace gate on chunks_exact_to_as_chunks in parse_spimtx. The length
is already checked to be 12, so as_chunks::<4>() has no remainder and
the behaviour is unchanged; the workspace's rust-version (1.95) has it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 09:36:59 -04:00
Jeremy Yao d86c4aff52 Startup: a launch that stops as its window is shown switches the next to OpenGL
On an ASUS G513QY (Radeon RX 6800M, Adrenalin 26.9.2, Windows 11) every
launch died with STATUS_ACCESS_VIOLATION inside amdvlk64.dll, on a
driver worker thread, a moment after the first frame: eframe shows the
window it created hidden, the resulting resize re-creates the swapchain,
and the driver crashes there (same offset on two driver versions, with
the OBS/Steam/Epic Vulkan layers disabled, and with FIFO, immediate and
latency-1 presentation). DirectX 12 and OpenGL open the window fine.

A crash in a driver can't be caught, and the launch guard (#243) removed
its marker at the first frame, so this looked like a successful launch
every time and Window Graphics never switched. The marker now stays
until the window has kept drawing for SETTLE (3 s) or the app quits
normally before that; frame_drawn returns the time left and the app
asks for a repaint then, so an idle window settles too. The notice,
the Settings note and docs/preferences.md describe the new rule.

Verified on that machine with a scratch settings folder: launch 1
(Vulkan) crashed and left the marker, launch 2 switched to OpenGL with
the notice and closed with exit code 0, launch 3 stayed on OpenGL
without the notice.

Tests: effectcraft launch_guard
a_launch_that_stops_after_its_first_frame_switches_the_next_to_opengl
(a first frame then drop: the next launch gets GL and the notice; a
normal quit in that time leaves no marker), and the existing test now
asserts that the first frames don't settle a launch (marker present at
SETTLE/2, gone at SETTLE).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 09:36:59 -04:00
Claude 42ad4edaee
MCP: set_property and add_keyframe take timeBase too (#522 follow-up)
Issue #257 names these tools: an agent can now give comp times on an
offset layer through MCP, as it can through prop.addKey and keys.select.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 12:59:03 +00:00
Claude 195b862036
Text: layer.setText's schema accepts discretionaryLigatures and ss01–ss20 under checked execution (#521 follow-up)
The params parser reads ':' as the start of a value, so 'OpenType?:
discretionaryLigatures?' declared a bogus OpenType key and hid
discretionaryLigatures, and 'ss01?…ss20?' declared only ss01.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 12:58:15 +00:00
Claude 9308d8e0f0
Merge pull request #521 from mjohnson7cd/fix-layer-settext-schema-520 2026-10-10 12:57:32 +00:00
Claude 816fc855d8
Merge pull request #217 from RicSchonfelder/i18n-pt-br
Ported onto the current language plumbing next to Spanish (catalog codes,
Match System for pt-* locales, native menu Services label, docs, tests);
the language list and settings validation stay the shared LANGUAGES table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 12:57:01 +00:00
Claude b8d23801ff
Merge pull request #460 from llopjulen/i18n/es
Ported onto the current language plumbing next to Ukrainian (catalog
codes, Match System, native menu Services label, docs, tests), and the
stale Ease Presets row (a ScriptUI panel since #404) dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 12:55:58 +00:00
Claude 00e9e37c0a
UI: the drop hint and drop errors go through the interface catalog; the Wayland notice points to the README (#447 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 12:55:10 +00:00
Claude 63acbb1eb0
Merge pull request #447 from David-T-Campos/fix/footage-drag-drop
# Conflicts:
#	apps/effectcraft/src/main.rs
2026-10-10 12:54:44 +00:00
Claude ce743af717
Appearance: zbus follows eframe's version instead of an exact pin (#519 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 12:54:31 +00:00
Claude 8f43e69cf1
i18n: one Red/Green/Blue row per language, Title/Action Safe keeps After Effects' label, no process spawn to read the locale (#490, #571 follow-up)
- #571's Red/Green/Blue rows duplicated #490's; keep 红色/绿色/蓝色 and give
  Traditional Chinese 紅色/綠色/藍色 instead of the English copies.
- The viewer's grid menu says Title/Action Safe again (as in After
  Effects and the View menu), with its own catalog rows.
- Half is 二分之一, as in After Effects' Simplified Chinese resolution menu.
- Drop the macOS 'defaults read' fallback for a C locale: sys-locale never
  reports one on Apple, so it never ran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 12:54:24 +00:00
Claude c7b02cc9a3
Merge pull request #472 from dmatviichuk/i18n/ukrainian-panel-followup
# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/src/i18n/ui.rs
#	docs/preferences.md
2026-10-10 12:53:37 +00:00
Claude e87f8a8d6d
Merge pull request #469 from haranishi/i18n-japanese-panels 2026-10-10 12:53:19 +00:00
Claude d8c1711f52
Merge pull request #519 from luniiya/feat/system-appearance 2026-10-10 12:53:19 +00:00
Claude 5de747735e
Merge pull request #571 from seldoms/i18n/chinese-translations 2026-10-10 12:53:18 +00:00
Claude 5b7c314317
Merge pull request #490 from cjcwwww/i18n/panel-helper-strings 2026-10-10 12:53:18 +00:00
Claude 57bd68c86e
Import: Automatically Reload Footage is Off / Non-Sequence Footage / All Footage, as in After Effects; a reload never shows disk-cached frames from before (#578 follow-up)
Image sequences are watched only with All Footage (the default,
Non-Sequence Footage, leaves them alone). Purge also forgets the viewer's
per-revision disk keys, which hash footage files' size and time, so a
file reloaded without a new revision is keyed again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 12:53:09 +00:00
Claude c5af482bce
QA: frame scores refuse NaN samples and mismatched buffers (#427 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 12:53:09 +00:00
Claude 4578f9090c
Packaging: pin the MSI test's checkout; the MSI comment says what it installs (#466 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 12:51:38 +00:00
Claude 6039e86a04
Merge pull request #466 from tylerjenningsw/enhance/windows-installer-folder
# Conflicts:
#	contributors/people.toml
2026-10-10 12:51:31 +00:00
Katt 98cdc981ad Footage: a file that can't be read is tried again once it changes, not on every look
A footage file the automatic reload couldn't read (still being written, or
one the importer doesn't read) kept its old stamp, so every two-second look
saw it as changed and probed it again. It now keeps its new stamp and is
tried again once it changes again (when the other app finishes writing it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:21:21 +05:30
Claude 22345370ac
Merge pull request #517 from sawizzle/docs/installed-release-mcp 2026-10-10 12:51:16 +00:00
Claude 8c2b464a4d
Merge pull request #582 from LloydNicholson/macos-project-documents 2026-10-10 12:51:16 +00:00
Claude 92332897af
Merge pull request #516 from dylanmtaylor/chore/update-actions 2026-10-10 12:51:16 +00:00
Claude a5feb441e9
Merge pull request #499 from tylerjenningsw/enhance/project-shift-delete 2026-10-10 12:51:16 +00:00
Claude 85d71011ac
Merge pull request #458 from storybored379/fix/mcp-review-followups 2026-10-10 12:51:16 +00:00
Claude 681919fe92
Merge pull request #478 from mjohnson7cd/fix-webm-bitrate-440 2026-10-10 12:51:16 +00:00
Claude adeccc02e0
Merge pull request #479 from MarekSpodBiedry/issue-475
# Conflicts:
#	crates/script/src/tests.rs
2026-10-10 12:51:16 +00:00
Claude b0277f898f
Merge pull request #480 from MarekSpodBiedry/issue-476 2026-10-10 12:51:15 +00:00
Claude d020715b1e
Merge pull request #487 from MarekSpodBiedry/issue-481 2026-10-10 12:51:15 +00:00
Claude 2013ec318a
Merge pull request #557 from haranishi/fix/pdf-inflate-limit 2026-10-10 12:51:15 +00:00
Claude 79003f7598
Merge pull request #575 from haranishi/fix/pdf-sampled-function-size 2026-10-10 12:51:15 +00:00
Claude 072c33ecf0
Merge pull request #558 from haranishi/fix/uninstall-script-name 2026-10-10 12:51:15 +00:00
Claude 0ce5063d9f
Viewer: Ctrl/Cmd draws a shape from its centre, as in After Effects (#443 follow-up)
Also keeps #442's newest-completed-revision lookup for the live drag, so
Frames has one way to pick the frame shown while an edit renders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 12:51:15 +00:00
Claude b7b47a0e10
Merge pull request #443 from Aavirash/fix/shape-drag-preview
# Conflicts:
#	crates/ui-egui/src/frames.rs
#	crates/ui-egui/src/panels/viewer.rs
2026-10-10 12:50:43 +00:00
Claude 5e28ecd53b
Merge pull request #578 from KattKnight/footage-reload 2026-10-10 12:50:18 +00:00
Claude 37cff1f547
Merge pull request #427 from vbasky/pr/measured-ae-fidelity 2026-10-10 12:50:18 +00:00
Claude 91d37dd449
Merge pull request #423 from cdinhoo/codex/effect-input-control-fixes 2026-10-10 12:50:18 +00:00
Claude b5c6eb7d5e
Merge pull request #461 from vbasky/feature/recursive-footage-relink 2026-10-10 12:50:18 +00:00
Claude 82d95d4c72
Merge pull request #299 from DWAA1660/fix/property-group-diagnostic-263 2026-10-10 12:50:18 +00:00
Claude 790f57ef24
Merge pull request #442 from cdinhoo/codex/live-preview-updates
# Conflicts:
#	docs/architecture.md
2026-10-10 12:50:18 +00:00
Claude ab09f219d4
Import: Purge frees decoded GIFs; a browser tab keeps less of one GIF (#567 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 12:50:12 +00:00
Claude e625ef3bef
Merge pull request #567 from Limestudio90/import-animated-gifs
# Conflicts:
#	crates/media/src/pool.rs
2026-10-10 12:50:00 +00:00
Claude 9093fd1804
Merge pull request #507 from daijro/perf/footage-playback 2026-10-10 12:49:56 +00:00
Claude 0df89c84ec
Merge pull request #579 from KattKnight/windows-test-fixes 2026-10-10 12:49:56 +00:00
Claude a12242c806
Format the merged community fixes (#561-#566 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 12:49:52 +00:00
Claude 20ac6189bc
Scripting: Property.selected=false deselects mask and effect groups too (#566 follow-up)
prop.deselect resolved its uid through prop_ref, which only finds leaf
properties, so mask.selected=false threw. Resolve it as prop.select does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 12:49:52 +00:00
Claude 45897f0aaf
Merge pull request #566 from Ni-zav/fix/548-property-selected-false-s20261010T084400Z-a6e9
# Conflicts:
#	crates/script/src/tests.rs
2026-10-10 12:49:18 +00:00
Claude b96628e154
Merge pull request #565 from Ni-zav/fix/541-locked-resize-height-s20261010T084400Z-a6e9
# Conflicts:
#	crates/engine/src/rq_tests.rs
2026-10-10 12:49:18 +00:00
Claude f27c834196
Merge pull request #451 from sanyamsingh001/fix/cli-template-rendering 2026-10-10 12:49:17 +00:00
Claude 6eb386002e
Merge pull request #471 from sanyamsingh001/fix/parenting-3d-position 2026-10-10 12:49:17 +00:00
Claude ab474485b7
Merge pull request #497 from sanyamsingh001/fix/time-boundaries-cancellation 2026-10-10 12:49:17 +00:00
Claude f6d130ac38
Merge pull request #512 from sanyamsingh001/fix/settings-info-return 2026-10-10 12:49:17 +00:00
Claude 21a9486d8a
Merge pull request #522 from sanyamsingh001/fix/property-key-time-bases 2026-10-10 12:49:17 +00:00
Claude cff0cf390d
Merge pull request #524 from sanyamsingh001/fix/key-attribute-selection-order 2026-10-10 12:49:17 +00:00
Claude 1eb154c2d9
Merge pull request #556 from sanyamsingh001/fix/export-audio-sample-positions 2026-10-10 12:49:16 +00:00
Claude f001b8e187
Merge pull request #573 from sanyamsingh001/fix/dock-tab-identity 2026-10-10 12:49:16 +00:00
Claude c7b75a04d7
Merge pull request #555 from LloydNicholson/fix/addsolid-pixel-aspect
# Conflicts:
#	crates/script/src/tests.rs
2026-10-10 12:49:11 +00:00
Claude ead13d81d4
Merge pull request #559 from Ni-zav/fix/544-project-item-deselect-s20261010T084400Z-a6e9
# Conflicts:
#	crates/script/src/tests.rs
2026-10-10 12:49:11 +00:00
Claude c226cd839a
Merge pull request #560 from Ni-zav/fix/549-textdocument-baseline-getters-s20261010T084400Z-a6e9 2026-10-10 12:49:11 +00:00
Claude 2e92dcad5f
Merge pull request #561 from Ni-zav/fix/545-expression-baseline-getters-s20261010T084400Z-a6e9 2026-10-10 12:49:11 +00:00
Claude f546bf9d9e
Merge pull request #562 from Ni-zav/fix/528-open-xml-project-on-launch-s20261010T084400Z-a6e9 2026-10-10 12:49:10 +00:00
Claude 380cb16369
Merge pull request #564 from Ni-zav/fix/542-render-queue-settings-template-s20261010T084400Z-a6e9 2026-10-10 12:49:10 +00:00
Claude cd5441cd1a
Merge pull request #563 from Ni-zav/fix/537-validate-new-text-position-s20261010T084400Z-a6e9 2026-10-10 12:49:10 +00:00
lloyd f041aa7782 macOS: projects open from Finder; .pcraft is PhotoCraft's again
The app bundle exported ai.storyteller.effectcraft.document with the
`.pcraft` extension, PhotoCraft's document format. Installing EffectCraft
took `.pcraft` over, and since EffectCraft declared no document types,
neither app was offered for PhotoCraft documents in Finder. EffectCraft's
own projects (.ecproj, .ecprojx) had no file type at all.

- Info.plist: the exported type now tags .ecproj and .ecprojx ("EffectCraft
  Project", conforming to public.data and public.content rather than
  public.image), and CFBundleDocumentTypes makes EffectCraft their owner.
- Finder double-click, Open With, Dock drops and `open -a EffectCraft
  x.ecproj` send a kAEOpenDocuments Apple event that winit 0.30 doesn't
  handle. Register it with the audited fmv-macos-events crate that
  PhotoCraft and PdfCraft use (it keeps the unsafe Objective-C interop out
  of this workspace). Opened paths go through the dropped-file handling, so
  a project opens after the unsaved-changes prompt and media is imported. A
  quit event closes the window.
- The event that launches the app arrives before the UI draws: the first
  frames skip the panels until the fonts are installed, which dropped it.
  The paths wait for EffectcraftApp::is_ready().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:22:22 +02:00
Fabio Lussi 00156ce22e Effect reset: restore parameters inside twirl-down groups
effect.reset looked parameters up by their full spec id among the effect's direct
children, so every 'group/param' id (most of Fractal, Scribble, and other grouped
effects) was skipped and Reset did nothing for them. Fixes #526

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:57:45 +02:00
Fabio Lussi 4e8a906fae Rotation: dragging the degrees field can go negative
The degrees field shows the remainder's size, so a drag was applied to the size and
the sign was re-applied afterwards; past zero it flipped back to positive. A drag now
moves the angle itself. Fixes #569

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:52:49 +02:00
sanyamsingh001 b0cceb1723 G2: preserve shown panels when removing inactive tabs
Co-authored-by: Codex <codex@openai.com>
2026-10-10 21:51:18 +10:00
Ikki Nishihara ff38282674 PDF colour spaces and functions: bound sizes, depth and stacks
A PDF could abort the process through its colour spaces and functions.
An Indexed hival of 10^12 asked an image for a 24 TB palette. A sampled
function's outputs (one per /Range pair) multiplied its table without a
bound, and copies of the table (one per q, one per reference) multiplied
it again. Self-referencing colour spaces and functions overflowed the
stack, a calculator's copy doubled its operand stack, index, idiv and
mod could panic, and f64::clamp panicked on a reversed /Domain or
/Range. Clamp hival to 255, count samples with checked arithmetic (at
most 32 inputs and outputs, 2^24 samples a table), share the samples,
give one function read a budget of nodes and samples, limit nesting to
8 levels and the calculator stack to 100, and clamp with the bounds in
either order.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 20:29:28 +09:00
seldoms 8c838728b5 i18n: wrap hardcoded UI strings for Chinese localization
- Wrap remaining hardcoded UI labels and buttons with tr()/tl!() calls
- Add missing Simplified and Traditional Chinese translations
- Add entries to other language catalogs for consistency
2026-10-10 19:09:46 +08:00
Katt a650e6c21d Tests: four failures on Windows (autosave paths, GPU viewer adapter, audio preview race)
- automation / cli autosave restart tests: the MCP instructions embed the
  auto-save state as JSON, so a Windows path's backslashes are escaped there;
  look for the JSON form of the path.
- ui_gpu: egui_kittest prefers a CPU adapter, which on Windows is WARP on
  DirectX 12, where FXC fails to compile the compositor's `pointwise` kernel
  ("compilation aborted unexpectedly"), so the viewer stayed on the CPU and
  the test failed before comparing anything. Build the harness on the adapter
  the headless compositor picked (falling back to egui_kittest's choice).
- ui_audio_scrub (#103 test): with a fixed 2 s comp, a machine with many
  render threads cached the whole preview before the first step, so the sound
  rightly started at once and no silent frame was counted (3 of 24 runs
  failed with 8 copies in parallel). Size the comp to four times the frames
  playback requests per step, so caching takes at least four steps (0 of 24).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:36:52 +05:30
Katt e6d37c2fa6 Reload Footage shows new pixels and keeps Photoshop layers; Reload Footage Changed on Disk
Reload Footage (Cmd+Alt+L) updated an item's metadata but left the media
pool's decoded frames and the layer cache in place, so the old image kept
rendering until Edit > Purge. It also replaced the footage with a probe of
the merged document, so a Photoshop layer item lost its layer and its
Retain Layer Sizes bounds.

Reload now re-reads only what the file decides (size, a movie's duration,
streams and frame rate, codec, a data file's text), keeps the layer or
page and the interpretation, finds a Photoshop layer again by name, and
makes the media layer forget the file (FootageSource::forget, which also
drops the EXR aux-channel cache) and clears the layer cache.

Settings > Import > Reload Footage Changed on Disk (on by default): the
desktop app stamps footage files (size + modification time) on a worker
thread every two seconds and when the window comes back to the front, and
reloads what changed. Not an undo step and it doesn't modify the project;
a file still being written is retried on the next check.
footage.reloadChanged runs one check (CLI, MCP, agents).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:36:50 +05:30
Vikram Bhaskaran 9f53d9c6bc
Move separated Position when the composition frame is resized or cropped.
Those edits offset the combined Position property, which a separated layer does not render. X and Y Position, including their keys, now move by the same amount.
2026-10-10 21:47:15 +11:00
Vikram Bhaskaran be9e1e9f42
Show a layer through its first Intersect or Darken mask.
Those modes were combined with an empty coverage, so the first one hid an opaque solid. A later mask still intersects or darkens.
2026-10-10 21:45:20 +11:00
Vikram Bhaskaran 39a0c6aa54
Move a layer when Position is edited and its dimensions are separated.
The renderer follows X, Y and Z Position once those channels exist. The Position command was writing only the combined property, so the layer stayed where it was.
2026-10-10 21:42:03 +11:00
Vikram Bhaskaran da67ea9553
Keep a composition work area inside the composition and at least one frame long.
Setting the start past the current end, or shortening the composition, stored an inverted interval. Trimming that interval then saved a negative duration.
2026-10-10 21:38:57 +11:00
Aavirash 20f0062981 Contributors: Aavirash's display name is Asquareportal 2026-10-10 12:14:05 +02:00
Aavirash 603337a061 Viewer: a layer drag moves the picture while the button is down
Every pointer move replaces the comp's content identity, so the frame on
screen takes a new cache key at every step while its render is still in
flight; the viewer only ever showed an exact key, so the picture froze on
the last completed frame until the drag ended. While a drag or a property
scrub is running, show the newest frame of the same comp, frame, scale,
view and render options that is ready — a render behind, as After Effects
does — and go back to exact frames when it stops.
2026-10-10 12:13:55 +02:00
Aavirash a2554c404c Tests: the CLI render-out test compares canonical temp paths
macOS's /var is a symlink to /private/var: the CLI resolves its working
directory through current_dir(), which is canonical, so the recorded
output path was /private/var/... while the test expected /var/....
Canonicalise the test's temp directory so the comparison holds on macOS.
2026-10-10 12:13:21 +02:00
Aavirash 0d2264dbed Viewer: the shape tools show the tool's own shape while dragging
The drag preview was a 1 px axis-aligned rectangle for every shape tool,
which read as no preview at all against most comps. Draw the tool's own
path instead (rectangle, rounded rectangle, ellipse, polygon, star), from
the engine's own geometry, filled with the Tools bar's Fill and outlined
in its Stroke, with a caption saying where the shape lands — a mask on
the selected layer, into the selected shape layer, or a new shape layer.

The ghost and the released shape go through one normalisation, so the
drag shows exactly what it commits: Alt (Option) draws from the press
point and Shift squares the box, both live. The drag also starts at the
press point now, not where the 6 px drag threshold was crossed, so the
shape is drawn from where the button went down, as in After Effects.
2026-10-10 12:13:21 +02:00
Limestudio90 bfbdadd34a Import animated GIFs as movies
GIFs went through the still-image path, so an animated GIF came in as a
single frame with zero duration. A GIF with two or more frames is now
probed as a movie: its frame rate is chosen so every per-frame delay is
a whole number of frames (uneven timing is kept exactly), delays of 0
or 1 hundredths count as 10 like in browsers, and the pool decodes the
frame shown at the requested time. A single-frame GIF stays a still.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 11:38:00 +02:00
Luniiya 23855865eb Merge remote-tracking branch 'upstream/main' into feat/system-appearance 2026-10-10 11:12:44 +02:00
Ikki Nishihara 6364ddf555 PDF: bound what every stream filter may decode to
decode_stream_within now bounds the output of each stage of a /Filter
chain, not only FlateDecode. RunLength, LZW, ASCIIHex and ASCII85 stop
when their output would pass the limit, a full LZW table takes no more
entries, PNG predictor rows are sized with checked arithmetic and only
allocated when a whole row fits in the data, and CCITT checks
/Rows x row bytes against the limit before allocating.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 18:12:06 +09:00
Nigel Ihza Firdausy 4bc619b010 fix(script): deselect individual properties via Property.selected=false (#548) 2026-10-10 15:58:22 +07:00
Nigel Ihza Firdausy c822fcdff3 fix(render-queue): honor height-only locked resize edits (#541) 2026-10-10 15:57:09 +07:00
Nigel Ihza Firdausy 65bf4702fd fix(render-queue): distinguish settings template on queue creation (#542) 2026-10-10 15:55:02 +07:00
Nigel Ihza Firdausy 9d10d84dee fix(engine): validate text layer position coordinates (#537) 2026-10-10 15:53:14 +07:00
Nigel Ihza Firdausy da131fcd57 fix(app): open .ecprojx project copies passed on the command line (#528) 2026-10-10 15:51:14 +07:00
Nigel Ihza Firdausy 6ed3d210fb fix(expr): reflect pending TextStyle baseline edits in expression getters (#545) 2026-10-10 15:50:18 +07:00
Ikki Nishihara 78e1c50268 Scripts: a script name is a file name, not a path
file.uninstallScript and script_code joined the name from the request into
Scripts/<name> and handed it to the settings store, so "../.." reached files
outside the settings folder (uninstall deleted them; run-by-name ran them).
Accept only bare file names (and, on Windows, none of its reserved names),
require a .jsx/.js name, and skip refused names in the script list.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 17:50:05 +09:00
Nigel Ihza Firdausy 4f6256c486 fix(script): keep pending TextDocument baseline reads in sync (#549) 2026-10-10 15:49:32 +07:00
Nigel Ihza Firdausy d0489021b5 fix(script): make Item.selected=false deselect only that item (#544) 2026-10-10 15:47:58 +07:00
Ikki Nishihara 8c665a19e2 PDF: bound what a FlateDecode stream may inflate to
inflate used the unbounded miniz_oxide functions, and File::parse decodes
every object stream eagerly. Use the limit variants (1 GiB per stream, 256 MiB
for object streams, a quarter of that on 32-bit targets) and bound the
truncated-stream salvage buffer too.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 17:29:44 +09:00
sanyamsingh001 34b894a249 G3: preserve exported audio sample positions
Co-authored-by: Codex <codex@openai.com>
2026-10-10 17:55:50 +10:00
WaWu 9b7add4046 i18n: the forms, the settings pages and the viewer menus draw their technical terms in Chinese
The second pass over the interface below the menu bar: the strings that are technical terms or
proper nouns - `Mercury GPU Acceleration (…)`, `8/16/32 bits per channel`, `OCIO (built-in ACES
config)`, `sRGB IEC61966-2.1 (default)`, `Import Photoshop File`, `Current Settings`, the viewer's
channel and Fast Previews items. Each wording was checked against the installed After Effects
26.5.0 Simplified Chinese, the way the menu bar was in #353. Terms Adobe keeps in English
(`Display P3`, `HDTV (Rec. 709)`, `RGB`, `Alpha`, `H.264`, `ProRes`) need no row: a missing row
falls back to the English source, which is the decision.

The text reaches the catalog through the funnels that draw it, so one change each covers every
call site:

- `forms.rs`: field labels, notes, choice items and info bodies, plus `dialogs::modal`'s title -
  the modal id stays keyed on the untranslated source, so changing the language cannot move it;
- `settings.rs`: page titles, sections, notes, buttons, setting labels and choice items (the page
  table itself is engine data and cannot reach the catalog);
- `comp_settings.rs` (3D renderer), `key_dialogs.rs::combo` (Current Settings and the roving
  items), `properties.rs` + `timeline.rs` (the text animator menu), `dialogs.rs` (About links);
- `render_queue.rs`: the Opus bitrate goes through `tr_args`, so the value is substituted after
  the lookup - worth calling out, because building the string with `format!` and translating the
  result looks up the assembled text and silently stays English (the same trap as the project
  settings' GPU label, which this commit also fixes).

30 rows join the Simplified catalog. The same keys join the Traditional one with the English
source as their value: the tests require the two lists to match, and the Traditional wording is
the translator's call.

Tests: `CONVERTED` gains the three engine files whose strings the interface draws
(`prefs.rs`, `viewer.rs`, `commands/viewer_cmds.rs`); each holds no drawn literals, so the literal
scan stays green and the new rows have a source to be "used" from. `cargo test -p
effectcraft-ui-egui --lib i18n`: 16 passed.
2026-10-10 15:52:10 +08:00
lloyd 6843510a25 Scripting: layers.addSolid keeps the requested pixel aspect (#543)
LayerCollection.addSolid dropped its fifth argument, so layer.newSolid
fell back to the composition's pixel aspect. The wrapper now passes
pixelAspect through; layer.newSolid already validates it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:42:07 +02:00
WaWu 1ab6eb963f i18n: the viewer toolbar and the solid dialog ask the catalog for the text they draw
Text a helper draws for a panel was handed to that helper as a bare literal, so the catalog lookup
never happened and parts of the interface stayed English with a Chinese language chosen:

- the Composition panel's toolbar tooltips - Choose grid and guide options, Toggle Transparency
  Grid, Toggle Mask and Shape Path Visibility, Region of Interest, Show Channel and Color
  Management Settings, Reset Exposure, Take Snapshot, Show Snapshot (hold);
- the items of its four popups (grid and guides, resolution, magnification, channel) - Grid,
  Guides, Rulers, Proportional Grid, 3D Reference Axes, Full, Half, Third, Quarter, Custom...,
  Fit, Fit up to 100%, Colorize;
- the solid dialog's "Affect all layers that use this solid" checkbox and its disabled hint
  "Only this layer uses this solid.".

`tog` and `popup` now look the label up themselves, so every current and future call site is
covered by one change each; the automation ids and labels stay English, as the rest of the panels
keep them for scripts and tests. `resolution_label` composes its words from the catalog, so the
closed resolution dropdown reads Chinese too, and the viewer's item now reuses the panel's existing
"Title/action safe" row instead of a second spelling of the same concept.

21 rows join the Simplified catalog. The same keys join the Traditional one with the English source
as their value: the tests require the two lists to match, and the Traditional wording is the
translator's call rather than this change's.

Tests: the row-completeness check now also accepts a row that a helper draws (its English source
appears as a literal there), and a new check refuses a bare `Checkbox::new` / `RadioButton::new`
label - the shape that hid the solid dialog, and the one the literal scan cannot see because the
label is the call's second argument.

This also corrects two Simplified rows #398 left wrong (`Channel` read 色版 in a Simplified
catalog, `Motion Blur: {}` read 动态模糊 while the menu catalog's own Motion Blur is 运动模糊).
2026-10-10 13:33:35 +08:00
Sawizzle abbf38f716 docs: drop the third-party link; the Windows path is the default install folder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:50:57 -07:00
sanyamsingh001 8eb77a7d1e G2: preserve keyframe selection during attribute edits
Co-authored-by: Codex <codex@openai.com>
2026-10-10 13:22:32 +10:00
sanyamsingh001 a7a6295bb0 Issue 257: support explicit keyframe time bases
Co-authored-by: Codex <codex@openai.com>
2026-10-10 11:12:14 +10:00
Brandon Thomas 813c7c4650 Bump version to 0.7.0 (from 0.6.0) for release; refresh contributors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:02:08 -04:00
Mara Johnson 6f0369b3a8 fix(engine): layer.setText schema requires optional OpenType keys (#520)
- Mark OpenType? and ss01? as optional in params doc
- Fix clippy lint: use as_chunks instead of chunks_exact
2026-10-09 20:57:21 -04:00
Luniiya fe27c375e3 feat: standardise system appearance modes with PhotoCraft 2026-10-10 02:15:26 +02:00
Dylan M. Taylor 675fc55303
chore: bump github actions to fix Node 20 deprecation warnings 2026-10-09 20:08:26 -04:00
Sawizzle fbe028717e docs: register the MCP server from an installed release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:06:42 -07:00
tylerjenningsw 4359899f7a Project: a Cut is read with the modifiers held where it is in the frame (#484)
Cause: clipboard events carry no modifiers, and the Cut and Copy/Paste
mapping read i.modifiers, egui's state after all of the frame's events.
A Shift+Delete with Shift released in the same frame became Ctrl+X (no
deletion), and a Ctrl+X followed in the same frame by Ctrl up and Shift
down became Shift+Delete: the selected items and the layers using them
deleted without the prompt.

Fix: walk the frame's events in order from the modifiers held when it
began (the state the last pass ended with, recorded each pass), so each
Cut, Copy and Paste uses the modifiers at its place in the stream. When
that state isn't known (a pass was skipped), a Cut is never Shift+Delete.
Only Shift alone counts, not Ctrl/Alt+Shift.

Tests: a_cut_is_read_with_the_modifiers_where_it_is_in_the_frame queues
modifier changes and a Cut in one frame (Shift then Cut and Shift up;
Ctrl, Cut, Ctrl up, Shift down; and the mixed orders); the Windows
Shift+Delete helper now sends Shift, Cut and Shift up as one frame. They
fail on the previous mapping and pass now.
2026-10-09 19:55:48 -04:00
mjohnson7cd c195e6c40b fix: CLI --bitrate now applies to VP9 WebM (#440)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:50:13 -04:00
sanyamsingh001 e4ca1e919a G2: return to Settings after dismissing GPU Information
Preserve pending preference edits through the existing OK and Cancel actions.

Co-authored-by: Codex <codex@openai.com>
2026-10-10 08:46:03 +10:00
daijro ddfa566d7d Render: 8 and 16 bpc quantisation rounds with floor in the browser
Cause: quantize_px rounded with `(v + 0.5) as u32 as f32`. Converting back
from u32 is several instructions in V8's wasm code on x86 (no unsigned
conversion), so quantising a 1080p layer took about 15 ms in the browser:
every 8/16 bpc layer is quantised after its source and every effect, and
the CPU compositor quantises the comp after every layer.

Fix: round_half_up uses floor in wasm (one instruction there) and keeps the
integer conversion elsewhere (x86-64's baseline has no floor instruction,
where floor is slower). The values are the same: both round non-negative
values half up. About 1.5x faster quantising in the browser (15 -> 10 ms
per 1080p layer in a wasm micro-benchmark), unchanged on the desktop.

Tests: color::tests::rounding_forms_agree: both forms agree on 0..=levels
in sixteenths (exact halves included) and on 100 001 products, at 8 and
16 bpc.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:44:49 -05:00
daijro 74b151cedc Footage: reduced-resolution renders get movie frames converted at their size
Cause: at Half, Third and Quarter resolution (the viewer's Auto resolution
in most windows) every movie frame was converted from Y'CbCr to f32 RGBA at
full size and then resampled down. In the browser (wasm, one thread) that
was about 8 ms converting a 1080p frame, 6 ms resampling it and 2 ms
zeroing a 33 MB buffer, per layer and frame, for a frame shown at a
quarter or a ninth of its pixels; the footage frame cache held the
full-size frames.

Fix: FootageSource::frame_at_size asks for a frame shown at a given size
(default: the full frame, as before). The renderer asks for it where it
resampled footage (below 3/4 scale; not for proxies, fields or Interpret
Footage's pixel options, which need the full frame). MediaPool makes opaque
4:2:0 movie frames of the footage's own size there: it box-filters them to
half size in Y'CbCr before converting (each 2x2 block of luma, and the
[1 6 1]/8 neighbourhood of its chroma sample, which is the mean of the
upsampled chroma its pixels get), resamples the rest of the way and caches
the result per size. Conversion is linear until colours clip, so this is
the RGB box filter resample starts with, except in blocks where some
colours clip. Other frames convert at full size and resample as before.
In the browser the conversion and resampling take about 4 ms instead of
16. Docs (architecture).

Tests:
- convert::scaled_conversion_matches_resampling_the_converted_frame: half,
  third, quarter and odd sizes of an unclipped 4:2:0 frame are within 1e-5
  of resampling the converted frame; larger sizes, odd frame sizes and
  4:4:4 convert at full size.
- tests_footage_size::reduced_renders_take_footage_resampled_by_the_source:
  a source that resamples frames itself renders the same frames as one
  that doesn't, at 8 and 32 bpc and every resolution, and is asked only
  below 3/4 scale, and not with Invert Alpha.
- footage::h264_frames_at_reduced_sizes (ffmpeg fixture): 1080p H.264
  testsrc2 frames, whose saturated colours clip, at 960x540, 640x360,
  480x270 and 323x182 are 58-62 dB from the resampled full frames, and
  are cached per size.
- convert's ignored 1080p bench also times the conversion at a third.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:44:49 -05:00
daijro 2ccc66e73b Web: the layer and footage caches stay small enough for eight stacked clips
Cause: the browser build kept the desktop's cache budgets (1 GB layer
cache, 1 GB footage frame cache) inside the page's memory, which wasm32
caps at 4 GiB. FilmCraft's decoders keep up to 384 MB of decoded frames
per open movie beside them, so a comp of eight stacked 1080p H.264 clips
grew the page by about 55 MB/s while playing and ran it out of memory
after about 50 s ("unreachable": a failed allocation).

Fix: in the browser Prefs::cache_budgets keeps the Layer Cache within
512 MB and the Footage Frame Cache within 256 MB (WEB_LAYER_CACHE,
WEB_MEDIA_CACHE); smaller settings and the desktop are unchanged, and
the RAM preview keeps its budget. Docs (web, preferences) say so.

Tests: tests_m145::web_cache_budgets_leave_room_for_the_decoders: with
4 GB settings the web budgets are the caps, the desktop's are the
settings, and smaller settings stay as they are.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:44:15 -05:00
sanyamsingh001 c37b88602f G3: fix frame-rate boundaries and bounded timecode arithmetic
Preserve relative MIN-field cancellation with direct saturating addition and subtraction.

Co-authored-by: Codex <codex@openai.com>
2026-10-10 06:40:02 +10:00
tylerjenningsw 80a23372df Docs: gaps.md records the #484 Shift+Delete tests 2026-10-09 16:27:50 -04:00
tylerjenningsw a65d70f10e Project: Shift+Delete deletes the selected items without asking (#484)
With the Project panel focused, Delete Project Items Without Confirmation
runs project.delete on the selection: items compositions use go without
the prompt, with their layers, in one undo step. In the other panels it
does nothing, and like every shortcut it doesn't fire while typing or
with a dialog open.

On Windows the windowing layer sends Shift+Delete as Cut (the old Cut
key), which was mapped to Ctrl+X: in the Project panel, which has no
clipboard, a Cut with only Shift held is Shift+Delete now. Elsewhere it
still cuts.

Tests (ui_project_delete): Shift+Delete as a key and as Windows' Cut
deletes a comp, a solid in use, footage and a folder without the prompt
and one undo restores them; a multi-selection with a folder holding a
solid in use goes together; an empty selection deletes nothing; the next
Delete still asks after Shift+Delete or an answered prompt; it does
nothing in the Composition panel, while renaming or with a dialog open;
Shift+Backspace, Alt/Ctrl+Delete and Ctrl/Alt+Shift+Delete don't delete.
The deleting tests fail without this change.
2026-10-09 16:27:48 -04:00
tylerjenningsw 01ba8a85e9 Project: answering the Delete Items prompt confirms that deletion only (#484)
The prompt's Delete set a flag that let the next project.delete skip the
prompt. It now runs project.delete directly (delete_items::delete_confirmed),
so the confirmation goes with that request and can't carry over to a later
deletion. Shift+Delete uses the same path.
2026-10-09 16:27:21 -04:00
tylerjenningsw f59acb5b80 Project: Delete Project Items Without Confirmation is a command, on Shift+Delete (#484)
A frontend command (project.deleteWithoutConfirmation) so the shortcut is
listed and rebindable in Keyboard Shortcuts and runs by id over the
control channel and MCP. The UI runs project.delete for it.
2026-10-09 16:24:53 -04:00
MarekSpodBiedry 6c31247ceb EXR: do not double the part name of Blender 5 per-pass parts (#481) 2026-10-09 21:53:30 +02:00
MarekSpodBiedry 56cd3f1bf5 Expressions: Add Expression applies to the selected properties (#476) 2026-10-09 21:13:46 +02:00
MarekSpodBiedry bca027de6a Scripting: importFile(ImportOptions) imports one file unless sequence is set (#475) 2026-10-09 21:12:22 +02:00
Dmytro Matviichuk 4823c08d9c
G9: expand Ukrainian UI coverage (#333)
Translate the 396 current panel catalog rows and route Ukrainian Reveal
labels for Finder, Explorer and File Manager. Enforce catalog parity,
placeholder preservation and platform label coverage; update language docs.
Use a fixed-size OCIO row iterator to satisfy the current Clippy gate.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 20:54:14 +02:00
sanyamsingh001 cac47626f3 fix(engine): preserve supported 3D parenting compensation (#348)
Convert Position values and spatial tangents into the new parent space.
Preserve supported static Orientation and Scale in constant uniform
parent spaces, and convert spatial Position ease speeds for uniform
transforms. Retain the compensation and Undo/Redo regressions.

Refs #348.

Co-authored-by: Codex <codex@openai.com>
2026-10-10 04:51:15 +10:00
tylerjenningsw 4aea350f2a Packaging: the installer's Repair puts back a damaged effectcraft-cli.exe (#466)
Cause: Repair used REINSTALLMODE=ecmus. effectcraft-cli.exe has no version
resource, so Windows Installer treats it as unversioned: a changed copy
with a later write time counts as a user edit and is kept, and "c" only
checks files that carry an MSI checksum, which these don't.

Fix: Repair uses "amus", which puts back every file whatever its version
or date.

Tests: test-msi.ps1's stand-in CLI is unversioned like the real one; the
test damages it in place and repairs with the REINSTALLMODE read from
installer-ui.wxs (no longer msiexec /fa after deleting it), then compares
contents. It fails with ecmus (the damaged file stays) and passes now.
2026-10-09 14:49:19 -04:00
tylerjenningsw 462913a483 Packaging: upgrading a custom-folder install from 0.6.0 or earlier keeps its folder (#466)
Cause: the folder search read only the App Paths "Path" value, which this
branch introduced. 0.6.0 and earlier could already be installed elsewhere
with INSTALLFOLDER=..., but have no Path value, so an upgrade went to
Program Files while the wizard said it would stay in the same folder.

Fix: when there is no Path value, a second search reads the App Paths
default value (the full path of effectcraft.exe, written by every
release). A "file" registry search with no FileSearch inside yields the
folder of that file.

Tests: test-msi.ps1 installs a legacy package (the same .wxs without the
Path value) into a custom folder, then upgrades it without naming a
folder; it fails on the previous .wxs (the upgrade lands in Program
Files) and passes now. A further upgrade checks the Path value route.
2026-10-09 14:45:47 -04:00
Ikki Nishihara a2dfd6631a i18n: Japanese for the panels, dialogs and tooltips
ui.rs gets a Japanese table with the same 396 rows as the Simplified and
Traditional Chinese ones, so Japanese now translates the interface below
the menu bar too (it covered the menus only). The shape test keeps the
three tables listing the same source strings; a new test checks that
Japanese reads its own rows and fills values in order. The existing
"no rows yet" check uses Ukrainian instead.

ROADMAP.md and docs/preferences.md say which catalogs have panel rows.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 03:17:26 +09:00
tylerjenningsw 184df34875 CI: the MSI test reads Apps entries one value at a time (#466)
Get-ItemProperty throws on a malformed Uninstall entry, which aborted the
upgrade check on a real Windows 10 machine. Failures now print their message.
2026-10-09 14:11:53 -04:00
tylerjenningsw 2412173b7e Contributors: tylerjenningsw's display name is tylerjenningsw 2026-10-09 14:05:49 -04:00
tylerjenningsw 6c58eea643 Contributors: credit tylerjenningsw as Tyler Jennings 2026-10-09 14:05:18 -04:00
BFlat 30aaddf7c2
Merge pull request #459 from storytold/fix/394-408-arabic-flac
Arabic, Hebrew, Indic and Thai names; FLAC preview opens once and off the UI thread (#394, #408)
2026-10-10 01:04:24 +07:00
tylerjenningsw 5d58fd4029 Docs: releasing.md describes the MSI install folder and its test (#466) 2026-10-09 14:00:46 -04:00
tylerjenningsw 7dc2b7a381 README Downloads: the Windows installer lets you pick the folder (#466) 2026-10-09 14:00:29 -04:00
tylerjenningsw f2d335b138 CI: the MSI is install-tested with a custom folder, upgrade, repair and uninstall (#466)
test-msi.ps1 builds the MSI around stub binaries, validates it (ICE) and,
silently: installs into a folder with spaces and a non-ASCII name, upgrades
without naming a folder (it must stay there, one entry in Apps), repairs a
deleted file, uninstalls (files and App Paths gone), and installs to the
Program Files default. Run by a windows-latest job in packaging-lint.yml.
2026-10-09 14:00:09 -04:00
tylerjenningsw 9ceda6a8f5 CI: packaging lint checks every Windows .wxs file (#466) 2026-10-09 13:59:46 -04:00
tylerjenningsw b32965f209 Packaging: the Windows installer asks where to install EffectCraft (#466)
A fresh install opens on a destination page (Program Files by default;
Change... browses, with Up and New folder), then a Ready page that shows
the folder before anything is written. An upgrade goes straight to the
Ready page with the installed copy's folder and can't move it; opening the
MSI again offers Repair or Remove. Cancel, finish, files-in-use and error
dialogs are authored too. Silent installs (/qn) show none of it.

The dialogs (installer-ui.wxs) are original, built from Windows Installer's
own controls and events, not the WixUI library, whose MS-RL licence is not
on the allowed list. package.ps1 builds both .wxs files.
2026-10-09 13:58:35 -04:00
bflatastic 67305fbcd1 Viewer: each composition keeps its Resolution in the project, as in After Effects (#417)
What: the Composition panel's Resolution (Auto, Full, Half, Third, Quarter, Custom) is now a
property of the composition (`Comp::resolution`, moved with its `Resolution` type from the UI
into the project crate) instead of one global viewer setting. Switching to another comp shows
that comp's resolution, switching back shows the first one's again, and saving and reopening the
project keeps them. `view.res.full|half|third|quarter|custom` and the new engine command
`view.res.auto` set it (headless too, with an optional `comp`); `ui.set {viewer: {res}}` sets
the active comp's and `ui.inspect` still reports `ui.viewer.res`; `comp.info` reports
`resolution` and `resolutionFactor`; View ▸ Resolution's check marks come from the engine.

Why: After Effects saves each composition's Resolution with the project (CompItem.resolutionFactor
is a per-comp attribute). As there, changing it marks the project modified but adds no undo step:
undo, redo and History jumps carry the current values over to the restored state. It is not part
of the comp's pixels (`Comp::same_pixels` ignores it and `Comp::pixel_form` normalises it), so
changing it keeps the comp's RAM and disk cache keys. Auto is not written (serde default and
skip_serializing_if), so projects saved before open with Auto and older versions read new files.
The magnification is not saved per comp: Adobe's documentation doesn't say it is.

Tests: project `comp_resolution_is_saved_and_old_projects_load_with_auto`,
`resolution_factors_and_scales`; engine
`each_comp_keeps_its_resolution_outside_the_undo_history_and_in_the_file` (switching comps,
no undo step, undo/redo keep it, save/open round trip, menu check marks, Custom dialog);
ui-egui `each_comp_keeps_its_viewer_resolution` (the viewer popup on comp A, comp B through
`ui.set`, back to A, unchanged frame cache identity, save/open). They don't build before the
change (no `Comp::resolution`, no `view.res.auto` engine command).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:57:00 +07:00
bflatastic 09a5dc357a Audio: a FLAC opens once and off the UI thread; conforming is bounded (#408)
The freeze and quit during a FLAC preview did not reproduce on current
main (Windows 11): a 4-minute stereo and a 10-minute 24-bit 6-channel
96 kHz FLAC played through a RAM preview with a real-time audio pull,
after trimming and zooming, with no stall, no growing queue and memory
bounded by the RAM preview budget; 150 cpal output start/stop cycles
ran clean. Three defects in that path were found and fixed:

- FilmCraft decodes FLAC, MP3, Ogg and AIFF files whole when they are
  opened (8 s and 1.4 GB for the 10-minute 6-channel file). MediaPool
  opened a file outside its lock, so every caller asking before the
  first open finished decoded the whole file too: a preview's frame
  workers, audio feeder, conform and waveform threads at once (8 reads
  at once: 8 decodes). Each path now opens once; the others wait for it.
- AudioPlayback::start mixed its first 50 ms on the calling (UI)
  thread, so the first read of such a file froze the UI for the whole
  decode (audio-only previews start the sound right away). All mixing
  is on the feeder thread now; the device plays silence until the first
  block arrives, and the clock (frames actually played) waits for it.
- Conforming (Settings > Disk > Conformed Audio Folder, on by default)
  held the whole conformed track in memory before writing it, sized by
  the footage duration from the file's header: a header claiming a
  month of audio aborted the app on a failed 995 GB allocation. The
  conformed file is now written a chunk at a time, and footage longer
  than 24 hours is read from its decoder instead of conformed.

Tests: media concurrent_first_reads_open_a_file_once (8 threads: 8
opens before, 1 now; PoolStats::opened), pool
a_header_claiming_a_month_of_audio_is_not_conformed (aborted before),
ui-egui audio starting_audio_does_not_wait_for_slow_footage (start took
the footage's 2 s before), conformed_flac_audio_reads_back_like_the_
decoder, and the robustness test flac_audio_of_any_shape_reads_in_
device_blocks (2 minutes of 24-bit 6-channel 96 kHz, mono 8 kHz,
11.025 kHz, 192 kHz and 8-channel FLAC read in 1024-frame blocks at
48 kHz through and past the end: level, silence after the end, opened
once). FLAC fixtures come from ffmpeg (skipped without it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:53:21 +07:00
bflatastic 98a6a3a041 UI: Arabic, Hebrew, Indic and Thai names can be typed and are drawn (#394)
Cause: two things kept Arabic out of a layer name on Windows.
- Windows switches the keyboard layout with Alt+Shift. Releasing Shift
  before Alt left Alt held alone, and the in-window menu bar (#279)
  counted the release as an Alt tap: it took the keyboard focus, the
  rename field closed, and the letters typed next were swallowed by the
  menu bar. The Project panel's rename field lost them the same way.
- The UI fonts are Inter plus the CJK system fallbacks, so Arabic,
  Hebrew, Indic and Thai names that did arrive (pasted, or from a
  project) showed as missing-glyph boxes.

Fix:
- An Alt tap only counts when Alt went down with no other modifier
  held, so Alt+Shift in either release order is no tap; a plain Alt tap
  still focuses the menu bar.
- The UI registers installed system faces for Arabic, Hebrew, Devanagari,
  Bengali, Gurmukhi, Gujarati, Tamil, Telugu, Kannada, Malayalam and Thai
  after the CJK ones, the same way (native only; the web build has no
  system fonts). A face covering several scripts is registered once
  (Segoe UI: Arabic and Hebrew; Nirmala UI: every Indic script).
- The text engine's fallback tries each script's plain platform families
  (Segoe UI, Nirmala UI, Leelawadee UI, Geeza Pro, Kohinoor, Noto…)
  before any covering face, so neither the UI nor a text layer gets a
  calligraphic face that merely sorts first.
- The Timeline's rename field registers `timeline.layer.<id>.nameEdit`.

egui 0.36 shapes each run of one face, so Arabic letters join and a word
reads right to left; it has no bidirectional layout, so the words of a
multi-word Arabic name (and mixed Arabic/Latin) still follow each other
left to right.

Tests: ui_inline_edit a_layout_switch_keeps_the_name_field_and_arabic_
names_are_kept (Alt+Shift both ways in the layer rename, Arabic typed and
stored; Hebrew/Devanagari/Thai in the Project panel; a plain Alt tap
still focuses File; failed before: the field closed), theme
installed_script_fallbacks_draw_arabic_hebrew_indic_and_thai_names
(failed before: replacement glyph for Arabic) and
arabic_words_are_joined_and_read_right_to_left, text
other_scripts_fall_back_to_a_plain_system_family and the script
classification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:53:21 +07:00
BFlat 921f0dbdbe
Merge pull request #454 from storytold/fix/397-particle-world-camera
Effects: CC Particle World follows the comp camera (#397)
2026-10-10 00:52:58 +07:00
tylerjenningsw 8b8a57780f Packaging: Windows upgrades and repairs install into the existing folder (#466)
The MSI records its folder as the App Paths "Path" value of effectcraft.exe
and reads it back with a registry search, so an upgrade, a repair or a
silent reinstall goes where the installed copy already is. A folder passed
on the command line (INSTALLFOLDER=...) still wins, and INSTALLFOLDER is
Secure so a folder chosen in the UI reaches the elevated install. v0.6.0 and
earlier have no Path value and upgrade in Program Files, where they are.
2026-10-09 13:51:44 -04:00
Vikram Bhaskaran db6b2e8079
Relink missing footage by scanning a folder for compatible files.
A same-named file is used only when kind, size, codec, and for moving pictures the rate and duration agree. Symlinks are not followed, and duplicate names are left alone.
2026-10-10 04:37:44 +11:00
bflatastic 923d978119 Effects: CC Particle World follows the comp camera (#397)
Cause: CC Particle World projected its particles through a fixed camera
(distance 2 layer widths, looking at the layer centre) and never asked the
host for the comp camera, so moving or orbiting a camera layer left the
particles flat on the layer. It also had no Extras > Effect Camera. Separately,
the layer cache key ignored the comp camera and lights, so the effects that
read them (Card Dance / Shatter / Card Wipe Comp Camera, First Comp Light,
Caustics) kept a stale frame when the camera, a light or the layer moved.

Fix: as the Cycore manual describes, Particle World sees its particles through
the comp's active camera layer when there is one (EffectHost::comp_scene now
says whether its camera is a camera layer's, CompScene::camera_layer), through
card3d's comp-camera projection (factored out of Card Dance as comp_proj, with
Proj::view for depth, size scale and screen velocity). Otherwise it uses the
new Extras > Effect Camera: Distance (2), Rotation X / Y / Z (0) and FOV (50)
orbit the world origin; FOV moves the camera so the origin's plane keeps the
scale Distance gives it, so the defaults reproduce the old fixed camera bit for
bit (golden hashes unchanged) and old projects render as before. The Effect
Camera is left out of the simulation cache key, so animating it doesn't
restart the simulation. The plan is shared, so the GPU compositor follows too.
The layer cache folds the comp scene into the key of layers running an effect
in effects::READS_COMP_SCENE (render::comp_scene is shared with the host).

Tests: sim particle_world_follows_the_comp_camera (panning a camera layer moves
the particles, zooming spreads them, the Effect Camera is ignored then, the
default view keeps the Effect Camera, cameras facing away / zero / NaN draw
nothing) and particle_world_effect_camera (missing params = defaults, rotation,
distance and FOV change the view, extremes stay finite, the sim key ignores the
camera); render particle_world_follows_the_comp_camera (a real camera layer
panned 40 px moves the particles left, and the cached render after the move
equals an uncached one: it failed with the old cache key); gpu
particle_world_through_the_comp_camera (CPU vs GPU at 8 / 32 bpc, full and
half resolution). particle_golden / sim_golden unchanged. effects.md
regenerated; parity.md and gaps.md describe the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:35:25 +07:00
BFlat e9032c5ce2
Merge pull request #452 from storytold/fix/363-property-pick-whip
Timeline: every property has a pick whip in the Parent & Link column (#363)
2026-10-10 00:34:54 +07:00
llopjulen 096cca08b0 Add Spanish (es) UI translation
Add a Spanish catalog for the menus (618 entries), the computed titles and the macOS Services label, and list Español in the Language preference. Match System now maps es, es-ES, es_MX and es-419 to it. Add llopjulen to contributors/people.toml.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HcDWLRR3UfieXAyFa9cq4p
2026-10-09 19:30:26 +02:00
bflatastic 67b9b20a07 Timeline: every property has a pick whip in the Parent & Link column (#363)
What: each property row that can take an expression (it has a stopwatch) now
has a property pick whip in the Parent & Link column, as in After Effects
CC 2018 and later (automation id `timeline.prop.<uid>.propertyPickWhip`).
Dragged onto another property's name or one of its values, it runs
`prop.pickWhip`: the reference is added as the property's expression, or
replaces the one it has, and the expression is enabled. A drop on one value
picks that dimension (`position[1]`).

The parent, expression and property pick whips share one widget and one
drop path (rubber band, autoscroll, dimension pick); only the expression
pick whip inserts at the cursor of an expression being edited.

Pick whips (expression and property) now also drop on properties in
Effect Controls: its property rows register as targets while a pick whip
is dragged, with their per-dimension value fields.

Why: the last part of #363. Adobe's docs say the Property Link pick whip
adds the expression without enabling expressions first, and a pick whip
outside an edit replaces the whole expression text. No modifier keys for
the property pick whip are documented, so none are implemented.

Tests: `the_property_pick_whip_links_a_property_to_another` (ui_inline_edit)
drops it on a property's name, on one of Position's values, on a property
with a disabled expression (replaced and enabled), on an Effect Controls
slider and one value of a point, and on itself (no change). It fails
before the change; with Effect Controls' targets removed it fails at the
slider. Looked at the result with the headless snapshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:28:22 +07:00
BFlat 4b7bd135ec
Merge pull request #449 from storytold/fix/410-411-412-exr-ocio
EXR beauty layer, Interpret Footage Preserve RGB, OCIO v2 transforms for Blender configs (#412, #411, #410)
2026-10-10 00:27:39 +07:00
storybored379 8ed8a7e220 MCP: return on write errors while stdin is open; document requests during a render
Follow-ups from the #365 review:
- serve() no longer hangs when a reply cannot be written: the stdin reader thread is
  detached instead of scoped, so a failed write returns at once rather than waiting for a
  read that only ends at EOF. serve() now takes a 'static reader (serve_stdio is unchanged).
- A test pins down requests during a render: a second render is refused however it is
  started (blocking, wait: false, inside a batch), and edits apply to the project while the
  job keeps rendering its snapshot.
- docs/mcp.md: isError is true for any JSON result with failed > 0; what requests mean
  mid-render; wait: false renders are polled with renderQueue.list.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 10:26:57 -07:00
bflatastic 492fa6de0a Effects: OCIO custom configs read Blender's v2 transforms and warn (#410)
Cause: the config reader didn't know ColorSpaceTransform or
ExponentWithLinearTransform and passed colours through them silently.
Blender's Linear Rec.709 -> sRGB therefore stayed in its E-white XYZ
reference space (the blue cast) without the sRGB curve. Display
colorspaces, view transforms, v2 views, looks, AllocationTransform,
BuiltinTransform and .spi1d / .spimtx files were missing too, and
nothing told the user.

Fix (from the OCIO v2 documentation and the CLF "moncurve", not OCIO's
source): ExponentWithLinear, ColorSpace, Look, DisplayView, Allocation
and Builtin transforms (identity, ACES AP0/AP1 matrices, ACEScct /
ACEScc, the sRGB / P3 / Rec.1886 / gamma / PQ displays). RangeTransform
now clamps and takes one-sided ranges. Display colorspaces meet the
scene reference through the default view transform. Added v2 views,
view looks, the looks section and .spi1d / .spi3d / .spimtx files. A
conversion is built once as a flat list of steps (Config::path /
display_path) for both the CPU effect and the GPU colour program (new
moncurve and clamp ops and the ACEScc curve; a program over the
kernel's 255 ops renders on the CPU). Named-transform resolution is
limited to 16 levels and 4096 steps, and YAML nesting to 64 levels, so
hostile or looping configs stop. Transforms that are still unsupported
(Grading Primary / Tone, ACES output, HLG…), unreadable configs and
unknown spaces are now reported: a warning under the effect in Effect
Controls, and the new effect.warning query.

Tests: ocio_config::blender_like_config_converts_linear_rec709_to_srgb
(CST, display colorspaces, v1 / v2 views, allocation, looks, builtin,
range, loops), moncurve_matches_srgb_and_inverts, builtin_styles,
yaml_nesting_is_bounded, ocio::spi_files_parse,
tests_effects::ocio_effect_warns_about_unsupported_config_transforms,
gpu tests_fx_lut::ocio_custom_config_v2 (GPU = CPU).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:57:12 +07:00
bflatastic d6de68b20d Import: Interpret Footage ▸ Preserve RGB keeps the file's values (#411)
Cause: float footage (OpenEXR, float TIFF) was always sRGB-encoded on
decode, and Interpret Footage could not turn that off, so an OCIO effect
on the layer converted values that were already encoded.

Fix: After Effects' Interpret Footage ▸ Color ▸ Preserve RGB
(Footage::preserve_rgb; file.interpretFootage {"preserveRgb": true}; a
checkbox in the Interpret Footage form; Remember / Apply Interpretation
carry it). The decoder keeps float values linear, and the working-space
conversion from the colour profile and Interpret As Linear Light don't
apply, so the file's values enter the composition unconverted. The media
cache key includes the flag, so both interpretations decode separately.

Tests: pool::preserve_rgb_reads_float_exr_values_unconverted,
tests_color::preserve_rgb_footage_is_not_converted (managed, linearized
and not), tests_proxy::guess_alpha_invert_and_linear_light (command,
render, Apply Interpretation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:57:12 +07:00
bflatastic 698ade94ac Import: layered EXRs show the beauty pass, never Cryptomatte (#412)
Cause: a multi-layer OpenEXR without an unnamed RGB layer showed its
first colour layer unless one was named combined / beauty / rgba. A
Blender compositor File Output names the beauty pass "Image", and its
Cryptomatte layers (lowercase r, g, b, a) sort first, so the picture was
CryptoMaterial00.

Fix: the picture is the unnamed RGB layer, else the beauty pass by the
last part of its name (Combined, Beauty, Image, RGBA, RGB, Color), else
the first colour layer that isn't a data pass (Cryptomatte, depth, mist,
normals, vectors, positions, IDs). A layer without R, G and B shows its
own first channel as grey. After Effects shows a file's main RGBA and
leaves the other layers to EXtractoR, which is unchanged.

Tests: exr_channels::layered_exr_shows_the_beauty_pass_not_cryptomatte
(the reported Blender layout, ViewLayer.Combined, no beauty name,
data-only files).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:57:12 +07:00
BFlat 176cddcdad
Merge pull request #444 from storytold/fix/415-416-417-text-fidelity
Text and viewer fidelity: glyph seams, overlay fringes, text raster scale, viewer minification (#415, #416, #390, #417)
2026-10-09 23:56:18 +07:00
sanyamsingh001 4751a462dc #332: render Essential Graphics templates from CLI values
Co-authored-by: Codex <codex@openai.com>
2026-10-10 02:48:06 +10:00
David Campos 6bde4d7590
ui: import native file drops with drop-target hint (lib.rs) 2026-10-09 20:38:13 +04:00
David Campos f1839dd180
ui: import native file drops with drop-target hint (ui_drag_drop.rs)
Add tests for file drop behavior and error handling.
2026-10-09 20:37:39 +04:00
David Campos 95c7cf1f05
ui: import native file drops with drop-target hint (main.rs)
Add Wayland drop notice for unsupported file drops
2026-10-09 20:37:19 +04:00
David Campos bf4d616fc5
ui: import native file drops with drop-target hint (prefs_live) 2026-10-09 20:36:52 +04:00
David Campos 53d6b396ac
Update README with Linux file drag-and-drop details
Added information about file drag-and-drop functionality on Linux with Wayland backend.
2026-10-09 20:36:31 +04:00
bflatastic 9d85d4f116 Viewer: Auto resolution follows the magnification; minified frames are averaged (#417)
Cause: Auto dropped to Half below 75 % magnification, so text between
50 % and 75 % was rendered with fewer pixels than the screen shows and
looked soft. Below 100 % the frame texture was minified with a plain
bilinear sampler and no mip levels (Full at 46 % skipped most frame
pixels), so edges stair-stepped.

Fix:
- Auto renders the pixels the magnification needs, as in After Effects:
  the coarsest whole factor that still gives every screen pixel a
  rendered one (Full above 50 %, Half down to 33.3 %, Third down to
  25 %, Quarter below).
- GPU frames: the display texture carries a full mip chain, each level
  the 2 x 2 mean of the one above (display.wgsl `mip`, same bind group
  layout as `display`), sampled trilinearly with Viewer Zoom Quality
  More Accurate (Faster keeps the nearest pixel of the full-size frame).
- CPU frames (egui-wgpu textures have no mips): uploaded averaged down
  by the whole factor that leaves about one texel per screen pixel, the
  same box average as the texture-limit fit (#201), re-uploaded when the
  magnification calls for another factor; drawn with texture coordinates
  that keep each texel over its pixels when the size isn't a multiple.
  Show Channel / exposure textures follow.

Tests: gpu tests::display_frames_are_mipmapped (level count and level 1
= 2 x 2 means of level 0), viewer_tools
auto_resolution_follows_the_magnification (60 % was Half) and
minified_frames_keep_their_texels_in_place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:17:33 +07:00
bflatastic 94e4afe8de Text: text layers are always rasterised at their on-screen scale (#390)
Text is vector in After Effects and stays sharp at any Scale. Here a
text layer was drawn at its font size and the bitmap resampled by Scale
unless Continuously Rasterize was on, so text grown with Scale went soft.
This reworks #200 (reverted in 29c4d3a for kinetic_typography).

Why kinetic_typography failed: its time remap key at 1.9 s sits on
frame 23 (1.917 s), so main-comp 0.3 s shows the precomp at 1.6026 s,
but the test compared it with the precomp rendered at 1.6 s. The moving,
motion-blurred title already differed by 0.89 of the 1.0 mean-difference
budget on main (0.987 in all); with continuous rasterisation the "Sub"
line, whose expression scale is 84.00 % at 1.6 s and 84.105 % at
1.6026 s, falls on either side of the 2^-1/4 quarter-octave step and was
drawn at 0.84 at one time and at 1.0 at the other, pushing the total to
1.075.

Fix: raster_scale treats text layers as always continuously rasterised
(shape layers and vector footage still follow the switch), and rounds
the quarter-octave step up past rounding noise only: an unscaled layer
at a fractional position measured its scale as 1 + 1e-15 and was
rasterised at 2^1/4, softening unscaled text. The QA test now compares
the remapped precomp with the precomp at the time the remap really
shows (read back from the property): difference 0.63, all of it the
title's motion blur, the Sub line identical.

Tests: tests_collapse::text_scaled_up_stays_sharp_without_the_switch
(from #200: 10 px text at 800 % matches 80 px text),
tests_collapse::unscaled_text_rasterises_at_the_output_scale (fails
without the noise tolerance), automation qa kinetic_typography.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:17:32 +07:00
bflatastic 36ef45077a Styles: overlays keep anti-aliased edges clean (#416)
Cause: the interior styles were blended over the layer body with the
content's coverage folded into their opacity. On an edge pixel at 50 %
coverage a Gradient or Color Overlay went over the half-transparent body
as a half-transparent layer, so the result kept a quarter of the layer's
own fill and its alpha rose to 0.75: a light rim (the white fill
showing through) on styled text.

Fix: blend_clipped composites the interior styles (Gradient Overlay,
Color Overlay, Satin, Inner Glow, Inner Shadow and the inside of
Stroke) onto the layer within its shape: the body is divided by the
content coverage, blended with the style at its own opacity, and
multiplied back, so the alpha is kept and an edge pixel mixes the style
with the layer's colour inside the shape only, as a clipped layer does.
Fill Opacity and Blend Interior Styles as Group behave as before.

Tests: tests_styles::overlays_leave_antialiased_edges_clean (a masked
square with half-covered edge columns; Color Overlay, Gradient Overlay
and an inside Stroke now match a plain layer of the overlay colour;
Color Overlay's edge was 0.41 red against 0.23 before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:17:32 +07:00
bflatastic 88e2e6470a Text: touching glyphs join without a seam (#415)
Cause: each glyph's fill was rasterised and composited on its own, so
where two glyphs share an edge (the crossbars of "ff") two half-covered
pixels composited to 0.75 coverage instead of 1: a thin light seam,
where After Effects draws the line as one outline.

Fix: consecutive fill passes of the same opaque colour, without Blur,
under Normal Inter-Character Blending are rasterised as one coverage
mask (the glyphs' outlines in layer space, wound the same way so
overlaps add up under NonZero). Characters of other colours, translucent
or blurred ones, strokes and the other blending modes are drawn one by
one as before.

Tests: text::tests::touching_glyphs_join_without_a_seam (two glyphs
sharing an edge mid-pixel: 0.75 before, 1.0 now; opposite windings and
overlaps stay filled; other colours and Multiply still composite one by
one).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:17:32 +07:00
BFlat 2a460986ee
Merge pull request #445 from storytold/fix/398-tr-lookup
i18n: interface translations are looked up by hash (#398 follow-up)
2026-10-09 23:16:12 +07:00
bflatastic ea5dfae95f i18n: interface translations are looked up by hash, not by scanning the catalog (#398)
tr() scanned the whole catalog (about 380 rows) for every string a panel
draws, every frame, in Chinese. Each catalog is now indexed once, on
first use, in a HashMap keyed by the English source; English and
languages without rows still skip the lookup. The catalogs already have
no duplicate sources (catalogs_are_unique_and_keep_the_english_shape),
so the index finds the same rows.

Test: the i18n tests (pass-through, tr_args, catalog shape).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:15:54 +07:00
BFlat 4fad3e7af4
Merge pull request #436 from storytold/fix/393-370-timeline-ux
Timeline and viewer: resize cursors, time display toggle, pick whip, expression field, Tab, property menu (#393, #370, #362, #363, #371, #361, #407)
2026-10-09 23:10:56 +07:00
cdinhoo e445877494 UI: keep live previews updating during rapid edits 2026-10-09 19:06:17 +03:00
bflatastic 79bf5a6b59 Timeline: property names have the property menu (#407)
Right-clicking a property's name in the Timeline did nothing, so
Position's dimensions could be separated only from the Graph Editor.

As in After Effects, it now opens the property's menu: Reset, Edit
Value…, Separate Dimensions (Position and, once separated, X / Y / Z
Position, checked), Add / Remove Expression and Add Property to
Essential Graphics. Only Position separates, as in After Effects
(Scale stays one property).

`prop.reset` without `default` used to leave the value as it was: it
now gives the property its own default (an effect parameter's, or a
Transform property's, shared with Layer ▸ Transform ▸ Reset), which
also makes the Properties panel's Reset reset the values. `prop.set
{layer, prop}` without `value` opens the value dialog, like
`keys.set`.

Tests: timeline_property_names_have_the_property_menu
(ui_blank_context_menus), reset_gives_a_property_its_default (engine).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:52:56 +07:00
bflatastic 9c9e954ee2 Timeline: Tab moves to the next value field (#361)
Tab in a value being typed (hot text) dropped the keyboard: egui moved
the focus to the next widget, and the inline editors asked for the
focus again every frame, which also reset what keys a field keeps for
itself.

As in After Effects, Tab now commits the value and types in the next
value field of the same panel (the next dimension, then the next
property's), Shift+Tab the previous one. The hot-text fields of each
panel register in drawing order; the dock tags each panel's Ui so the
order stays within it. `keep_focus` asks for the focus only when the
field doesn't have it.

Tests: tab_commits_a_value_and_moves_to_the_next (ui_inline_edit:
Timeline and Effect Controls).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:52:56 +07:00
bflatastic 0171eb1e2e Timeline: the expression field grows while typing and drags taller (#371)
The inline expression field took its height from the saved
expression, so lines typed into it ran over the rows below until the
expression was committed.

Its height now comes from the text being typed (up to 8 lines, as
before), and its bottom edge drags it to any height in whole lines, as
in After Effects; the dragged height is kept per property in the
Timeline state (`expr_lines`).

Tests: the_expression_field_grows_while_editing_and_drags_taller
(ui_inline_edit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:52:56 +07:00
bflatastic 6fadae2818 Timeline: the pick whip picks the dimension it is dropped on (#363)
Dropping the expression pick whip on one of a property's values (the X
of Position) referenced the whole property. As in After Effects, a
drop on one value now picks that dimension: `transform.position[0]`;
a drop on the name still picks the whole property.

`prop.pickWhip` takes `target.dimension`.

The rest of #363 is unchanged: inserting at the cursor landed with
#284, and After Effects' property pick whip in the Parent & Link
column is left for later.

Tests: pick_whip_picks_one_dimension_of_the_target (engine),
the_expression_pick_whip_picks_the_dimension_it_is_dropped_on
(ui_inline_edit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:52:56 +07:00
bflatastic 639342a5e7 Timeline: the pick whip scrolls the layers at their edges (#362)
A pick whip dragged to the top or bottom of the Timeline's layers left
them where they were, so a property out of view couldn't be picked.

While a pick whip (expression or parent) is held within a row of the
top or bottom edge of the layers, or past it, the layers scroll that
way, faster the deeper, as in After Effects.

Tests: the_pick_whip_scrolls_the_timeline_at_its_edges
(ui_inline_edit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:52:56 +07:00
bflatastic 0b2ef8e584 Timeline: Ctrl/Cmd-click on the time display toggles Timecode / Frames (#370)
The Time Display Style could only be changed in Project Settings. As in
After Effects, Ctrl-click (Cmd-click on macOS) on the current-time
display of the Timeline or the Composition panel now toggles it between
Timecode and Frames (Feet + Frames goes to Timecode). It runs
`file.projectSettings {timeDisplay}`, so it is one undo step.

Under the Timeline's display the other style shows now (frames under
timecode, timecode under frames), as in After Effects; it always showed
frames.

Tests: ctrl_click_on_the_time_display_toggles_timecode_and_frames
(ui_inline_edit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:52:56 +07:00
bflatastic fcf3bb5dca Viewer: resize cursors follow each handle and the layer's rotation (#393)
Every bounding-box handle in the Composition viewer showed the
north-west / south-east resize cursor, so two corners and all four
edges showed the wrong direction.

Each handle now takes the cursor along the direction it scales, worked
out from the on-screen box: a corner points between its two edges (the
box's diagonal whatever its proportions), an edge handle across its
edge. The cursors turn with the layer's rotation, as in After Effects.

Tests: handle_cursors_follow_each_handle_and_the_layer_rotation
(ui_viewer: a wide box at 0°, 90° and 45°).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:52:56 +07:00
BFlat 609ef9cacb
Merge pull request #429 from storytold/fix/413-414-export-cache
Export: Render Queue jobs keep a layer cache and render on one compositor (#413, #414)
2026-10-09 22:51:10 +07:00
Brandon Thomas e3873ae92f CI: pushes to main no longer cancel the FreeBSD / Windows ARM64 run in progress
Merges land faster than the hour-long FreeBSD job, so every main run was
cancelled and main went unverified. Only PR pushes cancel now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:37:20 -04:00
bflatastic 8c4a3cbfe0 Export: a Render Queue job renders every frame on the project's renderer (#414)
Cause: pipeline::Cx rendered with Backend::Auto, which picks the CPU or the
GPU compositor for each top-level frame from measured frame times
(render::AutoPick: warm-up on both sides, a re-probe every 48 frames). The
two compositors differ slightly, so frames came out differently depending
on which side the timings favoured in that run, and two GPU exports of the
same frames differed.

Fix: the job renders every frame on the project's renderer, as After
Effects renders a job with one renderer: Backend::Gpu with Mercury GPU
Acceleration (frames the GPU declines still render on the CPU, as they do
in every GPU render), Backend::Cpu with Mercury Software Only. The viewer
and previews keep Auto. Docs (architecture, parity, web) say so.

Tests: render_settings::a_job_renders_on_one_compositor exports ten frames
with a stand-in GPU compositor that keeps Auto history and draws solid
green: with Mercury GPU Acceleration every frame is green and two runs are
identical (with Auto the warm-up sent frames to the CPU: it fails), with
Software Only the stand-in is never asked. The web worker GPU job test's
comment no longer describes Auto.

Diagnosis by @pat-the-wizard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:26:17 +07:00
bflatastic 1ccf7cb910 Export: Render Queue jobs keep a layer cache, so static layers render once (#413)
Cause: pipeline::Cx::render_at built the job's Renderer without a layer
cache, so every layer of every frame rendered from scratch (source, masks,
effects, styles), and the GPU compositor, whose uploads are keyed by the
cached buffers, uploaded every layer again on every frame.

Fix: each job (Cx) owns a LayerCache, the cache type the viewer uses, with
its default budget (1 GiB, the default Settings > Memory & CPU layer cache),
shared by the job's parallel frames and dropped with the job. With deferred
GPU readbacks it holds the accelerator's miss gate, as the job workers'
caches do. Cx::renderer builds the job's renderer (options, accelerator,
cache) for render_at and the test.

Tests: pipeline::tests::layers_render_once_per_job exports ten frames of a
solid animated only in Transform: the cache misses of the first frame are
the only ones (it fails without the cache: no layer is cached), and every
frame equals the frame rendered without the cache.

Diagnosis by @pat-the-wizard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:26:16 +07:00
BFlat f08d090cd8
Merge pull request #433 from storytold/fix/402-no-row-separators
UI: no parameter row separators, as in After Effects (#402 follow-up)
2026-10-09 22:14:05 +07:00
bflatastic 82f0cd9668 UI: Effect Controls and the Timeline have no parameter row separators, as in After Effects (#402)
#402 made Effect Controls' rows compact and aligned, as After Effects
draws them, and also added thin separator lines between parameter rows
in Effect Controls and the Timeline, with a Settings > Appearance > Show
Parameter Row Separators preference. After Effects has neither, so the
lines, the preference and the Tokens field go; the compact rows, the
alignment and the automation ids stay.

Saved settings with the old key load as before (unknown keys are
ignored).

Test: ui_parameter_spacing keeps the row geometry, hit target and
nesting tests; the separator tests go with the feature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:13:31 +07:00
FeiLiuEM 66df1696ac Text: the variable-font test fails when nothing moves instead of skipping
The test picked the first axis that moves an advance and skipped when none did, so a variation path
that broke completely - every axis measuring the same - took the skip and hid exactly the regression
the check is there for. Only a face with no axis carrying a range skips now.

When no ranged axis moves the advance (a monospaced family holds its advances on purpose), the
outline must still move along `wght`, or, on a face without one, along the first axis that carries a
range; a path that moves neither fails with a message that says so.

Verified on Linux with Google Sans Code and Bahnschrift installed:
- the normal path passes (the MONO axis is the one that moves the advance),
- forcing the branch where no axis moves the advance passes, so a legitimate monospaced face does
  not fail,
- forcing that branch and freezing the outline area fails with "wght 300..800: neither the advances
  nor the outlines move, so variation support looks broken".
`cargo fmt --all --check` and `cargo clippy -p effectcraft-text --all-targets -- -D warnings` clean.
2026-10-09 23:01:54 +08:00
Vikram Bhaskaran c0b3bde66f
Add rendered frame fidelity scoring 2026-10-10 01:59:03 +11:00
Brandon Thomas 9144eef269 Merge remote-tracking branch 'origin/main' into integrate5
# Conflicts:
#	docs/preferences.md
2026-10-09 10:33:41 -04:00
Brandon Thomas f1a31fb9eb i18n: drop the Ukrainian row for the removed Ease Presets panel (#401 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:33:17 -04:00
BFlat be676f90ec
Merge pull request #401 from dmatviichuk/i18n/ukrainian
i18n: add Ukrainian (uk) menu translation
2026-10-09 21:32:04 +07:00
Brandon Thomas 4047ea94ea Merge pull request #398 from FeiLiuEM/ui-i18n
i18n: translate the interface below the menu bar (panels, dialogs, tooltips)

# Conflicts:
#	crates/ui-egui/src/lib.rs
#	docs/preferences.md
2026-10-09 10:22:32 -04:00
bflatastic 07e163db64 i18n: Ukrainian drops the Ease Presets panel entry, which became a ScriptUI panel (#395, #404)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:22:15 +07:00
Brandon Thomas a0c6a6e96e Merge pull request #401 from dmatviichuk/i18n/ukrainian
i18n: add Ukrainian (uk) menu translation

# Conflicts:
#	crates/text/src/fonts.rs
#	docs/preferences.md
2026-10-09 10:22:14 -04:00
Dmytro Matviichuk a787d077d4 i18n: add Ukrainian menu translation (#395) 2026-10-09 21:22:07 +07:00
Brandon Thomas ea785355cd GPU: note why Posterize's division correction is an fma (#405 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:22:01 -04:00
Brandon Thomas 9b852b62af Merge pull request #405 from sanyamsingh001/fix/gpu-threshold-parity
Fix GPU Posterize threshold parity

# Conflicts:
#	contributors/people.toml
2026-10-09 10:21:53 -04:00
Brandon Thomas 9b62a4a833 Merge pull request #402 from cdinhoo/codex/parameter-row-spacing
UI: Compact and align effect parameter rows
2026-10-09 10:21:43 -04:00
BFlat 7885b67aa9
Merge pull request #404 from storytold/feat/plugins-ae-parity
Plug-ins and extensions as in After Effects: ScriptUI panels reopen, Ease Presets becomes a bundled panel, AE's Snapping options
2026-10-09 21:21:25 +07:00
cdinhoo 5f1f346366 G2: Fix effect input semantics and control activation 2026-10-09 17:16:36 +03:00
bflatastic da165724e9 Plug-ins: Ease Presets moves out of core into a bundled ScriptUI panel
The Ease Presets panel (#254) is modelled on a third-party panel, so,
as with third-party tools in After Effects, it isn't core any more: it
is `extensions/scriptui-panels/Ease Presets.jsx`, a ScriptUI panel
bundled as an optional extension (Window ▸ Ease Presets.jsx), written
only against the public scripting API. Clicking a preset eases every
pair of neighbouring selected keyframes in one undo step (per
dimension, along the motion path for spatial properties, the keys'
other sides as they play); the fields edit the working curve, which the
panel draws; From Keys, Save, Rename and Delete as before. The twelve
built-in curves are unchanged.

Scripting API fixes it needed (After Effects parity):
- `app.settings` lasts between runs (`script_settings.json` in the
  settings folder; `script.settings.get` / `script.settings.save`).
- Per-key methods (`setTemporalEaseAtKey`, `setInterpolationTypeAtKey`,
  `removeKey`, the continuous / auto-Bezier / roving setters) no longer
  change the user's key selection (they selected the key they edited).
- `keyInTemporalEase` / `keyOutTemporalEase` report linear and
  auto-Bezier sides as they play, `keyIn/OutSpatialTangent` the
  tangents the motion path plays.
- `selectedProperties` includes the properties of selected keyframes.
- `isInterpolationTypeValid` is false for Linear / Bezier on values that
  only hold.

Removed from core: Window ▸ Ease Presets, the `keys.easePreset.*`
commands, `engine::ease_presets` and the `ease_presets.json` store,
`keyframe::EaseCurve` (nothing else used it) and the egui panel. User
presets saved by the v0.6.0 panel move into the panel's app.settings
section the first time settings load (the old file is left as is).
Docs: plugins.md (bundled extensions), parity.md, gaps.md G8, agents.md.

Test: app_settings_last_between_runs,
per_key_methods_keep_the_key_selection_and_report_eases_as_they_play,
spatial_tangents_are_reported_as_the_path_plays (each fails before),
the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step,
a_typed_curve_scales_to_each_dimension_and_follows_motion_paths,
user_presets_save_rename_delete_and_persist,
presets_saved_by_the_old_core_panel_carry_over (script, through the
panel's controls), ease_presets_panel_eases_the_selected_keys_from_its_list
(UI: the panel docks and a click on a preset eases the keys).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:03:12 +07:00
bflatastic 9da33c157e Plug-ins: ScriptUI Panels folder scripts dock as panels and reopen, as in After Effects
EffectCraft's extension points compared with After Effects' (public
user guide and Scripting Guide) are now in docs/plugins.md, which
becomes "Plug-ins and extensions": scripts, ScriptUI panels and effect
plug-ins, with a short guide and an example panel for writing your own.

ScriptUI panels already came from `Scripts/ScriptUI Panels/` in the
settings folder, were listed at the bottom of the Window menu and
docked as tabs. What was missing: After Effects reopens the panels
that were open when it quit. Now the open panels are remembered in
`scriptui_panels.json` in the settings folder (a panel is added when
it opens and dropped when its tab or its script closes it; quitting
leaves the list), and `window.restoreScriptPanels` opens them again;
the desktop and web apps run it at start-up. A panel whose script is
gone or fails is reported and forgotten, and the others still open.
Where a panel was docked isn't kept, as workspace layouts aren't saved
between launches.

Gaps recorded in plugins.md and gaps.md G8: the Startup and Shutdown
script folders, keyboard shortcuts for scripts, ExtendScript's
`#include` / `#target` directives and `.jsxbin`.

Test: open_panels_open_again_at_the_next_launch (fails before: no
restore command), a_panel_dropped_in_the_folder_is_listed_and_opens_as_a_panel
(script), panels_open_at_quit_dock_again_at_launch (UI: the restored
panel docks with its controls; closing the tab forgets it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:03:12 +07:00
bflatastic 6ce58040fd Viewer: the Snapping menu has After Effects' two options
The Tools bar's Snapping options menu (#265) offered per-feature
toggles (Edges, Corners, Centers, Anchor Points, Mask and Shape Path
Points) that After Effects' Snapping menu doesn't have. It now has the
two After Effects has:

- Snap Edges Extended (as before).
- Snap to Features in Collapsed Compositions and Text Layers: the
  visible layers inside a precomp layer with Collapse Transformations
  on are snap targets too, mapped into the outer comp (points always,
  edge lines while the precomp layer is unrotated); a snap highlights
  the precomp layer. Nested collapsed precomps are followed eight deep.

Every layer feature snaps again (corners, edge midpoints, centre,
anchor point, mask and shape path points); the snapping engine itself
is unchanged. `view.snappingOptions` takes `edgesExtended` and
`collapsedFeatures` (and `toggle` of either); the old keys are refused.

Test: collapsed_precomp_features_snap (fails before: no such option),
snapping_options_command_sets_and_toggles (the per-feature keys are
refused), snapping_options_pick_the_targets (AE behaviour kept: edges
extended or not, hidden layers, guides only), and the Snapping menu UI
test (only the two options are listed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:03:12 +07:00
BFlat b9d1f79f7d
Merge pull request #422 from storytold/fix/297-start-timecode
Import: Interpret Footage has Start Timecode, as in After Effects (#297)
2026-10-09 21:02:18 +07:00
bflatastic a34aecd719 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:39 +07:00
Brandon Thomas e12bf5ca7d Merge pull request #289 from storytold/fix/227-shape-effect-points
Effects: effect points on shape layers start at the layer centre (#227)

# Conflicts:
#	crates/ui-egui/src/panels/puppet_tool.rs
#	crates/ui-egui/src/panels/viewer.rs
2026-10-09 09:32:13 -04:00
Brandon Thomas 2c199d55f8 Merge pull request #384 from storytold/feat/297-image-sequences
Import: image sequences with a Sequence option, ranges, folders and missing frames (#297)

# Conflicts:
#	docs/parity.md
2026-10-09 09:31:51 -04:00
Brandon Thomas 14417c0400 Merge pull request #354 from thanhcong2k7/fix/ui-dock-focus-outline-zorder
UI: Draw panel focus outline after panel body to prevent clipping
2026-10-09 09:31:43 -04:00
Brandon Thomas 3d1b87f0d4 Merge pull request #345 from thanhcong2k7/fix/ui-commit-text-on-tool-switch
UI: Commit text edit session when switching away from Type tool
2026-10-09 09:31:42 -04:00
Brandon Thomas e52ff9ab95 Merge pull request #342 from HEOJUNFO/fix-313-single-pin-jump
Puppet: the first pin no longer turns the layer about it (#313)
2026-10-09 09:31:41 -04:00
Brandon Thomas d5beb4b3c4 Merge pull request #344 from MindlessRuff/fix/exr-over-range
fix(export): keep values above 1.0 in EXR output (#339)
2026-10-09 09:31:40 -04:00
Brandon Thomas f87f3b3e1a Merge pull request #357 from HEOJUNFO/fix-317-aiff-float
Export: AIFF 32-bit float writes AIFF-C fl32 instead of 24-bit (#317)
2026-10-09 09:31:38 -04:00
Brandon Thomas b6ba84d828 Merge pull request #353 from cjcwwww/i18n/zh-hans-terminology
i18n: match the Simplified Chinese labels to the installed 26.5.0 UI
2026-10-09 09:28:40 -04:00
Brandon Thomas a990432962 Merge pull request #312 from FeiLiuEM/font/cjk-locale
text, ui: the CJK fallback fonts follow the interface language
2026-10-09 09:28:38 -04:00
Brandon Thomas 64d1965648 Merge pull request #319 from thanhcong2k7/main
fix: rewrite Audio Spectrum to match After Effects linear frequency/amplitude model
2026-10-09 09:28:36 -04:00
Brandon Thomas 3df56ce9d9 MCP: describe_command and execute_command accept `id` again (#365 follow-up)
#365 rejects arguments missing from a tool's schema; both tools read `id`
as an alias of `command`, so declare it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:25:25 -04:00
Brandon Thomas 7b41793ab4 Merge pull request #365 from storybored379/feat/mcp-conventions
MCP: core tools, resources, render progress and cancellation
2026-10-09 09:25:25 -04:00
Brandon Thomas 754e998989 Merge pull request #374 from storytold/fix/368-tflite-budgets
Fix #368: tflite rejects models whose scratch or work exceeds a budget
2026-10-09 09:25:24 -04:00
Brandon Thomas a3cfc3e014 Merge pull request #388 from KazW/fix/mediapipe-crop-sampling
segment: sample crops like MediaPipe's ImageToTensor; no overflow in crop
2026-10-09 09:25:23 -04:00
Brandon Thomas d3684216e4 Merge pull request #373 from storytold/fix/367-nms-degenerate
Fix #367: weighted_nms terminates on NaN or zero-area boxes
2026-10-09 09:25:22 -04:00
Brandon Thomas 68d94b8031
Merge pull request #281 from XusBadia/dmg-background
macOS DMG: branded window background and icon layout
2026-10-09 09:20:44 -04:00
WaWu 1eecb40a6a i18n: match the Simplified Chinese labels to the installed 26.5.0 UI
Checked every submenu of Adobe After Effects 26.5.0 (Build 89), Simplified Chinese, against
the catalog and took the wording the application shows. 81 of the 618 labels change; keys,
order, count and the Traditional Chinese catalog are untouched.

Three groups:

- 61 labels where #309 and this branch disagreed and the application matches this branch -
  including the five blend modes whose Chinese names differ between After Effects and
  Photoshop (屏幕, 发光度, 较深的颜色, 较浅的颜色, 纯色混合), 导入参考... / 导出参考...
  (the application omits the 线), and 类型... for the Type settings page.
- 11 labels where both catalogs were wrong and this takes the application's wording:
  向基本图形添加属性, 移除所有的文本动画器, 显示关键帧的属性, 显示动画的属性,
  将合成裁剪到工作区, 裁切合成至选定图层边界, 基于 3D 视图生成摄像机,
  创建立体 3D 设备, 使用摄像机控制光线, 使用显示色彩管理, 淡灰色.
- 9 labels of spelling and punctuation: 沿路径进行空值追踪 (a stray '1'), 启用逐字 3D 化,
  裁剪合成到目标区域, 中灰(默认) with full-width parentheses.

Left for the maintainers: Material (the application says 素材, but it also calls Footage 素材),
the 导出 prefix on the Premiere export row (the application repeats it but drops 'Pro'), and the
verb in Reset 3D View (the application is inconsistent between 重设 and 重置).
2026-10-09 21:20:41 +08:00
cdinhoo b6cf4a8a60 UI: Compact and align effect parameter rows 2026-10-09 16:09:13 +03:00
sanyamsingh001 6d99f68330 G1: preserve GPU Posterize threshold parity
Use an explicit FMA for the final quotient correction at Posterize
boundaries. Align the kinetic motion-blur QA comparison with export
settings and credit sanyamsingh001.

Co-authored-by: Codex <codex@openai.com>
2026-10-09 23:03:05 +10:00
Dmytro Matviichuk ceda5bd607
i18n: add Ukrainian menu translation (#395) 2026-10-09 14:46:20 +02:00
FeiLiuEM 5344f304c3 text, ui: borrow the app at the test call sites, and drop a redundant lifetime
Two things are one level deeper than the first pass at this review:

- Nine of the call sites that pass the resolved language are inside test functions, where `app`
  is owned, so `crate::i18n::language(app)` needs `&app`. `cargo check --all-targets` (which
  compiles the test modules as well) is what reported them; a check without it does not, which is
  how they survived the first round.
- `clippy -D warnings` rejects the explicit `'static` on `const KANA` / `const HAN`: a const
  already has one.

On this branch `cargo fmt --all --check`, `cargo check -p effectcraft-ui-egui --all-targets`,
`cargo clippy -p effectcraft-ui-egui --all-targets -- -D warnings` and
`cargo test -p effectcraft-ui-egui` (40 targets, 113 lib tests) are clean.
2026-10-09 20:34:03 +08:00
Brandon Thomas 29c4d3ad15 Revert #200 (text always continuously rasterised): breaks kinetic_typography
The QA test's time-remapped precomp no longer matches the precomp rendered
directly (crates/automation/tests/qa/kinetic.rs:78); FreeBSD CI has been red
since. Reverting until the PR is reworked against that test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:22:58 -04:00
FeiLiuEM 55ba002acf Text: the variable-font test picks an axis that moves the advance
`style_variations_change_advances_and_outlines` picked `wdth`, else `wght`, else the first axis
with a range, and then asserted that the advance moves. That assumes the chosen axis changes
advances, which a monospaced family does not: Google Sans Code - the first variable face on a
machine where it is installed, and the one this check met on Linux - moves its outlines along
`wght` but keeps its advances fixed by design, so the test failed with "wght 300..800: advances
780 vs 780" while `measure` and the font were both right.

The test now tries the axes in order and uses the first one whose advance actually moves (Google
Sans Code answers on its own MONO axis, Bahnschrift on `wdth`), and skips with a message on a
machine whose variable faces never move an advance - the same shape as the "no variable font
installed" skip next to it. The outline assertion keeps its teeth: the axis is picked for moving
the advance, but whether the outline follows it is still checked.

On Linux with Google Sans Code and Bahnschrift installed: `cargo test -p effectcraft-text`
reported 43 passed / 1 failed before and 44 passed after. `cargo fmt --all --check` and
`cargo clippy -p effectcraft-text --all-targets -- -D warnings` are clean.
2026-10-09 20:21:05 +08:00
FeiLiuEM 7df9b981cd panels: the render queue, effects and the remaining panels read their text from the catalog
Render queue settings, templates and progress, the effect and preset panels, Essential Graphics,
precomposing, the tutorials, the credits and the info/scope readouts now go through `crate::i18n`.
Units (`px`, `%`, `mm`, `s`) and the automation ids and labels stay English on purpose: they are
read by scripts and tests, not by people.
2026-10-09 20:17:05 +08:00
FeiLiuEM a15a436647 panels: the timeline, viewer, project and layer panels read their text from the catalog
Timeline switches and column headers, the viewer's tools and guides, the Project panel's menus,
tooltips and the layer, properties, graph, marker, tracker and camera-tracker panels now go through
`crate::i18n`.
2026-10-09 20:17:05 +08:00
FeiLiuEM add2e977f6 panels: dialogs, settings and forms read their text from the catalog
The dialogs, Settings pages, unsaved-work and template prompts, the script console and the media
panels now go through `crate::i18n`, so their buttons, field names and hints follow
Settings ▸ General ▸ Language.
2026-10-09 20:17:05 +08:00
FeiLiuEM cceaba0452 i18n: a catalog for the interface below the menu bar
The menu bar has had a catalog since #229 (keyed by `(command, English label)`); everything below
it - panel labels, buttons, field names, tooltips, the sentences a panel draws itself - was written
straight into the code and stayed English in every language (#333).

This adds `crates/ui-egui/src/i18n/ui.rs`:

- Rows are keyed by the English source string, so a panel wraps a literal it already has instead of
  inventing an id, and a row that is missing falls back to English rather than to an empty label.
- `tr("Rename")` translates a plain string; `tr_args("Proxy Use: {}", &[&quality])` fills the values
  of a row that carries them. `tr_args` writes into its own buffer, so it cannot panic on a mismatch,
  and a test keeps every row's placeholder count equal to its English source.
- The language in force is set once per frame from the resolved interface language - the same value
  the CJK fonts are installed for - and kept in a thread-local, so the panels do not have to thread
  it through every function. The render threads never draw interface text.
- Simplified and Traditional Chinese are complete (384 strings); the Japanese rows are still open,
  and the catalog takes them without any other change.

Tests: the two Chinese catalogs must list the same source strings, no empty or duplicated rows, every
row must be asked for by a converted panel, and a converted panel that gains a hard-coded string in a
common call shape fails the build.
2026-10-09 20:17:05 +08:00
bflatastic efd348335f 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:52:19 +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
FeiLiuEM ad3da3c8de text, ui: follow a language change, and resolve the language before the fonts
Review follow-ups on #312:

- `crates/ui-egui/src/theme.rs`'s `japanese_font_tests` still called `install` with two
  arguments, so `cargo test` did not compile. Every call site now passes a language.
- The call sites passed the stored `general.language` preference. With the default `system`
  that is `""`, which handed the CJK choice back to the environment locale — one macOS and
  Windows usually do not set. They now pass `crate::i18n::language(app)`, the resolved
  language, so Match System draws Chinese on a Chinese system.
- Changing Settings ▸ General ▸ Language only reached the fonts on the first frame.
  `EffectcraftApp` now remembers the language its fonts were installed for and installs them
  again when it changes, so the switch applies without a restart.
- Documented that `fonts::set_cjk_locale` is process-global: the Han preference therefore
  applies to composition text as well as the interface chrome (docs/preferences.md and the
  function's own comment).

Rebased onto main after #309 landed — `header.rs` gained an `install` call site in the
meantime, and the catalogs now resolve through `i18n::language`.
2026-10-09 19:28:42 +08:00
FeiLiuEM a4d1e8d0b3 text, ui: the CJK fallback fonts follow the interface language
- `effectcraft_text::fonts::set_cjk_locale` lets the host say whose face should draw Han
  ideographs instead of leaving it to the locale environment, and drops the fallback cache when
  it changes. `han_order` reads that request first; the tag → order rule moves into the pure
  `han_order_for`, so it can be tested without touching process state.
- `theme::install` now takes the stored `general.language` value and registers one CJK system
  fallback per script in every font family, the interface's own script first: kana for Japanese,
  a Han character for `zh-hans` / `zh-hant`, then the other script so names in it still render.
  A face is registered at most once, and a machine with no CJK font installs nothing (the web
  build installs nothing, as before).
- Before this the probe was always `あ`, so Han text was drawn by the Japanese face whenever the
  operating system's locale was not Chinese: a Chinese interface, and Chinese file, layer or
  template names on any locale, both got Japanese glyph forms.
- Tests: the tag → order rule (`zh`, `zh_Hans`, `zh-Hant-TW`, `ko`, `ja`, `en`, empty), and the
  registration order plus once-only registration for a Chinese, a Japanese and an English
  interface.
2026-10-09 19:27:47 +08:00
bflatastic 50c9549529 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:00:07 +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
Kaz Walker 5304437629 segment: sample crops like MediaPipe's ImageToTensor; no overflow in crop (#385, #386)
Fixes #385, Fixes #386.

crop now maps the square's corners to the output corners, reads the source position as a
pixel-centre index, rounds to a whole 0-255 value, and takes a Border (Zero for the detector,
Replicate for the mesh), matching MediaPipe's CPU ImageToTensor. The frame size product is
checked, so an absurd size gives an lo-filled tensor instead of an overflow panic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 04:38:07 -06: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
BFlat 928117db25 Fix #368: tflite rejects models whose scratch or work exceeds a budget
Model::read bounded each tensor but nothing else, so a tiny hostile
.tflite could make Model::run abort on a huge CONV_2D patch allocation,
or keep it busy for minutes. Read now works out from the static shapes,
with checked arithmetic, what one run takes and refuses a model over a
fixed budget: kernel side (32), one operator's working space (2^22
floats), multiply-adds (2^33), values written (2^30), floats alive at
once (2^29) and constant values decoded or laid out at load (2^27).

The shapes run() computes itself now have to match the declared ones
(PAD, CONCATENATION, MAX_POOL_2D channels), and a zero dimension no
longer lets the others grow without bound.

Reported by KazW, with the suggested load-time budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:33:32 +07:00
Nguyen Thanh Cong 581326ec9f Effects: Audio Spectrum uses linear frequency bands and amplitude 2026-10-09 16:27:29 +07:00
BFlat 597f239c57 Fix #367: weighted_nms terminates on NaN or zero-area boxes
A degenerate detection box (any NaN coordinate, or zero area) has an
overlap of 0 with itself, so `top` was partitioned into the remainder and
the loop never shrank `dets`. `top` now always joins its own cluster, so
every pass removes at least one detection. Well-formed boxes behave as
before (their self-overlap is 1).

Reported with the fix by KazW.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:16:19 +07:00
storybored379 7b632bdbc1 MCP: report render progress and handle cancellation
Keep serving during long renders, recover failed workers, and remove only files opened by the interrupted export. Cover progress, cancellation, EOF and output ownership with regression tests.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 00:20:22 -07:00
storybored379 164538fecb MCP: add core tools, annotations and resources
Add strict tool keys, resource reads and panic recovery with regression coverage. Keep existing documented tools listed.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
2026-10-09 00:09:37 -07:00
heojunfo 303056eec4 Export: AIFF 32-bit float writes AIFF-C fl32 instead of 24-bit (#317)
The Output Module offers 32 Bit Float for AIFF and the queue reports F32, but
audio_file silently wrote 24-bit PCM because plain AIFF has no float. Write
an AIFF-C file instead: FORM type AIFC, an FVER chunk (AIFC Version 1), and a
COMM chunk naming the fl32 compression type, with big-endian IEEE float
samples in SSND. 16- and 24-bit stay plain AIFF.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 15:33:04 +09:00
Nguyen Thanh Cong 101d1956ac UI: Draw panel focus outline after panel body to prevent clipping 2026-10-09 12:58:31 +07:00
Xus Badia 5826377d17 macOS DMG: branded window background and icon layout
The DMG opened as a plain Finder window. It now shows the app icon's figure on
the EffectCraft colour, with EffectCraft.app and the Applications link side by
side.

- packaging/macos/dmg/: background.svg (source; text outlined, the figure an
  <image> of assets/app-icon) and dmg-layout.DS_Store (Finder view settings:
  window, icon size 128, positions, background), written by generate.py on any
  OS, byte-for-byte reproducible, with nothing from the machine that ran it.
- package.sh renders the SVG with resvg (1x + 2x) into a HiDPI TIFF at package
  time, so no bitmap is committed; release.yml installs resvg 0.48.1 (pinned,
  --locked) on the macOS runner. The image is still built with hdiutil
  makehybrid.
- The volume name drops the version: the layout finds the background through
  an alias that includes the volume name. The DMG file name keeps the version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:38:17 +02:00
Nguyen Thanh Cong b5da7a5754 UI: Commit text edit session when switching away from Type tool 2026-10-09 12:17:13 +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
heojunfo 2cd7a36d66 Puppet: the first pin no longer turns the layer about it (#313)
With one Position pin and no Bend pins, every rotation and scale about the
pin is free in the ARAP energy. The 1e-9 regulariser (about 1e-12 after the
edge-length scaling) is too weak to hold them, so the CG solve turned
round-off into a visible rotation of several pixels, depending on where the
pin landed. The exact solution is the rest mesh translated by the pin's
offset, so deform() returns it directly in that case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 12:34:00 +09:00
MindlessRuff 1b2c5d1944 fix(export): keep values above 1.0 in EXR output (#339)
The EXR sequence writer and comp.saveFrameAsExr clamped colour to 0..1 before the sRGB decode, so a 32 bpc project lost every over-range value. The lower bound stays at 0 and alpha stays clamped. Tests cover Rgba and Rgb sequences and saveFrameAsExr.
2026-10-08 20:22:49 -07: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
DWAA1660 69310216fb Fix #263: explain property-group references in property commands 2026-10-08 08:49:42 -06: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 940bea3ba7 Effects: effect points on shape layers start at the layer centre (#227)
Shape and text layers have comp-sized effect bounds centred on their
origin, but effects measured positions from the origin: a point at its
default "layer centre" sat at the comp's bottom-right corner, so Twirl
on a centred shape changed nothing and Bulge only its bottom-right
quarter. As in After Effects, effects now work in effect space,
measured from the top-left of the layer's effect bounds.

The renderer moves the layer buffer into effect space for the whole
stack (Buf::rebase, CPU and GPU alike) and hands effects everything
else in it: masks, other layers' pixels for layer parameters (each in
its own effect space, so Set Matte and displacement maps see a whole
shape layer), the layer's own frames (layer_input, also what Puppet,
Roto Brush and the Layer panel read) and the comp camera
(CompScene::rebased). Layer bounds are now right for every effect, so
pin_rect and EffectEnv::bounds_origin go and layer_rect serves edge
pinning too. Adjustment layers still run on the comp below as it is.

effect_bounds / EvalCtx::effect_to_comp replace the repeated "comp size
when there is no source" code; the viewer's effect point handles and
eyedroppers, Puppet pins, the Layer panel (paint, Roto Brush) and the
tracker's Corner Pin output use them.

Project schema 2: opening a schema 1 project moves positions held by
effects on shape and text layers by half the comp size, so set points
keep their place (point parameters with keyframes, Paint paths and
clone positions, Puppet seeds and pins, Liquify and Roto Brush
strokes). Points still at their default become the layer centre;
expression controls, adjustment layers and expressions are left as
they are (docs/architecture.md). The disk cache's render version is
bumped.

Tests: default_effect_points_sit_at_the_centre_of_a_shape_layer and
layer_parameters_see_a_whole_shape_layer (render, both fail before),
effect_points_on_a_shape_layer (GPU vs CPU),
opening_a_schema_1_project_moves_shape_layer_effect_points_into_effect_space
(engine), and the effects migration tests.

Part of #227

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:33:03 +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
RicSchonfelder c8fde702fe i18n: add Brazilian Portuguese (pt-br) UI translation 2026-10-07 18:46:26 -03: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
Brandon Thomas 726d7e47a1 Control: ui.click / ui.drag hold their modifiers for the whole gesture
egui takes i.modifiers from ModifiersChanged events, not from the modifiers
on a pointer event, so a control-channel Shift-click arrived as a plain click
(e.g. Shift-click in the Timeline replaced the selection instead of adding).
ui.click and ui.drag now queue ModifiersChanged before the gesture and clear
it after. The synthetic-input feeding moves into EffectcraftApp::feed_synthetic
so it can be tested; the new tests fail without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:16:02 -04: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
805 changed files with 89595 additions and 7063 deletions

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

@ -0,0 +1,32 @@
# 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
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
# or dependency change: a drifted dependency still shows up within a day, and always before a
# release ships. PRs that change the FreeBSD packaging itself still run it.
on:
schedule:
- cron: "47 2 * * *"
push:
branches: [release]
pull_request:
paths:
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
workflow_dispatch:
concurrency:
group: freebsd-${{ github.ref }}
# A push to main doesn't cancel the run in progress (the newest waiting run replaces older ones),
# so main gets complete runs however often it moves; PR pushes do cancel.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
freebsd:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main

View File

@ -1,5 +1,6 @@
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax,
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata. Plus a
# Windows MSI install test with stub binaries (install folder, upgrade, repair, uninstall).
name: Packaging lint
on:
@ -19,35 +20,5 @@ concurrency:
jobs:
lint:
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP" actionlint
- name: actionlint
run: |
"$RUNNER_TEMP/actionlint" -color
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh
- name: PowerShell
shell: pwsh
run: |
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/effectcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
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'"
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main

View File

@ -1,9 +1,3 @@
# 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.
name: Release
on:
@ -17,271 +11,32 @@ on:
default: ""
permissions:
contents: read
contents: write
concurrency:
group: release-${{ github.ref }}
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
EFFECTCRAFT_BUILD_SHA: ${{ github.sha }}
INPUT_VERSION: ${{ inputs.version }}
jobs:
version:
name: version
runs-on: ubuntu-latest
environment: release
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
- id: v
name: Resolve version
run: |
. packaging/env.sh
v="${INPUT_VERSION#v}"
v="${v:-$VERSION}"
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
exit 1
fi
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
{
echo "version=$v"
echo "date=$(date -u +%Y-%m-%d)"
echo "prerelease=$pre"
} >>"$GITHUB_OUTPUT"
echo "### EffectCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
name: macOS universal
needs: version
runs-on: macos-15
environment: release
env:
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
with:
key: release-macos-universal
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$EFFECTCRAFT_VERSION"
- name: Import signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: packaging/macos/import-cert.sh
- name: Build, sign, notarize
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@v4
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
- name: Remove temporary keychain
if: always()
run: |
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
env:
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
shell: bash
run: cargo xtask version set "$EFFECTCRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build, sign, package
shell: pwsh
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
# 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
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
env:
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
- name: Install build and packaging tools
run: |
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
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"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$EFFECTCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
run: packaging/linux/package.sh
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/effectcraft$|metainfo|applications'
ldd target/release/effectcraft
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.effectcraft.yml')); assert m['id'] == 'ai.storyteller.effectcraft'"
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
done
- uses: actions/upload-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
environment: release
env:
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
WASM_BINDGEN_VERSION: 0.2.129
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
key: release-web
- name: Install wasm-bindgen CLI
run: |
# Must match the wasm-bindgen version in Cargo.lock exactly (cargo xtask web checks it).
if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION"; }; then
cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
fi
wasm-bindgen --version
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$EFFECTCRAFT_VERSION"
- name: Build and zip
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
with:
name: release-web
path: dist/release/*
if-no-files-found: error
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, web]
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@v4
with:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Checksums
working-directory: artifacts
run: |
sha256sum -- * | tee SHA256SUMS.txt
- name: Create or update the draft release
working-directory: artifacts
run: |
tag="v$VERSION"
title="EffectCraft v$VERSION"
pre=()
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != "true" ]; then
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
exit 1
fi
echo "Updating draft $tag: replacing its assets"
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
gh release delete-asset "$tag" "$asset" --yes
done
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
echo "Creating draft $tag"
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
fi
{
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$GITHUB_STEP_SUMMARY"
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
app-name: effectcraft
app-title: EffectCraft
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}

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

@ -0,0 +1,35 @@
# 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
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
on:
schedule:
- cron: "13 5 * * *"
push:
branches: [release]
pull_request:
paths:
- .github/workflows/windows-arm64.yml
- packaging/windows/**
workflow_dispatch:
concurrency:
group: windows-arm64-${{ github.ref }}
# A push to main doesn't cancel the run in progress (the newest waiting run replaces older ones),
# so main gets complete runs however often it moves; PR pushes do cancel.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
windows-arm64:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: effectcraft
app-dir: EffectCraft

2
.gitignore vendored
View File

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

View File

@ -105,11 +105,40 @@ 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
- [ROADMAP.md](ROADMAP.md): stage, headline numbers and what's next
- [docs/gaps.md](docs/gaps.md): every known shortfall against After Effects, ranked, and the prioritised workstreams (G1–G9); read it before choosing work
- [docs/target-app-parity.md](docs/target-app-parity.md): the parity numbers and how they were measured, with the per-area docs it links (UI, file formats, hardware, localization, effects, scripting); progress docs follow [craftrules `standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md)
- [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

@ -17,6 +17,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri
| `assets/fonts/NotoSerif-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/latin-greek-cyrillic | OFL-1.1 (`assets/fonts/OFL-NotoSerif.txt`) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | The JetBrains Mono Project Authors | https://github.com/JetBrains/JetBrainsMono | OFL-1.1 (`assets/fonts/OFL-JetBrainsMono.txt`) |
| `apps/effectcraft-web/web/favicon.svg` | EffectCraft contributors | Original work: web app icon, a copy of `assets/app-icon/effectcraft-small.svg` (the unicorn app icon) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `packaging/macos/dmg/background.svg` | @XusBadia | Original work: macOS DMG Finder window background, derived from the EffectCraft app icon (`assets/app-icon/effectcraft.svg`); rendered to a TIFF by `packaging/macos/package.sh` at package time | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) |
| `assets/app-icon/effectcraft-1024.png` | EffectCraft contributors | Original work: effectCraft app icon, 1024 px (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/effectcraft-macos-512.png` | EffectCraft contributors | Original work: effectCraft runtime Dock icon (macOS) (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/effectcraft-small.svg` | EffectCraft contributors | Original work: effectCraft app icon, light vector (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
@ -57,19 +58,51 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall
## Code-drawn assets (original work, MIT OR Apache-2.0)
- UI icons drawn in code: `crates/ui-egui/src/icons.rs`.
- UI icons drawn in code: `crates/ui-egui/src/icons.rs`, including the original filled favorite star (2026-10-11, EffectCraft contributors).
- Procedural demo content (solids, shapes, gradients, text animations): `crates/engine/src/demo.rs`.
- Built-in project templates (Lower Third, Title Card, Logo Reveal, Kinetic Type, Social Square, Vertical Story,
Slideshow, 3D Text Orbit) built from engine commands, and their thumbnails rendered by our own renderer:
`crates/engine/src/templates.rs`.
- Text animator presets (Typewriter, Fade Up Characters, Bounce In Words, Tracking In, Scramble, Blur In, Jitter,
Drop In Lines): `crates/engine/presets/text_animators.json` (sidecar `.attribution`).
- Transition animation presets (23: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades):
`crates/engine/presets/transitions.json` (sidecar `.attribution`).
- Sample scripts (File ▸ Scripts) and ScriptUI panel: `crates/engine/scripts/Rename Layers.jsx`,
`crates/engine/scripts/Sort Layers by In Point.jsx`, `crates/engine/scripts/Create Null at Selected Layers.jsx`,
`crates/engine/scripts/Render Queue Batch.jsx`, `crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx`
(sidecars `.attribution`).
- Bundled extension (ScriptUI panel) with its twelve built-in ease presets:
`extensions/scriptui-panels/Ease Presets.jsx` (sidecar `.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
@ -87,3 +120,5 @@ under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), black) | ArtCraft brand terms, not open source (`docs/brand/LICENSE-brand.txt`) |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), blue) | ArtCraft brand terms, not open source (`docs/brand/LICENSE-brand.txt`) |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), blue) | ArtCraft brand terms, not open source (`docs/brand/LICENSE-brand.txt`) |
- Appearance theme previews, drawn procedurally from original shapes: `crates/ui-egui/src/panels/settings.rs` (original work, MIT OR Apache-2.0).

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): every known shortfall, ranked, and the prioritised workstreams; the numbers are in [`docs/target-app-parity.md`](docs/target-app-parity.md). 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
```

220
Cargo.lock generated
View File

@ -962,6 +962,15 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
dependencies = [
"egui",
"serde",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@ -1134,7 +1143,7 @@ dependencies = [
[[package]]
name = "effectcraft"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"cpal",
"effectcraft-engine",
@ -1142,20 +1151,24 @@ dependencies = [
"effectcraft-ui-egui",
"eframe",
"egui",
"fmv-macos-events",
"log",
"muda",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"pollster 0.4.0",
"rfd",
"serde",
"serde_json",
"winit",
"winresource",
"zbus",
]
[[package]]
name = "effectcraft-automation"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
@ -1167,14 +1180,14 @@ dependencies = [
[[package]]
name = "effectcraft-av1enc"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"filmcraft-av1",
]
[[package]]
name = "effectcraft-cli"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"cpu-time",
"effectcraft-automation",
@ -1190,7 +1203,7 @@ dependencies = [
[[package]]
name = "effectcraft-color"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"proptest",
"serde",
@ -1198,13 +1211,14 @@ dependencies = [
[[package]]
name = "effectcraft-effects"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
"effectcraft-keyframe",
"effectcraft-project",
"effectcraft-raster",
"effectcraft-segment",
"effectcraft-track",
"log",
"rayon",
@ -1214,7 +1228,7 @@ dependencies = [
[[package]]
name = "effectcraft-engine"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@ -1229,6 +1243,7 @@ dependencies = [
"effectcraft-psd",
"effectcraft-raster",
"effectcraft-render",
"effectcraft-segment",
"effectcraft-svg",
"effectcraft-text",
"effectcraft-time",
@ -1246,10 +1261,11 @@ dependencies = [
[[package]]
name = "effectcraft-export"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"effectcraft-av1enc",
"effectcraft-color",
"effectcraft-effects",
"effectcraft-hevcenc",
"effectcraft-keyframe",
"effectcraft-media",
@ -1275,7 +1291,7 @@ dependencies = [
[[package]]
name = "effectcraft-expr"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"boa_engine",
"effectcraft-color",
@ -1292,7 +1308,7 @@ dependencies = [
[[package]]
name = "effectcraft-geom"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"proptest",
"serde",
@ -1300,7 +1316,7 @@ dependencies = [
[[package]]
name = "effectcraft-gpu"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"bytemuck",
"effectcraft-color",
@ -1312,21 +1328,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.9.2"
dependencies = [
"filmcraft-hevc",
]
[[package]]
name = "effectcraft-host"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@ -1341,6 +1360,8 @@ dependencies = [
"effectcraft-script",
"effectcraft-svg",
"effectcraft-time",
"exr",
"gif",
"image",
"rayon",
"serde_json",
@ -1348,7 +1369,7 @@ dependencies = [
[[package]]
name = "effectcraft-interchange"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"effectcraft-color",
"effectcraft-keyframe",
@ -1367,7 +1388,7 @@ dependencies = [
[[package]]
name = "effectcraft-keyframe"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1379,7 +1400,7 @@ dependencies = [
[[package]]
name = "effectcraft-lottie"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@ -1396,7 +1417,7 @@ dependencies = [
[[package]]
name = "effectcraft-media"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"effectcraft-model",
"effectcraft-pdf",
@ -1410,8 +1431,10 @@ dependencies = [
"filmcraft-codecs",
"filmcraft-color",
"filmcraft-frame",
"filmcraft-isobmff",
"filmcraft-media",
"filmcraft-time",
"gif",
"image",
"log",
"rayon",
@ -1421,10 +1444,11 @@ dependencies = [
[[package]]
name = "effectcraft-model"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"effectcraft-geom",
"image",
"miniz_oxide 0.8.9",
"serde",
"serde_json",
"thiserror 2.0.21",
@ -1432,14 +1456,14 @@ dependencies = [
[[package]]
name = "effectcraft-opusenc"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"filmcraft-opus",
]
[[package]]
name = "effectcraft-path"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
@ -1451,7 +1475,7 @@ dependencies = [
[[package]]
name = "effectcraft-pdf"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"effectcraft-raster",
"effectcraft-svg",
@ -1465,7 +1489,7 @@ dependencies = [
[[package]]
name = "effectcraft-plugin"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
@ -1477,7 +1501,7 @@ dependencies = [
[[package]]
name = "effectcraft-project"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1490,14 +1514,14 @@ dependencies = [
[[package]]
name = "effectcraft-psd"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"miniz_oxide 0.8.9",
]
[[package]]
name = "effectcraft-raster"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1506,7 +1530,7 @@ dependencies = [
[[package]]
name = "effectcraft-render"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@ -1528,7 +1552,7 @@ dependencies = [
[[package]]
name = "effectcraft-script"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"boa_engine",
"effectcraft-engine",
@ -1536,14 +1560,25 @@ dependencies = [
"effectcraft-keyframe",
"effectcraft-path",
"effectcraft-project",
"effectcraft-psd",
"effectcraft-render",
"effectcraft-time",
"serde_json",
"web-time",
]
[[package]]
name = "effectcraft-segment"
version = "0.9.2"
dependencies = [
"miniz_oxide 0.8.9",
"ndarray",
"rayon",
]
[[package]]
name = "effectcraft-svg"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"effectcraft-geom",
"effectcraft-path",
@ -1555,7 +1590,7 @@ dependencies = [
[[package]]
name = "effectcraft-text"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"effectcraft-keyframe",
"harfrust",
@ -1567,7 +1602,7 @@ dependencies = [
[[package]]
name = "effectcraft-time"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"proptest",
"serde",
@ -1575,9 +1610,10 @@ dependencies = [
[[package]]
name = "effectcraft-track"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"effectcraft-raster",
"effectcraft-segment",
"rayon",
"serde",
"serde_json",
@ -1586,8 +1622,9 @@ dependencies = [
[[package]]
name = "effectcraft-ui-egui"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"craft-ui",
"effectcraft-color",
"effectcraft-engine",
"effectcraft-geom",
@ -1602,6 +1639,7 @@ dependencies = [
"eframe",
"egui",
"egui_kittest",
"exr",
"image",
"kurbo",
"log",
@ -1609,16 +1647,18 @@ dependencies = [
"rayon",
"serde",
"serde_json",
"sys-locale",
"toml",
"web-time",
]
[[package]]
name = "effectcraft-vp9enc"
version = "0.2.0"
version = "0.9.2"
[[package]]
name = "effectcraft-web"
version = "0.2.0"
version = "0.9.2"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@ -1732,6 +1772,7 @@ dependencies = [
"raw-window-handle",
"smithay-clipboard",
"web-time",
"webbrowser",
"winit",
]
@ -2259,6 +2300,17 @@ dependencies = [
"zlib-rs",
]
[[package]]
name = "fmv-macos-events"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
dependencies = [
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
]
[[package]]
name = "fnv"
version = "1.0.7"
@ -2313,6 +2365,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 +2780,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 +3132,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 +3259,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 +4276,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 +4984,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 +5309,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 +5709,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 +6468,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.2.0"
version = "0.9.2"
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.9.2"
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" }
@ -97,11 +98,15 @@ bytemuck = "1"
pollster = "0.4"
# ui
# Shared frontend components; keep this dependency above the engine boundary.
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
egui = "0.36"
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"]

131
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,64 @@ 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, plus a riscv64 tar.gz; 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.
EffectCraft is in **alpha**: about 91% of After Effects' features exist, and we estimate it is
about 45% of the way to replacing After Effects for professional work. The [ROADMAP](ROADMAP.md)
has the plan, [docs/target-app-parity.md](docs/target-app-parity.md) the measurement and
[docs/gaps.md](docs/gaps.md) every known shortfall. 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 +252,69 @@ 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. The installer asks where to install EffectCraft
(Program Files by default), and later versions install into the same folder. To install silently
into a folder of your choice: `msiexec /i effectcraft-<ver>-windows-x64.msi /qn INSTALLFOLDER="D:\Apps\EffectCraft"`.
### 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 |
RISC-V 64-bit: `effectcraft-<ver>-linux-riscv64.tar.gz` (tarball only; needs glibc 2.39+, e.g. Ubuntu 24.04+ or Debian 13+).
**File drag-and-drop on Linux:** The current eframe/winit 0.30 native Wayland backend does not
deliver file-hover or file-drop events. On Hyprland and other native Wayland sessions, use
**File ▸ Import ▸ File…** (which accepts multiple files), or start EffectCraft via XWayland if
XWayland is installed and `DISPLAY` is set:
```sh
env -u WAYLAND_DISPLAY effectcraft
```
When the backend delivers events (X11/XWayland, Windows and macOS), dropping media anywhere
in the window imports it, and a drop-target hint appears while hovering. On native Wayland the
app cannot detect an attempt that generates no event; it shows a startup notice with the
File ▸ Import fallback instead.
### 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 +327,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 +374,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,38 +1,155 @@
# Roadmap
# EffectCraft Roadmap
**Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against After Effects 2026 26.5; standard progress-docs layout) · **Target:** Adobe After Effects 2026 (26.5.0.89)
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.
from scratch in Rust. This page is the one-page summary; the evidence is in
[docs/target-app-parity.md](docs/target-app-parity.md), the work list in
[docs/gaps.md](docs/gaps.md), and the plan in [docs/roadmap.md](docs/roadmap.md).
| | Milestone | State |
## Headline numbers
| | Value | Kind |
|---|---|---|
| M0 | Skeleton: crates, compositor, the After Effects style shell, control channel, `cargo xtask` | Done (web build: [docs/web.md](docs/web.md)) |
| 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) |
| 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 |
| M8 | Expressions with the After Effects object model | Done |
| M9 | Text and effects: text animators, layer styles, 306 effects (all 298 of After Effects') incl. time and audio effects | Done (range, wiggly and expression selectors, per-character 3D, text on a path) |
| 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) |
| 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 |
| **Feature breadth** | **≈ 91%** | Estimated, weighted by area; the self-graded 92-item checklist says ≈ 99% |
| **Ready for real work** | **≈ 45%** (40–50%) | Estimated; one live fidelity measurement so far |
| **Mainstream practitioner** | **≈ 47%** | Estimated: the typical motion designer's weekly work ([how](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user)) |
| **Essentials user** | **≈ 52%** | Estimated: core features only ([how](docs/target-app-parity.md#essentials-user)) |
| Remaining to **beta** | **≈ 350–620 Opus 5.5 agent-hours** | Estimated |
| Remaining to **full parity** | **≈ 850–1,500 Opus 5.5 agent-hours** | Estimated; ≈ 70% parallelises |
## How far from full parity
| Audience | Ready % | Hours to ~95% | Work that dominates |
|---|---:|---:|---|
| Full target | ≈ 45% | ≈ 850–1,500 h | `.aep`, fidelity corpus, bug backlog, performance, formats, hardware, localization |
| Mainstream practitioner | ≈ 47% | ≈ 280–530 h | `.aep` exchange, stability, everyday fidelity and bugs, preview speed |
| Essentials user | ≈ 52% | ≈ 150–300 h | Opening tutorial `.aep` files, start-up stability, core bugs, preview with sound |
Measured feature by feature in [docs/parity.md](docs/parity.md) (4 October 2026):
About 60–70% of these hours parallelise ([detail](docs/target-app-parity.md#readiness-by-audience)).
- **≈ 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)).
Hours are one Opus 5.5 agent working sequentially, calibrated on this repository's history
([how](docs/target-app-parity.md#calibration-of-the-hours)).
### Stage
**Alpha**, because all six core workflows of the [alpha gate](docs/roadmap.md#alpha-gate)
(set up, animate, text and shapes, composite, preview, save and deliver) work end to end on macOS
and the work saves and reopens, and ready for real work (≈ 45%) is above the ≈ 40% bar. But: After Effects projects (`.aep`) can't be opened, which
alone rules out beta; fidelity against After Effects is measured for one narrow case (scalar ease,
168 samples); 134 issues are open, eight of them crash reports. Beta needs ≈ 75% ready and `.aep`
opening reliably: the `.aep` owner decision and importer (80–160 h), the fidelity harness and
P0/P1 corpus (60–100 h), the crash and bug backlog (40–80 h), performance (40–70 h),
Linux/Windows reliability (30–50 h) and depth in animation, masks, effects and text (100–160 h).
## By dimension
| Dimension | % | Hours | Doc |
|---|---:|---:|---|
| Features: breadth | 91% | — | [target-app-parity.md](docs/target-app-parity.md#by-feature-area) |
| Features: ready for real work | 50% | 360–660 | [target-app-parity.md](docs/target-app-parity.md#by-feature-area) |
| UI/UX fidelity | 65% | 60–120 | [ui-parity.md](docs/ui-parity.md) |
| File formats | 50% | 130–230 | [file-format-parity.md](docs/file-format-parity.md) |
| Hardware | 40% | 60–120 | [hardware-parity.md](docs/hardware-parity.md) |
| Localization | 12% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) |
| Performance | 35% | 50–100 | [gaps.md](docs/gaps.md#g5-performance-at-real-world-scale) |
| Stability | 45% | 40–80 | [gaps.md](docs/gaps.md#g3-stability-and-a-green-main) |
| Platforms | 65% | 30–60 | [gaps.md](docs/gaps.md#g2-real-user-reliability-on-every-platform) |
| Ecosystem and plug-ins | 20% | 40–80 | [plugins.md](docs/plugins.md), [scripting-parity.md](docs/scripting-parity.md) |
| AI features | 35% | 40–80 | [gaps.md](docs/gaps.md) |
| Agent control (beyond After Effects) | ahead | — | [agents.md](docs/agents.md) |
## Features
| Area | Breadth | Ready | Hours |
|---|---:|---:|---:|
| Project, footage and import | 92% | 55% | 25–45 |
| Compositions, layers and compositing | 98% | 60% | 30–55 |
| Animation: keyframes, graph editor, motion paths, puppet | 97% | 55% | 35–60 |
| Expressions and scripting ([detail](docs/scripting-parity.md)) | 93% | 65% | 20–40 |
| Shapes, masks and roto | 93% | 45% | 30–50 |
| Text and typography | 94% | 50% | 20–35 |
| Effects and animation presets ([detail](docs/effects-parity.md)) | 85% | 40% | 70–130 |
| 3D (Classic and Advanced) | 82% | 35% | 35–65 |
| Tracking and AI-assisted tools | 80% | 35% | 35–70 |
| Preview and cache | 92% | 40% | 25–50 |
| Render queue and export | 85% | 55% | 25–45 |
| Audio | 85% | 45% | 6–12 |
Weights and evidence per row: [target-app-parity.md](docs/target-app-parity.md#by-feature-area).
## Languages
UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/localization-parity.md)).
| Language | Status | % |
|---|---|---:|
| English | full | 100% |
| Simplified Chinese | partial (menus, part of the panels) | 28% |
| Spanish | menus only | 16% |
| Hindi | none | 0% |
| Arabic | none | 0% |
| French | none | 0% |
| Portuguese (Brazil) | menus only | 16% |
| Indonesian | none | 0% |
| Japanese | partial (menus, part of the panels) | 26% |
| German | none | 0% |
| Korean | none | 0% |
| Vietnamese | none | 0% |
Also shipped: Traditional Chinese (partial, 28%) and Ukrainian (partial, 29%). No language has had
native-speaker review.
## Upcoming
Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gaps.md).
| | Next | Hours |
|---|---|---:|
| 1 | Fix the open crash reports; run `cargo xtask ci` on every pull request (G3) | 15–30 |
| 2 | Fidelity harness against After Effects: rendered frames, a corpus case per P0/P1 feature (G1) | 60–100 |
| 3 | Real-user reliability on Linux and Windows; the open mask, effect, text and 3D bugs (G2) | 100–190 |
| 4 | `.aep` / `.aepx` import, after the owner's clean-room decision (G4) | 80–160 |
| 5 | Performance: benchmarks at 1080p / 4K, real-time cached playback, hardware video (G5) | 50–100 |
| 6 | Localization depth: effect names, all panels, French / German / Korean (G9) | 60–110 |
Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
## Progress log
- 2026-10-11: Ukrainian panels caught up with the Chinese catalogs. The 95 rows the panel
catalog gained since 9 October are translated: the Home screen (recent files, their Kind and
Opened columns, the empty and no-match states), the viewer's toolbar tooltips, magnification,
resolution, grid, channel and Fast Previews popups, the Appearance settings, the solid dialog
and the project, import, export and colour-management forms. `uk` now carries 495 of the 496
panel rows — `RGB` reads the same in Ukrainian, so it falls back to the English source by
design — alongside its 618/618 menus.
- 2026-10-10: Full parity re-measure against After Effects 2026 26.5 (installed bundle, 26.2–26.5
release notes, repository counts): breadth ≈ 91%, ready ≈ 45%, stage alpha. Progress docs
follow the shared standard: `docs/parity.md` became `docs/target-app-parity.md`; new
`docs/roadmap.md`, `localization-parity.md`, `file-format-parity.md`, `hardware-parity.md`,
`ui-parity.md`, `effects-parity.md` and `scripting-parity.md`.
- 2026-10-10: Spanish (#460) and Brazilian Portuguese (#217) menu translations.
- 2026-10-10: Japanese now translates the interface below the menu bar too: `crates/ui-egui/src/i18n/ui.rs` has a Japanese table with the same 396 rows as the Chinese ones (panels, dialogs, buttons and tooltips; the shape test keeps the three catalogs listing the same source strings). Menus were already Japanese.
- 2026-10-09: Ukrainian now covers all 396 current panel-catalog strings, including dialogs, buttons and tooltips, alongside its menu translation. The catalog keeps the same source keys and placeholder counts as the Chinese catalogs; missing or engine-generated text still falls back to English.
- UI.6: Viewer breadcrumbs use theme colors for readable active text. The bottom control row scrolls horizontally at compact widths; keyboard focus reveals the selected control while dropdowns, exposure adjustments and snapshot holds retain their behavior.
- UI.3: Shared docking transactions and pointer targets now handle panel moves, splits, tab ordering and floating groups, preserving accordion metadata and the saved workspace schema. Dock dividers use bounded shared geometry and keyboard-accessible resize handles. Tiny layouts no longer reach an invalid floating-point clamp; workspace trees and saved layouts are retained.
- 2026-10-09: Settings ▸ General ▸ Language now translates the interface below the menu bar as well: panels, dialogs, buttons and tooltips read their text through `crates/ui-egui/src/i18n/ui.rs`, keyed by the English source string. Simplified and Traditional Chinese are complete (384 strings); Japanese rows can follow in the same file. A test fails when a converted panel gains a hard-coded string, and another keeps the catalog free of unused rows.
- 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.
- 2026-10-05: Honest assessment ([docs/gaps.md](docs/gaps.md)): breadth ≈ 99% by our own checklist, real use ≈ 30–50%; workstreams G1–G9.
- 2026-10-04: Checklist audit at `d39c0e8`: 89 done / 3 partial / 0 missing of 92 features.
- 2026-10-01: First commit.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | minor | Hours to ≈ 95% for each audience |
| 2026-10-10 | minor | Headline adds mainstream practitioner (≈ 47%) and essentials user (≈ 52%) |
| 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) |
| 2026-10-10 | major | Rewritten to the shared progress-docs standard: stage, two numbers, dimensions, features, languages, upcoming, progress log; milestones moved to docs/roadmap.md |
| 2026-10-05 | major | Two numbers (breadth vs real use) and workstreams G1–G9 |

View File

@ -9,19 +9,22 @@
//! 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 --template T.ectemplate [--values V.json] --out FILE
//! effectcraft-cli render-frame --template T.ectemplate [--values V.json] [--time S] [--out F.png]
//! 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 +39,56 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::io::Write;
mod template;
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 +97,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 +105,8 @@ 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
render/render-frame --template T.ectemplate [--values V.json] render one template instance
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 +116,10 @@ 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
[--automation-read-root DIR] [--automation-write-root DIR]
confine the agent's file access: reads under a read or write root,
writes under a write root (each repeatable); scripts are refused
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";
@ -91,6 +145,8 @@ const VALUED: &[&str] = &[
"--depth",
"--eval",
// render
"--template",
"--values",
"--format",
"--start",
"--end",
@ -110,6 +166,29 @@ const VALUED: &[&str] = &[
"--webm-codec",
"--audio-bitrate",
"--opus-app",
// mcp
"--automation-read-root",
"--automation-write-root",
];
/// 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 {
@ -121,7 +200,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 +211,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 +222,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()),
@ -149,6 +238,10 @@ impl Args {
fn flag(&self, k: &str) -> bool {
self.opts.iter().any(|(o, _)| o == k)
}
/// Every value of a repeatable option, in order.
fn opts(&self, k: &str) -> Vec<&str> {
self.opts.iter().filter(|(o, _)| o == k).filter_map(|(_, v)| v.as_deref()).collect()
}
fn opt(&self, k: &str) -> Option<&str> {
self.opts.iter().rev().find(|(o, _)| o == k).and_then(|(_, v)| v.as_deref())
}
@ -173,11 +266,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 +283,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 +341,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 +399,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 +426,17 @@ 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");
}
template::validate(cmd, args)?;
match cmd {
"render" => render(args, json_out)?,
"bench" => bench_cmd(args)?,
@ -344,19 +455,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 +477,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 +533,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 +542,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"]);
}
}
}
@ -457,7 +568,13 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
emit(&with_saved(v, saved), json_out);
}
"render-frame" | "frame" => {
let mut b = backend(args, true)?;
let mut b = if args.opt("--template").is_some() {
let mut s = session(args)?;
template::prepare(&mut s, args)?;
Backend::headless(s)
} else {
backend(args, true)?
};
let comp = args.opt("--comp").and_then(reference);
let mut time = args.num("--time")?;
let mut max_side = args.num("--max-side")?.map(|m| m as u32).unwrap_or(0);
@ -474,19 +591,39 @@ 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" => {
let b = backend(args, false)?;
McpServer::new(b).serve_stdio().map_err(|e| e.to_string())?;
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 mut b = backend(args, false)?;
let (read, write) = (args.opts("--automation-read-root"), args.opts("--automation-write-root"));
if !read.is_empty() || !write.is_empty() {
let roots = |r: Vec<&str>| r.into_iter().map(std::path::PathBuf::from).collect::<Vec<_>>();
b.confine(effectcraft_automation::confine::Confinement::new(&roots(read), &roots(write))?);
}
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,21 +644,32 @@ 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> {
use effectcraft_engine::project::render_queue::RenderStatus;
use effectcraft_engine::project::render_queue::{OutputFormat, RenderStatus};
if args.opt("--bridge").is_some() {
return usage_err("render runs headless; use `exec renderQueue.add` / `renderQueue.render` with --bridge");
}
let mut s = session(args)?;
match &args.project {
Some(p) => s.execute("file.open", json!({"path": p})).map_err(|e| Failure::Error(e.to_string()))?,
None => s.execute("file.openDemoProject", json!({})).map_err(|e| Failure::Error(e.to_string()))?,
};
if args.opt("--template").is_some() {
template::prepare(&mut s, args)?;
} else {
match &args.project {
Some(p) => s.execute("file.open", json!({"path": p})).map_err(|e| Failure::Error(e.to_string()))?,
None => s.execute("file.openDemoProject", json!({})).map_err(|e| Failure::Error(e.to_string()))?,
};
}
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);
@ -555,6 +703,11 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
}
if let Some(v) = args.num("--bitrate")? {
p["bitrate"] = json!(v);
// VP9 WebM encodes by quality unless told to target the bitrate (#440).
// The format parser decides what is WebM, so its aliases (`vp9`, `webmvp9opus`) count.
if p["format"].as_str().and_then(OutputFormat::from_name) == Some(OutputFormat::WebM) {
p["webmBitrate"] = json!(true);
}
}
for (flag, key) in [
("--profile", "profile"),
@ -577,7 +730,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 +746,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 +761,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 +769,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 +794,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 +862,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 +870,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 +942,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 +950,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 +973,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 +981,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 +1144,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 +1162,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 +1242,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

@ -0,0 +1,138 @@
//! CLI-only setup of an Essential Graphics template instance using engine commands.
use std::io::Read;
use effectcraft_engine::{
EngineError, Session,
project::{ItemId, LayerId},
};
use serde_json::{Map, Value, json};
use crate::{Args, Failure, usage_err};
pub(super) fn validate(cmd: &str, args: &Args) -> Result<(), Failure> {
if args.opt("--values").is_some() && args.opt("--template").is_none() {
return usage_err("--values requires --template");
}
if args.opt("--template").is_none() {
return Ok(());
}
if !matches!(cmd, "render" | "render-frame" | "frame") {
return usage_err("--template is available for render and render-frame");
}
if args.project.is_some() || args.flag("--demo") || args.opt("--bridge").is_some() || args.flag("--queue") || args.opt("--comp").is_some() {
return usage_err("--template renders a new instance; it cannot be combined with --project, --demo, --bridge, --queue or --comp");
}
if !args.pos.is_empty() {
return usage_err("template rendering takes --template FILE.ectemplate, without positional arguments");
}
if cmd == "render" && args.opt("--out").is_none() {
return usage_err("render: --out FILE is required (or --queue)");
}
// Resolve existing outputs before importing or rendering can overwrite an input.
let out = args.opt("--out").or_else(|| matches!(cmd, "render-frame" | "frame").then_some("frame.png"));
if let Some(out) = out
&& let Ok(output) = std::fs::canonicalize(out)
{
for input in ["--template", "--values"] {
if let Some(path) = args.opt(input)
&& std::fs::canonicalize(path).is_ok_and(|path| path == output)
{
return Err(Failure::Error(format!("--out {out} would overwrite {input} {path}")));
}
}
}
Ok(())
}
fn values(args: &Args) -> Result<Map<String, Value>, Failure> {
let Some(path) = args.opt("--values") else { return Ok(Map::new()) };
// A bounded read also handles a file that grows after opening.
const MAX_BYTES: u64 = 1024 * 1024;
let file = std::fs::File::open(path).map_err(|e| Failure::Error(format!("cannot read values {path}: {e}")))?;
let mut bytes = Vec::new();
file.take(MAX_BYTES + 1).read_to_end(&mut bytes).map_err(|e| Failure::Error(format!("cannot read values {path}: {e}")))?;
if bytes.len() as u64 > MAX_BYTES {
return Err(Failure::Error(format!("values {path}: JSON exceeds 1 MiB")));
}
let value: Value = serde_json::from_slice(&bytes).map_err(|e| Failure::Error(format!("values {path}: invalid JSON: {e}")))?;
match value {
Value::Object(values) => Ok(values),
_ => Err(Failure::Error(format!("values {path}: expected a JSON object mapping control names to values"))),
}
}
fn execute(s: &mut Session, command: &str, params: Value) -> Result<Value, Failure> {
s.execute_checked(command, params).map_err(|e| Failure::Error(e.to_string()))
}
// Use the same instance commands to reject invalid values before import extracts media.
fn preflight(s: &Session, path: &str, values: &Map<String, Value>) -> Result<(), Failure> {
let bytes = s.services.read_file(path).map_err(|e| Failure::Error(format!("cannot read {path}: {e}")))?;
effectcraft_engine::guard::guarded("template value validation", || {
let bad = |msg: String| EngineError::BadParams { cmd: "essential.importTemplate".into(), msg };
let (manifest, mut project, _) = effectcraft_engine::commands::essential::read_template(&bytes).map_err(bad)?;
let source = manifest.get("comp").and_then(Value::as_u64).ok_or_else(|| bad("manifest has no `comp`".into()))?;
let base = s.project.next_id;
let source = source.checked_add(base).ok_or_else(|| bad("template composition ID overflows the import offset".into()))?;
effectcraft_engine::project::essential::offset_ids(&mut project, base);
project.fix_next_id();
let mut check = Session::new();
check.project = project.into();
Ok(instantiate(&mut check, source, values))
})
.map_err(|e| Failure::Error(e.to_string()))?
}
pub(super) fn prepare(s: &mut Session, args: &Args) -> Result<(), Failure> {
let Some(path) = args.opt("--template") else { return Ok(()) };
let values = values(args)?;
if !values.is_empty() {
preflight(s, path, &values)?;
}
let imported = execute(s, "essential.importTemplate", json!({"path": path, "addToComp": false}))?;
let source = imported.get("comp").and_then(Value::as_u64).ok_or_else(|| Failure::Error("template import did not return a composition".into()))?;
instantiate(s, source, &values)
}
fn instantiate(s: &mut Session, source: u64, values: &Map<String, Value>) -> Result<(), Failure> {
// Duplicate and empty the source with commands: this preserves exact rational frame rate,
// Tick duration, background, work area and renderer settings without round-tripping floats.
let duplicate = execute(s, "project.duplicate", json!({"items": [source]}))?;
let parent = duplicate
.get("items")
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(Value::as_u64)
.ok_or_else(|| Failure::Error("cannot create template render composition".into()))?;
execute(s, "comp.open", json!({"comp": parent}))?;
let layers: Vec<u64> = s
.project
.comp(ItemId(parent))
.ok_or_else(|| Failure::Error("template render composition is missing".into()))?
.layers
.iter()
.map(|layer| layer.id.0)
.collect();
if !layers.is_empty() {
execute(s, "layer.setSwitch", json!({"comp": parent, "layers": layers, "switch": "lock", "value": false}))?;
execute(s, "edit.clear", json!({"comp": parent, "layers": layers}))?;
}
let added = execute(s, "layer.addItem", json!({"comp": parent, "item": source, "time": 0}))?;
let instance = added.get("layer").and_then(Value::as_u64).ok_or_else(|| Failure::Error("cannot create template instance".into()))?;
// Keep a partial final frame: layer.addItem snaps its default end to the frame grid.
s.edit("Template Duration", None, |project, _| {
let comp = project.comp_mut(ItemId(parent)).ok_or(EngineError::NoComp)?;
let duration = comp.duration;
let layer = comp.layer_mut(LayerId(instance)).ok_or_else(|| EngineError::Other("template instance is missing".into()))?;
layer.out_point = duration;
Ok(())
})
.map_err(|e| Failure::Error(e.to_string()))?;
// Let the wrapper carry the source composition's authored motion blur.
execute(s, "layer.setSwitch", json!({"comp": parent, "layers": [instance], "switch": "motionBlur", "value": true}))?;
for (control, value) in values {
execute(s, "essential.set", json!({"comp": parent, "layer": instance, "control": control, "value": value}))?;
}
Ok(())
}

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};
@ -28,7 +28,9 @@ fn ok_json(args: &[&str]) -> Value {
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("ec-cli-test-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d.join(name)
// macOS's `/var` is a symlink to `/private/var`: a child process resolves its working
// directory through `current_dir()`, which is canonical, so compare canonical paths.
std::fs::canonicalize(&d).unwrap_or(d).join(name)
}
#[test]
@ -130,6 +132,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 +241,253 @@ 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();
// current_dir resolves aliases such as macOS /var -> /private/var.
let root = root.canonicalize().unwrap();
let (proj, elsewhere) = (root.join("proj"), root.join("elsewhere"));
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!({}));
// The instructions carry the auto-save state as JSON, where a Windows path's backslashes
// are escaped.
assert!(init["instructions"].as_str().unwrap().contains(&Value::from(path).to_string()));
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();
}
/// `render --bitrate` (#440) targets the bitrate in VP9 WebM.
#[test]
fn render_bitrate_applies_to_vp9_webm() {
let root = tmp("webm-bitrate");
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let project = root.join("x.ecproj");
let p = project.to_str().unwrap();
ok_json(&[
"run",
"comp.new",
r##"{"name":"T","width":320,"height":180,"duration":1}"##,
"layer.newSolid",
r##"{"width":320,"height":180}"##,
"effect.apply",
r##"{"layer":"#1","effect":"ec.noise.fractal"}"##,
"--empty",
"--save-as",
p,
]);
let size = |kbps: &str| {
let out = root.join(format!("b{kbps}.webm"));
ok_json(&["render", "--project", p, "--start", "0", "--end", "0.5", "--bitrate", kbps, "--out", out.to_str().unwrap()]);
std::fs::metadata(&out).unwrap().len()
};
let (low, high) = (size("100"), size("8000"));
assert!(high > low, "100 kbps: {low} bytes, 8000 kbps: {high} bytes");
let _ = std::fs::remove_dir_all(&root);
}

View File

@ -1,6 +1,7 @@
//! M13.15 scenario 5 — Essential Graphics through the CLI: a MOGRT-style template built by an
//! After Effects-style script (`effectcraft-cli script`), its controls changed with commands on
//! an instance, rendered, exported as a template and imported into a fresh project.
//! an instance, rendered, exported as a template and imported into a fresh project. Direct
//! template rendering uses the same original, procedural fixture under the project licence.
use std::path::PathBuf;
use std::process::Command;
@ -111,5 +112,543 @@ fn essential_graphics_template_via_script_and_commands() {
assert!(imp["saved"].is_string(), "{imp}");
let list = ok(&dir, &["exec", "essential.list", r##"{"comp":"Lower Third"}"##, "fresh.ecproj"]);
assert_eq!(list["controls"].as_array().unwrap().len(), 3, "{list}");
// One-shot rendering applies the JSON values to an instance, preserving the template defaults.
let original_template = std::fs::read(dir.join("lt.ectemplate")).unwrap();
std::fs::write(dir.join("values.json"), r##"{"Title":"John Smith","Bar Color":"#00c080","bar opacity":50}"##).unwrap();
ok(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "direct.png"]);
assert_eq!(image::open(dir.join("direct.png")).unwrap().to_rgba8(), edit);
ok(&dir, &["frame", "--template", "lt.ectemplate", "--frame", "12", "--out", "defaults.png"]);
assert_eq!(image::open(dir.join("defaults.png")).unwrap().to_rgba8(), plain);
let output = dir.join("sequence/direct_[#####].png");
let rendered = ok(
&dir,
&[
"render",
"--template",
"lt.ectemplate",
"--values",
"values.json",
"--out",
output.to_str().unwrap(),
"--format",
"png",
"--channels",
"rgb",
"--start",
"1",
"--end",
"1.0833333333333333",
],
);
assert_eq!(rendered["rendered"].as_array().unwrap().len(), 1, "{rendered}");
let frames: Vec<_> = std::fs::read_dir(dir.join("sequence"))
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| path.extension().is_some_and(|extension| extension == "png"))
.collect();
assert_eq!(frames.len(), 1, "{frames:?}");
assert_eq!(image::open(&frames[0]).unwrap().to_rgba8(), edit);
assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), original_template);
// Invalid values fail before the queue creates an output directory, or frame rendering
// overwrites an existing output. A JSON object and existing control names are required.
for (name, values, expected) in [
("malformed", "{", "invalid JSON"),
("array", "[]", "JSON object"),
("unknown", r#"{"Subtitle":"x"}"#, "controls: Title, Bar Color, Bar Opacity"),
("invalid-value", r#"{"Bar Opacity":"bad"}"#, "can't use"),
] {
let values_file = format!("{name}.json");
std::fs::write(dir.join(&values_file), values).unwrap();
let output_dir = dir.join(format!("failed-{name}"));
let out = output_dir.join("frame_[#####].png");
let (code, error) = cli(&dir, &["render", "--template", "lt.ectemplate", "--values", &values_file, "--out", out.to_str().unwrap()]);
assert_eq!(code, 1, "{name}: {error}");
assert!(error["error"].as_str().unwrap().contains(expected), "{name}: {error}");
assert!(!output_dir.exists(), "{name}: invalid input created render output");
std::fs::write(dir.join("untouched.png"), b"existing output").unwrap();
let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", &values_file, "--out", "untouched.png"]);
assert_eq!(code, 1, "{name}: {error}");
assert_eq!(std::fs::read(dir.join("untouched.png")).unwrap(), b"existing output");
}
let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "missing-values.json", "--out", "missing.png"]);
assert_eq!(code, 1, "{error}");
assert!(error["error"].as_str().unwrap().contains("cannot read values"), "{error}");
assert!(!dir.join("missing.png").exists());
std::fs::write(dir.join("large-values.json"), vec![b' '; 1024 * 1024 + 1]).unwrap();
let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "large-values.json", "--out", "large.png"]);
assert_eq!(code, 1, "{error}");
assert!(error["error"].as_str().unwrap().contains("exceeds 1 MiB"), "{error}");
assert!(!dir.join("large.png").exists());
for args in [
vec!["render-frame", "--values", "values.json"],
vec!["info", "--template", "lt.ectemplate"],
vec!["render-frame", "--template", "lt.ectemplate", "--comp", "Lower Third"],
vec!["render-frame", "t.ecproj", "--template", "lt.ectemplate"],
vec!["render", "--template", "lt.ectemplate", "--queue"],
vec!["render-frame", "--template", "lt.ectemplate", "--bridge", "1"],
] {
let output = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")).current_dir(&dir).args(&args).output().unwrap();
assert_eq!(output.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
}
// File-loaded templates can use exact rational rates that comp.new's float parser rounds.
// Preserve that rate, Tick duration, work area, background and locked source layers.
let mut project = effectcraft_engine::project::Project::from_json(&std::fs::read_to_string(dir.join("t.ecproj")).unwrap()).unwrap();
let source = project.find_by_name("Lower Third").unwrap().id;
let rate = effectcraft_time::FrameRate::new(24001, 1001);
let comp = project.comp_mut(source).unwrap();
comp.frame_rate = rate;
comp.duration = rate.tick_of(24);
comp.work_area = (rate.tick_of(3), rate.tick_of(7));
comp.background = [0.08, 0.16, 0.24];
comp.layers.first_mut().unwrap().switches.locked = true;
std::fs::write(dir.join("rational.ecproj"), project.to_json()).unwrap();
ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"rational.ectemplate"}"#, "rational.ecproj"]);
let rational_template = std::fs::read(dir.join("rational.ectemplate")).unwrap();
let rational = ok(&dir, &["render-frame", "--template", "rational.ectemplate", "--frame", "5", "--out", "rational.png"]);
assert!((rational["time"].as_f64().unwrap() - 5.0 / rate.as_f64()).abs() < 1e-9, "{rational}");
assert_eq!((rational["width"].as_u64().unwrap(), rational["height"].as_u64().unwrap()), (320, 90));
let output = dir.join("rational-work-area/frame_[#####].png");
ok(&dir, &["render", "--template", "rational.ectemplate", "--work-area", "--out", output.to_str().unwrap(), "--format", "png", "--resolution", "0.05"]);
let count = std::fs::read_dir(dir.join("rational-work-area"))
.unwrap()
.filter_map(Result::ok)
.filter(|entry| entry.path().extension().is_some_and(|extension| extension == "png"))
.count();
assert_eq!(count, 4, "the template's exact work area contains frames 3 through 6");
assert_eq!(std::fs::read(dir.join("rational.ectemplate")).unwrap(), rational_template);
assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), original_template);
let _ = std::fs::remove_dir_all(&dir);
}
fn template_output_alias_fixture(case: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("ec-qa-template-output-{case}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("build.jsx"), BUILD).unwrap();
let built = ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]);
assert_eq!(built["output"], "controls: 3", "{built}");
let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"lt.ectemplate"}"#, "t.ecproj"]);
assert_eq!(exported["controls"], 3, "{exported}");
std::fs::write(dir.join("values.json"), r##"{"Title":"John Smith","Bar Color":"#00c080","bar opacity":50}"##).unwrap();
let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap();
let values_before = std::fs::read(dir.join("values.json")).unwrap();
// The same valid inputs must still render to a distinct output before testing aliases.
ok(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "ordinary.png"]);
let image = image::open(dir.join("ordinary.png")).unwrap().to_rgba8();
let bar = image.get_pixel(10, 75).0;
assert!(bar[0] < 15 && (bar[1] as i32 - 96).abs() < 8 && (bar[2] as i32 - 64).abs() < 8, "50 % of #00c080 over black: {bar:?}");
assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), template_before);
assert_eq!(std::fs::read(dir.join("values.json")).unwrap(), values_before);
dir
}
fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) {
let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap();
let values_before = std::fs::read(dir.join("values.json")).unwrap();
// Read the inputs before interpreting the subprocess status or error text.
let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli"))
.current_dir(dir)
.args(["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", out, "--json"])
.output()
.unwrap();
let template_after = std::fs::read(dir.join("lt.ectemplate")).unwrap();
let values_after = std::fs::read(dir.join("values.json")).unwrap();
let template_preserved = template_after == template_before;
let values_preserved = values_after == values_before;
eprintln!(
"output={out:?} status={:?} template_preserved={template_preserved} values_preserved={values_preserved} template_bytes={}->{} values_bytes={}->{} stdout={} stderr={}",
result.status.code(),
template_before.len(),
template_after.len(),
values_before.len(),
values_after.len(),
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr),
);
assert!(template_preserved, "template rendering must preserve its template input when --out aliases that file");
assert!(values_preserved, "template rendering must preserve its values input when --out aliases that file");
assert!(!result.status.success(), "an output alias must fail before rendering overwrites an input");
}
#[test]
fn template_frame_output_cannot_replace_its_template_input() {
let dir = template_output_alias_fixture("template");
assert_template_output_preserves_inputs(&dir, "lt.ectemplate");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn template_frame_output_cannot_replace_its_values_input() {
let dir = template_output_alias_fixture("values");
assert_template_output_preserves_inputs(&dir, "values.json");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn template_frame_output_cannot_alias_its_template_through_parent_components() {
let dir = template_output_alias_fixture("normalized-template");
std::fs::create_dir(dir.join("alias")).unwrap();
let out = "alias/../lt.ectemplate";
assert_eq!(dir.join(out).canonicalize().unwrap(), dir.join("lt.ectemplate").canonicalize().unwrap());
assert_template_output_preserves_inputs(&dir, out);
let _ = std::fs::remove_dir_all(&dir);
}
fn timeline_tail_render_pair(dir: &PathBuf, name: &str, duration: effectcraft_time::Tick) {
let mut project = effectcraft_engine::project::Project::from_json(&std::fs::read_to_string(dir.join("base.ecproj")).unwrap()).unwrap();
let source = project.find_by_name("Lower Third").unwrap().id;
let comp = project.comp_mut(source).unwrap();
assert_eq!(comp.frame_rate, effectcraft_time::FrameRate::new(12, 1));
comp.duration = duration;
comp.work_area = (effectcraft_time::Tick::ZERO, duration);
for layer in &mut comp.layers {
layer.out_point = duration;
}
assert!(comp.layers.iter().all(|layer| layer.is_active_at(effectcraft_time::Tick::from_seconds_f64(1.0))));
let project_name = format!("{name}.ecproj");
let template_name = format!("{name}.ectemplate");
let source_frame = format!("{name}-source.png");
let template_frame = format!("{name}-template.png");
std::fs::write(dir.join(&project_name), project.to_json()).unwrap();
let params = serde_json::json!({"comp":"Lower Third", "path":template_name}).to_string();
let exported = ok(dir, &["exec", "essential.exportTemplate", &params, &project_name]);
assert_eq!(exported["controls"], 3, "{exported}");
let project_before = std::fs::read(dir.join(&project_name)).unwrap();
let template_before = std::fs::read(dir.join(&template_name)).unwrap();
let direct = ok(dir, &["render-frame", &project_name, "--comp", "Lower Third", "--time", "1", "--out", &source_frame]);
let instance = ok(dir, &["render-frame", "--template", &template_name, "--time", "1", "--out", &template_frame]);
for result in [&direct, &instance] {
assert_eq!(result["time"].as_f64(), Some(1.0), "{name}: {result}");
assert_eq!((result["width"].as_u64(), result["height"].as_u64()), (Some(320), Some(90)), "{name}: {result}");
}
assert_eq!(std::fs::read(dir.join(&project_name)).unwrap(), project_before, "{name}: rendering preserves the source project bytes");
assert_eq!(std::fs::read(dir.join(&template_name)).unwrap(), template_before, "{name}: rendering preserves the exported template bytes");
let direct = image::open(dir.join(&source_frame)).unwrap().to_rgba8();
let instance = image::open(dir.join(&template_frame)).unwrap().to_rgba8();
assert_eq!(direct.dimensions(), (320, 90));
assert_eq!(instance.dimensions(), direct.dimensions());
let direct_bar = direct.get_pixel(10, 75).0;
let instance_bar = instance.get_pixel(10, 75).0;
assert!(
direct_bar[0] > 240 && direct_bar[1] < 15 && direct_bar[2] < 15,
"{name}: direct source has the authored red bar at the requested valid time: {direct_bar:?}"
);
let different = direct.pixels().zip(instance.pixels()).filter(|(a, b)| a != b).count();
eprintln!("{name}: duration={duration:?}, direct_bar={direct_bar:?}, instance_bar={instance_bar:?}, different_pixels={different}");
assert_eq!(different, 0, "{name}: the template instance must preserve every source pixel at the same valid time");
}
#[test]
fn template_instance_preserves_the_partial_final_source_frame() {
let dir = std::env::temp_dir().join(format!("ec-qa-template-partial-tail-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("build.jsx"), BUILD).unwrap();
let built = ok(&dir, &["script", "build.jsx", "--save-as", "base.ecproj"]);
assert_eq!(built["output"], "controls: 3", "{built}");
// Establish ordinary instance rendering before testing the final partial source frame.
timeline_tail_render_pair(&dir, "aligned", effectcraft_time::Tick::from_seconds_f64(2.0));
let partial = effectcraft_time::Tick(effectcraft_time::TICKS_PER_SECOND * 101 / 100);
assert_eq!(partial.seconds(), 1.01);
timeline_tail_render_pair(&dir, "partial", partial);
let _ = std::fs::remove_dir_all(&dir);
}
// Original procedural motion: the renderer supplies the expected pixels for both states.
const GEOMETRY_BLUR_BUILD: &str = r#"
app.beginUndoGroup("Build Blur Template");
var comp = app.project.items.addComp("Blur Template", 160, 64, 1, 2, 24);
var mover = comp.layers.addSolid([1, 0.3, 0.1], "Mover", 12, 16, 1, 2);
var position = mover.property("ADBE Transform Group").property("ADBE Position");
position.setValueAtTime(0, [16, 32]);
position.setValueAtTime(1, [144, 32]);
position.setInterpolationTypeAtKey(1, KeyframeInterpolationType.LINEAR, KeyframeInterpolationType.LINEAR);
position.setInterpolationTypeAtKey(2, KeyframeInterpolationType.LINEAR, KeyframeInterpolationType.LINEAR);
mover.motionBlur = true;
comp.motionBlur = true;
comp.shutterAngle = 180;
comp.shutterPhase = -90;
mover.property("ADBE Transform Group").property("ADBE Opacity").addToMotionGraphicsTemplateAs(comp, "Opacity");
app.endUndoGroup();
"#;
fn geometry_blur_difference(a: &image::RgbaImage, b: &image::RgbaImage) -> usize {
assert_eq!(a.dimensions(), b.dimensions());
a.pixels().zip(b.pixels()).filter(|(a, b)| a != b).count()
}
fn geometry_blur_assert_inputs(dir: &std::path::Path, inputs: &[(&str, Vec<u8>)]) {
for (name, before) in inputs {
assert_eq!(&std::fs::read(dir.join(name)).unwrap(), before, "template rendering preserves input {name}");
}
}
#[test]
fn template_wrapper_preserves_authored_motion_blur() {
let dir = std::env::temp_dir().join(format!("ec-qa-template-authored-blur-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("motion.jsx"), GEOMETRY_BLUR_BUILD).unwrap();
let built = ok(&dir, &["script", "motion.jsx", "--save-as", "moving.ecproj"]);
assert_eq!(built["ok"], true, "{built}");
// Check the authored switch/key setup, then use an unchanged top-level composition as
// the production pixel oracle. No expected raster or threshold is synthesized here.
let project = effectcraft_engine::project::Project::from_json(&std::fs::read_to_string(dir.join("moving.ecproj")).unwrap()).unwrap();
let source = project.find_by_name("Blur Template").unwrap().id;
let comp = project.comp(source).unwrap();
assert!(comp.enable_motion_blur);
assert_eq!(comp.frame_rate, effectcraft_time::FrameRate::new(24, 1));
assert_eq!(comp.layers.len(), 1);
assert!(comp.layers[0].switches.motion_blur);
assert_eq!(comp.layers[0].props.prop("transform/position").unwrap().keys.len(), 2);
let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Blur Template","path":"moving.ectemplate"}"#, "moving.ecproj"]);
assert_eq!(exported["controls"], 1, "{exported}");
// The positive control disables only the source layer's authored Motion Blur switch.
// It retains the same geometry, timing, source comp settings and template-control values.
ok(
&dir,
&[
"run",
"moving.ecproj",
"layer.setSwitch",
r#"{"comp":"Blur Template","layers":["Mover"],"switch":"motionBlur","value":false}"#,
"--save-as",
"unblurred.ecproj",
],
);
let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Blur Template","path":"unblurred.ectemplate"}"#, "unblurred.ecproj"]);
assert_eq!(exported["controls"], 1, "{exported}");
let inputs: Vec<_> = ["moving.ecproj", "moving.ectemplate", "unblurred.ecproj", "unblurred.ectemplate"]
.into_iter()
.map(|name| (name, std::fs::read(dir.join(name)).unwrap()))
.collect();
ok(&dir, &["render-frame", "moving.ecproj", "--comp", "Blur Template", "--time", "0.5", "--transparent", "--out", "direct-blur.png"]);
geometry_blur_assert_inputs(&dir, &inputs);
ok(&dir, &["render-frame", "unblurred.ecproj", "--comp", "Blur Template", "--time", "0.5", "--transparent", "--out", "direct-unblurred.png"]);
geometry_blur_assert_inputs(&dir, &inputs);
ok(&dir, &["render-frame", "--template", "unblurred.ectemplate", "--time", "0.5", "--transparent", "--out", "template-unblurred.png"]);
geometry_blur_assert_inputs(&dir, &inputs);
let blurred = image::open(dir.join("direct-blur.png")).unwrap().to_rgba8();
let unblurred = image::open(dir.join("direct-unblurred.png")).unwrap().to_rgba8();
let template_unblurred = image::open(dir.join("template-unblurred.png")).unwrap().to_rgba8();
assert_eq!(blurred.dimensions(), (160, 64));
assert_eq!(unblurred.dimensions(), (160, 64));
assert!(blurred.pixels().any(|p| p.0[3] > 0), "the direct blurred source is visible");
assert!(unblurred.pixels().any(|p| p.0[3] > 0), "the direct unblurred source is visible");
let authored_blur_difference = geometry_blur_difference(&blurred, &unblurred);
assert!(authored_blur_difference > 0, "the real source renderer must show the authored motion blur");
assert_eq!(template_unblurred, unblurred, "an unblurred template preserves its source pixels");
eprintln!("CLI_TEMPLATE_BLUR_CONTROLS_PASSED source_blur_vs_source_unblurred={authored_blur_difference}");
ok(&dir, &["render-frame", "--template", "moving.ectemplate", "--time", "0.5", "--transparent", "--out", "template-blur.png"]);
geometry_blur_assert_inputs(&dir, &inputs);
let template_blurred = image::open(dir.join("template-blur.png")).unwrap().to_rgba8();
let differing_pixels = geometry_blur_difference(&template_blurred, &blurred);
let unblurred_difference = geometry_blur_difference(&template_blurred, &unblurred);
eprintln!(
"template authored-blur result: source_vs_template_differing_pixels={differing_pixels}, template_vs_unblurred_differing_pixels={unblurred_difference}"
);
assert_eq!(differing_pixels, 0, "template wrapper must preserve authored source motion blur");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn template_unknown_control_does_not_replace_extracted_media() {
let temp_root = std::env::temp_dir().canonicalize().unwrap();
let dir = temp_root.join(format!("ec-qa-template-embedded-media-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("build.jsx"), BUILD).unwrap();
let built = ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]);
assert_eq!(built["output"], "controls: 3", "{built}");
// Both PNGs are generated here; the template uses an ordinary file-backed footage layer.
image::RgbaImage::from_pixel(2, 2, image::Rgba([30, 60, 90, 255])).save(dir.join("source.png")).unwrap();
let imported_reply = ok(&dir, &["exec", "file.import", r#"{"paths":["source.png"]}"#, "t.ecproj", "--save"]);
let imported = &imported_reply["result"];
assert!(imported["errors"].as_array().unwrap().is_empty(), "{imported}");
let items = imported["items"].as_array().unwrap();
assert_eq!(items.len(), 1, "{imported}");
let item = items[0].as_u64().unwrap();
let params = serde_json::json!({"comp": "Lower Third", "item": item, "position": [8, 8], "time": 0}).to_string();
ok(&dir, &["exec", "layer.addItem", &params, "t.ecproj", "--save"]);
let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"lt.ectemplate"}"#, "t.ecproj"]);
assert_eq!(exported["controls"], 3, "{exported}");
let info = ok(&dir, &["exec", "essential.templateInfo", r#"{"path":"lt.ectemplate"}"#, "--empty"]);
let media = info["media"].as_array().unwrap();
assert_eq!(media.len(), 1, "{info}");
let files = media[0]["files"].as_array().unwrap();
assert_eq!(files.len(), 1, "{info}");
let entry = files[0].as_str().unwrap();
let relative = std::path::Path::new(entry.strip_prefix("media/").unwrap());
assert_eq!(relative.components().count(), 2, "{entry}");
assert!(relative.components().all(|part| matches!(part, std::path::Component::Normal(_))), "{entry}");
assert_eq!(relative.file_name().unwrap(), "source.png");
let extracted = dir.join("lt Media").join(relative);
assert!(extracted.starts_with(&dir));
std::fs::write(dir.join("values.json"), r##"{"Title":"John Smith","Bar Color":"#00c080","bar opacity":50}"##).unwrap();
let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap();
let valid_values_before = std::fs::read(dir.join("values.json")).unwrap();
let source_before = std::fs::read(dir.join("source.png")).unwrap();
ok(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "ordinary.png"]);
let ordinary = image::open(dir.join("ordinary.png")).unwrap().to_rgba8();
let bar = ordinary.get_pixel(10, 75).0;
assert!(bar[0] < 15 && (bar[1] as i32 - 96).abs() < 8 && (bar[2] as i32 - 64).abs() < 8, "known control values rendered: {bar:?}");
let footage = ordinary.get_pixel(8, 8).0;
assert!(
(footage[0] as i32 - 30).abs() <= 2 && (footage[1] as i32 - 60).abs() <= 2 && (footage[2] as i32 - 90).abs() <= 2 && footage[3] == 255,
"embedded footage rendered: {footage:?}"
);
assert_eq!(std::fs::read(&extracted).unwrap(), source_before, "ordinary render extracted the declared embedded PNG");
assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), template_before);
assert_eq!(std::fs::read(dir.join("values.json")).unwrap(), valid_values_before);
assert_eq!(std::fs::read(dir.join("source.png")).unwrap(), source_before);
let ordinary_before = std::fs::read(dir.join("ordinary.png")).unwrap();
// Keep a different valid PNG at the exact extraction path and an existing render output.
image::RgbaImage::from_pixel(2, 2, image::Rgba([220, 10, 20, 255])).save(&extracted).unwrap();
let sentinel_before = std::fs::read(&extracted).unwrap();
assert_ne!(sentinel_before, source_before);
std::fs::copy(dir.join("ordinary.png"), dir.join("failed.png")).unwrap();
let output_before = std::fs::read(dir.join("failed.png")).unwrap();
std::fs::write(dir.join("bad-values.json"), r#"{"Subtitle":"x"}"#).unwrap();
let invalid_values_before = std::fs::read(dir.join("bad-values.json")).unwrap();
// Capture every file before interpreting status/JSON, so a failed command cannot hide a write.
let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli"))
.current_dir(&dir)
.args(["render-frame", "--template", "lt.ectemplate", "--values", "bad-values.json", "--time", "1", "--out", "failed.png", "--json"])
.output()
.unwrap();
let template_after = std::fs::read(dir.join("lt.ectemplate")).unwrap();
let valid_values_after = std::fs::read(dir.join("values.json")).unwrap();
let invalid_values_after = std::fs::read(dir.join("bad-values.json")).unwrap();
let source_after = std::fs::read(dir.join("source.png")).unwrap();
let ordinary_after = std::fs::read(dir.join("ordinary.png")).unwrap();
let output_after = std::fs::read(dir.join("failed.png")).unwrap();
let sentinel_after = std::fs::read(&extracted).unwrap();
let media_preserved = sentinel_after == sentinel_before;
eprintln!(
"EMBEDDED_MEDIA_OBSERVATION status={:?} media_preserved={media_preserved} source_preserved={} output_preserved={} stdout={} stderr={}",
result.status.code(),
source_after == source_before,
output_after == output_before,
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr)
);
assert_eq!(result.status.code(), Some(1));
let reply: Value = serde_json::from_slice(&result.stdout).unwrap();
let error = reply["error"].as_str().unwrap();
assert!(error.contains("no control named `Subtitle`") && error.contains("controls: Title, Bar Color, Bar Opacity"), "{reply}");
assert_eq!(template_after, template_before);
assert_eq!(valid_values_after, valid_values_before);
assert_eq!(invalid_values_after, invalid_values_before);
assert_eq!(source_after, source_before);
assert_eq!(ordinary_after, ordinary_before);
assert_eq!(output_after, output_before);
eprintln!("EMBEDDED_MEDIA_UNKNOWN_CONTROL: valid ordinary render and actual unknown-control failure verified");
assert!(media_preserved, "invalid template values must fail before overwriting previously extracted media");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn template_missing_output_does_not_replace_extracted_media() {
let temp_root = std::env::temp_dir().canonicalize().unwrap();
let dir = temp_root.join(format!("ec-qa-template-missing-output-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("build.jsx"), BUILD).unwrap();
let built = ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]);
assert_eq!(built["output"], "controls: 3", "{built}");
// Both PNGs are generated here; the template uses an ordinary file-backed footage layer.
image::RgbaImage::from_pixel(2, 2, image::Rgba([30, 60, 90, 255])).save(dir.join("source.png")).unwrap();
let imported_reply = ok(&dir, &["exec", "file.import", r#"{"paths":["source.png"]}"#, "t.ecproj", "--save"]);
let imported = &imported_reply["result"];
assert!(imported["errors"].as_array().unwrap().is_empty(), "{imported}");
let items = imported["items"].as_array().unwrap();
assert_eq!(items.len(), 1, "{imported}");
let item = items[0].as_u64().unwrap();
let params = serde_json::json!({"comp": "Lower Third", "item": item, "position": [8, 8], "time": 0}).to_string();
ok(&dir, &["exec", "layer.addItem", &params, "t.ecproj", "--save"]);
let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"lt.ectemplate"}"#, "t.ecproj"]);
assert_eq!(exported["controls"], 3, "{exported}");
let info = ok(&dir, &["exec", "essential.templateInfo", r#"{"path":"lt.ectemplate"}"#, "--empty"]);
let media = info["media"].as_array().unwrap();
assert_eq!(media.len(), 1, "{info}");
let files = media[0]["files"].as_array().unwrap();
assert_eq!(files.len(), 1, "{info}");
let entry = files[0].as_str().unwrap();
let relative = std::path::Path::new(entry.strip_prefix("media/").unwrap());
assert_eq!(relative.components().count(), 2, "{entry}");
assert!(relative.components().all(|part| matches!(part, std::path::Component::Normal(_))), "{entry}");
assert_eq!(relative.file_name().unwrap(), "source.png");
let extracted = dir.join("lt Media").join(relative);
assert!(extracted.starts_with(&dir));
std::fs::write(dir.join("values.json"), r##"{"Title":"John Smith","Bar Color":"#00c080","bar opacity":50}"##).unwrap();
let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap();
let valid_values_before = std::fs::read(dir.join("values.json")).unwrap();
let source_before = std::fs::read(dir.join("source.png")).unwrap();
ok(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "ordinary.png"]);
let ordinary = image::open(dir.join("ordinary.png")).unwrap().to_rgba8();
let bar = ordinary.get_pixel(10, 75).0;
assert!(bar[0] < 15 && (bar[1] as i32 - 96).abs() < 8 && (bar[2] as i32 - 64).abs() < 8, "known control values rendered: {bar:?}");
let footage = ordinary.get_pixel(8, 8).0;
assert!(
(footage[0] as i32 - 30).abs() <= 2 && (footage[1] as i32 - 60).abs() <= 2 && (footage[2] as i32 - 90).abs() <= 2 && footage[3] == 255,
"embedded footage rendered: {footage:?}"
);
assert_eq!(std::fs::read(&extracted).unwrap(), source_before, "ordinary render extracted the declared embedded PNG");
assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), template_before);
assert_eq!(std::fs::read(dir.join("values.json")).unwrap(), valid_values_before);
assert_eq!(std::fs::read(dir.join("source.png")).unwrap(), source_before);
let ordinary_before = std::fs::read(dir.join("ordinary.png")).unwrap();
// Keep a different valid PNG at the normal extraction path before a usage error.
image::RgbaImage::from_pixel(2, 2, image::Rgba([220, 10, 20, 255])).save(&extracted).unwrap();
let sentinel_before = std::fs::read(&extracted).unwrap();
assert_ne!(sentinel_before, source_before);
// Observe writes before interpreting the actual CLI usage exit.
let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli"))
.current_dir(&dir)
.args(["render", "--template", "lt.ectemplate", "--values", "values.json", "--json"])
.output()
.unwrap();
let template_after = std::fs::read(dir.join("lt.ectemplate")).unwrap();
let values_after = std::fs::read(dir.join("values.json")).unwrap();
let source_after = std::fs::read(dir.join("source.png")).unwrap();
let output_after = std::fs::read(dir.join("ordinary.png")).unwrap();
let sentinel_after = std::fs::read(&extracted).unwrap();
let media_preserved = sentinel_after == sentinel_before;
let stderr = String::from_utf8_lossy(&result.stderr);
eprintln!(
"MISSING_OUTPUT_OBSERVATION status={:?} media_preserved={media_preserved} source_preserved={} output_preserved={} stdout={} stderr={stderr}",
result.status.code(),
source_after == source_before,
output_after == ordinary_before,
String::from_utf8_lossy(&result.stdout),
);
assert_eq!(result.status.code(), Some(2), "{stderr}");
assert!(stderr.contains("render: --out FILE is required (or --queue)"), "{stderr}");
assert_eq!(template_after, template_before);
assert_eq!(values_after, valid_values_before);
assert_eq!(source_after, source_before);
assert_eq!(output_after, ordinary_before);
assert!(!dir.join("frame.png").exists(), "a missing render output must not create a default frame");
eprintln!("MISSING_OUTPUT_REQUIRED_OUT: valid ordinary render/extraction and actual required-output usage failure verified");
assert!(media_preserved, "missing --out must fail before overwriting previously extracted media");
let _ = std::fs::remove_dir_all(&dir);
}

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();
@ -214,7 +241,10 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
let _ = session.execute("file.openDemoProject", json!({}));
}
api::set_info("restored", json!(restored));
// The ScriptUI panels open on the last visit open again.
let _ = session.execute("window.restoreScriptPanels", json!({}));
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,19 +14,28 @@ 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 = { workspace = true }
serde_json = { workspace = true }
# The window's monitors, size and position (src/window.rs), and macOS event-loop options (no app
# activation when driven by an agent). The same winit eframe runs on.
winit = { version = "0.30", default-features = false }
rfd = { workspace = true }
pollster = { workspace = true }
cpal = { workspace = true }
log = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
# Settings ▸ Appearance ▸ Sync with System: the XDG desktop portal's colour scheme. Already in
# Cargo.lock (accesskit, through eframe), so it adds no new crates.
zbus = "5.19"
[build-dependencies]
# Icon + VERSIONINFO for effectcraft.exe; build.rs only uses it when targeting Windows.
winresource = "0.1"
[target.'cfg(target_os = "macos")'.dependencies]
# Same winit as eframe: macOS event-loop options (no app activation when driven by an agent).
winit = { version = "0.30", default-features = false }
# Keep the control channel responsive when the window is covered (App Nap / occlusion throttling).
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSProcessInfo", "NSString", "NSObject"] }
# The native menu bar (NSMenu). MIT OR Apache-2.0; macOS only, never in the web build.
@ -34,3 +43,8 @@ muda = { version = "0.21", default-features = false }
objc2 = "0.6"
# NSApplication hide / hide others / show all, and the pasteboard for Edit ▸ Paste in text fields.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSPasteboard"] }
# Finder double-click, Open With, Dock drops and `open -a EffectCraft x.ecproj` arrive as a
# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This audited crate (also used by
# PhotoCraft and PdfCraft) registers the handler (Objective-C interop needs unsafe, which our
# workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"

View File

@ -0,0 +1,154 @@
//! The desktop's light or dark appearance on Linux, for Settings ▸ Appearance ▸ Appearance Mode ▸
//! Sync with System. Wayland compositors often report no theme to winit, so this reads the XDG
//! desktop portal instead. Other platforms use what egui reports.
#[cfg(target_os = "linux")]
const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
#[cfg(target_os = "linux")]
const PORTAL_KEY: &str = "color-scheme";
#[cfg(target_os = "linux")]
fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
}
#[cfg(target_os = "linux")]
fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option<egui::Theme> {
let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?;
decode(portal_code(value)?)
}
/// The colour-scheme number inside a portal reply.
#[cfg(target_os = "linux")]
fn portal_code(value: zbus::zvariant::OwnedValue) -> Option<u32> {
let mut value: zbus::zvariant::Value<'_> = value.into();
// `Settings.Read` answers with a variant, and some portals nest another variant inside it.
for _ in 0..4 {
match value {
zbus::zvariant::Value::Value(inner) => value = *inner,
other => return u32::try_from(other).ok(),
}
}
None
}
/// The portal's colour scheme: 1 prefers dark, 2 prefers light, 0 (or anything else) says nothing.
#[cfg(target_os = "linux")]
fn decode(code: u32) -> Option<egui::Theme> {
match code {
1 => Some(egui::Theme::Dark),
2 => Some(egui::Theme::Light),
_ => None,
}
}
/// Fallback for desktops without the settings portal. It runs once at start-up, by absolute path,
/// and is never repeated.
#[cfg(target_os = "linux")]
fn gtk_theme() -> Option<egui::Theme> {
let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?;
let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?;
if !output.status.success() {
return None;
}
parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?)
}
#[cfg(target_os = "linux")]
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
match s.trim().trim_matches('\'') {
"prefer-dark" => Some(egui::Theme::Dark),
"prefer-light" => Some(egui::Theme::Light),
_ => None,
}
}
#[cfg(target_os = "linux")]
fn code_of(theme: Option<egui::Theme>) -> u8 {
match theme {
Some(egui::Theme::Dark) => 1,
Some(egui::Theme::Light) => 2,
None => 0,
}
}
/// Reads the colour scheme once, then follows the portal's `SettingChanged` signal. There is no
/// polling: the worker sleeps in the signal iterator and wakes the UI only when the value changes.
pub fn service() -> Option<effectcraft_ui_egui::SystemThemeFn> {
#[cfg(target_os = "linux")]
{
use std::sync::{
Arc, OnceLock,
atomic::{AtomicU8, Ordering},
};
let value = Arc::new(AtomicU8::new(0));
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
let (worker_value, worker_wake) = (Arc::clone(&value), Arc::clone(&wake));
let (ready, wait) = std::sync::mpsc::channel();
if std::thread::Builder::new()
.name("appearance-portal".into())
.spawn(move || {
let proxy = portal_proxy();
// Subscribe before reading so a change during startup stays queued.
let signals = proxy.as_ref().and_then(|p| p.receive_signal("SettingChanged").ok());
let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme);
// Startup can finish after the UI's bounded wait: wake its first frame too.
if worker_value.swap(code_of(initial), Ordering::Relaxed) != code_of(initial)
&& let Some(ctx) = worker_wake.get()
{
ctx.request_repaint();
}
let _ = ready.send(());
let Some(signals) = signals else { return };
for message in signals {
let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else {
continue;
};
if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY {
continue;
}
let code = code_of(portal_code(changed).and_then(decode));
if worker_value.swap(code, Ordering::Relaxed) != code
&& let Some(ctx) = worker_wake.get()
{
ctx.request_repaint();
}
}
})
.is_err()
{
return None;
}
// The first frame already has the answer when the portal replies quickly.
let _ = wait.recv_timeout(std::time::Duration::from_millis(250));
Some(Box::new(move |ctx| {
let _ = wake.set(ctx.clone());
decode(u32::from(value.load(Ordering::Relaxed)))
}))
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
#[cfg(test)]
mod tests {
#[cfg(target_os = "linux")]
#[test]
fn portal_and_gsettings_values_decode() {
assert_eq!(super::decode(0), None);
assert_eq!(super::decode(1), Some(egui::Theme::Dark));
assert_eq!(super::decode(2), Some(egui::Theme::Light));
assert_eq!(super::decode(9), None);
let nested = zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::U32(2)))));
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2));
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::from(1u32)), Some(1));
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(zbus::zvariant::Value::from("dark")).unwrap()), None);
assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light));
assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark));
assert_eq!(super::parse_gtk_scheme("'default'\n"), None);
assert_eq!(super::code_of(super::decode(2)), 2);
}
}

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

@ -0,0 +1,80 @@
//! macOS open-documents and quit Apple events.
//!
//! Finder double-clicks, Open With, drops on the Dock icon and `open -a EffectCraft x.ecproj` don't
//! pass paths on the command line: LaunchServices sends the running (or just-launched) app a
//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the
//! `NSApplicationDelegate`, so the project never opened. Handling it ourselves needs Objective-C
//! class declarations, i.e. `unsafe`, which this workspace forbids. The audited `fmv-macos-events`
//! crate (also used by PhotoCraft and PdfCraft) wraps exactly that, an `NSAppleEventManager`
//! handler registered before Finder's launch event that leaves winit's delegate alone, behind a
//! safe main-thread API.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use fmv_macos_events::{Event, Inbox, Registration};
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
pub struct AppleEvents {
_registration: Registration,
inbox: Inbox,
}
impl AppleEvents {
/// Register the handlers. Call on the main thread before the event loop starts, so the event
/// that launched the app (a Finder double-click) is caught too.
pub fn install() -> Self {
let (registration, inbox) = Registration::install();
Self { _registration: registration, inbox }
}
/// The queue the app drains every frame ([`Opened::feed`]); events arriving later wake `ctx`.
pub fn connect(&self, ctx: &egui::Context) -> Opened {
let ctx = ctx.clone();
self.inbox.set_wake(move || ctx.request_repaint());
Opened { inbox: self.inbox.clone(), pending: Vec::new() }
}
}
/// Documents macOS asked us to open, waiting for the UI to take them.
pub struct Opened {
inbox: Inbox,
/// Paths that arrived before the UI was ready (the Finder event that launched the app comes
/// before the first frame is drawn).
pending: Vec<PathBuf>,
}
impl Opened {
/// Hand the documents to the dropped-file handling once the UI is `ready` (a project opens,
/// asking about unsaved changes first; media is imported), and act on quit requests.
pub fn feed(&mut self, ready: bool, ctx: &egui::Context, raw: &mut egui::RawInput) {
for e in self.inbox.drain() {
match e {
Event::Open(paths) => self.pending.extend(paths),
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
}
}
if self.pending.is_empty() {
return;
}
if !ready {
ctx.request_repaint();
return;
}
raw.dropped_files.extend(self.pending.drain(..).map(|p| Arc::new(OpenedFile(p)) as egui::DroppedFileHandle));
}
}
/// A document macOS asked us to open, presented to egui as a dropped file.
#[derive(Debug)]
struct OpenedFile(PathBuf);
impl egui::DroppedFile for OpenedFile {
fn path(&self) -> &Path {
&self.0
}
fn bytes(&self) -> Result<Vec<u8>, String> {
std::fs::read(&self.0).map_err(|e| e.to_string())
}
}

View File

@ -10,11 +10,32 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc;
use std::thread::JoinHandle;
use std::time::Duration;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{FromSample, SizedSample};
use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed, AudioOutput};
/// Device callback period: about 10 ms, clamped so Pulse/PipeWire/ALSA does not open a
/// multi-hundred-millisecond default buffer that plays as silence at the start of preview (#554).
fn preferred_buffer_frames(rate: u32) -> u32 {
(rate / 100).clamp(256, 2048)
}
/// Wait until the feeder has mixed a block, or until preview is stopped. Starting the stream
/// before that fills the device with silence, so the first real samples are heard late (#554).
fn wait_to_play(feed: &AudioFeed, stop: &mpsc::Receiver<()>) -> bool {
loop {
if feed.queued_frames() > 0 {
return true;
}
match stop.recv_timeout(Duration::from_millis(2)) {
Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return false,
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
}
}
pub struct CpalOut {
out: AudioOutput,
rate: u32,
@ -76,7 +97,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) {
@ -96,7 +117,7 @@ fn run<T: SizedSample + FromSample<f32>>(
let dev_lat = ts.playback.duration_since(&ts.callback).map(|d| d.as_secs_f64()).unwrap_or(0.0);
latency.store((dev_lat * rate) as u64 + frames as u64, Ordering::Relaxed);
},
|e| eprintln!("effectcraft: audio output: {e}"),
|e| log::warn!("audio output: {e}"),
None,
)
.map_err(|e| e.to_string())
@ -131,21 +152,44 @@ impl AudioDevice for CpalOut {
};
let mut cfg: cpal::StreamConfig = sup.config();
cfg.sample_rate = cpal::SampleRate(rate);
let stream = match sup.sample_format() {
cpal::SampleFormat::F32 => run::<f32>(&dev, &cfg, feed, latency, map),
cpal::SampleFormat::I16 => run::<i16>(&dev, &cfg, feed, latency, map),
cpal::SampleFormat::U16 => run::<u16>(&dev, &cfg, feed, latency, map),
cpal::SampleFormat::I32 => run::<i32>(&dev, &cfg, feed, latency, map),
// A short period only where the default one is long (ALSA/PulseAudio on Linux, #554),
// and only within what the device reports it takes.
if cfg!(target_os = "linux") {
let want = preferred_buffer_frames(rate);
cfg.buffer_size = match sup.buffer_size() {
cpal::SupportedBufferSize::Range { min, max } if *min <= *max => cpal::BufferSize::Fixed(want.clamp(*min, *max)),
_ => cpal::BufferSize::Default,
};
}
let build = |cfg: &cpal::StreamConfig| match sup.sample_format() {
cpal::SampleFormat::F32 => run::<f32>(&dev, cfg, feed.clone(), latency.clone(), map),
cpal::SampleFormat::I16 => run::<i16>(&dev, cfg, feed.clone(), latency.clone(), map),
cpal::SampleFormat::U16 => run::<u16>(&dev, cfg, feed.clone(), latency.clone(), map),
cpal::SampleFormat::I32 => run::<i32>(&dev, cfg, feed.clone(), latency.clone(), map),
f => Err(format!("unsupported sample format {f:?}")),
};
let stream = match stream.and_then(|s| s.play().map(|_| s).map_err(|e| e.to_string())) {
let stream = match build(&cfg) {
Ok(s) => s,
Err(e) => {
let _ = ready_tx.send(Err(e));
return;
Err(_) => {
// Some hosts reject a fixed period; a large default still plays, just later.
cfg.buffer_size = cpal::BufferSize::Default;
match build(&cfg) {
Ok(s) => s,
Err(e) => {
let _ = ready_tx.send(Err(e));
return;
}
}
}
};
let _ = ready_tx.send(Ok(()));
if !wait_to_play(&feed, &stop_rx) {
return;
}
if let Err(e) = stream.play() {
log::warn!("audio output: {e}");
return;
}
let _ = stop_rx.recv();
drop(stream);
})
@ -174,3 +218,37 @@ impl Drop for CpalOut {
self.stop();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preferred_buffer_is_about_ten_milliseconds() {
assert_eq!(preferred_buffer_frames(48_000), 480);
assert_eq!(preferred_buffer_frames(8_000), 256);
assert_eq!(preferred_buffer_frames(192_000), 1920);
}
#[test]
fn wait_to_play_returns_once_the_feeder_has_samples() {
let feed = Arc::new(AudioFeed::default());
let (_tx, rx) = mpsc::channel();
let f = feed.clone();
let h = std::thread::spawn(move || wait_to_play(&f, &rx));
assert_eq!(feed.queued_frames(), 0);
std::thread::sleep(Duration::from_millis(20));
feed.push(&[0.1, 0.2]);
assert!(h.join().unwrap());
assert_eq!(feed.queued_frames(), 1);
}
#[test]
fn wait_to_play_stops_without_samples() {
let feed = AudioFeed::default();
let (tx, rx) = mpsc::channel();
let h = std::thread::spawn(move || wait_to_play(&feed, &rx));
tx.send(()).unwrap();
assert!(!h.join().unwrap());
}
}

View File

@ -1,61 +1,274 @@
//! 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)) {
Ok(l) => l,
Err(e) => {
eprintln!("effectcraft: control server failed to bind 127.0.0.1:{port}: {e}");
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
return rx;
}
};
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));
}
});
log::info!("control server listening on 127.0.0.1:{port}");
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,220 @@
//! 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), or a moment after
//! its first frame, as the window is shown and its surface re-created (AMD's Vulkan driver for
//! a Radeon RX 6800M, Adrenalin 26.9.2: an access violation in amdvlk64.dll on every launch).
//! A crash in a driver can't be caught, so each launch leaves a marker in the settings folder
//! until its window has kept drawing for [`SETTLE`] (or the app quits normally before then). 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 std::time::{Duration, Instant};
use effectcraft_engine::prefs::{PREFS_FILE, Prefs};
use eframe::wgpu::Backends;
/// Left in the settings folder from a launch until its window has settled.
const MARKER: &str = "launch-pending";
/// How long the window keeps drawing after its first frame before the launch counts as made.
/// A driver that fails as the window is shown and its surface re-created does so within the
/// first second; a user quitting sooner than this is told the next launch uses OpenGL and can
/// switch back in Settings.
pub const SETTLE: Duration = Duration::from_secs(3);
/// 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 stopped as its window opened: the message
/// to show.
pub notice: Option<String>,
marker: Option<PathBuf>,
/// Holds the marker's lock until the launch settles (or the process ends).
lock: Option<File>,
/// When the window drew its first frame, until the launch settles.
first_frame: Option<Instant>,
}
impl Launch {
/// Read Window Graphics in the settings folder `dir`, switch it to OpenGL when the previous
/// launch stopped as its window opened (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 stopped as its window opened: switching Window Graphics to OpenGL"),
Err(e) => log::warn!("the last launch stopped as its window opened; saving Window Graphics failed: {e}"),
}
notice = Some(
"EffectCraft's last launch stopped as 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, first_frame: None }
}
/// The window drew a frame. Until the launch has settled, returns how long that still takes
/// (ask for a repaint then, so that an idle window settles too).
pub fn frame_drawn(&mut self) -> Option<Duration> {
self.frame_drawn_at(Instant::now())
}
fn frame_drawn_at(&mut self, now: Instant) -> Option<Duration> {
self.marker.as_ref()?;
let first = *self.first_frame.get_or_insert(now);
let left = SETTLE.saturating_sub(now.saturating_duration_since(first));
if left.is_zero() {
self.settled();
return None;
}
Some(left)
}
/// The app quits normally: however soon after opening, that's no crash.
pub fn exited(&mut self) {
self.settled();
}
/// The launch made it: release and remove its marker.
fn settled(&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. Its first frames don't settle it (a driver
// may still crash as the window is shown); frames for `SETTLE` do.
let mut l = Launch::begin(Some(&dir), false);
assert_eq!((l.backends, l.notice.is_some()), (None, false));
assert!(dir.join(MARKER).exists());
let t0 = Instant::now();
assert_eq!(l.frame_drawn_at(t0), Some(SETTLE));
assert!(l.frame_drawn_at(t0 + SETTLE / 2).is_some_and(|left| left <= SETTLE / 2));
assert!(dir.join(MARKER).exists());
assert_eq!(l.frame_drawn_at(t0 + SETTLE), None);
assert!(!dir.join(MARKER).exists());
assert_eq!(l.frame_drawn_at(t0 + SETTLE), None, "settled stays settled");
// 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.exited();
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 launch that stops moments after its first frame (AMD's Vulkan driver crashing as the
/// window is shown and its surface re-created) switches the next one to OpenGL too; a
/// normal quit in that time doesn't.
#[test]
fn a_launch_that_stops_after_its_first_frame_switches_the_next_to_opengl() {
let dir = std::env::temp_dir().join(format!("ec-launch-guard-3-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut l = Launch::begin(Some(&dir), false);
assert!(l.frame_drawn().is_some());
drop(l);
let l = Launch::begin(Some(&dir), false);
if cfg!(target_os = "macos") {
assert_eq!(l.backends, None, "no OpenGL on macOS");
} else {
assert_eq!(l.backends, Some(Backends::GL));
assert!(l.notice.is_some());
}
drop(l);
let _ = std::fs::remove_dir_all(&dir);
// Quitting right after the window opened leaves no marker.
let mut l = Launch::begin(Some(&dir), false);
assert!(l.frame_drawn().is_some());
l.exited();
assert!(!dir.join(MARKER).exists());
assert_eq!(Launch::begin(Some(&dir), false).backends, None);
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,69 +2,170 @@
//!
//! 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.
mod appearance;
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
mod appimage;
#[cfg(target_os = "macos")]
mod apple_events;
mod audio_out;
mod control_server;
mod launch_guard;
#[cfg(target_os = "macos")]
mod native_menu;
// Built everywhere so it stays checked; only the macOS app uses it.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
mod sheet_dialogs;
mod window;
use effectcraft_ui_egui::EffectcraftApp;
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()
}
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);
/// Both native JSON projects and Save a Copy As XML projects are openable on launch.
/// Keep this classification shared by project opening and the subsequent media-import pass.
fn is_project_file(path: &str) -> bool {
std::path::Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("ecproj") || ext.eq_ignore_ascii_case("ecprojx"))
}
/// winit 0.30 cannot deliver native Wayland file drops. XWayland can.
#[cfg(target_os = "linux")]
fn wayland_drop_notice(wayland_display: Option<&std::ffi::OsStr>, backend: Option<&std::ffi::OsStr>) -> Option<&'static str> {
(wayland_display.is_some_and(|v| !v.is_empty()) && backend != Some(std::ffi::OsStr::new("x11")))
.then_some("Native Wayland file drops are unavailable. Use File ▸ Import ▸ File… or run via XWayland (see README, Linux).")
}
/// 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" => 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),
"--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 {
// First, so the panic hook and every start-up warning are recorded. Records go to stderr
// and the session log; Help ▸ Enable Logging writes through this logger too, and warnings
// feed the compatibility report.
effectcraft_engine::logging::install();
effectcraft_engine::logging::install_panic_hook();
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 session log lives in the settings folder (`Logs/effectcraft.log`, the previous two runs
// kept): a launch from the Dock or a menu has no terminal. Opened after the arguments, so
// `--version` leaves no file behind; records logged until now are written to it first.
if let Some(dir) = effectcraft_host::config_dir() {
match effectcraft_engine::logging::attach_session_log(&effectcraft_engine::logging::log_dir(Some(&dir))) {
Ok(path) => log::info!("effectcraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
Err(e) => log::warn!("no log file: {e}"),
}
}
// 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
// stopped as its window opened (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")
.with_inner_size([1680.0, 1020.0])
.with_min_inner_size([960.0, 600.0])
.with_inner_size(window::DEFAULT_SIZE)
.with_min_inner_size(window::MIN_SIZE)
.with_drag_and_drop(true)
// Started for an agent (`--control`): open without taking the user's keyboard focus.
.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 go to stderr and the session log, so a window that never appears
// shows how far start-up got (#234).
log::info!("effectcraft {}: opening the window", env!("CARGO_PKG_VERSION"));
// Registered before the event loop starts, so it catches the Finder event that launched us as
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
#[cfg(target_os = "macos")]
let apple_events = apple_events::AppleEvents::install();
#[cfg(target_os = "macos")]
let apple_events = &apple_events;
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 +173,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() {
@ -81,44 +182,88 @@ fn main() -> eframe::Result {
session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir)));
}
session.load_settings();
// Fit the window to its monitor, or put it back where it was (still hidden).
let saved_window = session.config.as_deref().and_then(window::load);
let opened = cc.winit_window().map(|w| window::restore(w, saved_window));
let recovery = if control_port.is_none() { session.begin_recovery() } else { None };
let project = files.iter().find(|f| f.ends_with(".ecproj")).cloned();
let project = files.iter().find(|f| is_project_file(f)).cloned();
if let Some(p) = project {
if let Err(e) = session.execute("file.open", json!({"path": p})) {
eprintln!("effectcraft: {e}");
log::warn!("{p}: {e}");
}
} else if demo {
let _ = session.execute("file.openDemoProject", json!({}));
}
let media: Vec<String> = files.iter().filter(|f| !f.ends_with(".ecproj")).cloned().collect();
let media: Vec<String> = files.iter().filter(|f| !is_project_file(f)).cloned().collect();
if !media.is_empty()
&& let Err(e) = session.execute("file.import", json!({"paths": media}))
{
eprintln!("effectcraft: {e}");
log::warn!("import: {e}");
}
let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none());
// The ScriptUI panels open when the app last quit open again (a failing one is
// reported in a toast and forgotten).
let _ = session.execute("window.restoreScriptPanels", json!({}));
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);
}
#[cfg(target_os = "linux")]
if let Some(note) = wayland_drop_notice(std::env::var_os("WAYLAND_DISPLAY").as_deref(), std::env::var_os("WINIT_UNIX_BACKEND").as_deref())
&& app.ui.status.is_empty()
{
app.ui.status = note.into();
}
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
rfd::FileDialog::new().add_filter("Media", exts).pick_files().unwrap_or_default().into_iter().map(|p| p.to_string_lossy().to_string()).collect()
}));
app.hooks.pick_save = Some(Box::new(|name: &str| {
rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj"]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
}));
// The window closes after a lost graphics device (#591); say why, unless an agent drives
// the app through the control channel.
if control_port.is_none() {
app.hooks.device_lost = Some(Box::new(|saved: Option<Result<String, String>>| {
let saved = match saved {
Some(Ok(path)) => format!("\n\nYour unsaved changes were saved to:\n{path}\n\nReopen EffectCraft to recover them."),
Some(Err(e)) => format!("\n\nYour unsaved changes could not be saved ({e})."),
None => String::new(),
};
rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title("EffectCraft: graphics device lost")
.set_description(format!("The graphics driver stopped responding, so EffectCraft has to close.{saved}"))
.show();
}));
}
app.hooks.pick_open_project = Some(Box::new(|| {
rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file().map(|p| p.to_string_lossy().to_string())
}));
app.hooks.system_theme = appearance::service();
app.hooks.audio_device = Some(Box::new(audio_out::open));
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")]
{
// The commands' file dialogs open as sheets instead of nested modal loops (#511).
let ctx = cc.egui_ctx.clone();
app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone())));
app.hooks.app_action = Some(Box::new(native_menu::app_action));
app.hooks.clipboard_text = Some(Box::new(native_menu::clipboard_text));
}
@ -127,13 +272,77 @@ 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),
#[cfg(target_os = "macos")]
apple_events: apple_events.connect(&cc.egui_ctx),
app,
window: window::Tracker::new(opened, saved_window, control_port.is_none()),
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."
);
// Standard error and the session log; standard error alone when RUST_LOG turned errors off.
if log::log_enabled!(log::Level::Error) {
log::error!("{message}");
} else {
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 +350,14 @@ struct Desktop {
app: EffectcraftApp,
#[cfg(target_os = "macos")]
menu: native_menu::NativeBar,
/// Documents opened from Finder, the Dock or `open` (macOS).
#[cfg(target_os = "macos")]
apple_events: apple_events::Opened,
/// The window's size and position, saved for the next launch.
window: window::Tracker,
launch: launch_guard::Launch,
/// The first frame was drawn (logged once).
drawn: bool,
}
impl eframe::App for Desktop {
@ -152,35 +369,31 @@ impl eframe::App for Desktop {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.app.ui(ui, frame);
self.window.frame(ui.ctx(), self.app.session.config.as_deref());
if !self.drawn {
self.drawn = true;
log::info!("first frame drawn");
}
// The launch guard's marker goes once the window has kept drawing for a few seconds: a
// driver may still crash as the window is shown and its surface re-created.
if let Some(left) = self.launch.frame_drawn() {
ui.ctx().request_repaint_after(left);
}
}
fn on_exit(&mut self) {
self.launch.exited();
self.window.save(self.app.session.config.as_deref());
self.app.on_exit();
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
#[cfg(target_os = "macos")]
self.apple_events.feed(self.app.is_ready(), ctx, raw_input);
self.app.raw_input_hook(ctx, raw_input);
}
}
/// 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 +421,62 @@ 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)
}
#[cfg(target_os = "linux")]
#[test]
fn wayland_drop_notice_recommends_import_or_xwayland() {
use std::ffi::OsStr;
let warning = super::wayland_drop_notice(Some(OsStr::new("wayland-0")), None).unwrap();
assert!(warning.contains("File ▸ Import") && warning.contains("XWayland"));
assert!(super::wayland_drop_notice(None, None).is_none());
assert!(super::wayland_drop_notice(Some(OsStr::new("")), None).is_none());
assert!(super::wayland_drop_notice(Some(OsStr::new("wayland-0")), Some(OsStr::new("x11"))).is_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);
}
/// A positional .ecprojx is a project file, not an unsupported media import (#528).
#[test]
fn launch_classifies_xml_and_json_projects_as_projects() {
for path in ["Copy.ecprojx", "/tmp/Scene.ecproj", "C:\\\\work\\\\Shot.ECPROJX"] {
assert!(super::is_project_file(path), "{path}");
}
for path in ["scene.ecprojx.png", "scene.mp4", "scene", "copy.ecprojx.bak"] {
assert!(!super::is_project_file(path), "{path}");
}
let files = ["scene.ecprojx".to_string(), "clip.mov".to_string()];
assert_eq!(files.iter().find(|f| super::is_project_file(f)).map(String::as_str), Some("scene.ecprojx"));
assert_eq!(files.iter().filter(|f| !super::is_project_file(f)).map(String::as_str).collect::<Vec<_>>(), vec!["clip.mov"],);
}
/// 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,76 @@
//! File dialogs that don't block the frame (macOS), for `Hooks::file_dialog_async`.
//!
//! A blocking `rfd::FileDialog` runs a nested modal event loop inside eframe's frame, that is
//! inside winit's event handler. On macOS a file dragged over the window while that loop ran
//! re-entered winit's handler, which panics across the Objective-C boundary and aborts the app
//! (#511). `rfd::AsyncFileDialog` shows the dialog as a sheet on the window and returns at once;
//! the answer arrives when the sheet closes, outside the frame, and the app runs the command then.
use effectcraft_ui_egui::{DialogDone, FileDialog};
use rfd::{AsyncFileDialog, FileHandle};
use std::future::Future;
use std::pin::Pin;
type Answer = Pin<Box<dyn Future<Output = Vec<String>> + Send>>;
/// Show `dialog` (on the main thread: the sheet opens here) and hand its paths to `done` once it
/// closes, then wake the UI.
pub fn show(dialog: FileDialog, done: DialogDone, ctx: egui::Context) {
let answer = open(dialog);
let spawned = std::thread::Builder::new().name("ec-file-dialog".into()).spawn(move || {
done(pollster::block_on(answer));
ctx.request_repaint();
});
if let Err(e) = spawned {
log::error!("file dialog: {e}");
}
}
fn paths(files: Option<Vec<FileHandle>>) -> Vec<String> {
files.unwrap_or_default().into_iter().map(|f| f.path().to_string_lossy().to_string()).collect()
}
fn path(file: Option<FileHandle>) -> Vec<String> {
paths(file.map(|f| vec![f]))
}
/// The dialog, as the blocking hooks in `main.rs` show it.
fn open(dialog: FileDialog) -> Answer {
match dialog {
FileDialog::Open { exts, many } => {
let d = AsyncFileDialog::new().add_filter("Media", &exts);
if many {
let f = d.pick_files();
Box::pin(async move { paths(f.await) })
} else {
let f = d.pick_file();
Box::pin(async move { path(f.await) })
}
}
FileDialog::OpenProject => {
let f = AsyncFileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file();
Box::pin(async move { path(f.await) })
}
FileDialog::Folder => {
let f = AsyncFileDialog::new().pick_folder();
Box::pin(async move { path(f.await) })
}
FileDialog::Save { default, ext, project } => {
let at = std::path::Path::new(&default);
let name = at.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let mut d = if project {
AsyncFileDialog::new().add_filter("EffectCraft Project", &["ecproj"])
} else if ext.is_empty() {
AsyncFileDialog::new()
} else {
AsyncFileDialog::new().add_filter(&ext, &[ext.as_str()])
};
// A default with a folder that exists opens the dialog there.
if let Some(dir) = at.parent().filter(|d| d.is_dir()) {
d = d.set_directory(dir);
}
let f = d.set_file_name(name).save_file();
Box::pin(async move { path(f.await) })
}
}
}

View File

@ -0,0 +1,371 @@
//! The main window's size and position. The first launch fits the window to its monitor, opening
//! it maximized where the default size leaves no room for a taskbar or Dock. Later launches restore
//! the size, position and maximized state saved in the config directory (`window.json`), moved
//! onto a monitor that is still connected.
use effectcraft_engine::config::ConfigStore;
use serde::{Deserialize, Serialize};
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize};
const FILE: &str = "window.json";
const MAX_BYTES: usize = 4096;
/// Seconds a window opened maximized gets to say so before its size is tracked.
const MAXIMIZE_WAIT: f64 = 2.0;
/// The window's geometry between launches.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct WindowGeometry {
/// The outer top-left corner in physical pixels; `None` where the system doesn't say (Wayland).
pub pos: Option<[i32; 2]>,
/// The inner size in points; while maximized, the size to return to.
pub size: [f32; 2],
pub maximized: bool,
}
/// The first window's size, in points.
pub const DEFAULT_SIZE: [f32; 2] = [1680.0, 1020.0];
/// The smallest window, in points.
pub const MIN_SIZE: [f32; 2] = [960.0, 600.0];
/// Room the first window leaves around itself for a taskbar, Dock or menu bar (the system doesn't
/// say how much of the screen they take): a monitor with less room opens it maximized.
const SCREEN_ROOM: [f32; 2] = [64.0, 128.0];
/// A monitor: its top-left corner and size in physical pixels, and its scale factor.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Monitor {
pos: [i64; 2],
size: [i64; 2],
scale: f32,
}
impl Monitor {
pub fn new(pos: PhysicalPosition<i32>, size: PhysicalSize<u32>, scale: f64) -> Self {
let scale = scale as f32;
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
Self { pos: [pos.x.into(), pos.y.into()], size: [size.width.into(), size.height.into()], scale }
}
/// Its size in points.
fn points(&self) -> [f32; 2] {
self.size.map(|s| s as f32 / self.scale)
}
/// Its size in points less room for a taskbar, Dock or menu bar.
fn usable(&self) -> [f32; 2] {
let points = self.points();
std::array::from_fn(|i| points[i] - SCREEN_ROOM[i])
}
/// A window of `size` points on this monitor, in physical pixels.
fn pixels(&self, size: [f32; 2]) -> [i64; 2] {
size.map(|s| (s * self.scale) as i64)
}
/// How much of a window at `pos` of `size` points this monitor shows, in square pixels.
fn overlap(&self, pos: [i32; 2], size: [f32; 2]) -> i64 {
let px = self.pixels(size);
let span = |i: usize| {
let start = i64::from(pos[i]).max(self.pos[i]);
let end = i64::from(pos[i]).saturating_add(px[i]).min(self.pos[i] + self.size[i]);
(end - start).max(0)
};
span(0).saturating_mul(span(1))
}
/// The top-left corner nearest to `pos` (centred when none) that keeps a window of `size`
/// points on this monitor.
fn keep_on(&self, pos: Option<[i32; 2]>, size: [f32; 2]) -> [i32; 2] {
let px = self.pixels(size);
std::array::from_fn(|i| {
let lo = self.pos[i];
let hi = lo + self.size[i].saturating_sub(px[i]).max(0);
let want = pos.map_or(lo + (hi - lo) / 2, |p| i64::from(p[i]));
i32::try_from(want.max(lo).min(hi)).unwrap_or_default()
})
}
}
/// Where the window opens: `saved` (the last session's geometry) kept on a connected monitor, else
/// the default size on `home` (the monitor the system opens it on). Without monitors (the system
/// doesn't list them, so nothing to check a saved geometry against) the default size.
pub fn place(saved: Option<WindowGeometry>, monitors: &[Monitor], home: Option<usize>) -> WindowGeometry {
let default = WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false };
let Some(home) = home.and_then(|i| monitors.get(i)).or(monitors.first()) else {
return default;
};
let Some(saved) = saved else {
let usable = home.usable();
if (0..2).all(|i| DEFAULT_SIZE[i] <= usable[i]) {
return default;
}
// A small screen: maximized, with a size that fits once un-maximized.
let size = fit(DEFAULT_SIZE, usable);
return WindowGeometry { pos: Some(home.keep_on(None, size)), size, maximized: true };
};
// The monitor showing most of the window; none when it was on a monitor that is gone.
let shown = saved.pos.and_then(|p| {
let (area, m) = monitors.iter().map(|m| (m.overlap(p, saved.size), m)).max_by_key(|(area, _)| *area)?;
(area > 0).then_some(m)
});
let m = shown.unwrap_or(home);
// Bigger than its monitor (a smaller screen than last time): maximized, like the first window.
let room = m.points();
let too_big = (0..2).any(|i| saved.size[i] > room[i]);
let size = fit(saved.size, if too_big { m.usable() } else { room });
WindowGeometry {
// Moved fully onto its monitor, or centred on the home monitor.
pos: saved.pos.map(|p| m.keep_on(shown.map(|_| p), size)),
size,
maximized: saved.maximized || too_big,
}
}
/// `size` no bigger than `room`, and no smaller than the smallest window unless `room` is.
fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] {
std::array::from_fn(|i| size[i].min(room[i]).max(MIN_SIZE[i].min(room[i])).max(1.0))
}
/// Size and place the new window (eframe shows it after drawing the first frame) and return its
/// geometry.
pub fn restore(window: &winit::window::Window, saved: Option<WindowGeometry>) -> WindowGeometry {
let handles: Vec<_> = window.available_monitors().collect();
let monitors: Vec<Monitor> = handles.iter().map(|m| Monitor::new(m.position(), m.size(), m.scale_factor())).collect();
let home = window.current_monitor().or_else(|| window.primary_monitor()).and_then(|c| handles.iter().position(|m| *m == c));
let g = place(saved, &monitors, home);
if let Some([x, y]) = g.pos {
window.set_outer_position(PhysicalPosition::new(x, y));
}
// Returns the new size when it applies at once; either way the window gets it.
let _ = window.request_inner_size(LogicalSize::new(g.size[0], g.size[1]));
if g.maximized {
window.set_maximized(true);
}
g
}
/// Per frame: remember the window's geometry for the next launch. Minimized or full screen keeps
/// the last one; maximized only sets its flag, so un-maximizing next time restores the last size.
pub fn track(ctx: &egui::Context, geometry: &mut Option<WindowGeometry>) {
let zoom = ctx.zoom_factor();
ctx.input(|i| {
let v = i.viewport();
if v.minimized == Some(true) || v.fullscreen == Some(true) {
return;
}
if v.maximized == Some(true) {
if let Some(g) = geometry {
g.maximized = true;
}
return;
}
let Some(inner) = v.inner_rect else { return };
let pos = v.outer_rect.map(|r| [r.min.x, r.min.y].map(|c| (c * i.pixels_per_point).round() as i32));
*geometry = Some(WindowGeometry { pos, size: [inner.width() * zoom, inner.height() * zoom], maximized: false });
});
}
/// The geometry saved by the last session; `None` when there is none or it can't be read.
pub fn load(config: &dyn ConfigStore) -> Option<WindowGeometry> {
let text = config.read_bounded(FILE, MAX_BYTES).ok()??;
serde_json::from_str(&text).ok()
}
/// Follows the window and saves its geometry for the next launch: when it has changed (at most
/// once a second, so a session that ends badly still has it) and on exit.
pub struct Tracker {
geometry: Option<WindowGeometry>,
saved: Option<WindowGeometry>,
last_attempt: f64,
/// Opened maximized, and the system hasn't reported it so yet (when the wait ends): the
/// window's first frames report the maximized size as an ordinary one, which would replace
/// the size to return to.
maximizing: Option<f64>,
/// Agent-driven runs (`--control`) leave the user's saved window alone.
save: bool,
}
impl Tracker {
/// `geometry`: what the window opened with.
pub fn new(geometry: Option<WindowGeometry>, saved: Option<WindowGeometry>, save: bool) -> Self {
let maximizing = geometry.is_some_and(|g| g.maximized).then_some(f64::INFINITY);
Self { geometry, saved, last_attempt: f64::NEG_INFINITY, maximizing, save }
}
/// Per frame.
pub fn frame(&mut self, ctx: &egui::Context, config: Option<&dyn ConfigStore>) {
let (now, maximized) = ctx.input(|i| (i.time, i.viewport().maximized == Some(true)));
if let Some(until) = self.maximizing {
// A system that never maximizes the window is believed after a moment.
let until = until.min(now + MAXIMIZE_WAIT);
self.maximizing = (!maximized && now < until).then_some(until);
}
if self.maximizing.is_none() {
track(ctx, &mut self.geometry);
}
if now - self.last_attempt >= 1.0 {
self.last_attempt = now;
self.save(config);
}
}
/// Write the geometry when it differs from the saved one.
pub fn save(&mut self, config: Option<&dyn ConfigStore>) {
let (Some(config), Some(g)) = (config, self.geometry) else { return };
if !self.save || self.saved == Some(g) {
return;
}
match serde_json::to_string(&g).map_err(|e| e.to_string()).and_then(|text| config.write(FILE, &text).map_err(|e| e.to_string())) {
Ok(()) => self.saved = Some(g),
Err(e) => log::warn!("could not save the window geometry: {e}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use effectcraft_engine::config::MemoryConfig;
fn monitor(x: i32, y: i32, w: u32, h: u32, scale: f64) -> Monitor {
Monitor::new(PhysicalPosition::new(x, y), PhysicalSize::new(w, h), scale)
}
fn saved(pos: Option<[i32; 2]>, size: [f32; 2], maximized: bool) -> Option<WindowGeometry> {
Some(WindowGeometry { pos, size, maximized })
}
#[test]
fn the_first_window_fits_its_screen() {
// 2560 × 1440: the default size, where the system puts it.
let big = [monitor(0, 0, 2560, 1440, 1.0)];
assert_eq!(place(None, &big, Some(0)), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
// 1920 × 1080, 1600 × 900, 1366 × 768 and 1920 × 1080 at 150% (1280 × 720 points):
// maximized, and a size that fits the screen with room for the taskbar once un-maximized.
for (m, size) in [
(monitor(0, 0, 1920, 1080, 1.0), [1680.0, 952.0]),
(monitor(0, 0, 1600, 900, 1.0), [1536.0, 772.0]),
(monitor(0, 0, 1366, 768, 1.0), [1302.0, 640.0]),
(monitor(0, 0, 1920, 1080, 1.5), [1216.0, 592.0]),
] {
let g = place(None, &[m], Some(0));
assert!(g.maximized, "{m:?}");
assert_eq!(g.size, size, "{m:?}");
let [x, y] = g.pos.unwrap();
let px = m.pixels(g.size);
assert!(x >= 0 && y >= 0 && i64::from(x) + px[0] <= m.size[0] && i64::from(y) + px[1] <= m.size[1], "{m:?}: {g:?}");
}
// No monitor information: the default size, whatever was saved.
assert_eq!(place(None, &[], None), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
assert_eq!(place(saved(Some([9000, 9000]), [1e9, 1e9], true), &[], None).size, DEFAULT_SIZE);
}
#[test]
fn the_saved_window_comes_back_where_it_was() {
let two = [monitor(0, 0, 2560, 1440, 1.0), monitor(2560, 0, 1920, 1080, 1.0)];
for g in [saved(Some([100, 80]), [1200.0, 800.0], false), saved(Some([2700, 50]), [1000.0, 700.0], true), saved(None, [1000.0, 700.0], false)] {
assert_eq!(Some(place(g, &two, Some(0))), g);
}
}
#[test]
fn a_saved_window_stays_on_a_connected_monitor() {
let laptop = [monitor(0, 0, 1600, 900, 1.0)];
// Last time on a second monitor that is gone: centred on the remaining one.
let g = place(saved(Some([2700, 50]), [1000.0, 700.0], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([300, 100]), size: [1000.0, 700.0], maximized: false });
// Half off the monitor's right and bottom edges: moved fully onto it.
let g = place(saved(Some([1200, 600]), [1000.0, 700.0], false), &laptop, Some(0));
assert_eq!(g.pos, Some([600, 200]));
// Bigger than the screen now (it was on a larger one): it fits, and opens maximized.
let g = place(saved(Some([0, 0]), [2400.0, 1300.0], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([0, 0]), size: [1536.0, 772.0], maximized: true });
// A monitor left of the primary one (negative coordinates) still counts.
let left = [monitor(0, 0, 1920, 1080, 1.0), monitor(-1920, 0, 1920, 1080, 1.0)];
assert_eq!(place(saved(Some([-1800, 40]), [1000.0, 700.0], false), &left, Some(0)).pos, Some([-1800, 40]));
// Nonsense sizes from a damaged preferences file are kept within the screen.
let g = place(saved(Some([i32::MAX, i32::MIN]), [-5.0, f32::MAX], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([320, 64]), size: [960.0, 772.0], maximized: true });
let g = place(saved(Some([5, 5]), [f32::NEG_INFINITY, f32::INFINITY], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([320, 64]), size: [960.0, 772.0], maximized: true });
}
#[test]
fn the_window_geometry_is_tracked_for_the_next_launch() {
let ctx = egui::Context::default();
let mut geometry = None;
let mut frame = |info: egui::ViewportInfo| {
let mut input = egui::RawInput::default();
input.viewports.insert(egui::ViewportId::ROOT, info);
ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry)).textures_delta.clear();
geometry
};
let rect = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(1000.0, 600.0));
let normal = egui::ViewportInfo {
native_pixels_per_point: Some(1.5),
inner_rect: Some(rect),
outer_rect: Some(rect),
maximized: Some(false),
..Default::default()
};
// Position in physical pixels, size in points.
let g = WindowGeometry { pos: Some([30, 45]), size: [1000.0, 600.0], maximized: false };
assert_eq!(frame(normal.clone()), Some(g));
// Maximized or minimized: the size to restore stays.
let big = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 700.0));
let maximized = egui::ViewportInfo { inner_rect: Some(big), outer_rect: Some(big), maximized: Some(true), ..normal.clone() };
assert_eq!(frame(maximized), Some(WindowGeometry { maximized: true, ..g }));
let minimized = egui::ViewportInfo { inner_rect: None, outer_rect: None, minimized: Some(true), ..normal.clone() };
assert_eq!(frame(minimized), Some(WindowGeometry { maximized: true, ..g }));
assert_eq!(frame(normal), Some(g));
}
/// A window restored maximized keeps the size it returns to, though its first frames report
/// the maximized size as an ordinary one.
#[test]
fn opening_maximized_keeps_the_size_to_return_to() {
let ctx = egui::Context::default();
let g = WindowGeometry { pos: Some([40, 30]), size: [1000.0, 600.0], maximized: true };
let mut tracker = Tracker::new(Some(g), Some(g), true);
let big = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1728.0, 1052.0));
let small = egui::Rect::from_min_size(egui::pos2(40.0, 30.0), egui::vec2(900.0, 500.0));
let frame = |tracker: &mut Tracker, time: f64, rect: egui::Rect, maximized: bool| {
let info = egui::ViewportInfo { inner_rect: Some(rect), outer_rect: Some(rect), maximized: Some(maximized), ..Default::default() };
let mut input = egui::RawInput { time: Some(time), ..Default::default() };
input.viewports.insert(egui::ViewportId::ROOT, info);
ctx.run_ui(input, |ui| tracker.frame(ui.ctx(), None)).textures_delta.clear();
tracker.geometry
};
assert_eq!(frame(&mut tracker, 0.0, big, false), Some(g));
assert_eq!(frame(&mut tracker, 0.5, big, true), Some(g));
// Un-maximized by the user afterwards: tracked as usual.
let restored = WindowGeometry { pos: Some([40, 30]), size: [900.0, 500.0], maximized: false };
assert_eq!(frame(&mut tracker, 0.6, small, false), Some(restored));
// A system that doesn't maximize: the window's real size is tracked after the wait.
let mut tracker = Tracker::new(Some(g), Some(g), true);
assert_eq!(frame(&mut tracker, 10.0, small, false), Some(g));
assert_eq!(frame(&mut tracker, 10.0 + MAXIMIZE_WAIT, small, false), Some(restored));
}
/// The reported bug: the window opened at the default size and place on every launch.
#[test]
fn the_window_geometry_is_saved_and_loaded() {
let config = MemoryConfig::default();
assert_eq!(load(&config), None);
let g = WindowGeometry { pos: Some([30, 45]), size: [1000.0, 600.0], maximized: true };
let mut tracker = Tracker::new(Some(g), None, true);
tracker.save(Some(&config));
assert_eq!(load(&config), Some(g));
// Positions the system doesn't report (Wayland) round-trip too.
tracker.geometry = Some(WindowGeometry { pos: None, ..g });
tracker.save(Some(&config));
assert_eq!(load(&config), Some(WindowGeometry { pos: None, ..g }));
// An agent-driven run leaves the saved window alone.
Tracker::new(Some(g), None, false).save(Some(&config));
assert_eq!(load(&config).and_then(|g| g.pos), None);
// A damaged or oversized file is ignored: the window opens at its default.
for text in ["{", "null", "{\"pos\":[1],\"size\":[1,2],\"maximized\":false}", &" ".repeat(MAX_BYTES + 1)] {
config.write(FILE, text).unwrap();
assert_eq!(load(&config), None, "{text:.20}");
}
}
}

View File

@ -0,0 +1,409 @@
{
"schema": 1,
"repo": "storytold/effectcraft",
"branch": "main",
"head": "30aaddf7c23329dcdfc72a5bf65b55747bbd6be1",
"head_date": "2026-10-09T17:19:03Z",
"commits": 656,
"unattributed_commits": 0,
"contributors": [
{
"login": "echelon",
"people_entry": true,
"github_name": null,
"prs": 35,
"commits": 366,
"lines_added": 327779,
"lines_deleted": 14400,
"binary_added": 96,
"binary_deleted": 0,
"first_commit": "2026-10-01T16:48:39Z",
"last_commit": "2026-10-09T15:37:20Z"
},
{
"login": "storybored379",
"people_entry": false,
"github_name": null,
"prs": 7,
"commits": 8,
"lines_added": 1041,
"lines_deleted": 37,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-03T16:31:41Z",
"last_commit": "2026-10-09T05:22:16Z"
},
{
"login": "bflatastic",
"people_entry": false,
"github_name": null,
"prs": 106,
"commits": 214,
"lines_added": 34610,
"lines_deleted": 6196,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-03T17:19:22Z",
"last_commit": "2026-10-09T17:19:03Z"
},
{
"login": "jonassneu",
"people_entry": true,
"github_name": "Jonas Neubauer",
"prs": 5,
"commits": 6,
"lines_added": 1391,
"lines_deleted": 45,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-05T16:01:41Z",
"last_commit": "2026-10-09T04:22:05Z"
},
{
"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": 3,
"commits": 3,
"lines_added": 1030,
"lines_deleted": 20,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T07:22:23Z",
"last_commit": "2026-10-08T14:36:26Z"
},
{
"login": "EthanSuquet",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 45,
"lines_deleted": 549,
"binary_added": 0,
"binary_deleted": 67,
"first_commit": "2026-10-06T19:02:20Z",
"last_commit": "2026-10-07T14:06:42Z"
},
{
"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"
},
{
"login": "ulanch",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 4,
"lines_deleted": 3,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T04:38:19Z",
"last_commit": "2026-10-08T04:38:19Z"
},
{
"login": "switch87",
"people_entry": true,
"github_name": null,
"prs": 1,
"commits": 2,
"lines_added": 15,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T09:25:55Z",
"last_commit": "2026-10-08T09:27:34Z"
},
{
"login": "pat-the-wizard",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 3,
"lines_added": 64,
"lines_deleted": 10,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T10:14:48Z",
"last_commit": "2026-10-08T11:58:55Z"
},
{
"login": "h-a-n-a-s-h-i",
"people_entry": false,
"github_name": null,
"prs": 0,
"commits": 2,
"lines_added": 74,
"lines_deleted": 74,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T12:13:07Z",
"last_commit": "2026-10-08T14:02:02Z"
},
{
"login": "prekabreki",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 53,
"lines_deleted": 8,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T13:03:19Z",
"last_commit": "2026-10-08T13:03:19Z"
},
{
"login": "XusBadia",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 185,
"lines_deleted": 1,
"binary_added": 1,
"binary_deleted": 0,
"first_commit": "2026-10-08T13:12:32Z",
"last_commit": "2026-10-08T13:12:32Z"
},
{
"login": "FeiLiuEM",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 10,
"lines_added": 3493,
"lines_deleted": 659,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T16:42:28Z",
"last_commit": "2026-10-09T12:34:03Z"
},
{
"login": "DWAA1660",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 127,
"lines_deleted": 2,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T16:47:38Z",
"last_commit": "2026-10-08T16:47:38Z"
},
{
"login": "Oldrich333",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 59,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T20:32:51Z",
"last_commit": "2026-10-08T20:32:51Z"
},
{
"login": "MindlessRuff",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 114,
"lines_deleted": 3,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T03:22:49Z",
"last_commit": "2026-10-09T03:22:49Z"
},
{
"login": "HEOJUNFO",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 83,
"lines_deleted": 11,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T03:34:00Z",
"last_commit": "2026-10-09T06:33:04Z"
},
{
"login": "thanhcong2k7",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 3,
"lines_added": 138,
"lines_deleted": 31,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T04:52:22Z",
"last_commit": "2026-10-09T05:58:31Z"
},
{
"login": "cjcwwww",
"people_entry": true,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 81,
"lines_deleted": 78,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T05:52:09Z",
"last_commit": "2026-10-09T05:52:09Z"
},
{
"login": "KazW",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 76,
"lines_deleted": 18,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T10:38:07Z",
"last_commit": "2026-10-09T10:38:07Z"
},
{
"login": "dmatviichuk",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 2,
"lines_added": 1528,
"lines_deleted": 18,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T12:46:20Z",
"last_commit": "2026-10-09T12:46:20Z"
},
{
"login": "cdinhoo",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 215,
"lines_deleted": 11,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T12:56:05Z",
"last_commit": "2026-10-09T12:56:05Z"
},
{
"login": "sanyamsingh001",
"people_entry": true,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 7,
"lines_deleted": 1,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T13:03:05Z",
"last_commit": "2026-10-09T13:03:05Z"
}
],
"models": [
{
"id": "anthropic/claude-opus/5.5",
"company": "Anthropic",
"model": "Claude Opus",
"version": "5.5",
"commits": 603,
"lines_added": 362122,
"lines_deleted": 21289
},
{
"id": "anthropic/claude",
"company": "Anthropic",
"model": "Claude",
"version": "",
"commits": 16,
"lines_added": 1577,
"lines_deleted": 146
},
{
"id": "openai/codex",
"company": "OpenAI",
"model": "Codex",
"version": "",
"commits": 5,
"lines_added": 1046,
"lines_deleted": 40
},
{
"id": "anthropic/claude-opus/4.8",
"company": "Anthropic",
"model": "Claude Opus",
"version": "4.8",
"commits": 1,
"lines_added": 1632,
"lines_deleted": 3
},
{
"id": "anthropic/claude-sonnet/5.5",
"company": "Anthropic",
"model": "Claude Sonnet",
"version": "5.5",
"commits": 1,
"lines_added": 59,
"lines_deleted": 0
}
]
}

66
contributors/people.toml Normal file
View File

@ -0,0 +1,66 @@
# 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
[people.cjcwwww]
display_name = "ShirokoWaWu"
[people.sanyamsingh001]
display_name = "sanyamsingh001"
sync_github_name = false
[people.Aavirash]
display_name = "Asquareportal"
[people.tylerjenningsw]
real_name = "Tyler Jennings"
display_name = "tylerjenningsw"
sync_github_name = false
[people.llopjulen]
real_name = "Julen Llop"
display_name = "Julen Llop"
sync_github_name = false
[people.vbasky]
real_name = "Vikram Bhaskaran"
display_name = "Vikram Bhaskaran"
sync_github_name = false
[people.vangel76]
real_name = "Valentin Klein"
display_name = "Valentin Klein"
[people.Losminek]
sync_github_name = true
[people.webdevtodayjason]
display_name = "Jason Brashear"

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

@ -4,9 +4,16 @@ use effectcraft_engine::Session;
use effectcraft_engine::time::Tick;
use serde_json::{Value, json};
use crate::confine::Confinement;
use crate::{BridgeClient, Error, Result, base64, encode_png, recompress_png};
pub enum Backend {
pub struct Backend {
target: Target,
/// File access confined to folders (`--automation-read-root` / `--automation-write-root`).
confinement: Option<Confinement>,
}
enum Target {
/// An in-process session (no window): engine commands, inspection and rendering.
Headless(Box<Session>),
/// A running desktop app reached over its control channel: everything, plus the live UI.
@ -24,43 +31,66 @@ pub struct Frame {
impl Backend {
pub fn headless(session: Session) -> Self {
Backend::Headless(Box::new(session))
Backend { target: Target::Headless(Box::new(session)), confinement: None }
}
/// Bridge to the app's control channel at `addr` (`9877` or `127.0.0.1:9877`).
pub fn bridge(addr: &str) -> Result<Self> {
Ok(Backend::Bridge(BridgeClient::new(addr)?))
Ok(Backend { target: Target::Bridge(BridgeClient::new(addr)?), confinement: None })
}
/// Confine the files the commands run here may read and write ([`Confinement`]).
pub fn confine(&mut self, confinement: Confinement) {
self.confinement = Some(confinement);
}
pub fn confinement(&self) -> Option<&Confinement> {
self.confinement.as_ref()
}
pub fn is_bridge(&self) -> bool {
matches!(self, Backend::Bridge(_))
matches!(self.target, Target::Bridge(_))
}
pub fn session(&mut self) -> Option<&mut Session> {
match self {
Backend::Headless(s) => Some(s),
Backend::Bridge(_) => None,
match &mut self.target {
Target::Headless(s) => Some(s),
Target::Bridge(_) => None,
}
}
/// Run an engine command by id.
/// Run an engine command by id (checked against the [`Confinement`], if any).
pub fn exec(&mut self, id: &str, params: Value) -> Result<Value> {
let params = if params.is_null() { json!({}) } else { params };
match self {
Backend::Headless(s) => {
if let Some(c) = self.confinement.clone() {
let lookup = match c.check_command(id, &params)? {
Some(crate::confine::Lookup::ProjectFile) => Some((crate::confine::Lookup::ProjectFile, self.run("project.summary", json!({}))?)),
Some(crate::confine::Lookup::RenderOutputs) => Some((crate::confine::Lookup::RenderOutputs, self.run("renderQueue.list", json!({}))?)),
None => None,
};
if let Some((lookup, found)) = lookup {
c.check_lookup(&lookup, &found)?;
}
}
self.run(id, params)
}
fn run(&mut self, id: &str, params: Value) -> Result<Value> {
match &mut self.target {
Target::Headless(s) => {
let r = s.execute_checked(id, params);
s.drain_events();
Ok(r?)
}
Backend::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params})),
Target::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params})),
}
}
/// Call a raw control-channel method (bridge only).
pub fn control(&mut self, method: &str, params: Value) -> Result<Value> {
match self {
Backend::Bridge(b) => b.call(method, if params.is_null() { json!({}) } else { params }),
Backend::Headless(_) => Err(Error::BadArgs(NEED_BRIDGE.into())),
match &mut self.target {
Target::Bridge(b) => b.call(method, if params.is_null() { json!({}) } else { params }),
Target::Headless(_) => Err(Error::BadArgs(NEED_BRIDGE.into())),
}
}
@ -73,15 +103,15 @@ impl Backend {
/// [`Backend::render`]; `transparent` keeps the frame's alpha instead of compositing it over
/// the comp's background colour.
pub fn render_with(&mut self, comp: Option<&Value>, time: Option<f64>, max_side: u32, transparent: bool) -> Result<Frame> {
match self {
Backend::Headless(s) => {
match &mut self.target {
Target::Headless(s) => {
let cid = s.resolve_comp(comp).map_err(|_| no_comp(comp))?;
let t = time.map(Tick::from_seconds_f64).unwrap_or_else(|| s.time());
let (w, h, rgba) = s.render_rgba8_alpha(cid, t, max_side, transparent)?;
let png = encode_png(w, h, rgba, max_side)?;
Ok(Frame { png, comp: json!(cid.0), time: t.seconds(), width: w, height: h })
}
Backend::Bridge(b) => {
Target::Bridge(b) => {
let mut p = json!({"max_side": max_side, "base64": true});
if transparent {
p["transparent"] = json!(true);

View File

@ -0,0 +1,276 @@
//! File access confinement for automation (`effectcraft-cli mcp --automation-read-root DIR
//! --automation-write-root DIR`, as in PhotoCraft): an agent may only read files under a read
//! root (or a write root) and only write under a write root.
//!
//! Every engine command an agent runs ([`crate::Backend::exec`], also each step of an
//! `engine.batch`) is checked before it runs: its file parameters must be absolute paths inside
//! the roots (symbolic links resolved, no `..`), any other absolute path in its parameters too,
//! and commands that write to a file they are not given (Save, Increment and Save, rendering the
//! Render Queue) are checked against where they would write. Commands that run code or open
//! other applications (scripts, Execute File, Reveal in Finder…) are refused while confined.
use std::path::{Component, Path, PathBuf};
use serde_json::Value;
use crate::{Error, Result};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Access {
Read,
Write,
}
/// The folders an agent may read and write.
#[derive(Clone, Debug)]
pub struct Confinement {
read: Vec<PathBuf>,
write: Vec<PathBuf>,
}
/// Commands that run code, act outside the project or open other applications.
const REFUSED: &[&str] = &[
"script.run",
"file.runScript",
"file.installScript",
"file.installScriptUIPanel",
"file.uninstallScript",
"file.executeFile",
"file.revealInFinder",
"layer.openSource",
"help.revealLogFile",
"file.autoSave",
];
/// Commands that read files named by these parameters.
const READS: &[(&str, &[&str])] = &[
("file.open", &["path"]),
("file.openRecent", &["path"]),
("file.import", &["path", "paths"]),
("file.importMultiple", &["paths"]),
("file.importRecent", &["path"]),
("file.importLottie", &["path"]),
("file.importTimeline", &["path"]),
("file.replaceFootage", &["path"]),
("file.relinkFootage", &["folder"]),
("file.setProxy", &["path"]),
("file.watchFolder", &["folder"]),
("anim.applyPreset", &["path"]),
("anim.applyRecentPreset", &["path"]),
("keys.rpfCameraImport", &["path"]),
("essential.importTemplate", &["path"]),
("view.importGuides", &["path"]),
("shortcuts.import", &["path"]),
("effect.plugins.load", &["path", "folder"]),
("mediaBrowser.go", &["path"]),
("mediaBrowser.addFavorite", &["path"]),
];
/// Commands that write files named by these parameters.
const WRITES: &[(&str, &[&str])] = &[
("file.save", &["path"]),
("file.saveAs", &["path"]),
("file.saveCopy", &["path"]),
("file.saveCopyAsXml", &["path"]),
("file.collectFiles", &["folder"]),
("file.exportLottie", &["path"]),
("comp.saveFrameAs", &["path"]),
("comp.saveFrameAsPsd", &["path"]),
("comp.saveFrameAsExr", &["path"]),
("anim.savePreset", &["path"]),
("view.exportGuides", &["path"]),
("render.saveCurrentPreview", &["path"]),
("render.preRender", &["output"]),
("essential.exportTemplate", &["path"]),
("shortcuts.export", &["path"]),
("renderQueue.add", &["output", "path"]),
];
/// Commands that write where they are not told: the project's file, the Render Queue's outputs.
const IMPLICIT_WRITES: &[&str] = &["file.save", "file.incrementAndSave", "renderQueue.render", "render.preRender"];
/// What must be looked up before an implicitly writing command may run.
#[derive(Debug, PartialEq, Eq)]
pub enum Lookup {
/// The open project's file (`project.summary` → `path`).
ProjectFile,
/// Every output of the queued Render Queue items (`renderQueue.list`).
RenderOutputs,
}
impl Confinement {
/// Roots that must exist (each resolved to its real path). With no write root nothing may be
/// written; write roots may also be read.
pub fn new(read: &[PathBuf], write: &[PathBuf]) -> Result<Self> {
let real = |p: &PathBuf| p.canonicalize().map_err(|e| Error::BadArgs(format!("automation root {}: {e}", p.display())));
let read = read.iter().map(real).collect::<Result<Vec<_>>>()?;
let write = write.iter().map(real).collect::<Result<Vec<_>>>()?;
Ok(Self { read, write })
}
fn roots(&self, access: Access) -> impl Iterator<Item = &PathBuf> {
let read = if access == Access::Read { &self.read[..] } else { &[] };
read.iter().chain(&self.write)
}
/// `path` resolved, if it is inside the roots for `access`.
pub fn check(&self, path: &str, access: Access) -> Result<PathBuf> {
let denied = |why: &str| {
let kind = if access == Access::Read { "read" } else { "write" };
let roots: Vec<String> = self.roots(access).map(|r| r.display().to_string()).collect();
let roots = if roots.is_empty() { "none".to_string() } else { roots.join(", ") };
Error::BadArgs(format!("cannot {kind} `{path}`: {why} (automation {kind} roots: {roots})"))
};
let p = Path::new(path);
if !p.is_absolute() {
return Err(denied("give an absolute path"));
}
if p.components().any(|c| matches!(c, Component::ParentDir)) {
return Err(denied("`..` is not allowed"));
}
let real = resolve(p).ok_or_else(|| denied("it can't be resolved"))?;
if self.roots(access).any(|r| real.starts_with(r)) { Ok(real) } else { Err(denied("it is outside the automation roots")) }
}
/// Check engine command `id` with `params` before it runs. Returns what must still be looked
/// up and passed to [`Confinement::check_lookup`] for commands that write implicitly.
pub fn check_command(&self, id: &str, params: &Value) -> Result<Option<Lookup>> {
if REFUSED.contains(&id) {
return Err(Error::BadArgs(format!("`{id}` is not available while file access is confined (--automation-read-root/--automation-write-root)")));
}
if id == "engine.batch" {
for step in params.get("steps").and_then(Value::as_array).into_iter().flatten() {
let Some(inner) = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str) else { continue };
let inner_params = step.get("params").cloned().unwrap_or(Value::Null);
let named = keys_of(READS, inner).or_else(|| keys_of(WRITES, inner)).unwrap_or(&[]);
// A step's `$N` reference is resolved only as the batch runs: never for a file.
if named.iter().filter_map(|k| inner_params.get(*k)).any(has_reference) {
return Err(Error::BadArgs(format!("`{inner}` in a batch: file paths can't be `$N` references while file access is confined")));
}
if self.check_command(inner, &inner_params)?.is_some() {
return Err(Error::BadArgs(format!("`{inner}` writes to its project's or queue's files: run it on its own while file access is confined")));
}
}
return Ok(None);
}
let (named, access) = match (keys_of(WRITES, id), keys_of(READS, id)) {
(Some(k), _) => (k, Access::Write),
(None, Some(k)) => (k, Access::Read),
(None, None) if id.starts_with("renderQueue.") || id == "prefs.set" => (&[][..], Access::Write),
(None, None) => (&[][..], Access::Read),
};
let mut given = false;
for key in named {
let Some(v) = params.get(*key).filter(|v| !v.is_null()) else { continue };
given = true;
let paths: Vec<&Value> = match v {
Value::Array(a) => a.iter().collect(),
v => vec![v],
};
for p in paths {
// A Render Queue output may name a template instead of a file.
match p.as_str() {
Some(s) if id == "render.preRender" && !looks_like_path(s) => {}
Some(s) => {
self.check(s, access)?;
}
None => return Err(Error::BadArgs(format!("`{id}`: `{key}` must be a path"))),
}
}
}
// Any other absolute path the command is given.
let mut other = vec![];
absolute_strings(params, named, &mut other);
for s in other {
self.check(&s, access)?;
}
// An entry of a recent list by index is a path the agent didn't give.
if matches!(id, "file.openRecent" | "file.importRecent" | "anim.applyRecentPreset") && !given {
return Err(Error::BadArgs(format!("`{id}`: give `path` while file access is confined")));
}
if IMPLICIT_WRITES.contains(&id) && !(given && id == "file.save") {
return Ok(Some(if id == "file.save" || id == "file.incrementAndSave" { Lookup::ProjectFile } else { Lookup::RenderOutputs }));
}
Ok(None)
}
/// Check what a [`Lookup`] found: `project.summary` or `renderQueue.list`'s result.
pub fn check_lookup(&self, lookup: &Lookup, found: &Value) -> Result<()> {
match lookup {
Lookup::ProjectFile => match found.get("path").and_then(Value::as_str) {
// Increment and Save writes beside it.
Some(p) => self.check(p, Access::Write).map(drop),
None => Err(Error::BadArgs("the project has no file yet: give `path` (Save As) inside an automation write root".into())),
},
Lookup::RenderOutputs => {
let items = found.get("items").or(Some(found)).and_then(Value::as_array).cloned().unwrap_or_default();
for it in items.iter().filter(|it| it.get("render").and_then(Value::as_bool) != Some(false)) {
let extra = it.get("extraOutputs").and_then(Value::as_array).into_iter().flatten().map(|o| o.get("outputPath"));
for p in std::iter::once(it.get("outputPath")).chain(extra).chain([it.get("logPath")]).flatten() {
if let Some(p) = p.as_str() {
self.check(p, Access::Write)?;
}
}
}
Ok(())
}
}
}
}
fn keys_of(table: &[(&str, &'static [&'static str])], id: &str) -> Option<&'static [&'static str]> {
table.iter().find(|(c, _)| *c == id).map(|(_, k)| *k)
}
fn has_reference(v: &Value) -> bool {
match v {
Value::String(s) => s.starts_with('$'),
Value::Array(a) => a.iter().any(has_reference),
_ => false,
}
}
/// A file path rather than a template name: has a separator or an extension.
fn looks_like_path(s: &str) -> bool {
s.contains(['/', '\\']) || Path::new(s).extension().is_some()
}
/// Strings in `v` (recursively, except under the top-level `skip` keys) that are absolute paths,
/// `~` paths or `file:` URLs.
fn absolute_strings(v: &Value, skip: &[&str], out: &mut Vec<String>) {
fn walk(v: &Value, out: &mut Vec<String>, depth: usize) {
if depth > 64 {
return;
}
match v {
Value::String(s) => {
let t = s.trim();
let drive = t.len() > 2 && t.as_bytes().get(1) == Some(&b':') && t.as_bytes().first().is_some_and(u8::is_ascii_alphabetic);
if t.starts_with('/') || t.starts_with('\\') || t.starts_with('~') || drive || t.to_ascii_lowercase().starts_with("file:") {
out.push(t.to_string());
}
}
Value::Array(a) => a.iter().for_each(|x| walk(x, out, depth + 1)),
Value::Object(o) => o.values().for_each(|x| walk(x, out, depth + 1)),
_ => {}
}
}
match v {
Value::Object(o) => o.iter().filter(|(k, _)| !skip.contains(&k.as_str())).for_each(|(_, x)| walk(x, out, 0)),
v => walk(v, out, 0),
}
}
/// `p` with symbolic links resolved: its longest existing ancestor's real path plus the rest
/// (which doesn't exist yet, so can't be a link).
fn resolve(p: &Path) -> Option<PathBuf> {
let mut rest = vec![];
let mut at = p;
loop {
if let Ok(real) = at.canonicalize() {
return Some(rest.iter().rev().fold(real, |acc: PathBuf, c| acc.join(c)));
}
rest.push(at.file_name()?.to_os_string());
at = at.parent()?;
}
}

View File

@ -15,9 +15,12 @@
#![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;
pub mod confine;
mod long_job;
pub mod server;
pub mod tools;
@ -60,8 +63,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

@ -0,0 +1,177 @@
//! Long renders over MCP (docs/mcp.md "Progress and cancellation"). While [`McpServer::serve`]
//! runs, a blocking `renderQueue.render` (`command_run`, headless) renders on the session's
//! background render job instead of inline: the server keeps reading stdin, answers every other
//! request in between (the job renders a snapshot of the project), reports progress as
//! `notifications/progress` when the request carried `_meta.progressToken`, and stops the render
//! on `notifications/cancelled` (deleting the partial output, sending no response).
use std::io::Write;
use std::sync::mpsc::{Receiver, RecvTimeoutError};
use std::time::Duration;
use serde_json::{Value, json};
use crate::server::{McpServer, call_result};
use crate::tools::{self, Reply};
/// A `tools/call` result for `call`, with `resultType` for modern clients.
fn result_for(call: &LongCall, r: Result<Reply, crate::Error>) -> Value {
let mut result = call_result(r);
if call.modern {
result["resultType"] = json!("complete");
}
result
}
/// How often the job is polled (and at most how often progress is reported).
const POLL: Duration = Duration::from_millis(100);
/// A `tools/call` that runs as a long job.
pub struct LongCall {
pub id: Value,
pub params: Value,
pub token: Option<Value>,
/// The request declared MCP 2026-07-28+ (its result then carries `resultType`).
pub modern: bool,
}
/// Whether `msg` is a blocking `renderQueue.render` call (valid arguments, headless backend).
pub fn long_call(server: &mut McpServer, msg: &Value) -> Option<LongCall> {
let id = msg.get("id").filter(|i| i.is_string() || i.is_number())?;
if msg.get("method").and_then(Value::as_str) != Some("tools/call") || server.backend().is_bridge() {
return None;
}
let p = msg.get("params")?;
let name = p.get("name")?.as_str()?;
let args = p.get("arguments").cloned().unwrap_or(json!({}));
if !matches!(name, "command_run" | "execute_command")
|| args.get("id").or_else(|| args.get("command")).and_then(Value::as_str) != Some("renderQueue.render")
{
return None;
}
if tools::find(name).and_then(|t| t.unknown_arg(&args)).is_some() {
return None; // the normal path reports it
}
let params = match args.get("params") {
None | Some(Value::Null) => json!({}),
Some(v) if v.is_object() => v.clone(),
_ => return None,
};
if params.get("wait").and_then(Value::as_bool) == Some(false) {
return None;
}
let token = p.get("_meta").and_then(|m| m.get("progressToken")).filter(|t| t.is_string() || t.is_number()).cloned();
Some(LongCall { id: id.clone(), params, token, modern: crate::server::is_modern(p) })
}
fn send(out: &mut impl Write, v: &Value) -> std::io::Result<()> {
out.write_all(v.to_string().as_bytes())?;
out.write_all(b"\n")?;
out.flush()
}
/// The `requestId` of a `notifications/cancelled` line.
fn cancelled_id(line: &str) -> Option<Value> {
let v: Value = serde_json::from_str(line).ok()?;
(v.get("method")?.as_str()? == "notifications/cancelled").then(|| v.get("params")?.get("requestId").cloned())?
}
impl McpServer {
/// Run `call` to completion or cancellation, handling the other messages from `inbox`
/// (`None` = stdin closed) meanwhile. Returns false when stdin closed.
pub(crate) fn run_long(&mut self, call: LongCall, inbox: &Receiver<Option<String>>, out: &mut impl Write) -> std::io::Result<bool> {
let mut params = call.params.clone();
params["wait"] = json!(false);
let start = self.guarded("command_run", &json!({"id": "renderQueue.render", "params": params}));
let items = match &start {
Ok(Reply::Json(v)) => v["items"].as_array().cloned().unwrap_or_default(),
_ => {
send(out, &json!({"jsonrpc": "2.0", "id": call.id, "result": result_for(&call, start)}))?;
return Ok(true);
}
};
let ids: Vec<u64> = items.iter().filter_map(|i| i["id"].as_u64()).collect();
let shared = self.backend().session().and_then(|s| s.render_job.as_ref().map(|job| job.shared.clone()));
let mut last = -1.0f64;
let mut last_report: Option<std::time::Instant> = None;
let mut open = true;
let mut cancelled = false;
loop {
let state = shared.as_ref().map(|job| job.snapshot());
let rendering = state.as_ref().is_some_and(|state| !state.finished);
if let (Some(token), Some(st)) = (&call.token, &state)
&& st.items_total > 0
&& !cancelled
{
let frac = if st.total > 0 { st.done as f64 / st.total as f64 } else { 0.0 };
// the current item's frames count until it is done
let progress = (st.items_done as f64 + if st.items_done < st.items_total { frac.min(1.0) } else { 0.0 }).min(st.items_total as f64);
if progress > last && (!rendering || last_report.is_none_or(|at| at.elapsed() >= POLL)) {
last = progress;
last_report = Some(std::time::Instant::now());
let message = format!(
"Rendering item {} of {}: frame {} of {}",
st.items_done.saturating_add(1).min(st.items_total),
st.items_total,
st.done,
st.total
);
send(
out,
&json!({"jsonrpc": "2.0", "method": "notifications/progress",
"params": {"progressToken": token, "progress": progress, "total": st.items_total, "message": message}}),
)?;
}
}
if !rendering {
break;
}
if !open {
// stdin closed: finish the render (a piped script expects its reply), read nothing more
std::thread::sleep(POLL);
continue;
}
match inbox.recv_timeout(POLL) {
Ok(Some(line)) => {
if cancelled_id(&line).as_ref() == Some(&call.id) {
cancelled = true;
if let Some(job) = &shared {
job.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
}
continue;
}
if let Some(other) = serde_json::from_str::<Value>(&line).ok().and_then(|msg| long_call(self, &msg)) {
let result = result_for(&other, Err(crate::Error::Other("a render is already in progress".into())));
send(out, &json!({"jsonrpc":"2.0", "id":other.id, "result":result}))?;
continue;
}
if let Some(reply) = self.handle_line(&line) {
out.write_all(reply.as_bytes())?;
out.write_all(b"\n")?;
out.flush()?;
}
}
Ok(None) | Err(RecvTimeoutError::Disconnected) => open = false,
Err(RecvTimeoutError::Timeout) => {}
}
}
let Some(s) = self.backend().session() else { return Ok(open) };
s.poll_render();
s.drain_events();
let list = self.guarded("command_run", &json!({"id": "renderQueue.list"}));
let items: Vec<Value> = match &list {
Ok(Reply::Json(v)) => {
v["items"].as_array().cloned().unwrap_or_default().into_iter().filter(|i| i["id"].as_u64().is_some_and(|id| ids.contains(&id))).collect()
}
_ => Vec::new(),
};
if cancelled {
// Exporters remove only files they actually opened for this interrupted output.
return Ok(open); // MCP: no response for a cancelled request
}
let failed = ids.len().saturating_sub(items.len()).saturating_add(items.iter().filter(|item| item["statusLabel"].as_str() != Some("Done")).count());
let result = result_for(&call, Ok(Reply::Json(json!({"rendering": false, "items": items, "failed": failed}))));
send(out, &json!({"jsonrpc": "2.0", "id": call.id, "result": result}))?;
Ok(open)
}
}

View File

@ -1,31 +1,58 @@
//! MCP server: JSON-RPC 2.0, one message per line (the MCP stdio transport). Implements
//! `initialize`, `ping`, `tools/list` and `tools/call`; notifications are accepted and ignored.
//! tools and JSON resources. The stdio loop handles progress and cancellation for long renders.
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};
/// Protocol revisions we speak, newest first. We answer with the client's if we know it.
pub const PROTOCOL_VERSIONS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"];
const INSTRUCTIONS: &str = "EffectCraft is an After Effects-class motion graphics compositor. Everything is an engine command: list_commands discovers ids and params, execute_command runs them (undoable). Typical flow: execute_command comp.new -> execute_command layer.newText / layer.newSolid (returns the layer id) -> set_property / add_keyframe -> execute_command effect.apply -> render_frame to look at the result. Inspect with get_project, get_comp and get_layer (every property node carries its `path`, e.g. `transform/position`, `effects/#1/blurriness`). Times are seconds. In bridge mode (app started with `--control <port>`) screenshot and the ui_* tools show and operate the live window.";
const INSTRUCTIONS: &str = "EffectCraft is an After Effects-class motion graphics compositor. Everything is an engine command: command_list discovers ids and params, command_run {id, params} runs them (undoable; command_batch runs several in order). Typical flow: command_run comp.new -> command_run layer.newText / layer.newSolid (returns the layer id) -> set_property / add_keyframe -> command_run effect.apply -> render_preview to look at the result. Inspect with doc_inspect, get_project, get_comp and get_layer (every property node carries its `path`, e.g. `transform/position`, `effects/#1/blurriness`). Times are seconds. In bridge mode (app started with `--control <port>`) screenshot and the ui_* tools show and operate the live window.";
// JSON-RPC error codes.
const PARSE_ERROR: i64 = -32700;
const INVALID_REQUEST: i64 = -32600;
const METHOD_NOT_FOUND: i64 = -32601;
const INVALID_PARAMS: i64 = -32602;
const INTERNAL_ERROR: i64 = -32603;
const RESOURCE_NOT_FOUND: i64 = -32002;
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 {
@ -53,7 +80,10 @@ impl McpServer {
};
let params = msg.get("params").cloned().unwrap_or(json!({}));
Some(match self.request(method, &params) {
Ok(result) => json!({"jsonrpc": "2.0", "id": id, "result": result}),
Ok(mut result) => {
modern_result_fields(method, &params, &mut result);
json!({"jsonrpc": "2.0", "id": id, "result": result})
}
Err((code, m)) => error(id, code, &m),
})
}
@ -71,34 +101,79 @@ impl McpServer {
Some(reply.to_string())
}
/// 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()?;
/// Serve until EOF on `input`. Input is read on its own thread so a long render can report
/// progress, be cancelled and let other requests through while it runs (`long_job`). The
/// reader thread is detached: when writing a reply fails, the server returns at once instead
/// of waiting for a read that may never complete.
pub fn serve(&mut self, input: impl BufRead + Send + 'static, mut output: impl Write) -> std::io::Result<()> {
let (tx, inbox) = std::sync::mpsc::channel::<Option<String>>();
// A read error ends the input like EOF and is returned once the queued requests are answered.
let read_error = std::sync::Arc::new(std::sync::Mutex::new(None::<std::io::Error>));
let reader_error = read_error.clone();
std::thread::Builder::new().name("mcp-input".into()).spawn(move || {
for line in input.lines() {
let line = match line {
Ok(line) => line,
Err(e) => {
*reader_error.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(e);
break;
}
};
if tx.send(Some(line)).is_err() {
return;
}
}
let _ = tx.send(None);
})?;
let served = (|| {
while let Ok(Some(line)) = inbox.recv() {
let long = serde_json::from_str::<Value>(line.trim()).ok().and_then(|m| crate::long_job::long_call(self, &m));
if let Some(call) = long {
if !self.run_long(call, &inbox, &mut output)? {
break;
}
continue;
}
if let Some(reply) = self.handle_line(&line) {
output.write_all(reply.as_bytes())?;
output.write_all(b"\n")?;
output.flush()?;
}
}
match read_error.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() {
Some(e) => Err(e),
None => 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).
pub fn serve_stdio(&mut self) -> std::io::Result<()> {
self.serve(std::io::stdin().lock(), std::io::stdout().lock())
self.serve(std::io::BufReader::new(std::io::stdin()), std::io::stdout().lock())
}
fn request(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
match method {
"initialize" => {
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 version = PROTOCOL_VERSIONS.iter().find(|v| **v == asked).copied().unwrap_or(PROTOCOL_VERSIONS.first().copied().unwrap_or("2025-06-18"));
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}},
"capabilities": {"tools": {"listChanged": false}, "resources": {"listChanged": false, "subscribe": 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!({})),
@ -108,16 +183,62 @@ impl McpServer {
}
"tools/call" => {
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?;
let args = params.get("arguments").cloned().unwrap_or(json!({}));
if tools::available(self.backend.is_bridge()).all(|t| t.name != name) {
let hint = if tools::find(name).is_some() { format!(" ({})", crate::backend::NEED_BRIDGE) } else { String::new() };
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)))
if let Some(m) = tools::find(name).and_then(|t| t.unknown_arg(&args)) {
return Err((INVALID_PARAMS, m));
}
let mut result = call_result(self.guarded(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": []})),
"resources/list" => Ok(json!({"resources": [
{"uri": DOCUMENT_URI, "name": "document", "title": "Project", "description": "The project overview and the active composition (same as doc_inspect).", "mimeType": "application/json"},
{"uri": COMMANDS_URI, "name": "commands", "title": "Command catalog", "description": "Every engine command with id, label, menu, shortcut and params (same as command_list).", "mimeType": "application/json"},
]})),
"resources/templates/list" => Ok(json!({"resourceTemplates": []})),
"resources/read" => {
let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?;
let tool = match uri {
DOCUMENT_URI => "doc_inspect",
COMMANDS_URI => "command_list",
_ => return Err((RESOURCE_NOT_FOUND, format!("resource not found: {uri}"))),
};
match self.guarded(tool, &json!({})) {
Ok(Reply::Json(v)) => Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": v.to_string()}]})),
Ok(Reply::Image { .. }) => Err((INTERNAL_ERROR, "unexpected image".into())),
Err(e) => Err((INTERNAL_ERROR, e.to_string())),
}
}
"prompts/list" => Ok(json!({"prompts": []})),
m => Err((METHOD_NOT_FOUND, format!("method not found: {m}"))),
}
@ -127,7 +248,9 @@ impl McpServer {
/// A `tools/call` result: tool failures are reported in-band (`isError`) so the model can react.
pub fn call_result(r: Result<Reply, Error>) -> Value {
match r {
Ok(Reply::Json(v)) => json!({"content": [{"type": "text", "text": v.to_string()}], "isError": false}),
Ok(Reply::Json(v)) => {
json!({"content": [{"type": "text", "text": v.to_string()}], "isError": v.get("failed").and_then(Value::as_u64).is_some_and(|n| n > 0)})
}
Ok(Reply::Image { png, info }) => json!({
"content": [
{"type": "image", "data": base64::encode(&png), "mimeType": "image/png"},
@ -139,6 +262,47 @@ pub fn call_result(r: Result<Reply, Error>) -> Value {
}
}
impl McpServer {
/// Run a tool with panics caught: a panic becomes an error result and the session keeps
/// serving (docs/mcp.md).
pub(crate) fn guarded(&mut self, name: &str, args: &Value) -> Result<Reply, Error> {
let backend = &mut self.backend;
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| tools::run(backend, name, args))).unwrap_or_else(|p| {
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
Err(Error::Other(format!("internal error: {msg}")))
})
}
}
/// The craft catalog resources (docs/mcp.md).
const DOCUMENT_URI: &str = "effectcraft://document";
const COMMANDS_URI: &str = "effectcraft://commands";
/// Whether a request declares MCP 2026-07-28 or later in its `_meta` (a "modern" client, which
/// negotiates per request instead of through `initialize`).
pub(crate) fn is_modern(params: &Value) -> bool {
params.pointer("/_meta/io.modelcontextprotocol~1protocolVersion").and_then(Value::as_str).is_some_and(|v| v >= "2026-07-28")
}
/// Modern clients reject list/read results without `resultType`, `ttlMs` and `cacheScope`; those
/// fields are added for them. Sessions that negotiated an older revision through `initialize` keep
/// the legacy shape. The tool and resource lists never change while the server runs (cacheable
/// for ten minutes); reads follow the project, so they are never cached.
pub(crate) fn modern_result_fields(method: &str, params: &Value, result: &mut Value) {
let ttl_ms = match method {
"tools/list" | "resources/list" | "resources/templates/list" | "prompts/list" => Some(600_000),
"resources/read" => Some(0),
"tools/call" => None,
_ => return,
};
let Some(obj) = result.as_object_mut().filter(|_| is_modern(params)) else { return };
obj.insert("resultType".into(), json!("complete"));
if let Some(ttl_ms) = ttl_ms {
obj.insert("ttlMs".into(), json!(ttl_ms));
obj.insert("cacheScope".into(), json!("private"));
}
}
fn error(id: Value, code: i64, message: &str) -> Value {
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})
}

View File

@ -35,6 +35,28 @@ fn call_json(s: &mut McpServer, name: &str, args: Value) -> Value {
serde_json::from_str(c[0]["text"].as_str().unwrap()).unwrap()
}
#[test]
fn property_group_error_points_to_layer_inspection() {
let mut s = server();
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Main"}}));
let l = call_json(&mut s, "execute_command", json!({"command": "layer.newText", "params": {"text": "Hi"}}))["layer"].clone();
call_json(&mut s, "execute_command", json!({"command": "comp.renderer", "params": {"renderer": "advanced3d"}}));
for tool in ["get_property", "set_property", "add_keyframe"] {
let mut args = json!({"layer": l, "path": "geometryOptions", "time": 0});
if tool != "get_property" {
args["value"] = json!(0);
}
let (content, err) = call(&mut s, tool, args);
assert!(err, "{tool}: {content:?}");
let text = content[0]["text"].as_str().unwrap();
assert!(text.contains("is a property group"), "{tool}: {text}");
assert!(text.contains("get_layer"), "{tool}: {text}");
}
let tree = call_json(&mut s, "get_layer", json!({"layer": l, "flat": true}));
assert!(tree["properties"].as_array().unwrap().iter().any(|p| p["path"] == "geometryOptions/extrusionDepth"));
assert!(call_json(&mut s, "get_property", json!({"layer": l, "path": "geometryOptions/extrusionDepth"}))["value"].is_number());
}
#[test]
fn initialize_and_list_tools() {
let mut s = server();
@ -135,6 +157,21 @@ fn headless_workflow() {
let mid = call_json(&mut s, "get_property", json!({"layer": l, "path": "transform/position", "time": 1.0}));
assert!((mid["value"][0].as_f64().unwrap() - 160.0).abs() < 1.0, "{mid}");
// Comp-time keys on an offset layer (#257): the key lands where the Timeline shows it.
call_json(&mut s, "execute_command", json!({"command": "layer.timing", "params": {"layer": l, "start": 1.0}}));
let p = call_json(
&mut s,
"add_keyframe",
json!({"layer": l, "path": "transform/scale", "keys": [{"time": 1.5, "value": [50, 50]}, {"time": 2.5, "value": [100, 100]}], "timeBase": "comp", "interpolation": "hold"}),
);
let times: Vec<f64> = p["keys"].as_array().unwrap().iter().map(|k| k["time"].as_f64().unwrap()).collect();
// Comp times snap to the comp's frames (29.97 fps) before becoming layer time.
assert!((times[0] - 0.5).abs() < 0.02 && (times[1] - 1.5).abs() < 0.02, "layer-time keys: {times:?}");
assert_eq!(p["keys"][0]["out"], "Hold", "the comp-time keys were selected for the interpolation");
call_json(&mut s, "set_property", json!({"layer": l, "path": "transform/scale", "value": [75, 75], "time": 3.0, "timeBase": "comp"}));
let p = call_json(&mut s, "get_property", json!({"layer": l, "path": "transform/scale"}));
assert!(p["keys"].as_array().unwrap().iter().any(|k| (k["time"].as_f64().unwrap() - 2.0).abs() < 0.02), "{p}");
// Expression via set_property.
let p = call_json(&mut s, "set_property", json!({"layer": l, "path": "transform/rotation", "expression": "time * 90"}));
assert_eq!(p["expression"], "time * 90");
@ -177,6 +214,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();
@ -189,13 +248,200 @@ fn stdio_loop() {
.map(|v| v.to_string() + "\n")
.collect::<String>();
let mut out = Vec::new();
s.serve(input.as_bytes(), &mut out).unwrap();
s.serve(std::io::Cursor::new(input), &mut out).unwrap();
let lines: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 2);
assert_eq!(lines[1]["id"], 2);
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(std::io::Cursor::new(input), &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!({}));
// The instructions carry the auto-save state as JSON, where a Windows path's backslashes
// are escaped.
assert!(init["instructions"].as_str().unwrap().contains(&Value::from(path).to_string()));
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();
}
/// A reply that cannot be written ends `serve` with the write error, even while stdin stays open
/// (the client is gone but never closed its end): the reader thread must not be waited for.
#[test]
fn broken_output_returns_while_stdin_stays_open() {
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 (in_r, mut in_w) = std::io::pipe().unwrap();
writeln!(in_w, "{}", json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})).unwrap();
let (done_tx, done) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = done_tx.send(server().serve(BufReader::new(in_r), Broken));
});
let result = done.recv_timeout(std::time::Duration::from_secs(10)).expect("serve must return while stdin is still open");
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::BrokenPipe);
drop(in_w);
}
#[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 +639,622 @@ 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}");
assert_eq!(mono["variable"], false, "{one}");
// `variable` keeps only the variable fonts (the font menu's filter); the static bundled
// families drop out.
let var = call_json(&mut s, "list_fonts", json!({"variable": true}));
let listed = var["families"].as_array().unwrap();
assert!(listed.iter().all(|f| f["variable"] == true && f["family"] != "Inter"), "{var}");
}
/// 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")));
}
#[test]
fn core_tools_annotations_and_strict_keys() {
let mut s = server();
let tools = rpc(&mut s, 1, "tools/list", json!({}));
let tools = tools["tools"].as_array().unwrap();
for name in ["command_list", "command_run", "command_batch", "doc_inspect", "render_preview"] {
assert!(tools.iter().any(|t| t["name"] == name), "missing {name}");
}
for t in tools {
assert!(t["title"].as_str().is_some_and(|s| !s.is_empty()));
for hint in ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] {
assert!(t["annotations"][hint].is_boolean(), "{t}");
}
let msg = json!({"jsonrpc":"2.0", "id":2, "method":"tools/call", "params":{"name":t["name"], "arguments":{"typo_key":1}}});
let reply = s.handle(&msg).unwrap();
assert_eq!(reply["error"]["code"], -32602, "{reply}");
assert!(reply["error"]["message"].as_str().unwrap().contains("typo_key"));
}
let r = call_json(&mut s, "command_run", json!({"id":"comp.new", "params":{"width":32,"height":32}}));
assert!(r.is_object());
let (content, err) = call(&mut s, "command_batch", json!({"steps":[{"id":"no.such.command"},{"id":"layer.newNull"}]}));
assert!(err);
let result: Value = serde_json::from_str(content[0]["text"].as_str().unwrap()).unwrap();
assert_eq!(result["completed"], 0);
assert_eq!(result["failed"], 1);
assert_eq!(result["results"].as_array().unwrap().len(), 1);
let (content, err) = call(&mut s, "command_batch", json!({"stop_on_error":false,"steps":[{"id":"no.such.command"},{"id":"layer.newNull"}]}));
assert!(err);
let result: Value = serde_json::from_str(content[0]["text"].as_str().unwrap()).unwrap();
assert_eq!(result["completed"], 1);
assert_eq!(result["failed"], 1);
assert!(call_json(&mut s, "doc_inspect", json!({}))["activeComp"].is_object());
assert!(!call(&mut s, "render_preview", json!({"max_side":16})).1);
}
#[test]
fn resources_match_tools_and_recover_after_parse_error() {
let mut s = server();
let bad: Value = serde_json::from_str(&s.handle_line("{broken").unwrap()).unwrap();
assert_eq!(bad["error"]["code"], -32700);
assert!(bad["id"].is_null());
assert_eq!(rpc(&mut s, 1, "ping", json!({})), json!({}));
let list = rpc(&mut s, 2, "resources/list", json!({}));
for (uri, tool) in [("effectcraft://document", "doc_inspect"), ("effectcraft://commands", "command_list")] {
assert!(list["resources"].as_array().unwrap().iter().any(|r| r["uri"] == uri));
let r = rpc(&mut s, 3, "resources/read", json!({"uri":uri}));
assert_eq!(r["contents"][0]["mimeType"], "application/json");
let value: Value = serde_json::from_str(r["contents"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(value, call_json(&mut s, tool, json!({})));
}
}
/// Rendering is a direct tool call, outside Session::execute's existing panic guard.
#[test]
fn render_panic_is_an_error_and_session_remains_usable() {
use effectcraft_engine::{
color::Label,
project::{Footage, FootageKind, ItemId, ItemKind},
raster::Image,
render::FootageSource,
time::Tick,
};
use std::sync::{Arc, Mutex};
struct BrokenFootage(Mutex<bool>);
impl FootageSource for BrokenFootage {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
let mut first = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if *first {
*first = false;
panic!("synthetic decoder failure");
}
Some(Arc::new(Image::new(16, 16)))
}
}
let mut session = Session { footage: Arc::new(BrokenFootage(Mutex::new(true))), ..Default::default() };
session.execute("comp.new", json!({"width":16,"height":16})).unwrap();
let item = Arc::make_mut(&mut session.project).add_item(
"synthetic",
Label::Aqua,
None,
ItemKind::Footage(Footage { path: "synthetic.png".into(), kind: FootageKind::Still, width: 16, height: 16, has_video: true, ..Default::default() }),
);
session.execute("layer.addItem", json!({"item":item.0})).unwrap();
let mut s = McpServer::new(Backend::headless(session));
let (content, err) = call(&mut s, "render_frame", json!({}));
assert!(err);
assert!(content[0]["text"].as_str().unwrap().contains("synthetic decoder failure"));
assert_eq!(rpc(&mut s, 2, "ping", json!({})), json!({}));
assert!(!call(&mut s, "render_frame", json!({})).1);
}
/// MCP 2026-07-28 ("modern") clients declare their revision per request in `_meta` and reject
/// list/read results without `resultType`, `ttlMs` and `cacheScope`; legacy sessions (revision
/// negotiated through `initialize`) keep the old result shape.
#[test]
fn modern_clients_get_result_type() {
let mut s = server();
let meta = json!({
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "t", "version": "1"},
"io.modelcontextprotocol/clientCapabilities": {},
});
for (id, method, params) in [
(1, "tools/list", json!({"_meta": meta})),
(2, "resources/list", json!({"_meta": meta})),
(3, "resources/templates/list", json!({"_meta": meta})),
(4, "resources/read", json!({"_meta": meta, "uri": "effectcraft://document"})),
(5, "tools/call", json!({"_meta": meta, "name": "command_list", "arguments": {}})),
] {
let r = rpc(&mut s, id, method, params);
assert_eq!(r["resultType"], "complete", "{method}: {r}");
if method != "tools/call" {
assert!(r["ttlMs"].is_u64(), "{method}: {r}");
assert!(matches!(r["cacheScope"].as_str(), Some("public" | "private")), "{method}: {r}");
}
}
let mut s = server();
rpc(&mut s, 1, "initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}}));
for (id, method, params) in
[(2, "tools/list", json!({})), (3, "resources/list", json!({})), (4, "resources/read", json!({"uri": "effectcraft://document"}))]
{
let r = rpc(&mut s, id, method, params);
let res = r.as_object().unwrap();
assert!(!res.contains_key("resultType") && !res.contains_key("ttlMs") && !res.contains_key("cacheScope"), "{method}: {r}");
}
}
#[test]
fn render_job_state_recovers_a_poisoned_lock() {
let shared = std::sync::Arc::new(effectcraft_engine::render_queue::JobShared::default());
let worker = shared.clone();
let _ = std::thread::spawn(move || {
let mut state = worker.state.lock().unwrap();
state.done = 3;
panic!("synthetic worker panic while holding the progress lock");
})
.join();
assert_eq!(shared.snapshot().done, 3);
}
/// A render that takes ~2 ms per frame and writes its file first (so a cancel leaves a partial
/// one to clean up).
struct SlowExporter;
impl effectcraft_engine::Exporter for SlowExporter {
fn formats(&self) -> Vec<effectcraft_engine::project::render_queue::OutputFormat> {
effectcraft_engine::project::render_queue::OutputFormat::ALL.to_vec()
}
fn export(&self, job: &effectcraft_engine::ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result<effectcraft_engine::ExportResult, String> {
std::fs::write(job.path, b"partial").map_err(|e| e.to_string())?;
let n = 2000;
for k in 0..=n {
if !progress(k, n) {
std::fs::remove_file(job.path).map_err(|e| e.to_string())?;
return Err(effectcraft_engine::render_queue::CANCELLED.into());
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
Ok(effectcraft_engine::ExportResult { path: job.path.to_string(), frames: n, ..Default::default() })
}
}
/// docs/mcp.md "Requests during a render": while a blocking render runs, every way of starting a
/// second render is refused (blocking, `wait: false`, inside a batch), and edits are applied to
/// the project without changing the running job, which renders the snapshot it started with.
#[test]
fn second_render_is_refused_and_edits_apply_during_a_render() {
let dir = std::env::temp_dir().join(format!("effectcraft-mcp-busy-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (in_r, mut in_w) = std::io::pipe().unwrap();
let (out_r, out_w) = std::io::pipe().unwrap();
let server = std::thread::spawn(move || {
let session = Session { exporter: Some(std::sync::Arc::new(SlowExporter)), ..Default::default() };
McpServer::new(Backend::headless(session)).serve(BufReader::new(in_r), out_w).unwrap();
});
let mut lines = BufReader::new(out_r).lines();
let mut next = || -> Value { serde_json::from_str(&lines.next().unwrap().unwrap()).unwrap() };
let mut send = |v: Value| writeln!(in_w, "{v}").unwrap();
let call =
|id: u64, name: &str, arguments: Value| json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": name, "arguments": arguments}});
send(json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}}));
next();
send(call(2, "command_run", json!({"id": "comp.new", "params": {"width": 64, "height": 64, "duration": 1}})));
assert_eq!(next()["result"]["isError"], false);
let a = dir.join("a.mp4").to_string_lossy().to_string();
send(call(3, "command_run", json!({"id": "renderQueue.add", "params": {"output": a}})));
assert_eq!(next()["result"]["isError"], false);
send(call(4, "command_run", json!({"id": "renderQueue.render"})));
send(call(5, "command_run", json!({"id": "renderQueue.render", "params": {"wait": false}})));
send(call(6, "command_run", json!({"id": "renderQueue.render"})));
send(call(7, "command_batch", json!({"steps": [{"id": "renderQueue.render", "params": {"wait": false}}]})));
send(call(8, "command_run", json!({"id": "comp.new", "params": {"name": "Added while rendering", "width": 32, "height": 32, "duration": 1}})));
let mut replies = std::collections::HashMap::new();
while !replies.contains_key(&4) {
let m = next();
replies.insert(m["id"].as_u64().unwrap(), m);
}
for id in [5, 6, 7] {
let m = &replies[&id];
assert_eq!(m["result"]["isError"], true, "a second render is refused: {m}");
let text = m["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("already in progress") || text.contains("is rendering"), "{m}");
}
assert_eq!(replies[&8]["result"]["isError"], false, "edits are applied during a render");
let done = &replies[&4];
assert_eq!(done["result"]["isError"], false, "{done}");
let v: Value = serde_json::from_str(done["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(v["items"].as_array().unwrap().len(), 1, "{v}");
assert_eq!(v["items"][0]["statusLabel"], "Done", "{v}");
send(call(9, "doc_inspect", json!({})));
let doc = next();
assert!(doc.to_string().contains("Added while rendering"), "{doc}");
drop(in_w);
server.join().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
/// docs/mcp.md "Progress and cancellation" over the stdio loop: a blocking `renderQueue.render`
/// reports `notifications/progress` for its token, other requests are answered while it renders,
/// and `notifications/cancelled` stops it, deletes the partial file and sends no response.
#[test]
fn render_progress_and_cancel() {
let dir = std::env::temp_dir().join(format!("effectcraft-mcp-progress-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (in_r, mut in_w) = std::io::pipe().unwrap();
let (out_r, out_w) = std::io::pipe().unwrap();
let server = std::thread::spawn(move || {
let session = Session { exporter: Some(std::sync::Arc::new(SlowExporter)), ..Default::default() };
McpServer::new(Backend::headless(session)).serve(BufReader::new(in_r), out_w).unwrap();
});
let mut lines = BufReader::new(out_r).lines();
let mut next = || -> Value { serde_json::from_str(&lines.next().unwrap().unwrap()).unwrap() };
let mut send = |v: Value| writeln!(in_w, "{v}").unwrap();
let run = |id: u64, cmd: &str, params: Value, token: Option<Value>| {
let mut p = json!({"name": "command_run", "arguments": {"id": cmd, "params": params}});
if let Some(t) = token {
p["_meta"] = json!({"progressToken": t, "io.modelcontextprotocol/protocolVersion":"2026-07-28"});
}
json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": p})
};
send(json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}}));
next();
send(run(2, "comp.new", json!({"width": 64, "height": 64, "duration": 1}), None));
assert_eq!(next()["result"]["isError"], false);
let a = dir.join("a.mp4").to_string_lossy().to_string();
send(run(3, "renderQueue.add", json!({"output": a}), None));
assert_eq!(next()["result"]["isError"], false);
// progress for the token, strictly increasing; doc_inspect answered while rendering
send(run(4, "renderQueue.render", json!({}), Some(json!("tok"))));
let (mut last, mut notes, mut asked, mut answered) = (-1.0, 0, false, false);
let done = loop {
let m = next();
if m["method"] == "notifications/progress" {
assert_eq!(m["params"]["progressToken"], "tok", "{m}");
let p = m["params"]["progress"].as_f64().unwrap();
assert!(p > last && p <= m["params"]["total"].as_f64().unwrap(), "{m}");
(last, notes) = (p, notes + 1);
if !asked && p > 0.0 {
asked = true;
send(json!({"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "doc_inspect", "arguments": {}}}));
}
} else if m["id"] == 5 {
answered = true;
assert_eq!(m["result"]["isError"], false, "{m}");
} else {
assert_eq!(m["id"], 4, "{m}");
break m;
}
};
assert_eq!(done["result"]["isError"], false, "{done}");
assert_eq!(done["result"]["resultType"], "complete");
assert_eq!(last, 1.0, "final progress reaches the total");
let v: Value = serde_json::from_str(done["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(v["items"][0]["statusLabel"], "Done", "{v}");
assert!(notes >= 2 && answered, "{notes} notifications; doc_inspect answered during the render: {answered}");
// cancel after the first notification: no response, the partial file is gone
let b = dir.join("b.mp4");
send(run(6, "renderQueue.add", json!({"output": b.to_string_lossy()}), None));
assert_eq!(next()["result"]["isError"], false);
send(run(7, "renderQueue.render", json!({}), Some(json!(7))));
loop {
let m = next();
assert_ne!(m["id"], 7, "{m}");
if m["method"] == "notifications/progress" && m["params"]["progress"].as_f64().unwrap() > 0.0 {
break;
}
}
assert!(b.exists(), "the render started writing");
send(json!({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 7, "reason": "test"}}));
send(json!({"jsonrpc": "2.0", "id": 8, "method": "ping"}));
let m = loop {
let m = next();
if m["method"] != "notifications/progress" {
break m;
}
};
assert_eq!(m["id"], 8, "no response for the cancelled request: {m}");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
let v = loop {
send(run(9, "renderQueue.list", json!({}), None));
let v: Value = serde_json::from_str(next()["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
// the queue stops rendering a moment before the stopped item's status is updated
if v["rendering"] == false && v["items"][1]["statusLabel"] != "Rendering" {
break v;
}
assert!(std::time::Instant::now() < deadline, "cancel did not stop the job: {v}");
std::thread::sleep(std::time::Duration::from_millis(20));
};
assert!(!b.exists(), "partial output deleted");
assert!(std::path::Path::new(&a).exists(), "completed earlier queue item is retained");
assert_eq!(v["items"][1]["statusLabel"], "User Stopped", "{v}");
drop(in_w);
server.join().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn render_without_token_finishes_after_stdin_closes() {
let dir = std::env::temp_dir().join(format!("ec-mcp-eof-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("complete.mp4");
let mut s = McpServer::new(Backend::headless(Session { exporter: Some(std::sync::Arc::new(SlowExporter)), ..Default::default() }));
let mut input = String::new();
for (id, command, params) in
[(1, "comp.new", json!({"width":16,"height":16})), (2, "renderQueue.add", json!({"output":path})), (3, "renderQueue.render", json!({}))]
{
input.push_str(&format!(
"{}\n",
json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"command_run","arguments":{"id":command,"params":params}}})
));
}
let mut out = Vec::new();
s.serve(std::io::Cursor::new(input), &mut out).unwrap();
let replies: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|line| serde_json::from_str(line).unwrap()).collect();
assert_eq!(replies.len(), 3, "no token means no notifications: {replies:?}");
assert_eq!(replies[2]["result"]["isError"], false);
assert!(path.exists());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn panicked_render_worker_finishes_with_an_error() {
struct BrokenExporter;
impl effectcraft_engine::Exporter for BrokenExporter {
fn formats(&self) -> Vec<effectcraft_engine::project::render_queue::OutputFormat> {
effectcraft_engine::project::render_queue::OutputFormat::ALL.to_vec()
}
fn export(&self, _: &effectcraft_engine::ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result<effectcraft_engine::ExportResult, String> {
panic!("synthetic exporter failure");
}
}
let mut session = Session { exporter: Some(std::sync::Arc::new(BrokenExporter)), ..Default::default() };
session.execute("comp.new", json!({"width":16,"height":16})).unwrap();
session.execute("renderQueue.add", json!({"output":"unused.mp4"})).unwrap();
session.execute("renderQueue.render", json!({"wait":false})).unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
while session.is_rendering() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(!session.is_rendering(), "a panicked worker must not leave an unfinishable job");
session.poll_render();
let queue = session.execute("renderQueue.list", json!({})).unwrap();
assert!(queue.to_string().contains("synthetic exporter failure"), "{queue}");
session.execute("renderQueue.add", json!({"output":"unused-again.mp4"})).unwrap();
let mut server = McpServer::new(Backend::headless(session));
let input =
json!({"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"command_run","arguments":{"id":"renderQueue.render"}}}).to_string() + "\n";
let mut out = Vec::new();
server.serve(std::io::Cursor::new(input), &mut out).unwrap();
let reply: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(reply["result"]["isError"], true, "{reply}");
}
/// `id` is an accepted alias of `command` (#365 made unknown keys an error).
#[test]
fn command_tools_accept_id_as_an_alias() {
let mut s = server();
let d = call_json(&mut s, "describe_command", json!({"id": "comp.new"}));
assert_eq!(d["id"], "comp.new", "{d}");
call_json(&mut s, "execute_command", json!({"id": "comp.new", "params": {"name": "Alias", "width": 64, "height": 36}}));
let (_, err) = call(&mut s, "execute_command", json!({}));
assert!(err, "neither key is an error");
}
/// #500: `--automation-read-root` / `--automation-write-root` confine the files an agent reads
/// and writes: inside the roots works, outside (or relative, `..`, through a symbolic link, by
/// a batch step, by Save to the project's own file, from a script) is refused.
#[test]
fn automation_roots_confine_file_access() {
use crate::confine::Confinement;
let base = std::env::temp_dir().join(format!("ec-mcp-confine-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (read, write, outside) = (base.join("in"), base.join("out"), base.join("elsewhere"));
for d in [&read, &write, &outside] {
std::fs::create_dir_all(d).unwrap();
}
let p = |d: &std::path::Path, f: &str| d.join(f).to_string_lossy().into_owned();
// Unconfined (the default): anywhere.
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, "save_project", json!({"path": p(&outside, "free.ecproj")}));
call_json(&mut s, "save_project", json!({"path": p(&read, "source.ecproj")}));
let mut s = server();
s.backend().confine(Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap());
let denied = |s: &mut McpServer, name: &str, args: Value| {
let (c, err) = call(s, name, args.clone());
assert!(err, "{name} {args} should be refused: {c:?}");
c[0]["text"].as_str().unwrap_or_default().to_string()
};
// Reading: inside a read root, not outside.
call_json(&mut s, "open_project", json!({"path": p(&read, "source.ecproj")}));
let msg = denied(&mut s, "open_project", json!({"path": p(&outside, "free.ecproj")}));
assert!(msg.contains("outside the automation roots"), "{msg}");
// Writing: inside a write root only; not into a read root, not relative, not with `..`.
call_json(&mut s, "save_project", json!({"path": p(&write, "saved.ecproj")}));
assert!(write.join("saved.ecproj").is_file());
denied(&mut s, "save_project", json!({"path": p(&read, "copy.ecproj")}));
denied(&mut s, "save_project", json!({"path": "relative.ecproj"}));
denied(&mut s, "save_project", json!({"path": p(&write, "../elsewhere/up.ecproj")}));
denied(&mut s, "execute_command", json!({"command": "comp.saveFrameAs", "params": {"path": p(&outside, "f.png")}}));
// Any other absolute path a command is given.
denied(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"output": p(&outside, "movie.mp4")}}));
// A batch step.
let steps = json!([{"command": "comp.new", "params": {"name": "B"}}, {"command": "file.saveAs", "params": {"path": p(&outside, "b.ecproj")}}]);
denied(&mut s, "batch", json!({"steps": steps}));
let steps = json!([{"command": "file.saveAs", "params": {"path": "$1.path"}}]);
denied(&mut s, "batch", json!({"steps": steps}));
// Save (and Increment and Save) write the open project's own file: it must be writable.
call_json(&mut s, "open_project", json!({"path": p(&read, "source.ecproj")}));
denied(&mut s, "save_project", json!({}));
denied(&mut s, "execute_command", json!({"command": "file.incrementAndSave"}));
// Rendering the queue writes its items' outputs (by default beside the project).
call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "A"}}));
let queue = call_json(&mut s, "execute_command", json!({"command": "renderQueue.list"}));
assert!(queue["items"][0]["outputPath"].as_str().is_some_and(|o| o.starts_with(&*read.to_string_lossy())), "{queue}");
let msg = denied(&mut s, "execute_command", json!({"command": "renderQueue.render"}));
assert!(msg.contains("cannot write"), "{msg}");
call_json(&mut s, "open_project", json!({"path": p(&write, "saved.ecproj")}));
call_json(&mut s, "save_project", json!({}));
// Through a symbolic link out of a root.
#[cfg(unix)]
{
std::os::unix::fs::symlink(&outside, write.join("link")).unwrap();
denied(&mut s, "save_project", json!({"path": p(&write.join("link"), "escaped.ecproj")}));
assert!(!outside.join("escaped.ecproj").exists());
}
// Scripts can read and write anything: refused.
denied(&mut s, "run_script", json!({"code": "1 + 1"}));
denied(&mut s, "execute_command", json!({"command": "file.runScript", "params": {"path": p(&read, "x.jsx")}}));
// Writes to the files the agent named: none outside.
assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "only the unconfined save");
let _ = std::fs::remove_dir_all(&base);
}

View File

@ -31,10 +31,62 @@ impl ToolDef {
pub fn input_schema(&self) -> Value {
(self.schema)()
}
/// The MCP `Tool` object.
/// The MCP `Tool` object, with a title and annotations (docs/mcp.md).
pub fn descriptor(&self) -> Value {
json!({"name": self.name, "description": self.description, "inputSchema": self.input_schema()})
let title = title_of(self.name);
let read_only = READ_ONLY.contains(&self.name);
let files = WRITES_FILES.contains(&self.name);
json!({
"name": self.name,
"title": title,
"description": self.description,
"inputSchema": self.input_schema(),
"annotations": {
"title": title,
"readOnlyHint": read_only,
"destructiveHint": !read_only && !files,
"idempotentHint": read_only || files,
"openWorldHint": false,
},
})
}
/// The first argument this tool doesn't take, as a message naming the accepted ones.
pub fn unknown_arg(&self, args: &Value) -> Option<String> {
let Some(m) = args.as_object() else { return Some("arguments must be an object".into()) };
let schema = self.input_schema();
let props = schema.get("properties")?.as_object()?;
let bad = m.keys().find(|k| !props.contains_key(*k))?;
let accepted: Vec<&str> = props.keys().map(String::as_str).collect();
Some(format!("unknown argument \"{bad}\" for {}; expected: {}", self.name, accepted.join(", ")))
}
}
/// Tools that only read.
const READ_ONLY: &[&str] = &[
"command_list",
"list_commands",
"describe_command",
"doc_inspect",
"render_preview",
"get_project",
"get_comp",
"get_layer",
"get_property",
"list_effects",
"list_fonts",
"get_state",
"ui_inspect",
"ui_elements",
];
/// Tools that write files without changing the project.
const WRITES_FILES: &[&str] = &["save_project", "render_frame", "screenshot"];
/// `get_project` → "Get project".
fn title_of(name: &str) -> String {
let s = name.replace('_', " ");
let mut c = s.chars();
c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default()
}
/// All tools (headless + bridge-only).
@ -51,12 +103,19 @@ pub fn available(bridge: bool) -> impl Iterator<Item = &'static ToolDef> {
TOOLS.iter().filter(move |t| bridge || !t.bridge_only)
}
/// Tools refused while file access is confined: they drive the live app like a person (menus,
/// shortcuts, Open Recent…) or call control-channel methods directly, past the command checks.
const UNCONFINED_ONLY: &[&str] = &["control", "ui_click", "ui_drag", "ui_key", "ui_type", "ui_set", "script_ui"];
/// Run a tool by name.
pub fn run(b: &mut Backend, name: &str, args: &Value) -> Result<Reply> {
let t = find(name).ok_or_else(|| Error::BadArgs(format!("unknown tool `{name}`")))?;
if t.bridge_only && !b.is_bridge() {
return Err(Error::BadArgs(NEED_BRIDGE.into()));
}
if b.confinement().is_some() && UNCONFINED_ONLY.contains(&name) {
return Err(Error::BadArgs(format!("`{name}` is not available while file access is confined (--automation-read-root/--automation-write-root)")));
}
let empty = json!({});
(t.run)(b, if args.is_null() { &empty } else { args })
}
@ -64,7 +123,7 @@ pub fn run(b: &mut Backend, name: &str, args: &Value) -> Result<Reply> {
// ------------------------------------------------------------------ schema fragments
fn schema(props: Value, required: &[&str]) -> Value {
json!({"type": "object", "properties": props, "required": required})
json!({"type": "object", "properties": props, "required": required, "additionalProperties": false})
}
fn comp_s() -> Value {
json!({"type": ["integer", "string"], "description": "Composition item id or name; omit for the active comp."})
@ -150,7 +209,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")), ("variable", get(a, "variable")), ("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 +222,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![];
@ -256,6 +326,9 @@ fn set_property(b: &mut Backend, a: &Value) -> Result<Reply> {
match time {
Some(t) => {
p["time"] = t.clone();
if let Some(tb) = get(a, "timeBase") {
p["timeBase"] = tb.clone();
}
b.exec("prop.addKey", p)?;
}
None => {
@ -288,6 +361,9 @@ fn add_keyframe(b: &mut Backend, a: &Value) -> Result<Reply> {
if let Some(v) = get(k, "value") {
p["value"] = v.clone();
}
if let Some(tb) = get(a, "timeBase") {
p["timeBase"] = tb.clone();
}
b.exec("prop.addKey", p)?;
}
let info = b.exec("prop.get", obj(&base))?;
@ -297,7 +373,11 @@ fn add_keyframe(b: &mut Backend, a: &Value) -> Result<Reply> {
b.exec("comp.open", json!({"comp": c}))?;
}
let sel: Vec<Value> = keys.iter().map(|k| json!({"layer": info["layer"], "prop": info["uid"], "time": k["time"]})).collect();
b.exec("keys.select", json!({"keys": sel}))?;
let mut select = json!({"keys": sel});
if let Some(tb) = get(a, "timeBase") {
select["timeBase"] = tb.clone();
}
b.exec("keys.select", select)?;
match interp.to_ascii_lowercase().as_str() {
"easyease" | "ease" => b.exec("keys.easyEase", json!({}))?,
"easyeasein" => b.exec("keys.easyEase", json!({"which": "in"}))?,
@ -316,6 +396,9 @@ fn render_frame(b: &mut Backend, a: &Value) -> Result<Reply> {
let f = b.render_with(get(a, "comp"), get(a, "time").and_then(Value::as_f64), max_side, transparent)?;
let mut info = json!({"comp": f.comp, "time": f.time, "width": f.width, "height": f.height});
if let Some(p) = get(a, "path").and_then(Value::as_str) {
if let Some(c) = b.confinement() {
c.check(p, crate::confine::Access::Write)?;
}
std::fs::write(p, &f.png).map_err(|e| Error::Other(format!("cannot write {p}: {e}")))?;
info["path"] = json!(p);
}
@ -409,6 +492,9 @@ fn batch_tool(b: &mut Backend, a: &Value) -> Result<Reply> {
fn screenshot(b: &mut Backend, a: &Value) -> Result<Reply> {
let max_side = get(a, "max_side").and_then(Value::as_u64).unwrap_or(1600) as u32;
let keep = get(a, "path").and_then(Value::as_str).map(str::to_string);
if let (Some(c), Some(p)) = (b.confinement(), &keep) {
c.check(p, crate::confine::Access::Write)?;
}
let path =
keep.clone().unwrap_or_else(|| std::env::temp_dir().join(format!("effectcraft-mcp-shot-{}.png", std::process::id())).to_string_lossy().into_owned());
b.control("ui.screenshot", obj(&[("path", Some(&json!(path))), ("panel", get(a, "panel")), ("id", get(a, "id"))]))?;
@ -474,7 +560,12 @@ static TOOLS: &[ToolDef] = &[
name: "describe_command",
description: "One command in full: id, label, menu path, shortcut, the `params` doc, a JSON Schema of its parameters (`schema`: properties, required, additionalProperties) and whether it can run now (`why` if not). Use before execute_command when unsure of the parameters; execute_command rejects unknown keys.",
bridge_only: false,
schema: || schema(json!({"command": {"type": "string", "description": "Command id, e.g. `layer.newText`."}}), &["command"]),
schema: || {
schema(
json!({"command": {"type": "string", "description": "Command id, e.g. `layer.newText`."}, "id": {"type": "string", "description": "Alias of `command`."}}),
&[],
)
},
run: describe_command,
},
ToolDef {
@ -485,9 +576,10 @@ static TOOLS: &[ToolDef] = &[
schema(
json!({
"command": {"type": "string", "description": "Command id, e.g. `comp.new`, `layer.newText`, `effect.apply` (see list_commands)."},
"id": {"type": "string", "description": "Alias of `command`."},
"params": {"type": "object", "description": "Command parameters as documented by list_commands.", "additionalProperties": true}
}),
&["command"],
&[],
)
},
run: execute_command,
@ -523,7 +615,7 @@ static TOOLS: &[ToolDef] = &[
},
ToolDef {
name: "get_layer",
description: "A layer's property tree (transform, masks, effects, text, shape contents, ...): every node has a `path` usable with get_property/set_property/add_keyframe, plus uid, match id, name, type, value at `time`, keyframes and expression. `flat: true` returns just a list of {path, type, value, keys, expression} (compact).",
description: "A layer's property tree (transform, masks, effects, text, shape contents, ...): leaf properties have a `path` usable with get_property/set_property/add_keyframe, plus uid, match id, name, type, value at `time`, keyframes and expression. Groups contain children; inspect them here, then select a leaf path. `flat: true` returns just a list of {path, type, value, keys, expression} (compact).",
bridge_only: false,
schema: || {
schema(
@ -559,6 +651,7 @@ static TOOLS: &[ToolDef] = &[
json!({
"layer": layer_s(), "path": path_s(), "value": value_s(), "comp": comp_s(),
"time": {"type": "number", "description": "Keyframe time in seconds (layer time; equals comp time unless the layer is offset/stretched)."},
"timeBase": {"type": "string", "enum": ["layer", "comp"], "description": "What `time` counts: layer time (the default) or comp time, as the Timeline shows it."},
"expression": {"type": "string", "description": "Expression text; empty string removes the expression."}
}),
&["layer", "path"],
@ -575,6 +668,7 @@ static TOOLS: &[ToolDef] = &[
json!({
"layer": layer_s(), "path": path_s(), "comp": comp_s(),
"time": {"type": "number", "description": "Key time in seconds (layer time)."},
"timeBase": {"type": "string", "enum": ["layer", "comp"], "description": "What `time` counts: layer time (the default) or comp time, as the Timeline shows it."},
"value": value_s(),
"keys": {"type": "array", "items": {"type": "object", "properties": {"time": {"type": "number"}, "value": value_s()}, "required": ["time"]}, "description": "Several keys at once."},
"interpolation": {"type": "string", "enum": ["linear", "bezier", "hold", "easyEase", "easyEaseIn", "easyEaseOut"]}
@ -586,7 +680,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 +701,22 @@ 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), whether it is a variable font and the name in the font's own language. Narrow with `query` and `variable`; `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."},
"variable": {"type": "boolean", "description": "Only variable fonts (families with variation axes)."},
"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.",
@ -799,4 +909,101 @@ static TOOLS: &[ToolDef] = &[
},
run: batch_tool,
},
ToolDef {
name: "command_list",
description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`).",
bridge_only: false,
schema: || {
schema(
json!({
"filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)."},
"enabled_only": {"type": "boolean", "description": "Only commands that can run right now."},
"schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."}
}),
&[],
)
},
run: list_commands,
},
ToolDef {
name: "command_run",
description: "Run any engine command by id with JSON params; edits are undoable. Examples: comp.new {\"name\":\"Main\",\"width\":1920,\"height\":1080,\"frameRate\":30,\"duration\":5}; layer.newText {\"text\":\"Hello\",\"size\":120,\"fill\":\"#ffffff\"} (returns {\"layer\":id}); layer.newSolid {\"color\":\"#3366ff\"}; layer.newShape; effect.apply {\"layer\":3,\"effect\":\"Gaussian Blur\"}; time.set {\"time\":2.5}; layer.select {\"layers\":[3]}. Layers are referenced by id, \"#n\" or name; times are seconds.",
bridge_only: false,
schema: || {
schema(
json!({
"id": {"type": "string", "description": "Command id, e.g. `comp.new`, `layer.newText`, `effect.apply` (see list_commands)."},
"params": {"type": "object", "description": "Command parameters as documented by list_commands.", "additionalProperties": true}
}),
&["id"],
)
},
run: execute_command,
},
ToolDef {
name: "render_preview",
description: "Render a composition frame (default: the active comp at the current time) and return it as a PNG image, longest side `max_side` (default 960, 0 = full size). Use it to check your work.",
bridge_only: false,
schema: || {
schema(
json!({
"comp": comp_s(),
"time": {"type": "number", "description": "Comp time in seconds (default: current time)."},
"max_side": {"type": "integer", "description": "Longest side in pixels (default 960; 0 = full comp size)."},
"transparent": {"type": "boolean", "description": "Keep the frame's alpha (RGBA PNG, as an RGB + Alpha render writes it) instead of compositing over the comp's background colour."}
}),
&[],
)
},
run: render_frame,
},
ToolDef {
name: "command_batch",
description: "Run commands in order, returning each result or error. Stop at the first error by default; stop_on_error: false continues. Each edit has its own undo step; use batch for an atomic undo group.",
bridge_only: false,
schema: || {
schema(
json!({"steps": {"type": "array", "items": {"type": "object", "properties": {"id": {"type": "string"}, "params": {"type": "object"}}, "required": ["id"], "additionalProperties": false}}, "stop_on_error": {"type": "boolean"}}),
&["steps"],
)
},
run: command_batch,
},
ToolDef {
name: "doc_inspect",
description: "Inspect the project overview and active composition (null when no composition is active).",
bridge_only: false,
schema: || schema(json!({}), &[]),
run: doc_inspect,
},
];
fn doc_inspect(b: &mut Backend, _: &Value) -> Result<Reply> {
let project = b.exec("project.summary", json!({}))?;
let comp = b.exec("comp.info", json!({})).ok();
json_reply(json!({"project": project, "activeComp": comp}))
}
fn command_batch(b: &mut Backend, a: &Value) -> Result<Reply> {
let steps = need(a, "steps")?.as_array().ok_or_else(|| Error::BadArgs("steps must be an array".into()))?;
let stop = get(a, "stop_on_error").and_then(Value::as_bool).unwrap_or(true);
let mut results = Vec::new();
let (mut completed, mut failed) = (0, 0);
for step in steps {
match execute_command(b, step) {
Ok(Reply::Json(result)) => {
completed += 1;
results.push(json!({"ok": true, "result": result}));
}
Ok(Reply::Image { .. }) => return Err(Error::Other("unexpected command image".into())),
Err(e) => {
failed += 1;
results.push(json!({"ok": false, "error": e.to_string()}));
if stop {
break;
}
}
}
}
json_reply(json!({"completed": completed, "failed": failed, "results": results}))
}

View File

@ -71,11 +71,17 @@ fn kinetic_typography() {
// A precomp of everything, time-remapped to play backwards.
let pre = qa.exec("layer.precompose", json!({"layers": ["Title", "Sub"], "name": "Type Pre", "mode": "move"}));
assert!(pre["comp"].is_u64() && pre["layer"].is_u64(), "{pre}");
// Nested switches pass the precomp layer's blur flag down to the animated title.
qa.exec("layer.setSwitch", json!({"layers": [pre["layer"].clone()], "switch": "motionBlur", "value": true}));
qa.exec("layer.enableTimeRemap", json!({"layers": [pre["layer"].clone()], "value": true}));
qa.tool("add_keyframe", json!({"layer": pre["layer"].clone(), "path": "timeRemap", "keys": [{"time": 0, "value": 1.9}, {"time": 1.9, "value": 0}]}));
let fwd = qa.frame(Some(pre["comp"].clone()), 1.6);
// The 1.9 s key sits on frame 23 (1.917 s), so 0.3 s maps to 1.6026 s: compared at that time,
// not 1.6 s, where the moving title (and text drawn at its on-screen scale) differs.
let at = qa.tool("get_property", json!({"layer": pre["layer"].clone(), "path": "timeRemap", "time": 0.3}))["value"].as_f64().unwrap();
assert!((at - 1.6).abs() < 0.01, "time remap 0.3 s → 1.6 s of the precomp: {at}");
let fwd = qa.frame(Some(pre["comp"].clone()), at);
let rev = qa.frame(Some(comp.clone()), 0.3);
assert!(mean_diff(&fwd, &rev) < 1.0, "time remap 0.3 s → 1.6 s of the precomp");
assert!(mean_diff(&fwd, &rev) < 1.0, "the remapped precomp shows its frame at {at} s: {}", mean_diff(&fwd, &rev));
// Render: H.264 and a PNG sequence through the render queue.
let mp4 = qa.path("kinetic.mp4");

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

@ -159,7 +159,8 @@ impl BlendMode {
/// Modes whose formulas are defined for over-range (HDR, 32 bpc) values. The others clamp
/// their inputs to 0..1 before blending, as After Effects documents for 32 bpc projects
/// (in 8/16 bpc everything is already in range).
/// (in 8/16 bpc everything is already in range). Overlay, Exclusion, Luminosity and the
/// Color Burns keep their plain formula above 1 in After Effects (#494).
pub fn supports_hdr(self) -> bool {
matches!(
self,
@ -168,6 +169,8 @@ impl BlendMode {
| BlendMode::DancingDissolve
| BlendMode::Darken
| BlendMode::Multiply
| BlendMode::ColorBurn
| BlendMode::ClassicColorBurn
| BlendMode::LinearBurn
| BlendMode::DarkerColor
| BlendMode::Add
@ -177,6 +180,9 @@ impl BlendMode {
| BlendMode::LighterColor
| BlendMode::Difference
| BlendMode::ClassicDifference
| BlendMode::Overlay
| BlendMode::Exclusion
| BlendMode::Luminosity
| BlendMode::Subtract
| BlendMode::Divide
| BlendMode::StencilAlpha
@ -188,6 +194,17 @@ impl BlendMode {
)
}
/// The source colour a classic mode leaves the backdrop unchanged with. After Effects fades
/// the source toward it by the layer's opacity before applying the classic formula at full
/// strength, rather than mixing the full-strength result by opacity (#494).
fn classic_neutral(self) -> Option<f32> {
match self {
BlendMode::ClassicColorDodge | BlendMode::ClassicDifference => Some(0.0),
BlendMode::ClassicColorBurn => Some(1.0),
_ => None,
}
}
pub fn next(self) -> BlendMode {
let i = BlendMode::ALL.iter().position(|m| *m == self).unwrap_or(0);
BlendMode::ALL[(i + 1) % BlendMode::ALL.len()]
@ -242,11 +259,8 @@ fn separable(mode: BlendMode, cb: f32, cs: f32) -> f32 {
BlendMode::Multiply => cb * cs,
BlendMode::ColorBurn => burn(cb, cs),
BlendMode::ClassicColorBurn => {
if cs <= 0.0 {
0.0
} else {
(1.0 - (1.0 - cb) / cs).max(0.0)
}
// (negative results are kept, as in After Effects)
if cs <= 0.0 { 0.0 } else { 1.0 - (1.0 - cb) / cs }
}
BlendMode::LinearBurn => (cb + cs - 1.0).max(0.0),
BlendMode::Add | BlendMode::LinearDodge => cb + cs,
@ -401,11 +415,19 @@ pub fn blend_pixel(mode: BlendMode, dst: [f32; 4], src: [f32; 4], noise: f32) ->
cs = cs.map(|v| v.clamp(0.0, 1.0));
cb = cb.map(|v| v.clamp(0.0, 1.0));
}
let b = non_separable(mode, cb, cs);
let ao = sa + da - sa * da;
let mut o = [0.0f32; 4];
for i in 0..3 {
o[i] = (1.0 - da) * src[i] + (1.0 - sa) * dst[i] + sa * da * b[i];
if let Some(neutral) = mode.classic_neutral() {
// Fade the source toward the neutral colour by its coverage, then apply the formula.
let b = non_separable(mode, cb, cs.map(|v| neutral + sa * (v - neutral)));
for i in 0..3 {
o[i] = (1.0 - da) * src[i] + da * b[i];
}
} else {
let b = non_separable(mode, cb, cs);
for i in 0..3 {
o[i] = (1.0 - da) * src[i] + (1.0 - sa) * dst[i] + sa * da * b[i];
}
}
o[3] = ao;
o
@ -469,12 +491,41 @@ mod tests {
#[test]
fn hdr_modes() {
// Add and Screen keep over-range values (32 bpc); Overlay clamps its inputs.
// Add and Screen keep over-range values (32 bpc); Soft Light clamps its inputs.
let d = [2.0, 2.0, 2.0, 1.0];
let s = [0.5, 0.5, 0.5, 1.0];
assert!((blend_pixel(BlendMode::Add, d, s, 0.5)[0] - 2.5).abs() < 1e-6);
assert!((blend_pixel(BlendMode::Screen, d, s, 0.5)[0] - 1.5).abs() < 1e-6);
assert!(blend_pixel(BlendMode::Overlay, d, s, 0.5)[0] <= 1.0);
assert!(blend_pixel(BlendMode::SoftLight, d, s, 0.5)[0] <= 1.0);
}
/// #494: After Effects' 32 bpc formulas, measured by the reporter. b = bottom, o = top,
/// k = opacity (premultiplied into the source, as the compositor passes it).
#[test]
fn after_effects_32bpc_formulas() {
let at = |m, b: f32, o: f32, k: f32| blend_pixel(m, opaque(b), [o * k, o * k, o * k, k], 0.5)[0];
let close = |a: f32, e: f32| (a - e).abs() < 1e-5;
// Classic modes fade the source toward their neutral colour first.
for (b, o) in [(0.4, 0.5), (0.8, 0.9), (0.1, 0.3)] {
let k = 0.15;
assert!(close(at(BlendMode::ClassicColorDodge, b, o, k), (b / (1.0 - k * o)).min(1.0)));
assert!(close(at(BlendMode::ClassicDifference, b, o, k), (b - k * o).abs()));
assert!(close(at(BlendMode::ClassicColorBurn, b, o, k), 1.0 - (1.0 - b) / (1.0 - k * (1.0 - o))));
}
// At full opacity the classic formulas are unchanged.
assert!(close(at(BlendMode::ClassicDifference, 0.6, 0.3, 1.0), 0.3));
// Over-range top layers keep the plain formulas.
let (b, o) = (0.7, 3.0);
assert!(close(at(BlendMode::Exclusion, b, o, 1.0), b + o - 2.0 * b * o));
let s = 2.0 * b - 1.0;
assert!(close(at(BlendMode::Overlay, b, o, 1.0), o + s - o * s));
assert!(close(at(BlendMode::ColorBurn, 0.0, 1.35, 0.15), 0.15 * (1.0 - 1.0 / 1.35)));
assert!(close(at(BlendMode::ClassicColorBurn, 0.0, 0.1, 1.0), -9.0), "negative results are kept");
// Luminosity: SetLum + ClipColor on the unclamped top layer.
let l = blend_pixel(BlendMode::Luminosity, [0.2, 0.4, 0.6, 1.0], [2.0, 2.0, 2.0, 1.0], 0.5);
let e = set_lum([0.2, 0.4, 0.6], 2.0);
assert!((0..3).all(|i| close(l[i], e[i])), "{l:?} {e:?}");
assert!((l[0] - 1.0).abs() > 0.1, "not the white of a top layer clamped to 1: {l:?}");
}
#[test]

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

@ -245,10 +245,8 @@ pub fn process(id: &str, params: &Params, buf: &mut [f32], rate: u32, start: f64
}
let sr = rate as f64;
match id {
"ec.audio.backwards" => {
if params.b("swapChannels") {
buf.chunks_exact_mut(2).for_each(|f| f.swap(0, 1));
}
"ec.audio.backwards" if params.b("swapChannels") => {
buf.as_chunks_mut::<2>().0.iter_mut().for_each(|f| f.swap(0, 1));
}
"ec.audio.basstreble" => {
let bass = params.f("bass") / 100.0 * 12.0;
@ -471,7 +469,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 +495,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 +558,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 +694,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

@ -4,7 +4,8 @@
//!
//! Pieces live in "buffer world" space: x, y in buffer pixels, z in buffer pixels away from the
//! viewer (the layer plane is z = 0). A [`Proj`] maps buffer-world points to buffer pixels
//! (homogeneous), so every camera system reduces to one 3×4 matrix.
//! (homogeneous), so every camera system reduces to one 3×4 matrix. CC Particle World sees its
//! particles through the comp camera with the same [`comp_proj`].
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
@ -170,6 +171,29 @@ impl Proj {
(w > self.near).then(|| [r(0) / w, r(1) / w])
}
/// How a buffer-world point `p` moving by `v` is seen: its buffer pixel, its depth W, the
/// buffer pixels per buffer-world pixel of a camera-facing size there, and its velocity in
/// buffer pixels. `None` behind or too close to the camera, or for a degenerate camera.
pub fn view(&self, p: [f64; 3], v: [f64; 3]) -> Option<Seen> {
let row = |i: usize| [self.m[i][0], self.m[i][1], self.m[i][2]];
let (m0, m1, m2) = (row(0), row(1), row(2));
let w = dot(m2, p) + self.m[2][3];
if !(w > self.near && w.is_finite()) {
return None;
}
let at = [(dot(m0, p) + self.m[0][3]) / w, (dot(m1, p) + self.m[1][3]) / w];
// The focal length (pixels per unit at unit depth): |m_i × m2| / |m2|² for K·[R|t]
// scaled by |m2|; the geometric mean of the two image axes.
let l2 = dot(m2, m2);
let scale = (len(cross(m0, m2)) * len(cross(m1, m2))).sqrt() / (l2.sqrt() * w);
if !(scale > 0.0 && scale.is_finite() && at.iter().all(|a| a.is_finite())) {
return None;
}
// d(X / W) = (m_i − at_i · m2) · dp / W.
let vel = [(dot(m0, v) - at[0] * dot(m2, v)) / w, (dot(m1, v) - at[1] * dot(m2, v)) / w];
Some(Seen { at, depth: w, scale, vel })
}
/// The camera's centre in buffer-world space (where the projection collapses), or a point
/// far in front of the layer when it has none (orthographic).
pub fn eye(&self) -> [f64; 3] {
@ -184,6 +208,15 @@ impl Proj {
}
}
/// A point seen through a [`Proj`] (see [`Proj::view`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Seen {
pub at: [f64; 2],
pub depth: f64,
pub scale: f64,
pub vel: [f64; 2],
}
pub(crate) fn inv3(m: &M3) -> Option<M3> {
let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
@ -235,24 +268,10 @@ pub(crate) fn projection(ctx: &EffectCtx, b: &Buf) -> Proj {
None => base,
}
}
2 => {
// Comp Camera: the comp's camera looking at this layer (layer px → layer px).
let base = Proj::simple(c, size * 2.0);
let Some(cam) = ctx.env.host.and_then(|h| h.comp_scene()).and_then(|sc| sc.camera) else { return base };
// buffer world → layer: (p − offset) / s; layer → buffer: · s + offset.
let mut m = [[0.0; 4]; 3];
for i in 0..3 {
for j in 0..3 {
m[i][j] = cam[i][j] / s;
}
m[i][3] = cam[i][3] - (cam[i][0] * b.offset[0] + cam[i][1] * b.offset[1]) / s;
}
let back: M3 = [[s, 0.0, b.offset[0]], [0.0, s, b.offset[1]], [0.0, 0.0, 1.0]];
let p = Proj { m, near: 1e-6 }.then(&back);
// W of the layer centre sets the scale of `near`.
let wc = p.m[2][0] * c[0] + p.m[2][1] * c[1] + p.m[2][3];
Proj { near: (wc.abs() * 0.02).max(1e-9), ..p }
}
2 => match ctx.env.host.and_then(|h| h.comp_scene()).and_then(|sc| sc.camera) {
Some(cam) => comp_proj(&cam, c, b),
None => Proj::simple(c, size * 2.0),
},
_ => {
let zpos = f("cameraPosition/cameraZ", 2.0).max(0.05);
let focal = f("cameraPosition/focalLength", 70.0).max(1.0);
@ -282,6 +301,25 @@ pub(crate) fn projection(ctx: &EffectCtx, b: &Buf) -> Proj {
}
}
/// The comp camera `cam` ([`CompScene::camera`], layer px → layer px) looking at buffer `b`,
/// whose layer centre is buffer pixel `c`.
pub(crate) fn comp_proj(cam: &[[f64; 4]; 3], c: [f64; 2], b: &Buf) -> Proj {
let s = b.scale;
// buffer world → layer: (p − offset) / s; layer → buffer: · s + offset.
let mut m = [[0.0; 4]; 3];
for i in 0..3 {
for j in 0..3 {
m[i][j] = cam[i][j] / s;
}
m[i][3] = cam[i][3] - (cam[i][0] * b.offset[0] + cam[i][1] * b.offset[1]) / s;
}
let back: M3 = [[s, 0.0, b.offset[0]], [0.0, s, b.offset[1]], [0.0, 0.0, 1.0]];
let p = Proj { m, near: 1e-6 }.then(&back);
// W of the layer centre sets the scale of `near`.
let wc = p.m[2][0] * c[0] + p.m[2][1] * c[1] + p.m[2][3];
Proj { near: (wc.abs() * 0.02).max(1e-9), ..p }
}
/// The comp's first light as the effect sees it (layer pixels), from [`crate::EffectHost::comp_scene`].
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct CompLight {
@ -298,19 +336,51 @@ pub struct CompLight {
/// The comp's camera and lights relative to the effect's layer.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct CompScene {
/// Layer pixels (x, y, z) → layer pixels, homogeneous rows X, Y, W: the comp camera's view
/// of the layer brought back into the layer's own pixel grid (identity on the layer plane
/// for the default camera).
/// Effect-space pixels (x, y, z) → effect-space pixels, homogeneous rows X, Y, W: the comp
/// camera's view of the layer brought back into the layer's own pixel grid (identity on the
/// layer plane for the default camera).
pub camera: Option<[[f64; 4]; 3]>,
/// `camera` is an active camera layer's, not the comp's default view (CC Particle World
/// follows only a camera layer).
pub camera_layer: bool,
pub light: Option<CompLight>,
}
impl CompScene {
/// The scene measured from `origin` (in its current coordinates, as [`crate::Buf::rebase`]):
/// a point `p` becomes `p − origin`.
pub fn rebased(mut self, origin: [f64; 2]) -> CompScene {
if let Some(c) = &mut self.camera {
for row in c.iter_mut() {
row[3] += row[0] * origin[0] + row[1] * origin[1];
}
let w = c[2];
for (row, o) in c.iter_mut().zip(origin) {
for (v, wv) in row.iter_mut().zip(w) {
*v -= o * wv;
}
}
}
if let Some(l) = &mut self.light {
l.pos[0] -= origin[0];
l.pos[1] -= origin[1];
}
self
}
}
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
fn len(a: [f64; 3]) -> f64 {
dot(a, a).sqrt()
}
fn norm(a: [f64; 3]) -> [f64; 3] {
let l = dot(a, a).sqrt();
if l < 1e-12 { [0.0, 0.0, -1.0] } else { [a[0] / l, a[1] / l, a[2] / l] }

View File

@ -86,6 +86,7 @@ const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[
("ec.blur.cameralens", &["irisProperties/diffractionFringe", "blurMap/blurMapLayer"]),
("ec.noise.removegrain", &["temporalFiltering/enabled"]),
("ec.distort.liquify", &["viewFreezeAreaMask", "viewMesh"]),
("ec.distort.ccpowerpin", &["unstretch"]),
("ec.matte.refinesoft", &["reduceChatter", "useMotionBlur", "invert"]),
("ec.matte.refinehard", &["reduceChatter", "useMotionBlur", "invert"]),
];
@ -173,7 +174,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");
}
@ -200,6 +202,8 @@ mod tests {
assert!(ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(3))]));
assert!(!ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(9))]));
assert!(!ok("ec.distort.turbulentdisplace", &[("pinning", Value::Enum(9))]));
assert!(ok("ec.distort.ccpowerpin", &[]));
assert!(!ok("ec.distort.ccpowerpin", &[("unstretch", Value::Bool(true))]));
assert!(ok("ec.blur.gaussian", &[]));
}

View File

@ -171,8 +171,7 @@ const CC_COMPOSITE_MODES: [&str; 16] = [
"Stencil Alpha",
];
/// Composites the original over/under/with the effect result. The original is the image as it
/// enters this effect (the host does not expose the pre-effect source).
/// Composites the original stack input over/under/with the result of preceding effects.
fn cc_composite(ctx: &EffectCtx, mut b: Buf) -> Buf {
let op = (ctx.params.f("opacity") as f32 / 100.0).clamp(0.0, 1.0);
let m = ctx.params.e("compositeOriginal");
@ -194,9 +193,10 @@ fn cc_composite(ctx: &EffectCtx, mut b: Buf) -> Buf {
15 => BlendMode::StencilAlpha,
_ => BlendMode::Normal,
};
b.img.data.par_iter_mut().for_each(|px| {
let original = ctx.env.host.and_then(|h| h.original()).map(|o| crate::util::fit_buffer(o, b.img.width, b.img.height, b.offset, b.scale));
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let result = *px;
let orig = result.map(|v| v * op);
let orig = original.as_ref().and_then(|o| o.data.get(i)).copied().unwrap_or(result).map(|v| v * op);
let mut o = if m == 1 { blend_pixel(BlendMode::Normal, orig, result, 0.5) } else { blend_pixel(mode, result, orig, 0.5) };
o[3] = o[3].clamp(0.0, 1.0);
if rgb_only {
@ -446,3 +446,53 @@ pub(crate) mod tests {
assert!((sc.img.get(4, 3)[0] - 0.75).abs() < 1e-5);
}
}
#[cfg(test)]
mod original_tests {
use super::*;
use crate::{EffectEnv, EffectHost, Image, LayerPixels};
struct Original(Buf);
impl EffectHost for Original {
fn original(&self) -> Option<&Buf> {
Some(&self.0)
}
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
}
#[test]
fn composite_preserves_alpha_opacity_and_padded_alignment() {
let spec = crate::find("ec.channel.cccomposite").unwrap();
let mut params = crate::Params { values: spec.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
let host = Original(Buf { img: Image::filled(1, 1, [0.5, 0.0, 0.0, 0.5]), offset: [0.0; 2], scale: 1.0 });
for (mode, opacity, rgb, want) in [
(0, 100.0, false, [0.5, 0.0, 0.125, 0.625]),
(1, 100.0, false, [0.375, 0.0, 0.25, 0.625]),
(0, 50.0, false, [0.25, 0.0, 0.1875, 0.4375]),
(0, 100.0, true, [0.2, 0.0, 0.05, 0.25]),
] {
params.values.insert("compositeOriginal".into(), Value::Enum(mode));
params.values.insert("opacity".into(), num(opacity));
params.values.insert("rgbOnly".into(), Value::Bool(rgb));
let ctx = EffectCtx {
params: &params,
time: 0.0,
layer_size: [1.0; 2],
seed: 0,
adjustment: false,
env: EffectEnv { host: Some(&host), ..Default::default() },
};
let mut input = Buf { img: Image::filled(1, 1, [0.0, 0.0, 0.25, 0.25]), offset: [0.0; 2], scale: 1.0 };
input.pad(1);
let out = cc_composite(&ctx, input);
for (a, b) in out.img.get(1, 1).iter().zip(want) {
assert!((a - b).abs() < 1e-6, "{:?} vs {want:?}", out.img.get(1, 1));
}
assert_eq!(out.img.get(0, 0), [0.0; 4]);
assert_eq!(out.offset, [1.0; 2]);
}
}
}

View File

@ -323,11 +323,40 @@ fn wild(pat: &str, name: &str) -> bool {
}
}
/// Parse a selection: comma-separated names (optionally quoted) or `<hex>` hashes / wildcards.
/// Split a selection at its commas. A name in double or single quotes stays whole, commas
/// included (Blender allows `Green, Glossy`), and a backslash takes the next character
/// literally (`Green\, Glossy`, `\"`). Unquoted text is trimmed; the quotes are dropped.
fn selection_names(sel: &str) -> Vec<String> {
let mut out = vec![];
let mut cur = String::new();
let mut quote: Option<char> = None;
let mut chars = sel.chars();
while let Some(c) = chars.next() {
match (c, quote) {
('\\', _) => {
if let Some(next) = chars.next() {
cur.push(next);
}
}
(c, Some(q)) if c == q => quote = None,
('"' | '\'', None) if cur.trim().is_empty() => {
cur.clear();
quote = Some(c);
}
(',', None) => out.push(std::mem::take(&mut cur).trim().to_string()),
(c, _) => cur.push(c),
}
}
out.push(cur.trim().to_string());
out
}
/// Parse a selection: comma-separated names (optionally quoted, see [`selection_names`]) or
/// `<hex>` hashes / wildcards.
pub fn selection_hashes(sel: &str, manifest: Option<&[(String, u32)]>) -> Vec<u32> {
let mut out = vec![];
for tok in sel.split(',') {
let t = tok.trim().trim_matches('"').trim_matches('\'');
for t in selection_names(sel) {
let t = t.as_str();
if t.is_empty() {
continue;
}
@ -677,6 +706,25 @@ mod tests {
assert_eq!(selection_hashes(&hex, None), vec![crypto_hash("left")]);
}
/// #473: a name with a comma in it (Blender allows `Green, Glossy`) is selected when it is
/// quoted or its comma escaped, instead of being cut into two names that match nothing.
#[test]
fn cryptomatte_selection_keeps_quoted_names_with_commas_whole() {
let manifest = vec![("Green, Glossy".to_string(), 7), ("Green".to_string(), 1), ("Glossy".to_string(), 2), ("ring".to_string(), 3)];
let m = Some(manifest.as_slice());
assert_eq!(selection_hashes(r#""Green, Glossy""#, m), vec![7]);
assert_eq!(selection_hashes("'Green, Glossy', ring", m), vec![7, 3]);
assert_eq!(selection_hashes(r"Green\, Glossy", m), vec![7]);
assert_eq!(selection_hashes(r#"ring , "Green, Glossy""#, m), vec![3, 7]);
// Without a manifest the whole name is hashed.
assert_eq!(selection_hashes(r#""Green, Glossy""#, None), vec![crypto_hash("Green, Glossy")]);
// Plain lists, apostrophes inside names and an unclosed quote still parse.
assert_eq!(selection_hashes("Green, Glossy", m), vec![1, 2]);
assert_eq!(selection_names("Bob's mat, ring"), vec!["Bob's mat", "ring"]);
assert_eq!(selection_names(r#""Green, Glossy"#), vec!["Green, Glossy"]);
assert_eq!(selection_names(r#"say \"hi\""#), vec![r#"say "hi""#]);
}
#[test]
fn fog_and_depth_of_field_follow_depth() {
let h = scene();

View File

@ -43,16 +43,27 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
/// Brightness & Contrast without Use Legacy: tone curves that keep black and white fixed (no
/// clipping). Brightness bends the curve with a power (`b` in -1.5..1.5), contrast is an S
/// curve around mid grey (`c` in -1..1). Values above 1 pass through. The GPU kernel mirrors it.
/// Brightness & Contrast without Use Legacy: pin black and white, then a mild S-curve.
///
/// Fitted to After Effects non-legacy grey ramps (#681, kasper9n oracles). Brightness is a
/// power toward white (`1-(1-v)^e`) or black (`v^e`); contrast uses `g = 1 + 0.53 c` instead of
/// the old `2^(2c)` (that crushed 0.25 to ~8/255 at Contrast 100; AE is ~40/255). Values above 1
/// pass through. The GPU kernel mirrors it.
pub fn bc_modern(v: f32, b: f32, c: f32) -> f32 {
if v >= 1.0 {
return v;
}
let v = v.max(0.0).powf(2f32.powf(-b));
let g = 2f32.powf(2.0 * c);
if v < 0.5 { 0.5 * (2.0 * v).powf(g) } else { 1.0 - 0.5 * (2.0 - 2.0 * v).max(0.0).powf(g) }
let mut v = v.max(0.0);
if b > 0.0 {
v = 1.0 - (1.0 - v).powf(2f32.powf(1.24 * b));
} else if b < 0.0 {
v = v.powf(2f32.powf(-1.32 * b));
}
if c != 0.0 {
let g = (1.0 + 0.53 * c).max(0.05);
v = if v < 0.5 { 0.5 * (2.0 * v).powf(g) } else { 1.0 - 0.5 * (2.0 - 2.0 * v).max(0.0).powf(g) };
}
v
}
fn brightness_contrast(ctx: &EffectCtx, mut b: Buf) -> Buf {
@ -1181,6 +1192,34 @@ mod tests {
assert!((bc(&legacy, 0.0) - 0.5).abs() < 1e-5);
}
/// After Effects non-legacy ramps (kasper9n, #681). B0 C-100 matched B0 C-50 and is omitted.
#[test]
fn brightness_contrast_modern_matches_ae_ramp_samples() {
let u8 = |v: f32| (v * 255.0).round() as i32;
let go = |b: f64, c: f64, x: u8| {
u8(px("ec.color.brightnesscontrast", &[("brightness", num(b)), ("contrast", num(c))], [x as f32 / 255.0, x as f32 / 255.0, x as f32 / 255.0, 1.0])
[0])
};
// (brightness, contrast, input, AE 8-bit)
let samples: &[(f64, f64, u8, u8)] = &[
(0.0, 100.0, 64, 40),
(0.0, 100.0, 128, 129),
(0.0, 100.0, 192, 217),
(0.0, 50.0, 64, 53),
(0.0, 50.0, 192, 205),
(0.0, -50.0, 64, 77),
(100.0, 0.0, 128, 209),
(50.0, 0.0, 128, 171),
(106.0, -18.0, 128, 214),
];
for &(b, c, x, ae) in samples {
let got = go(b, c, x);
assert!((got - ae as i32).abs() <= 10, "B{b} C{c} in {x}: got {got} AE {ae}");
}
// Old g=4 S-curve put Contrast 100 at ~8/255 here.
assert!(go(0.0, 100.0, 64) >= 30);
}
#[test]
fn levels_clips_black_and_white_independently() {
let lv = |vals: &[(&str, Value)], v: f32| px("ec.color.levels", vals, [v, v, v, 1.0])[0];

View File

@ -6,6 +6,7 @@ use effectcraft_project::ParamUi;
use effectcraft_raster::Image;
use rayon::prelude::*;
use crate::util::layer_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) = layer_rect(ctx, &b);
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) = layer_rect(ctx, &b);
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_cover_pad, layer_rect, lerp4, map_xy, pick, 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 {
@ -678,6 +678,9 @@ fn page_turn(ctx: &EffectCtx, mut b: Buf) -> Buf {
fn cc_tiler(ctx: &EffectCtx, mut b: Buf) -> Buf {
let s = (ctx.params.f("scale") / 100.0).max(0.001);
if !ctx.adjustment {
b.pad(layer_cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale));
}
let c = b.to_px(ctx.params.v2("center"));
let blend = (ctx.params.f("blendWithOriginal") / 100.0) as f32;
let (x0, y0, w, h) = layer_rect(ctx, &b);

View File

@ -182,8 +182,9 @@ fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf {
let (x0, y0) = (-ex[1] / 100.0 * lw, -ex[0] / 100.0 * lh);
let (x1, y1) = (lw + ex[2] / 100.0 * lw, lh + ex[3] / 100.0 * lh);
let corners_layer = [ctx.params.v2("topLeft"), ctx.params.v2("topRight"), ctx.params.v2("bottomRight"), ctx.params.v2("bottomLeft")];
// Grow the buffer to hold the pinned quad.
if !ctx.adjustment {
let unstretch = ctx.params.b("unstretch");
// Grow the buffer to hold the pinned quad (Unstretch writes inside the layer rectangle, so it needs no room).
if !ctx.adjustment && !unstretch {
let mut need = 0.0f64;
for c in &corners_layer {
let (px, py) = b.to_px(*c);
@ -198,7 +199,8 @@ fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf {
vec2(p.0, p.1)
});
let persp = (ctx.params.f("perspective") / 100.0).clamp(0.0, 1.0);
let Some(inv) = Mat3::square_to_quad(q).inverse() else { return b };
let fwd = Mat3::square_to_quad(q);
let Some(inv) = fwd.inverse() else { return b };
let src = b.img.clone();
let (sc, off) = (b.scale, b.offset);
let bil = move |u: f64, v: f64| -> (f64, f64) {
@ -208,6 +210,17 @@ fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf {
};
b.img = crate::util::gen_image(src.width, src.height, |x, y| {
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
if unstretch {
// Reverse pinning: the layer rectangle is the target, and the pinned quad is the region that fills it.
let u = ((px - off[0]) / sc - x0) / (x1 - x0);
let v = ((py - off[1]) / sc - y0) / (y1 - y0);
if !(-1e-9..=1.0 + 1e-9).contains(&u) || !(-1e-9..=1.0 + 1e-9).contains(&v) {
return [0.0; 4];
}
let m = fwd.apply(vec2(u, v));
let (bx, by) = bil(u, v);
return src.sample_bilinear(bx + (m.x - bx) * persp, by + (m.y - by) * persp);
}
let m = inv.apply(vec2(px, py));
let (mut u, mut v) = (m.x, m.y);
if persp < 1.0 {
@ -317,9 +330,12 @@ fn cc_slant(ctx: &EffectCtx, mut b: Buf) -> Buf {
// ---------------------------------------------------------------- CC Smear
fn cc_smear(ctx: &EffectCtx, mut b: Buf) -> Buf {
let reach = ctx.params.f("reach") / 100.0;
if !ctx.adjustment {
b.pad(cc_smear_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale));
}
let from = b.to_px(ctx.params.v2("from"));
let to = b.to_px(ctx.params.v2("to"));
let reach = ctx.params.f("reach") / 100.0;
let radius = (ctx.params.f("radius") * b.scale).max(0.5);
let (dx, dy) = (to.0 - from.0, to.1 - from.1);
let l2 = dx * dx + dy * dy;
@ -338,6 +354,21 @@ fn cc_smear(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
/// The transparent pixels to add on every side of a buffer `size` pixels big (with `offset` and
/// `scale`) for CC Smear: it moves pixels by up to Reach × From→To, and they may move out to the
/// layer bounds. A text or shape layer's buffer ends at its content while its bounds are
/// comp-sized, so the smear was cut off at the content's box (#659).
pub fn cc_smear_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64) -> u32 {
let reach = ctx.params.f("reach") / 100.0;
let (from, to) = (ctx.params.v2("from"), ctx.params.v2("to"));
let len = ((to[0] - from[0]).powi(2) + (to[1] - from[1]).powi(2)).sqrt() * scale;
let moved = (reach.abs() * len).ceil();
if !moved.is_finite() || moved <= 0.0 {
return 0;
}
(moved.min(4096.0) as u32).min(crate::util::layer_cover_pad(ctx, size, offset, scale))
}
// ---------------------------------------------------------------- CC Split / CC Split 2
fn split_impl(ctx: &EffectCtx, mut b: Buf, s1: f64, s2: f64) -> Buf {
@ -1096,6 +1127,43 @@ mod tests {
}
}
/// A text layer's buffer: 20 × 10 opaque pixels at (90, 45)–(110, 55) of its comp-sized
/// (200 × 100) layer bounds, after `id` with `set`.
fn on_text(id: &str, set: &[(&str, Value)]) -> Buf {
let s = crate::find(id).unwrap();
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
for (k, v) in set {
assert!(params.values.contains_key(*k), "{id}: {k}");
params.values.insert(k.to_string(), v.clone());
}
let mut im = Image::new(20, 10);
im.data.iter_mut().for_each(|p| *p = [1.0; 4]);
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [200.0, 100.0], seed: 1, adjustment: false, env: Default::default() };
crate::apply(s, &ctx, Buf { img: im, offset: [-90.0, -45.0], scale: 1.0 })
}
/// Alpha at layer point `p` (0 outside the buffer).
fn alpha_at(b: &Buf, p: [f64; 2]) -> f32 {
let (x, y) = b.to_px(p);
b.img.get(x.floor() as i64, y.floor() as i64)[3]
}
/// #659: CC Smear and CC Tiler on a text layer reach past the text's box to the layer bounds
/// (comp-sized, as in After Effects), not cut off where the glyphs end.
#[test]
fn effects_on_text_reach_past_its_box() {
let smear = on_text("ec.distort.ccsmear", &[("from", pt(100.0, 50.0)), ("to", pt(140.0, 50.0)), ("reach", num(100.0)), ("radius", num(30.0))]);
assert!(alpha_at(&smear, [130.0, 50.0]) > 0.9, "the smear is cut off at the text's box");
assert!(smear.img.width < 200, "it grows only as far as pixels move");
let tiles = on_text("ec.distort.cctiler", &[("scale", num(50.0)), ("center", pt(100.0, 50.0))]);
assert!(alpha_at(&tiles, [100.0, 50.0]) > 0.9);
assert!(alpha_at(&tiles, [2.0, 50.0]) > 0.9, "tiles fill the layer bounds");
assert!(alpha_at(&tiles, [50.0, 50.0]) < 0.1);
// A footage layer's buffer is its bounds already: no change.
let o = run("ec.distort.ccsmear", &[], img(40, 30), &[]);
assert_eq!((o.img.width, o.img.height, o.offset), (40, 30, [0.0, 0.0]));
}
#[test]
fn every_warp_style_runs_and_bends() {
let im = img(40, 30);
@ -1127,6 +1195,23 @@ mod tests {
assert!((px[0] - 0.5).abs() < 0.1, "{px:?}");
}
#[test]
fn power_pin_unstretch_expands_pinned_region() {
let im = crate::util::gen_image(24, 16, |_, _| [1.0, 0.0, 0.0, 1.0]);
let inset = [("topLeft", pt(6.0, 4.0)), ("topRight", pt(18.0, 4.0)), ("bottomRight", pt(18.0, 12.0)), ("bottomLeft", pt(6.0, 12.0))];
let opaque = |o: &Buf| o.img.data.iter().filter(|p| p[3] > 0.99).count();
let off = run("ec.distort.ccpowerpin", &inset, im.clone(), &[]);
assert_eq!(opaque(&off), 96);
let mut set = inset.to_vec();
set.push(("unstretch", Value::Bool(true)));
let on = run("ec.distort.ccpowerpin", &set, im.clone(), &[]);
assert_eq!(opaque(&on), 24 * 16);
assert!(on.img.get(0, 0)[3] > 0.99);
// Default corners are the identity either way.
let id = run("ec.distort.ccpowerpin", &[("unstretch", Value::Bool(true))], im.clone(), &[]);
assert!(maxd(&id.img, &im) < 1e-4);
}
#[test]
fn split_opens_a_gap() {
let im = img(40, 30);

View File

@ -0,0 +1,57 @@
//! Deterministic rendered-frame comparison helpers for the fidelity corpus.
use effectcraft_raster::Image;
/// How far a rendered frame is from its reference.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FrameScore {
/// Pixels compared (the frames' width × height).
pub compared_pixels: usize,
/// Mean absolute difference over every channel sample.
pub mean_absolute_error: f64,
/// Largest absolute difference of any channel sample.
pub max_error: f32,
}
/// Compare two premultiplied rendered frames. A size mismatch is reported as an error instead of
/// silently producing a misleading score.
pub fn compare_frames(actual: &Image, reference: &Image) -> Result<FrameScore, String> {
if actual.width != reference.width || actual.height != reference.height || actual.data.len() != reference.data.len() {
return Err(format!("frame size mismatch: {}x{} vs {}x{}", actual.width, actual.height, reference.width, reference.height));
}
let mut sum = 0.0f64;
let mut max = 0.0f32;
for (a, b) in actual.data.iter().zip(&reference.data) {
for (&x, &y) in a.iter().zip(b) {
let error = (x - y).abs();
// A NaN would make the mean NaN, which a `mean > tolerance` check reads as a pass.
if !error.is_finite() {
return Err("frame has a non-finite sample".into());
}
sum += f64::from(error);
max = max.max(error);
}
}
let compared_pixels = actual.data.len();
let samples = compared_pixels.saturating_mul(4).max(1);
Ok(FrameScore { compared_pixels, mean_absolute_error: sum / samples as f64, max_error: max })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reports_pixel_error_and_rejects_size_mismatch() {
let mut actual = Image::filled(2, 1, [0.0, 0.0, 0.0, 1.0]);
let reference = Image::filled(2, 1, [0.0, 0.0, 0.0, 1.0]);
actual.data[1][0] = 0.5;
let score = compare_frames(&actual, &reference).expect("same size");
assert_eq!(score.compared_pixels, 2);
assert!((score.mean_absolute_error - 0.0625).abs() < f64::EPSILON);
assert_eq!(score.max_error, 0.5);
assert!(compare_frames(&actual, &Image::new(1, 1)).is_err());
actual.data[0][1] = f32::NAN;
assert!(compare_frames(&actual, &reference).is_err(), "NaN is an error, not a pass");
}
}

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

@ -15,7 +15,7 @@ use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::util::{Plane, gauss_plane, hash1, layer_or_self, morph_frac, poly_length, premul, smoothstep, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
use crate::{Buf, EffectCtx, EffectSpec, Params, col, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category: "Generate", params, render, gpu: false, float: true }
@ -503,10 +503,8 @@ fn scan_runs(polys: &[(Vec<[f64; 2]>, bool, bool)], fill: u32, ew: f64, d: [f64;
let entering = k % 2 == 0;
let l = ew / sin;
match mode {
0 => {
if entering && k + 1 < xs.len() {
out.push((t, xs[k + 1].0));
}
0 if entering && k + 1 < xs.len() => {
out.push((t, xs[k + 1].0));
}
1 => out.push((t - l * 0.5, t + l * 0.5)),
2 => out.push(if entering { (t, t + l) } else { (t - l, t) }),
@ -1074,23 +1072,66 @@ fn vegas_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan {
// ---------------------------------------------------------------- Write-on / Glue Gun
/// Brush dab history: only the brush position at the current time is known to an effect
/// (parameter histories are not passed in), so the stroke is drawn as the dabs laid along
/// the current position — a single dab when the brush is static. Deterministic.
/// Write-on draws a stroke along the animated Brush Position, as in After Effects: a dab every
/// Brush Spacing seconds from the layer's start up to now, each one lasting Stroke Length
/// seconds (0: for ever). The dabs' positions come from the host's parameters at their times;
/// with Paint Time Properties ▸ Opacity or Brush Time Properties ▸ Size each dab keeps its own
/// opacity or size, else the current ones apply to every dab. (Colour and hardness are one per
/// stroke: the current ones.) Without a host (or dabs) the current dab draws. Deterministic.
fn write_on(ctx: &EffectCtx, mut b: Buf) -> Buf {
let plan = write_on_plan(ctx, &b);
draw_paint(&mut b, &plan);
b
}
/// The most dabs one Write-on frame draws; longer strokes lay them further apart, joined.
const WRITE_ON_MAX_DABS: i64 = 4096;
fn write_on_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan {
let pr = ctx.params;
let q = b.to_px(pr.v2("brushPosition"));
let r = (pr.f("brushSize") * b.scale * 0.5).max(0.05);
let own_opacity = pr.e("paintTimeProperties") == 1;
let own_size = matches!(pr.e("brushTimeProperties"), 1 | 3);
let opacity = |p: &Params| (p.f("brushOpacity") / 100.0).clamp(0.0, 1.0) as f32;
let radius = |p: &Params| (p.f("brushSize") * b.scale * 0.5).max(0.05);
let dab = |p: &Params| {
let q = b.to_px(p.v2("brushPosition"));
([q.0, q.1], radius(if own_size { p } else { pr }), if own_opacity { opacity(p) } else { 1.0 })
};
let mut segs = vec![];
let t = ctx.time;
let spacing = pr.f("brushSpacing").max(0.001);
let life = pr.f("strokeLength").max(0.0);
if let Some(host) = ctx.env.host
&& t.is_finite()
&& t >= 0.0
{
let start = if life > 0.0 { (t - life).max(0.0) } else { 0.0 };
let k0 = (start / spacing - 1e-9).ceil().max(0.0) as i64;
let k1 = (t / spacing + 1e-9).floor().max(0.0) as i64;
let n = k1.saturating_sub(k0).saturating_add(1);
let stride = (n / WRITE_ON_MAX_DABS).saturating_add(1).max(1);
let mut prev: Option<[f64; 2]> = None;
let mut k = k0;
while k <= k1 {
let tk = k as f64 * spacing;
let at = if (tk - t).abs() < 1e-9 { Some(dab(pr)) } else { host.params_at(tk).map(|p| dab(&p)) };
if let Some((q, r, v)) = at {
// Dabs laid further apart than the brush spacing are joined into a line.
let a = if stride > 1 { prev.unwrap_or(q) } else { q };
segs.push(Seg { a, b: q, r, v });
prev = Some(q);
}
k = k.saturating_add(stride);
}
}
if segs.is_empty() {
let (q, r, v) = dab(pr);
segs.push(Seg { a: q, b: q, r, v });
}
PaintPlan {
cov: Coverage::Segs { segs: vec![Seg { a: [q.0, q.1], b: [q.0, q.1], r, v: 1.0 }], hardness: pr.f("brushHardness") / 100.0 },
cov: Coverage::Segs { segs, hardness: pr.f("brushHardness") / 100.0 },
color: pr.color("color"),
opacity: (pr.f("brushOpacity") / 100.0) as f32,
opacity: if own_opacity { 1.0 } else { opacity(pr) },
style: pr.e("paintStyle"),
}
}
@ -1602,26 +1643,55 @@ fn audio_waveform(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
fn compute_fft_mags(samples: &[f64]) -> (usize, f64, Vec<f64>) {
let m = samples.len();
let n = m.next_power_of_two().max(64);
let mut re = vec![0.0; n];
let mut im = vec![0.0; n];
let denom = (m.max(2) - 1) as f64;
for (i, s) in samples.iter().enumerate() {
let w = 0.5 - 0.5 * (2.0 * PI * i as f64 / denom).cos();
if let Some(r) = re.get_mut(i) {
*r = s * w;
}
}
fft(&mut re, &mut im);
let hz_per_bin = AUDIO_RATE as f64 / n as f64;
let scale = 4.0 / m.max(1) as f64;
let num_bins = n / 2 + 1;
let mut mags = Vec::with_capacity(num_bins);
for k in 0..num_bins {
let r = re.get(k).copied().unwrap_or(0.0);
let im_val = im.get(k).copied().unwrap_or(0.0);
let mag = (r * r + im_val * im_val).sqrt() * scale;
mags.push(mag);
}
(n, hz_per_bin, mags)
}
fn spectrum_plan(ctx: &EffectCtx, b: &Buf) -> Option<MarksPlan> {
let pr = ctx.params;
let samples = audio_window(ctx, pr.f("audioDuration"), pr.f("audioOffset"), 0)?;
let n = samples.len().next_power_of_two().max(64);
let mut re = vec![0.0; n];
let mut im = vec![0.0; n];
let m = samples.len();
for (i, s) in samples.iter().enumerate() {
// Hann window.
re[i] = s * (0.5 - 0.5 * (2.0 * PI * i as f64 / (m.max(2) - 1) as f64).cos());
}
fft(&mut re, &mut im);
let (_n, hz_per_bin, mags) = compute_fft_mags(&samples);
let bands = pr.f("frequencyBands").round().clamp(1.0, 4096.0) as usize;
let f0 = pr.f("startFrequency").max(1.0);
let f0 = pr.f("startFrequency").max(0.0);
let f1 = pr.f("endFrequency").max(f0 + 1.0);
let hz_per_bin = AUDIO_RATE as f64 / n as f64;
let mag = |f: f64| {
let k = ((f / hz_per_bin).round() as usize).min(n / 2);
(re[k] * re[k] + im[k] * im[k]).sqrt() * 4.0 / m as f64
let max_bin = mags.len().saturating_sub(1);
let get_band_mag = |fc: f64| -> f64 {
if mags.is_empty() || hz_per_bin <= 0.0 {
return 0.0;
}
let k_float = (fc / hz_per_bin).clamp(0.0, max_bin as f64);
let k0 = (k_float.floor() as usize).min(max_bin.saturating_sub(1));
let k1 = (k0 + 1).min(max_bin);
let frac = (k_float - k0 as f64).clamp(0.0, 1.0);
let m0 = mags.get(k0).copied().unwrap_or(0.0);
let m1 = mags.get(k1).copied().unwrap_or(0.0);
m0 * (1.0 - frac) + m1 * frac
};
let max_h = pr.f("maximumHeight") * b.scale;
let thick = (pr.f("thickness") * b.scale).max(0.1);
let soft = pr.f("softness") / 100.0;
@ -1632,15 +1702,9 @@ fn spectrum_plan(ctx: &EffectCtx, b: &Buf) -> Option<MarksPlan> {
let mut segs = Vec::new();
for i in 0..bands {
let u = if bands > 1 { i as f64 / (bands - 1) as f64 } else { 0.5 };
let fa = f0 * (f1 / f0).powf(i as f64 / bands as f64);
let fb = f0 * (f1 / f0).powf((i + 1) as f64 / bands as f64);
let mut v = 0.0f64;
let k = 4;
for j in 0..k {
v = v.max(mag(fa + (fb - fa) * j as f64 / k as f64));
}
let db = (20.0 * v.max(1e-9).log10() + 60.0) / 60.0;
let hgt = db.clamp(0.0, 1.0) * max_h;
let fc = f0 + (f1 - f0) * u;
let v = get_band_mag(fc);
let hgt = if (v * max_h).is_finite() { (v * max_h).clamp(0.0, max_h.max(0.0)) } else { 0.0 };
let (q, nrm) = base.at(u);
let (ua, ub) = match side {
0 => (0.0, hgt),
@ -2015,6 +2079,37 @@ mod tests {
s
}
#[test]
fn audio_spectrum_linear_frequency_and_linear_amplitude() {
let host = Tone; // Tone produces 440 Hz
let env = EffectEnv { host: Some(&host), comp_time: 1.0, ..Default::default() };
let img = Image::new(200, 100);
let out = run_env(
"ec.generate.audiospectrum",
&[
("audioLayer", Value::Layer(Some(1))),
("startPoint", Value::Vec2([0.0, 50.0])),
("endPoint", Value::Vec2([200.0, 50.0])),
("startFrequency", num(20.0)),
("endFrequency", num(2000.0)),
("frequencyBands", num(100.0)),
("maximumHeight", num(40.0)),
("thickness", num(2.0)),
("softness", num(0.0)),
],
img,
1.0,
env,
);
// Verify 440 Hz tone produces a peak above baseline, while high frequencies have zero bar height
let peak_zone = alpha_sum(&out.img, 36, 10, 48, 48);
let high_zone = alpha_sum(&out.img, 120, 10, 150, 48);
let silent_zone = alpha_sum(&out.img, 170, 10, 195, 48);
assert!(peak_zone > 5.0, "peak zone has energy: {peak_zone}");
assert!(peak_zone > high_zone * 2.0, "linear peak is distinctly taller than far frequencies: {peak_zone} vs {high_zone}");
assert_eq!(silent_zone, 0.0, "silent end has zero height, not boosted by dB scale");
}
#[test]
fn fractal_deterministic_and_has_inside_set() {
let img = Image::new(64, 48);
@ -2150,6 +2245,75 @@ mod tests {
assert_eq!(g.img.get(0, 0), src.get(0, 0));
}
/// #536: an animated Brush Position draws a stroke that stays (Stroke Length 0), expires
/// after Stroke Length, and lays its dabs Brush Spacing seconds apart.
#[test]
fn write_on_accumulates_a_stroke_along_the_animated_brush() {
// Brush Position from [8, 16] at 0 s to [56, 16] at 1 s; red, hard, 6 px.
struct Brush(Vec<(&'static str, Value)>);
impl Brush {
fn at(&self, t: f64) -> Vec<(&'static str, Value)> {
let mut v = self.0.clone();
v.push(("brushPosition", Value::Vec2([8.0 + 48.0 * t.clamp(0.0, 1.0), 16.0])));
v
}
}
impl EffectHost for Brush {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn params_at(&self, t: f64) -> Option<Params> {
let s = crate::find("ec.generate.writeon").unwrap();
let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
for (k, v) in self.at(t) {
p.values.insert(k.to_string(), v);
}
Some(p)
}
}
let render = |extra: &[(&'static str, Value)], t: f64| {
let mut set = vec![("color", col(1.0, 0.0, 0.0)), ("brushSize", num(6.0)), ("brushHardness", num(100.0)), ("brushSpacing", num(0.05))];
set.extend(extra.iter().cloned());
let host = Brush(set);
let env = EffectEnv { host: Some(&host), ..Default::default() };
let at = host.at(t);
run_env("ec.generate.writeon", &at, Image::new(64, 32), t, env).img
};
let on = |img: &Image, x: i64| img.get(x, 16)[3] > 0.9;
let half = render(&[], 0.5);
assert!(on(&half, 8) && on(&half, 20) && on(&half, 32) && !on(&half, 44) && !on(&half, 56));
let end = render(&[], 1.0);
assert!([8, 20, 32, 44, 56].iter().all(|x| on(&end, *x)));
// Stroke Length 0.25 s: only the last quarter second's trail.
let trail = render(&[("strokeLength", num(0.25))], 1.0);
assert!(!on(&trail, 8) && !on(&trail, 32) && on(&trail, 48) && on(&trail, 56));
// Brush Spacing 0.5 s: three separate marks.
let spaced = render(&[("brushSpacing", num(0.5))], 1.0);
assert!(on(&spaced, 8) && on(&spaced, 32) && on(&spaced, 56) && !on(&spaced, 20) && !on(&spaced, 44));
// Paint Time Properties ▸ Opacity: each dab keeps the opacity it was laid with.
struct Fade;
impl EffectHost for Fade {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn params_at(&self, t: f64) -> Option<Params> {
let mut p = Brush(vec![("brushHardness", num(100.0)), ("brushSpacing", num(0.05)), ("paintTimeProperties", Value::Enum(1))]).params_at(t)?;
p.values.insert("brushOpacity".into(), num(if t < 0.5 { 100.0 } else { 20.0 }));
Some(p)
}
}
let p1 = Fade.params_at(1.0).unwrap();
let set: Vec<(&str, Value)> = p1.values.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
let faded = run_env("ec.generate.writeon", &set, Image::new(64, 32), 1.0, EffectEnv { host: Some(&Fade), ..Default::default() }).img;
assert!(faded.get(8, 16)[3] > 0.9 && (faded.get(56, 16)[3] - 0.2).abs() < 0.05, "{:?} {:?}", faded.get(8, 16), faded.get(56, 16));
}
#[test]
fn paint_bucket_fills_enclosed_region_only() {
let mut img = Image::filled(32, 32, [1.0, 1.0, 1.0, 1.0]);

View File

@ -340,6 +340,24 @@ fn wire_removal(ctx: &EffectCtx, mut b: Buf) -> Buf {
let (nx, ny) = (-ty, tx);
let src = b.img.clone();
let soft_w = half * slope as f64;
// Frame Offset: the same pixels from the previous or next frame, resampled into this grid.
let offset = ctx.params.f("frameOffset").round();
let neighbour = if style == 1 && offset != 0.0 {
ctx.env.host.and_then(|h| h.self_at(ctx.time + offset / ctx.fps(), ctx.env.effect_index)).map(|nb| {
if nb.img.width == b.img.width && nb.img.height == b.img.height && nb.offset == b.offset && nb.scale == b.scale {
nb.img
} else {
let k = nb.scale / b.scale.max(1e-9);
crate::util::gen_image(b.img.width, b.img.height, |x, y| {
let lx = (x as f64 + 0.5 - b.offset[0]) * k + nb.offset[0];
let ly = (y as f64 + 0.5 - b.offset[1]) * k + nb.offset[1];
nb.img.sample_bilinear(lx, ly)
})
}
})
} else {
None
};
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (qx, qy) = (x as f64 + 0.5 - ax, y as f64 + 0.5 - ay);
@ -357,7 +375,9 @@ fn wire_removal(ctx: &EffectCtx, mut b: Buf) -> Buf {
let t = ((d + edge) / (2.0 * edge)) as f32;
lerp4(pos(-edge), pos(edge), t.clamp(0.0, 1.0))
}
// Displace: shift the image across the wire from the nearer side.
1 if neighbour.is_some() => neighbour.as_ref().map_or(*px, |n| n.get(x as i64, y as i64)),
// Displace: shift the image across the wire from the nearer side (also Frame Offset
// when no other frame is available).
2 | 1 => {
let s = if d >= 0.0 { d + 2.0 * (half - ad) + 1.0 } else { d - 2.0 * (half - ad) - 1.0 };
let sh = pos(s);
@ -560,6 +580,49 @@ mod tests {
}
}
#[test]
fn wire_removal_frame_offset_uses_neighbour_frame() {
use crate::{EffectHost, LayerPixels};
struct Frames;
impl EffectHost for Frames {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
// Frame n (at 10 fps) is a flat grey of n / 10.
let v = (t * 10.0).round() as f32 / 10.0;
Some(Buf { img: Image::filled(20, 20, [v, v, v, 1.0]), offset: [0.0; 2], scale: 1.0 })
}
}
let mut img = Image::filled(20, 20, [0.5, 0.5, 0.5, 1.0]);
for y in 0..20 {
img.set(10, y, [0.0, 0.0, 0.0, 1.0]);
}
let host = Frames;
let run = |style: u32, off: f64| {
let env = EffectEnv { host: Some(&host), frame_rate: 10.0, ..Default::default() };
let vals = [
("pointA", Value::Vec2([10.5, 0.0])),
("pointB", Value::Vec2([10.5, 20.0])),
("removalStyle", Value::Enum(style)),
("thickness", num(3.0)),
("frameOffset", num(off)),
];
run_fx("ec.key.ccsimplewireremoval", &vals, img.clone(), 0.5, env).img
};
// At 0.5 s (frame 5) offset -1 shows frame 4 and +1 shows frame 6 inside the strip.
let (prev, next) = (run(1, -1.0), run(1, 1.0));
assert!((prev.get(10, 10)[0] - 0.4).abs() < 1e-4, "{:?}", prev.get(10, 10));
assert!((next.get(10, 10)[0] - 0.6).abs() < 1e-4, "{:?}", next.get(10, 10));
// Outside the strip the current frame stays.
assert_eq!(prev.get(2, 10), img.get(2, 10));
// Displace ignores Frame Offset.
assert_eq!(run(2, -1.0).data, run(2, 1.0).data);
}
#[test]
fn inner_outer_key_more_masks_and_cleanup_strokes() {
let sq = |x0: f64, x1: f64| MaskShape { name: String::new(), points: vec![[x0, 2.0], [x1, 2.0], [x1, 8.0], [x0, 8.0]], closed: true, inverted: false };

View File

@ -26,6 +26,7 @@ mod distort2;
mod distort3;
pub mod distort4;
pub mod face_track;
pub mod fidelity;
mod generate;
mod generate2;
mod generate3;
@ -77,7 +78,7 @@ pub use color2::{Curve, auto_correct_settings, color_link_mode, color_link_sampl
pub use color3::OffsetCurve;
pub use distort::transform_shutter;
pub use distort2::{MAGNIFY_MODES, bezier_grid, parse_mesh};
pub use distort3::{RESHAPE_POINTS, ReshapeSetup, SmearSetup, reshape_setup, smear_setup, warp_inverse_map};
pub use distort3::{RESHAPE_POINTS, ReshapeSetup, SmearSetup, cc_smear_pad, reshape_setup, smear_setup, warp_inverse_map};
pub use distort4::liquify_mesh;
use effectcraft_keyframe::Value;
use effectcraft_project::build::Ids;
@ -104,7 +105,7 @@ pub use noise::remove_grain_level;
pub use noise3::{FractalGpu, fractal_gpu};
pub use stylize3::{BumpLight, bump_layer_id};
pub use textfx::{TextLayer, text_layer};
pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, posterized_time, time_frames, timewarp_crop, timewarp_plan};
pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, time_frames, timewarp_crop, timewarp_plan};
// effectcraft-gpu fx_tone.
pub use ocio::color_stabilizer_maps;
// GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes).
@ -238,7 +239,20 @@ pub fn flatten_params(g: &PropGroup, eval: &mut dyn FnMut(&Property) -> Value) -
Params { values }
}
/// A layer image in flight: layer-space point `p` sits at pixel `p * scale + offset`.
/// The warning shown with effect instance `g` (Effect Controls, `effect.warning`), its
/// parameters evaluated by `eval`: settings it can't render as asked, such as an OCIO config it
/// can't read or transforms it passes through. `None` when it renders as asked.
pub fn warning(g: &PropGroup, eval: &mut dyn FnMut(&Property) -> Value) -> Option<String> {
let GroupKind::Effect { effect } = &g.kind else { return None };
// Only the effects that can warn evaluate their parameters.
if !ocio::CUSTOM_CONFIG_FX.contains(&effect.as_str()) {
return None;
}
ocio::warning(effect, &flatten_params(g, eval))
}
/// A layer image in flight: point `p` sits at pixel `p * scale + offset`. Points are in layer
/// space, or in effect space while the layer's effects run (see [`Buf::rebase`]).
#[derive(Clone, Debug, Default)]
pub struct Buf {
pub img: Image,
@ -250,6 +264,27 @@ impl Buf {
pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) {
(p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1])
}
/// Grow the buffer so it covers a `size`-layer-pixel region starting at its offset (see
/// [`cover_plan`]): the whole layer rectangle a sized source's buffer already is. Content
/// keeps its place; the new area is transparent. No-op when the buffer already covers it.
pub fn cover(&mut self, size: [f64; 2]) {
let Some((dx, dy, w, h)) = cover_plan(self.offset, self.scale, size, self.img.width, self.img.height) else { return };
let src = std::mem::replace(&mut self.img, Image::new(w, h));
let (sw, sh) = (src.width as usize, src.height as usize);
for y in 0..sh {
let s = y * sw;
let d = (y + dy as usize) * w as usize + dx as usize;
self.img.data[d..d + sw].copy_from_slice(&src.data[s..s + sw]);
}
self.offset[0] += dx as f64;
self.offset[1] += dy as f64;
}
/// Measure from `origin` (in the current coordinates): a point `p` becomes `p − origin`.
/// Moves between layer space and effect space (the renderer's `effect_bounds`).
pub fn rebase(&mut self, origin: [f64; 2]) {
self.offset[0] += origin[0] * self.scale;
self.offset[1] += origin[1] * self.scale;
}
/// Grow by `pad` transparent pixels on every side.
pub fn pad(&mut self, pad: u32) {
if pad == 0 {
@ -261,6 +296,27 @@ impl Buf {
}
}
/// Where [`Buf::cover`] would take a buffer: shift its pixels by `(dx, dy)` and resize it to
/// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the
/// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer
/// already covers that region unchanged (footage, solids, the comp below an adjustment).
pub fn cover_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32, u32, u32)> {
if scale <= 0.0 || !scale.is_finite() || !offset[0].is_finite() || !offset[1].is_finite() || !size[0].is_finite() || !size[1].is_finite() {
return None;
}
// Region and content, both as pixel ranges in the buffer's own coordinates.
let (rw, rh) = (offset[0] + (size[0] * scale).ceil(), offset[1] + (size[1] * scale).ceil());
let (lo_x, lo_y) = (offset[0].min(0.0).floor(), offset[1].min(0.0).floor());
let (hi_x, hi_y) = (rw.max(width as f64).ceil(), rh.max(height as f64).ceil());
let (nw_f, nh_f) = (hi_x - lo_x, hi_y - lo_y);
if !(nw_f <= 16384.0 && nh_f <= 16384.0) {
return None;
}
let (dx, dy) = ((-lo_x) as u32, (-lo_y) as u32);
let (nw, nh) = (nw_f.round().max(1.0) as u32, nh_f.round().max(1.0) as u32);
(dx != 0 || dy != 0 || nw != width || nh != height).then_some((dx, dy, nw, nh))
}
/// A layer mask at the current time, flattened to a polyline in layer coordinates.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MaskShape {
@ -280,8 +336,14 @@ pub struct LayerPixels {
/// Services the renderer offers effects that look beyond their own pixels.
pub trait EffectHost: Sync {
/// Pixels entering the effect stack, in effect space, before any effects. For an
/// adjustment layer this is the composition below it, in the effect working space.
fn original(&self) -> Option<&Buf> {
None
}
/// Layer `id` of the same composition at the current comp time. `masks_and_effects`
/// selects "Effects & Masks" over "Source". `None` for missing/self/recursive references.
/// selects "Effects & Masks" over "Source". `None` for missing/recursive references; own Source reads are supported.
fn layer(&self, id: u64, masks_and_effects: bool) -> Option<LayerPixels>;
/// `frames` stereo sample frames (interleaved L R …) of layer `id`'s audio starting at comp
/// time `start` seconds, at `rate` Hz. `None` when the layer has no audio.
@ -404,6 +466,10 @@ pub(crate) fn p(id: &'static str, name: &'static str, default: Value, ui: ParamU
pub(crate) fn slider(min: f64, max: f64, smin: f64, smax: f64, decimals: u8) -> ParamUi {
ParamUi::Slider { min, max, slider_min: smin, slider_max: smax, decimals }
}
/// A file parameter offering files with these extensions (Effect Controls' Choose…).
pub(crate) fn file_ui(exts: &[&str]) -> ParamUi {
ParamUi::File { filters: exts.iter().map(|s| s.to_string()).collect() }
}
pub(crate) fn popup(opts: &[&str]) -> ParamUi {
ParamUi::Popup { options: opts.iter().map(|s| s.to_string()).collect() }
}
@ -662,6 +728,7 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
("sourceCrops", "Source Crops"),
// Simulation.
("extras", "Extras"),
("effectCamera", "Effect Camera"),
("wiggle", "Wiggle"),
("light", "Light"),
("shading", "Shading"),
@ -1089,6 +1156,11 @@ pub const GPU_EFFECTS: &[&str] = &[
"ec.obsolete.pathtext",
];
/// Effects that can read [`EffectHost::comp_scene`] (the comp camera or the first comp light):
/// their output changes when the camera, the lights or the layer's own transform move, so the
/// renderer's layer cache folds the comp scene into the key for these.
pub const READS_COMP_SCENE: &[&str] = &["ec.sim.carddance", "ec.sim.shatter", "ec.transition.cardwipe", "ec.sim.caustics", "ec.sim.ccparticleworld"];
/// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated
/// parameters). The renderer's layer cache folds the layer time into the key for these.
/// The `time_dependence_is_declared` test checks the list against every registered effect.
@ -1107,6 +1179,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

@ -236,6 +236,9 @@ fn refresh(pr: &mut Property, ps: &ParamSpec, spec: &EffectSpec) {
}
// A parameter that became internal (superseded by a new one) is hidden.
(_, ParamUi::Hidden) => pr.ui = ParamUi::Hidden,
// A path typed as text, or set only by commands, that got a Choose… button (Apply Color
// LUT's LUT, the OCIO files, #514).
(ParamUi::Hidden | ParamUi::Text, ParamUi::File { .. }) => pr.ui = ps.ui.clone(),
// Same kind of value: take the spec's hints (units, ranges).
(a, b) if std::mem::discriminant(a) == std::mem::discriminant(b) || is_numeric_ui(a) && is_numeric_ui(b) => pr.ui = ps.ui.clone(),
_ => {}
@ -339,6 +342,120 @@ pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, lay
*g != before
}
/// Bring an effect instance saved before project schema 2 into effect space (#227).
///
/// Layers without a source rectangle (shape, text) have comp-sized effect bounds centred on
/// their origin. Effects used to measure positions from the origin, so the default "layer
/// centre" sat at the comp's bottom-right corner; they now measure from the bounds' top-left,
/// as After Effects does. `d` is the bounds' half size: positions the instance holds move by it
/// so they keep their place in the comp (point parameters with their keyframes, Paint stroke
/// paths and clone positions, Puppet mesh seeds and pin rest points, Liquify and Roto Brush
/// strokes). A point parameter still at its default (`layer_size`) and not animated stays: it
/// meant the layer centre and now is. Expression controls keep their values (what they mean is
/// up to the expressions reading them), and expressions are not rewritten.
///
/// `clone_d(source)`: the shift of a Paint clone stroke's source layer (`None` = this layer),
/// whose effect space its Clone Position is in.
pub fn to_effect_space(spec: &EffectSpec, g: &mut PropGroup, layer_size: [f64; 2], d: [f64; 2], clone_d: &dyn Fn(Option<u64>) -> [f64; 2]) {
if spec.id.starts_with("ec.control.") {
return;
}
let defaults: Vec<u64> = spec
.params
.iter()
.filter(|ps| matches!(ps.ui, ParamUi::Point | ParamUi::Point3))
.filter_map(|ps| g.prop(ps.id).filter(|pr| pr.keys.is_empty() && same_point(&pr.value, &default_value(ps, layer_size))).map(|pr| pr.uid))
.collect();
let at = Shift { d, clone_d, defaults: &defaults, effect: spec.id };
at.group(g);
}
/// What [`to_effect_space`] moves, and by how much.
struct Shift<'a> {
d: [f64; 2],
clone_d: &'a dyn Fn(Option<u64>) -> [f64; 2],
/// Point parameters left at their defaults.
defaults: &'a [u64],
effect: &'a str,
}
impl Shift<'_> {
fn group(&self, g: &mut PropGroup) {
// A Paint clone stroke's Clone Position is in its source layer's effect space.
let clone_d = g.get("clone_source").map(|s| (self.clone_d)(s.value.as_layer())).unwrap_or(self.d);
for n in &mut g.children {
match n {
Node::Group(sg) => self.group(sg),
Node::Prop(pr) => {
let d = if pr.match_id == "clone_position" { clone_d } else { self.d };
let kind = match (&pr.ui, pr.match_id.as_str()) {
(ParamUi::Point | ParamUi::Point3, _) if !self.defaults.contains(&pr.uid) => Some(Data::Point),
(ParamUi::Path, _) => Some(Data::Point),
(ParamUi::Hidden, "seed" | "rest") if self.effect == crate::puppet::ID => Some(Data::Point),
(ParamUi::Hidden, "distortionMesh") if self.effect == "ec.distort.liquify" => Some(Data::Liquify),
(ParamUi::Hidden, crate::roto::STROKES) if self.effect == crate::roto::ID => Some(Data::Roto),
_ => None,
};
if let Some(kind) = kind.filter(|_| d != [0.0; 2]) {
shift_value(&mut pr.value, kind, d);
for k in &mut pr.keys {
shift_value(&mut k.value, kind, d);
}
}
}
}
}
}
}
/// How a value holds positions.
#[derive(Clone, Copy)]
enum Data {
/// A point (2D or 3D: x and y move) or a path (its vertices; tangents are relative).
Point,
/// Liquify's Distortion Mesh text (`distort4::LiquifyStroke` lines).
Liquify,
/// Roto Brush's Strokes JSON ([`effectcraft_track::roto::RotoData`]).
Roto,
}
fn shift_value(v: &mut Value, kind: Data, d: [f64; 2]) {
let add = |p: &mut [f64; 2]| *p = [p[0] + d[0], p[1] + d[1]];
match (kind, v) {
(Data::Point, Value::Vec2(p)) => add(p),
(Data::Point, Value::Vec3(p)) => *p = [p[0] + d[0], p[1] + d[1], p[2]],
(Data::Point, Value::Path(sp)) => sp.vertices.iter_mut().for_each(add),
(Data::Liquify, Value::Str(s)) => {
// Lines that don't read as strokes are kept as they are.
let lines: Vec<String> = s
.lines()
.map(|l| match crate::distort4::LiquifyStroke::parse(l) {
Some(mut st) => {
st.points.iter_mut().for_each(add);
st.to_line()
}
None => l.to_string(),
})
.collect();
*s = lines.join("\n");
}
(Data::Roto, Value::Str(s)) => {
// Unreadable data is kept as it is.
if let Ok(mut r) = serde_json::from_str::<effectcraft_track::roto::RotoData>(s) {
r.strokes.iter_mut().flat_map(|st| st.points.iter_mut()).for_each(add);
*s = r.to_json();
}
}
_ => {}
}
}
/// Two point values equal up to float noise.
fn same_point(a: &Value, b: &Value) -> bool {
let (a, b) = (a.components(), b.components());
a.len() == b.len() && a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-6)
}
#[cfg(test)]
mod tests {
use super::*;
@ -380,6 +497,23 @@ mod tests {
assert_eq!(pr.ui, popup.ui);
}
/// #514: Apply Color LUT's LUT was hidden (set only by commands) and the OCIO files were
/// plain text; old projects get the Choose… file parameter, keeping the path.
#[test]
fn hidden_and_text_paths_become_file_parameters() {
for (fx, pid, old) in [("ec.utility.applylut", "lut", ParamUi::Hidden), ("ec.color.ociofile", "file", ParamUi::Text)] {
let mut next = 1;
let (spec, mut g) = instance(fx, &mut next);
let pr = g.get_mut(pid).unwrap();
pr.ui = old;
pr.value = Value::Str("/luts/look.cube".into());
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 100.0]), "{fx}");
let pr = g.get(pid).unwrap();
assert!(matches!(&pr.ui, ParamUi::File { filters } if filters.iter().any(|f| f == "cube")), "{fx}: {:?}", pr.ui);
assert_eq!(pr.value, Value::Str("/luts/look.cube".into()));
}
}
#[test]
fn upgrade_moves_params_into_twirl_down_groups() {
let spec = find(crate::warp_stab::ID).unwrap();
@ -467,4 +601,122 @@ mod tests {
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
assert_eq!(g.get("viewingMode").unwrap().value, Value::Enum(4), "Final Output");
}
/// The shift a 200 × 100 shape layer's effect positions get (#227): half its bounds.
const D: [f64; 2] = [100.0, 50.0];
const SIZE: [f64; 2] = [200.0, 100.0];
fn instance(id: &str, next: &mut u64) -> (&'static EffectSpec, PropGroup) {
let spec = find(id).unwrap();
(spec, instantiate(spec, &mut Ids(next), spec.name, SIZE))
}
fn own(_: Option<u64>) -> [f64; 2] {
D
}
#[test]
fn effect_points_move_into_effect_space_unless_left_at_their_default() {
let mut next = 1;
// Twirl: a set centre (with keyframes) moves; Bulge's default centre stays.
let (spec, mut twirl) = instance("ec.distort.twirl", &mut next);
let c = twirl.get_mut("center").unwrap();
c.value = Value::Vec2([-30.0, -10.0]);
let key = |t: i64, v: Value| {
let mut k = effectcraft_keyframe::Keyframe::new(Default::default(), v);
k.time.0 = t;
k
};
c.keys = vec![key(0, Value::Vec2([0.0, 0.0])), key(1000, Value::Vec2([5.0, -5.0]))];
to_effect_space(spec, &mut twirl, SIZE, D, &own);
let c = twirl.get("center").unwrap();
assert_eq!(c.value, Value::Vec2([70.0, 40.0]));
assert_eq!(c.keys.iter().map(|k| k.value.clone()).collect::<Vec<_>>(), vec![Value::Vec2([100.0, 50.0]), Value::Vec2([105.0, 45.0])]);
let (spec, mut bulge) = instance("ec.distort.bulge", &mut next);
let before = bulge.clone();
to_effect_space(spec, &mut bulge, SIZE, D, &own);
assert_eq!(bulge, before, "the default centre now means the layer centre");
// Expression controls keep their values.
let (spec, mut ctl) = instance("ec.control.point", &mut next);
let pr = ctl.props().find(|p| p.ui == ParamUi::Point).unwrap().uid;
ctl.find_mut(pr).unwrap().value = Value::Vec2([5.0, 5.0]);
to_effect_space(spec, &mut ctl, SIZE, D, &own);
assert_eq!(ctl.find(pr).unwrap().value, Value::Vec2([5.0, 5.0]));
}
#[test]
fn puppet_paint_liquify_and_roto_data_move_into_effect_space() {
let mut next = 1;
// Puppet: mesh seed, pin rest and Position.
let (spec, mut puppet) = instance(crate::puppet::ID, &mut next);
let mut mesh = crate::puppet::mesh_group(&mut Ids(&mut next), "Mesh 1", [1.0, 2.0], &crate::puppet::MeshOpts::default());
let pin = crate::puppet::pin_group(&mut Ids(&mut next), "Puppet Pin 1", crate::puppet::PinKind::Position, [3.0, 4.0]);
mesh.sub_mut("deform").unwrap().children.push(pin.into());
puppet.children.push(mesh.into());
to_effect_space(spec, &mut puppet, SIZE, D, &own);
let mesh = puppet.sub("mesh").unwrap();
assert_eq!(mesh.get("seed").unwrap().value, Value::Vec2([101.0, 52.0]));
let pin = mesh.sub("deform").unwrap().sub("pin").unwrap();
assert_eq!(pin.get("rest").unwrap().value, Value::Vec2([103.0, 54.0]));
assert_eq!(pin.get("position").unwrap().value, Value::Vec2([103.0, 54.0]));
// Paint: the stroke path, its transform, and a clone position in its source layer's space.
let (spec, mut paint) = instance(crate::paint::ID, &mut next);
let s = crate::paint::StrokeSpec {
kind: crate::paint::StrokeKind::Clone,
points: vec![[0.0, 0.0], [10.0, 0.0]],
clone_source: Some(7),
clone_position: [20.0, 20.0],
..Default::default()
};
paint.children.push(crate::paint::stroke_group(&mut Ids(&mut next), "Clone 1", &s).into());
to_effect_space(spec, &mut paint, SIZE, D, &|src| if src == Some(7) { [0.0; 2] } else { D });
let st = paint.sub("clone").unwrap();
let Value::Path(path) = &st.get("path").unwrap().value else { panic!("no path") };
assert_eq!(path.vertices, vec![[100.0, 50.0], [110.0, 50.0]]);
assert_eq!(st.sub("transform").unwrap().get("position").unwrap().value, Value::Vec2([100.0, 50.0]));
assert_eq!(st.sub("stroke_options").unwrap().get("clone_position").unwrap().value, Value::Vec2([20.0, 20.0]), "a footage source");
// Liquify: stroke points move, the clone offset (relative) doesn't; unknown lines stay.
let (spec, mut liquify) = instance("ec.distort.liquify", &mut next);
let line =
crate::distort4::LiquifyStroke { tool: 0, size: 64.0, pressure: 50.0, jitter: 0.0, clone_offset: [3.0, 3.0], points: vec![[1.0, 1.0]] }.to_line();
liquify.get_mut("distortionMesh").unwrap().value = Value::Str(format!(
"{line}
not a stroke"
));
to_effect_space(spec, &mut liquify, SIZE, D, &own);
let Value::Str(text) = &liquify.get("distortionMesh").unwrap().value else { panic!("no mesh") };
let moved = crate::distort4::LiquifyStroke { points: vec![[101.0, 51.0]], ..crate::distort4::LiquifyStroke::parse(&line).unwrap() };
assert_eq!(
text,
&format!(
"{}
not a stroke",
moved.to_line()
)
);
// Roto Brush strokes.
let (spec, mut roto) = instance(crate::roto::ID, &mut next);
let data = effectcraft_track::roto::RotoData {
base: Some(0),
span: [0, 10],
strokes: vec![effectcraft_track::roto::Stroke { kind: effectcraft_track::roto::StrokeKind::Fg, frame: 0, radius: 5.0, points: vec![[2.0, 2.0]] }],
};
roto.get_mut(crate::roto::STROKES).unwrap().value = Value::Str(data.to_json());
to_effect_space(spec, &mut roto, SIZE, D, &own);
let Value::Str(text) = &roto.get(crate::roto::STROKES).unwrap().value else { panic!("no strokes") };
assert_eq!(effectcraft_track::roto::RotoData::from_json(text).strokes[0].points, vec![[102.0, 52.0]]);
}
#[test]
fn layers_with_a_source_rectangle_keep_their_effect_positions() {
let mut next = 1;
let (spec, mut twirl) = instance("ec.distort.twirl", &mut next);
twirl.get_mut("center").unwrap().value = Value::Vec2([3.0, 4.0]);
let before = twirl.clone();
to_effect_space(spec, &mut twirl, SIZE, [0.0; 2], &|_| [0.0; 2]);
assert_eq!(twirl, before);
}
}

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.
@ -423,7 +429,8 @@ pub fn overflow(v: f32, mode: u32) -> f32 {
}
/// Composite grey noise `val` over the original pixel by Blending Mode, Opacity and (ours) Blend
/// With Original.
/// With Original. The noise stays inside the layer's alpha, so a masked solid shows it only within
/// its masks (#939).
pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend_orig: f32) -> [f32; 4] {
let src = [val * opacity, val * opacity, val * opacity, opacity];
let out = match mode {
@ -434,7 +441,7 @@ pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend
}
};
let k = 1.0 - blend_orig;
[0, 1, 2, 3].map(|c| orig[c] * blend_orig + out[c] * k)
[0, 1, 2, 3].map(|c| orig[c] * blend_orig + out[c] * orig[3] * k)
}
fn render(ctx: &EffectCtx, mut b: Buf, turbulent: bool) -> Buf {
@ -568,6 +575,28 @@ mod tests {
assert!(mul.data.iter().any(|p| p[2] > p[0] + 0.01));
}
#[test]
fn noise_stays_inside_the_layer_alpha() {
// #939: a masked solid (transparent outside the mask, a soft edge) filled the whole layer.
let mut img = Image::filled(40, 24, [0.2, 0.4, 0.6, 1.0]);
for (i, p) in img.data.iter_mut().enumerate() {
let a = match i % 40 {
0..10 => 0.0,
10..20 => 0.5,
_ => 1.0,
};
*p = p.map(|c| c * a);
}
for mode in [BLEND_NONE, BLEND_NORMAL, 3] {
let out = run_fx("ec.noise.fractal", &[("blendingMode", Value::Enum(mode))], img.clone(), 0.0, EffectEnv::default()).img;
for (o, p) in img.data.iter().zip(&out.data) {
assert!((o[3] - p[3]).abs() < 1e-6, "mode {mode}: alpha {} became {}", o[3], p[3]);
assert!(p[..3].iter().all(|&c| c <= p[3] + 1e-6), "mode {mode}: premultiplied colour above alpha");
}
assert!(out.data.iter().any(|p| p[3] == 1.0 && (p[0] - 0.2).abs() > 0.01), "mode {mode}: noise drawn inside the mask");
}
}
#[test]
fn sub_settings_and_fractal_types_change_the_result() {
let base = run("ec.noise.fractal", &[]);
@ -587,4 +616,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

@ -17,9 +17,12 @@
//! * CDL maths follows the ASC CDL v1.2 specification (slope, offset, power, then saturation with
//! Rec. 709 luma weights); the "No Clamp" style mirrors power around zero;
//! * File Transform reads `.cube` (Adobe/Resolve), `.3dl` (Autodesk Lustre/Flame integer
//! lattices), `.csp` (cineSpace, with its per-channel pre-LUT shaper) and ASC `.cc` / `.ccc` /
//! `.cdl` XML, with nearest, trilinear or tetrahedral interpolation and an inverse direction
//! (exact for 1D/CDL, iterative for 3D lattices).
//! lattices), `.csp` (cineSpace, with its per-channel pre-LUT shaper), Sony Imageworks
//! `.spi1d` / `.spi3d` / `.spimtx` and ASC `.cc` / `.ccc` / `.cdl` XML, with nearest, trilinear
//! or tetrahedral interpolation and an inverse direction (exact for 1D/CDL/matrices, iterative
//! for 3D lattices);
//! * Configuration ▸ Custom reads a `.ocio` file instead ([`crate::ocio_config`]); what it can't
//! apply is reported by [`warning`].
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
@ -36,13 +39,13 @@ use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider};
type M3 = [[f64; 3]; 3];
const D65: [f64; 2] = [0.3127, 0.3290];
pub(crate) const D65: [f64; 2] = [0.3127, 0.3290];
const ACES_WHITE: [f64; 2] = [0.32168, 0.33767];
const AP0: [[f64; 2]; 3] = [[0.7347, 0.2653], [0.0, 1.0], [0.0001, -0.0770]];
const AP1: [[f64; 2]; 3] = [[0.713, 0.293], [0.165, 0.830], [0.128, 0.044]];
const REC709: [[f64; 2]; 3] = [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]];
const REC2020: [[f64; 2]; 3] = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]];
const P3: [[f64; 2]; 3] = [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]];
pub(crate) const REC709: [[f64; 2]; 3] = [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]];
pub(crate) const REC2020: [[f64; 2]; 3] = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]];
pub(crate) const P3: [[f64; 2]; 3] = [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]];
const IDENTITY: M3 = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
/// Transfer functions (encoded ↔ linear).
@ -52,10 +55,16 @@ pub enum Tf {
Srgb,
Gamma(f64),
AcesCct,
/// ACEScc (Academy S-2014-003), the pure-log grading encoding.
AcesCc,
/// SMPTE ST 2084 with 1.0 = 100 cd/m².
Pq,
}
/// ACEScc: the lowest code value (linear 0 and below) and the end of its toe (linear 2⁻¹⁵).
const ACESCC_MIN: f64 = (-16.0 + 9.72) / 17.52;
const ACESCC_TOE: f64 = (-15.0 + 9.72) / 17.52;
impl Tf {
pub fn decode(self, v: f64) -> f64 {
match self {
@ -75,6 +84,15 @@ impl Tf {
65504.0
}
}
Tf::AcesCc => {
if v < ACESCC_TOE {
(2f64.powf(v * 17.52 - 9.72) - 2f64.powi(-16)) * 2.0
} else if v < (65504f64.log2() + 9.72) / 17.52 {
2f64.powf(v * 17.52 - 9.72)
} else {
65504.0
}
}
Tf::Pq => {
let (m1, m2, c1, c2, c3) = pq_consts();
let e = v.max(0.0).powf(1.0 / m2);
@ -99,6 +117,15 @@ impl Tf {
(v.log2() + 9.72) / 17.52
}
}
Tf::AcesCc => {
if v <= 0.0 {
ACESCC_MIN
} else if v < 2f64.powi(-15) {
((2f64.powi(-16) + v * 0.5).log2() + 9.72) / 17.52
} else {
(v.log2() + 9.72) / 17.52
}
}
Tf::Pq => {
let (m1, m2, c1, c2, c3) = pq_consts();
let y = (v / 100.0).max(0.0).powf(m1);
@ -123,13 +150,13 @@ pub struct Space {
pub raw: bool,
}
const fn sp(name: &'static str, prims: [[f64; 2]; 3], white: [f64; 2], tf: Tf) -> Space {
pub(crate) const fn sp(name: &'static str, prims: [[f64; 2]; 3], white: [f64; 2], tf: Tf) -> Space {
Space { name, prims: Some((prims, white)), tf, raw: false }
}
/// The built-in configuration's colour spaces, in popup order.
const ACES2065_1: Space = sp("ACES2065-1", AP0, ACES_WHITE, Tf::Linear);
const ACES_CG: Space = sp("ACEScg", AP1, ACES_WHITE, Tf::Linear);
pub(crate) const ACES2065_1: Space = sp("ACES2065-1", AP0, ACES_WHITE, Tf::Linear);
pub(crate) const ACES_CG: Space = sp("ACEScg", AP1, ACES_WHITE, Tf::Linear);
const ACES_CCT: Space = sp("ACEScct", AP1, ACES_WHITE, Tf::AcesCct);
pub const SPACES: &[Space] = &[
@ -332,6 +359,92 @@ pub enum FileXform {
Lut { lut: Lut, shaper: Option<[Vec<(f32, f32)>; 3]> },
/// CDL corrections by id (in file order).
Cdl(Vec<(String, Cdl)>),
/// A matrix with offset (Sony Imageworks `.spimtx`), as a MatrixTransform's.
Matrix { m: [f64; 16], offset: [f64; 4] },
}
/// Parse a Sony Imageworks `.spimtx` matrix: three rows of four numbers, three matrix columns
/// and an offset in 16-bit code values (÷ 65535).
pub fn parse_spimtx(text: &str) -> Option<FileXform> {
let v: Vec<f64> = text.split_whitespace().map(|t| t.parse().ok()).collect::<Option<_>>()?;
if v.len() != 12 || v.iter().any(|x| !x.is_finite()) {
return None;
}
let mut m = [0.0; 16];
let mut offset = [0.0; 4];
for (r, row) in v.as_chunks::<4>().0.iter().enumerate() {
m[r * 4..r * 4 + 3].copy_from_slice(&row[..3]);
offset[r] = row[3] / 65535.0;
}
m[15] = 1.0;
Some(FileXform::Matrix { m, offset })
}
/// Parse a Sony Imageworks `.spi1d` 1D LUT: `From <min> <max>`, `Length <n>`, `Components
/// <1 | 3>`, then `{` the values `}`.
pub fn parse_spi1d(text: &str) -> Option<Lut> {
let (mut from, mut len, mut comps) = ([0.0f32, 1.0], 0usize, 1usize);
let mut values: Vec<f32> = vec![];
let mut body = false;
for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
if body {
if line.starts_with('}') {
break;
}
for t in line.split_whitespace() {
values.push(t.parse().ok()?);
}
continue;
}
let mut t = line.split_whitespace();
match t.next()? {
"{" => body = true,
"From" => from = [t.next()?.parse().ok()?, t.next()?.parse().ok()?],
"Length" => len = t.next()?.parse().ok()?,
"Components" => comps = t.next()?.parse().ok()?,
_ => {}
}
}
// A 1D table is interpolated by index, so cap it like the lattices (2²⁴ entries).
if !(2..=1 << 24).contains(&len)
|| !matches!(comps, 1 | 3)
|| values.len() != len.checked_mul(comps)?
|| from[1] <= from[0]
|| from.iter().any(|v| !v.is_finite())
{
return None;
}
let d1 = values.chunks_exact(comps).map(|c| if comps == 3 { [c[0], c[1], c[2]] } else { [c[0]; 3] }).collect();
Some(Lut { n1: len, d1, n3: 0, d3: vec![], dmin: [from[0]; 3], dmax: [from[1]; 3] })
}
/// Parse a Sony Imageworks `.spi3d` lattice: `SPILUT 1.0`, `3 3`, the sizes, then one
/// `r g b R G B` line per lattice point (indices, then the colour).
pub fn parse_spi3d(text: &str) -> Option<Lut> {
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
if !lines.next()?.starts_with("SPILUT") {
return None;
}
lines.next()?;
let size: Vec<usize> = lines.next()?.split_whitespace().map(|t| t.parse().ok()).collect::<Option<_>>()?;
let n = *size.first()?;
if size.len() != 3 || size.iter().any(|s| *s != n) || !(2..=129).contains(&n) {
return None;
}
let mut d3 = vec![[0.0f32; 3]; n * n * n];
let mut seen = 0usize;
for line in lines {
let v: Vec<&str> = line.split_whitespace().collect();
let [r, g, b, cr, cg, cb] = v.as_slice() else { return None };
let (r, g, b): (usize, usize, usize) = (r.parse().ok()?, g.parse().ok()?, b.parse().ok()?);
if r >= n || g >= n || b >= n {
return None;
}
// Our lattice is red fastest.
*d3.get_mut(r + g * n + b * n * n)? = [cr.parse().ok()?, cg.parse().ok()?, cb.parse().ok()?];
seen += 1;
}
(seen == n * n * n).then_some(Lut { n1: 0, d1: vec![], n3: n, d3, dmin: [0.0; 3], dmax: [1.0; 3] })
}
/// Parse an Autodesk `.3dl` lattice (integers, blue fastest; an optional input-mesh line).
@ -470,6 +583,15 @@ pub fn parse_file(text: &str, ext: &str) -> Option<FileXform> {
if ext == "csp" || text.trim_start().starts_with("CSPLUTV100") {
return parse_csp(text).map(|(lut, s)| FileXform::Lut { lut, shaper: Some(s) });
}
if ext == "spimtx" {
return parse_spimtx(text);
}
if ext == "spi1d" || text.trim_start().starts_with("Version") {
return parse_spi1d(text).map(|lut| FileXform::Lut { lut, shaper: None });
}
if ext == "spi3d" || text.trim_start().starts_with("SPILUT") {
return parse_spi3d(text).map(|lut| FileXform::Lut { lut, shaper: None });
}
if ext == "cube" || text.contains("LUT_3D_SIZE") || text.contains("LUT_1D_SIZE") {
return parse_cube(text).map(|lut| FileXform::Lut { lut, shaper: None });
}
@ -514,6 +636,7 @@ impl FileXform {
let o = if inverse { cdl.invert(c64, true) } else { cdl.apply(c64, true) };
o.map(|v| v as f32)
}
FileXform::Matrix { m, offset } => crate::ocio_config::matrix_apply(m, offset, c.map(|v| v as f64), inverse).map(|v| v as f32),
FileXform::Lut { lut, shaper } => {
let fwd = |c: [f32; 3]| {
let c = match shaper {
@ -656,33 +779,86 @@ fn space_at(i: u32) -> &'static Space {
&SPACES[(i as usize).min(SPACES.len() - 1)]
}
/// The custom config chosen by an effect (Configuration ▸ Custom with a readable file).
fn custom_config(ctx: &EffectCtx) -> Option<std::sync::Arc<crate::ocio_config::Config>> {
if ctx.params.e("config") != 1 {
return None;
/// The effects that take Configuration ▸ Custom (a `.ocio` file).
pub(crate) const CUSTOM_CONFIG_FX: [&str; 2] = ["ec.color.ociocolorspace", "ec.color.ociodisplay"];
/// Effect `id`'s transform under Configuration ▸ Custom: the config's conversion from the source
/// space to the destination space (Color Space Transform) or through a display's view (Display
/// Transform), see [`crate::ocio_config`]. `Ok(None)` with the built-in configuration; an error
/// (the pixels pass through) when the config can't be read or has no such colour space.
fn custom_xf(id: &str, pr: &crate::Params) -> Result<Option<crate::ocio_config::Xf>, String> {
use crate::ocio_config::Xf;
if !CUSTOM_CONFIG_FX.contains(&id) || pr.e("config") != 1 {
return Ok(None);
}
crate::ocio_config::load_config(ctx.params.s("configFile"))
let cfg = crate::ocio_config::load_config(pr.s("configFile")).map_err(|e| format!("Can't read the OCIO config: {e}"))?;
// A space by its typed name, else by the built-in popup's name.
let space = |name: &str, popup: u32| {
let name = if name.trim().is_empty() { space_at(popup).name } else { name.trim() };
cfg.space(name).ok_or_else(|| format!("The OCIO config has no color space `{name}`"))
};
let a = space(pr.s("sourceName"), pr.e("source"))?;
let inverse = pr.e("direction") == 1;
if id == "ec.color.ociodisplay" {
let (dn, vn) = display_view_names(pr);
let xf = cfg.display_path(a, dn, vn);
return Ok(Some(if inverse { Xf::Inverse(Box::new(xf)) } else { xf }));
}
let z = space(pr.s("destinationName"), pr.e("destination"))?;
// Inverse: the conversion the other way (OCIO's inverse ColorSpaceTransform).
Ok(Some(if inverse { cfg.path(z, a) } else { cfg.path(a, z) }))
}
/// A space of a custom config by its typed name, else by the built-in popup's name.
fn custom_space<'c>(cfg: &'c crate::ocio_config::Config, name: &str, popup: u32) -> Option<&'c crate::ocio_config::ConfigSpace> {
cfg.space(name).or_else(|| cfg.space(space_at(popup).name))
/// The display and view a Display Transform names: the typed names, else the popups'.
fn display_view_names(pr: &crate::Params) -> (&str, &str) {
let display = DISPLAYS.get(pr.e("display") as usize).map_or("", |d| d.0);
let view = VIEWS.get(pr.e("view") as usize).copied().unwrap_or("");
let typed = |k: &str, popup| if pr.s(k).trim().is_empty() { popup } else { pr.s(k).trim() };
(typed("displayName", display), typed("viewName", view))
}
/// Effect Controls' warning for effect `id`: a custom OCIO configuration that can't be read, has
/// no such colour space, display or view, or uses transforms EffectCraft can't apply (colours
/// pass through those steps, #410). `None` when the effect renders as asked.
pub fn warning(id: &str, pr: &crate::Params) -> Option<String> {
let xf = match custom_xf(id, pr) {
Ok(x) => x?,
Err(e) => return Some(format!("{e}: the colors pass through.")),
};
let mut notes = vec![];
if id == "ec.color.ociodisplay"
&& let Ok(cfg) = crate::ocio_config::load_config(pr.s("configFile"))
{
let (dn, vn) = display_view_names(pr);
if let Some((d, v)) = cfg.view(dn, vn)
&& (!d.eq_ignore_ascii_case(dn) || !v.name.eq_ignore_ascii_case(vn))
{
notes.push(format!("The OCIO config has no display `{dn}` with a view `{vn}`: showing {d} / {}.", v.name));
}
}
let missing = xf.unsupported_list();
if !missing.is_empty() {
notes.push(format!("Not supported, the colors pass through: {}.", missing.join(", ")));
}
(!notes.is_empty()).then(|| notes.join(" "))
}
/// Configuration ▸ Custom: render through the config's transform. `false` with the built-in
/// configuration (the caller renders it).
fn custom_render(id: &str, ctx: &EffectCtx, b: &mut Buf) -> bool {
match custom_xf(id, ctx.params) {
Ok(None) => false,
Ok(Some(xf)) => {
map_px(b, |c| xf.apply(c.map(|v| v as f64), false).map(|v| v as f32));
true
}
// A config that can't be read, or an unknown space: the pixels pass through.
Err(_) => true,
}
}
fn ocio_cst(ctx: &EffectCtx, mut b: Buf) -> Buf {
// Custom configuration that can't be read: pixels pass through.
if ctx.params.e("config") == 1 && custom_config(ctx).is_none() {
return b;
}
if let Some(cfg) = custom_config(ctx) {
let (Some(a), Some(z)) = (
custom_space(&cfg, ctx.params.s("sourceName"), ctx.params.e("source")),
custom_space(&cfg, ctx.params.s("destinationName"), ctx.params.e("destination")),
) else {
return b;
};
let (a, z) = if ctx.params.e("direction") == 1 { (z, a) } else { (a, z) };
map_px(&mut b, |c| cfg.convert(a, z, c.map(|v| v as f64)).map(|v| v as f32));
if custom_render("ec.color.ociocolorspace", ctx, &mut b) {
return b;
}
let (a, z) = (space_at(ctx.params.e("source")), space_at(ctx.params.e("destination")));
@ -720,19 +896,7 @@ fn ocio_cdl(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
fn ocio_display(ctx: &EffectCtx, mut b: Buf) -> Buf {
// Custom configuration that can't be read: pixels pass through.
if ctx.params.e("config") == 1 && custom_config(ctx).is_none() {
return b;
}
if let Some(cfg) = custom_config(ctx) {
// The display's view names a colour space of the config.
let display = DISPLAYS.get(ctx.params.e("display") as usize).map_or("", |d| d.0);
let view = VIEWS.get(ctx.params.e("view") as usize).copied().unwrap_or("");
let dn = if ctx.params.s("displayName").is_empty() { display } else { ctx.params.s("displayName") };
let vn = if ctx.params.s("viewName").is_empty() { view } else { ctx.params.s("viewName") };
let (Some(a), Some(z)) = (custom_space(&cfg, ctx.params.s("sourceName"), ctx.params.e("source")), cfg.view_space(dn, vn)) else { return b };
let (a, z) = if ctx.params.e("direction") == 1 { (z, a) } else { (a, z) };
map_px(&mut b, |c| cfg.convert(a, z, c.map(|v| v as f64)).map(|v| v as f32));
if custom_render("ec.color.ociodisplay", ctx, &mut b) {
return b;
}
let input = space_at(ctx.params.e("source"));
@ -899,9 +1063,26 @@ pub enum ColorOp {
Pow([f32; 3]),
/// LogTransform / LogAffineTransform, forward or inverse (as `ocio_config::Xf::apply`).
LogAffine { base: f32, log_slope: [f32; 3], log_offset: [f32; 3], lin_slope: [f32; 3], lin_offset: [f32; 3], inverse: bool },
/// ExponentWithLinearTransform, forward (decode) or inverse (as
/// [`crate::ocio_config::moncurve`]).
MonCurve { gamma: [f32; 3], offset: [f32; 3], inverse: bool, mirror: bool },
/// Clamp each channel to `lo..=hi` (a clamping RangeTransform; ±`f32::MAX` = unbounded).
Clamp { lo: [f32; 3], hi: [f32; 3] },
}
/// The ops of a LUT / CDL file transform (as [`FileXform::apply`]).
/// The ops of a matrix-and-offset transform (MatrixTransform, `.spimtx`), forward or inverse
/// (as [`crate::ocio_config::matrix_apply`]).
fn matrix_ops(m: &[f64; 16], offset: &[f64; 4], inverse: bool, ops: &mut Vec<ColorOp>) {
if !inverse {
let m = [0, 1, 2].map(|r| [m[r * 4] as f32, m[r * 4 + 1] as f32, m[r * 4 + 2] as f32]);
ops.push(ColorOp::Affine { m, pre: [0.0; 3], post: [offset[0] as f32, offset[1] as f32, offset[2] as f32] });
} else if let Some(inv) = crate::ocio_config::mat3_inverse(m) {
let m = inv.map(|r| r.map(|v| v as f32));
ops.push(ColorOp::Affine { m, pre: [-offset[0] as f32, -offset[1] as f32, -offset[2] as f32], post: [0.0; 3] });
}
}
/// The ops of a LUT / CDL / matrix file transform (as [`FileXform::apply`]).
fn file_ops(x: &FileXform, interp: u32, inverse: bool, ccc_id: &str, ops: &mut Vec<ColorOp>) {
match x {
FileXform::Cdl(list) => {
@ -911,6 +1092,7 @@ fn file_ops(x: &FileXform, interp: u32, inverse: bool, ccc_id: &str, ops: &mut V
FileXform::Lut { lut, shaper } => {
ops.push(ColorOp::Lut { lut: Arc::new(lut.clone()), shaper: shaper.clone().map(Arc::new), interp, inverse });
}
FileXform::Matrix { m, offset } => matrix_ops(m, offset, inverse, ops),
}
}
@ -920,16 +1102,12 @@ fn config_xf_ops(x: &crate::ocio_config::Xf, inverse: bool, ops: &mut Vec<ColorO
let f3 = |v: &[f64; 3]| v.map(|x| x as f32);
match x {
Xf::Inverse(x) => config_xf_ops(x, !inverse, ops),
Xf::Matrix { m, offset } => {
if !inverse {
let m = [0, 1, 2].map(|r| [m[r * 4] as f32, m[r * 4 + 1] as f32, m[r * 4 + 2] as f32]);
ops.push(ColorOp::Affine { m, pre: [0.0; 3], post: [offset[0] as f32, offset[1] as f32, offset[2] as f32] });
} else if let Some(inv) = crate::ocio_config::mat3_inverse(m) {
let m = inv.map(|r| r.map(|v| v as f32));
ops.push(ColorOp::Affine { m, pre: [-offset[0] as f32, -offset[1] as f32, -offset[2] as f32], post: [0.0; 3] });
}
}
Xf::Matrix { m, offset } => matrix_ops(m, offset, inverse, ops),
Xf::File { xf: Some(x), interp, ccc, .. } => file_ops(x, *interp, inverse, ccc, ops),
Xf::ExponentLinear { gamma, offset, mirror } => ops.push(ColorOp::MonCurve { gamma: f3(gamma), offset: f3(offset), inverse, mirror: *mirror }),
// A BuiltinTransform's curve: forward decodes.
Xf::Curve(tf) => ops.push(if inverse { ColorOp::Encode(*tf) } else { ColorOp::Decode(*tf) }),
Xf::Named(_) => {}
Xf::File { xf: None, .. } | Xf::Unsupported(_) => {}
Xf::Exponent(e) => ops.push(ColorOp::Pow([0, 1, 2].map(|k| (if inverse { 1.0 / e[k].max(1e-9) } else { e[k] }) as f32))),
Xf::Log { base } => {
@ -944,11 +1122,14 @@ fn config_xf_ops(x: &crate::ocio_config::Xf, inverse: bool, ops: &mut Vec<ColorO
inverse,
}),
Xf::Cdl(cdl) => ops.push(ColorOp::Cdl { cdl: *cdl, clamp: true, inverse }),
Xf::Range { min_in, max_in, min_out, max_out } => {
let (a0, a1, b0, b1) = if inverse { (*min_out, *max_out, *min_in, *max_in) } else { (*min_in, *max_in, *min_out, *max_out) };
let k = if (a1 - a0).abs() > 1e-12 { (b1 - b0) / (a1 - a0) } else { 0.0 };
Xf::Range(r) => {
let (k, off, lo, hi) = r.parts(inverse);
let k = k as f32;
ops.push(ColorOp::Affine { m: [[k, 0.0, 0.0], [0.0, k, 0.0], [0.0, 0.0, k]], pre: [-a0 as f32; 3], post: [b0 as f32; 3] });
ops.push(ColorOp::Affine { m: [[k, 0.0, 0.0], [0.0, k, 0.0], [0.0, 0.0, k]], pre: [0.0; 3], post: [off as f32; 3] });
if lo.is_finite() || hi.is_finite() {
let bound = |v: f64| (v as f32).clamp(-f32::MAX, f32::MAX);
ops.push(ColorOp::Clamp { lo: [bound(lo); 3], hi: [bound(hi); 3] });
}
}
Xf::Group(list) => {
if inverse {
@ -960,23 +1141,6 @@ fn config_xf_ops(x: &crate::ocio_config::Xf, inverse: bool, ops: &mut Vec<ColorO
}
}
/// The ops of a custom config's conversion from space `a` to space `z` (as `Config::convert`).
fn config_ops(a: &crate::ocio_config::ConfigSpace, z: &crate::ocio_config::ConfigSpace, ops: &mut Vec<ColorOp>) {
if a.is_data || z.is_data || a.name == z.name {
return;
}
match (&a.to_ref, &a.from_ref) {
(Some(x), _) => config_xf_ops(x, false, ops),
(None, Some(x)) => config_xf_ops(x, true, ops),
_ => {}
}
match (&z.from_ref, &z.to_ref) {
(Some(x), _) => config_xf_ops(x, false, ops),
(None, Some(x)) => config_xf_ops(x, true, ops),
_ => {}
}
}
/// How a colour program meets premultiplied pixels.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Straight {
@ -1004,6 +1168,16 @@ fn xform_ops(x: &Xform, ops: &mut Vec<ColorOp>) {
pub fn color_program(id: &str, ctx: &EffectCtx) -> Option<(Vec<ColorOp>, Straight)> {
let pr = ctx.params;
let mut ops = vec![];
// Configuration ▸ Custom compiles the config's transforms (a config that can't be read, or
// names unknown spaces, passes the pixels through).
match custom_xf(id, pr) {
Ok(None) => {}
Ok(Some(xf)) => {
config_xf_ops(&xf, false, &mut ops);
return Some((ops, Straight::Unpremul));
}
Err(_) => return Some((ops, Straight::Unpremul)),
}
match id {
"ec.utility.applylut" => {
if let Some(lut) = crate::utility::load_lut(pr.s("lut")) {
@ -1012,18 +1186,6 @@ pub fn color_program(id: &str, ctx: &EffectCtx) -> Option<(Vec<ColorOp>, Straigh
return Some((ops, Straight::Divide));
}
"ec.color.ociocolorspace" => {
if pr.e("config") == 1 {
// A custom config (one that can't be read, or names unknown spaces: the pixels
// pass through).
if let Some(cfg) = custom_config(ctx)
&& let (Some(a), Some(z)) =
(custom_space(&cfg, pr.s("sourceName"), pr.e("source")), custom_space(&cfg, pr.s("destinationName"), pr.e("destination")))
{
let (a, z) = if pr.e("direction") == 1 { (z, a) } else { (a, z) };
config_ops(a, z, &mut ops);
}
return Some((ops, Straight::Unpremul));
}
let (a, z) = (space_at(pr.e("source")), space_at(pr.e("destination")));
let (a, z) = if pr.e("direction") == 1 { (z, a) } else { (a, z) };
if a.name != z.name {
@ -1037,19 +1199,6 @@ pub fn color_program(id: &str, ctx: &EffectCtx) -> Option<(Vec<ColorOp>, Straigh
}
}
"ec.color.ociodisplay" => {
if pr.e("config") == 1 {
if let Some(cfg) = custom_config(ctx) {
let display = DISPLAYS.get(pr.e("display") as usize).map_or("", |d| d.0);
let view = VIEWS.get(pr.e("view") as usize).copied().unwrap_or("");
let dn = if pr.s("displayName").is_empty() { display } else { pr.s("displayName") };
let vn = if pr.s("viewName").is_empty() { view } else { pr.s("viewName") };
if let (Some(a), Some(z)) = (custom_space(&cfg, pr.s("sourceName"), pr.e("source")), cfg.view_space(dn, vn)) {
let (a, z) = if pr.e("direction") == 1 { (z, a) } else { (a, z) };
config_ops(a, z, &mut ops);
}
}
return Some((ops, Straight::Unpremul));
}
let input = space_at(pr.e("source"));
let (display, view, inverse) = (pr.e("display") as usize, pr.e("view") as usize, pr.e("direction") == 1);
let (_, prims, tf) = DISPLAYS[display.min(DISPLAYS.len() - 1)];
@ -1278,7 +1427,7 @@ pub fn specs() -> Vec<EffectSpec> {
cc,
vec![
p("config", "Configuration", Value::Enum(0), popup(CONFIGS)),
p("configFile", "Config File (.ocio)", Value::Str(String::new()), ParamUi::Text),
p("configFile", "Config File (.ocio)", Value::Str(String::new()), crate::file_ui(&["ocio"])),
space_param("source", "Source", lin709),
p("sourceName", "Source (config name)", Value::Str(String::new()), ParamUi::Text),
space_param("destination", "Destination", srgb),
@ -1293,7 +1442,7 @@ pub fn specs() -> Vec<EffectSpec> {
cc,
vec![
p("config", "Configuration", Value::Enum(0), popup(CONFIGS)),
p("configFile", "Config File (.ocio)", Value::Str(String::new()), ParamUi::Text),
p("configFile", "Config File (.ocio)", Value::Str(String::new()), crate::file_ui(&["ocio"])),
space_param("source", "Input Color Space", lin709),
p("sourceName", "Source (config name)", Value::Str(String::new()), ParamUi::Text),
p("display", "Display Device", Value::Enum(0), popup(&DISPLAYS.iter().map(|d| d.0).collect::<Vec<_>>())),
@ -1310,7 +1459,7 @@ pub fn specs() -> Vec<EffectSpec> {
"OCIO File Transform",
cc,
vec![
p("file", "File", Value::Str(String::new()), ParamUi::Text),
p("file", "File", Value::Str(String::new()), crate::file_ui(&["cube", "3dl", "csp", "cc", "ccc", "cdl", "spi1d", "spi3d", "spimtx"])),
p("cccId", "CCC ID", Value::Str(String::new()), ParamUi::Text),
p("interpolation", "Interpolation", Value::Enum(3), popup(&["Nearest", "Linear", "Tetrahedral", "Best"])),
direction(),
@ -1474,6 +1623,44 @@ mod tests {
v
}
/// Sony Imageworks `.spi1d`, `.spi3d` and `.spimtx` (Blender's configs use them, #410), and
/// malformed ones rejected without panicking.
#[test]
fn spi_files_parse() {
let spi1d = "Version 1\nFrom 0.0 2.0\nLength 3\nComponents 1\n{\n0.0\n0.25\n1.0\n}\n";
let x = parse_file(spi1d, "spi1d").unwrap();
let o = x.apply([1.0, 0.5, 2.0], 1, false, "");
assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.125).abs() < 1e-6 && (o[2] - 1.0).abs() < 1e-6, "{o:?}");
let back = x.apply(o, 1, true, "");
assert!((back[0] - 1.0).abs() < 1e-5 && (back[2] - 2.0).abs() < 1e-5, "{back:?}");
let three = parse_spi1d("Version 1\nFrom 0 1\nLength 2\nComponents 3\n{\n0 0 0\n1 0.5 2\n}\n").unwrap();
assert_eq!(three.d1, vec![[0.0; 3], [1.0, 0.5, 2.0]]);
let mut spi3d = String::from("SPILUT 1.0\n3 3\n2 2 2\n");
for (r, g, b) in (0..8).map(|i| (i & 1, (i >> 1) & 1, i >> 2)) {
spi3d += &format!("{r} {g} {b} {} {} {}\n", r as f32 * 0.5, g, b);
}
let x = parse_file(&spi3d, "spi3d").unwrap();
let o = x.apply([1.0, 0.5, 0.25], 1, false, "");
assert!((o[0] - 0.5).abs() < 1e-6 && (o[1] - 0.5).abs() < 1e-6 && (o[2] - 0.25).abs() < 1e-6, "{o:?}");
let m = parse_file("2 0 0 6553.5\n0 1 0 0\n0 0 1 0\n", "spimtx").unwrap();
let o = m.apply([0.25, 0.5, 1.0], 1, false, "");
assert!((o[0] - 0.6).abs() < 1e-6 && o[1] == 0.5 && o[2] == 1.0, "{o:?}");
assert!((m.apply(o, 1, true, "")[0] - 0.25).abs() < 1e-6);
for bad in [
"Version 1\nFrom 0 1\nLength 99999999999\nComponents 1\n{\n0\n}\n",
"Version 1\nFrom 1 0\nLength 2\nComponents 1\n{\n0\n1\n}\n",
"Version 1\nFrom 0 1\nLength 3\nComponents 1\n{\n0\n1\n}\n",
"Version 1\nFrom 0 1\nLength 2\nComponents 2\n{\n0 0\n1 1\n}\n",
] {
assert!(parse_spi1d(bad).is_none(), "{bad}");
}
assert!(parse_spi3d("SPILUT 1.0\n3 3\n4000 4000 4000\n").is_none());
assert!(parse_spi3d("SPILUT 1.0\n3 3\n2 2 2\n9 0 0 1 1 1\n").is_none());
assert!(parse_spi3d("SPILUT 1.0\n3 3\n2 2 2\n0 0 0 1 1 1\n").is_none(), "incomplete lattice");
assert!(parse_spimtx("1 0 0\n0 1 0\n").is_none());
assert!(parse_spimtx("NaN 0 0 0 0 1 0 0 0 0 1 0").is_none());
}
#[test]
fn lut_files_parse_and_interpolate() {
// .cube with a non-linear lattice: trilinear between lattice points.

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@
//! ```text
//! Paint (effect `ec.paint.paint`; Paint on Transparent)
//! Brush 1 (match `brush` | `clone` | `eraser`)
//! Path (the recorded stroke, layer space, animatable)
//! Path (the recorded stroke, effect space, animatable)
//! Stroke Options (Start, End, Color, Diameter, Angle, Hardness, Roundness, Spacing,
//! Channels, Opacity, Flow; clone: Clone Source, Clone Position,
//! Clone Time / Clone Time Shift)
@ -162,7 +162,7 @@ pub fn specs() -> Vec<EffectSpec> {
#[derive(Clone, Debug, PartialEq)]
pub struct StrokeSpec {
pub kind: StrokeKind,
/// Layer-space points.
/// Effect-space points.
pub points: Vec<[f64; 2]>,
/// Pen pressure per point (0–1); empty = full pressure.
pub pressure: Vec<f64>,

View File

@ -219,28 +219,63 @@ fn cylinder(ctx: &EffectCtx, mut b: Buf) -> Buf {
let (_, _, w, h) = rect;
let r = (ctx.params.f("radius") / 100.0 * w / std::f64::consts::TAU).max(0.5);
let c = b.to_px(ctx.params.v2("position"));
// Keep the pre-existing Rotation control as an additional texture spin.
let spin = ctx.params.f("rotation").to_radians();
let (rx, ry, rz) = (ctx.params.f("rotationX"), ctx.params.f("rotationY"), ctx.params.f("rotationZ"));
let tilted = rx != 0.0 || ry != 0.0 || rz != 0.0;
let m = rot_xyz(rx, ry, rz);
// The cylinder's local Y axis after rotation, in viewer coordinates.
let axis = [m[0][1], m[1][1], m[2][1]];
let a = 1.0 - axis[2] * axis[2];
let mode = ctx.params.e("render");
let sh = Shading::from(ctx);
let src = b.img.clone();
b.img.rows_mut().for_each(|(y, row)| {
let v = (y as f64 + 0.5 - c.1) / h + 0.5;
let dy = y as f64 + 0.5 - c.1;
for (x, px) in row.iter_mut().enumerate() {
let s = (x as f64 + 0.5 - c.0) / r;
if s.abs() >= 1.0 {
let dx = x as f64 + 0.5 - c.0;
if !tilted {
// Preserve exactly the mapping of projects saved before axis rotations.
let v = dy / h + 0.5;
let s = dx / r;
if s.abs() >= 1.0 {
*px = [0.0; 4];
continue;
}
let th = s.asin();
let cz = th.cos();
let front = || sh.shade(texel(&src, rect, (th + spin) / std::f64::consts::TAU + 0.5, v), [s, 0.0, cz]);
let back = || sh.shade(texel(&src, rect, (std::f64::consts::PI - th + spin) / std::f64::consts::TAU + 0.5, v), [-s, 0.0, cz]);
*px = match mode {
1 => front(),
2 => back(),
_ => over(front(), back()),
};
continue;
}
// Intersect the viewing ray (dx,dy,z) with the rotated cylindrical
// side wall: |q - dot(q,axis)*axis|^2 = radius^2.
let da = dx * axis[0] + dy * axis[1];
let bq = -da * axis[2];
let discriminant = bq * bq - a * (dx * dx + dy * dy - da * da - r * r);
if a <= 1e-10 || !discriminant.is_finite() || discriminant <= 0.0 {
*px = [0.0; 4];
continue;
}
let th = s.asin();
let cz = th.cos();
let front = || {
let u = (th + spin) / std::f64::consts::TAU + 0.5;
sh.shade(texel(&src, rect, u, v), [s, 0.0, cz])
};
let back = || {
let u = (std::f64::consts::PI - th + spin) / std::f64::consts::TAU + 0.5;
sh.shade(texel(&src, rect, u, v), [-s, 0.0, cz])
let root = discriminant.sqrt();
let face = |z: f64, back: bool| {
// Rᵀ turns the intersection into cylinder-local coordinates.
let local = [m[0][0] * dx + m[1][0] * dy + m[2][0] * z, m[0][1] * dx + m[1][1] * dy + m[2][1] * z, m[0][2] * dx + m[1][2] * dy + m[2][2] * z];
let u = (local[0].atan2(local[2]) + spin) / std::f64::consts::TAU + 0.5;
let v = local[1] / h + 0.5;
let sign = if back { -1.0 } else { 1.0 };
let nx = local[0] / r * sign;
let nz = local[2] / r * sign;
let normal = [m[0][0] * nx + m[0][2] * nz, m[1][0] * nx + m[1][2] * nz, m[2][0] * nx + m[2][2] * nz];
sh.shade(texel(&src, rect, u, v), normal)
};
let front = || face((-bq + root) / a, false);
let back = || face((-bq - root) / a, true);
*px = match mode {
1 => front(),
2 => back(),
@ -362,6 +397,9 @@ pub fn specs() -> Vec<EffectSpec> {
p("radius", "Radius", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
p("position", "Position", pt(0.5, 0.5), ParamUi::Point),
p("rotation", "Rotation", num(0.0), ParamUi::Angle),
p("rotationX", "Rotation X", num(0.0), ParamUi::Angle),
p("rotationY", "Rotation Y", num(0.0), ParamUi::Angle),
p("rotationZ", "Rotation Z", num(0.0), ParamUi::Angle),
p("render", "Render", Value::Enum(0), render()),
];
cyl.extend(shading());
@ -527,6 +565,31 @@ mod tests {
assert_eq!(out.get(50 + 18, 10)[3], 0.0);
}
#[test]
fn cylinder_axis_rotations_affect_geometry_and_texture() {
let img = Image::filled(100, 100, [0.5, 0.5, 0.5, 1.0]);
let default = run("ec.perspective.cccylinder", &img, &[]).img;
let x_tilt = run("ec.perspective.cccylinder", &img, &[("rotationX", num(70.0))]).img;
// Tilting the finite cylinder shortens its vertical projection.
assert!(default.get(50, 10)[3] > 0.99);
assert_eq!(x_tilt.get(50, 10)[3], 0.0);
assert!(x_tilt.get(50, 50)[3] > 0.99);
let z_roll = run("ec.perspective.cccylinder", &img, &[("rotationZ", num(90.0))]).img;
// A quarter turn moves the side wall from vertical to horizontal.
assert_eq!(default.get(5, 50)[3], 0.0);
assert!(z_roll.get(5, 50)[3] > 0.99);
let mut striped = Image::filled(100, 100, [0.0, 0.0, 0.0, 1.0]);
for y in 0..100 {
for x in 0..100 {
striped.set(x, y, [x as f32 / 100.0, 0.2, 0.3, 1.0]);
}
}
let baseline = run("ec.perspective.cccylinder", &striped, &[]).img;
let spun = run("ec.perspective.cccylinder", &striped, &[("rotationY", num(90.0))]).img;
assert!((baseline.get(50, 50)[0] - spun.get(50, 50)[0]).abs() > 0.05);
}
#[test]
fn spotlight_darkens_outside_cone() {
let img = Image::filled(80, 80, [1.0, 1.0, 1.0, 1.0]);

View File

@ -164,6 +164,12 @@ pub fn deform(rest: &[[f64; 2]], tris: &[[usize; 3]], weights: &[f64], h: &Handl
t[1] /= h.fixed.len() as f64;
}
let x0: Vec<[f64; 2]> = rest.iter().map(|p| [p[0] + t[0], p[1] + t[1]]).collect();
// One pin and no edge targets: every rotation and scale about the pin costs nothing, and
// the regulariser that picks the translation is too weak to stop CG turning round-off
// into a visible rotation (#313). The translation is the exact solution.
if h.fixed.len() == 1 && h.edges.is_empty() {
return x0;
}
let mut fixed = vec![false; n];
for (i, _) in &h.fixed {
fixed[*i] = true;

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

@ -306,3 +306,20 @@ fn groups_round_trip_serde() {
assert_eq!(m[0].pins[1].kind, PinKind::Starch);
assert_eq!(m[0].pins[0].position, [3.0, 4.0]);
}
#[test]
fn single_pin_on_a_traced_solid_does_not_rotate_it() {
// #313: an 800×600 solid traced from its alpha, as the Puppet Pin tool builds it. One
// pin is not enough to rotate or scale the mesh, so it may only translate it.
let (w, h) = (800, 600);
let o = outline(&vec![1.0; w * h], w, h, 1.0, [0.0; 2], [400.0, 300.0], 3.0);
let m = triangulate(&o, &MeshOpts::default());
check_valid(&m);
for at in [[40.0, 40.0], [760.0, 30.0], [20.0, 580.0], [790.0, 590.0], [400.0, 300.0], [123.0, 456.0]] {
for t in [[0.0, 0.0], [35.0, -20.0]] {
let d = solve(&m, &[pin(PinKind::Position, at, [at[0] + t[0], at[1] + t[1]])], 2);
let err = d.iter().zip(&m.verts).map(|(a, b)| (a[0] - b[0] - t[0]).hypot(a[1] - b[1] - t[1])).fold(0.0, f64::max);
assert!(err < 1e-6, "pin at {at:?} moved by {t:?}: vertices off by up to {err} px");
}
}
}

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

@ -15,7 +15,7 @@ use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::generate::value_noise;
use crate::util::{SimCache, hash1, params_key, unpremul};
use crate::util::{SimCache, hash1, params_key, params_key_except, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
pub(crate) fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
@ -989,11 +989,18 @@ pub fn particle_state(id: &str, ctx: &EffectCtx) -> Option<Vec<crate::psim::SimP
"ec.sim.ccparticlesystems2" => (ps2_phys(ctx), &PS2_CACHE, 2),
_ => return None,
};
let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, salt);
let key = sim_key(ctx, salt);
let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(cache, key, ctx.time, &phys));
Some(st.parts.iter().map(|q| crate::psim::SimParticle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect())
}
/// The simulation cache key of a stepped particle effect: every parameter but CC Particle World's
/// Effect Camera, which only changes how the particles are seen (animating it doesn't restart
/// the simulation).
fn sim_key(ctx: &EffectCtx, salt: u64) -> u64 {
params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, salt, &[PW_EFFECT_CAMERA])
}
/// Run a particle system to layer time `t` through `cache`.
pub(crate) fn simulate(cache: &SimCache<PState>, key: u64, t: f64, phys: &Phys) -> std::sync::Arc<PState> {
cache.run(key, steps_at(t), PState::default, |st, i| phys.step(st, i))
@ -1068,6 +1075,90 @@ fn particle_world(ctx: &EffectCtx, b: Buf) -> Buf {
}
}
/// CC Particle World's Extras ▸ Effect Camera parameters (id prefix).
const PW_EFFECT_CAMERA: &str = "extras/effectCamera/";
/// Effect Camera ▸ FOV's default (degrees).
const PW_FOV: f64 = 50.0;
/// What CC Particle World sees its particles through.
enum PwCamera {
/// Extras ▸ Effect Camera, in world units (layer widths): `dist` from the world origin (the
/// layer centre) along −z, the world turned by `rot` (`None` = not turned) about the origin,
/// `focal` the scale at unit depth; particles nearer than `near` aren't drawn.
Effect { rot: Option<[[f32; 3]; 3]>, dist: f32, focal: f32, near: f32 },
/// The comp's active camera layer, on buffer-world points (After Effects then ignores the
/// Effect Camera).
Comp(crate::card3d::Proj),
}
/// A particle as the camera sees it: depth (larger is farther), buffer pixel, size scale `k`
/// (buffer pixels per world unit = `k · layer width · buffer scale`) and velocity in world units
/// on screen (× the same factor for buffer pixels).
struct PwSeen {
depth: f32,
x: f32,
y: f32,
k: f32,
v: [f32; 2],
}
impl PwCamera {
fn new(ctx: &EffectCtx, b: &Buf) -> PwCamera {
let [lw, lh] = ctx.layer_size;
if let Some(cam) = ctx.env.host.and_then(|h| h.comp_scene()).filter(|sc| sc.camera_layer).and_then(|sc| sc.camera) {
let c = [b.offset[0] + lw * 0.5 * b.scale, b.offset[1] + lh * 0.5 * b.scale];
return PwCamera::Comp(crate::card3d::comp_proj(&cam, c, b));
}
let f = |id: &str, d: f64| ctx.params.get(&format!("{PW_EFFECT_CAMERA}{id}")).map(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(d);
// The camera keeps the origin's plane at the scale Distance gives it as FOV changes
// (moving in for a wider view), so FOV sets how strong the perspective is. Distance 2
// shows the origin's plane at the layer's own scale.
let half = |fov: f64| (fov.clamp(1.0, 179.0).to_radians() * 0.5).tan();
let zoom = half(PW_FOV) / half(f("fov", PW_FOV));
let dist = f("distance", 2.0).max(0.01) * zoom;
let a = [f("rotationX", 0.0), f("rotationY", 0.0), f("rotationZ", 0.0)];
let rot = (a != [0.0; 3]).then(|| {
let r = crate::card3d::rot_ordered(a[0].to_radians(), a[1].to_radians(), a[2].to_radians(), 0);
r.map(|row| row.map(|v| v as f32))
});
PwCamera::Effect { rot, dist: dist as f32, focal: (2.0 * zoom) as f32, near: (0.025 * dist) as f32 }
}
/// How the camera sees world point `p` moving at `v`; `None` behind or too close to it.
fn see(&self, p: [f32; 3], v: [f32; 3], lw: f32, lh: f32, b: &Buf) -> Option<PwSeen> {
match self {
PwCamera::Effect { rot, dist, focal, near } => {
let turn = |r: &[[f32; 3]; 3], q: [f32; 3]| r.map(|row| row[0] * q[0] + row[1] * q[1] + row[2] * q[2]);
let (p, v) = match rot {
Some(r) => (turn(r, p), turn(r, v)),
None => (p, v),
};
let zc = dist + p[2];
if zc.is_nan() || zc <= *near {
return None;
}
let k = focal / zc;
let (x, y) = b.to_px([(lw * 0.5 + p[0] * lw * k) as f64, (lh * 0.5 + p[1] * lw * k) as f64]);
Some(PwSeen { depth: zc, x: x as f32, y: y as f32, k, v: [v[0], v[1]] })
}
PwCamera::Comp(proj) => {
let (s, lw, lh) = (b.scale, lw as f64, lh as f64);
let u = lw * s;
let at = [b.offset[0] + lw * 0.5 * s + p[0] as f64 * u, b.offset[1] + lh * 0.5 * s + p[1] as f64 * u, p[2] as f64 * u];
let seen = proj.view(at, [v[0] as f64, v[1] as f64, v[2] as f64])?;
let k = seen.scale;
Some(PwSeen {
depth: seen.depth as f32,
x: seen.at[0] as f32,
y: seen.at[1] as f32,
k: k as f32,
v: [(seen.vel[0] / k) as f32, (seen.vel[1] / k) as f32],
})
}
}
}
}
/// CC Particle World's sprites (far to near), `None` before anything is born.
fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan> {
let pr = ctx.params;
@ -1076,7 +1167,7 @@ fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan> {
if phys.rate <= 0.0 && ctx.time <= 0.0 {
return None;
}
let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1);
let key = sim_key(ctx, 1);
let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(&PW_CACHE, key, ctx.time, &phys));
let kind = pr.e("particle/particleType");
let birth = pr.color("particle/birthColor");
@ -1085,24 +1176,16 @@ fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan> {
let svar = pr.f("particle/sizeVariation") as f32 / 100.0;
let max_op = pr.f("particle/maxOpacity") as f32 / 100.0;
let s = b.scale as f32;
// Camera at distance 2 (world units = layer width) looking at the origin (layer centre).
let cam = 2.0f32;
let cam = PwCamera::new(ctx, b);
let mut list: Vec<(f32, Sprite)> = st
.parts
.par_iter()
.filter_map(|q| {
let zc = cam + q.p[2];
if zc <= 0.05 {
return None;
}
let k = cam / zc;
let x = lw * 0.5 + q.p[0] * lw * k;
let y = lh * 0.5 + q.p[1] * lw * k;
let (bx, by) = b.to_px([x as f64, y as f64]);
let PwSeen { depth, x, y, k, v } = cam.see(q.p, q.v, lw, lh, b)?;
let (c, size) = particle_look(q, birth, death, bs, ds, svar, max_op);
let r = size * 0.5 * lw * k * s * 0.25;
let sp = type_sprite(kind, bx as f32, by as f32, r, c, q.v[0] * lw * k * s, q.v[1] * lw * k * s, (q.id % 628) as f32 * 0.01 + q.age * 2.0);
Some((zc, sp))
let sp = type_sprite(kind, x, y, r, c, v[0] * lw * k * s, v[1] * lw * k * s, (q.id % 628) as f32 * 0.01 + q.age * 2.0);
Some((depth, sp))
})
.collect();
list.sort_by(|a, b| b.0.total_cmp(&a.0));
@ -1167,7 +1250,7 @@ fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
let lh = ctx.layer_size[1] as f32;
let unit = lh.max(1.0);
let phys = ps2_phys(ctx);
let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 2);
let key = sim_key(ctx, 2);
let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(&PS2_CACHE, key, ctx.time, &phys));
let kind = pr.e("particle/particleType");
let birth = pr.color("particle/birthColor");
@ -1440,6 +1523,11 @@ pub fn specs() -> Vec<EffectSpec> {
p("particle/birthColor", "Birth Color", col(1.0, 1.0, 0.0), ParamUi::Color),
p("particle/deathColor", "Death Color", col(0.6, 0.0, 0.0), ParamUi::Color),
p("particle/transferMode", "Transfer Mode", Value::Enum(0), popup(&["Composite", "Screen", "Add", "Black Matte"])),
p("extras/effectCamera/distance", "Distance", num(2.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
p("extras/effectCamera/rotationX", "Rotation X", num(0.0), ParamUi::Angle),
p("extras/effectCamera/rotationY", "Rotation Y", num(0.0), ParamUi::Angle),
p("extras/effectCamera/rotationZ", "Rotation Z", num(0.0), ParamUi::Angle),
p("extras/effectCamera/fov", "FOV", num(PW_FOV), slider(1.0, 179.0, 10.0, 120.0, 1)),
p("extras/randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)),
],
particle_world,
@ -1532,18 +1620,24 @@ mod tests {
a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|k| (p[k] - q[k]).abs()).sum::<f32>()).sum()
}
/// Centre of mass (luminance-weighted difference from `base`) in y.
fn mass_y(img: &Image, base: &Image) -> f32 {
let (mut m, mut sy) = (0.0, 0.0);
/// Centre of mass (luminance-weighted difference from `base`), `[-1, -1]` when equal.
fn mass(img: &Image, base: &Image) -> [f32; 2] {
let (mut m, mut sx, mut sy) = (0.0, 0.0, 0.0);
for y in 0..img.height {
for x in 0..img.width {
let i = img.idx(x, y);
let d: f32 = (0..3).map(|k| (img.data[i][k] - base.data[i][k]).abs()).sum();
m += d;
sx += d * x as f32;
sy += d * y as f32;
}
}
if m > 0.0 { sy / m } else { -1.0 }
if m > 0.0 { [sx / m, sy / m] } else { [-1.0; 2] }
}
/// [`mass`] in y.
fn mass_y(img: &Image, base: &Image) -> f32 {
mass(img, base)[1]
}
#[test]
@ -1695,6 +1789,119 @@ mod tests {
assert!(down > up + 2.0, "{up} {down}");
}
/// A comp whose camera is `camera` ([`crate::CompScene::camera`]); `layer`: a camera layer
/// (else the comp's default view).
struct Comp {
camera: [[f64; 4]; 3],
layer: bool,
}
impl crate::EffectHost for Comp {
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn comp_scene(&self) -> Option<crate::CompScene> {
Some(crate::CompScene { camera: Some(self.camera), camera_layer: self.layer, light: None })
}
}
/// A camera `d` px in front of layer point `c` looking at it, moved right by `dx` px and
/// zoomed by `zoom` (1 = identity on the layer plane): `c + zoom·d·(p − c − dx) / (z + d)`.
fn cam(c: [f64; 2], d: f64, dx: f64, zoom: f64) -> [[f64; 4]; 3] {
let k = zoom * d;
[[k, 0.0, c[0], d * c[0] - k * (c[0] + dx)], [0.0, k, c[1], d * c[1] - k * c[1]], [0.0, 0.0, 1.0, d]]
}
fn pw_in(comp: &Comp, vals: &[(&str, Value)]) -> Image {
run_fx("ec.sim.ccparticleworld", vals, Image::new(48, 48), 1.0, EffectEnv { host: Some(comp), ..Default::default() }).img
}
fn finite(img: &Image) -> bool {
img.data.iter().all(|p| p.iter().all(|c| c.is_finite()))
}
/// #397: with a camera layer in the comp, the particles are seen through it.
#[test]
fn particle_world_follows_the_comp_camera() {
let v = pw_vals(3.0);
let blank = Image::new(48, 48);
let here = Comp { camera: cam([24.0, 24.0], 96.0, 0.0, 1.0), layer: true };
let base = pw_in(&here, &v);
assert!(base.data.iter().any(|p| p[3] > 0.0), "particles alive");
// Moving the camera right moves the particles left.
let moved = pw_in(&Comp { camera: cam([24.0, 24.0], 96.0, 8.0, 1.0), ..here }, &v);
let (a, b) = (mass(&base, &blank), mass(&moved, &blank));
assert!(b[0] < a[0] - 4.0 && (b[1] - a[1]).abs() < 2.0, "{a:?} -> {b:?}");
// Zooming in spreads them out.
let zoomed = pw_in(&Comp { camera: cam([24.0, 24.0], 96.0, 0.0, 2.0), ..here }, &v);
let cover = |i: &Image| i.data.iter().filter(|p| p[3] > 0.01).count();
assert!(cover(&zoomed) > cover(&base), "{} {}", cover(&zoomed), cover(&base));
// The Effect Camera is ignored then.
let mut turned = v.clone();
turned.push(("extras/effectCamera/rotationY", num(60.0)));
assert_eq!(pw_in(&here, &turned), base);
// The comp's default view (no camera layer) leaves it to the Effect Camera.
let default_view = Comp { layer: false, ..here };
assert_eq!(pw_in(&default_view, &v), run("ec.sim.ccparticleworld", &v, blank.clone(), 1.0));
// A camera facing away, or a degenerate one, draws nothing and never NaN.
for camera in [[[96.0, 0.0, 24.0, 0.0], [0.0, 96.0, 24.0, 0.0], [0.0, 0.0, -1.0, -96.0]], [[0.0; 4]; 3], [[f64::NAN; 4]; 3]] {
assert_eq!(pw_in(&Comp { camera, layer: true }, &v), blank);
}
// A camera among the particles draws the ones in front of it.
let inside = pw_in(&Comp { camera: cam([24.0, 24.0], 1.0, 0.0, 1.0), ..here }, &v);
assert!(finite(&inside));
}
/// Extras ▸ Effect Camera: the defaults are the fixed camera Particle World always had.
#[test]
fn particle_world_effect_camera() {
let v = pw_vals(5.0);
let base = run("ec.sim.ccparticleworld", &v, Image::new(48, 48), 1.0);
let with = |extra: &[(&'static str, Value)]| {
let mut w = v.clone();
w.extend_from_slice(extra);
run("ec.sim.ccparticleworld", &w, Image::new(48, 48), 1.0)
};
// Missing parameters (projects saved before) read as the defaults.
let spec = crate::find("ec.sim.ccparticleworld").unwrap();
let mut params = crate::Params { values: spec.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
params.values.retain(|k, _| !k.starts_with(PW_EFFECT_CAMERA));
for (k, val) in &v {
params.values.insert(k.to_string(), val.clone());
}
let ctx = EffectCtx { params: &params, time: 1.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: EffectEnv::default() };
assert_eq!(crate::apply(spec, &ctx, Buf { img: Image::new(48, 48), offset: [0.0; 2], scale: 1.0 }).img, base);
assert_eq!(with(&[("extras/effectCamera/fov", num(f64::NAN))]), base);
// Turning or pulling back the camera changes the view.
let turned = with(&[("extras/effectCamera/rotationY", num(60.0))]);
assert!(diff(&turned, &base) > 1.0);
let cover = |i: &Image| i.data.iter().filter(|p| p[3] > 0.01).count();
let far = with(&[("extras/effectCamera/distance", num(4.0))]);
assert!(cover(&far) < cover(&base), "{} {}", cover(&far), cover(&base));
assert!(diff(&with(&[("extras/effectCamera/fov", num(120.0))]), &base) > 0.1);
// Extremes never panic or produce NaN.
for extra in [
("extras/effectCamera/distance", num(0.0)),
("extras/effectCamera/distance", num(-5.0)),
("extras/effectCamera/fov", num(0.0)),
("extras/effectCamera/fov", num(180.0)),
("extras/effectCamera/rotationX", num(1e12)),
] {
assert!(finite(&with(std::slice::from_ref(&extra))), "{extra:?}");
}
// Moving the camera doesn't restart the simulation.
let key = |pr: &crate::Params| {
sim_key(&EffectCtx { params: pr, time: 1.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: EffectEnv::default() }, 1)
};
let mut moved = params.clone();
moved.values.insert("extras/effectCamera/rotationZ".into(), num(30.0));
assert_eq!(key(&params), key(&moved));
moved.values.insert("extras/randomSeed".into(), num(9.0));
assert_ne!(key(&params), key(&moved));
}
#[test]
fn particle_systems2_seek_and_direction() {
let v = vec![

View File

@ -2468,7 +2468,7 @@ mod tests {
None
}
fn comp_scene(&self) -> Option<crate::CompScene> {
Some(crate::CompScene { camera: self.camera, light: self.light })
Some(crate::CompScene { camera: self.camera, camera_layer: true, light: self.light })
}
}

View File

@ -583,6 +583,13 @@ fn smooth_flow(flow: &mut Flow, pm: &PixelMotion) {
/// The motion field from frame `a` to frame `b` (or from the warp layer's frames).
pub fn timewarp_flow(a: &Image, b: &Image, pm: &PixelMotion) -> Flow {
timewarp_flow_search(a, b, pm, 4)
}
/// [`timewarp_flow`] with an explicit coarse search radius: Timewarp's own resampling needs
/// little (motion between adjacent source frames at near-100% speed), but Pixel Motion Blur
/// must find fast full-frame displacements (#535).
fn timewarp_flow_search(a: &Image, b: &Image, pm: &PixelMotion, radius: i32) -> Flow {
let ga = motion_grey(&crop_edges(a, pm.crops), pm.weights, 1.0);
let gain = if pm.correct_luminance {
let (ma, mb) = (mean_grey(&ga), mean_grey(&motion_grey(b, pm.weights, 1.0)));
@ -591,7 +598,7 @@ pub fn timewarp_flow(a: &Image, b: &Image, pm: &PixelMotion) -> Flow {
1.0
};
let gb = motion_grey(&crop_edges(b, pm.crops), pm.weights, gain);
let mut flow = effectcraft_raster::flow::block_flow_window(&ga, &gb, pm.tile, pm.window, 4);
let mut flow = effectcraft_raster::flow::block_flow_window(&ga, &gb, pm.tile, pm.window, radius);
smooth_flow(&mut flow, pm);
flow
}
@ -923,12 +930,71 @@ fn wide_time(ctx: &EffectCtx, b: Buf) -> Buf {
mean_of(ctx, b, &times)
}
fn pixel_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf {
fn shutter_times(ctx: &EffectCtx) -> Vec<f64> {
let angle = if ctx.params.e("shutterControl") == 1 { ctx.params.f("shutterAngle") } else { 180.0 };
let n = ctx.params.f("shutterSamples").round().clamp(1.0, 64.0) as usize;
let span = angle.clamp(0.0, 720.0) / 360.0 / ctx.fps();
let times: Vec<f64> = (0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect();
mean_of(ctx, b, &times)
(0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect()
}
fn pixel_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf {
match pmb_plan(ctx).as_ref().map(TimewarpPlan::render_cpu) {
Some(blur) => blur,
None => mean_of(ctx, b, &shutter_times(ctx)),
}
}
/// Pixel Motion Blur as a [`TimewarpPlan`]: each shutter instant is decoded between the two
/// source frames it falls between along a measured motion field (the same synthesis Timewarp
/// uses), and the instants average into the blur. Vector Detail sets the spacing of the motion
/// vectors, so a coarse grid follows one big motion and a fine grid separates neighbouring
/// ones (#535). `None` without a host — the caller then falls back. Shared by the CPU effect
/// and the GPU compositor (`fx_time`), which renders the plan like Timewarp's.
pub fn pmb_plan(ctx: &EffectCtx) -> Option<TimewarpPlan> {
ctx.env.host?;
let fps = ctx.fps();
let samples = shutter_times(ctx);
let n = samples.len();
let mut times = Vec::new();
for &s in &samples {
let (f0, _) = tw_split(s, fps);
times.push(f0 / fps);
times.push((f0 + 1.0) / fps);
}
let fr = fetch(ctx, &times)?;
let idx = |t: f64| times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0);
let mut pm = PixelMotion::from(ctx, fr.grid.scale);
// PMB's own Vector Detail: 0 → one vector per 32 px, 100 → one per 2 px.
let detail = (ctx.params.f("vectorDetail") / 100.0).clamp(0.0, 1.0);
pm.tile = (32.0 * (1.0f64 / 16.0).powf(detail) * fr.grid.scale).round().max(2.0) as u32;
let mut flows: Vec<FlowEntry> = vec![];
let mut steps = vec![];
let w = 1.0 / n as f32;
for &s in &samples {
let (f0, frac) = tw_split(s, fps);
let (i0, i1) = (idx(f0 / fps), idx((f0 + 1.0) / fps));
let step = if frac < 1e-4 {
TwStep::Whole(i0)
} else {
let flow = match flows.iter().position(|fl| fl.key == f0 as i64) {
Some(p) => p,
None => {
let reach = (fr.grid.img.width.max(fr.grid.img.height) / 4).clamp(8, 64);
flows.push(FlowEntry { key: f0 as i64, flow: timewarp_flow_search(&fr.imgs[i0], &fr.imgs[i1], &pm, reach as i32) });
flows.len() - 1
}
};
TwStep::Motion { a: i0, b: i1, w: frac, flow }
};
steps.push((step, w));
}
Some(TimewarpPlan { grid: fr.grid, frames: fr.imgs, steps, flows: flows.into_iter().map(|f| f.flow).collect(), pm })
}
/// A measured flow with the source frame pair it came from (plans cache one per pair).
struct FlowEntry {
key: i64,
flow: Flow,
}
#[cfg(test)]

View File

@ -424,7 +424,24 @@ pub fn remap(src: &Image, clamp: bool, f: impl Fn(f64, f64) -> Option<(f64, f64)
/// Layer rectangle in buffer pixels: (x0, y0, width, height).
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)
layer_rect_at(ctx, b.offset, b.scale)
}
/// [`layer_rect`] in the pixels of a buffer with `offset` and `scale` (see [`crate::Buf`];
/// GPU buffers): the layer bounds start at effect-space (0, 0).
pub fn layer_rect_at(ctx: &crate::EffectCtx, offset: [f64; 2], scale: f64) -> (f64, f64, f64, f64) {
(offset[0], offset[1], ctx.layer_size[0] * scale, ctx.layer_size[1] * scale)
}
/// Transparent pixels to add on every side ([`crate::Buf::pad`]) of a buffer `size` pixels big,
/// with `offset` and `scale`, so it covers the whole layer rectangle (at most 4096). A text or
/// shape layer's buffer only surrounds its content, but its layer bounds are comp-sized (see
/// the renderer's `effect_bounds`): an effect that fills the layer, such as CC Tiler, fills
/// those as in After Effects (#659).
pub fn layer_cover_pad(ctx: &crate::EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64) -> u32 {
let (x0, y0, w, h) = layer_rect_at(ctx, offset, scale);
let need = [-x0, -y0, x0 + w - f64::from(size[0]), y0 + h - f64::from(size[1])].into_iter().fold(0.0, f64::max);
need.ceil().min(4096.0) as u32
}
/// Resample another layer (a layer parameter) into `b`'s pixel grid. With `stretch` the other
@ -444,6 +461,17 @@ pub fn fit_layer(ctx: &crate::EffectCtx, b: &crate::Buf, other: &crate::LayerPix
})
}
/// Resample a buffer into another buffer's pixel grid without stretching its layer space.
/// Used to composite the pre-effect image after earlier effects expand or shift the bounds.
pub fn fit_buffer(source: &crate::Buf, width: u32, height: u32, offset: [f64; 2], scale: f64) -> Image {
let inv = 1.0 / scale.max(1e-9);
gen_image(width, height, |x, y| {
let sx = (x as f64 + 0.5 - offset[0]) * inv * source.scale + source.offset[0];
let sy = (y as f64 + 0.5 - offset[1]) * inv * source.scale + source.offset[1];
source.img.sample_bilinear(sx, sy)
})
}
/// The layer chosen in layer parameter `id` fitted to `b` (see [`fit_layer`]), or a copy of
/// `b`'s own pixels when none is chosen / available (After Effects' "self" behaviour).
pub fn layer_or_self(ctx: &crate::EffectCtx, b: &crate::Buf, id: &str, masks_and_effects: bool, stretch: bool) -> Image {
@ -456,9 +484,15 @@ pub fn layer_or_self(ctx: &crate::EffectCtx, b: &crate::Buf, id: &str, masks_and
/// A stable hash of an effect instance's evaluated parameters, seed, layer size and buffer
/// scale (cache key for simulations).
pub fn params_key(ctx: &crate::EffectCtx, b: &crate::Buf, salt: u64) -> u64 {
params_key_except(ctx, b, salt, &[])
}
/// [`params_key`] without the parameters whose ids start with one of `skip` (ones that only
/// change how the simulation is viewed, so animating them doesn't restart it).
pub fn params_key_except(ctx: &crate::EffectCtx, b: &crate::Buf, salt: u64, skip: &[&str]) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
let mut keys: Vec<&String> = ctx.params.values.keys().collect();
let mut keys: Vec<&String> = ctx.params.values.keys().filter(|k| !skip.iter().any(|s| k.starts_with(s))).collect();
keys.sort();
for k in keys {
k.hash(&mut h);

View File

@ -385,8 +385,9 @@ pub fn specs() -> Vec<EffectSpec> {
],
cineon,
),
// `lut`: a path to a `.cube` file, or the `.cube` text itself (set via commands/automation).
spec("ec.utility.applylut", "Apply Color LUT", vec![p("lut", "LUT", Value::Str(String::new()), ParamUi::Hidden)], apply_lut),
// `lut`: a path to a `.cube` file (Effect Controls' Choose…, asked for when the effect is
// applied, as in After Effects), or the `.cube` text itself (commands/automation).
spec("ec.utility.applylut", "Apply Color LUT", vec![p("lut", "LUT", Value::Str(String::new()), crate::file_ui(&["cube"]))], apply_lut),
]
}

View File

@ -2,8 +2,9 @@
//!
//! The GPU compositor rasterises these effects from geometry the CPU computes (bolts, strokes,
//! glyphs, audio marks, wave lists, flood-fill regions, sampled colours); the CPU effects were
//! refactored to build the same plans, and must render exactly what they did before. Each
//! effect renders on a generated layer with masks, another layer and an audio track, at default
//! refactored to build the same plans. The hashes guard against unintended output changes;
//! intended behavior changes require a focused regression test before re-pinning. Each effect
//! renders on a generated layer with masks, another layer and an audio track, at default
//! and non-default parameters and two layer times, in an 8 bpc pipeline (input and output
//! clamped and quantised) and a 32 bpc one, at full and at half resolution. Each frame's pixels
//! are hashed (FNV-1a over the `f32` bits) and compared with the pinned hashes below.
@ -323,6 +324,70 @@ fn hashes() -> Vec<(String, u64)> {
v
}
/// Pin the intended linear spectrum behavior independently of the pixel hash table.
#[test]
fn audio_spectrum_uses_linear_frequency_and_amplitude() {
struct Tone {
frequency: f64,
amplitude: f64,
}
impl EffectHost for Tone {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, start: f64, frames: usize, rate: u32) -> Option<Vec<f32>> {
Some(
(0..frames)
.flat_map(|i| {
let t = start + i as f64 / rate as f64;
let sample = (self.amplitude * (t * self.frequency * std::f64::consts::TAU).sin()) as f32;
[sample, sample]
})
.collect(),
)
}
}
let id = "ec.generate.audiospectrum";
let spec = find(id).unwrap();
let size = [320.0, 100.0];
let mut params = Params { values: spec.params.iter().map(|p| (p.id.to_string(), default_value(p, size))).collect() };
for (key, value) in [
("audioLayer", Value::Layer(Some(MAP))),
("startPoint", pt(0.0, 50.0)),
("endPoint", pt(320.0, 50.0)),
("startFrequency", n(0.0)),
("endFrequency", n(1500.0)),
("frequencyBands", n(33.0)),
("maximumHeight", n(40.0)),
("audioDuration", n(256.0)),
] {
params.values.insert(key.to_string(), value);
}
let buf = Buf { img: Image::new(320, 100), offset: [0.0; 2], scale: 1.0 };
for (frequency, expected_band) in [(375.0, 8), (750.0, 16), (1125.0, 24)] {
let mut peaks = Vec::new();
for amplitude in [0.0, 0.125, 0.25] {
let host = Tone { frequency, amplitude };
let env = EffectEnv { host: Some(&host), comp_time: 1.0, ..Default::default() };
let ctx = EffectCtx { params: &params, time: 1.0, layer_size: size, seed: 1, adjustment: false, env };
let plan = effectcraft_effects::marks_plan(id, &ctx, &buf).unwrap();
assert_eq!(plan.segs.len(), 33);
let heights: Vec<f64> = plan.segs.iter().map(|seg| (seg.b[1] - seg.a[1]).abs()).collect();
assert!(heights.iter().all(|height| height.is_finite()));
if amplitude == 0.0 {
assert!(heights.iter().all(|height| *height == 0.0), "silence has no bar height");
continue;
}
let (band, height) = heights.iter().enumerate().max_by(|(_, a), (_, b)| a.total_cmp(b)).unwrap();
assert_eq!(band, expected_band, "{frequency} Hz uses its linear frequency position");
// A windowed pure tone retains its amplitude, allowing for the finite Hann window.
assert!((height - amplitude * 40.0).abs() < 0.01, "{frequency} Hz at {amplitude}: height {height}");
peaks.push(*height);
}
assert!((peaks[1] - 2.0 * peaks[0]).abs() < 1e-6, "doubling the signal doubles the height: {peaks:?}");
}
}
#[test]
fn plan_effects_golden_hashes() {
let got = hashes();
@ -520,24 +585,25 @@ const GOLDEN: &[(&str, u64)] = &[
("ec.generate.eyedropperfill/2/32bpc/t1.3", 0x500e27795935722a),
("ec.generate.eyedropperfill/2/32bpc/half/t0.4", 0x480fd6a293c6597c),
("ec.generate.eyedropperfill/2/32bpc/half/t1.3", 0x480fd6a293c6597c),
("ec.generate.audiospectrum/0/8bpc/t0.4", 0xb33f32432e79920c),
("ec.generate.audiospectrum/0/8bpc/t1.3", 0xb8256632165ce18a),
("ec.generate.audiospectrum/0/32bpc/t0.4", 0xf98f14d21a3c442e),
("ec.generate.audiospectrum/0/32bpc/t1.3", 0x18211baf9f0d4d41),
("ec.generate.audiospectrum/0/32bpc/half/t0.4", 0xa8d8b213c0f7d53e),
("ec.generate.audiospectrum/0/32bpc/half/t1.3", 0xc4fe7346c0da92b8),
("ec.generate.audiospectrum/1/8bpc/t0.4", 0xf37ca291072917fa),
("ec.generate.audiospectrum/1/8bpc/t1.3", 0xb59f0c33274846b9),
("ec.generate.audiospectrum/1/32bpc/t0.4", 0xaf6d6567f830fc1e),
("ec.generate.audiospectrum/1/32bpc/t1.3", 0x48ba15c0c33741c5),
("ec.generate.audiospectrum/1/32bpc/half/t0.4", 0xaceadbc6ccabd6dc),
("ec.generate.audiospectrum/1/32bpc/half/t1.3", 0xc62932f485afd259),
("ec.generate.audiospectrum/2/8bpc/t0.4", 0x20267f8c48b2a0c4),
("ec.generate.audiospectrum/2/8bpc/t1.3", 0xaf6447ee94fe284c),
("ec.generate.audiospectrum/2/32bpc/t0.4", 0x6850f20c7cfea994),
("ec.generate.audiospectrum/2/32bpc/t1.3", 0x7fc36e46f726f3f4),
("ec.generate.audiospectrum/2/32bpc/half/t0.4", 0x96e53e6547ea4822),
("ec.generate.audiospectrum/2/32bpc/half/t1.3", 0x69643e75820123ef),
// Linear frequency bands and amplitude from 581326ec; all other effects retain their hashes.
("ec.generate.audiospectrum/0/8bpc/t0.4", 0x07be49f76890493c),
("ec.generate.audiospectrum/0/8bpc/t1.3", 0xc0109e19c3958517),
("ec.generate.audiospectrum/0/32bpc/t0.4", 0x24fe2e48cc432c1f),
("ec.generate.audiospectrum/0/32bpc/t1.3", 0x9bdbf835e7d42f78),
("ec.generate.audiospectrum/0/32bpc/half/t0.4", 0x445e16a81e313a40),
("ec.generate.audiospectrum/0/32bpc/half/t1.3", 0x88849fde30e6b048),
("ec.generate.audiospectrum/1/8bpc/t0.4", 0xf95a769e610688cc),
("ec.generate.audiospectrum/1/8bpc/t1.3", 0x7df3a63a59a10c38),
("ec.generate.audiospectrum/1/32bpc/t0.4", 0x53f3e442e240599b),
("ec.generate.audiospectrum/1/32bpc/t1.3", 0x213066492a32f6a8),
("ec.generate.audiospectrum/1/32bpc/half/t0.4", 0x38ceb79d523c7402),
("ec.generate.audiospectrum/1/32bpc/half/t1.3", 0x7d63c37d260eb112),
("ec.generate.audiospectrum/2/8bpc/t0.4", 0x73c15c41ad557cec),
("ec.generate.audiospectrum/2/8bpc/t1.3", 0x0bb38170a3fbb6fd),
("ec.generate.audiospectrum/2/32bpc/t0.4", 0x2ac65e1ca97db691),
("ec.generate.audiospectrum/2/32bpc/t1.3", 0xa8be6d0571415ab0),
("ec.generate.audiospectrum/2/32bpc/half/t0.4", 0x318b82f955d58300),
("ec.generate.audiospectrum/2/32bpc/half/t1.3", 0x4775b23a8cc7c6c6),
("ec.generate.audiowaveform/0/8bpc/t0.4", 0x68a7733a023bc9f1),
("ec.generate.audiowaveform/0/8bpc/t1.3", 0x57e13a1aaa458b14),
("ec.generate.audiowaveform/0/32bpc/t0.4", 0xb78e946d794b5ade),

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

@ -0,0 +1,255 @@
{
"format": "effectcraft.animationPresets/1",
"notes": "Original EffectCraft transition presets (MIT OR Apache-2.0), written from scratch on EffectCraft's own Transition effects. Key times are seconds from the current time (layer time); effect paths are the effect's parameter ids; layer paths are property paths such as transform/opacity.",
"presets": [
{
"id": "fadeIn",
"name": "Fade In",
"category": "Transitions",
"direction": "in",
"description": "The layer fades up from transparent over half a second.",
"layer": { "keys": { "transform/opacity": [[0, 0], [0.5, 100]] }, "ease": true }
},
{
"id": "fadeOut",
"name": "Fade Out",
"category": "Transitions",
"direction": "out",
"description": "The layer fades down to transparent over half a second.",
"layer": { "keys": { "transform/opacity": [[0, 100], [0.5, 0]] }, "ease": true }
},
{
"id": "wipeInFromLeft",
"name": "Wipe In from Left",
"category": "Transitions",
"direction": "in",
"description": "A soft edge sweeps across from the left and reveals the layer.",
"effects": [
{ "effect": "ec.transition.linearwipe", "set": { "angle": 270, "feather": 40 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
]
},
{
"id": "wipeInFromRight",
"name": "Wipe In from Right",
"category": "Transitions",
"direction": "in",
"description": "A soft edge sweeps across from the right and reveals the layer.",
"effects": [
{ "effect": "ec.transition.linearwipe", "set": { "angle": 90, "feather": 40 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
]
},
{
"id": "wipeInFromTop",
"name": "Wipe In from Top",
"category": "Transitions",
"direction": "in",
"description": "The layer is revealed from the top edge downwards.",
"effects": [
{ "effect": "ec.transition.linearwipe", "set": { "angle": 0, "feather": 30 }, "keys": { "completion": [[0, 100], [0.9, 0]] }, "ease": true }
]
},
{
"id": "wipeInFromBottom",
"name": "Wipe In from Bottom",
"category": "Transitions",
"direction": "in",
"description": "The layer is revealed from the bottom edge upwards.",
"effects": [
{ "effect": "ec.transition.linearwipe", "set": { "angle": 180, "feather": 30 }, "keys": { "completion": [[0, 100], [0.9, 0]] }, "ease": true }
]
},
{
"id": "diagonalSweepIn",
"name": "Diagonal Sweep In",
"category": "Transitions",
"direction": "in",
"description": "A wide, very soft edge sweeps in from the bottom-left corner.",
"effects": [
{ "effect": "ec.transition.linearwipe", "set": { "angle": 45, "feather": 160 }, "keys": { "completion": [[0, 100], [1.2, 0]] }, "ease": true }
]
},
{
"id": "wipeOutToRight",
"name": "Wipe Out to Right",
"category": "Transitions",
"direction": "out",
"description": "A soft edge travels left to right and takes the layer away.",
"effects": [
{ "effect": "ec.transition.linearwipe", "set": { "angle": 90, "feather": 40 }, "keys": { "completion": [[0, 0], [1, 100]] }, "ease": true }
]
},
{
"id": "clockReveal",
"name": "Clock Reveal",
"category": "Transitions",
"direction": "in",
"description": "A clock hand sweeps once around the centre and reveals the layer.",
"effects": [
{ "effect": "ec.transition.radialwipe", "set": { "wipe": 1, "feather": 6 }, "keys": { "completion": [[0, 100], [1.2, 0]] }, "ease": true }
]
},
{
"id": "fanOpen",
"name": "Fan Open",
"category": "Transitions",
"direction": "in",
"description": "The layer opens like a fan, from the top down both sides.",
"effects": [
{ "effect": "ec.transition.radialwipe", "set": { "wipe": 2, "startAngle": 180, "feather": 10 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
]
},
{
"id": "clockWipeOut",
"name": "Clock Wipe Out",
"category": "Transitions",
"direction": "out",
"description": "A clock hand sweeps once around the centre and removes the layer.",
"effects": [
{ "effect": "ec.transition.radialwipe", "set": { "feather": 6 }, "keys": { "completion": [[0, 0], [1.2, 100]] }, "ease": true }
]
},
{
"id": "blindsOpen",
"name": "Blinds Open",
"category": "Transitions",
"direction": "in",
"description": "Vertical slats widen until the layer is whole.",
"effects": [
{ "effect": "ec.transition.venetian", "set": { "direction": 0, "width": 60, "feather": 4 }, "keys": { "completion": [[0, 100], [0.9, 0]] }, "ease": true }
]
},
{
"id": "slatsDown",
"name": "Slats Down",
"category": "Transitions",
"direction": "in",
"description": "Horizontal slats thicken until the layer is whole.",
"effects": [
{ "effect": "ec.transition.venetian", "set": { "direction": 90, "width": 36, "feather": 3 }, "keys": { "completion": [[0, 100], [0.8, 0]] } }
]
},
{
"id": "blindsClose",
"name": "Blinds Close",
"category": "Transitions",
"direction": "out",
"description": "Vertical slats narrow until the layer is gone.",
"effects": [
{ "effect": "ec.transition.venetian", "set": { "direction": 0, "width": 60, "feather": 4 }, "keys": { "completion": [[0, 0], [0.9, 100]] }, "ease": true }
]
},
{
"id": "pixelDissolveIn",
"name": "Pixel Dissolve In",
"category": "Transitions",
"direction": "in",
"description": "Small hard-edged blocks pop in at random until the layer is whole.",
"effects": [
{
"effect": "ec.transition.blockdissolve",
"set": { "blockWidth": 6, "blockHeight": 6, "softEdges": false },
"keys": { "completion": [[0, 100], [1.2, 0]] }
}
]
},
{
"id": "mosaicFadeIn",
"name": "Mosaic Fade In",
"category": "Transitions",
"direction": "in",
"description": "Large soft tiles fade in at random.",
"effects": [
{
"effect": "ec.transition.blockdissolve",
"set": { "blockWidth": 48, "blockHeight": 48, "feather": 16 },
"keys": { "completion": [[0, 100], [1.2, 0]] },
"ease": true
}
]
},
{
"id": "pixelDissolveOut",
"name": "Pixel Dissolve Out",
"category": "Transitions",
"direction": "out",
"description": "Small hard-edged blocks drop out at random until the layer is gone.",
"effects": [
{
"effect": "ec.transition.blockdissolve",
"set": { "blockWidth": 6, "blockHeight": 6, "softEdges": false },
"keys": { "completion": [[0, 0], [1.2, 100]] }
}
]
},
{
"id": "cardFlipIn",
"name": "Card Flip In",
"category": "Transitions",
"direction": "in",
"description": "A grid of cards turns over, left to right, to show the layer.",
"effects": [
{
"effect": "ec.transition.cardwipe",
"set": { "rows": 4, "columns": 8, "flipAxis": 1, "flipOrder": 1, "transitionWidth": 70 },
"keys": { "completion": [[0, 100], [1.4, 0]] },
"ease": true
}
]
},
{
"id": "cardFlipOut",
"name": "Card Flip Out",
"category": "Transitions",
"direction": "out",
"description": "A grid of cards turns over, left to right, and the layer is gone.",
"effects": [
{
"effect": "ec.transition.cardwipe",
"set": { "rows": 4, "columns": 8, "flipAxis": 1, "transitionWidth": 70 },
"keys": { "completion": [[0, 0], [1.4, 100]] },
"ease": true
}
]
},
{
"id": "gridDoorsIn",
"name": "Grid Doors In",
"category": "Transitions",
"direction": "in",
"description": "Tiles open like small doors from the centre outwards.",
"effects": [
{ "effect": "ec.transition.ccgridwipe", "set": { "shape": 0, "tiles": 12, "reverse": true }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
]
},
{
"id": "lensPopIn",
"name": "Lens Pop In",
"category": "Transitions",
"direction": "in",
"description": "The layer grows out of a circle that stretches the picture outwards.",
"effects": [
{ "effect": "ec.transition.ccradialscalewipe", "set": { "reverse": true }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
]
},
{
"id": "lineSweepIn",
"name": "Line Sweep In",
"category": "Transitions",
"direction": "in",
"description": "A stepped, slanted edge sweeps in from the lower left.",
"effects": [
{ "effect": "ec.transition.cclinesweep", "set": { "direction": 90, "thickness": 30, "slant": 30 }, "keys": { "completion": [[0, 100], [1, 0]] } }
]
},
{
"id": "jawsCloseIn",
"name": "Jaws Close In",
"category": "Transitions",
"direction": "in",
"description": "Toothed edges close in from the top and bottom until the layer is whole.",
"effects": [
{ "effect": "ec.transition.ccjaws", "set": { "shape": 2, "height": 30, "width": 40 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
]
}
]
}

View File

@ -0,0 +1,7 @@
asset: transitions.json
title: EffectCraft Transitions animation presets (Fade In/Out, Wipe In from Left/Right/Top/Bottom, Diagonal Sweep In, Wipe Out to Right, Clock Reveal, Fan Open, Clock Wipe Out, Blinds Open/Close, Slats Down, Pixel Dissolve In/Out, Mosaic Fade In, Card Flip In/Out, Grid Doors In, Lens Pop In, Line Sweep In, Jaws Close In)
author: EffectCraft contributors
source: original work
license: MIT OR Apache-2.0
added: 2026-10-11 by vangel76 (Claude Code agent, #757)
notes: Effect choices, values and key timings written from scratch for EffectCraft on its own Transition effects; not derived from any Adobe preset (.ffx) or its values.

View File

@ -320,8 +320,7 @@ impl Session {
if times.len() < 2 {
return Err("the layer must be at least two frames long".into());
}
let (w, h) = effectcraft_render::source_size(&self.project, l);
let size = if w == 0 { [c.width as f64, c.height as f64] } else { [w as f64, h as f64] };
let size = effectcraft_render::effect_bounds(&self.project, c, l).0;
let params = static_params(g);
let key = tracks_key(&self.project, comp, l, g);
let settings = ct::settings(&params);

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",
@ -241,6 +265,27 @@ mod tests {
assert!(s.execute("layer.align", json!({"edge": "diagonal", "layers": [a]})).is_err());
}
/// #352: shape layers align and snap by their visual edges (half the stroke, no
/// anti-aliasing padding), so two rectangles lined up edge to edge touch with no gap.
#[test]
fn shape_layers_align_by_their_visual_edges() {
let mut s = session();
let shape = |s: &mut Session, extra: Value| {
let mut p = json!({"kind": "rect", "size": [100, 100], "fill": "#ff0000"});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
s.execute("layer.newShape", p).unwrap()["layer"].as_u64().unwrap()
};
let a = shape(&mut s, json!({}));
let b = shape(&mut s, json!({"stroke": "#ffffff", "strokeWidth": 10}));
let width = |s: &Session, id| {
let r = bounds(s, id);
r[2] - r[0]
};
assert!((width(&s, a) - 100.0).abs() < 1e-6 && (width(&s, b) - 110.0).abs() < 1e-6, "{} {}", width(&s, a), width(&s, b));
s.execute("layer.align", json!({"edge": "left", "layers": [a, b]})).unwrap();
assert!(bounds(&s, a)[0].abs() < 1e-6 && bounds(&s, b)[0].abs() < 1e-6);
}
#[test]
fn align_honours_parent_scale_and_rotation() {
let mut s = session();
@ -282,4 +327,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

@ -6,7 +6,7 @@ use effectcraft_project::{LayerId, Property, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::prop::prop_ref;
use super::prop::{KeyTimeBase, key_time, key_timing, prop_ref};
use super::{CommandSpec, bad, comp_id, f_p, has_comp, has_keys, layers_p, merge_p, str_p, time_p};
use crate::{EngineError, KeyClip, KeyRef, Result, Session, cmd, query};
@ -105,7 +105,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
if plan.is_empty() {
return Err(bad("keys.paste", "no compatible property to paste into (select a property of the same type)"));
}
s.edit("Paste Keyframes", None, |proj, st| {
s.edit("Paste Keyframes", merge_p(p), |proj, st| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let mut sel = vec![];
for (lid, uid, ci) in &plan {
@ -206,11 +206,13 @@ fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
/// Edit one key directly: move it in time and/or set its value (Graph Editor drags).
fn set_key(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "keys.set")?;
let t = Tick::from_seconds_f64(f_p(p, "time").ok_or_else(|| bad("keys.set", "missing `time` (layer time of the key)"))?);
let new_t = f_p(p, "newTime").map(Tick::from_seconds_f64);
let base = KeyTimeBase::from_params(p, "keys.set")?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let t = key_time(layer, comp.frame_rate, p, "time", base, "keys.set", false)?.ok_or_else(|| bad("keys.set", "missing `time` (layer time by default)"))?;
let new_t = key_time(layer, comp.frame_rate, p, "newTime", base, "keys.set", true)?;
let value = p.get("value").cloned();
let fr = s.project.comp(cid).map(|c| c.frame_rate);
s.edit("Edit Keyframe", merge_p(p), |proj, st| {
let nt = s.edit("Edit Keyframe", merge_p(p), |proj, st| {
let l = super::layer_mut(proj, cid, lid)?;
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.set", "no property"))?;
let i = nearest_key(&pr.keys, t).ok_or_else(|| bad("keys.set", "the property has no keyframes"))?;
@ -221,10 +223,10 @@ fn set_key(s: &mut Session, p: &Value) -> Result<Value> {
}
let mut nt = old_t;
if let Some(n) = new_t {
nt = fr.map(|r| r.snap_nearest(n)).unwrap_or(n);
nt = n;
// Keys can't pass their neighbours or share a time.
let lo = if i > 0 { pr.keys[i - 1].time.0 + 1 } else { i64::MIN };
let hi = if i + 1 < pr.keys.len() { pr.keys[i + 1].time.0 - 1 } else { i64::MAX };
let lo = if i > 0 { pr.keys[i - 1].time.0.saturating_add(1) } else { i64::MIN };
let hi = if i + 1 < pr.keys.len() { pr.keys[i + 1].time.0.saturating_sub(1) } else { i64::MAX };
nt = Tick(nt.0.clamp(lo, hi));
pr.keys[i].time = nt;
}
@ -233,8 +235,10 @@ fn set_key(s: &mut Session, p: &Value) -> Result<Value> {
for k in st.selected_keys.iter_mut().filter(|k| k.layer == lid && k.prop == uid && k.time == old_t) {
k.time = nt;
}
Ok(json!({"time": nt.seconds()}))
})
Ok(nt)
})?;
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
Ok(key_timing(layer, nt))
}
fn nearest_key(keys: &[Keyframe], t: Tick) -> Option<usize> {
@ -449,7 +453,15 @@ pub fn specs() -> Vec<CommandSpec> {
s,
&json!({"frames": -10})
)),
cmd!("keys.set", "Edit Keyframe", [], None, "{layer?, path|prop, time (layer s), newTime?, value?, merge?}", has_comp, set_key),
cmd!(
"keys.set",
"Edit Keyframe",
[],
None,
"{layer?, path|prop, time (layer s by default), newTime? (same base), timeBase?: layer|comp, value?, merge?}",
has_comp,
set_key
),
cmd!("keys.selectEqual", "Select Equal Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "equal")),
cmd!("keys.selectPrevious", "Select Previous Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "previous")),
cmd!("keys.selectFollowing", "Select Following Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "following")),

View File

@ -0,0 +1,177 @@
//! EffectCraft's own animation preset library (Effects & Presets ▸ * Animation Presets): the
//! Transitions category, original presets built on our Transition effects and layer opacity.
//!
//! Each preset adds effects (with values and keyframes) and/or keys layer properties, starting
//! at the current time, on every selected layer. The library is data
//! (`presets/transitions.json`), written from scratch; nothing comes from After Effects' `.ffx`
//! presets.
use effectcraft_keyframe::Keyframe;
use effectcraft_project::build::Ids;
use effectcraft_project::{Node, Property};
use effectcraft_time::Tick;
use serde_json::{Map, Value, json};
use super::text_anim::json_value;
use super::{CommandSpec, bad, has_layers, layer_mut, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd, query};
/// The original transition presets (`presets/transitions.json`).
pub const TRANSITIONS_JSON: &str = include_str!("../../presets/transitions.json");
/// Keys per animated property, so a hostile inline preset can't allocate without bound.
const MAX_KEYS: usize = 256;
fn presets() -> Vec<Value> {
serde_json::from_str::<Value>(TRANSITIONS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default()
}
fn text<'a>(v: &'a Value, key: &str) -> Option<&'a str> {
v.get(key).and_then(Value::as_str)
}
/// (id, name, category) of every built-in animation preset, in library order.
pub fn preset_names() -> Vec<(String, String, String)> {
presets()
.iter()
.filter_map(|p| Some((text(p, "id")?.to_string(), text(p, "name")?.to_string(), text(p, "category").unwrap_or("Transitions").to_string())))
.collect()
}
fn find_preset(want: &str) -> Option<Value> {
presets().into_iter().find(|x| text(x, "id") == Some(want) || text(x, "name").is_some_and(|n| n.eq_ignore_ascii_case(want)))
}
/// Set `pr` to `v` (when given) and replace its keys with `keys` (`[[seconds, value], …]`,
/// seconds from `at`). Returns false when nothing in the request fits the property.
fn set_prop(pr: &mut Property, v: Option<&Value>, keys: Option<&Value>, at: Tick, ease: bool) -> bool {
let mut ok = true;
if let Some(v) = v {
match json_value(v, &pr.value) {
Some(val) => pr.value = val,
None => ok = false,
}
}
let Some(list) = keys else { return ok };
let Some(list) = list.as_array() else { return false };
let mut ks: Vec<Keyframe> = Vec::new();
for k in list.iter().take(MAX_KEYS) {
let t = k.get(0).and_then(Value::as_f64).filter(|t| t.is_finite() && t.abs() <= 86_400.0);
let (Some(t), Some(val)) = (t, k.get(1).and_then(|x| json_value(x, &pr.value))) else { return false };
let mut key = Keyframe::new(at + Tick::from_seconds_f64(t), val);
if pr.hold_only {
key = key.hold();
} else if ease {
key = key.eased();
}
ks.push(key);
}
ks.sort_by_key(|k| k.time);
ks.dedup_by_key(|k| k.time);
let Some(first) = ks.first() else { return false };
pr.value = first.value.clone();
pr.keys = ks;
ok
}
/// Apply `set` / `keys` maps to the properties under `root`; returns the paths that don't exist
/// or don't take the given values.
fn apply_maps(root: &mut effectcraft_project::PropGroup, spec: &Value, at: Tick) -> Vec<String> {
let ease = spec.get("ease").and_then(Value::as_bool).unwrap_or(false);
let empty = Map::new();
let set = spec.get("set").and_then(Value::as_object).unwrap_or(&empty);
let keys = spec.get("keys").and_then(Value::as_object).unwrap_or(&empty);
let mut skipped = Vec::new();
for (path, v) in set {
if !root.prop_mut(path).is_some_and(|pr| set_prop(pr, Some(v), None, at, false)) {
skipped.push(path.clone());
}
}
for (path, list) in keys {
if !root.prop_mut(path).is_some_and(|pr| !pr.static_only && set_prop(pr, None, Some(list), at, ease)) {
skipped.push(path.clone());
}
}
skipped
}
/// `anim.applyBuiltinPreset`: add a built-in preset's effects and keyframes to the layers.
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "anim.applyBuiltinPreset";
let want = str_p(p, "preset").ok_or_else(|| bad(C, "`preset` id or name required"))?;
let preset = find_preset(want).ok_or_else(|| bad(C, format!("no animation preset `{want}`")))?;
let name = text(&preset, "name").unwrap_or("Preset").to_string();
let effects: Vec<(&'static effectcraft_effects::EffectSpec, Value)> = preset
.get("effects")
.and_then(Value::as_array)
.into_iter()
.flatten()
.map(|e| {
let id = text(e, "effect").unwrap_or_default();
effectcraft_effects::lookup(id).map(|spec| (spec, e.clone())).ok_or_else(|| bad(C, format!("preset `{name}` uses unknown effect `{id}`")))
})
.collect::<Result<_>>()?;
let layer_spec = preset.get("layer").cloned();
let (cid, ids) = layers_p(s, p)?;
if ids.is_empty() {
return Err(bad(C, "select a layer"));
}
let sizes: Vec<_> = {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
ids.iter().filter_map(|id| comp.layer(*id)).map(|l| (l.id, effectcraft_render::effect_bounds(&s.project, comp, l).0)).collect()
};
let now = s.time();
let (added, skipped) = s.edit(&format!("Apply Animation Preset {name}"), None, |proj, _| {
let mut added: Vec<u64> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
for (lid, size) in &sizes {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, *lid)?;
let at = l.layer_time(now);
if !effects.is_empty() {
let fx = l.props.sub_mut("effects").ok_or_else(|| bad(C, "this layer type has no effects"))?;
for (spec, ej) in &effects {
let same = fx.groups().filter(|g| g.match_id == spec.id).count();
let inst = if same == 0 { spec.name.to_string() } else { format!("{} {}", spec.name, same.saturating_add(1)) };
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), &inst, *size);
skipped.extend(apply_maps(&mut g, ej, at).into_iter().map(|path| format!("{}/{path}", spec.id)));
added.push(g.uid);
fx.children.push(Node::Group(g));
}
}
if let Some(ls) = &layer_spec {
skipped.extend(apply_maps(&mut l.props, ls, at));
}
proj.next_id = next;
}
Ok((added, skipped))
})?;
Ok(json!({"preset": text(&preset, "id"), "effects": added, "skipped": skipped}))
}
/// `anim.builtinPresets`: the library, optionally one category.
fn list(_: &mut Session, p: &Value) -> Result<Value> {
let cat = str_p(p, "category");
Ok(Value::Array(
presets()
.iter()
.filter(|x| cat.is_none_or(|c| text(x, "category").is_some_and(|k| k.eq_ignore_ascii_case(c))))
.map(|x| {
json!({
"id": x.get("id"),
"name": x.get("name"),
"category": x.get("category"),
"direction": x.get("direction"),
"description": x.get("description"),
})
})
.collect(),
))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("anim.applyBuiltinPreset", "Apply Built-in Animation Preset", [], None, "{preset: id|name (see anim.builtinPresets), layers?}", has_layers, apply),
query!("anim.builtinPresets", "Built-in Animation Presets", "{category?}", list),
]
}

View File

@ -5,7 +5,8 @@
use std::collections::BTreeMap;
use effectcraft_keyframe::{Interp, Keyframe, Value as KV};
use effectcraft_project::{GroupKind, LayerId, Node, PropGroup, Property, Uid};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{GroupKind, Layer, LayerId, LayerSource, Node, Project, PropGroup, Property, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@ -293,6 +294,9 @@ fn modified(pr: &Property, group: &str, default_anchor: [f64; 2], comp_center: [
match (group, pr.match_id.as_str()) {
("transform", "anchor") => !near(&v.as_vec2(), &default_anchor),
("transform", "position") => !near(&v.as_vec2(), &comp_center) || v.as_vec3()[2].abs() > 1e-6,
// Separate Dimensions: X and Y Position default to the comp centre.
("transform", "positionX") => (v.as_f64() - comp_center[0]).abs() > 1e-6,
("transform", "positionY") => (v.as_f64() - comp_center[1]).abs() > 1e-6,
("transform", "scale") => !near(&v.as_vec3(), &[100.0; 3]),
("transform", "opacity") => (v.as_f64() - 100.0).abs() > 1e-6,
("transform", "orientation") => !near(&v.as_vec3(), &[0.0; 3]),
@ -301,6 +305,50 @@ fn modified(pr: &Property, group: &str, default_anchor: [f64; 2], comp_center: [
}
}
/// The group a fresh layer of `l`'s kind (`fresh`) has in place of `top`, for the groups whose
/// defaults are fixed: Audio, Material / Geometry / Camera / Light Options, a text layer's Path
/// and More Options, and a camera's or light's Transform (whose defaults aren't a 2D layer's).
fn default_group(top: &PropGroup, l: &Layer, fresh: &Layer) -> Option<PropGroup> {
let mut next = 0;
let mut ids = Ids(&mut next);
let camera_or_light = matches!(l.source, LayerSource::Camera | LayerSource::Light { .. });
let mut g = match top.match_id.as_str() {
"transform" if !camera_or_light => return None,
// Added to a layer after it is made (a source with audio, an Advanced 3D comp).
"audio" => build::audio(&mut ids),
"geometryOptions" if matches!(l.source, LayerSource::Text | LayerSource::Shape) => build::extrusion_geometry_options(&mut ids),
"transform" | "cameraOptions" | "lightOptions" | "materialOptions" | "geometryOptions" | "text" => fresh.props.sub(&top.match_id)?.clone(),
_ => return None,
};
match top.match_id.as_str() {
// Source Text and the animators are the layer's content, not options with a default.
"text" => g.children.retain(|c| matches!(c.match_id(), "pathOptions" | "moreOptions")),
// A model's scale and clip come from its file.
"geometryOptions" if matches!(l.source, LayerSource::Model { .. }) => g.children.retain(|c| !matches!(c.match_id(), "unitScale" | "animation")),
_ => {}
}
Some(g)
}
/// Static properties of `g` whose value differs from the same property of `default` (matched by
/// match id; properties `default` lacks have no known default and are left out).
fn changed_from(g: &PropGroup, default: &PropGroup, out: &mut Vec<Uid>) {
for c in &g.children {
match c {
Node::Prop(pr) => {
if default.get(&pr.match_id).is_some_and(|d| d.value != pr.value) && !out.contains(&pr.uid) {
out.push(pr.uid);
}
}
Node::Group(sg) => {
if let Some(d) = default.sub(&sg.match_id) {
changed_from(sg, d, out);
}
}
}
}
}
fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
let kind = str_p(p, "kind").unwrap_or("keyframes").to_string();
let cid = super::comp_id(s, p)?;
@ -312,10 +360,27 @@ fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
let (w, h) = effectcraft_render::source_size(&s.project, l);
let anchor = [w as f64 / 2.0, h as f64 / 2.0];
let center = [comp.width as f64 / 2.0, comp.height as f64 / 2.0];
// Properties at the top of the layer's tree (Time Remap): enabled time remapping is a
// modification in itself.
for pr in l.props.props() {
let hit = match kind.as_str() {
"keyframes" => pr.is_animated(),
"animation" => pr.is_animated() || pr.has_expression(),
_ => pr.match_id == "timeRemap" || modified(pr, "", anchor, center),
};
if hit {
out.push(json!({"layer": lid.0, "prop": pr.uid}));
}
}
// Modified: a fresh layer of the same kind gives the defaults to compare against.
let fresh = (kind == "modified").then(|| build::layer(&mut Project::default(), comp, "", l.source.clone(), (w, h), None));
let camera_or_light = matches!(l.source, LayerSource::Camera | LayerSource::Light { .. });
for top in l.props.groups() {
let hit = |pr: &Property| match kind.as_str() {
"keyframes" => pr.is_animated(),
"animation" => pr.is_animated() || pr.has_expression(),
// A camera's or light's Transform is compared with a fresh one's below.
_ if camera_or_light && top.match_id == "transform" => pr.is_animated() || pr.expr.is_some(),
_ => modified(pr, &top.match_id, anchor, center),
};
let mut found = vec![];
@ -335,10 +400,28 @@ fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
found.push(g.uid);
}
}
if let Some(fresh) = &fresh {
if let Some(default) = default_group(top, l, fresh) {
changed_from(top, &default, &mut found);
}
// Every layer style counts in full, like an effect; so does each overridden
// Essential Property.
if top.match_id == effectcraft_project::styles::GROUP {
found.extend(top.groups().filter(|g| g.match_id != effectcraft_project::styles::BLENDING).map(|g| g.uid));
}
if let GroupKind::Essential { overridden } = &top.kind {
for u in overridden {
if !found.contains(u) && (top.find(*u).is_some() || top.find_group(*u).is_some()) {
found.push(*u);
}
}
}
}
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

@ -128,7 +128,7 @@ fn clear_recent_presets(s: &mut Session, _: &Value) -> Result<Value> {
fn save_copy_xml(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopyAsXml", "missing `path` (.ecprojx)"))?;
let xml = crate::xml_project::to_xml(&s.project.to_file_json()?).map_err(EngineError::Other)?;
let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view())?).map_err(EngineError::Other)?;
s.services.write_file(path, xml.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
s.toast(format!("Saved an XML copy to {path}"));
Ok(json!({"path": path, "bytes": xml.len()}))
@ -194,7 +194,7 @@ fn execute_file(s: &mut Session, p: &Value) -> Result<Value> {
s.events.push(Event::Frontend { command: "app.confirmExecute".into(), params: json!({"path": path}) });
return Ok(json!({"needsConfirmation": path}));
}
let url = format!("file://{}", path.replace(' ', "%20"));
let url = super::layer_menu::file_url(path);
s.events.push(Event::OpenUrl(url));
Ok(json!({"opened": path}))
}
@ -288,7 +288,12 @@ fn enable_logging(s: &mut Session, p: &Value) -> Result<Value> {
}
fn reveal_log(s: &mut Session, _: &Value) -> Result<Value> {
let file = crate::logging::current_file().unwrap_or_else(|| log_file(s));
// Enable Logging's file while it is on, else this run's session log.
let file = crate::logging::current_file()
.filter(|_| crate::logging::is_enabled())
.or_else(crate::logging::session_file)
.or_else(crate::logging::current_file)
.unwrap_or_else(|| log_file(s));
let path = file.to_string_lossy().to_string();
let url = super::layer_menu::folder_url(&path);
s.events.push(Event::OpenUrl(url.clone()));
@ -316,6 +321,8 @@ pub const IMPORT_FORMATS: &[&str] = &[
"Opus",
"glTF/GLB",
"OBJ",
"STL",
"FBX",
"Lottie JSON",
];
@ -328,7 +335,10 @@ pub fn system_report(s: &Session) -> Value {
let budgets = s.prefs.cache_budgets(mem.or(s.sys_memory));
let mut issues = vec![];
if gpu.is_none() {
issues.push("No GPU compositor: rendering uses the CPU (all features still work).".to_string());
issues.push(match &s.accel_note {
Some(why) => format!("No GPU compositor ({why}): rendering uses the CPU (all features still work)."),
None => "No GPU compositor: rendering uses the CPU (all features still work).".to_string(),
});
}
if mem.is_some_and(|m| m.total < 8 << 30) {
issues.push("Less than 8 GB of memory: use lower preview resolutions for long or large comps.".to_string());
@ -344,6 +354,8 @@ pub fn system_report(s: &Session) -> Value {
"cores": crate::sysinfo::cpu_cores(),
"memory": mem.map(|m| json!({"total": gb(m.total), "available": gb(m.available)})),
"gpu": gpu,
"gpuNote": if gpu.is_none() { s.accel_note.clone() } else { None },
"windowGraphics": s.window_adapter,
"caches": {"layer": gb(budgets.layer as u64), "footage": gb(budgets.media as u64), "preview": gb(budgets.preview as u64)},
"import": IMPORT_FORMATS,
"export": export,
@ -369,7 +381,14 @@ pub fn report_text(r: &Value) -> String {
Some(m) => t.push_str(&format!("Memory: {} ({} available)\n", m["total"].as_str().unwrap_or("?"), m["available"].as_str().unwrap_or("?"))),
None => t.push_str("Memory: unknown\n"),
}
t.push_str(&format!("GPU: {}\n", r.get("gpu").and_then(Value::as_str).unwrap_or("none (CPU compositing)")));
match (r.get("gpu").and_then(Value::as_str), r.get("gpuNote").and_then(Value::as_str)) {
(Some(gpu), _) => t.push_str(&format!("GPU: {gpu}\n")),
(None, Some(why)) => t.push_str(&format!("GPU: none (CPU compositing): {why}\n")),
(None, None) => t.push_str("GPU: none (CPU compositing)\n"),
}
if let Some(w) = r.get("windowGraphics").and_then(Value::as_str) {
t.push_str(&format!("Window graphics: {w}\n"));
}
let c = &r["caches"];
t.push_str(&format!(
"Cache budgets: layer {}, footage {}, preview {}\n",
@ -602,3 +621,25 @@ pub fn specs() -> Vec<CommandSpec> {
query!("help.systemInfo", "System Information", "{}", |s, _| Ok(system_report(s))),
]
}
#[cfg(test)]
mod tests {
use serde_json::json;
/// `File.execute()` opens the file as a well-formed `file://` URL (spaces, `#`, `%`).
#[test]
fn execute_file_opens_an_encoded_file_url() {
let dir = std::env::temp_dir().join(format!("ec-exec-url-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("run #1 100%.txt");
std::fs::write(&f, "x").unwrap();
let mut s = crate::Session::default();
s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap();
s.drain_events();
s.execute("file.executeFile", json!({"path": f.to_string_lossy(), "confirmed": true})).unwrap();
let expected = super::super::layer_menu::file_url(&f.to_string_lossy());
assert!(expected.ends_with("/run%20%231%20100%25.txt"), "{expected}");
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if *u == expected)));
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -109,6 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Run Commands (Batch)",
menu: &[],
shortcut: None,
alt_shortcuts: &[],
params: "{steps: [{command, params?}], label? (undo step name, default Batch), atomic?: bool (default true: a failing step rolls the whole batch back)} → {steps, results: [each step's result]}; a string param \"$N\" or \"$N.key.0\" is step N's result (1-based), e.g. {\"layer\": \"$1.layer\"}",
enabled: always,
run: batch,

View File

@ -1,7 +1,7 @@
//! Composition menu.
use effectcraft_color::Label;
use effectcraft_project::{Comp, ItemId, ItemKind, Marker};
use effectcraft_project::{Comp, ItemId, ItemKind, Marker, limits};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
@ -15,7 +15,7 @@ pub(crate) fn rate_p(p: &Value) -> Option<FrameRate> {
/// A zero, negative or tiny rate would make a zero-length frame (and divide by zero later).
fn check_rate(p: &Value, cmd: &str) -> Result<()> {
match f_p(p, "frameRate").or(f_p(p, "fps")) {
Some(f) if !(0.001..=1000.0).contains(&f) => Err(bad(cmd, "frameRate: fps from 0.001 to 1000")),
Some(f) if !(limits::MIN_FPS..=limits::MAX_FPS).contains(&f) => Err(bad(cmd, "frameRate: fps from 0.001 to 1000")),
_ => Ok(()),
}
}
@ -23,10 +23,10 @@ fn check_rate(p: &Value, cmd: &str) -> Result<()> {
fn apply_settings(c: &mut Comp, p: &Value) {
let (ow, oh) = (c.width, c.height);
if let Some(w) = p.get("width").and_then(Value::as_u64) {
c.width = (w as u32).clamp(4, 30000);
c.width = limits::size(w).max(4);
}
if let Some(h) = p.get("height").and_then(Value::as_u64) {
c.height = (h as u32).clamp(4, 30000);
c.height = limits::size(h).max(4);
}
// Advanced › Anchor: where the old frame sits in the resized one (0 = top left … 4 = center …
// 8 = bottom right). Unparented layers (and all their position keys) move by that offset.
@ -36,19 +36,8 @@ fn apply_settings(c: &mut Comp, p: &Value) {
let dy = (c.height as f64 - oh as f64) * (a / 3) as f64 / 2.0;
if dx != 0.0 || dy != 0.0 {
for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) {
let Some(pr) = l.props.prop_mut("transform/position") else { continue };
let shift = |v: &mut effectcraft_keyframe::Value| {
if let effectcraft_keyframe::Value::Vec2(x) = v {
x[0] += dx;
x[1] += dy;
} else if let effectcraft_keyframe::Value::Vec3(x) = v {
x[0] += dx;
x[1] += dy;
}
};
shift(&mut pr.value);
for k in &mut pr.keys {
shift(&mut k.value);
if let Some(tr) = l.transform_mut() {
super::layer::offset_position(tr, dx, dy);
}
}
}
@ -76,9 +65,11 @@ fn apply_settings(c: &mut Comp, p: &Value) {
c.work_area.1 = c.duration;
}
c.work_area.1 = c.work_area.1.min(c.duration);
// A shorter composition must not leave the start past the end.
keep_work_area_ordered(c);
}
if let Some(pa) = f_p(p, "pixelAspect") {
c.pixel_aspect = pa.max(0.1);
c.pixel_aspect = limits::pixel_aspect(pa.max(limits::MIN_PIXEL_ASPECT));
}
if let Some(Value::Array(bg)) = p.get("background") {
let g = |i: usize| bg.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32;
@ -93,10 +84,10 @@ fn apply_settings(c: &mut Comp, p: &Value) {
c.shutter_phase = a.clamp(-360.0, 360.0);
}
if let Some(n) = p.get("motionBlurSamples").and_then(Value::as_u64) {
c.motion_blur_samples = (n as u32).clamp(2, 64);
c.motion_blur_samples = n.clamp(limits::MOTION_BLUR_SAMPLES.0.into(), limits::MOTION_BLUR_SAMPLES.1.into()) as u32;
}
if let Some(n) = p.get("adaptiveSampleLimit").and_then(Value::as_u64) {
c.motion_blur_adaptive_limit = (n as u32).clamp(16, 256);
c.motion_blur_adaptive_limit = n.clamp(limits::ADAPTIVE_SAMPLE_LIMIT.0.into(), limits::ADAPTIVE_SAMPLE_LIMIT.1.into()) as u32;
}
if let Some(b) = p.get("preserveFrameRate").and_then(Value::as_bool) {
c.preserve_frame_rate = b;
@ -191,22 +182,46 @@ fn work_area(s: &mut Session, p: &Value) -> Result<Value> {
_ => {}
}
if let Some(b) = begin {
c.work_area.0 = b.clamp(Tick::ZERO, c.duration - fd);
// min/max, not clamp: the bounds cross on a comp shorter than a frame or an inverted area.
c.work_area.0 = b.min(c.duration - fd).max(Tick::ZERO);
}
if let Some(e) = end {
c.work_area.1 = e.clamp(c.work_area.0 + fd, c.duration);
c.work_area.1 = e.max(c.work_area.0 + fd).min(c.duration);
}
// A start on its own can land after the current end. Pull it back so the interval
// still covers one frame, the same rule as `set: "begin"`.
keep_work_area_ordered(c);
Ok(())
})?;
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;
Ok(json!({"start": c.work_area.0.seconds(), "end": c.work_area.1.seconds()}))
}
/// Keep `work_area` inside the composition and at least one frame long.
fn keep_work_area_ordered(c: &mut Comp) {
let fd = c.frame_duration();
c.work_area.1 = c.work_area.1.min(c.duration);
if c.work_area.0 < Tick::ZERO {
c.work_area.0 = Tick::ZERO;
}
if c.work_area.0 + fd > c.work_area.1 {
c.work_area.0 = (c.work_area.1 - fd).max(Tick::ZERO);
}
if c.work_area.0 + fd > c.work_area.1 {
c.work_area.1 = (c.work_area.0 + fd).min(c.duration);
}
}
fn trim_to_wa(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
s.edit("Trim Comp to Work Area", None, |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let (a, b) = c.work_area;
let fd = c.frame_duration();
// An interval that is already inverted must not become a negative duration.
if a < Tick::ZERO || b > c.duration || a + fd > b {
return Err(bad("comp.trimToWorkArea", "the work area must cover at least one frame inside the composition"));
}
for l in &mut c.layers {
l.start_time -= a;
l.in_point = (l.in_point - a).max(Tick::ZERO);
@ -268,7 +283,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 +292,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

@ -1,13 +1,13 @@
//! Composition menu, part two: crop to region of interest / layer bounds, Save Frame As ▸ File,
//! Responsive Design — Time.
//! Copy Frame to Clipboard, Responsive Design — Time.
use effectcraft_keyframe::Value as KV;
use effectcraft_project::Marker;
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd};
use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p};
use crate::prefs::CompPreset;
use crate::{EngineError, Result, Session, cmd, query};
fn has_roi(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
@ -18,20 +18,15 @@ fn has_roi(s: &Session) -> std::result::Result<(), String> {
/// (all position keys too) so nothing moves on screen.
fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: &str) -> Result<Value> {
let (x, y) = (r[0].round(), r[1].round());
let (w, h) = (r[2].round().max(1.0) as u32, r[3].round().max(1.0) as u32);
// At most 30,000 px a side, like any composition (#788).
let (w, h) = (effectcraft_project::limits::size_f64(r[2]), effectcraft_project::limits::size_f64(r[3]));
s.edit(label, None, |proj, st| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
c.width = w;
c.height = h;
for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) {
let Some(pos) = l.transform_mut().and_then(|tr| tr.get_mut("position")) else { continue };
let mv = |v: &KV| {
let a = v.as_vec3();
KV::Vec3([a[0] - x, a[1] - y, a[2]])
};
pos.value = mv(&pos.value);
for k in &mut pos.keys {
k.value = mv(&k.value);
if let Some(tr) = l.transform_mut() {
super::layer::offset_position(tr, -x, -y);
}
}
for g in &mut c.guides {
@ -127,6 +122,26 @@ fn save_frame(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"path": path, "width": img.width, "height": img.height, "time": t.seconds()}))
}
/// Composition ▸ Copy Frame to Clipboard (After Effects 26.3): render the frame as Save Frame As
/// ▸ File does and hand it to the frontend, which puts it on the system clipboard.
fn copy_frame(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(s.time());
let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0);
let bg = comp.background;
let img = s.render(cid, t, effectcraft_render::RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() });
let (width, height) = (img.width, img.height);
let rgba = img.to_rgba8_over(bg);
let expected = (width as usize).checked_mul(height as usize).and_then(|n| n.checked_mul(4));
if width == 0 || height == 0 || expected != Some(rgba.len()) {
return Err(EngineError::Other(format!("cannot copy an empty or malformed {width}×{height} frame")));
}
s.events.push(crate::Event::CopyImage { width, height, rgba });
s.toast("Copied frame to the clipboard");
Ok(json!({"width": width, "height": height, "time": t.seconds()}))
}
/// Responsive Design — Time: protected comp markers for an intro, an outro or the work area.
fn responsive_time(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
@ -150,6 +165,65 @@ fn responsive_time(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"start": start.seconds(), "end": end.seconds()}))
}
// ---------------------------------------------------------------- composition presets
/// `comp.savePreset`: remember a frame size, pixel aspect and frame rate under a name in the
/// settings (shown in Composition Settings ▸ Preset). A preset with the same name is replaced.
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "comp.savePreset";
let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?;
let num = |k: &str| f_p(p, k).ok_or_else(|| bad(C, format!("missing `{k}`")));
let dim = |k: &str| {
num(k).and_then(|v| {
if v.fract() == 0.0 && (0.0..=f64::from(u32::MAX)).contains(&v) { Ok(v as u32) } else { Err(bad(C, format!("`{k}` must be a whole number"))) }
})
};
let preset =
CompPreset { name: name.to_string(), width: dim("width")?, height: dim("height")?, pixel_aspect: num("pixelAspect")?, frame_rate: num("frameRate")? };
if !preset.is_valid() {
return Err(bad(
C,
format!(
"not a valid preset: width and height 4–30000 px, pixel aspect 0.01–100, frame rate 1–999, a name of at most {} characters",
CompPreset::MAX_NAME
),
));
}
let presets = &mut s.prefs.comp_presets;
let replaced = match presets.iter_mut().find(|q| q.name == preset.name) {
Some(q) => {
*q = preset;
true
}
None => {
presets.push(preset);
false
}
};
s.save_prefs();
s.prefs_revision += 1;
Ok(json!({"name": name, "replaced": replaced}))
}
/// `comp.deletePreset`: forget a saved composition preset.
fn delete_preset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "comp.deletePreset";
let name = str_p(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let before = s.prefs.comp_presets.len();
s.prefs.comp_presets.retain(|q| q.name != name);
if s.prefs.comp_presets.len() == before {
return Err(bad(C, format!("no saved preset named `{name}`")));
}
s.save_prefs();
s.prefs_revision += 1;
Ok(Value::Null)
}
/// `comp.presets`: the saved composition presets.
fn list_presets(s: &mut Session, _: &Value) -> Result<Value> {
serde_json::to_value(&s.prefs.comp_presets).map_err(|e| bad("comp.presets", e.to_string()))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", ["Composition"], None, "{comp?}", has_roi, crop_to_roi),
@ -163,6 +237,18 @@ pub fn specs() -> Vec<CommandSpec> {
has_comp,
save_frame
),
cmd!("comp.copyFrame", "Copy Frame to Clipboard", ["Composition"], None, "{comp?, time?, scale?}", has_comp, copy_frame),
cmd!(
"comp.savePreset",
"Save Composition Preset",
[],
None,
"{name, width, height, pixelAspect, frameRate} (replaces a saved preset with the same name)",
always,
save_preset
),
cmd!("comp.deletePreset", "Delete Composition Preset", [], None, "{name}", always, delete_preset),
query!("comp.presets", "Composition Presets", "{}", list_presets),
cmd!("comp.responsiveTime", "Responsive Design — Time", [], None, "{op: intro|outro|workArea}", has_comp, responsive_time),
]
}

View File

@ -405,6 +405,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Generate Fill Layer",
menu: &[],
shortcut: None,
alt_shortcuts: &[],
params: P,
enabled: has_layers,
run: generate,
@ -415,6 +416,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Content-Aware Fill Layer...",
menu: &["Layer", "New"],
shortcut: None,
alt_shortcuts: &[],
params: P,
enabled: always,
run: open_panel,

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