286 Commits

Author SHA1 Message Date
Marcos Paulo 593d923f09 chore: release 0.9.3 2026-10-12 21:40:57 -03:00
Marcos Paulo 660f26e2fd feat(ui): Catppuccin themes, View > Preferences > Theme and a custom title bar
Adds the four Catppuccin themes, the View > Preferences > Theme menu, and a window-drawn
title bar on Windows and Linux (Settings > Appearance > Use System Title Bar opts out).
macOS gets the traffic lights over the top row.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 20:56:53 -03:00
Marcos Paulo fe23624202 build: use the masi/craft-libs fork
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 20:53:00 -03:00
Marcos Paulo 8b3d7e8516 build(windows): set working directory to repo root in package.ps1 2026-10-12 20:53:00 -03:00
Marcos Paulo 115cf2b972 build(windows): package standalone effectcraft-cli zip and auto-detect rc.exe 2026-10-12 20:53:00 -03:00
Marcos Paulo 91efb894f7 chore: debrand ArtCraft and Discord references
Remove Discord button, Help menu links, sibling-app links and brand assets; rewrite docs and packaging URLs. Add .debrand rules.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 20:53:00 -03:00
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
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
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
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
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
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
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
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
327 changed files with 25470 additions and 1833 deletions
+28
View File
@@ -0,0 +1,28 @@
# Strip upstream (ArtCraft / Discord) branding. Run: perl tools/craft-skills/skills/debrand/debrand.pl . (--check in CI)
delete docs/brand
# MIT/Apache texts stay verbatim. The copyright holder line stays everywhere (legal requirement).
keep ^(LICENSE-(MIT|APACHE)|\.debrand)$
dropline ^ATTRIBUTION\.md$ :: \|\s*`docs/brand/
sub (?s)\nThe ArtCraft name, wordmark and logos in docs/brand/.*?LICENSE-brand\.txt\.\n =>
sub drawn in ArtCraft, vectorised => drawn by hand, vectorised
sub drawing by the project owner in ArtCraft\) => drawing by the project owner)
sub made for this project by the project owner in ArtCraft, then => made for this project by the project owner by hand, then
sub the project owner drew it in ArtCraft => the project owner drew it by hand
sub owner's ArtCraft unicorn drawing => owner's unicorn drawing
sub The owner's ArtCraft drawing => The owner's drawing
sub https://getartcraft\.com/apps/effectcraft => https://github.com/storytold/effectcraft
sub https://discord\.gg/artcraft => https://github.com/storytold/effectcraft/issues
sub https://getartcraft\.com => https://example.com
sub \[Discord\]\(https://github\.com/storytold/effectcraft/issues\) and in\nGitHub issues => GitHub issues
sub Come tell us what matters most to you on \[Discord\]\(https://github\.com/storytold/effectcraft/issues\)\. => Tell us what matters most to you in [GitHub issues](https://github.com/storytold/effectcraft/issues).
sub links \(Help menu, Discord, Settings\) => links (Help menu, Settings)
sub the Discord and other links do nothing => the links do nothing
sub \(6 October, on Discord\) => (6 October)
sub the Tools bar and About dialog carry the ArtCraft mark => the Tools bar and About dialog carry the app mark
forbid (?i)artcraft(?! Team and the EffectCraft contributors)
forbid (?i)discord
sub ffmpeg export, Discord presence, => ffmpeg export,
dropline ^crates/ui-egui/src/i18n(?:\.rs|/ui\.rs)$ :: \s*\(".*(?:ArtCraft|Discord)
sub (?m)^ \(\n "[^"\n]*ArtCraft[^"\n]*",\n "[^"\n]*",\n \),\n =>
sub \(#612, reported on Discord\) => (#612, reported by a user)
sub Reported on Discord: => Reported by a user:
+8 -4
View File
@@ -2,15 +2,19 @@
# (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: [main]
branches: [release]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
workflow_dispatch:
concurrency:
+6
View File
@@ -34,3 +34,9 @@ jobs:
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 }}
+5 -7
View File
@@ -4,19 +4,17 @@
# 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: [main]
branches: [release]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
workflow_dispatch:
concurrency:
+3
View File
@@ -0,0 +1,3 @@
[submodule "tools/craft-skills"]
path = tools/craft-skills
url = https://git.marcospaulo.dev.br/storytold/craft-skills.git
-6
View File
@@ -76,12 +76,6 @@ reproduce an image (for example, point lists traced from someone else's icon).
`crates/engine/src/demo.rs`, generators in `crates/effects`) are original work under the project licence
and are listed in `ATTRIBUTION.md`.
7. **When in doubt, leave it out** and draw or generate it yourself.
8. **The one exception: first-party ArtCraft brand marks.** The ArtCraft name and logos in `docs/brand/`
are trademarks of the ArtCraft Team, not open source, usable only unmodified and only in the context of
EffectCraft under `docs/brand/LICENSE-brand.txt` (forks and modified versions must remove them). They still
need a sidecar and an `ATTRIBUTION.md` row (licence `LicenseRef-ArtCraft-Trademark`). No other
non-open asset is allowed, and this exception never covers third-party marks (Adobe, Discord, GitHub
and other logos stay out; draw a generic icon instead).
## 2. Clean-room code
+18 -34
View File
@@ -18,21 +18,21 @@ iconography, images or other Adobe assets; open licences only; every asset attri
| `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`) |
| `assets/app-icon/effectcraft.icns` | EffectCraft contributors | Original work: effectCraft macOS app icon (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/effectcraft.ico` | EffectCraft contributors | Original work: effectCraft Windows app icon (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/effectcraft.svg` | EffectCraft contributors | Original work: effectCraft app icon, master vector (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 128x128 (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 16x16 (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 24x24 (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 256x256 (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/32x32/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 32x32 (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/48x48/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 48x48 (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/512x512/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 512x512 (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 64x64 (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.effectcraft.svg` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor scalable (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/effectcraft-1024.png` | EffectCraft contributors | Original work: effectCraft app icon, 1024 px (owner's 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 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 unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/effectcraft.icns` | EffectCraft contributors | Original work: effectCraft macOS app icon (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/effectcraft.ico` | EffectCraft contributors | Original work: effectCraft Windows app icon (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/effectcraft.svg` | EffectCraft contributors | Original work: effectCraft app icon, master vector (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 128x128 (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 16x16 (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 24x24 (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 256x256 (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/32x32/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 32x32 (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/48x48/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 48x48 (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/512x512/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 512x512 (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 64x64 (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.effectcraft.svg` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor scalable (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `crates/lottie/tests/fixtures/bouncing-ball.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (bouncing ball, eased position keys) | MIT OR Apache-2.0 |
| `crates/lottie/tests/fixtures/shapes-gradient.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (star, rectangle, gradient fill, dashed stroke, trim paths) | MIT OR Apache-2.0 |
| `crates/lottie/tests/fixtures/precomp-matte-text.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (precomp, track matte, parented text) | MIT OR Apache-2.0 |
@@ -58,13 +58,15 @@ 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`
@@ -101,22 +103,4 @@ configuration from these Apache-2.0 projects:
- `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
covered by the project licence; they may be used only unmodified and only as part of this repository and EffectCraft,
under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and modified versions must remove them
(AGENTS.md §1.8).
| File | Author | Source | Licence |
|---|---|---|---|
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft wordmark, light ink for dark backgrounds) | ArtCraft brand terms, not open source (`docs/brand/LICENSE-brand.txt`) |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft wordmark, light ink for dark backgrounds) | ArtCraft brand terms, not open source (`docs/brand/LICENSE-brand.txt`) |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft wordmark (symbol + "ARTCRAFT"), dark ink for light backgrounds) | ArtCraft brand terms, not open source (`docs/brand/LICENSE-brand.txt`) |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft wordmark (symbol + "ARTCRAFT"), dark ink for light backgrounds) | ArtCraft brand terms, not open source (`docs/brand/LICENSE-brand.txt`) |
| `docs/brand/artcraft-mark-black.png` | 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-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).
+1 -1
View File
@@ -19,7 +19,7 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e
- **Property tree:** everything animatable is a `Property` in the layer's `PropGroup` tree, addressed by paths (`transform/position`, `effects/#1/blurriness`, `@uid`).
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP dispatch by id.
- **Everything is agent-drivable:** every interactive widget registers an automation id; UI state is serde.
- **Community links** (Help menu, About, header Discord button): https://discord.gg/artcraft, https://getartcraft.com, https://getartcraft.com/apps/effectcraft, https://github.com/storytold/effectcraft.
- **Project links** (Help menu, About): https://github.com/storytold/effectcraft.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm).
- **Commits:** one task id per commit (`M6.3: trim paths`). Only green states.
+1 -2
View File
@@ -42,5 +42,4 @@ cargo xtask ci # what every commit must pass
## Talk to us
Questions, ideas and bug reports are welcome on [Discord](https://discord.gg/artcraft) and in
GitHub issues.
Questions, ideas and bug reports are welcome on GitHub issues.
Generated
+42 -37
View File
@@ -965,7 +965,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
source = "git+https://git.marcospaulo.dev.br/masi/craft-libs?rev=22544b6d619d4f16779b8bdfa3f0b642a86fc065#22544b6d619d4f16779b8bdfa3f0b642a86fc065"
dependencies = [
"egui",
"serde",
@@ -1143,7 +1143,7 @@ dependencies = [
[[package]]
name = "effectcraft"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"cpal",
"effectcraft-engine",
@@ -1159,6 +1159,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"pollster 0.4.0",
"rfd",
"serde",
"serde_json",
"winit",
"winresource",
@@ -1167,7 +1168,7 @@ dependencies = [
[[package]]
name = "effectcraft-automation"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
@@ -1179,14 +1180,14 @@ dependencies = [
[[package]]
name = "effectcraft-av1enc"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"filmcraft-av1",
]
[[package]]
name = "effectcraft-cli"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"cpu-time",
"effectcraft-automation",
@@ -1202,7 +1203,7 @@ dependencies = [
[[package]]
name = "effectcraft-color"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"proptest",
"serde",
@@ -1210,7 +1211,7 @@ dependencies = [
[[package]]
name = "effectcraft-effects"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1227,7 +1228,7 @@ dependencies = [
[[package]]
name = "effectcraft-engine"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1260,7 +1261,7 @@ dependencies = [
[[package]]
name = "effectcraft-export"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"effectcraft-av1enc",
"effectcraft-color",
@@ -1290,7 +1291,7 @@ dependencies = [
[[package]]
name = "effectcraft-expr"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"boa_engine",
"effectcraft-color",
@@ -1307,7 +1308,7 @@ dependencies = [
[[package]]
name = "effectcraft-geom"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"proptest",
"serde",
@@ -1315,7 +1316,7 @@ dependencies = [
[[package]]
name = "effectcraft-gpu"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"bytemuck",
"effectcraft-color",
@@ -1337,14 +1338,14 @@ dependencies = [
[[package]]
name = "effectcraft-hevcenc"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"filmcraft-hevc",
]
[[package]]
name = "effectcraft-host"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -1368,7 +1369,7 @@ dependencies = [
[[package]]
name = "effectcraft-interchange"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"effectcraft-color",
"effectcraft-keyframe",
@@ -1387,7 +1388,7 @@ dependencies = [
[[package]]
name = "effectcraft-keyframe"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1399,7 +1400,7 @@ dependencies = [
[[package]]
name = "effectcraft-lottie"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1416,7 +1417,7 @@ dependencies = [
[[package]]
name = "effectcraft-media"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"effectcraft-model",
"effectcraft-pdf",
@@ -1430,6 +1431,7 @@ dependencies = [
"filmcraft-codecs",
"filmcraft-color",
"filmcraft-frame",
"filmcraft-isobmff",
"filmcraft-media",
"filmcraft-time",
"gif",
@@ -1442,10 +1444,11 @@ dependencies = [
[[package]]
name = "effectcraft-model"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"effectcraft-geom",
"image",
"miniz_oxide 0.8.9",
"serde",
"serde_json",
"thiserror 2.0.21",
@@ -1453,14 +1456,14 @@ dependencies = [
[[package]]
name = "effectcraft-opusenc"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"filmcraft-opus",
]
[[package]]
name = "effectcraft-path"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
@@ -1472,7 +1475,7 @@ dependencies = [
[[package]]
name = "effectcraft-pdf"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"effectcraft-raster",
"effectcraft-svg",
@@ -1486,7 +1489,7 @@ dependencies = [
[[package]]
name = "effectcraft-plugin"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
@@ -1498,7 +1501,7 @@ dependencies = [
[[package]]
name = "effectcraft-project"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1511,14 +1514,14 @@ dependencies = [
[[package]]
name = "effectcraft-psd"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"miniz_oxide 0.8.9",
]
[[package]]
name = "effectcraft-raster"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1527,7 +1530,7 @@ dependencies = [
[[package]]
name = "effectcraft-render"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1549,7 +1552,7 @@ dependencies = [
[[package]]
name = "effectcraft-script"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"boa_engine",
"effectcraft-engine",
@@ -1557,14 +1560,16 @@ dependencies = [
"effectcraft-keyframe",
"effectcraft-path",
"effectcraft-project",
"effectcraft-psd",
"effectcraft-render",
"effectcraft-time",
"serde_json",
"web-time",
]
[[package]]
name = "effectcraft-segment"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"miniz_oxide 0.8.9",
"ndarray",
@@ -1573,7 +1578,7 @@ dependencies = [
[[package]]
name = "effectcraft-svg"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"effectcraft-geom",
"effectcraft-path",
@@ -1585,7 +1590,7 @@ dependencies = [
[[package]]
name = "effectcraft-text"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"effectcraft-keyframe",
"harfrust",
@@ -1597,7 +1602,7 @@ dependencies = [
[[package]]
name = "effectcraft-time"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"proptest",
"serde",
@@ -1605,7 +1610,7 @@ dependencies = [
[[package]]
name = "effectcraft-track"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"effectcraft-raster",
"effectcraft-segment",
@@ -1617,7 +1622,7 @@ dependencies = [
[[package]]
name = "effectcraft-ui-egui"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"craft-ui",
"effectcraft-color",
@@ -1649,11 +1654,11 @@ dependencies = [
[[package]]
name = "effectcraft-vp9enc"
version = "0.8.0"
version = "0.9.3"
[[package]]
name = "effectcraft-web"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -6463,7 +6468,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.8.0"
version = "0.9.3"
dependencies = [
"serde_json",
]
+2 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*"]
[workspace.package]
version = "0.8.0"
version = "0.9.3"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -99,7 +99,7 @@ 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"] }
craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "22544b6d619d4f16779b8bdfa3f0b642a86fc065", features = ["serde"] }
egui = "0.36"
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
-4
View File
@@ -3,10 +3,6 @@ Copyright (c) 2026 ArtCraft Team and the EffectCraft contributors.
Licensed under the MIT License or the Apache License, Version 2.0, at your option
(LICENSE-MIT, LICENSE-APACHE).
The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are
not open source and are not covered by the license above; their terms are in
docs/brand/LICENSE-brand.txt.
Third-party material
This product bundles third-party material under its own licenses, listed below. The per-file
+11 -70
View File
@@ -1,13 +1,3 @@
<p align="center">
<a href="https://getartcraft.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/artcraft-logo-white.svg">
<img alt="ArtCraft" src="docs/brand/artcraft-logo.svg" width="200">
</picture>
</a>
</p>
<h1 align="center">EffectCraft</h1>
<p align="center">
@@ -26,26 +16,12 @@
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-555?style=flat-square">
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<p align="center">
<a href="https://getartcraft.com/apps/effectcraft"><b>EffectCraft on getartcraft.com</b></a> ·
<a href="https://getartcraft.com/">ArtCraft</a> ·
<a href="https://getartcraft.com/apps">All Crafting Apps</a>
</p>
<br>
<p align="center">
<img src="docs/images/effectcraft-hero.png" alt="EffectCraft's main window: the animated demo composition in the Composition panel, the Project panel, a Timeline with text, shape and solid layers, and the Properties panel showing the selected text layer's transform, font and paragraph settings" width="100%">
</p>
> [!NOTE]
> **ArtCraft is a community of artists from all walks of life.** Digital, generative, music,
> games &mdash; if you make things, you're one of us. **[Come say hi on Discord](https://discord.gg/artcraft).**
<p align="center">
<a href="#what-effectcraft-is">What it is</a> ·
<a href="#animate">Animate</a> ·
@@ -254,7 +230,7 @@ send us right now.
## 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.
**New to EffectCraft?** Download it from the [latest release](https://github.com/storytold/effectcraft/releases/latest). 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.
@@ -317,43 +293,18 @@ File ▸ Import fallback instead.
## The Crafting Apps
EffectCraft is one of the **Crafting Apps**: free, open-source creative tools from the
[ArtCraft](https://getartcraft.com/) team, each written from scratch in Rust and each able to
EffectCraft is one of the **Crafting Apps**: free, open-source creative tools, each written from scratch in Rust and each able to
stand on its own.
| | App | What it's for | Code | Learn more |
|:-:|---|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
| <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/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) |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
<br>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<h3 align="center">Come make things with us</h3>
<p align="center">
Our Discord is where artists of every kind hang out: people who paint, shoot, draw, cut film,
set type, and people still figuring out what they like to make. Share what you're working on,
ask for help, tell us what's broken, or tell us what you wish these tools could do.
Whatever your medium and however long you've been at it, you're welcome here.
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><b>discord.gg/artcraft</b></a> ·
<a href="https://getartcraft.com/">getartcraft.com</a> ·
<a href="https://getartcraft.com/apps">The Crafting Apps</a> ·
<a href="https://getartcraft.com/apps/effectcraft">EffectCraft</a>
</p>
| | App | What it's for | Code |
|:-:|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) |
| <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) |
| <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) |
| <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) |
| <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) |
| <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) |
| <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) |
## License and credits
@@ -363,18 +314,8 @@ Copyright (c) 2026 ArtCraft Team and the EffectCraft contributors. Required noti
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
part of this repository and EffectCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. EffectCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<p align="center">
<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)
+9 -2
View File
@@ -97,7 +97,7 @@ UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/lo
| Korean | none | 0% |
| Vietnamese | none | 0% |
Also shipped: Traditional Chinese (partial, 28%) and Ukrainian (partial, 26%). No language has had
Also shipped: Traditional Chinese (partial, 28%) and Ukrainian (partial, 29%). No language has had
native-speaker review.
## Upcoming
@@ -113,10 +113,17 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| 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).
Tell us what matters most to you in [GitHub issues](https://github.com/storytold/effectcraft/issues).
## 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
@@ -1,6 +1,6 @@
asset: favicon.svg
title: EffectCraft web app icon (favicon)
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
+5 -3
View File
@@ -14,9 +14,13 @@ effectcraft-engine = { workspace = true }
effectcraft-host = { workspace = true }
effectcraft-ui-egui = { workspace = true }
egui = { workspace = true }
# `links`: links (Help menu, Discord, Settings) open in the system browser.
# `links`: links (Help menu, 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 }
@@ -32,8 +36,6 @@ zbus = "5.19"
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.
+2 -2
View File
@@ -117,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())
@@ -187,7 +187,7 @@ impl AudioDevice for CpalOut {
return;
}
if let Err(e) = stream.play() {
eprintln!("effectcraft: audio output: {e}");
log::warn!("audio output: {e}");
return;
}
let _ = stop_rx.recv();
+2 -2
View File
@@ -27,11 +27,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<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}");
log::info!("control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || accept_loop(listener, tx, ctx));
rx
}
+69 -10
View File
@@ -24,6 +24,7 @@ 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;
@@ -44,6 +45,21 @@ fn app_icon() -> egui::IconData {
/// 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.
/// Windows and Linux: no OS title bar; the menu bar row is the title bar
/// (`effectcraft_ui_egui::titlebar`). macOS keeps its traffic lights over the integrated top row.
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
/// Whether this start draws its own title bar: Windows and Linux do, unless Settings ▸ Appearance ▸
/// Use System Title Bar asks for the system's. Read from the saved settings before the window
/// opens; a missing or unreadable file keeps the default.
fn custom_titlebar(config_dir: Option<&std::path::Path>) -> bool {
let prefs = config_dir
.and_then(|d| std::fs::read_to_string(d.join(effectcraft_engine::prefs::PREFS_FILE)).ok())
.map(|t| effectcraft_engine::prefs::Prefs::from_json(&t))
.unwrap_or_default();
CUSTOM_TITLEBAR && !prefs.appearance.system_title_bar
}
fn is_project_file(path: &str) -> bool {
std::path::Path::new(path)
.extension()
@@ -88,13 +104,24 @@ fn parse_args(args: impl IntoIterator<Item = String>, env_port: Option<String>)
}
fn main() -> eframe::Result {
// Help ▸ Enable Logging writes through this logger; warnings feed the compatibility report.
// 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.
@@ -105,12 +132,17 @@ fn main() -> eframe::Result {
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 custom_titlebar = custom_titlebar(effectcraft_host::config_dir().as_deref());
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)
.with_decorations(!custom_titlebar)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
// Started for an agent (`--control`): open without taking the user's keyboard focus.
.with_active(control_port.is_none())
.with_icon(app_icon())
@@ -120,8 +152,8 @@ fn main() -> eframe::Result {
wgpu_options: wgpu_options(launch.backends),
..Default::default()
};
// Start-up milestones: with `RUST_LOG=info` they go to stderr, so a window that never
// appears shows how far start-up got (#234).
// 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.
@@ -170,11 +202,14 @@ 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| 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!({}));
@@ -183,13 +218,15 @@ fn main() -> eframe::Result {
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.custom_titlebar = custom_titlebar;
app.integrated_titlebar = cfg!(target_os = "macos");
app.set_gpu_failure_bridge(gpu_failures);
app.ui.start_screen = show_home;
if let Some(n) = notice {
@@ -264,6 +301,7 @@ fn main() -> eframe::Result {
#[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,
}))
@@ -315,8 +353,12 @@ fn startup_failed(e: &eframe::Error, gl_next: bool) {
let message = format!(
"EffectCraft couldn't start its window: {e}\n\n{next}Updating the graphics driver often helps. If it keeps happening, please report it at https://github.com/storytold/effectcraft/issues with this message."
);
log::error!("{message}");
eprintln!("effectcraft: {message}");
// 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")
@@ -333,6 +375,8 @@ struct Desktop {
/// 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,
@@ -347,6 +391,7 @@ 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");
@@ -360,6 +405,7 @@ impl eframe::App for Desktop {
fn on_exit(&mut self) {
self.launch.exited();
self.window.save(self.app.session.config.as_deref());
self.app.on_exit();
}
@@ -400,7 +446,20 @@ fn disable_app_nap() {
#[cfg(test)]
mod tests {
use super::{Args, parse_args};
use super::{Args, custom_titlebar, parse_args};
#[test]
fn the_system_title_bar_setting_keeps_the_window_decorations() {
let dir = std::env::temp_dir().join(format!("ec-titlebar-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join(effectcraft_engine::prefs::PREFS_FILE);
assert_eq!(custom_titlebar(None), super::CUSTOM_TITLEBAR, "no settings folder: the default");
std::fs::write(&file, "not json").unwrap();
assert_eq!(custom_titlebar(Some(&dir)), super::CUSTOM_TITLEBAR, "an unreadable file: the default");
std::fs::write(&file, r#"{"appearance":{"systemTitleBar":true}}"#).unwrap();
assert!(!custom_titlebar(Some(&dir)));
std::fs::remove_dir_all(&dir).ok();
}
fn parse(args: &[&str]) -> Option<Args> {
parse_args(args.iter().map(|a| a.to_string()), None)
+371
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}");
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ EffectCraft app icon (effectcraft.svg, effectcraft-small.svg and every file deri
this directory: effectcraft-1024.png, effectcraft-macos-512.png, effectcraft.icns, effectcraft.ico,
hicolor/), and the web favicon apps/effectcraft-web/web/favicon.svg.
Original artwork made for this project by the project owner in ArtCraft, then vectorised (no
Original artwork made for this project by the project owner by hand, then vectorised (no
third-party material). Licensed like the rest of EffectCraft, under either of
* Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
+1 -1
View File
@@ -15,7 +15,7 @@ full-bleed colour field, tight head-and-shoulders crop, the body running off the
| Paper | `#efe9dc` | the figure |
| Ink | `#0b0b0c` | line work and contour |
**Provenance:** the project owner drew it in ArtCraft (a 2880 px engraving-style image, kept in
**Provenance:** the project owner drew it by hand (a 2880 px engraving-style image, kept in
craftrules at `assets/app-icons/effectcraft/source.png`). It was vectorised with craftrules
`assets/logo-options/_tools/vectorize_tile.py`, which keys out the field and traces the ink with potrace.
Licence: see [LICENSE.txt](LICENSE.txt) (MIT OR Apache-2.0, like the repo).
@@ -1,6 +1,6 @@
asset: effectcraft-1024.png
title: EffectCraft app icon, 1024 px
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
@@ -1,6 +1,6 @@
asset: effectcraft-macos-512.png
title: EffectCraft runtime Dock icon (macOS)
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
@@ -1,6 +1,6 @@
asset: effectcraft-small.svg
title: EffectCraft app icon, light vector
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
+1 -1
View File
@@ -1,6 +1,6 @@
asset: effectcraft.icns
title: EffectCraft macOS app icon
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
+1 -1
View File
@@ -1,6 +1,6 @@
asset: effectcraft.ico
title: EffectCraft Windows app icon
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
+2 -2
View File
@@ -1,8 +1,8 @@
asset: effectcraft.svg
title: EffectCraft app icon, master vector
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
added: 2026-10-02 by EffectCraft contributors (Claude agent, app icon)
notes: The owner's ArtCraft drawing of an engraved unicorn bust, vectorised at 2048 px with craftrules assets/logo-options/_tools/vectorize_tile.py (potrace; three colours). Canonical artwork; the other icon files derive from it.
notes: The owner's drawing of an engraved unicorn bust, vectorised at 2048 px with craftrules assets/logo-options/_tools/vectorize_tile.py (potrace; three colours). Canonical artwork; the other icon files derive from it.
@@ -1,6 +1,6 @@
asset: ai.storyteller.effectcraft.png
title: EffectCraft app icon, Linux hicolor 128x128
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
@@ -1,6 +1,6 @@
asset: ai.storyteller.effectcraft.png
title: EffectCraft app icon, Linux hicolor 16x16
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
@@ -1,6 +1,6 @@
asset: ai.storyteller.effectcraft.png
title: EffectCraft app icon, Linux hicolor 24x24
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
@@ -1,6 +1,6 @@
asset: ai.storyteller.effectcraft.png
title: EffectCraft app icon, Linux hicolor 256x256
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
@@ -1,6 +1,6 @@
asset: ai.storyteller.effectcraft.png
title: EffectCraft app icon, Linux hicolor 32x32
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
@@ -1,6 +1,6 @@
asset: ai.storyteller.effectcraft.png
title: EffectCraft app icon, Linux hicolor 48x48
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
@@ -1,6 +1,6 @@
asset: ai.storyteller.effectcraft.png
title: EffectCraft app icon, Linux hicolor 512x512
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
@@ -1,6 +1,6 @@
asset: ai.storyteller.effectcraft.png
title: EffectCraft app icon, Linux hicolor 64x64
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
@@ -1,6 +1,6 @@
asset: ai.storyteller.effectcraft.svg
title: EffectCraft app icon, Linux hicolor scalable
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
+10
View File
@@ -54,3 +54,13 @@ sync_github_name = false
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"
+6
View File
@@ -681,6 +681,12 @@ fn list_fonts_lists_families_with_styles() {
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
+3 -2
View File
@@ -210,7 +210,7 @@ fn list_effects(b: &mut Backend, a: &Value) -> Result<Reply> {
}
fn list_fonts(b: &mut Backend, a: &Value) -> Result<Reply> {
json_reply(b.exec("text.fonts", obj(&[("query", get(a, "query")), ("rescan", get(a, "rescan"))]))?)
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,
@@ -703,12 +703,13 @@ static TOOLS: &[ToolDef] = &[
},
ToolDef {
name: "list_fonts",
description: "The font families text layers can use, bundled and installed on this machine: family, styles, origin (bundled / system / user) and the name in the font's own language. Narrow with `query`; `rescan` picks up fonts installed since launch. Set one with execute_command `layer.setText {\"layer\", \"font\", \"style\"}`.",
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."}
}),
&[],
+51 -3
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();
+45 -6
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];
+37 -1
View File
@@ -105,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).
@@ -264,6 +264,21 @@ 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]) {
@@ -281,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 {
+25 -2
View File
@@ -429,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 {
@@ -440,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 {
@@ -574,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", &[]);
+75 -12
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]);
+70 -4
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)]
+255
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 }
]
}
]
}
@@ -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.
+177
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),
]
}
+83 -1
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,6 +400,23 @@ 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})));
}
}
+32 -3
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",
];
@@ -614,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);
}
}
+1
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,
+7 -7
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.
@@ -69,7 +69,7 @@ fn apply_settings(c: &mut Comp, p: &Value) {
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;
@@ -84,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;
+97 -4
View File
@@ -1,12 +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_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)?;
@@ -17,7 +18,8 @@ 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;
@@ -120,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)?;
@@ -143,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),
@@ -156,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),
]
}
@@ -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,
+68 -1
View File
@@ -531,6 +531,42 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(new.iter().map(|l| l.0).collect::<Vec<_>>()))
}
/// Largest SVG text `edit.pasteSvg` reads (a clipboard can hold anything).
const MAX_SVG_PASTE: usize = 64 << 20;
/// `edit.pasteSvg`: SVG markup (the system clipboard's, copied from a browser, Illustrator or a
/// design tool) as a new shape layer above the selected layer, its canvas centred in the comp:
/// After Effects 26.3 pastes copied vector artwork as shape layers. Edit ▸ Paste runs it when
/// the system clipboard holds SVG copied after anything copied in the app.
fn paste_svg(s: &mut Session, p: &Value) -> Result<Value> {
let c = "edit.pasteSvg";
let text = str_p(p, "svg").ok_or_else(|| super::bad(c, "missing `svg` (SVG markup)"))?;
if text.len() > MAX_SVG_PASTE {
return Err(super::bad(c, format!("the SVG is larger than {} MB", MAX_SVG_PASTE >> 20)));
}
if !crate::vector::is_svg_text(text) {
return Err(super::bad(c, "the text is not SVG markup"));
}
let doc = effectcraft_svg::parse(text.as_bytes()).map_err(|e| super::bad(c, format!("cannot read the SVG: {e}")))?;
if doc.flatten().is_empty() {
return Err(super::bad(c, "the SVG has no shapes"));
}
let cid = super::comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let name = match str_p(p, "name") {
Some(n) => n.to_string(),
None => {
let count = comp.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count();
comp.unique_layer_name(&format!("Shape Layer {}", count.saturating_add(1)))
}
};
let id = s.edit("Paste", None, |proj, st| {
let l = crate::vector::shapes_from_svg(proj, &comp, &doc, &name);
super::layer::insert_layer(proj, st, cid, l)
})?;
Ok(json!({"layer": id.0, "skipped": doc.skipped}))
}
fn split(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let t = s.time();
@@ -773,7 +809,7 @@ fn original_path(s: &Session) -> Option<String> {
fn edit_original(s: &mut Session, _: &Value) -> Result<Value> {
let path = original_path(s).ok_or_else(|| super::bad("edit.editOriginal", "select a footage item or layer"))?;
let url = format!("file://{path}");
let url = super::layer_menu::file_url(&path);
s.events.push(crate::Event::OpenUrl(url.clone()));
Ok(json!({"url": url}))
}
@@ -818,6 +854,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_clip,
paste
),
cmd!(
"edit.pasteSvg",
"Paste SVG as Shapes",
[],
None,
"{svg: SVG markup, name?, comp?} → {layer, skipped} (Edit ▸ Paste runs it for SVG on the system clipboard)",
has_comp,
paste_svg
),
cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{layers?, prop?|path?, time?}", has_key_clip, paste_reversed),
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?} (effects or shape items when they are selected)", has_layers, duplicate),
@@ -893,3 +938,25 @@ fn layers_or_keys(s: &Session) -> std::result::Result<(), String> {
fn always_ok(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
#[cfg(test)]
mod tests {
use serde_json::json;
/// Edit Original opens the footage as a well-formed `file://` URL, whatever its name contains.
#[test]
fn edit_original_opens_an_encoded_file_url() {
let mut s = crate::Session::default();
let id = s
.edit("add", None, |p, _| {
let f = effectcraft_project::Footage { path: "/clips/take #1.mov".into(), ..Default::default() };
Ok(p.add_item("take", effectcraft_color::Label::Lavender, None, effectcraft_project::ItemKind::Footage(f)))
})
.unwrap();
s.state.project_selection = vec![id];
s.drain_events();
let r = s.execute("edit.editOriginal", json!({})).unwrap();
assert_eq!(r["url"], "file:///clips/take%20%231.mov");
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if u == "file:///clips/take%20%231.mov")));
}
}
+18 -5
View File
@@ -43,8 +43,18 @@ impl ExprError {
pub fn errors(s: &Session, cid: ItemId, t: Tick) -> Vec<ExprError> {
let Some(comp) = s.project.comp(cid) else { return vec![] };
let ctx = EvalCtx { footage: Some(s.footage.as_ref()), expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, t) };
errors_in(&ctx, s.expr_check, || true).unwrap_or_default()
}
/// Evaluate diagnostics against an immutable snapshot. Background callers can cancel between
/// properties; cancelled scans return no partial result. Includes hidden/non-solo layers and
/// syntax errors on disabled expressions, just like the explicit `expr.errors` query.
pub fn errors_in(ctx: &EvalCtx, check: Option<fn(&str) -> std::result::Result<(), String>>, mut keep_going: impl FnMut() -> bool) -> Option<Vec<ExprError>> {
let mut out = vec![];
for (i, l) in comp.layers.iter().enumerate() {
for (i, l) in ctx.comp.layers.iter().enumerate() {
if !keep_going() {
return None;
}
let mut props: Vec<(&Property, &Expression)> = vec![];
l.props.walk("", &mut |_, p| {
if let Some(e) = p.expr.as_ref().filter(|e| !e.text.trim().is_empty()) {
@@ -52,20 +62,23 @@ pub fn errors(s: &Session, cid: ItemId, t: Tick) -> Vec<ExprError> {
}
});
for (p, e) in props {
if !keep_going() {
return None;
}
let msg = if e.enabled {
match &ctx.expr {
Some(h) => h.eval(&ctx, l, p, &p.value_at(l.layer_time(t))).err(),
Some(h) => h.eval(ctx, l, p, &p.value_at(l.layer_time(ctx.time))).err(),
None => None,
}
} else {
s.expr_check.and_then(|check| check(&e.text).err())
check.and_then(|check| check(&e.text).err())
};
if let Some(message) = msg {
out.push(error_of(cid, i, l, p, message, !e.enabled));
out.push(error_of(ctx.comp_id, i, l, p, message, !e.enabled));
}
}
}
out
keep_going().then_some(out)
}
fn error_of(cid: ItemId, i: usize, l: &Layer, p: &Property, message: String, disabled: bool) -> ExprError {
+12 -3
View File
@@ -43,10 +43,19 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("not a text project file".into()))?;
// XML copies (File ▸ Save a Copy As XML…) open like the JSON project.
let text = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).map_err(EngineError::Other)? } else { text };
let mut proj = Project::from_json(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?;
let (mut proj, repaired) =
Project::from_json_repaired(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?;
resolve_relative_footage(&mut proj, path, s.services.as_ref());
s.replace_project(proj, Some(path.to_string()));
s.restore_view(&effectcraft_project::saved_view(&text));
s.note_project_path(path);
// Values past the limits (a corrupt or hostile file) were brought within them (#788).
if !repaired.is_empty() {
let more = repaired.len().saturating_sub(3);
let shown = repaired.iter().take(3).cloned().collect::<Vec<_>>().join("; ");
let more = if more > 0 { format!(" and {more} more") } else { String::new() };
s.toast(format!("This project had values outside EffectCraft's limits, corrected when it opened: {shown}{more}."));
}
// Written by a newer EffectCraft: what this version doesn't know would be lost on saving.
let saved_by = effectcraft_project::saved_by(&text);
if let Some(v) = saved_by.as_deref().filter(|v| effectcraft_project::is_newer_version(v, effectcraft_project::APP_VERSION)) {
@@ -60,7 +69,7 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
if s.check_footage_on_open {
crate::footage_check::start(s, None, false)?;
}
Ok(json!({"path": path, "savedBy": saved_by}))
Ok(json!({"path": path, "savedBy": saved_by, "repaired": repaired}))
}
/// Footage saved with a relative path (imported by the CLI or an agent as `clip.mp4`, or written
@@ -89,7 +98,7 @@ fn resolve_relative_footage(p: &mut Project, project_path: &str, services: &dyn
/// project opened from an XML copy saves as XML again). Fails, writing nothing, when the project
/// wouldn't read back.
pub(crate) fn file_text(s: &Session, path: &str) -> Result<String> {
let json = s.project.to_file_json()?;
let json = s.project.to_file_json_with_view(&s.saved_view())?;
if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other) } else { Ok(json) }
}
+8 -7
View File
@@ -5,7 +5,7 @@
use std::collections::{BTreeMap, BTreeSet};
use effectcraft_color::Label;
use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid};
use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
@@ -84,12 +84,12 @@ fn save_copy(s: &mut Session, p: &Value) -> Result<Value> {
}
fn rate_of(p: &Value) -> FrameRate {
f_p(p, "frameRate").map(FrameRate::from_f64).unwrap_or(FrameRate::FPS_29_97)
f_p(p, "frameRate").map(|f| limits::frame_rate(FrameRate::from_f64(f), FrameRate::FPS_29_97)).unwrap_or(FrameRate::FPS_29_97)
}
fn placeholder_footage(p: &Value) -> Footage {
let w = p.get("width").and_then(Value::as_u64).unwrap_or(1920) as u32;
let h = p.get("height").and_then(Value::as_u64).unwrap_or(1080) as u32;
let w = p.get("width").and_then(Value::as_u64).map_or(1920, limits::size);
let h = p.get("height").and_then(Value::as_u64).map_or(1080, limits::size);
Footage {
path: String::new(),
kind: FootageKind::Video,
@@ -133,8 +133,8 @@ fn solid_from(p: &Value, s: &Session) -> Solid {
.unwrap_or([0.5, 0.5, 0.5]);
Solid {
color,
width: p.get("width").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(cw).max(1),
height: p.get("height").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(ch).max(1),
width: p.get("width").and_then(Value::as_u64).map(limits::size).unwrap_or(cw).max(1),
height: p.get("height").and_then(Value::as_u64).map(limits::size).unwrap_or(ch).max(1),
pixel_aspect: 1.0,
}
}
@@ -620,7 +620,8 @@ impl Interpretation {
),
};
it.invert_alpha = super::b_p(p, "invertAlpha");
it.loops = p.get("loop").and_then(Value::as_u64).map(|v| v.clamp(1, 9999) as u32);
// The Interpret Footage form sends numbers as floats (3.0), which `as_u64` rejects.
it.loops = f_p(p, "loop").filter(|x| x.is_finite()).map(|v| v.round().clamp(1.0, 9999.0) as u32);
it.par = f_p(p, "pixelAspect").filter(|x| *x > 0.0);
it.fields = match str_p(p, "fields") {
Some(f) => Some(effectcraft_project::FieldOrder::parse(f).ok_or_else(|| bad(cmd, "fields: off|upper|lower"))?),
+7 -2
View File
@@ -179,7 +179,8 @@ fn edit_into_comp(s: &mut Session, p: &Value, ripple: bool) -> Result<Value> {
let mut l = build::layer(proj, &comp, &it.name, src, size, Some(len));
l.start_time = at - src_in;
l.in_point = at;
l.out_point = (at + len).min(comp.duration);
// Keep the full marked length past the comp's end (After Effects); the comp just doesn't show it.
l.out_point = at + len;
let lid = l.id;
let cm = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
cm.layers.insert(0, l);
@@ -206,8 +207,12 @@ fn info(s: &mut Session, _: &Value) -> Result<Value> {
let item = s.project.item(v.item);
let name = item.map(|i| i.name.clone()).unwrap_or_default();
let frame = fr.frame_at(v.time);
let problem = match item.map(|i| &i.kind) {
Some(ItemKind::Footage(f)) => s.footage.problem(f),
_ => None,
};
Ok(
json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": frame, "timecode": timecode(item, fr, frame), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64()}),
json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": frame, "timecode": timecode(item, fr, frame), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64(), "problem": problem}),
)
}
+41 -9
View File
@@ -1,10 +1,12 @@
//! Frontend-only commands: viewer zoom/resolution/overlays, panels, workspaces, dialogs and app
//! Frontend-only commands: viewer zoom/overlays, panels, workspaces, dialogs and app
//! chrome. They live in the registry so every menu entry is a command (menus, shortcuts, the
//! control channel and MCP all see them); running one emits [`crate::Event::Frontend`] and the UI
//! performs it. Headless sessions accept and ignore them.
use super::{CommandSpec, always, frontend, has_comp, has_layers};
use crate::cmd;
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, frontend, has_comp, has_layers};
use crate::{Result, Session, cmd};
macro_rules! fe {
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr) => {
@@ -35,6 +37,16 @@ pub fn specs() -> Vec<CommandSpec> {
fe!("app.keyboardShortcuts", "Keyboard Shortcuts", ["Edit"], Some("Cmd+Alt+'"), "{}", always),
fe!("app.templates", "Templates", [], None, "{kind: renderSettings|outputModule}", always),
fe!("app.find", "Find", ["File"], Some("Cmd+F"), "{query?}", always),
// Themes (View ▸ Preferences ▸ Theme): `view.theme.toggle` cycles the appearance mode, the
// others show that theme and fix the mode to its family (see `appearance.theme`).
fe!("view.theme.toggle", "Next Appearance Mode", [], None, "{}", always),
fe!("view.theme.dark", "Theme: Dark", [], None, "{}", always),
fe!("view.theme.darker", "Theme: Darker", [], None, "{}", always),
fe!("view.theme.light", "Theme: Light", [], None, "{}", always),
fe!("view.theme.catppuccinLatte", "Theme: Catppuccin Latte", [], None, "{}", always),
fe!("view.theme.catppuccinFrappe", "Theme: Catppuccin Frappé", [], None, "{}", always),
fe!("view.theme.catppuccinMacchiato", "Theme: Catppuccin Macchiato", [], None, "{}", always),
fe!("view.theme.catppuccinMocha", "Theme: Catppuccin Mocha", [], None, "{}", always),
// Preview.
fe!(
"playback.toggle",
@@ -57,11 +69,6 @@ pub fn specs() -> Vec<CommandSpec> {
// Viewer.
fe!("view.zoomIn", "Zoom In", ["View"], Some("."), "{}", has_comp),
fe!("view.zoomOut", "Zoom Out", ["View"], Some(","), "{}", has_comp),
fe!("view.res.full", "Full", ["View", "Resolution"], Some("Cmd+J"), "{}", has_comp),
fe!("view.res.half", "Half", ["View", "Resolution"], Some("Cmd+Shift+J"), "{}", has_comp),
fe!("view.res.third", "Third", ["View", "Resolution"], None, "{}", has_comp),
fe!("view.res.quarter", "Quarter", ["View", "Resolution"], Some("Cmd+Alt+Shift+J"), "{}", has_comp),
fe!("view.res.custom", "Custom...", ["View", "Resolution"], None, "{factor?: 1..40 (render every n-th pixel)}", has_comp),
fe!("view.rulers", "Show Rulers", ["View"], Some("Cmd+R"), "{value?}", always),
fe!("view.panelBackground", "Panel Background Color", [], None, "{color?: black|darkGray|mediumGray|lightGray|white|custom|#hex, pick?: true}", always),
fe!("view.guides", "Show Guides", ["View"], Some("Cmd+;"), "{value?}", always),
@@ -71,7 +78,9 @@ pub fn specs() -> Vec<CommandSpec> {
fe!("view.snapToGrid", "Snap to Grid", ["View"], Some("Cmd+Shift+'"), "{value?}", always),
fe!("view.options", "View Options...", ["View"], Some("Cmd+Alt+U"), "{}", has_comp),
fe!("view.layerControls", "Show Layer Controls", ["View"], Some("Cmd+Shift+H"), "{value?}", always),
fe!("view.fullScreen", "Enter Full Screen", ["View"], Some("Ctrl+Cmd+F"), "{}", always),
// macOS uses Control+Command+F for native full screen. On Windows and Linux,
// Cmd maps to Ctrl; the After Effects-style Ctrl+\ is window.maximizeApp.
fe!("view.fullScreen", "Enter Full Screen", ["View"], cfg!(target_os = "macos").then_some("Ctrl+Cmd+F"), "{}", always),
fe!(
"view.newViewer",
"New Viewer",
@@ -116,5 +125,28 @@ pub fn specs() -> Vec<CommandSpec> {
"{} — the Project panel's selection, with the layers that use it; one undo step",
always
),
// A gradient property's "Edit Gradient…" (the Timeline's Colors value): opens the
// Gradient Editor on it at the current time. OK sets it with prop.set (one undo step).
cmd!(
"prop.editGradient",
"Edit Gradient...",
[],
None,
"{layer?, path|prop, comp?} — a gradient property; opens the Gradient Editor at the current time",
has_layers,
edit_gradient
),
]
}
/// `prop.editGradient`: check the target is a gradient property before the UI opens the editor,
/// so agents get an error (not success) for a wrong one, and hand the UI the resolved ids.
fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
let c = "prop.editGradient";
let (cid, lid, uid) = super::prop::prop_ref(s, p, c)?;
let pr = s.project.comp(cid).and_then(|comp| comp.layer(lid)).and_then(|l| l.props.find(uid)).ok_or_else(|| bad(c, format!("no property @{uid}")))?;
if !matches!(pr.value, effectcraft_keyframe::Value::Gradient(_)) {
return Err(bad(c, format!("{} is not a gradient property", pr.name)));
}
frontend(s, c, &json!({"comp": cid.0, "layer": lid.0, "prop": uid}))
}
+2 -19
View File
@@ -1,4 +1,4 @@
//! Help menu: community and project links (the frontend opens `Event::OpenUrl`).
//! Help menu: project links (the frontend opens `Event::OpenUrl`).
use serde_json::{Value, json};
@@ -11,11 +11,6 @@ fn open(s: &mut Session, url: String) -> Result<Value> {
Ok(json!({"url": url}))
}
fn sibling(s: &mut Session, p: &Value) -> Result<Value> {
let slug = str_p(p, "app").unwrap_or("photocraft").to_ascii_lowercase();
if str_p(p, "kind") == Some("github") { open(s, links::github_of(&slug)) } else { open(s, links::page_of(&slug)) }
}
/// Help ▸ EffectCraft Help / Scripting Help / Expression Reference / Effect Reference.
fn docs(s: &mut Session, p: &Value) -> Result<Value> {
let page = match str_p(p, "page").unwrap_or("help") {
@@ -31,25 +26,13 @@ fn docs(s: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("help.docs", "EffectCraft Help...", [], Some("F1"), "{page?: help|scripting|expressions|effects|agents}", always, docs),
cmd!("help.discord", "Join the ArtCraft Discord...", ["Help"], None, "{}", always, |s, _| open(s, links::DISCORD.into())),
cmd!("help.website", "ArtCraft Website", ["Help"], None, "{}", always, |s, _| open(s, links::WEBSITE.into())),
cmd!("help.appPage", "EffectCraft Home Page", ["Help"], None, "{}", always, |s, _| open(s, links::APP_PAGE.into())),
cmd!("help.github", "EffectCraft on GitHub", ["Help"], None, "{}", always, |s, _| open(s, links::GITHUB.into())),
cmd!("help.onlineTutorials", "Online Tutorials...", ["Help"], None, "{}", always, |s, _| open(s, links::APP_PAGE.into())),
cmd!("help.onlineTutorials", "Online Tutorials...", ["Help"], None, "{}", always, |s, _| open(s, links::GITHUB.into())),
cmd!("help.inAppTutorials", "In-App Tutorials...", ["Help"], None, "{} → the Home screen's Learn tab", always, |s, p| super::frontend(
s,
"help.inAppTutorials",
p
)),
cmd!("help.reportIssue", "Provide Feedback...", ["Help"], None, "{}", always, |s, _| open(s, links::ISSUES.into())),
cmd!(
"help.sibling",
"Other ArtCraft Apps",
[],
None,
"{app: photocraft|vectorcraft|filmcraft|lightcraft|pdfcraft|designcraft, kind?: page|github}",
always,
sibling
),
]
}
+97 -20
View File
@@ -4,7 +4,8 @@ use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::{Justify, TextDoc, Value as KV};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{
Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Project, PropGroup, Quality, Solid, TrackMatte,
Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid, TrackMatte,
limits,
};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@@ -92,8 +93,9 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value>
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let color = color_p(p, "color").unwrap_or(if adjustment { [1.0, 1.0, 1.0] } else { [0.85, 0.2, 0.2] });
let w = p.get("width").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(comp.width);
let h = p.get("height").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(comp.height);
// 1 to 30,000 px a side (#788): the layer is sized from the same numbers as its solid.
let w = p.get("width").and_then(Value::as_u64).map(limits::size).unwrap_or(comp.width);
let h = p.get("height").and_then(Value::as_u64).map(limits::size).unwrap_or(comp.height);
let pixel_aspect = pixel_aspect_p(p)?.unwrap_or(comp.pixel_aspect);
let default_name = if adjustment { "Adjustment Layer 1".to_string() } else { "Solid 1".to_string() };
let name = str_p(p, "name").map(str::to_string).unwrap_or(default_name);
@@ -114,7 +116,7 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value>
fn pixel_aspect_p(p: &Value) -> Result<Option<f64>> {
match p.get("pixelAspect").and_then(Value::as_f64) {
Some(v) if !(v.is_finite() && v > 0.0) => Err(bad("layer.settings", "`pixelAspect` must be a positive number")),
v => Ok(v),
v => Ok(v.map(limits::pixel_aspect)),
}
}
@@ -295,7 +297,10 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
let mut l = build::layer(proj, &comp, &it.name, src, size, dur);
l.start_time = start;
l.in_point = start;
l.out_point = fr.snap_nearest((start + dur.unwrap_or(comp.duration)).min(comp.duration)).max(start + fr.frame_duration());
// Footage and precomps keep their full length past the comp's end (After Effects); items
// without a duration (solids, comp-length stills) end with the comp.
let end = dur.map_or((start + comp.duration).min(comp.duration), |d| start + d);
l.out_point = fr.snap_nearest(end).max(start + fr.frame_duration());
place(&mut l, position);
insert_layer_at(proj, st, cid, l, index)
})?;
@@ -377,6 +382,9 @@ fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "switch").ok_or_else(|| bad("layer.setSwitch", "missing `switch`"))?.to_string();
let v = b_p(p, "value");
let label = format!("Layer Switch ({name})");
if b_p(p, "others") == Some(true) {
return set_switch_others(s, cid, &ids, &name, v, &label);
}
// `merge`: a switch dragged over several layers in the Timeline is one undo step.
let r = s.edit(&label, merge_p(p), |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
@@ -394,6 +402,38 @@ fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(r))
}
/// `layer.setSwitch {others: true}` (Alt-click on an A/V feature in the Timeline): the named
/// layers stand alone, shown / heard / soloed / unlocked while every other layer the switch
/// applies to is the opposite; when they already do, the other layers come back. `value` sets
/// the other layers directly.
fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str, value: Option<bool>, label: &str) -> Result<Value> {
let r = s.edit(label, None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
// The state that sets a layer apart: unlocked for Lock, on for the rest.
let active = !matches!(name, "lock" | "locked");
// Track mattes keep their video off by design.
let mattes: Vec<LayerId> =
if matches!(name, "video" | "eye") { comp.layers.iter().filter_map(|l| l.track_matte.map(|m| m.layer)).collect() } else { Vec::new() };
let applies = |l: &Layer| match name {
"audio" => l.props.sub("audio").is_some(),
"lock" | "locked" => true,
_ => l.source.is_av() && !mattes.contains(&l.id),
};
let mut alone = true;
for l in comp.layers.iter().filter(|l| applies(l)) {
let Some(on) = switch_value(l, name) else { return Err(bad("layer.setSwitch", format!("unknown switch `{name}`"))) };
alone &= on == (ids.contains(&l.id) == active);
}
let others = value.unwrap_or(alone == active);
for l in comp.layers.iter_mut().filter(|l| applies(l)) {
let v = if ids.contains(&l.id) { active } else { others };
set_switch_value(l, name, v);
}
Ok(others)
})?;
Ok(json!(r))
}
fn switch_value(l: &Layer, name: &str) -> Option<bool> {
let sw = &l.switches;
Some(match name {
@@ -857,9 +897,13 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
let fr = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate;
let snap = |k: &str| f_p(p, k).map(|v| fr.snap_nearest(Tick::from_seconds_f64(v)));
let (delta, start, inp, outp) = (snap("delta"), snap("start"), snap("in"), snap("out"));
// Relative trims: each layer moves its own in/out point by the same amount, so dragging an
// edge of several selected layers trims them all together (as in AE).
let (in_delta, out_delta) = (snap("inDelta"), snap("outDelta"));
s.edit("Layer Timing", merge_p(p), |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let fd = comp.frame_duration();
let comp_dur = comp.duration;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
let shift = |l: &mut Layer, d: Tick| {
l.start_time += d;
@@ -891,6 +935,14 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(o) = outp {
l.out_point = o.max(l.in_point + fd);
}
// Stops at the comp start/end (a point already past it doesn't jump onto
// it), keeping at least one frame of layer.
if let Some(d) = in_delta {
l.in_point = (l.in_point + d).max(Tick::ZERO.min(l.in_point)).min(l.out_point - fd);
}
if let Some(d) = out_delta {
l.out_point = (l.out_point + d).min(comp_dur.max(l.out_point)).max(l.in_point + fd);
}
}
}
}
@@ -1186,6 +1238,26 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
let uid = s.edit("Add Shape Item", None, |proj, _| {
let mut next = proj.next_id;
let mut ids = Ids(&mut next);
// Front / Bevel / Side / Back ▸ property: into the group's surface item (one per group).
if let Some((surface, prop)) = build::shape_surface_kind(&kind) {
let target = shape_item_target(proj, cid, lid, group_uid)?;
let item = match target.children.iter().position(|c| matches!(c, Node::Group(g) if g.match_id == surface)) {
Some(i) => i,
None => {
let g = build::shape_surface(&mut ids, surface).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{kind}`")))?;
target.children.push(g.into());
target.children.len() - 1
}
};
let Some(Node::Group(g)) = target.children.get_mut(item) else { return Err(bad("layer.addShapeItem", "no such group")) };
if g.get(prop).is_none() {
let p = build::shape_surface_prop(&mut ids, prop).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{kind}`")))?;
g.children.push(p.into());
}
let uid = g.uid;
proj.next_id = next;
return Ok(uid);
}
let g = match kind.as_str() {
"group" => build::shape_group(&mut ids, "Group 1", vec![]),
"rect" | "rectangle" => build::shape_rect(&mut ids, [200.0, 200.0], [0.0, 0.0], 0.0),
@@ -1203,16 +1275,7 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
};
let uid = g.uid;
proj.next_id = next;
let l = layer_mut(proj, cid, lid)?;
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("layer.addShapeItem", "not a shape layer"))?;
let target = match group_uid {
Some(u) => contents
.find_group_mut(u)
.and_then(|g| if g.match_id == "contents" { Some(g) } else { g.sub_mut("contents") })
.ok_or_else(|| bad("layer.addShapeItem", "no such group"))?,
None => contents,
};
target.children.push(g.into());
shape_item_target(proj, cid, lid, group_uid)?.children.push(g.into());
Ok(uid)
})?;
// The new item's property path, ready for prop.set / add_keyframe (`contents/trim#2/end`).
@@ -1220,6 +1283,20 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"uid": uid, "path": path}))
}
/// The contents a new shape item goes into: the shape group `group_uid` (its Contents), else the
/// layer's Contents.
fn shape_item_target(proj: &mut Project, cid: ItemId, lid: LayerId, group_uid: Option<u64>) -> Result<&mut PropGroup> {
let l = layer_mut(proj, cid, lid)?;
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("layer.addShapeItem", "not a shape layer"))?;
Ok(match group_uid {
Some(u) => contents
.find_group_mut(u)
.and_then(|g| if g.match_id == "contents" { Some(g) } else { g.sub_mut("contents") })
.ok_or_else(|| bad("layer.addShapeItem", "no such group"))?,
None => contents,
})
}
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.setText")?;
// The Character panel's recent fonts (Settings ▸ Type ▸ Number of Recent Fonts to Display).
@@ -1404,8 +1481,8 @@ fn layer_settings(s: &mut Session, p: &Value) -> Result<Value> {
_ => {}
}
let color = color_p(p, "color");
let w = p.get("width").and_then(Value::as_u64).map(|v| v.max(1) as u32);
let h = p.get("height").and_then(Value::as_u64).map(|v| v.max(1) as u32);
let w = p.get("width").and_then(Value::as_u64).map(limits::size);
let h = p.get("height").and_then(Value::as_u64).map(limits::size);
let pixel_aspect = pixel_aspect_p(p)?;
let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
let affect_all = p.get("affectAll").and_then(Value::as_bool).unwrap_or(true);
@@ -1508,7 +1585,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Layer Switch",
[],
None,
"{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?, merge?}",
"{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?, merge?, others? (the named layers alone: the other layers get the opposite state, or come back)}",
has_layers,
set_switch
),
@@ -1530,7 +1607,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Layer Timing",
[],
None,
"{layers?, op?: moveInToTime|moveOutToTime|trimInToTime|trimOutToTime, delta?, start?, in?, out?, merge?}",
"{layers?, op?: moveInToTime|moveOutToTime|trimInToTime|trimOutToTime, delta?, start?, in?, out?, inDelta?, outDelta? (seconds, per layer), merge?}",
has_layers,
timing
),
@@ -1570,7 +1647,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add (Shape)",
[],
None,
"{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge, group?: uid|path} → {uid, path}",
"{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)",
has_layers,
add_shape_item
),
+36 -1
View File
@@ -661,7 +661,24 @@ fn open_source(s: &mut Session, p: &Value) -> Result<Value> {
/// Folder containing a file as a `file://` URL.
pub(crate) fn folder_url(path: &str) -> String {
let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();
format!("file://{dir}")
file_url(&dir)
}
/// `path` as a `file://` URL: backslashes become `/`, a Windows drive path gets the third slash
/// (`file:///C:/…`), and every byte that isn't a plain path character is percent-encoded (spaces,
/// `#`, `%`, `?`, non-ASCII as UTF-8), so the URL names exactly this file.
pub(crate) fn file_url(path: &str) -> String {
use std::fmt::Write as _;
let path = path.replace('\\', "/");
let mut url = String::from(if path.starts_with('/') { "file://" } else { "file:///" });
for b in path.bytes() {
if b.is_ascii_alphanumeric() || b"/-._~:@!$&'()*+,;=".contains(&b) {
url.push(char::from(b));
} else {
let _ = write!(url, "%{b:02X}");
}
}
url
}
fn reveal_in_finder(s: &mut Session, p: &Value) -> Result<Value> {
@@ -845,3 +862,21 @@ pub fn specs() -> Vec<CommandSpec> {
),
]
}
#[cfg(test)]
mod tests {
use super::{file_url, folder_url};
#[test]
fn file_urls_are_well_formed() {
assert_eq!(file_url("C:\\Users\\Ann\\Footage\\clip 1.mov"), "file:///C:/Users/Ann/Footage/clip%201.mov");
assert_eq!(file_url("/home/ann/a#b%c?d.mov"), "file:///home/ann/a%23b%25c%3Fd.mov");
assert_eq!(file_url("/Users/ann/Café/x.mov"), "file:///Users/ann/Caf%C3%A9/x.mov");
assert_eq!(file_url("/plain/path-1_2.~(x).mov"), "file:///plain/path-1_2.~(x).mov", "plain characters stay");
}
#[test]
fn folder_url_names_the_containing_folder() {
assert_eq!(folder_url("/media/My Clips/a.mov"), "file:///media/My%20Clips");
}
}
+49 -6
View File
@@ -4,6 +4,7 @@
mod align;
mod align_data;
mod anim;
mod anim_presets;
pub mod anim_tools;
mod animation;
pub(crate) mod app_more;
@@ -43,7 +44,7 @@ pub mod panels_cmds;
pub(crate) mod path_nulls;
mod paths;
mod project_items;
mod prop;
pub(crate) mod prop;
mod prop_groups;
mod proxy;
pub mod puppet;
@@ -75,6 +76,8 @@ pub(crate) mod watch_folder;
pub(crate) use app_more::report_text as report_text_for_tests;
pub(crate) use app_more::view_command;
#[cfg(test)]
pub(crate) use file::open as open_for_tests;
#[cfg(test)]
pub(crate) use keys_more::amplitudes as amplitudes_for_tests;
#[cfg(test)]
pub(crate) use mask::split_segment as split_segment_for_tests;
@@ -98,6 +101,8 @@ pub struct CommandSpec {
pub menu: &'static [&'static str],
/// Default shortcut (egui-style names, `Cmd` = ⌘ on macOS / Ctrl elsewhere).
pub shortcut: Option<&'static str>,
/// Further default shortcuts (the menus and tooltips show `shortcut`).
pub alt_shortcuts: &'static [&'static str],
/// Parameter description for agents and the MCP schema.
pub params: &'static str,
pub enabled: Enabled,
@@ -108,8 +113,22 @@ pub struct CommandSpec {
#[macro_export]
macro_rules! cmd {
// Several default shortcuts: `["PageDown", "Cmd+ArrowRight"]`.
($id:literal, $label:literal, [$($m:literal),*], [$sc:literal, $($alt:literal),+], $params:literal, $en:expr, $run:expr) => {
$crate::commands::CommandSpec { alt_shortcuts: &[$($alt),+], ..$crate::cmd!($id, $label, [$($m),*], Some($sc), $params, $en, $run) }
};
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::commands::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true }
$crate::commands::CommandSpec {
id: $id,
label: $label,
menu: &[$($m),*],
shortcut: $sc,
alt_shortcuts: &[],
params: $params,
enabled: $en,
run: $run,
journal: true,
}
};
}
#[macro_export]
@@ -120,6 +139,7 @@ macro_rules! query {
label: $label,
menu: &[],
shortcut: None,
alt_shortcuts: &[],
params: $params,
enabled: $crate::commands::always,
run: $run,
@@ -162,6 +182,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(layer_menu::specs());
v.extend(create::specs());
v.extend(animation::specs());
v.extend(anim_presets::specs());
v.extend(view::specs());
v.extend(viewer_cmds::specs());
v.extend(shape_stroke::specs());
@@ -221,6 +242,11 @@ pub fn text_preset_list() -> Vec<(String, String)> {
text_anim::preset_names()
}
/// (id, name, category) of the built-in animation presets (Transitions…).
pub fn builtin_preset_list() -> Vec<(String, String, String)> {
anim_presets::preset_names()
}
pub fn find(id: &str) -> Option<&'static CommandSpec> {
// Every command execution looks its spec up: index the ~2,000 ids once.
static INDEX: OnceLock<std::collections::HashMap<&'static str, usize>> = OnceLock::new();
@@ -442,17 +468,34 @@ pub(crate) fn merge_p(p: &Value) -> Option<&str> {
str_p(p, "merge")
}
/// `comp`: item id or name; default the active comp.
/// `comp`: item id or name (an all-digit string that names no comp is taken as an id, as MCP
/// clients often send numbers as strings); default the active comp.
pub(crate) fn comp_id(s: &Session, p: &Value) -> Result<ItemId> {
let missing = |v: &Value| EngineError::NoSuchComp(v.to_string());
match p.get("comp") {
Some(v @ Value::Number(n)) => n.as_u64().map(ItemId).filter(|id| s.project.comp(*id).is_some()).ok_or_else(|| missing(v)),
Some(v @ Value::String(name)) => s.project.items.values().find(|i| &i.name == name && i.as_comp().is_some()).map(|i| i.id).ok_or_else(|| missing(v)),
Some(v @ Value::String(name)) => s
.project
.items
.values()
.find(|i| &i.name == name && i.as_comp().is_some())
.map(|i| i.id)
.or_else(|| digits_id(name).map(ItemId).filter(|id| s.project.comp(*id).is_some()))
.ok_or_else(|| missing(v)),
_ => s.active_comp_id().ok_or(EngineError::NoComp),
}
}
/// Resolve a layer reference: id (number), `#n` / index (1-based), or name.
/// An all-ASCII-digit string as a number (`"98"` -> 98); anything else (names, `#n`, signs) is `None`.
fn digits_id(s: &str) -> Option<u64> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
/// Resolve a layer reference: id (number), `#n` / index (1-based), or name. An all-digit string
/// that names no layer is resolved as the number it spells.
pub(crate) fn resolve_layer(comp: &Comp, v: &Value) -> Option<LayerId> {
match v {
Value::Number(n) => {
@@ -463,7 +506,7 @@ pub(crate) fn resolve_layer(comp: &Comp, v: &Value) -> Option<LayerId> {
if let Some(i) = s.strip_prefix('#').and_then(|i| i.parse::<usize>().ok()) {
return comp.layers.get(i.checked_sub(1)?).map(|l| l.id);
}
comp.layers.iter().find(|l| &l.name == s).map(|l| l.id)
comp.layers.iter().find(|l| &l.name == s).map(|l| l.id).or_else(|| digits_id(s).and_then(|id| resolve_layer(comp, &Value::from(id))))
}
_ => None,
}
+2 -2
View File
@@ -1,4 +1,4 @@
//! Advanced 3D: model layers (File ▸ Import of glTF/OBJ, Layer ▸ New ▸ 3D primitives), the
//! Advanced 3D: model layers (File ▸ Import of glTF/OBJ/STL/FBX, Layer ▸ New ▸ 3D primitives), the
//! comp renderer switch, Layer ▸ Environment Layer, Geometry Options for extruded text and
//! shapes, and Layer ▸ Material commands.
@@ -376,7 +376,7 @@ pub fn specs() -> Vec<CommandSpec> {
"3D Model Layer",
[],
None,
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}; switches a Classic 3D comp to Advanced 3D",
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb, .obj, .stl or .fbx file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}; switches a Classic 3D comp to Advanced 3D",
has_comp,
new_model
),
+1 -1
View File
@@ -284,7 +284,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result<Value> {
const SHAPE_ITEMS: &[&str] = &[
"group", "rect", "ellipse", "star", "path", "fill", "stroke", "gfill", "gstroke", "trim", "repeater", "round", "offset", "pucker", "twist", "zigzag",
"wiggle", "merge",
"wiggle", "merge", "front", "bevel", "side", "back",
];
/// Selected shape items (contents entries) of shape layers: (layer, uid).
+47 -10
View File
@@ -230,8 +230,20 @@ fn json_numbers(v: &Value) -> Option<Vec<f64>> {
}
}
/// The share of a Proportional Scrubbing change (After Effects 26.2, Ctrl+Alt / Cmd+Opt scrub)
/// that the layer at `index` of `count` selected layers (in selection order) takes: the first
/// none, the last all of it, the ones between even steps.
pub(crate) fn proportional_share(index: usize, count: usize) -> f64 {
match count.checked_sub(1) {
Some(last) if last > 0 => index.min(last) as f64 / last as f64,
_ => 1.0,
}
}
/// `prop.set`; with `selected`, also on the other selected layers: the same value, or with
/// `offset` (a scrub) the same change from each layer's own value. One undo step (with `merge`,
/// `offset` (a scrub) the same change from each layer's own value. With `offset` and
/// `proportional` (Proportional Scrubbing) each layer takes [`proportional_share`] of the change
/// by its place in the selection order, the scrubbed layer too. One undo step (with `merge`,
/// one that a continuing scrub keeps folding into).
fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
let twins = selected_twins(s, p, "prop.set")?;
@@ -244,16 +256,41 @@ fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
(Some(true), Some(n), Some(c)) => Some(n.iter().zip(c.iter().chain(std::iter::repeat(&0.0))).map(|(a, b)| a - b).collect::<Vec<f64>>()),
_ => None,
};
// Proportional Scrubbing: the edited layer and its twins in selection order.
let order: Option<Vec<LayerId>> = (delta.is_some() && b_p(p, "proportional") == Some(true))
.then(|| s.state.selected_layers.iter().copied().filter(|o| *o == lid || twins.iter().any(|(t, _)| t == o)).collect());
let share = |l: LayerId| order.as_ref().map_or(1.0, |o| o.iter().position(|x| *x == l).map_or(1.0, |i| proportional_share(i, o.len())));
let scalar = p.get("value").is_some_and(Value::is_number);
// `p` aimed at (layer, prop) with its value moved by `k` × the change.
let shifted = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid, k: f64| {
if let Some(d) = &delta
&& let Some(c) = current_components(s, cid, olid, ouid)
{
let v: Vec<f64> = c.iter().enumerate().map(|(i, x)| x + k * d.get(i).copied().unwrap_or(0.0)).collect();
q["value"] = if scalar { json!(v.first().copied().unwrap_or(0.0)) } else { json!(v) };
}
};
let run = |s: &mut Session| -> Result<Value> {
let out = set(s, p)?;
for (olid, ouid) in twins {
let mut q = twin_params(p, olid, ouid);
if let Some(d) = &delta
&& let Some(c) = current_components(s, cid, olid, ouid)
{
let v: Vec<f64> = c.iter().enumerate().map(|(i, x)| x + d.get(i).copied().unwrap_or(0.0)).collect();
q["value"] = if p.get("value").is_some_and(Value::is_number) { json!(v.first().copied().unwrap_or(0.0)) } else { json!(v) };
let out = if order.is_some() {
let k = share(lid);
if k <= 0.0 {
// (the first selected layer keeps its value: no key is added to it)
current_components(s, cid, lid, uid).map_or(Value::Null, |c| if scalar { json!(c.first().copied().unwrap_or(0.0)) } else { json!(c) })
} else {
let mut q = p.clone();
shifted(s, &mut q, lid, uid, k);
set(s, &q)?
}
} else {
set(s, p)?
};
for (olid, ouid) in twins {
let k = share(olid);
if k <= 0.0 {
continue;
}
let mut q = twin_params(p, olid, ouid);
shifted(s, &mut q, olid, ouid, k);
// (a layer that can't take it, e.g. a clamped or static property, keeps its value)
let _ = set(s, &q);
}
@@ -1140,7 +1177,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Property Value",
[],
None,
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change)}",
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all)}",
has_layers,
set_selected
),
+8
View File
@@ -46,6 +46,13 @@ fn summary(s: &mut Session, _: &Value) -> Result<Value> {
o["layers"] = json!(c.layers.len());
o["frameRate"] = json!(c.frame_rate.as_f64());
}
// Footage that shows no picture although its file is there (a codec or profile the
// decoders lack): why, so an agent can tell the user.
if let ItemKind::Footage(f) = &i.kind
&& let Some(why) = s.footage.problem(f)
{
o["problem"] = json!(why);
}
o
})
.collect();
@@ -80,6 +87,7 @@ fn comp_info(s: &mut Session, p: &Value) -> Result<Value> {
"id": cid.0, "name": name, "width": c.width, "height": c.height, "frameRate": c.frame_rate.as_f64(), "duration": c.duration.seconds(),
"workArea": [c.work_area.0.seconds(), c.work_area.1.seconds()], "background": c.background,
"preserveFrameRate": c.preserve_frame_rate, "preserveResolution": c.preserve_resolution,
"resolution": c.resolution.label(), "resolutionFactor": c.resolution.factor(),
"layers": c.layers.iter().enumerate().map(|(i, l)| layer_json(l, i + 1)).collect::<Vec<_>>(),
}))
}
+3 -3
View File
@@ -203,7 +203,7 @@ fn apply_om_template(om: &mut OutputModule, t: &OutputModule) {
}
/// The `format` parameter's values.
const FORMATS: &str = "format: h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff";
const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff";
/// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest
/// (captured by `crop.useRoi`).
@@ -999,7 +999,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add to Render Queue",
["Composition"],
Some("Cmd+M"),
"{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}",
"{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}",
can_add,
add
),
@@ -1019,7 +1019,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Output Module Settings...",
[],
None,
"{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}",
"{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}",
has_items,
set_output_module
),
+18
View File
@@ -384,6 +384,15 @@ pub fn specs() -> Vec<CommandSpec> {
always,
settings_save
),
cmd!(
"script.runTasks",
"Run Scheduled Script Tasks",
[],
None,
"{} → runs the app.scheduleTask tasks that are due (frontends do it every frame) → {ran, output, errors, next (ms until the next task, or null)}",
always,
scriptui::run_tasks
),
crate::query!("scriptui.list", "List Script Windows", "{} → [{window, title, kind: dialog|palette|window|panel, script, modal, size}]", scriptui::list),
crate::query!(
"scriptui.get",
@@ -409,6 +418,15 @@ pub fn specs() -> Vec<CommandSpec> {
always,
scriptui::set
),
cmd!(
"scriptui.mouse",
"Mouse Event on Script Window Control",
[],
None,
"{window?, widget, type: mousedown | mouseup | mousemove | mouseover | mouseout | click (default), x?, y? (relative to the control; default its centre), button? (0 left, 1 middle, 2 right), detail? (click count), screenX?, screenY?, altKey?, ctrlKey?, metaKey?, shiftKey?} → the script's addEventListener listeners on the control and its containers receive a MouseEvent",
always,
scriptui::mouse
),
cmd!(
"scriptui.close",
"Close Script Window",
+174 -17
View File
@@ -3,13 +3,14 @@
//! commands here set several of them in one undo step and add the groups to strokes saved before
//! they existed. Also the toolbar's Fill / Stroke options on selected shape layers.
use effectcraft_keyframe::Value as KV;
use effectcraft_keyframe::{Gradient, Value as KV};
use effectcraft_project::build::{self, EXTRA_DASHES, Ids};
use effectcraft_project::{ItemId, Layer, LayerId, LayerSource, Node, PropGroup, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::layer::color_p;
use super::shape_tool::{self, PaintKind};
use super::{CommandSpec, bad, f_p, has_layers, layer_mut, layer_p, layers_p, merge_p};
use crate::{EngineError, Result, Session, cmd};
@@ -224,15 +225,157 @@ pub fn first_paint(layer: &Layer, lt: Tick) -> (Option<[f64; 4]>, Option<[f64; 4
(fill, stroke, width)
}
/// Paint one contents group and its sub-groups: Fills take `fill`, Strokes `stroke`, Strokes and
/// Gradient Strokes `width`. With `add_stroke`, a group with a path and no stroke gets one (before
/// its fills, as new shapes have it).
#[allow(clippy::too_many_arguments)]
fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, stroke: Option<[f64; 4]>, width: Option<f64>, add_stroke: bool, depth: usize) {
/// What the toolbar's Fill and Stroke set on the selected shape layers.
#[derive(Clone, Copy, Debug, Default)]
struct PaintEdit {
fill: Option<[f64; 4]>,
stroke: Option<[f64; 4]>,
width: Option<f64>,
/// A group with a path and no stroke gets one.
add_stroke: bool,
fill_kind: Option<PaintKind>,
stroke_kind: Option<PaintKind>,
}
/// The kind of the first Fill and the first Stroke in `layer`'s contents at layer time `lt`: a
/// switched-off or missing one is None (what the toolbar shows for a selected shape layer).
pub fn first_paint_kinds(layer: &Layer, lt: Tick) -> (PaintKind, PaintKind) {
let (mut fill, mut stroke) = (None, None);
let kind = |g: &PropGroup| match g.match_id.as_str() {
_ if !g.enabled => PaintKind::None,
"fill" | "stroke" => PaintKind::Solid,
_ if g.get("type").is_some_and(|p| p.value_at(lt).as_enum() == 1) => PaintKind::Radial,
_ => PaintKind::Linear,
};
if let Some(c) = layer.props.sub("contents") {
walk_groups(c, &mut |g| match g.match_id.as_str() {
"fill" | "gfill" if fill.is_none() => fill = Some(kind(g)),
"stroke" | "gstroke" if stroke.is_none() => stroke = Some(kind(g)),
_ => {}
});
}
(fill.unwrap_or(PaintKind::None), stroke.unwrap_or(PaintKind::None))
}
/// The bounds (`[x, y, w, h]`) of the path items of one contents level at layer time `lt`, where
/// a new gradient's Start and End Points go; a 100 px square at the origin without paths.
fn path_bounds(c: &PropGroup, lt: Tick) -> [f64; 4] {
let mut acc: Option<[f64; 4]> = None;
for g in c.groups() {
let at = |id: &str| g.get(id).map(|p| p.value_at(lt));
let b = match g.match_id.as_str() {
"rect" | "ellipse" => {
let [w, h] = at("size").map(|v| v.as_vec2()).unwrap_or([100.0; 2]);
let [x, y] = at("position").map(|v| v.as_vec2()).unwrap_or([0.0; 2]);
Some([x - w.abs() / 2.0, y - h.abs() / 2.0, x + w.abs() / 2.0, y + h.abs() / 2.0])
}
"star" => {
let r = at("outerRadius").map(|v| v.as_f64().abs()).unwrap_or(100.0);
let [x, y] = at("position").map(|v| v.as_vec2()).unwrap_or([0.0; 2]);
Some([x - r, y - r, x + r, y + r])
}
"path" => {
let v = at("path");
let bez = v.as_ref().and_then(|v| v.as_path()).map(effectcraft_path::to_kurbo);
bez.and_then(|b| effectcraft_path::bounds(&[b])).map(|r| [r.x0, r.y0, r.x1, r.y1])
}
_ => None,
};
let Some(b) = b.filter(|b| b.iter().all(|v| v.is_finite())) else { continue };
acc = Some(match acc {
None => b,
Some(a) => [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])],
});
}
acc.map(|[x0, y0, x1, y1]| [x0, y0, x1 - x0, y1 - y0]).unwrap_or([-50.0, -50.0, 100.0, 100.0])
}
/// Fill Options / Stroke Options on one contents level (as in After Effects, with shape layers
/// selected they change the layers' paint): None switches its Fills and Gradient Fills (or
/// Strokes and Gradient Strokes) off; another kind switches them on, turns them into that kind
/// (keeping the properties both kinds have: blend mode, opacity, width, dashes…) and gives a
/// level with paths and no such item one.
fn set_kind(c: &mut PropGroup, ids: &mut Ids, lt: Tick, stroke: bool, kind: PaintKind, e: &PaintEdit) {
let (solid, grad) = if stroke { ("stroke", "gstroke") } else { ("fill", "gfill") };
let is_item = |g: &PropGroup| g.match_id == solid || g.match_id == grad;
if kind == PaintKind::None {
for n in &mut c.children {
if let Node::Group(g) = n
&& is_item(g)
{
g.enabled = false;
}
}
return;
}
let radial = kind == PaintKind::Radial;
let color = (if stroke { e.stroke } else { e.fill }).unwrap_or([1.0; 4]);
let width = e.width.filter(|w| *w > 0.0).unwrap_or(2.0);
let (start, end) = shape_tool::gradient_ends(radial, path_bounds(c, lt));
let fresh = |ids: &mut Ids| match (kind, stroke) {
(PaintKind::Solid, false) => build::shape_fill(ids, color),
(PaintKind::Solid, true) => build::shape_stroke(ids, color, width),
(_, false) => build::shape_gradient_fill(ids, radial, start, end, Gradient::default()),
(_, true) => build::shape_gradient_stroke(ids, radial, start, end, Gradient::default(), width),
};
let want = if kind == PaintKind::Solid { solid } else { grad };
let mut found = false;
for i in 0..c.children.len() {
let Some(Node::Group(g)) = c.children.get(i) else { continue };
if !is_item(g) {
continue;
}
found = true;
let replacement = (g.match_id != want).then(|| {
let mut new = fresh(ids);
new.uid = g.uid;
new.name = super::effect::unique_name(c, &new.name);
for n in &mut new.children {
if let Some(old) = g.children.iter().find(|o| o.match_id() == n.match_id()) {
*n = old.clone();
}
}
new
});
let Some(Node::Group(g)) = c.children.get_mut(i) else { continue };
match replacement {
Some(new) => *g = new,
None => {
g.enabled = true;
if let Some(t) = g.get_mut("type").filter(|_| want == grad) {
t.set_value_at(lt, KV::Enum(radial as u32));
}
}
}
}
if !found && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) {
let mut new = fresh(ids);
new.name = super::effect::unique_name(c, &new.name);
// Paths, strokes, then fills, as new shapes have them.
let at = if stroke {
c.children.iter().position(|n| matches!(n.match_id(), "fill" | "gfill")).unwrap_or(c.children.len())
} else {
c.children.iter().rposition(|n| PATHS.contains(&n.match_id()) || matches!(n.match_id(), "stroke" | "gstroke")).map_or(c.children.len(), |i| i + 1)
};
c.children.insert(at, new.into());
}
}
/// Paint one contents group and its sub-groups: the kinds first, then Fills take `fill`, Strokes
/// `stroke`, Strokes and Gradient Strokes `width`. With `add_stroke`, a group with a path and no
/// stroke gets one (before its fills, as new shapes have it).
fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, e: &PaintEdit, depth: usize) {
if depth > MAX_DEPTH {
return;
}
if add_stroke && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) && !c.groups().any(is_stroke) {
if let Some(k) = e.stroke_kind {
set_kind(c, ids, lt, true, k, e);
}
if let Some(k) = e.fill_kind {
set_kind(c, ids, lt, false, k, e);
}
let (fill, stroke, width) = (e.fill, e.stroke, e.width);
if e.add_stroke && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) && !c.groups().any(is_stroke) {
let at = c.children.iter().position(|n| matches!(n, Node::Group(g) if matches!(g.match_id.as_str(), "fill" | "gfill"))).unwrap_or(c.children.len());
c.children.insert(at, build::shape_stroke(ids, stroke.unwrap_or([1.0; 4]), width.unwrap_or(2.0)).into());
}
@@ -252,7 +395,7 @@ fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, str
"gstroke" => set(g, "width", width.map(KV::Scalar)),
"group" => {
if let Some(sub) = g.sub_mut("contents") {
paint(sub, ids, lt, fill, stroke, width, add_stroke, depth + 1);
paint(sub, ids, lt, e, depth + 1);
}
}
_ => {}
@@ -260,10 +403,20 @@ fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, str
}
}
/// `p`'s `fillType` / `strokeType` (none|solid|linear|radial).
fn kind_p(p: &Value, key: &str, c: &str) -> Result<Option<PaintKind>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_str().and_then(PaintKind::from_name).map(Some).ok_or_else(|| bad(c, format!("`{key}`: none|solid|linear|radial"))),
}
}
/// The toolbar's Fill and Stroke options with shape layers selected (as in After Effects, they
/// change the layers' fills and strokes): `fill` sets every Fill's colour, `stroke` every Stroke's,
/// `strokeWidth` every stroke's width. A stroke colour or a width above 0 gives the paths that
/// have no stroke one. Without values, reports the first layer's paint.
/// change the layers' fills and strokes): `fillType` / `strokeType` switch every Fill / Stroke
/// off (none) or turn them into Solid Color, Linear or Radial Gradient ones (adding one where a
/// path has none), `fill` sets every Fill's colour, `stroke` every Stroke's, `strokeWidth` every
/// stroke's width. A stroke colour or a width above 0 gives the paths that have no stroke one.
/// Without values, reports the first layer's paint.
fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let c = "shape.fillStroke";
let (cid, lids) = layers_p(s, p)?;
@@ -275,8 +428,10 @@ fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let rgba = |k: &str| color_p(p, k).map(|c| [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]);
let (fill, stroke) = (rgba("fill"), rgba("stroke"));
let width = f_p(p, "strokeWidth").filter(|w| w.is_finite()).map(|w| w.max(0.0));
if fill.is_some() || stroke.is_some() || width.is_some() {
let add_stroke = width.map_or(stroke.is_some(), |w| w > 0.0);
let (fill_kind, stroke_kind) = (kind_p(p, "fillType", c)?, kind_p(p, "strokeType", c)?);
let add_stroke = stroke_kind != Some(PaintKind::None) && width.map_or(stroke.is_some(), |w| w > 0.0);
let e = PaintEdit { fill, stroke, width, add_stroke, fill_kind, stroke_kind };
if fill.is_some() || stroke.is_some() || width.is_some() || fill_kind.is_some() || stroke_kind.is_some() {
let t = s.time();
s.edit("Fill and Stroke", merge_p(p), |proj, _| {
let mut next = proj.next_id;
@@ -284,7 +439,7 @@ fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
if let Some(contents) = l.props.sub_mut("contents") {
paint(contents, &mut Ids(&mut next), lt, fill, stroke, width, add_stroke, 0);
paint(contents, &mut Ids(&mut next), lt, &e, 0);
}
}
proj.next_id = next;
@@ -292,8 +447,10 @@ fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
})?;
}
let l = shapes.first().and_then(|id| s.project.comp(cid)?.layer(*id)).ok_or(EngineError::NoComp)?;
let (fill, stroke, width) = first_paint(l, l.layer_time(s.time()));
Ok(json!({"fill": fill, "stroke": stroke, "strokeWidth": width}))
let lt = l.layer_time(s.time());
let (fill, stroke, width) = first_paint(l, lt);
let (fill_kind, stroke_kind) = first_paint_kinds(l, lt);
Ok(json!({"fill": fill, "stroke": stroke, "strokeWidth": width, "fillType": fill_kind.id(), "strokeType": stroke_kind.id()}))
}
pub fn specs() -> Vec<CommandSpec> {
@@ -303,7 +460,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill and Stroke",
[],
None,
"{layers?, fill?: color, stroke?: color, strokeWidth?, merge?} → {fill, stroke, strokeWidth} (first layer)",
"{layers?, fillType?: none|solid|linear|radial, strokeType?, fill?: color, stroke?: color, strokeWidth?, merge?} → {fill, stroke, strokeWidth, fillType, strokeType} (first layer)",
has_layers,
fill_stroke
),
+9 -3
View File
@@ -139,12 +139,18 @@ fn blended(mut g: PropGroup, paint: &ToolPaint) -> PropGroup {
g
}
/// A gradient's Start and End Points across `bounds` (`[x, y, w, h]`): left to right, or out
/// from the centre.
pub(crate) fn gradient_ends(radial: bool, bounds: [f64; 4]) -> ([f64; 2], [f64; 2]) {
let [x, y, w, h] = bounds;
let mid = [x + w / 2.0, y + h / 2.0];
if radial { (mid, [mid[0] + w.max(h).max(2.0) / 2.0, mid[1]]) } else { ([x, mid[1]], [x + w.max(1.0), mid[1]]) }
}
/// The Stroke and Fill a new shape gets from `paint`; gradients run across `bounds` (`[x, y, w,
/// h]` in the group's space): left to right, or out from the centre.
pub(crate) fn paint_items(ids: &mut Ids, paint: &ShapeTool, bounds: [f64; 4]) -> Vec<PropGroup> {
let [x, y, w, h] = bounds;
let mid = [x + w / 2.0, y + h / 2.0];
let ends = |radial: bool| if radial { (mid, [mid[0] + w.max(h).max(2.0) / 2.0, mid[1]]) } else { ([x, mid[1]], [x + w.max(1.0), mid[1]]) };
let ends = |radial: bool| gradient_ends(radial, bounds);
let mut items = vec![];
let (s, width) = (&paint.stroke, paint.stroke_width);
let stroke = match s.kind {
+2 -2
View File
@@ -10,10 +10,10 @@ use super::{CommandSpec, not_yet, not_yet_run};
#[allow(unused_macros)]
macro_rules! stub {
($id:literal, $label:literal, [$($m:literal),*], $params:literal) => {
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: None, params: $params, enabled: not_yet, run: not_yet_run, journal: true }
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: None, alt_shortcuts: &[], params: $params, enabled: not_yet, run: not_yet_run, journal: true }
};
($id:literal, $label:literal, [$($m:literal),*], $sc:literal, $params:literal) => {
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: Some($sc), params: $params, enabled: not_yet, run: not_yet_run, journal: true }
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: Some($sc), alt_shortcuts: &[], params: $params, enabled: not_yet, run: not_yet_run, journal: true }
};
}
+9 -2
View File
@@ -115,6 +115,13 @@ fn add_animator(s: &mut Session, p: &Value) -> Result<Value> {
if added == 0 && !kinds.is_empty() {
return Err(bad(C, "unknown animator property"));
}
// Like After Effects, a scripted animator has no selectors until the script adds one
// (addProperty("ADBE Text Selector")); a default 0-100% one would select every character.
if kinds.is_empty()
&& let Some(sels) = g.sub_mut("selectors")
{
sels.children.clear();
}
let uid = g.uid;
anims.children.push(g.into());
proj.next_id = next;
@@ -246,7 +253,7 @@ fn enable_per_char(s: &mut Session, p: &Value) -> Result<Value> {
}
/// JSON → property value shaped like `like`.
fn json_value(v: &Value, like: &KV) -> Option<KV> {
pub(super) fn json_value(v: &Value, like: &KV) -> Option<KV> {
Some(match v {
Value::Bool(b) => match like {
KV::Bool(_) => KV::Bool(*b),
@@ -431,7 +438,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Animate Text",
[],
None,
"{layer?, properties: [anchor|position|scale|skew|rotation|opacity|transformAll|lineAnchor|lineSpacing|characterOffset|characterValue|blur|fillColor|fillHue|fillSaturation|fillBrightness|fillOpacity|strokeColor|strokeHue|strokeSaturation|strokeBrightness|strokeOpacity|strokeWidth|tracking|perChar3d], name?}",
"{layer?, properties: [anchor|position|scale|skew|rotation|opacity|transformAll|lineAnchor|lineSpacing|characterOffset|characterValue|blur|fillColor|fillHue|fillSaturation|fillBrightness|fillOpacity|strokeColor|strokeHue|strokeSaturation|strokeBrightness|strokeOpacity|strokeWidth|tracking|frontColor|bevelColor|sideColor|backColor|perChar3d], name?}",
has_text_layer,
add_animator
),
+5 -2
View File
@@ -506,7 +506,9 @@ pub fn fonts(_s: &mut Session, p: &Value) -> Result<Value> {
use effectcraft_text::fonts;
let added = if p.get("rescan").and_then(Value::as_bool).unwrap_or(false) { fonts::rescan_system() } else { 0 };
let query = p.get("query").and_then(Value::as_str).map(str::to_lowercase).unwrap_or_default();
let only_variable = p.get("variable").and_then(Value::as_bool).unwrap_or(false);
let native = fonts::native_families();
let variable = fonts::variable_families();
let mut origin: std::collections::BTreeMap<String, &'static str> = Default::default();
for f in fonts::all_faces() {
origin.entry(f.info.family.clone()).or_insert(f.info.origin);
@@ -514,8 +516,9 @@ pub fn fonts(_s: &mut Session, p: &Value) -> Result<Value> {
let list: Vec<Value> = effectcraft_text::families()
.into_iter()
.filter(|(f, _)| query.is_empty() || f.to_lowercase().contains(&query) || native.get(f).is_some_and(|n| n.to_lowercase().contains(&query)))
.filter(|(f, _)| !only_variable || variable.contains(f))
.map(|(f, styles)| {
let mut v = json!({"family": f, "styles": styles, "origin": origin.get(&f).copied().unwrap_or("system")});
let mut v = json!({"family": f, "styles": styles, "origin": origin.get(&f).copied().unwrap_or("system"), "variable": variable.contains(&f)});
if let Some(n) = native.get(&f) {
v["nativeName"] = json!(n);
}
@@ -559,7 +562,7 @@ pub fn specs() -> Vec<CommandSpec> {
"List Fonts",
[],
None,
"{query?, rescan?} → {count, families: [{family, styles, origin: bundled|system|user, nativeName?}], added} (rescan picks up fonts installed since launch)",
"{query?, variable?, rescan?} → {count, families: [{family, styles, origin: bundled|system|user, variable, nativeName?}], added} (variable: only variable fonts; rescan picks up fonts installed since launch)",
always_ok,
fonts
),
+24
View File
@@ -320,6 +320,21 @@ fn set_3d_view(s: &mut Session, p: &Value) -> Result<Value> {
Ok(switch_view(s, cid, v))
}
/// The view of one pane of the View Layout: pane 0 is the main view (the same as Switch 3D View),
/// panes 1 to 3 are the extra views of a 2- or 4-view layout. UI state, not part of the project.
fn set_pane_view(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let v = view_p(p, "view.setPaneView")?;
let pane = p.get("pane").and_then(Value::as_u64).filter(|n| *n <= 3).ok_or_else(|| bad("view.setPaneView", "pane: 0 (main view) to 3"))? as usize;
if pane == 0 {
switch_view(s, cid, v);
} else {
views(s, cid).set_pane(pane - 1, v);
}
let vs = views(s, cid);
Ok(json!({"pane": pane, "view": v.id(), "views": [vs.current.id(), vs.pane(0).id(), vs.pane(1).id(), vs.pane(2).id()]}))
}
macro_rules! view_cmd {
($f:ident, $v:expr) => {
fn $f(s: &mut Session, p: &Value) -> Result<Value> {
@@ -706,6 +721,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_comp,
set_3d_view
),
cmd!(
"view.setPaneView",
"Set View of Pane",
[],
None,
"{pane: 0 (main) | 1 | 2 | 3, view: activeCamera|default|front|left|top|back|right|bottom|custom1|custom2|custom3, comp?}",
has_comp,
set_pane_view
),
cmd!("view.3d.activeCamera", "Active Camera", ["View", "Switch 3D View"], Some("F12"), "{comp?}", has_comp, view_active),
cmd!("view.3d.default", "Default", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_default),
cmd!("view.3d.front", "Front", ["View", "Switch 3D View"], Some("F10"), "{comp?}", has_comp, view_front),
+11 -7
View File
@@ -39,7 +39,10 @@ pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) ->
effectcraft_time::parse_feet_frames(text, pf, current + start).map(|f| f - start).map_err(|e| e.0)
}
None => {
let parsed = effectcraft_time::parse_timecode(text, fr, fr.supports_drop_frame(), current).map_err(|e| e.0)?;
// The Timeline shows non-drop timecode (`:`), so typed `:`/`.` text is read as non-drop
// too; only explicit `;` fields mean drop-frame (#890).
let drop_frame = fr.supports_drop_frame() && text.contains(';');
let parsed = effectcraft_time::parse_timecode(text, fr, drop_frame, current).map_err(|e| e.0)?;
let relative = text.trim().starts_with('+') || text.trim().starts_with('-');
if relative {
Ok(parsed)
@@ -61,8 +64,9 @@ pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) ->
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
let t = if let Some(f) = p.get("frame").and_then(Value::as_i64) {
comp.frame_rate.tick_of(f)
// Dialog number fields send floats (`5.0`), so a frame is any JSON number, rounded.
let t = if let Some(f) = f_p(p, "frame") {
comp.frame_rate.tick_of(f.round() as i64)
} else if let Some(tc) = str_p(p, "timecode") {
let cur = comp.frame_rate.frame_at(s.time());
let f = parse_display_time(s, comp, tc, cur).map_err(|e| super::bad("time.set", e))?;
@@ -169,10 +173,10 @@ fn with_to(p: &Value, to: &str) -> Value {
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("time.set", "Go to Time...", ["View"], Some("Alt+Shift+J"), "{time? (s) | frame? | timecode?}", has_comp, set),
cmd!("time.nextFrame", "Next Frame", [], Some("PageDown"), "{}", has_comp, |s, _| step(s, &json!({"frames": 1}))),
cmd!("time.previousFrame", "Previous Frame", [], Some("PageUp"), "{}", has_comp, |s, _| step(s, &json!({"frames": -1}))),
cmd!("time.forward10", "Forward 10 Frames", [], Some("Shift+PageDown"), "{}", has_comp, |s, _| step(s, &json!({"frames": 10}))),
cmd!("time.back10", "Back 10 Frames", [], Some("Shift+PageUp"), "{}", has_comp, |s, _| step(s, &json!({"frames": -10}))),
cmd!("time.nextFrame", "Next Frame", [], ["PageDown", "Cmd+ArrowRight"], "{}", has_comp, |s, _| step(s, &json!({"frames": 1}))),
cmd!("time.previousFrame", "Previous Frame", [], ["PageUp", "Cmd+ArrowLeft"], "{}", has_comp, |s, _| step(s, &json!({"frames": -1}))),
cmd!("time.forward10", "Forward 10 Frames", [], ["Shift+PageDown", "Cmd+Shift+ArrowRight"], "{}", has_comp, |s, _| step(s, &json!({"frames": 10}))),
cmd!("time.back10", "Back 10 Frames", [], ["Shift+PageUp", "Cmd+Shift+ArrowLeft"], "{}", has_comp, |s, _| step(s, &json!({"frames": -10}))),
cmd!("time.step", "Step Frames", [], None, "{frames}", has_comp, step),
cmd!("time.start", "Go to Start", [], Some("Home"), "{}", has_comp, |s, _| go(s, &json!({"to": "start"}))),
cmd!("time.end", "Go to End", [], Some("End"), "{}", has_comp, |s, _| go(s, &json!({"to": "end"}))),
+35 -3
View File
@@ -1,7 +1,7 @@
//! View menu commands that change the project (guides) or headless-relevant editor state (the
//! region of interest).
//! View menu commands that change the project (guides, each comp's Resolution) or
//! headless-relevant editor state (the region of interest).
use effectcraft_project::Guide;
use effectcraft_project::{Guide, Resolution};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, str_p};
@@ -75,6 +75,24 @@ fn region_of_interest(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(s.state.region_of_interest))
}
/// View ▸ Resolution (and the Composition panel's Resolution popup): the comp's Resolution, saved
/// with the project but no undo step ([`Session::set_resolution`]).
fn set_resolution(s: &mut Session, p: &Value, r: Resolution) -> Result<Value> {
let cid = comp_id(s, p)?;
s.set_resolution(cid, r)?;
Ok(json!({"comp": cid.0, "resolution": r.label(), "factor": r.factor()}))
}
/// View ▸ Resolution ▸ Custom: `factor` renders every n-th pixel; without one the frontend asks
/// for it (the Custom Resolution dialog).
fn custom_resolution(s: &mut Session, p: &Value) -> Result<Value> {
match f_p(p, "factor") {
Some(n) => set_resolution(s, p, Resolution::from_factor(n.round().clamp(1.0, 40.0) as u64)),
None if p.get("factor").is_some_and(|v| !v.is_null()) => Err(bad("view.res.custom", "factor: a number from 1 to 40")),
None => super::frontend(s, "view.res.custom", p),
}
}
fn view_layout(s: &mut Session, p: &Value) -> Result<Value> {
let n = p.get("views").and_then(Value::as_u64).ok_or_else(|| bad("view.layout", "views: 1|2|4"))?;
if !matches!(n, 1 | 2 | 4) {
@@ -101,12 +119,26 @@ fn extended_viewer(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(v))
}
/// A `view.res.*` command setting one [`Resolution`].
macro_rules! res {
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $r:ident) => {
cmd!($id, $label, [$($m),*], $sc, "{comp?}", has_comp, |s, p| set_resolution(s, p, Resolution::$r))
};
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("view.addGuide", "Add Guide...", ["View"], None, "{orientation?: vertical|horizontal, position? (comp px)}", has_comp, add_guide),
cmd!("view.clearGuides", "Clear Guides", ["View"], None, "{comp?}", has_guides, clear_guides),
cmd!("view.importGuides", "Import Guides...", ["View"], None, "{path}", has_comp, import_guides),
cmd!("view.exportGuides", "Export Guides...", ["View"], None, "{path}", has_guides, export_guides),
res!("view.res.full", "Full", ["View", "Resolution"], Some("Cmd+J"), Full),
res!("view.res.half", "Half", ["View", "Resolution"], Some("Cmd+Shift+J"), Half),
res!("view.res.third", "Third", ["View", "Resolution"], None, Third),
res!("view.res.quarter", "Quarter", ["View", "Resolution"], Some("Cmd+Alt+Shift+J"), Quarter),
cmd!("view.res.custom", "Custom...", ["View", "Resolution"], None, "{factor?: 1..40 (render every n-th pixel), comp?}", has_comp, custom_resolution),
// The Composition panel's Resolution popup (not in the View menu).
res!("view.res.auto", "Resolution: Auto", [], None, Auto),
cmd!("view.layout", "Switch View Layout", [], None, "{views: 1|2|4}", has_comp, view_layout),
cmd!("view.shareViewOptions", "Share View Options", ["View", "Switch View Layout"], None, "{value?}", has_comp, share_view_options),
cmd!("view.extendedViewer", "Extended Viewer", [], None, "{value?}", always, extended_viewer),
+7 -1
View File
@@ -210,7 +210,13 @@ mod tests {
let mut paths: Vec<String> =
o.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.clone()) } else { None }).collect();
paths.sort();
assert_eq!(paths, ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"]);
// Windows joins the stored "media/..." onto the project's folder and leaves a path with mixed
// separators; `Path` equality compares components, so both spellings are the same file.
let expected = ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"];
assert_eq!(paths.len(), expected.len(), "{paths:?}");
for (got, want) in paths.iter().zip(expected) {
assert_eq!(std::path::Path::new(got), std::path::Path::new(want), "{paths:?}");
}
assert!(!o.is_dirty(), "finding the files is no edit");
let r = o.execute("footage.check", json!({"wait": true})).unwrap();
assert_eq!(r["missing"], 2);
+2 -1
View File
@@ -341,7 +341,8 @@ impl Session {
let k = new_line.iter().position(|&i| i == target).unwrap_or(0);
let undo: Vec<(String, Arc<Project>)> = (0..k).map(|i| (a.label[new_line[i + 1]].clone(), a.state[new_line[i]].clone())).collect();
let redo: Vec<(String, Arc<Project>)> = new_line[k + 1..].iter().rev().map(|&i| (a.label[i].clone(), a.state[i].clone())).collect();
self.project = a.state[target].clone();
let cur = std::mem::replace(&mut self.project, a.state[target].clone());
self.keep_view_settings(&cur);
self.history.undo = undo;
self.history.redo = redo;
self.history.branches = branches;
+83 -3
View File
@@ -350,6 +350,15 @@ pub enum Event {
},
/// Drop cached frames / renders (Edit ▸ Purge).
PurgeCaches,
/// Put an image on the system clipboard (Composition ▸ Copy Frame to Clipboard): `rgba` is
/// `width` × `height` straight-alpha RGBA8 rows. Headless sessions ignore it; the pixels are
/// not serialized.
CopyImage {
width: u32,
height: u32,
#[serde(skip)]
rgba: Vec<u8>,
},
}
pub struct Session {
@@ -698,9 +707,38 @@ impl Session {
self.events.push(Event::ProjectChanged { revision: self.revision });
}
/// Set a comp's Resolution. As in After Effects it is saved with the project (which becomes
/// modified) but is no undo step, and it changes no pixels: the comp's cached frames stay
/// valid ([`effectcraft_project::Comp::same_pixels`]).
pub fn set_resolution(&mut self, comp: ItemId, r: effectcraft_project::Resolution) -> Result<()> {
if self.project.comp(comp).ok_or(EngineError::NoComp)?.resolution == r {
return Ok(());
}
Arc::make_mut(&mut self.project).comp_mut(comp).ok_or(EngineError::NoComp)?.resolution = r;
self.bump();
Ok(())
}
/// Keep what lives outside the undo history (each comp's Resolution) as it is in `from`, the
/// project before undo, redo or a history jump restored another state.
pub(crate) fn keep_view_settings(&mut self, from: &Project) {
let keep: Vec<(ItemId, effectcraft_project::Resolution)> =
self.project.comps().filter_map(|(id, c)| from.comp(*id).map(|f| (*id, f.resolution)).filter(|(_, r)| *r != c.resolution)).collect();
if keep.is_empty() {
return;
}
let p = Arc::make_mut(&mut self.project);
for (id, r) in keep {
if let Some(c) = p.comp_mut(id) {
c.resolution = r;
}
}
}
pub fn undo(&mut self) -> bool {
let Some((label, p)) = self.history.undo.pop() else { return false };
let cur = std::mem::replace(&mut self.project, p);
self.keep_view_settings(&cur);
self.history.redo.push((label, cur));
self.history.merge_key = None;
self.sanitize_state();
@@ -711,6 +749,7 @@ impl Session {
pub fn redo(&mut self) -> bool {
let Some((label, p)) = self.history.redo.pop() else { return false };
let cur = std::mem::replace(&mut self.project, p);
self.keep_view_settings(&cur);
self.history.undo.push((label, cur));
self.history.merge_key = None;
self.sanitize_state();
@@ -850,6 +889,26 @@ impl Session {
self.events.push(Event::OpenComp(id));
}
/// The open compositions, as a project file records them ([`effectcraft_project::SavedView`]).
pub fn saved_view(&self) -> effectcraft_project::SavedView {
effectcraft_project::SavedView { active_comp: self.state.active_comp, open_comps: self.state.open_comps.clone() }
}
/// Reopen the compositions a project file was saved with (#748). Ids that no longer name a
/// comp are skipped; with none left the project stays on the comp [`Session::replace_project`]
/// opened (its first), as files from before the view was saved do.
pub fn restore_view(&mut self, view: &effectcraft_project::SavedView) {
let mut open: Vec<ItemId> = Vec::new();
for id in view.open_comps.iter().copied().filter(|id| self.project.comp(*id).is_some()) {
if !open.contains(&id) {
open.push(id);
}
}
let Some(active) = view.active_comp.filter(|id| self.project.comp(*id).is_some()).or_else(|| open.first().copied()) else { return };
self.state.open_comps = open;
self.open_comp(active);
}
pub fn toast(&mut self, msg: impl Into<String>) {
self.events.push(Event::Toast { message: msg.into(), error: false });
}
@@ -980,6 +1039,7 @@ impl Session {
self.roto_job = None;
self.roto_pending.clear();
self.drop_tasks();
self.script_ui.project_epoch = self.script_ui.project_epoch.wrapping_add(1);
p.fix_next_id();
upgrade_effects(&mut p);
self.project = Arc::new(p);
@@ -1046,6 +1106,8 @@ mod tests;
#[cfg(test)]
mod tests_3d;
#[cfg(test)]
mod tests_anim_presets;
#[cfg(test)]
mod tests_anim_tools;
#[cfg(test)]
mod tests_camera_track;
@@ -1068,10 +1130,14 @@ mod tests_fidelity;
#[cfg(test)]
mod tests_frame_export;
#[cfg(test)]
mod tests_fuzz_open;
#[cfg(test)]
mod tests_key_metadata_order;
#[cfg(test)]
mod tests_keylight;
#[cfg(test)]
mod tests_limits;
#[cfg(test)]
mod tests_lottie;
#[cfg(test)]
mod tests_m137;
@@ -1124,6 +1190,11 @@ pub fn text_presets() -> Vec<(String, String)> {
commands::text_preset_list()
}
/// Built-in animation presets (Effects & Presets ▸ * Animation Presets): (id, name, category).
pub fn animation_presets() -> Vec<(String, String, String)> {
commands::builtin_preset_list()
}
pub fn text_families() -> Vec<String> {
effectcraft_text::families().into_iter().map(|(f, _)| f).collect()
}
@@ -1153,6 +1224,8 @@ pub struct FontRow {
/// Settings ▸ Type ▸ Show Font Names in English is off.
pub display: String,
pub recent: bool,
/// A variable font (the menu's Variable Fonts filter keeps only these).
pub variable: bool,
}
/// Outline polylines of "Sample" set in `family` at `size` px (baseline at y = 0, y down), for
@@ -1193,14 +1266,21 @@ pub fn font_preview(family: &str, size: f64) -> Vec<Vec<[f32; 2]>> {
}
/// The Character panel's font menu: the recent fonts (Settings ▸ Type ▸ Number of Recent Fonts
/// to Display), a separator, then every family.
/// to Display), a separator, then every family. Each row says whether the family is variable,
/// for the menu's Variable Fonts filter.
pub fn font_menu(prefs: &prefs::Prefs) -> Vec<FontRow> {
let all = text_families();
let native = if prefs.type_.font_names_in_english { Default::default() } else { effectcraft_text::fonts::native_families() };
let row = |f: &String, recent: bool| FontRow { family: f.clone(), display: native.get(f).cloned().unwrap_or_else(|| f.clone()), recent };
let variable = effectcraft_text::fonts::variable_families();
let row = |f: &String, recent: bool| FontRow {
family: f.clone(),
display: native.get(f).cloned().unwrap_or_else(|| f.clone()),
recent,
variable: variable.contains(f),
};
let mut out: Vec<FontRow> = prefs.recent_fonts_shown().iter().filter(|f| all.contains(f)).map(|f| row(f, true)).collect();
if !out.is_empty() {
out.push(FontRow { family: String::new(), display: "-".into(), recent: false });
out.push(FontRow { family: String::new(), display: "-".into(), recent: false, variable: false });
}
out.extend(all.iter().map(|f| row(f, false)));
out
+1 -22
View File
@@ -1,27 +1,6 @@
//! Community and project links (shown in Help ▸, the About dialog, the start screen and the
//! header's Discord button).
//! Project links (shown in Help ▸, the About dialog and the start screen).
pub const APP_NAME: &str = "EffectCraft";
pub const APP_SLUG: &str = "effectcraft";
pub const DISCORD: &str = "https://discord.gg/artcraft";
pub const WEBSITE: &str = "https://getartcraft.com";
pub const APP_PAGE: &str = "https://getartcraft.com/apps/effectcraft";
pub const GITHUB: &str = "https://github.com/storytold/effectcraft";
pub const ISSUES: &str = "https://github.com/storytold/effectcraft/issues";
/// Sibling apps of the ArtCraft family: (name, what it is, GitHub repo, app page).
pub const SIBLINGS: &[(&str, &str)] = &[
("PhotoCraft", "photocraft"),
("VectorCraft", "vectorcraft"),
("FilmCraft", "filmcraft"),
("LightCraft", "lightcraft"),
("PdfCraft", "pdfcraft"),
("DesignCraft", "designcraft"),
];
pub fn github_of(slug: &str) -> String {
format!("https://github.com/storytold/{slug}")
}
pub fn page_of(slug: &str) -> String {
format!("https://getartcraft.com/apps/{slug}")
}
+301 -27
View File
@@ -1,25 +1,47 @@
//! Help ▸ Enable Logging / Reveal Logging File: a `log` logger that, while enabled, appends
//! every record to `Logs/EffectCraft Log.txt` in the settings folder (or the temporary folder),
//! and always keeps the last few hundred warnings and errors in memory for the System
//! Compatibility Report.
//! The `log` logger. Records go to standard error and, on the desktop, to a session log that
//! every launch starts afresh: `Logs/effectcraft.log` in the settings folder, with the two runs
//! before it kept as `effectcraft.1.log` and `effectcraft.2.log`, so the log of a run that
//! crashed survives the next start. A launch from the Dock, the Start menu or a launcher has no
//! terminal, so that file is what a bug report can attach. The last few hundred warnings and
//! errors are also kept in memory for the System Compatibility Report.
//!
//! Frontends call [`install`] once at startup; [`set_enabled`] (the `help.enableLogging`
//! command) turns file logging on and off. `RUST_LOG` (a level such as `debug`, or per target:
//! `effectcraft=debug,wgpu=warn`) also prints what it lets through to stderr, so a start-up that
//! fails before the window opens leaves a trace.
//! Levels: `info` for EffectCraft's own crates, `warn` for everything else (wgpu and naga are
//! chatty). `RUST_LOG` replaces that (a level such as `debug`, or per target:
//! `effectcraft=debug,wgpu=warn`).
//!
//! Help ▸ Enable Logging ([`set_enabled`], the `help.enableLogging` command) additionally appends
//! every EffectCraft record to `Logs/EffectCraft Log.txt`, across launches.
//!
//! Frontends call [`install`] once at startup and the desktop app [`attach_session_log`] once the
//! settings folder is known; records logged before that are kept (up to [`MAX_PENDING`]) and
//! written first. Writing never panics: a file that can't be created or written leaves standard
//! error as the only sink. Never log the control-channel token.
use std::collections::VecDeque;
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, OnceLock};
use std::sync::{Mutex, OnceLock, PoisonError};
use log::LevelFilter;
/// The session log's name inside the `Logs` folder.
pub const SESSION_LOG: &str = "effectcraft.log";
/// How many previous session logs are kept (`effectcraft.1.log` … `effectcraft.<KEEP>.log`).
pub const KEEP: usize = 2;
/// The session log stops growing past this size (a runaway warning can't fill the disk).
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
/// Records kept in memory until the session log is attached.
pub const MAX_PENDING: usize = 512;
/// The filter when `RUST_LOG` is unset or empty.
pub const DEFAULT_FILTER: &str = "warn,effectcraft=info";
static ENABLED: AtomicBool = AtomicBool::new(false);
/// The `RUST_LOG` filter for stderr: (target prefix or every target, level).
static STDERR: OnceLock<Vec<(Option<String>, LevelFilter)>> = OnceLock::new();
/// The filter for standard error and the session log: (target prefix or every target, level).
static FILTER: OnceLock<Vec<(Option<String>, LevelFilter)>> = OnceLock::new();
static STATE: Mutex<State> = Mutex::new(State { file: None, recent: VecDeque::new() });
static SESSION: Mutex<Sink> = Mutex::new(Sink::new(MAX_FILE_BYTES));
struct State {
file: Option<PathBuf>,
@@ -32,15 +54,16 @@ const RECENT: usize = 300;
impl log::Log for Logger {
fn enabled(&self, m: &log::Metadata) -> bool {
m.level() <= log::Level::Warn || (ENABLED.load(Ordering::Relaxed) && m.target().starts_with("effectcraft")) || to_stderr(m)
m.level() <= log::Level::Warn || (ENABLED.load(Ordering::Relaxed) && m.target().starts_with("effectcraft")) || shown(m)
}
fn log(&self, r: &log::Record) {
if !self.enabled(r.metadata()) {
return;
}
let line = format!("{} {:<5} {}: {}", unix_secs(), r.level(), r.target(), r.args());
if to_stderr(r.metadata()) {
eprintln!("{line}");
let line = format_line(&timestamp(unix_millis()), r.level(), &thread_name(), r.target(), &r.args().to_string());
if shown(r.metadata()) {
let _ = writeln!(std::io::stderr(), "{line}");
SESSION.lock().unwrap_or_else(PoisonError::into_inner).write(&format!("{line}\n"));
}
let Ok(mut st) = STATE.lock() else { return };
if r.level() <= log::Level::Warn {
@@ -59,22 +82,62 @@ impl log::Log for Logger {
fn flush(&self) {}
}
fn unix_secs() -> u64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
fn unix_millis() -> u64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)).unwrap_or(0)
}
fn thread_name() -> String {
#[cfg(not(target_arch = "wasm32"))]
return std::thread::current().name().unwrap_or("?").to_string();
#[cfg(target_arch = "wasm32")]
return "main".to_string();
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds) for `ms` milliseconds since 1970.
fn timestamp(ms: u64) -> String {
let secs = ms / 1000;
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, ms % 1000)
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned since the clock is clamped at the epoch). `days` is at most
/// `u64::MAX / 86_400_000`, so none of the arithmetic below can overflow.
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days + 719_468;
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + u64::from(m <= 2);
(y, m, d)
}
/// One log line (without the newline): `<timestamp> <LEVEL> [<thread>] <target>: <message>`.
fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
let message = message.strip_suffix('\n').unwrap_or(message);
format!("{ts} {level:<5} [{thread}] {target}: {message}")
}
/// Install the logger (once; later calls and other installed loggers are left alone).
pub fn install() {
static LOGGER: Logger = Logger;
if log::set_logger(&LOGGER).is_ok() {
let stderr = STDERR.get_or_init(|| std::env::var("RUST_LOG").map(|s| parse_filter(&s)).unwrap_or_default());
log::set_max_level(stderr.iter().map(|d| d.1).fold(LevelFilter::Info, Ord::max));
let filter = FILTER.get_or_init(|| {
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
parse_filter(spec.as_deref().unwrap_or(DEFAULT_FILTER))
});
log::set_max_level(filter.iter().map(|d| d.1).fold(LevelFilter::Info, Ord::max));
}
}
/// Whether `RUST_LOG` lets a record through to stderr.
fn to_stderr(m: &log::Metadata) -> bool {
STDERR.get().is_some_and(|f| m.level() <= level_for(f, m.target()))
/// Whether the filter lets a record through to standard error and the session log.
fn shown(m: &log::Metadata) -> bool {
FILTER.get().is_some_and(|f| m.level() <= level_for(f, m.target()))
}
/// `RUST_LOG`'s directives, as env_logger reads them: `level`, `target=level` or a bare target
@@ -110,9 +173,112 @@ pub fn install_panic_hook() {
}));
}
/// The log file inside `config_dir` (the settings folder), or the temporary folder.
/// The `Logs` folder inside `config_dir` (the settings folder), or in the temporary folder.
pub fn log_dir(config_dir: Option<&Path>) -> PathBuf {
config_dir.map(Path::to_path_buf).unwrap_or_else(std::env::temp_dir).join("Logs")
}
/// Help ▸ Enable Logging's file inside `config_dir` (the settings folder), or the temporary folder.
pub fn log_path(config_dir: Option<&Path>) -> PathBuf {
config_dir.map(Path::to_path_buf).unwrap_or_else(std::env::temp_dir).join("Logs").join("EffectCraft Log.txt")
log_dir(config_dir).join("EffectCraft Log.txt")
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
if n == 0 { dir.join(SESSION_LOG) } else { dir.join(format!("effectcraft.{n}.log")) }
}
/// Shift the previous session logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest
/// dropped) and create a fresh, empty log file in `dir` (created if missing).
fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
std::fs::create_dir_all(dir)?;
for n in (1..=KEEP).rev() {
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
_ => {}
}
}
let path = numbered(dir, 0);
let file = File::create(&path)?;
Ok((path, file))
}
/// Rotate the session logs in `dir` (the `Logs` folder, see [`log_dir`]) and write records to
/// a fresh `effectcraft.log` there, first the ones logged so far; returns its path.
pub fn attach_session_log(dir: &Path) -> Result<PathBuf, String> {
let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let mut sink = SESSION.lock().unwrap_or_else(PoisonError::into_inner);
sink.attach(file);
sink.path = Some(path.clone());
Ok(path)
}
/// This run's session log (once [`attach_session_log`] succeeded).
pub fn session_file() -> Option<PathBuf> {
SESSION.lock().unwrap_or_else(PoisonError::into_inner).path.clone()
}
/// Where lines go besides standard error: kept in memory until a file is attached, then
/// appended to it until it reaches its size cap.
struct Sink {
file: Option<File>,
path: Option<PathBuf>,
pending: Vec<String>,
dropped: usize,
written: u64,
max_bytes: u64,
capped: bool,
}
impl Sink {
const fn new(max_bytes: u64) -> Sink {
Sink { file: None, path: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
}
/// Append `line` to the file, or keep it until [`Sink::attach`].
fn write(&mut self, line: &str) {
if self.file.is_none() && !self.capped {
if self.pending.len() < MAX_PENDING {
self.pending.push(line.to_owned());
} else {
self.dropped = self.dropped.saturating_add(1);
}
return;
}
self.append(line);
}
/// Start writing to `file`, first the lines kept so far.
fn attach(&mut self, file: File) {
self.file = Some(file);
for line in std::mem::take(&mut self.pending) {
self.append(&line);
}
if self.dropped > 0 {
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
self.dropped = 0;
self.append(&note);
}
}
fn append(&mut self, line: &str) {
if self.capped {
return;
}
let Some(file) = self.file.as_mut() else { return };
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
let text = if self.written.saturating_add(len) > self.max_bytes {
self.capped = true;
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
} else {
self.written = self.written.saturating_add(len);
line.to_owned()
};
// A full disk or a vanished file must not take the app down: drop the file sink.
if file.write_all(text.as_bytes()).is_err() {
self.file = None;
self.capped = true;
}
}
}
/// Turn file logging on (writing to `file`) or off.
@@ -123,7 +289,7 @@ pub fn set_enabled(on: bool, file: PathBuf) -> std::io::Result<()> {
std::fs::create_dir_all(d)?;
}
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&file)?;
writeln!(f, "{} INFO effectcraft: logging enabled ({} {})", unix_secs(), std::env::consts::OS, env!("CARGO_PKG_VERSION"))?;
writeln!(f, "{} INFO effectcraft: logging enabled ({} {})", timestamp(unix_millis()), std::env::consts::OS, env!("CARGO_PKG_VERSION"))?;
}
if let Ok(mut st) = STATE.lock() {
st.file = Some(file);
@@ -136,7 +302,7 @@ pub fn is_enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
/// The current log file (set once logging was enabled).
/// The current Enable Logging file (set once logging was enabled).
pub fn current_file() -> Option<PathBuf> {
STATE.lock().ok().and_then(|s| s.file.clone())
}
@@ -148,7 +314,8 @@ pub fn recent() -> Vec<String> {
#[cfg(test)]
mod tests {
use super::{LevelFilter, level_for, parse_filter};
use super::*;
use std::sync::atomic::AtomicUsize;
/// `RUST_LOG` picks what start-up prints to stderr (#234).
#[test]
@@ -162,4 +329,111 @@ mod tests {
assert_eq!(level_for(&parse_filter("wgpu=debug"), "winit"), LevelFilter::Off);
assert!(parse_filter("").is_empty());
}
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("effectcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).unwrap()
}
/// Without `RUST_LOG`, EffectCraft's own info records and everyone's warnings are shown.
#[test]
fn the_default_filter_shows_effectcraft_info_and_other_crates_warnings() {
let f = parse_filter(DEFAULT_FILTER);
assert_eq!(level_for(&f, "effectcraft"), LevelFilter::Info);
assert_eq!(level_for(&f, "effectcraft_ui_egui::app"), LevelFilter::Info);
assert_eq!(level_for(&f, "wgpu_core::device"), LevelFilter::Warn);
assert_eq!(level_for(&f, "naga"), LevelFilter::Warn);
}
#[test]
fn timestamps_are_utc_with_milliseconds() {
assert_eq!(timestamp(0), "1970-01-01T00:00:00.000Z");
assert_eq!(timestamp(1_791_446_357_728), "2026-10-08T07:59:17.728Z");
assert_eq!(timestamp(1_709_164_800_000), "2024-02-29T00:00:00.000Z");
assert_eq!(timestamp(951_782_400_000), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(1_767_225_599_000), "2025-12-31T23:59:59.000Z");
let _ = timestamp(u64::MAX);
}
/// Each launch keeps the previous runs' logs, so the log of a run that crashed survives.
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).unwrap();
assert_eq!(path, dir.join(SESSION_LOG));
assert_eq!(read(&path), "");
std::fs::write(&path, format!("run {run}")).unwrap();
}
assert_eq!(read(&dir.join(SESSION_LOG)), "run 4");
assert_eq!(read(&dir.join("effectcraft.1.log")), "run 3");
assert_eq!(read(&dir.join("effectcraft.2.log")), "run 2");
assert!(!dir.join("effectcraft.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::write(&dir, "a file where the directory should be").unwrap();
assert!(rotate(&dir).is_err());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).unwrap();
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).unwrap();
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).unwrap();
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert!(text.lines().filter(|l| l.starts_with("line ")).count() < 5, "{text}");
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_line_carries_time_level_thread_target_and_message() {
assert_eq!(
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "effectcraft_engine::autosave", "disk full\n"),
"2026-10-08T07:59:17.728Z WARN [main] effectcraft_engine::autosave: disk full"
);
}
}
+2 -2
View File
@@ -194,10 +194,10 @@ pub struct Entry {
}
const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif"];
const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "hdr"];
const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "cin", "hdr"];
const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"];
const DATA: &[&str] = &["json", "csv", "tsv", "mgjson"];
const MODEL: &[&str] = &["gltf", "glb", "obj"];
const MODEL: &[&str] = &["gltf", "glb", "obj", "stl", "fbx"];
/// Kind of file from its extension.
pub fn kind_of(path: &str) -> Option<&'static str> {
+26 -3
View File
@@ -322,6 +322,9 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
}
"layer.markersLock" => Some(layer?.markers_locked),
"layer.mask.hideLocked" => Some(s.state.hide_locked_masks),
"view.res.full" | "view.res.half" | "view.res.third" | "view.res.quarter" | "view.res.custom" => {
Some(comp.resolution.label().eq_ignore_ascii_case(command.trim_start_matches("view.res.")))
}
"view.layout" => Some(params.get("views").and_then(Value::as_u64) == Some(s.state.view_layout.max(1) as u64)),
"view.shareViewOptions" => Some(s.state.share_view_options),
"view.extendedViewer" => Some(s.prefs.three_d.extended_viewer),
@@ -693,6 +696,7 @@ Composition
File... | comp.saveFrameAs
Photoshop Layers... | comp.saveFrameAsPsd
ProEXR... | comp.saveFrameAsExr
Copy Frame to Clipboard | comp.copyFrame
Pre-render... | render.preRender
Save Current Preview... | render.saveCurrentPreview
---
@@ -1021,6 +1025,14 @@ Animation
Brightness | layer.addTextAnimator {"properties":["strokeBrightness"]}
Opacity | layer.addTextAnimator {"properties":["strokeOpacity"]}
Stroke Width | layer.addTextAnimator {"properties":["strokeWidth"]}
Front
Color | layer.addTextAnimator {"properties":["frontColor"]}
Bevel
Color | layer.addTextAnimator {"properties":["bevelColor"]}
Side
Color | layer.addTextAnimator {"properties":["sideColor"]}
Back
Color | layer.addTextAnimator {"properties":["backColor"]}
---
Tracking | layer.addTextAnimator {"properties":["tracking"]}
Line Anchor | layer.addTextAnimator {"properties":["lineAnchor"]}
@@ -1138,6 +1150,20 @@ View
---
Go to Time... | time.set
Enter Full Screen | view.fullScreen
---
Preferences
Theme
Dark | view.theme.dark
Darker | view.theme.darker
Light | view.theme.light
---
Catppuccin Latte | view.theme.catppuccinLatte
Catppuccin Frappé | view.theme.catppuccinFrappe
Catppuccin Macchiato | view.theme.catppuccinMacchiato
Catppuccin Mocha | view.theme.catppuccinMocha
---
Next Appearance Mode | view.theme.toggle
Appearance... | app.settings {"page":"appearance"}
Window
Workspace
Default | window.workspace {"name":"Default"} | Shift+F10
@@ -1218,11 +1244,8 @@ Help
Enable Logging | help.enableLogging
Reveal Logging File | help.revealLogFile
---
Join the ArtCraft Discord... | help.discord
Provide Feedback... | help.reportIssue
---
ArtCraft Website | help.website
EffectCraft Home Page | help.appPage
EffectCraft on GitHub | help.github
Open Demo Project | file.openDemoProject
[!mac] ---
+92 -9
View File
@@ -32,11 +32,12 @@ pub const LANGUAGES: &[(&str, &str)] = &[
("Українська", "uk"),
("Español", "es"),
("Português (Brasil)", "pt-br"),
("Русский", "ru"),
("Français", "fr"),
("Deutsch", "de"),
("Čeština", "cs"),
];
/// Settings ▸ Appearance ▸ UI Scale: (label, `appearance.uiScale` percent).
pub const UI_SCALES: &[(&str, &str)] = &[("75%", "75"), ("100%", "100"), ("125%", "125"), ("150%", "150"), ("175%", "175"), ("200%", "200")];
/// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value).
pub const WINDOW_GRAPHICS: &[(&str, &str)] = &[("Automatic", "auto"), ("OpenGL (compatibility)", "gl")];
@@ -45,13 +46,27 @@ pub const WINDOW_GRAPHICS: &[(&str, &str)] = &[("Automatic", "auto"), ("OpenGL (
pub const APPEARANCE_MODES: &[(&str, &str)] = &[("Sync with System", "auto"), ("Dark", "dark"), ("Light", "light")];
/// The dark family of themes: (label, `appearance.darkTheme` value).
pub const DARK_THEMES: &[(&str, &str)] = &[("Dark", "dark"), ("Darker", "darker")];
pub const DARK_THEMES: &[(&str, &str)] = &[
("Dark", "dark"),
("Darker", "darker"),
("Catppuccin Frappé", "catppuccinFrappe"),
("Catppuccin Macchiato", "catppuccinMacchiato"),
("Catppuccin Mocha", "catppuccinMocha"),
];
/// The light family of themes: (label, `appearance.lightTheme` value).
pub const LIGHT_THEMES: &[(&str, &str)] = &[("Light", "light")];
pub const LIGHT_THEMES: &[(&str, &str)] = &[("Light", "light"), ("Catppuccin Latte", "catppuccinLatte")];
/// Every theme (the legacy single-theme `appearance.theme`).
pub const THEMES: &[(&str, &str)] = &[("Dark", "dark"), ("Darker", "darker"), ("Light", "light")];
pub const THEMES: &[(&str, &str)] = &[
("Dark", "dark"),
("Darker", "darker"),
("Light", "light"),
("Catppuccin Latte", "catppuccinLatte"),
("Catppuccin Frappé", "catppuccinFrappe"),
("Catppuccin Macchiato", "catppuccinMacchiato"),
("Catppuccin Mocha", "catppuccinMocha"),
];
/// The settings whose value must be one of their choices.
fn choices(key: &str) -> Option<&'static [(&'static str, &'static str)]> {
@@ -175,6 +190,8 @@ page!(Appearance {
dark_theme: String = "dark".into(),
/// The theme used in light appearance: `light`.
light_theme: String = "light".into(),
/// Theme shortcuts in the appearance context menu.
favorite_themes: Vec<String> = Vec::new(),
/// User interface brightness, -1 (darker) … 1 (lighter).
brightness: f64 = 0.0,
/// The size of the whole interface in percent (75–200), on top of the display's own scale.
@@ -185,6 +202,9 @@ page!(Appearance {
use_gradients: bool = true,
/// macOS: draw the menu bar inside the window instead of the system menu bar.
in_window_menu_bar_mac: bool = false,
/// Windows and Linux: keep the system's title bar instead of drawing the window's own.
/// Read before the window opens: it applies from the next launch.
system_title_bar: bool = false,
});
page!(Grids {
@@ -335,6 +355,42 @@ pub struct LabelPref {
pub color: String,
}
/// A Composition Settings preset the user saved (Composition Settings ▸ Preset ▸ Save): the
/// frame size, pixel aspect and frame rate, as in After Effects' own presets.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CompPreset {
pub name: String,
pub width: u32,
pub height: u32,
pub pixel_aspect: f64,
pub frame_rate: f64,
}
impl CompPreset {
/// Longest preset name kept.
pub const MAX_NAME: usize = 128;
/// Values a composition accepts (a hand-edited settings file can hold anything).
pub fn is_valid(&self) -> bool {
let size = 4..=30000;
!self.name.trim().is_empty()
&& self.name.chars().count() <= Self::MAX_NAME
&& size.contains(&self.width)
&& size.contains(&self.height)
&& self.pixel_aspect.is_finite()
&& (0.01..=100.0).contains(&self.pixel_aspect)
&& self.frame_rate.is_finite()
&& (1.0..=999.0).contains(&self.frame_rate)
}
}
/// Saved composition presets, skipping entries that don't parse instead of losing the list.
fn lenient_comp_presets<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<CompPreset>, D::Error> {
let v = Vec::<Value>::deserialize(d)?;
Ok(v.into_iter().filter_map(|x| serde_json::from_value(x).ok()).collect())
}
fn default_labels() -> Vec<LabelPref> {
Label::ALL.iter().skip(1).map(|l| LabelPref { name: l.name().to_string(), color: hex(l.rgb()) }).collect()
}
@@ -393,6 +449,9 @@ pub struct Prefs {
pub preview: crate::preview::PreviewSettings,
/// View ▸ Simulate Output ▸ My Custom RGB.
pub custom_rgb: crate::viewer::CustomRgb,
/// Composition Settings presets the user saved, in the order saved.
#[serde(deserialize_with = "lenient_comp_presets")]
pub comp_presets: Vec<CompPreset>,
/// Top-level keys this version doesn't know (kept for newer versions).
#[serde(flatten)]
pub extra: Extra,
@@ -428,6 +487,7 @@ impl Default for Prefs {
recent_fonts: vec![],
preview: crate::preview::PreviewSettings::default(),
custom_rgb: crate::viewer::CustomRgb::default(),
comp_presets: vec![],
extra: Extra::new(),
}
}
@@ -553,6 +613,7 @@ impl Prefs {
/// Clamp values into their valid ranges and fill missing labels.
pub fn normalize(&mut self) {
self.version = PREFS_VERSION;
self.comp_presets.retain(CompPreset::is_valid);
if !is_choice(WINDOW_GRAPHICS, &self.startup.window_graphics) {
self.startup.window_graphics = Startup::default().window_graphics;
}
@@ -567,6 +628,8 @@ impl Prefs {
a.interval_minutes = a.interval_minutes.clamp(1, 240);
a.max_versions = a.max_versions.clamp(1, 99);
let ap = &mut self.appearance;
let mut seen = std::collections::BTreeSet::new();
ap.favorite_themes.retain(|id| is_choice(THEMES, id) && seen.insert(id.clone()));
if !is_choice(APPEARANCE_MODES, &ap.appearance_mode) {
ap.appearance_mode = Appearance::default().appearance_mode;
}
@@ -623,6 +686,23 @@ impl Prefs {
let all: Vec<&str> = c.iter().map(|(_, v)| *v).collect();
return Err(format!("`{key}` expects one of {}", all.join(", ")));
}
let favorites = if key == "appearance.favoriteThemes" {
Some(&value)
} else if key == "appearance" {
value.get("favoriteThemes")
} else {
None
};
if let Some(favorites) = favorites {
let values = favorites.as_array().ok_or("favorite themes must be a list")?;
let mut seen = std::collections::BTreeSet::new();
for value in values {
let id = value.as_str().ok_or("favorite theme must be a string")?;
if !is_choice(THEMES, id) || !seen.insert(id) {
return Err("unknown or duplicate favorite theme".into());
}
}
}
// Older clients set the single theme (by key, or inside the page object): it still picks
// a visible theme, fixing the mode to its family.
let legacy_theme = key == "appearance.theme" || (key == "appearance" && value.get("theme").is_some() && value.get("appearanceMode").is_none());
@@ -642,8 +722,8 @@ impl Prefs {
Ok(())
}
/// Reset one page (`general`, `labels`, `autoSave`…) or everything (`None`). Open Recent is
/// kept.
/// Reset one page (`general`, `labels`, `autoSave`…) or everything (`None`). Open Recent and
/// the saved composition presets are kept.
pub fn reset(&mut self, page: Option<&str>) -> Result<(), String> {
let d = Prefs::default();
let Some(page) = page else {
@@ -653,6 +733,7 @@ impl Prefs {
recent_presets: std::mem::take(&mut self.recent_presets),
recent_fonts: std::mem::take(&mut self.recent_fonts),
preview: std::mem::take(&mut self.preview),
comp_presets: std::mem::take(&mut self.comp_presets),
..d
};
return Ok(());
@@ -1170,7 +1251,7 @@ pub fn pages() -> Vec<Page> {
s("appearance.lightTheme", "Light Theme", Kind::Choice(LIGHT_THEMES), true),
s("appearance.darkTheme", "Dark Theme", Kind::Choice(DARK_THEMES), true),
s("appearance.brightness", "Brightness", Kind::Slider(-1.0, 1.0), true),
s("appearance.uiScale", "UI Scale", Kind::Choice(UI_SCALES), true),
s("appearance.uiScale", "UI Scale", Kind::Int(75, 200, "%"), true),
Section("Labels and Colors"),
s("appearance.useLabelColorForHandles", "Use Label Color for Layer Handles and Paths", B, true),
s("appearance.useLabelColorForTabs", "Use Label Color for Related Tabs", B, true),
@@ -1178,6 +1259,8 @@ pub fn pages() -> Vec<Page> {
s("appearance.useGradients", "Use Gradients", B, true),
Section("Menu Bar"),
s("appearance.inWindowMenuBarMac", "Use In-Window Menu Bar on macOS", B, true),
Section("Title Bar"),
s("appearance.systemTitleBar", "Use System Title Bar on Windows and Linux", B, true),
],
},
Page {
+3 -1
View File
@@ -116,7 +116,9 @@ pub fn import(proj: &mut Project, psd: &Psd, path: &str, name: &str, retain: boo
}
fn new_comp(cx: &Ctx) -> Comp {
Comp::new(cx.psd.width, cx.psd.height, cx.rate, cx.duration)
// A PSB can be 300,000 px a side; a composition is at most 30,000 (#788).
let size = |v: u32| effectcraft_project::limits::size(v.into());
Comp::new(size(cx.psd.width), size(cx.psd.height), cx.rate, cx.duration)
}
fn build_comp(proj: &mut Project, cx: &mut Ctx, name: &str, nodes: &[Node], parent: Option<ItemId>) -> ItemId {
+5
View File
@@ -203,6 +203,11 @@ pub fn bytes_per_frame(om: &OutputModule, w: u32, h: u32, fps: f64) -> f64 {
};
mbps(rate) * px / 29.97
}
// 24-bit RGB rows padded to even bytes, or 32-bit ARGB.
OutputFormat::Uncompressed => {
let bpp = if om.channels == effectcraft_project::render_queue::Channels::Rgba { 4 } else { 3 };
((w as u64 * bpp).next_multiple_of(2) * h as u64) as f64
}
OutputFormat::WebM => mbps(8.0) * px / fps,
OutputFormat::Gif => w as f64 * h as f64 * 0.5,
OutputFormat::Wav | OutputFormat::Aiff => om.audio_sample_rate as f64 * 4.0 / fps,
+6 -6
View File
@@ -67,11 +67,11 @@ fn add_configure_render() {
let r = s.execute("renderQueue.setOutputModule", json!({"item": id, "format": "png", "channels": "rgba"})).unwrap();
assert!(r["outputPath"].as_str().unwrap().ends_with("_[#####].png"), "{r}");
assert!(s.execute("renderQueue.setOutputModule", json!({"item": id, "format": "jpeg", "channels": "rgba"})).is_err());
let r = s.execute("renderQueue.setOutput", json!({"item": id, "path": "/tmp/rq/out.mov"})).unwrap();
let r = s.execute("renderQueue.setOutput", json!({"item": id, "path": crate::tests_stubs::tmp_out("rq/out.mov")})).unwrap();
assert_eq!(r["output"]["format"], "ProRes", "Output To with .mov switches to ProRes");
assert_eq!(std::path::Path::new(r["outputPath"].as_str().unwrap()), std::path::absolute("/tmp/rq/out.mov").unwrap());
assert_eq!(std::path::Path::new(r["outputPath"].as_str().unwrap()), std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/out.mov")));
// A second item that fails, a third that is unqueued.
let b = s.execute("renderQueue.add", json!({"format": "gif", "output": "/tmp/rq/fail.gif"})).unwrap();
let b = s.execute("renderQueue.add", json!({"format": "gif", "output": crate::tests_stubs::tmp_out("rq/fail.gif")})).unwrap();
let c = s.execute("renderQueue.add", json!({"output": "/tmp/rq/skip.mp4"})).unwrap();
s.execute("renderQueue.setRender", json!({"item": c["item"], "render": false})).unwrap();
assert_eq!(s.project.render_queue[2].status.label(), "Unqueued");
@@ -79,12 +79,12 @@ fn add_configure_render() {
assert_eq!(r["items"].as_array().unwrap().len(), 2);
assert_eq!(
ex.log.lock().unwrap().iter().map(std::path::PathBuf::from).collect::<Vec<_>>(),
[std::path::absolute("/tmp/rq/out.mov").unwrap(), std::path::absolute("/tmp/rq/fail.gif").unwrap()]
[std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/out.mov")), std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/fail.gif"))]
);
let q = &s.project.render_queue;
assert_eq!(q[0].status.label(), "Done");
assert!(q[0].started.is_some() && q[0].render_time.is_some());
assert_eq!(std::path::Path::new(q[0].last_output.as_deref().unwrap()), std::path::absolute("/tmp/rq/out.mov").unwrap());
assert_eq!(std::path::Path::new(q[0].last_output.as_deref().unwrap()), std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/out.mov")));
assert_eq!(q[1].status, RenderStatus::Failed("boom".into()));
assert_eq!(q[2].status.label(), "Unqueued");
assert!(s.drain_events().iter().any(|e| matches!(e, Event::Toast { error: true, .. })));
@@ -162,7 +162,7 @@ fn without_exporter() {
s.execute("renderQueue.add", json!({})).unwrap();
assert!(!s.is_enabled("renderQueue.render"));
let f = s.execute("renderQueue.formats", json!({})).unwrap();
assert_eq!(f["formats"].as_array().unwrap().len(), 12);
assert_eq!(f["formats"].as_array().unwrap().len(), 13);
assert_eq!(f["formats"][0]["available"], false);
}
+79 -2
View File
@@ -110,6 +110,10 @@ pub struct Widget {
pub read_only: bool,
/// Event handlers the script attached (`onClick`, `onChange`, `onChanging`…).
pub handlers: Vec<String>,
/// The mouse events ([`MOUSE_EVENTS`]) a script listener receives when they happen on this
/// control: its own `addEventListener` listeners and its containers' (events bubble).
/// Frontends send only these.
pub mouse: Vec<String>,
/// The active tab of a tabbed panel (child index).
pub active_tab: usize,
/// What the control's `onDraw` handler painted (ScriptUIGraphics), in control coordinates.
@@ -364,16 +368,26 @@ pub struct ScriptWindow {
pub struct ScriptUiEvent {
pub window: u32,
pub widget: u32,
/// `click`, `change`, `changing` or `close`.
/// `click`, `change`, `changing`, `close`, or a mouse event ([`MOUSE_EVENTS`]).
pub kind: String,
/// The new value: text, number, bool, or selected index(es); for `close`, the result.
/// The new value: text, number, bool, or selected index(es); for `close`, the result; for
/// mouse events (and a mouse `click`), the pointer: `{x, y, button, detail, screenX, screenY,
/// altKey, ctrlKey, metaKey, shiftKey}` with `x`/`y` relative to the control.
pub value: Value,
}
/// The ScriptUI mouse events scripts can listen for (`addEventListener`).
pub const MOUSE_EVENTS: [&str; 6] = ["mousedown", "mouseup", "mousemove", "mouseover", "mouseout", "click"];
/// Hands an event to the script that owns the window; returns what the handler reported
/// (`{ok, output, error}`) and updates [`Session::script_ui`].
pub type ScriptUiDispatch = fn(&mut Session, &ScriptUiEvent) -> std::result::Result<Value, String>;
/// Runs the scripts' `app.scheduleTask` tasks that are due; returns
/// `{ran, output, errors: [{message, line, file}], next (ms until the next task is due, or null)}`
/// and updates [`Session::script_ui`]. Frontends call it every frame (see [`ScriptUi::run_tasks`]).
pub type ScriptTaskRunner = fn(&mut Session) -> Value;
/// The open script windows.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
@@ -384,6 +398,16 @@ pub struct ScriptUi {
/// The scripting engine's event hand-off (set with [`Session::script`]).
#[serde(skip)]
pub dispatch: Option<ScriptUiDispatch>,
/// The scripting engine's scheduled-task runner (set with [`Session::script`]).
#[serde(skip)]
pub tasks: Option<ScriptTaskRunner>,
/// Bumped whenever the project is closed or replaced (New / Open / Close Project): repeating
/// tasks of scripts without an open window stop then.
#[serde(skip)]
pub project_epoch: u64,
/// The script contexts of this session with scheduled tasks (kept by the scripting engine).
#[serde(skip)]
pub task_hosts: Vec<u32>,
}
impl ScriptUi {
@@ -391,6 +415,17 @@ impl ScriptUi {
self.windows.iter().find(|w| w.id == id)
}
/// Run the scripts' due `app.scheduleTask` tasks ([`ScriptTaskRunner`]); `None` without a
/// scripting engine.
pub fn run_tasks(s: &mut Session) -> Option<Value> {
let f = s.script_ui.tasks?;
// A task may close its ScriptUI panel: forget it then.
let open = crate::commands::scripts::open_panel_names(s);
let r = f(s);
crate::commands::scripts::forget_closed_panels(s, &open);
Some(r)
}
/// Replace the windows a script host published (laying them out).
pub fn publish(&mut self, host: u32, mut windows: Vec<ScriptWindow>) {
for w in &mut windows {
@@ -687,6 +722,48 @@ pub(crate) fn set(s: &mut Session, p: &Value) -> Result<Value> {
dispatch(s, ScriptUiEvent { window, widget, kind: kind.into(), value })
}
/// `script.runTasks`: run the scripts' `app.scheduleTask` tasks that are due now (frontends do
/// it every frame; headless hosts and agents call this).
pub(crate) fn run_tasks(s: &mut Session, _: &Value) -> Result<Value> {
ScriptUi::run_tasks(s).ok_or_else(|| EngineError::Other("scripting is not available in this build".into()))
}
/// `scriptui.mouse`: a mouse event on a control, as the pointer would make it: the script's
/// `addEventListener` listeners on the control and its containers receive a MouseEvent
/// (`clientX`/`clientY` relative to the control, `button`, `detail` = click count, modifier
/// keys). A `click` also does what clicking the control does (toggle a checkbox, press a button).
pub(crate) fn mouse(s: &mut Session, p: &Value) -> Result<Value> {
let c = "scriptui.mouse";
let window = window_p(s, p, c)?;
let widget = widget_p(s, window, p, c)?;
let ty = p.get("type").and_then(Value::as_str).unwrap_or("click").to_ascii_lowercase();
let Some(ty) = MOUSE_EVENTS.iter().find(|t| **t == ty) else {
return Err(crate::commands::bad(c, format!("unknown mouse event `{ty}` (one of {})", MOUSE_EVENTS.join(", "))));
};
let w = s.script_ui.window(window).and_then(|w| w.root.find(widget)).cloned().unwrap_or_default();
if !w.enabled {
return Err(crate::commands::bad(c, format!("control {widget} is disabled")));
}
// Hostile input stays finite and bounded.
const FAR: f64 = 1.0e6;
let num = |k: &str, lo: f64, hi: f64, d: f64| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).map_or(d, |v| v.clamp(lo, hi));
let flag = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false);
let clicks = if matches!(*ty, "mousemove" | "mouseover" | "mouseout") { 0.0 } else { 1.0 };
let value = json!({
"x": num("x", -FAR, FAR, w.bounds[2] / 2.0),
"y": num("y", -FAR, FAR, w.bounds[3] / 2.0),
"screenX": num("screenX", -FAR, FAR, 0.0),
"screenY": num("screenY", -FAR, FAR, 0.0),
"button": num("button", 0.0, 2.0, 0.0).round(),
"detail": num("detail", 0.0, 1000.0, clicks).round(),
"altKey": flag("altKey"),
"ctrlKey": flag("ctrlKey"),
"metaKey": flag("metaKey"),
"shiftKey": flag("shiftKey"),
});
dispatch(s, ScriptUiEvent { window, widget, kind: (*ty).to_string(), value })
}
/// `scriptui.close`: close a window (a dialog's `show()` returns `result`, default 2 = Cancel).
pub(crate) fn close(s: &mut Session, p: &Value) -> Result<Value> {
let window = window_p(s, p, "scriptui.close")?;
+4 -2
View File
@@ -88,7 +88,9 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
let entries = crate::menus::entries();
for (path, e) in &entries {
let key = binding_key(&e.command, &e.params);
let defaults = e.shortcut.iter().filter_map(|s| normalize(s)).collect();
// A plain entry also gets its command's further default keys.
let alt = if e.params.is_null() { crate::commands::find(&e.command).map_or(&[][..], |c| c.alt_shortcuts) } else { &[] };
let defaults = e.shortcut.as_deref().into_iter().chain(alt.iter().copied()).filter_map(normalize).collect();
push(
Bindable {
key,
@@ -106,7 +108,7 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
if !c.journal || entries.iter().any(|(_, e)| e.command == c.id) {
continue;
}
let defaults = c.shortcut.and_then(normalize).into_iter().collect();
let defaults = c.shortcut.into_iter().chain(c.alt_shortcuts.iter().copied()).filter_map(normalize).collect();
push(
Bindable {
key: c.id.to_string(),
+19
View File
@@ -190,6 +190,25 @@ pub fn cpu_cores() -> usize {
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1)
}
/// The graphics lines of Settings ▸ GPU Information and Help ▸ System Report: whether the GPU
/// compositor runs (and on which adapter), else why compositing is on the CPU, and the adapter
/// the window itself got. Without this a card the compositor couldn't use looked undetected (#596).
pub fn gpu_summary(s: &crate::Session) -> String {
let mut text = match s.accel.as_ref().map(|a| a.name()) {
Some(name) => format!("Compositing: GPU, {name}.\n"),
None => {
let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
let why = s.accel_note.as_deref().unwrap_or("no reason was recorded");
format!("Compositing: CPU, {threads} threads (pure Rust, rayon).\nGPU compositing is off: {why}\n")
}
};
match &s.window_adapter {
Some(adapter) => text.push_str(&format!("Window graphics: {adapter}\n")),
None => text.push_str("Window graphics: not known yet (the window has not drawn its first frame).\n"),
}
text
}
#[cfg(test)]
mod tests {
use super::*;
+111 -3
View File
@@ -280,6 +280,57 @@ fn toolbar_fill_and_stroke_paint_selected_shape_layers() {
let _ = s.render(cid, s.time(), Default::default());
}
/// The toolbar's Fill Options / Stroke Options change a selected shape layer's paint kind (#732):
/// None switches the fill off, a gradient turns the Fill into a Gradient Fill that keeps its
/// opacity, Solid Color turns it back, and a Stroke kind adds a stroke where there is none.
#[test]
fn toolbar_fill_and_stroke_kinds_change_selected_shape_layers() {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap();
let sh = s.execute("layer.newShape", json!({"kind": "rect", "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
let items = |s: &Session| -> Vec<(String, bool)> {
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap();
l.props.group("contents/group/contents").unwrap().groups().map(|g| (g.match_id.clone(), g.enabled)).collect()
};
let kinds = |s: &mut Session| {
let p = s.execute("shape.fillStroke", json!({})).unwrap();
(p["fillType"].as_str().unwrap().to_string(), p["strokeType"].as_str().unwrap().to_string())
};
assert_eq!(kinds(&mut s), ("solid".into(), "none".into()));
let cid = s.active_comp_id().unwrap();
let centre = |s: &Session| s.render(cid, s.time(), Default::default()).get(200, 150);
let red = centre(&s);
// None switches the fill off: nothing is drawn, and the item stays.
s.execute("shape.fillStroke", json!({"fillType": "none"})).unwrap();
assert_eq!(items(&s), [("rect".into(), true), ("fill".into(), false)]);
assert_eq!(kinds(&mut s).0, "none");
assert_ne!(centre(&s), red, "the fill is off");
// A radial gradient: the Fill becomes a Gradient Fill (switched on) that keeps its opacity.
s.execute("shape.fillStroke", json!({"fillType": "solid", "fill": "#ff0000"})).unwrap();
assert_eq!(centre(&s), red);
let opacity = |s: &Session, m: &str| {
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap();
l.props.group(&format!("contents/group/contents/{m}")).unwrap().get("opacity").unwrap().value.as_f64()
};
s.execute("prop.set", json!({"layer": sh, "path": "contents/group/contents/fill/opacity", "value": 40})).unwrap();
s.execute("shape.fillStroke", json!({"fillType": "radial"})).unwrap();
assert_eq!(items(&s), [("rect".into(), true), ("gfill".into(), true)]);
assert_eq!((kinds(&mut s).0.as_str(), opacity(&s, "gfill")), ("radial", 40.0));
s.execute("shape.fillStroke", json!({"fillType": "linear"})).unwrap();
assert_eq!(kinds(&mut s).0, "linear");
s.execute("shape.fillStroke", json!({"fillType": "solid", "fill": "#00ff00"})).unwrap();
assert_eq!(items(&s), [("rect".into(), true), ("fill".into(), true)]);
assert_eq!(opacity(&s, "fill"), 40.0);
// A Stroke kind adds a stroke (before the fill); None switches it off again; undo is per step.
s.execute("shape.fillStroke", json!({"strokeType": "linear"})).unwrap();
assert_eq!(items(&s), [("rect".into(), true), ("gstroke".into(), true), ("fill".into(), true)]);
s.execute("shape.fillStroke", json!({"strokeType": "none"})).unwrap();
assert_eq!(kinds(&mut s), ("solid".into(), "none".into()));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(kinds(&mut s), ("solid".into(), "linear".into()));
assert!(s.execute("shape.fillStroke", json!({"fillType": "plaid"})).is_err());
}
/// Selecting a mask in the Timeline (its row or its Mask Path) selects all of its points, so the
/// viewer drags the whole mask (#203); a shape's Path item works the same way.
#[test]
@@ -328,9 +379,8 @@ fn save_and_open_roundtrip() {
#[test]
fn help_links_emit_urls() {
let mut s = Session::default();
let r = s.execute("help.discord", json!({})).unwrap();
assert_eq!(r["url"], "https://discord.gg/artcraft");
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if u.contains("discord"))));
s.execute("help.github", json!({})).unwrap();
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if u.contains("github"))));
assert_eq!(s.execute("help.github", json!({})).unwrap()["url"], "https://github.com/storytold/effectcraft");
}
@@ -577,3 +627,61 @@ fn new_text_rejects_malformed_positions_without_creating_a_layer() {
s.execute("layer.newText", json!({"text": "No explicit position"})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 2);
}
/// The active comp and the open comp tabs are saved with the project and restored on open (#748).
#[test]
fn reopening_a_project_restores_its_active_comp() {
use effectcraft_project::ItemId;
let dir = std::env::temp_dir().join(format!("ec-test-748-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("view.ecproj").to_string_lossy().to_string();
let mut s = Session::default();
let a = s.execute("comp.new", json!({"name": "CompA"})).unwrap()["comp"].as_u64().unwrap();
let b = s.execute("comp.new", json!({"name": "CompB"})).unwrap()["comp"].as_u64().unwrap();
let c = s.execute("comp.new", json!({"name": "CompC"})).unwrap()["comp"].as_u64().unwrap();
s.execute("comp.open", json!({"comp": "CompB"})).unwrap();
s.state.open_comps.retain(|id| id.0 != c);
s.execute("file.saveAs", json!({"path": path})).unwrap();
let mut s2 = Session::default();
s2.execute("file.open", json!({"path": path})).unwrap();
assert_eq!(s2.state.active_comp, Some(ItemId(b)));
assert_eq!(s2.state.open_comps, vec![ItemId(a), ItemId(b)]);
assert!(!s2.is_dirty());
// A file from before the view was saved opens on its first comp, as it always did.
let text = std::fs::read_to_string(&path).unwrap();
let mut old: serde_json::Value = serde_json::from_str(&text).unwrap();
let obj = old.as_object_mut().unwrap();
obj.remove("activeComp");
obj.remove("openComps");
std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap();
let mut s3 = Session::default();
s3.execute("file.open", json!({"path": path})).unwrap();
assert_eq!((s3.state.active_comp, s3.state.open_comps.clone()), (Some(ItemId(a)), vec![ItemId(a)]));
// Ids that no longer name a comp (edited by hand, or written by another tool) fall back.
obj_set(&mut old, json!(999), json!([999, b, "x"]));
std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap();
let mut s4 = Session::default();
s4.execute("file.open", json!({"path": path})).unwrap();
assert_eq!((s4.state.active_comp, s4.state.open_comps.clone()), (Some(ItemId(b)), vec![ItemId(b)]));
obj_set(&mut old, json!(999), json!([]));
std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap();
let mut s5 = Session::default();
s5.execute("file.open", json!({"path": path})).unwrap();
assert_eq!((s5.state.active_comp, s5.state.open_comps.clone()), (Some(ItemId(a)), vec![ItemId(a)]));
// XML copies carry it too.
let xml = dir.join("view.ecprojx").to_string_lossy().to_string();
s.execute("file.saveAs", json!({"path": xml})).unwrap();
let mut s6 = Session::default();
s6.execute("file.open", json!({"path": xml})).unwrap();
assert_eq!(s6.state.active_comp, Some(ItemId(b)));
let _ = std::fs::remove_dir_all(dir);
fn obj_set(v: &mut serde_json::Value, active: serde_json::Value, open: serde_json::Value) {
let o = v.as_object_mut().unwrap();
o.insert("activeComp".into(), active);
o.insert("openComps".into(), open);
}
}
+22
View File
@@ -243,3 +243,25 @@ fn demo_has_3d_showcase() {
let lit = img.data.iter().filter(|p| p[3] > 0.99 && p[0] + p[1] + p[2] > 0.3).count();
assert!(lit > 2_000, "{lit}");
}
/// #670: the views of the panes of a multi-view layout are chosen per pane, kept per comp, and
/// editor states saved before panes existed load with the default Top / Front / Right.
#[test]
fn layout_panes_choose_their_view() {
use effectcraft_render::three_d::{View3D, Views3D};
let mut s = Session::default();
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 2})).unwrap();
let cid = s.active_comp_id().unwrap();
assert_eq!(s.state.views3d.get(&cid).cloned().unwrap_or_default().pane(0), View3D::Top);
let r = s.execute("view.setPaneView", json!({"pane": 2, "view": "bottom"})).unwrap();
assert_eq!(r["views"], json!(["activeCamera", "top", "bottom", "right"]));
assert_eq!(s.state.views3d[&cid].current, View3D::ActiveCamera, "the main view is untouched");
s.execute("view.setPaneView", json!({"pane": 0, "view": "custom1"})).unwrap();
assert_eq!(s.state.views3d[&cid].current, View3D::Custom1);
assert!(s.execute("view.setPaneView", json!({"pane": 4, "view": "top"})).is_err());
assert!(s.execute("view.setPaneView", json!({"pane": 1, "view": "nope"})).is_err());
let back: crate::EditorState = serde_json::from_value(serde_json::to_value(&s.state).unwrap()).unwrap();
assert_eq!(back.views3d[&cid].pane(1), View3D::Bottom);
let old: Views3D = serde_json::from_value(json!({"current": "Front"})).unwrap();
assert_eq!((old.current, old.pane(0), old.pane(1), old.pane(2)), (View3D::Front, View3D::Top, View3D::Front, View3D::Right));
}
+74
View File
@@ -0,0 +1,74 @@
//! Built-in animation presets (Effects & Presets ▸ * Animation Presets ▸ Transitions).
use effectcraft_time::Tick;
use serde_json::json;
use crate::Session;
use crate::render::RenderOpts;
/// A 160×90 comp with one white full-frame solid; returns (session, solid).
fn session() -> (Session, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "T", "width": 160, "height": 90, "frameRate": 30, "duration": 5})).unwrap();
let solid = s.execute("layer.newSolid", json!({"name": "S", "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
(s, solid)
}
/// Share of the comp's pixels that are mostly opaque at `secs`.
fn coverage(s: &Session, secs: f64) -> f64 {
let cid = s.active_comp_id().unwrap();
let img = s.render(cid, Tick::from_seconds_f64(secs), RenderOpts { scale: 1.0, ..Default::default() });
img.data.iter().filter(|p| p[3] > 0.5).count() as f64 / img.data.len().max(1) as f64
}
#[test]
fn every_transition_preset_applies_and_transitions() {
let (mut s, solid) = session();
let list = s.execute("anim.builtinPresets", json!({"category": "Transitions"})).unwrap();
let list = list.as_array().unwrap().clone();
assert!(list.len() >= 20, "{}", list.len());
assert_eq!(crate::animation_presets().len(), list.len());
let start = 0.5;
for p in &list {
let id = p["id"].as_str().unwrap();
let incoming = match p["direction"].as_str() {
Some("in") => true,
Some("out") => false,
d => panic!("{id}: direction {d:?}"),
};
s.execute("time.set", json!({"time": start})).unwrap();
let r = s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": id})).unwrap();
assert_eq!(r["skipped"], json!([]), "{id}: every path in the preset exists and takes its values");
let (a, b) = (coverage(&s, start), coverage(&s, start + 2.0));
let (hidden, shown) = if incoming { (a, b) } else { (b, a) };
assert!(hidden < 0.15, "{id}: hidden end covers {hidden:.3}");
assert!(shown > 0.97, "{id}: shown end covers {shown:.3}");
// Keys start at the current time: before it the layer holds the first key's state.
assert!((coverage(&s, 0.0) - a).abs() < 0.02, "{id}");
s.execute("edit.undo", json!({})).unwrap();
let l = &s.active_comp().unwrap().layers[0];
assert_eq!(l.effects().map_or(0, |fx| fx.groups().count()), 0, "{id}: undo removes the effects");
assert!(l.props.prop("transform/opacity").unwrap().keys.is_empty(), "{id}: undo removes the keys");
}
}
#[test]
fn transition_preset_by_name_and_bad_input() {
let (mut s, solid) = session();
// By name; a second application numbers the effect like Effect ▸ apply does.
s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": "Wipe In from Left"})).unwrap();
let r = s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": "wipeInFromLeft"})).unwrap();
assert_eq!(r["effects"].as_array().unwrap().len(), 1);
let names: Vec<String> = s.active_comp().unwrap().layers[0].effects().unwrap().groups().map(|g| g.name.clone()).collect();
assert_eq!(names, ["Linear Wipe", "Linear Wipe 2"]);
let fx = s.active_comp().unwrap().layers[0].effects().unwrap().groups().next().unwrap().clone();
let completion = fx.prop("completion").unwrap();
assert_eq!(completion.keys.len(), 2);
assert_eq!(completion.keys[0].time, Tick::ZERO);
// Unknown presets and missing params are errors, not panics.
assert!(s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": "nope"})).is_err());
assert!(s.execute("anim.applyBuiltinPreset", json!({"layers": [solid]})).is_err());
// A layer without effects (a camera) refuses effect presets.
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
assert!(s.execute("anim.applyBuiltinPreset", json!({"layers": [cam], "preset": "clockReveal"})).is_err());
}
+31 -1
View File
@@ -1,4 +1,4 @@
//! Composition ▸ Save Frame As ▸ Photoshop Layers… / ProEXR….
//! Composition ▸ Save Frame As ▸ Photoshop Layers… / ProEXR…, and Copy Frame to Clipboard.
use serde_json::json;
@@ -121,3 +121,33 @@ fn proexr_values_at_or_below_one_are_unchanged() {
assert!((got[0] - 1.0).abs() < 1e-4 && (got[1] - 0.1329).abs() < 1e-3 && got[2].abs() < 1e-6, "{got:?}");
}
}
/// Composition ▸ Copy Frame to Clipboard (After Effects 26.3): the frame, as Save Frame As
/// renders it, goes to the frontend as an image for the system clipboard.
#[test]
fn copy_frame_hands_the_rendered_frame_to_the_frontend() {
let mut s = frame();
s.drain_events();
let r = s.execute("comp.copyFrame", json!({})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(32), Some(32)));
let events = s.drain_events();
let Some((width, height, rgba)) = events.iter().find_map(|e| match e {
crate::Event::CopyImage { width, height, rgba } => Some((*width, *height, rgba)),
_ => None,
}) else {
panic!("no CopyImage event in {events:?}")
};
assert_eq!((width, height, rgba.len()), (32, 32, 32 * 32 * 4));
// Red in the corner; red × (50% blue multiply) in the middle; opaque everywhere.
assert_eq!(&rgba[..4], &[255, 0, 0, 255]);
let mid = &rgba[(16 * 32 + 16) * 4..][..4];
assert!(mid[0] > 100 && mid[0] < 160 && mid[2] < 13 && mid[3] == 255, "{mid:?}");
assert!(rgba.chunks(4).all(|p| p[3] == 255));
// The pixels are not serialized into the event stream.
let json = serde_json::to_value(events.iter().find(|e| matches!(e, crate::Event::CopyImage { .. })).unwrap()).unwrap();
assert!(json["CopyImage"].get("rgba").is_none(), "{json}");
// Scaled, and refused without a composition.
assert_eq!(s.execute("comp.copyFrame", json!({"scale": 0.5})).unwrap()["width"], 16);
assert!(Session::default().execute("comp.copyFrame", json!({})).is_err());
assert!(crate::menus::entries().iter().any(|(path, e)| e.command == "comp.copyFrame" && path.first().map(String::as_str) == Some("Composition")));
}
+342
View File
@@ -0,0 +1,342 @@
//! A project-file fuzzer (gaps G3, #779): File ▸ Open must turn any bytes into a project or an
//! error, never a panic, an abort or a hang (AGENTS.md "Never crash").
//!
//! Dependency-free, deterministic and seeded: seed projects (empty, and the demo cut down to one
//! layer at a time, each as `.ecproj` JSON and as `.ecprojx` XML) are mutated byte-wise (flips,
//! deletions, splices, truncation, interesting tokens, deep nesting) and structurally (a JSON
//! node replaced by an extreme number, another type or another subtree; members dropped,
//! elements duplicated), then opened through the real `file.open` path, followed by the queries
//! the app and agents run on a fresh project and a thumbnail-size render of the first frame.
//! Each input runs on its own thread under `catch_unwind` with a deadline, so a panic or a hang
//! is reported (with the iteration to reproduce it) instead of taking the test run down; a stack
//! overflow can't be caught and aborts the run, which fails it all the same.
//!
//! The default run is bounded (iterations and seconds) to stay fast in `cargo test`; a longer run:
//! `EFFECTCRAFT_FUZZ_ITERS=200000 EFFECTCRAFT_FUZZ_SECS=600 cargo test -p effectcraft-engine fuzz_`
//! (`EFFECTCRAFT_FUZZ_SEED` picks another sequence, `EFFECTCRAFT_FUZZ_START` the first iteration).
//! The `mutate` + `open_input` pair is what a cargo-fuzz target would call.
use std::sync::Arc;
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use crate::{Services, Session};
const PATH: &str = "/fuzz/input.ecproj";
/// splitmix64: tiny, good enough for picking mutations.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// `0..n` (`0` when `n` is 0).
fn below(&mut self, n: usize) -> usize {
if n == 0 { 0 } else { (self.next() % n as u64) as usize }
}
fn chance(&mut self, one_in: usize) -> bool {
self.below(one_in) == 0
}
}
/// One file, served from memory; everything else is missing.
struct OneFile(Vec<u8>);
impl Services for OneFile {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
if path == PATH { Ok(self.0.clone()) } else { Err(std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string())) }
}
fn write_file(&self, _: &str, _: &[u8]) -> std::io::Result<()> {
Err(std::io::Error::other("the fuzzer writes nothing"))
}
fn exists(&self, path: &str) -> bool {
path == PATH
}
fn stamp(&self, _: &str) -> Option<(u64, u128)> {
None
}
fn list_dir(&self, _: &str) -> Vec<String> {
vec![]
}
fn is_dir(&self, _: &str) -> bool {
false
}
}
/// Open `bytes` as a project file, then run what the app and agents read first: `Ok(true)` when
/// it opened, `Ok(false)` when it was refused. `Err` only for a crash: a panic in the open
/// itself, or one the command guard caught in a query.
fn open_input(bytes: Vec<u8>) -> Result<bool, String> {
let mut s = Session { services: Arc::new(OneFile(bytes)), ..Default::default() };
if crate::commands::open_for_tests(&mut s, &json!({ "path": PATH })).is_err() {
return Ok(false); // refused: fine
}
let crashed = |r: crate::Result<Value>| match r {
Err(e) if e.to_string().contains("internal error") => Err(e.to_string()),
_ => Ok(()),
};
crashed(s.execute("project.summary", json!({})))?;
let comps: Vec<_> = s.project.comps().map(|(id, c)| (id.0, c.layers.iter().map(|l| l.id.0).collect::<Vec<_>>())).collect();
for (comp, layers) in comps.into_iter().take(4) {
crashed(s.execute("comp.info", json!({ "comp": comp })))?;
for layer in layers.into_iter().take(8) {
crashed(s.execute("layer.tree", json!({ "comp": comp, "layer": layer, "depth": 3, "time": 1.0 })))?;
}
}
// The first frame the viewer shows, at thumbnail size.
if let Some(id) = s.state.active_comp
&& let Some(c) = s.project.comp(id)
{
let scale = (32.0 / f64::from(c.width.max(c.height).max(1))).min(1.0);
effectcraft_render::render_frame(&s.project, id, effectcraft_time::Tick::from_seconds_f64(1.0), scale);
}
Ok(true)
}
/// Run one input on its own thread: a panic becomes its message, a hang a timeout.
fn run_guarded(bytes: Vec<u8>, deadline: Duration) -> Result<bool, String> {
let (tx, rx) = std::sync::mpsc::channel();
let spawned = std::thread::Builder::new().name("fuzz-open".into()).stack_size(8 << 20).spawn(move || {
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| open_input(bytes)));
let r = match r {
Ok(r) => r,
Err(p) => {
Err(format!("panic: {}", p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default()))
}
};
let _ = tx.send(r);
});
if let Err(e) = spawned {
return Err(format!("couldn't start the fuzz thread: {e}"));
}
rx.recv_timeout(deadline).unwrap_or_else(|_| Err(format!("hang: no result within {deadline:?}")))
}
/// Small projects covering what a project holds: the empty project, and the demo cut down to
/// one layer of one comp at a time (shape, text, solid, precomp, camera, light, null, effects,
/// masks, expressions; other comps keep their first layer), each as JSON and as XML. The whole
/// demo (700 kB) would make every input slow.
fn seeds() -> Vec<Vec<u8>> {
let demo = crate::demo::demo_project();
let mut projects = vec![effectcraft_project::Project::default()];
for (cid, c) in demo.comps() {
for i in 0..c.layers.len() {
let mut p = demo.clone();
for (id, item) in p.items.iter_mut() {
if let effectcraft_project::ItemKind::Comp(c) = &mut item.kind {
let keep = if id == cid { i } else { 0 };
let layers = &mut Arc::make_mut(c).layers;
*layers = layers.get(keep).cloned().into_iter().collect();
}
}
projects.push(p);
}
}
let mut out = vec![];
for p in projects {
let json = p.to_file_json().unwrap();
out.push(crate::xml_project::to_xml(&json).unwrap().into_bytes());
out.push(json.into_bytes());
}
out
}
#[rustfmt::skip]
const TOKENS: &[&str] = &[
"{", "}", "[", "]", "\"", ",", ":", "null", "true", "-1", "0", "1e308", "-1e308", "1e-320", "18446744073709551615",
"-9223372036854775808", "4294967295", "4294967296", "\\u0000", "\\ud800", "<", ">", "</", "/>", "<arr>", "</arr>", "<obj>",
"<m n=\"", "</m>", "<num>", "</num>", "<str>", "</str>", "<null/>", "<!--", "-->", "<?", "?>", "&", ";", "&#x110000;",
"&#xD800;", "&#;", "&#x;", "&amp", "\u{feff}", "é", "\u{1F600}",
];
const NUMBERS: &[f64] = &[0.0, -0.0, 1.0, -1.0, 0.5, -0.5, 2.0, 1e-300, 1e15, 1e300, -1e300, 4294967295.0, 4294967296.0, 65536.0, 254016000000.0];
const INTEGERS: &[i64] = &[0, -1, 1, 2, 3, 255, 256, 65535, 2147483647, -2147483648, i64::MAX, i64::MIN, 9_007_199_254_740_993];
/// Every node of `v`, depth first (the root is 0).
fn count(v: &Value) -> usize {
1 + match v {
Value::Array(a) => a.iter().map(count).sum(),
Value::Object(m) => m.values().map(count).sum(),
_ => 0,
}
}
/// The `k`-th node (depth first; the root is 0).
fn nth<'a>(v: &'a mut Value, k: &mut usize) -> Option<&'a mut Value> {
if *k == 0 {
return Some(v);
}
*k -= 1;
match v {
Value::Array(a) => a.iter_mut().find_map(|x| nth(x, k)),
Value::Object(m) => m.values_mut().find_map(|x| nth(x, k)),
_ => None,
}
}
fn node_mut(v: &mut Value, k: usize) -> Option<&mut Value> {
nth(v, &mut { k })
}
fn interesting(r: &mut Rng, around: &Value) -> Value {
match r.below(7) {
0 => json!(NUMBERS[r.below(NUMBERS.len())]),
1 => json!(INTEGERS[r.below(INTEGERS.len())]),
2 => match around.as_f64() {
Some(x) => json!(x * [-1.0, 2.0, 1e6, 1e-6, 0.0][r.below(5)]),
None => Value::Null,
},
3 => json!(["", "#999", "@0", "effects/#1", "../../x", "\u{0}", "transform/position", &"a".repeat(r.below(300))][r.below(8)]),
4 => Value::Bool(r.chance(2)),
5 => json!([]),
_ => Value::Null,
}
}
/// A structural mutation of a project's JSON (when the input still parses).
fn mutate_tree(r: &mut Rng, v: &mut Value) {
let n = count(v);
for _ in 0..1 + r.below(4) {
let k = r.below(n.max(1));
let donor = node_mut(v, r.below(n.max(1))).map(|d| d.clone()).unwrap_or(Value::Null);
let Some(node) = node_mut(v, k) else { return };
match r.below(6) {
0 | 1 => *node = interesting(r, node),
2 => *node = donor,
3 => match node {
Value::Object(m) if !m.is_empty() => {
let key = m.keys().nth(r.below(m.len())).cloned().unwrap_or_default();
m.remove(&key);
}
Value::Array(a) if !a.is_empty() => {
a.remove(r.below(a.len()));
}
_ => *node = Value::Null,
},
4 => {
if let Value::Array(a) = node
&& !a.is_empty()
{
let i = r.below(a.len());
let x = a[i].clone();
a.insert(r.below(a.len() + 1), x);
}
}
_ => {
if let Value::Object(m) = node {
m.insert(["id", "parent", "source", "schema", "nextId", "layers", "items", "kind"][r.below(8)].into(), interesting(r, &Value::Null));
}
}
}
}
}
fn mutate_bytes(r: &mut Rng, b: &mut Vec<u8>) {
for _ in 0..1 + r.below(3) {
let len = b.len();
match r.below(8) {
0 if len > 0 => {
let i = r.below(len);
b[i] ^= 1 << r.below(8);
}
1 if len > 0 => {
let i = r.below(len);
b[i] = r.next() as u8;
}
2 if len > 0 => {
let a = r.below(len);
let e = (a + 1 + r.below(64)).min(len);
b.drain(a..e);
}
3 if len > 0 => {
let a = r.below(len);
let e = (a + 1 + r.below(256)).min(len);
let chunk = b[a..e].to_vec();
let at = r.below(len + 1);
b.splice(at..at, chunk);
}
4 => {
let t = TOKENS[r.below(TOKENS.len())].as_bytes();
let at = r.below(len + 1);
b.splice(at..at, t.iter().copied());
}
5 if len > 0 => b.truncate(r.below(len)),
6 => {
// Deep nesting: what a recursive reader overflows its stack on.
let (open, close) = [("[", "]"), ("<arr>", "</arr>"), ("<obj><m n=\"a\">", "</m></obj>")][r.below(3)];
let depth = [200, 5_000, 200_000][r.below(3)];
// Where the reader expects a value: after an opening token of the same kind.
let opens: Vec<usize> = b.windows(open.len().min(5)).enumerate().filter(|(_, w)| *w == &open.as_bytes()[..w.len()]).map(|(i, _)| i).collect();
let at = if opens.is_empty() { r.below(len + 1) } else { opens[r.below(opens.len())] };
let mut nest = open.repeat(depth).into_bytes();
nest.extend(close.repeat(if r.chance(2) { depth } else { 0 }).into_bytes());
b.splice(at..at, nest);
}
_ => {}
}
}
}
/// One fuzz input from the seeds, decided by the iteration's own generator.
fn mutate(r: &mut Rng, seeds: &[Vec<u8>]) -> Vec<u8> {
let mut b = seeds[r.below(seeds.len())].clone();
if r.chance(2) {
let text = String::from_utf8_lossy(&b).to_string();
let json = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).ok() } else { Some(text) };
if let Some(mut v) = json.and_then(|j| serde_json::from_str::<Value>(&j).ok()) {
mutate_tree(r, &mut v);
let j = v.to_string();
b = if r.chance(3) { crate::xml_project::to_xml(&j).unwrap_or(j).into_bytes() } else { j.into_bytes() };
}
}
if r.chance(3) || b.is_empty() {
mutate_bytes(r, &mut b);
}
b
}
fn env_u64(name: &str, default: u64) -> u64 {
std::env::var(name).ok().and_then(|v| v.trim().parse().ok()).unwrap_or(default)
}
#[test]
fn fuzz_file_open_never_crashes() {
let seed = env_u64("EFFECTCRAFT_FUZZ_SEED", 0x00EC_F022);
let start = env_u64("EFFECTCRAFT_FUZZ_START", 0);
let iters = env_u64("EFFECTCRAFT_FUZZ_ITERS", 3_000);
let budget = Duration::from_secs(env_u64("EFFECTCRAFT_FUZZ_SECS", 6));
let seeds = seeds();
// The unmutated seeds open.
for s in &seeds {
assert_eq!(run_guarded(s.clone(), Duration::from_secs(30)), Ok(true));
}
let began = Instant::now();
let mut failures = vec![];
let (mut ran, mut opened) = (0, 0);
for i in start..start + iters {
if began.elapsed() > budget {
break;
}
let mut r = Rng(seed ^ i.wrapping_mul(0xD6E8_FEB8_6659_FD93));
let input = mutate(&mut r, &seeds);
ran += 1;
match run_guarded(input.clone(), Duration::from_secs(10)) {
Ok(o) => opened += usize::from(o),
Err(e) => {
let head = String::from_utf8_lossy(&input[..input.len().min(200)]).to_string();
failures.push(format!("iteration {i} (seed {seed:#x}, {} bytes): {e}\n input starts: {head:?}", input.len()));
if failures.len() >= 5 {
break;
}
}
}
}
assert!(failures.is_empty(), "File ▸ Open crashed on {} of {ran} fuzz inputs:\n{}", failures.len(), failures.join("\n"));
eprintln!("fuzz_file_open: {ran} inputs ({opened} opened) in {:?}, no crashes", began.elapsed());
}
+104
View File
@@ -0,0 +1,104 @@
//! Sizes, frame rates and motion-blur samples stay within the project limits when a project is
//! opened and when a command sets them (#788).
use effectcraft_project::{ItemKind, limits};
use effectcraft_render::RenderOpts;
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
use crate::{Event, Session};
fn tmp(name: &str) -> String {
let d = std::env::temp_dir().join(format!("effectcraft-limits-tests-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d.join(name).to_string_lossy().to_string()
}
/// The saved project with `edit` applied to the JSON of every item of kind `kind`.
fn tamper(text: &str, kind: &str, edit: impl Fn(&mut Value)) -> String {
let mut v: Value = serde_json::from_str(text).unwrap();
let items = v["items"].as_object_mut().unwrap();
for it in items.values_mut().filter(|it| it["kind"]["type"] == kind) {
edit(&mut it["kind"]);
}
serde_json::to_string(&v).unwrap()
}
/// A project whose comp is 4 billion × 0 px at 10^18 fps, -5 s long with 4 billion motion-blur
/// samples, holding a solid of 4 billion px.
fn hostile_project() -> String {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 30, "duration": 2})).unwrap();
s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 64, "height": 2})).unwrap();
let text = s.project.to_file_json().unwrap();
let text = tamper(&text, "Comp", |c| {
c["width"] = json!(4_000_000_000u64);
c["height"] = json!(0);
c["frame_rate"] = json!({"num": 1_000_000_000_000_000_000i64, "den": 1});
c["duration"] = json!(-5);
c["motion_blur_samples"] = json!(4_000_000_000u64);
});
tamper(&text, "Solid", |so| so["width"] = json!(4_000_000_000u64))
}
#[test]
fn a_hostile_project_opens_within_the_limits_and_says_what_changed() {
let json_path = tmp("Hostile.ecproj");
let text = hostile_project();
std::fs::write(&json_path, &text).unwrap();
let xml_path = tmp("Hostile.ecprojx");
std::fs::write(&xml_path, crate::xml_project::to_xml(&text).unwrap()).unwrap();
for path in [json_path, xml_path] {
let mut s = Session::default();
let r = s.execute("file.open", json!({"path": path})).unwrap();
let repaired = r["repaired"].as_array().unwrap();
assert!(repaired.iter().any(|n| n.as_str().unwrap().contains("4000000000 × 0 px → 30000 × 1 px")), "{path}: {repaired:?}");
let toast = s.drain_events().into_iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("outside EffectCraft's limits")));
assert!(toast, "{path}");
let (cid, c) = s.project.comps().next().map(|(id, c)| (*id, c.clone())).unwrap();
assert_eq!((c.width, c.height), (limits::MAX_SIZE, 1));
assert_eq!(c.frame_rate.as_f64(), limits::MAX_FPS);
assert_eq!(c.duration, c.frame_rate.frame_duration(), "at least one frame");
assert_eq!(c.work_area, (Tick::ZERO, c.duration));
assert_eq!(c.motion_blur_samples, limits::MOTION_BLUR_SAMPLES.1);
let solid = s.project.items.values().find_map(|i| if let ItemKind::Solid(so) = &i.kind { Some(so.clone()) } else { None }).unwrap();
assert_eq!((solid.width, solid.height), (limits::MAX_SIZE, 2));
// The comp renders at Full resolution, at its (capped) size.
let img = s.render(cid, Tick::ZERO, RenderOpts { scale: 1.0, ..Default::default() });
assert_eq!((img.width, img.height), (limits::MAX_SIZE, 1));
}
}
#[test]
fn a_project_within_the_limits_opens_unchanged() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 24, "duration": 2})).unwrap();
let text = s.project.to_file_json().unwrap();
let (p, notes) = effectcraft_project::Project::from_json_repaired(&text).unwrap();
assert!(notes.is_empty(), "{notes:?}");
assert_eq!(p, *s.project);
}
#[test]
fn commands_cap_sizes_at_30000_px() {
let mut s = Session::default();
// 2^32 + 10 used to wrap to 10 px.
s.execute("comp.new", json!({"name": "Main", "width": 4_294_967_306u64, "height": 32, "pixelAspect": 1e9})).unwrap();
let c = s.active_comp().unwrap().clone();
assert_eq!((c.width, c.height, c.pixel_aspect), (limits::MAX_SIZE, 32, limits::MAX_PIXEL_ASPECT));
s.execute("comp.settings", json!({"width": 64, "height": 4_294_967_306u64})).unwrap();
assert_eq!((s.active_comp().unwrap().width, s.active_comp().unwrap().height), (64, limits::MAX_SIZE));
// New Solid: 0 and 4 billion px are brought to 1 and 30,000; the layer matches its solid.
s.execute("layer.newSolid", json!({"width": 0, "height": 4_000_000_000u64})).unwrap();
let solid = |s: &Session| s.project.items.values().find_map(|i| if let ItemKind::Solid(so) = &i.kind { Some(so.clone()) } else { None }).unwrap();
assert_eq!((solid(&s).width, solid(&s).height), (1, limits::MAX_SIZE));
// Layer Settings on the new (selected) solid layer.
s.execute("layer.settings", json!({"width": 4_000_000_000u64, "height": 8})).unwrap();
assert_eq!((solid(&s).width, solid(&s).height), (limits::MAX_SIZE, 8));
s.execute("file.importSolid", json!({"width": 99_999_999, "height": 4})).unwrap();
assert!(s.project.items.values().any(|i| matches!(&i.kind, ItemKind::Solid(so) if (so.width, so.height) == (limits::MAX_SIZE, 4))));
// A placeholder can't ask for a huge frame or a zero frame rate either.
s.execute("file.importPlaceholder", json!({"width": 4_000_000_000u64, "height": 8, "frameRate": 0})).unwrap();
let f = s.project.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.clone()) } else { None }).unwrap();
assert_eq!((f.width, f.height, f.frame_rate), (limits::MAX_SIZE, 8, FrameRate::FPS_29_97));
}
+1 -1
View File
@@ -74,7 +74,7 @@ fn help_tutorials() {
s.execute("help.onlineTutorials", json!({})).unwrap();
s.execute("help.inAppTutorials", json!({})).unwrap();
let ev = s.drain_events();
assert!(ev.iter().any(|e| matches!(e, Event::OpenUrl(u) if u == crate::links::APP_PAGE)));
assert!(ev.iter().any(|e| matches!(e, Event::OpenUrl(u) if u == crate::links::GITHUB)));
assert!(ev.iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == "help.inAppTutorials")));
}

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