Compare commits

...

93 Commits

Author SHA1 Message Date
bflatastic a48de85015 CI: packaging lint passes again (wasm-bindgen install step as an if, not A && B || C)
actionlint's shellcheck flagged SC2015 on release.yml's Install wasm-bindgen CLI step, failing
the Packaging lint workflow on main and release since 2 October.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:50:37 +07:00
Brandon Thomas 0bb8baf4da
Merge pull request #52 from storytold/pr/release-0.2.0
Release: EffectCraft v0.2.0
2026-10-05 02:46:06 -04:00
Brandon Thomas 3f7250043d Release: EffectCraft v0.2.0
Bump the workspace version 0.1.1 -> 0.2.0 (cargo xtask version set 0.2.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:45:53 -04:00
BFlat fc0462a855
Merge pull request #51 from storytold/home-screen-ae
M13.30–M13.31: ArtCraft logo in the Tools bar; Home screen laid out like After Effects'
2026-10-05 12:52:18 +07:00
bflatastic d31094692e M13.31: Home screen laid out like After Effects'
- Home covers the whole workspace below the Tools bar (the panels wait behind it) instead of
  the Composition panel.
- Left rail: New Project (primary) and Open Project, the Home / Templates / Learn pages with
  icons, and the community links and ArtCraft apps at its foot.
- Home page: "Welcome to EffectCraft", quick-start tiles (New Composition, Open Demo Project,
  Import Footage, New from Template) and Recent as a table — thumbnail, name and folder,
  Opened (relative), Size, Kind — with a filter field, Clear list and an empty state.
- Theme tokens throughout (light themes follow); web_time for "now" (works on the web).
- Tests: formatters (opened / size / kind) and the new automation ids. Docs: parity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:51:26 +07:00
bflatastic 2039688245 M13.30: the ArtCraft mark in the Tools bar, About and Home; VectorCraft sibling link
- paint_logo draws docs/brand/artcraft-mark.png (the README's / getartcraft.com's ArtCraft
  mark) as supplied, decoded once into a mipmapped texture, instead of a drawn placeholder.
- The "More ArtCraft apps" sibling and help.sibling named DrawCraft (storytold/drawcraft, which
  doesn't exist): it is VectorCraft (storytold/vectorcraft); provenance comments in the path
  crate follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:49:47 +07:00
BFlat 053c445465
Merge pull request #50 from storytold/m4-motion-blur-depth
M4.13: motion blur of collapsed precomps and of animated shape / text content
2026-10-05 12:36:07 +07:00
bflatastic 05188d036a M4.13: motion blur of collapsed precomps and of animated shape / text content
- Collapsed precomps: the renderer records the precomp layers a nested comp is drawn through;
  motion-blur sub-samples re-evaluate their transforms (when their motion blur is on) and map
  times down through each one, so the precomp layer's own motion blurs its nested layers. The
  containing (outermost) comp's shutter applies to collapsed layers. The GPU compositor gets
  this through Renderer::placement.
- Shape and text layers whose content changes within the shutter (compared by its cache key)
  draw their source at Samples Per Frame times and average them; the cache key covers every
  sub-sample's content.
- Tests: tests_motion_blur_depth (collapsed vs flat, the containing shutter, shape content vs
  the same move by Position); each fails without the change.
- Docs: roadmap, parity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:35:30 +07:00
BFlat 9e79271893
Merge pull request #10 from storybored379/pr/spatial-tangent-getter-names
Scripting: Property.keyInSpatialTangent / keyOutSpatialTangent under their AE names
2026-10-05 12:11:18 +07:00
BFlat 58dd94e212
Merge pull request #9 from storybored379/pr/hold-key-incoming-segment
Keyframes: a Hold key no longer eases the motion into it
2026-10-05 12:11:10 +07:00
BFlat 1e8bcb519a
Merge pull request #7 from storybored379/pr/comp-zero-frame-rate
Compositions: reject zero and negative frame rates instead of panicking
2026-10-05 12:11:02 +07:00
BFlat 26f577cb1a
Merge pull request #6 from storybored379/pr/expr-runtime-stack-leak
Expressions: keep the runtime usable after errors and property reads
2026-10-05 12:10:53 +07:00
BFlat 9b5652f686
Merge pull request #49 from storytold/timeline-comp-tabs
M3.15: Timeline tabs per open comp; Project double-click opens; tab drag insertion mark
2026-10-05 11:59:07 +07:00
bflatastic c7c90cfa74 M3.15: Timeline tabs per open comp; Project double-click opens; tab drag insertion mark
- The Timeline shows one tab per open comp (dock DocTab). Clicking a tab shows that comp; ×
  or middle-click closes that comp's Timeline (comp.close), not the panel. Opening a comp brings
  its viewer and Timeline tab to the front.
- Project panel: a double-click on an item's name opens it (a comp gets its own tab) instead of
  renaming; Enter and the context menu rename.
- Tab drags: over a tab strip an insertion mark shows where the tab lands, and it lands there
  (Layout::insert_tab), which also reorders a group's own tabs; side zones are unchanged.
- Tests: ui_tabs (double-click, tab switch / close, drag reorder and cross-group insert; an
  ignored mid-drag snapshot); the Timeline close test follows the new behaviour.
- Docs: parity, control protocol.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:58:19 +07:00
BFlat a008a904af
Merge pull request #48 from storytold/graph-auto-visible-keys
M5.15–M5.16: Timeline-visible J/K and Select All, Graph Editor Auto-Select Graph Type, Key Light eyedropper
2026-10-05 11:05:02 +07:00
bflatastic 73b2607c9a M5.16: Key Light: keyer eyedroppers pick from the effect input (effect.pickColor); "keylight" search
- effect.pickColor {effect, param|prop, x, y (layer px), average?}: the colour of the effect's
  input at a point, set on the parameter (one undo step). Keying effects' Effect Controls
  eyedroppers use it, since the shown frame is already keyed; Ctrl/Cmd+click averages 5x5.
- effects::aliases / name_matches: Effects & Presets and effect.list (MCP list_effects) find
  Key Light by After Effects' name ("Keylight (1.2)").
- End-to-end test: a green-screen plate with an uneven screen, spill and soft edges keyed via
  effect.apply "Keylight (1.2)", pick, Clip Black / White and a render; UI eyedropper test.
- Docs: parity, agents (green-screen recipe).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:04:10 +07:00
bflatastic 47f2a5a3e6 M5.15: J / K and Select All Keyframes use what the Timeline shows; Graph Editor Auto-Select Graph Type
- J / K (Go to Previous / Next Keyframe) and Ctrl+Alt+A (Select All Keyframes) act on what the
  Timeline shows, as in After Effects: the keys of its revealed properties, layer and comp
  markers and the work area (they used every property of the selected or all layers, revealed
  or not). The commands take `visible: [{layer, prop}]`, which the Timeline fills in; agents
  calling them without it keep the previous behaviour.
- Graph Editor ▸ Auto-Select Graph Type (an "Auto" button, on by default as in After Effects):
  the speed graph when every property shown is spatial (Position, Anchor Point…), else the
  value graph; choosing Value or Speed turns it off.
- Docs: parity, agents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:36:55 +07:00
BFlat c90fd35ff8
Merge pull request #40 from storytold/timeline-keyframes
Fix the periodic freeze on Windows; keyframe drag, copy/paste and Graph Editor behave like After Effects
2026-10-05 08:13:28 +07:00
bflatastic ef856d8f67 M5.14: docs: parity, agents and preferences cover the keyframe, clipboard and responsiveness fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:06:32 +07:00
bflatastic dfb0227468 M5.14: J / K stop at the work area; the Info panel shows a selected keyframe
- Go to Next / Previous Keyframe (K / J) also stops at the work area's start and end, as in
  After Effects.
- With one keyframe selected, the Info panel shows its property, time and value instead of
  "1 keyframes selected".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:05:03 +07:00
bflatastic 43bf2906d3 M5.13: keyframe paste: several copied layers paste onto as many layers in order; Paste Reversed pastes like Paste
- Keys copied from several layers and pasted onto as many selected layers go first to first,
  second to second, as in After Effects; every target used to receive every copied property
  (the later ones overwriting the earlier).
- Edit ▸ Paste Reversed Keyframes mirrors the clipboard over its span and pastes it through
  `keys.paste`, so it now follows the same rules as Paste: the layer's time stretch, the
  selected target property, Hold-only properties, value conversion and roving keys (its own
  simplified paste ignored them and kept existing keys' easing on a time collision).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:02:16 +07:00
bflatastic 9ea1f1325d M5.12: double-click a keyframe to edit its value; Select Equal / Previous / Following Keyframes; roving keys as dots
- Double-clicking a keyframe of a numeric property (or its context menu ▸ Edit Value…) opens
  the property's value dialog for that key, as in After Effects (it only moved the current
  time); other keys still move the current time to them.
- The keyframe context menu has Select Equal Keyframes, Select Previous Keyframes and Select
  Following Keyframes (`keys.selectEqual` / `selectPrevious` / `selectFollowing`).
- Roving keys draw as small dots in the Timeline, like After Effects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:58:03 +07:00
bflatastic daed03305a M5.11: Graph Editor: drags move every selected key, Shift keeps an axis, Ctrl / Ctrl+Alt clicks; drags and the transform box never merge or lose keys
- `keys.transform {fromStart}`: each step of a merged drag gives the whole transform since the
  drag started and applies to the keys as they were then. Keys squeezed onto one frame on the
  way (Alt-drag scaling, the transform box) are no longer merged for good, and a slow
  transform-box move, whose per-frame offsets rounded to nothing, now moves the keys.
- Dragging a key in the Graph Editor moves every selected key in time and value (it moved only
  the grabbed one), by how far the pointer went from where the button went down (the key no
  longer jumps under the pointer); Shift keeps the drag to the time or the value axis.
- Shift+click toggles a key in or out of the selection; Ctrl+click switches Linear ↔ Auto
  Bezier and Ctrl+Alt+click toggles Hold, as in the Timeline.
- The transform box and the Timeline's Alt-drag scale send whole-gesture values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:52:20 +07:00
bflatastic 9929b5f9ac M5.10: keyframes behave like After Effects: drags never destroy keys, stretched layers, Shift/Ctrl/Ctrl+Alt clicks, locked layers, Hold off
- A drag (keys.move steps sharing a merge key) applies its total offset to the keys as they
  were before it (`Session::key_move`, `prop::shift_keys`): dragging over another key of the
  property no longer deletes it; only the drop position replaces a key, as in After Effects.
- Keys of time-stretched layers move with the pointer: offsets are applied in comp time and
  snapped to comp frames (drags and Alt+Arrow nudges; they moved by the layer-time distance).
- Shift+click toggles a key in or out of the selection (`keys.select {toggle}`); Ctrl+click
  switches Linear ↔ Auto Bezier and Ctrl+Alt+click toggles Hold.
- Keys of locked layers can't be selected (clicks, marquee, Select All Keyframes), so nothing
  edits them.
- Toggle Hold off returns an eased key to Bezier (it always restored Linear).
- Shift-dragging a key snaps to everything the current time snaps to — other keys, layer in /
  out points, layer and comp markers, the work area — and a tooltip shows the key's time and how
  many frames it moved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:40:42 +07:00
bflatastic 3b6b390f68 M12.8: read the system memory every 30 s instead of 10 s
Each reading starts PowerShell on Windows (in the background since the previous commit); the
cache budgets only need to follow memory pressure, not track it closely.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:25:01 +07:00
bflatastic e3778b1afd M3.15: Ctrl+C / Ctrl+X / Ctrl+V reach Edit ▸ Copy, Cut and Paste in the real app
The windowing layer delivers Ctrl+C, Ctrl+X and Ctrl+V as clipboard events instead of key
presses, and the shortcut dispatcher only read key presses: in the app, copying and pasting
keyframes, layers and effects with the keyboard did nothing (tests passed because they inject
key presses). Clipboard events now map back to their keys with the modifiers held (so Ctrl+Alt+C
and the other variants work too), except while text is edited in the viewer, which handles them
itself. Ctrl+V sends no event while the system clipboard is empty, so copying now also puts a
line naming what was copied on it ("EffectCraft: 3 keyframes").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:15:22 +07:00
bflatastic f3db1ebe38 M5.9: keyframe drags in the Timeline follow the pointer to the end; Shift snaps
Dragging a keyframe moved it a frame or two and then let go: the key's widget id includes its
time, so after the first move it was a new widget and egui ended the drag. The Timeline now
follows the whole gesture itself (`KeyDrag`: from where the button went down, by whole frames,
every selected key, one undo step), like After Effects. Holding Shift snaps the grabbed key to
the current time, the work area and composition markers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:14:02 +07:00
bflatastic 2d4a0902ea M12.8: the app no longer freezes every 10 s on Windows: the system memory is read in the background
Every 10 seconds the UI thread re-read the system's memory for the cache budgets, and on
Windows that starts PowerShell (`Get-CimInstance Win32_OperatingSystem`) and waits for it:
about a second, sometimes two, during which the whole app froze. The reading now runs on a
background thread (`sysinfo::MemoryWatch`); `Session::memory_tick` takes the last reading and
starts the next one, never waiting. Console programs started for it no longer open a console
window (`CREATE_NO_WINDOW`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:05:17 +07:00
BFlat 4b37443481
Merge pull request #39 from storytold/docs-m3-m4
Docs after M3 and M4, plus three fixes the review found (never-crash, web restore, guard test)
2026-10-05 06:46:50 +07:00
bflatastic a965d8df49 M3.9: an edit that only allocates ids still counts as a change
`Project::same_content` ignored `next_id`, so an edit that only took an id was dropped as a
no-op and the id could be handed out again (an edit may keep it beyond the project).
`guard::tests::the_session_survives_a_panicking_edit`, merged after M3.9, failed on main for
this reason. Ids now count; setting a value to what it already is still records nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:43:10 +07:00
bflatastic cb892b5239 M4.12: docs: ROADMAP, parity, web, preferences and agents brought up to date after M3 and M4
- parity: the status note covers the M3.9–M4.12 passes (feature counts unchanged: they fixed
  behaviour inside features already counted done) and the GPU effect count matches EFF-5 (280);
  the Layers, Compositions, Interface, Project and Preview rows name what is left and what
  landed; landed items in "Highest-value gaps" are struck through with where they landed, the
  gaps the passes left are items 16–18, and the closing paragraph no longer says 69 menu entries
  are disabled.
- ROADMAP: what is left includes the preview gaps.
- web: a restored session with unsaved changes stays modified and asks before closing.
- preferences: Adaptive Resolution Limit applies while dragging, not to playing previews.
- agents: nested comp settings and New Comp from Selection for several items.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:35:00 +07:00
bflatastic a12d4ae1fa M3.9: a browser session restored with unsaved changes stays modified
The web app reopens the last visit's project and, when it had unsaved changes, marked it
modified with `bump()`. Since M3.9 the modified mark follows the saved snapshot, and a bump on
the very project just opened counts as "back at the saved state", so restored changes looked
saved (no `*`, no unsaved-changes prompt). `Session::mark_unsaved` forgets the saved snapshot
instead: the project stays modified until saved, whatever is undone. Regression test included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:33:40 +07:00
bflatastic b21e04b301 M4.12: never crash: hostile input in New Comp from Selection, an empty work area in Cache Frames When Idle
Following AGENTS.md's Never crash rules for the M3 / M4 additions:
- Cache Frames When Idle panicked (`clamp` with min > max) when the work area was inverted
  (start set past the end); an empty work area now has nothing to cache.
- `file.newCompFromSelection`: the sequence overlap times the layer count overflowed for a huge
  `overlapDuration` (wrapping to a wrong comp length without overflow checks); negative or
  non-finite values fall back to 1 s and the product saturates. The lead item is read with
  `get()` instead of indexing.
- Layer Settings' enablement reads the first selected layer with `first()`.
- Regression tests for both (each failed before the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:30:46 +07:00
Brandon Thomas 339a05d771
Merge pull request #37 from storytold/pr/never-crash-clean-crates
Never crash: deny panic lints in the already-clean crates and apps
2026-10-04 15:34:52 -04:00
Brandon Thomas 7f1e2df862
Merge pull request #24 from storytold/pr/never-crash-opus-vp9
Never crash: Opus and VP9 encoders without expect()
2026-10-04 15:34:33 -04:00
Brandon Thomas 61e859e3de
Merge pull request #23 from storytold/pr/never-crash-hevcenc
Never crash: HEVC encoder without expect()/unreachable!()
2026-10-04 15:34:15 -04:00
Brandon Thomas 6aa490b826
Merge pull request #22 from storytold/pr/never-crash-av1enc
Never crash: AV1 encoder without expect()/unreachable!()
2026-10-04 15:33:56 -04:00
Brandon Thomas c6b8f5412f
Merge pull request #36 from storytold/pr/never-crash-ui
Never crash: frame-pool panic handler and ui-egui without expect()
2026-10-04 15:33:38 -04:00
Brandon Thomas 4926dd2c4c
Merge pull request #35 from storytold/pr/never-crash-render-gpu
Never crash: render and gpu crates without panic!()
2026-10-04 15:33:18 -04:00
Brandon Thomas fc0eee53dc
Merge pull request #31 from storytold/pr/never-crash-raster-path
Never crash: raster and path crates without expect()/unwrap()/unreachable!()
2026-10-04 15:32:58 -04:00
Brandon Thomas 5d716c2f5a
Merge pull request #29 from storytold/pr/never-crash-text
Never crash: text layout and font resolution without expect()
2026-10-04 15:32:39 -04:00
Brandon Thomas f82ac49855
Merge pull request #26 from storytold/pr/never-crash-track
Never crash: trackers and camera solver without expect()
2026-10-04 15:32:18 -04:00
Brandon Thomas 64531e4aac
Merge pull request #33 from storytold/pr/never-crash-effects
Never crash: effects crate without expect()
2026-10-04 15:32:00 -04:00
Brandon Thomas c6488d0cd4
Merge pull request #30 from storytold/pr/never-crash-engine
Never crash: engine without expect() (job polling, commands, menus)
2026-10-04 15:31:40 -04:00
Brandon Thomas f864b29549
Merge pull request #27 from storytold/pr/never-crash-script
Never crash: scripts nested too deeply return an error instead of overflowing the stack
2026-10-04 15:31:21 -04:00
Brandon Thomas 46cd757f99
Merge pull request #21 from storytold/pr/never-crash-expr
Never crash: reject deeply nested expressions instead of overflowing the stack
2026-10-04 15:31:01 -04:00
Brandon Thomas a3976913c2
Merge pull request #34 from storytold/pr/never-crash-time
Never crash: Tick and timecode arithmetic can't overflow or divide by zero
2026-10-04 15:30:42 -04:00
Brandon Thomas 6f52a0aab8
Merge pull request #19 from storytold/pr/never-crash-project
Never crash: project crate without expect()
2026-10-04 15:30:22 -04:00
Brandon Thomas 010cb8129f
Merge pull request #17 from storytold/pr/never-crash-media
Never crash: media crate without expect()
2026-10-04 15:29:59 -04:00
Brandon Thomas 43c485e31e
Merge pull request #14 from storytold/pr/never-crash-interchange
Never crash: interchange import/export (bad precomp refs, deep XML) without panics
2026-10-04 15:29:41 -04:00
Brandon Thomas d350f4e7ac
Merge pull request #25 from storytold/pr/never-crash-lottie
Never crash: Lottie enum values can't overflow
2026-10-04 15:29:22 -04:00
Brandon Thomas a792844d7e
Merge pull request #16 from storytold/pr/never-crash-pdf
Never crash: PDF/EPS close-path, CFF outlines and PostScript radix numbers
2026-10-04 15:29:03 -04:00
Brandon Thomas e171c33686
Merge pull request #15 from storytold/pr/never-crash-svg
Never crash: SVG path data loop and deep nesting
2026-10-04 15:28:44 -04:00
Brandon Thomas 91a3b14cb2
Merge pull request #18 from storytold/pr/never-crash-psd
Never crash: PSD reader bounds overflow and input-sized allocations
2026-10-04 15:28:25 -04:00
Brandon Thomas a69f417d63
Merge pull request #28 from storytold/pr/never-crash-guard
Never crash: last-resort guard around command execution, and a logging panic hook
2026-10-04 15:28:05 -04:00
Brandon Thomas e6bda37e80
Merge pull request #12 from storytold/pr/never-crash-steering
Never crash: steering rules, clippy.toml, forbid unsafe
2026-10-04 15:27:45 -04:00
BFlat 7ee6d6a301
Merge pull request #38 from storytold/m4-nested-comps
Previews (M4): Preserve frame rate / resolution when nested, nested comps past their end, one motion blur gate
2026-10-05 02:08:13 +07:00
bflatastic e295679b7f M4.11: docs: architecture, parity and ROADMAP cover nested comps and motion blur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:56:17 +07:00
bflatastic 995d99f938 M4.11: one motion blur gate for layers, masks, effects and Advanced 3D
`Renderer::mb_on` (render options × the comp's switch × the layer's switch as the precomp
layers above limit it) now decides everywhere:
- Mask motion blur checked only the comp's switch and the layer's own switch, so Render
  Settings ▸ Motion Blur ▸ Off for All Layers and a precomp layer with motion blur off still
  blurred masks; it now takes the gate (`masks::MaskBlur`) and the comp's Samples Per Frame
  (four in Draft) instead of a fixed eight.
- Effects that read the shutter (`EffectEnv::shutter`) get it only when the layer is motion
  blurred in this render.
- Advanced 3D layers follow the precomp layers' Motion Blur switches too.
- The layer cache key of a layer with masks or effects includes whether it is blurred and the
  comp's shutter and samples, so a buffer blurred for the viewer is never reused by a render
  with motion blur off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:55:34 +07:00
Brandon Thomas c5e33f1a02 Never crash: deny panic lints in the already-clean crates and apps
These crates have no production panic sites: add the deny attribute so they stay clean
(geom, color, model, automation, host, export, plugin, and the desktop, CLI and web apps). The
plugin's wasm-only test module gets a separate cfg(test) attribute so clippy recognises it as
test code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:52:39 -04:00
Brandon Thomas 543942d085 Never crash: frame-pool panic handler and ui-egui without expect()
ui-egui without expect()/unreachable!(): the frame pool is optional (rayon's global pool if the
OS refuses threads) and gets a panic_handler, because a panic in a rayon spawn() job aborts the
whole process by default; render-queue progress, the text-edit anchor and the GPU viewer
texture are bound where they're checked; the settings panel's Bool arm no longer panics.
Denies the panic lints in the crate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:52:28 -04:00
bflatastic 0b5cff1743 M4.10: a nested comp shows nothing before it starts or after it ends
A precomp layer trimmed out (or time-remapped) past its nested comp's end kept drawing the
nested layers whose bars ran on. Like After Effects, the nested comp now shows nothing outside
its own span (`Comp::covers`), flattened, through a proxy or with Collapse Transformations, on
the CPU and the GPU (both go through `Renderer::source` / `collapse_into`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:47:54 +07:00
bflatastic 0fe6db00e8 M4.9: Preserve frame rate when nested or in render queue, Preserve resolution when nested
Composition Settings ▸ Advanced has both options (they were missing from the model, the
renderer and the dialog):
- Preserve frame rate: nested in another comp, the comp shows only its own frames
  (`EvalCtx::nested_time` floors the precomp layer's source time to the nested comp's frame
  rate; used for the nested frame, its proxy, aux channels, collapsed transformations and the
  viewer's internal wireframes), and the Render Queue renders it at its own rate whatever the
  Frame Rate override (`RenderSettings::rate`).
- Preserve resolution: nested in a comp rendered at Half / Third / Quarter, it renders at full
  size and the buffer's scale places it.
- `comp.settings {preserveFrameRate, preserveResolution}`, `comp.info` reports them, scripts
  read and set `CompItem.preserveNestedFrameRate` / `preserveNestedResolution`, Pre-compose
  copies them, and the dialog registers `dialog.comp.preserveFrameRate` /
  `dialog.comp.preserveResolution`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:33:14 +07:00
Brandon Thomas e910858445 Never crash: PostScript radix numbers outside 2..=36
In EPS/PostScript, `0#7` asked i64::from_str_radix for radix 0, which panics (found by
fuzzing). Radixes outside 2..=36 are not numbers now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:21:14 -04:00
Brandon Thomas fe89ab9146 Never crash: render and gpu crates without panic!()
render: the debug-only quantisation check uses debug_assert! instead of panic!. gpu: the
test-only panic on a wgpu validation error is cfg(test) with a narrow allow, and release
builds only log. Test-only unreachable!() calls become panic!(). Denies the panic lints in both
crates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:20:37 -04:00
Brandon Thomas fc737d76da Never crash: Tick and timecode arithmetic can't overflow or divide by zero
Tick arithmetic saturates, seconds-to-tick conversion clamps to Tick::MIN..=Tick::MAX (NaN is
zero), zero rates/timebases no longer divide by zero, and timecode / feet+frames parsing and
formatting use checked or saturating math. `layer.timing {"delta": -1e308}` over the control
channel overflowed FrameRate::snap_nearest (found by fuzzing every command), and a timecode
field holding 99999999999999:00:00:00 overflowed parse_timecode. Denies the panic lints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:20:33 -04:00
Brandon Thomas 5dd7d9bb57 Never crash: effects crate without expect()
Effects without expect(): nested parameter groups are looked up with ? (falling back to the
effect's top level), the OCIO effects use named constants for the built-in ACES spaces instead
of expect()ing a table lookup, curve fitting and Path Text no longer expect on just-pushed
vectors. Test-only unreachable!() becomes panic!(). Denies the panic lints in the crate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:14:16 -04:00
BFlat 4e98b8c4bd
Merge pull request #32 from storytold/m4-preview
Previews (M4): RAM preview keys and eviction, Auto resolution while playing, Cache Before Playback that fits, Cache Frames When Idle, purges
2026-10-05 01:13:37 +07:00
Brandon Thomas 19c1d2ea1d Never crash: reject SVGs nested too deeply
roxmltree recurses per element and overflowed a 2 MiB stack at a few thousand nested
elements (aborting the app). parse() now scans the element depth first and returns
Error::Xml beyond 256 levels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:05:58 -04:00
Brandon Thomas ad5aa542b4 Never crash: reject timeline documents nested too deeply
FCP 7 XML / FCPXML nested about 1 000 elements deep overflowed an 8 MiB stack in the XML
parser (aborting the app). Import now scans XML and OTIO JSON nesting first and returns
Error::TooDeep beyond 256 levels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:05:57 -04:00
Brandon Thomas d1413d5209 Never crash: bound PSD descriptor nesting
The descriptor depth limit restarted at 0 inside every nested descriptor (Objc / GlbO /
ObAr), so a crafted file of 100 000 nested descriptors (2.4 MB) recursed until the stack
overflowed and aborted the app. The depth now carries through nested descriptors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:05:55 -04:00
Brandon Thomas 786e4e8b97 Never crash: raster and path crates without expect()/unwrap()/unreachable!()
raster: the inpainting pyramid stops at a missing level, and video inpainting falls back to a
plain patch fill if the previous frame is missing, instead of expect(). path: curve fitting of
an empty point list returns a degenerate cubic instead of indexing/unwrapping, and Merge Paths
no longer has an unreachable!() arm. Denies the panic lints in both crates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:41:09 -04:00
Brandon Thomas 3d5432c318 Never crash: engine without expect() (job polling, commands, menus)
Removes the engine's production expect() calls: background-job polling (camera, mask, roto,
point and warp tracking, render queue) uses let-else / if-let instead of take().expect("job"),
command helpers bind what they checked instead of re-reading it with expect, the menu bar falls
back to an empty tree (logged) instead of expect()ing that a constant parses, and
autotrace/text animator group lookups no longer expect a just-inserted group. perf.rs, the
benchmark harness behind `effectcraft-cli bench --ops`, opts out with a module-level allow.
Test-only unreachable!() calls become panic!() (clippy.toml allows panics in tests). Denies the
panic lints in the crate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:40:46 -04:00
Brandon Thomas 0873393e43 Never crash: text layout and font resolution without expect()
Text layout and font resolution without expect(): an empty line list keeps the default bounds,
the line breaker stops instead of expect()ing a checked option, and font resolution falls back
to the first bundled face. The one remaining expect (the bundled-font database is never empty)
carries the documented narrow allow. Denies the panic lints in the crate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:27:38 -04:00
Brandon Thomas 6144ca0804 Never crash: last-resort guard around command execution, and a logging panic hook
Session::execute runs every command (UI, CLI, control channel, MCP, scripts; file import,
export and save are commands too) through guard::guarded, which catch_unwinds a panic that
escapes the command and returns EngineError::Other("command `x` failed with an internal error
(...); it was stopped and your project is still open"). Edits work on a copy of the project, so
a command that panics mid-edit leaves the project untouched. The desktop app also installs a
panic hook that logs every panic through the app logger (then runs the default hook).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:27:33 -04:00
Brandon Thomas f8460971f0 Never crash: scripts nested too deeply return an error instead of overflowing the stack
Scripts (Script Console, effectcraft-cli script, the MCP run_script tool) are parsed by Boa,
which recurses per nesting level and overflows the stack (aborting the app) at about 200
levels on an 8 MiB thread. run() now rejects a script nested deeper than MAX_NESTING (96)
brackets with a ScriptError before parsing; strings, template literals and comments are
skipped. Also replaces expect("console context") with an error, and denies the panic lints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:26:05 -04:00
Brandon Thomas bf8962becf Never crash: trackers and camera solver without expect()
The camera solver, KLT pyramid and subspace (warp stabilizer) tracker expect()ed data-structure
invariants: a track point seen in a frame, a solved camera, a pyramid level. A missing value now
skips that point/track (or, for finish(), returns a SolveError: "no frame was solved") instead of
panicking. Denies the panic lints in the crate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:24:17 -04:00
Brandon Thomas 1da7dc4659 Never crash: Lottie enum values can't overflow
Lottie stores line cap, line join, merge mode and some text-selector enums 1-based, and the
importer subtracted 1 from whatever number the file held: -1e308 (rounded to i64::MIN)
overflowed, a panic in debug builds (found by structural fuzzing). Use saturating_sub. Denies
the panic lints in the crate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:24:14 -04:00
Brandon Thomas 5cc376a818 Never crash: Opus and VP9 encoders without expect()
opusenc: the SILK state is used through if-let / let-else instead of expect("SILK state").
vp9enc: the motion-search SAD and inter prediction return early without a reference frame
instead of expect("reference"). Denies the panic lints in both crates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:23:06 -04:00
Brandon Thomas da3c7a842d Never crash: HEVC encoder without expect()/unreachable!()
Replaces the encoder's 8 expect()/unwrap()/unreachable!() calls with handling that can't
panic: coding-unit decisions without a candidate report an infinite cost, inter helpers
without a reference picture return a neutral result, residual_coding of an all-zero block
writes nothing, the impossible Intra arms are no-ops, and the edge padding of an empty row uses
0 instead of unwrapping. Denies the panic lints in the crate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:23:03 -04:00
Brandon Thomas ee90d87742 Never crash: AV1 encoder without expect()/unreachable!()
Replaces the encoder's 11 expect()/unreachable!() calls on internal invariants with handling
that can't panic: the rate-control state is passed in rather than re-borrowed, inter decisions
without a reference frame fall back to intra, per-plane results are bound directly, and the
writer's impossible branches become debug_asserts. Denies the panic lints in the crate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:22:59 -04:00
Brandon Thomas fc8fea031c Never crash: reject deeply nested expressions instead of overflowing the stack
An expression nested about 60 brackets deep overflowed the stack of a 2 MiB thread in the
JavaScript parser (release and debug; ~200 levels on 8 MiB), which aborts the whole app: it
can't be caught. The array-maths rewrite also nests one call per arithmetic operator, so a long
sum went the same way, and the rewriter itself recursed per bracket. rewrite() now measures the
nesting (brackets plus chains of prefix operators) before and after rewriting and turns an
expression deeper than MAX_NESTING (32) into a RangeError; check_syntax reports the same error.
Also replaces the unreachable!() in the bracket-tree builder with a let-chain, and denies the
panic lints in the crate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:22:36 -04:00
Brandon Thomas 587d4803fe Never crash: project crate without expect()
Layer::motion_trackers_mut expect()ed the group it had just inserted; it returns an Option
now and track.motion reports an error if the group is somehow missing. Denies the panic
lints in the crate (the project model behind .ecproj files).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:58:51 -04:00
Brandon Thomas dc573f77e6 Never crash: PSD reader bounds overflow and input-sized allocations
Layer bounds come straight from the file as four i32s. Rect::width/height subtracted them in
i32 (overflow: a panic in debug builds, garbage sizes in release), and layer_pixels allocated
width x height pixels before reading any data, so a 100-byte file claiming a 2^31-square layer
hit "capacity overflow" (or an out-of-memory abort). Widths are now computed in i64 and
clamped, every decoded image is capped at 2^28 pixels (Error::Unsupported beyond that), zlib
data is inflated with a limit of the expected size, RLE count tables and UTF-16 strings
reserve no more than the remaining bytes, and the per-pixel copy only visits the part of the
layer that lands in the output. Denies the panic lints in the crate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:27:18 -04:00
Brandon Thomas 37c9b449c6 Never crash: media crate without expect()
The smart-object branch of the layered-file decoder bound the footage layer twice and
re-read it with expect("embedded implies a layer"); bind it once with a let-chain. Denies the
panic lints in the crate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:18:36 -04:00
Brandon Thomas 8e4e29844e Never crash: PDF close-path operators and CFF outlines without a current point
The s, b and b* operators closed the path even when there was no current point, which kurbo
asserts against (a panic in debug builds; found by fuzzing). Close only when a subpath is open.
CFF charstrings that draw before their first rmoveto now start the subpath at the current
point, as Type 1 charstrings already did. Denies the panic lints in the crate (it had no panic
sites).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:15:02 -04:00
Brandon Thomas 6708ff55b4 Never crash: SVG path data no longer loops forever on a number after Z
Path data like "M0 0 L10 0 Z9" repeated Z implicitly for the stray number; Z consumes no
numbers, so the parser looped forever appending close-paths until memory ran out (a 1-byte
change to a valid SVG used 24 GB and was killed). A number after Z is a syntax error, so
parsing now stops there and keeps what was read, as the spec asks. Also denies the panic lints
in the crate (it had no panic sites).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:14:58 -04:00
Brandon Thomas ed54a8cd15 Never crash: interchange export/import without expect()
Removes the six expect() calls from timeline export/import and denies the panic lints in the
crate. A precomp layer whose source item is not a composition (a damaged project, or an item
that was deleted) made export panic in Exporter::sequence; it now leaves that layer out.
Exporter::sequence and track_item return Option, the Motion/Opacity/Volume effect defaults
come from effect_or_new (None if FilmCraft's registry lacks one), and import's motion() skips
a layer without a transform group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:10:09 -04:00
Brandon Thomas 0da12c676f Never crash: steering rules, clippy.toml, forbid unsafe
- AGENTS.md: a "Never crash" section ahead of the asset rules (the rule, why, the bans,
  Result/? patterns, hostile input-derived numbers, bounded recursion, lock poisoning, the
  last-resort guard, a regression test per crash fix), linking craftrules
  standards/never-crash.md. CLAUDE.md and CONTRIBUTING.md point to it.
- clippy.toml: allow unwrap/expect/panic/indexing in tests, so the restriction lints can be
  denied crate by crate without touching test code.
- [workspace.lints.rust] unsafe_code = "forbid" (was "deny"); there is no unsafe or
  allow(unsafe_code) in the workspace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:43:47 -04:00
storybored379 0a8cdfc67e Scripting: Property.keyInSpatialTangent / keyOutSpatialTangent under their AE names
The getters were registered as keySpatialInTangent / keySpatialOutTangent,
so scripts using the documented After Effects names failed with
"TypeError: not a callable function" (setSpatialTangentsAtKey and the
other keySpatial* methods already use AE's names).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:17:34 -07:00
storybored379 3411fc80ab Keyframes: a Hold key no longer eases the motion into it
segment_value treated any non-Linear in-side of the next key as Bezier,
so a key set to Hold (KeyframeInterpolationType.HOLD, add_keyframe
"interpolation": "hold", or the Keyframe Interpolation dialog, which set
both sides) turned the linear segment into it into an ease-in with the
default ease: 0 -> 100 over 2 s read 27.0 instead of 25 at 0.5 s. Hold
only affects what follows the key, which the out side already handles.
The Graph Editor and the Lottie exporter already treat a Hold in-side as
linear, so exports didn't match the preview either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:17:34 -07:00
storybored379 780419c235 Compositions: reject zero and negative frame rates instead of panicking
comp.new / comp.settings passed `frameRate` straight to
FrameRate::from_f64, so 0 (or anything below 0.0005) became a 0/1 rate
and the next frame-duration computation divided by zero, killing the
process (and an MCP session with its unsaved project); a negative rate
was accepted as-is. Scripts hit it via addComp(..., 0) or
comp.frameRate = 0. Return a parameter error like renderQueue.add's
frameRate check does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 09:31:41 -07:00
storybored379 0ae7a1925a Expressions: keep the runtime usable after errors and property reads
boa leaves the value stack of inner call frames behind when an exception
leaves a script (or a function called from Rust) from a nested call. Every
property read throws the pending-host-request abort from inside a prelude
helper, and so does any helper error (`thisComp.layer("Ghost")`), so the
thread's runtime filled up after ~100 evaluations of a pick-whip
expression: from then on every expression on that thread failed with
"RuntimeLimitError: reached the maximum stack size" and silently fell back
to its keyframed value (e.g. a follower layer stops following after ~90
render_frame calls).

Wrap the script and `__finish` in try/catch and rethrow from their own
frame, which unwinds the stack; backtraces (error line numbers) survive.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 09:31:41 -07:00
174 changed files with 4211 additions and 831 deletions

View File

@ -221,8 +221,9 @@ jobs:
- name: Install wasm-bindgen CLI
run: |
# Must match the wasm-bindgen version in Cargo.lock exactly (cargo xtask web checks it).
command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION" \
|| cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION"; }; then
cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
fi
wasm-bindgen --version
- name: Apply version override
if: inputs.version != ''

View File

@ -3,6 +3,39 @@
These rules apply to every human and AI contributor. `CLAUDE.md` holds the working instructions; this
file holds the rules that must never be broken. When the two disagree, this file wins.
## Never crash
People trust EffectCraft with hours of work. A malformed project, PSD, SVG, Lottie or media file, a bad
expression or script, a broken plug-in, a bad control-channel or MCP argument, a corrupt preferences file
or a full disk must produce an error the user (or agent) can act on, never a crash and never lost work.
**This rule outranks feature work:** don't ship a feature by adding a panic path, and fix a crash before
building on top of it. Full standard: [craftrules `standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
- **Fail with `Result<T, E>`** through the crate's error type and propagate with `?`; add context
(`map_err`, an error variant) rather than discarding it. Lenient readers fall back and record the repair.
- **No panicking shortcuts in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`,
`todo!` or `unimplemented!`. Use `?`, `ok_or(..)?`, `let … else`, `if let`, or `unwrap_or*` where a
fallback is genuinely correct (never where it would silently corrupt a document). Unfinished features
return an "unsupported" error. The only exception is a provably infallible literal, written as
`#[allow(clippy::expect_used)]` + `.expect("why this cannot fail")`.
- **No `unsafe`** (`unsafe_code = "forbid"` for the workspace).
- **Every input-derived number is hostile.** Use `get()` instead of `[i]`/`[a..b]` when the position
comes from a file, a user, an agent or arithmetic on those; slice strings only at char boundaries; use
checked or saturating arithmetic for lengths, offsets and counts; guard division by zero and NaN/inf
casts; cap allocations sized by input.
- **Bound recursion:** projects, precomps, expressions and SVG/PSD trees can be cyclic or deeply nested;
walk them with seen-sets or depth limits.
- **Don't cascade:** handle lock poisoning (`lock().unwrap_or_else(PoisonError::into_inner)` or an
error), and treat worker-thread join results as `Result`s.
- **Last-resort guard:** the app wraps command execution and file import/export in a panic hook /
`catch_unwind` boundary that turns an escaped panic into an error and keeps the document. It is a
safety net, not a substitute for the rules above; keep `panic = "unwind"`.
- **Prove it:** every crash fix comes with a small synthetic regression test that panicked before the fix.
Tests, benches, fuzz targets and `xtask` may `unwrap()`; `clippy.toml` allows it in tests. Crates that are
clean carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented,
clippy::todo, clippy::unreachable)]`; new crates start with it.
## 1. Assets: no Adobe artwork, every asset licensed and attributed
This rule is absolute. Breaking it is the most serious mistake a contributor can make on this project.

View File

@ -10,6 +10,7 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e
## Non-negotiables
**Read [`AGENTS.md`](AGENTS.md) first; its rules override everything here.** No Adobe assets, every asset licensed + attributed (`cargo xtask assets`).
- **Never crash** (AGENTS.md "Never crash"; outranks feature work: a crash loses people's work). Non-test code has no `unwrap()`/`expect()`/`panic!`/`unreachable!`/`todo!`/`unimplemented!` and no `unsafe`: return `Result` and use `?`, `ok_or`, `let … else`. Input-derived indices, slices and arithmetic use `get()` and checked/saturating math; recursion is depth-limited; locks handle poisoning. The app's last-resort guard turns an escaped panic into an error and keeps the document. Every crash fix gets a regression test. Standard: [craftrules `standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
- **Clean-room.** Behaviour and public docs only. No GPL/LGPL/AGPL code. ffmpeg only as an external test oracle.
- **Pure Rust.** Media codecs come from FilmCraft crates (git deps behind `crates/media`, see `plan/adr/0001`).
- **Layering** (`cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal; L0–L4 build for wasm32.

View File

@ -24,6 +24,10 @@ cargo xtask ci # what every commit must pass
- **Everything is a command.** New features are engine commands with an id, label, menu path,
shortcut and parameters, so the UI, the CLI and the MCP server get them at once. Interactive
widgets register an automation id.
- **Never crash.** Non-test code returns errors instead of panicking: no `unwrap()`, `expect()`,
`panic!`, `unreachable!`, `todo!`, `unimplemented!` or `unsafe`, checked indexing and arithmetic on
input-derived values, and a regression test with every crash fix. See the "Never crash" section of
[AGENTS.md](AGENTS.md).
- **Tests with every change**, and green `cargo xtask ci` before every commit. One task per commit.
## How to add…

70
Cargo.lock generated
View File

@ -1134,7 +1134,7 @@ dependencies = [
[[package]]
name = "effectcraft"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"cpal",
"effectcraft-engine",
@ -1155,7 +1155,7 @@ dependencies = [
[[package]]
name = "effectcraft-automation"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
@ -1167,14 +1167,14 @@ dependencies = [
[[package]]
name = "effectcraft-av1enc"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-av1",
]
[[package]]
name = "effectcraft-cli"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"cpu-time",
"effectcraft-automation",
@ -1190,7 +1190,7 @@ dependencies = [
[[package]]
name = "effectcraft-color"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"proptest",
"serde",
@ -1198,7 +1198,7 @@ dependencies = [
[[package]]
name = "effectcraft-effects"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1214,7 +1214,7 @@ dependencies = [
[[package]]
name = "effectcraft-engine"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@ -1246,7 +1246,7 @@ dependencies = [
[[package]]
name = "effectcraft-export"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-av1enc",
"effectcraft-color",
@ -1275,7 +1275,7 @@ dependencies = [
[[package]]
name = "effectcraft-expr"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"boa_engine",
"effectcraft-color",
@ -1292,7 +1292,7 @@ dependencies = [
[[package]]
name = "effectcraft-geom"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"proptest",
"serde",
@ -1300,7 +1300,7 @@ dependencies = [
[[package]]
name = "effectcraft-gpu"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"bytemuck",
"effectcraft-color",
@ -1319,14 +1319,14 @@ dependencies = [
[[package]]
name = "effectcraft-hevcenc"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-hevc",
]
[[package]]
name = "effectcraft-host"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@ -1348,7 +1348,7 @@ dependencies = [
[[package]]
name = "effectcraft-interchange"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-keyframe",
@ -1367,7 +1367,7 @@ dependencies = [
[[package]]
name = "effectcraft-keyframe"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1379,7 +1379,7 @@ dependencies = [
[[package]]
name = "effectcraft-lottie"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@ -1396,7 +1396,7 @@ dependencies = [
[[package]]
name = "effectcraft-media"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-model",
"effectcraft-pdf",
@ -1421,7 +1421,7 @@ dependencies = [
[[package]]
name = "effectcraft-model"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-geom",
"image",
@ -1432,14 +1432,14 @@ dependencies = [
[[package]]
name = "effectcraft-opusenc"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-opus",
]
[[package]]
name = "effectcraft-path"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
@ -1451,7 +1451,7 @@ dependencies = [
[[package]]
name = "effectcraft-pdf"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-raster",
"effectcraft-svg",
@ -1465,7 +1465,7 @@ dependencies = [
[[package]]
name = "effectcraft-plugin"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
@ -1477,7 +1477,7 @@ dependencies = [
[[package]]
name = "effectcraft-project"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1490,14 +1490,14 @@ dependencies = [
[[package]]
name = "effectcraft-psd"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"miniz_oxide 0.8.9",
]
[[package]]
name = "effectcraft-raster"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1506,7 +1506,7 @@ dependencies = [
[[package]]
name = "effectcraft-render"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@ -1528,7 +1528,7 @@ dependencies = [
[[package]]
name = "effectcraft-script"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"boa_engine",
"effectcraft-engine",
@ -1543,7 +1543,7 @@ dependencies = [
[[package]]
name = "effectcraft-svg"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-geom",
"effectcraft-path",
@ -1555,7 +1555,7 @@ dependencies = [
[[package]]
name = "effectcraft-text"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-keyframe",
"harfrust",
@ -1567,7 +1567,7 @@ dependencies = [
[[package]]
name = "effectcraft-time"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"proptest",
"serde",
@ -1575,7 +1575,7 @@ dependencies = [
[[package]]
name = "effectcraft-track"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-raster",
"rayon",
@ -1586,7 +1586,7 @@ dependencies = [
[[package]]
name = "effectcraft-ui-egui"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-engine",
@ -1614,11 +1614,11 @@ dependencies = [
[[package]]
name = "effectcraft-vp9enc"
version = "0.1.1"
version = "0.2.0"
[[package]]
name = "effectcraft-web"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@ -6318,7 +6318,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"serde_json",
]

View File

@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*"]
[workspace.package]
version = "0.1.1"
version = "0.2.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@ -116,7 +116,7 @@ cpal = "0.16"
# filmcraft-isobmff = { path = "../filmcraft/crates/isobmff" }
[workspace.lints.rust]
unsafe_code = "deny"
unsafe_code = "forbid"
[workspace.lints.clippy]
dbg_macro = "warn"

View File

@ -10,7 +10,7 @@ once.
| M1 | Keyframes: temporal and spatial interpolation, Easy Ease, roving, velocity | Done |
| M2 | Compositing: 38 blend modes, track mattes, parenting, adjustment layers | Done |
| M3 | Project operations: settings dialogs, layer commands, `.ecproj`, undo, After Effects menu bar | Done (unsaved-changes prompts, Solid / Layer Settings, lock-aware layer commands, safe project files; View ▸ New Viewer still opens only the one locked viewer) |
| M4 | Preview: precomps, motion blur, cached playback | Mostly done |
| M4 | Preview: precomps, motion blur, cached playback | Mostly done (Preserve frame rate / resolution when nested, one motion blur gate, RAM preview fixes, Cache Frames When Idle, motion blur of collapsed precomps and of animated shape / text content; left: content-keyed RAM preview frames) |
| M5 | Timeline depth: graph editor, keyframe clipboard and dialogs, time remapping, pick-whips, expression editor | Done |
| M6 | Shapes, masks and footage: shape operators, masks and the pen tool, video and image import | Done |
| M7 | 3D: 3D layers, cameras, lights, shadows, depth of field, 3D views and camera tools | In review |
@ -33,6 +33,6 @@ Measured feature by feature in [docs/parity.md](docs/parity.md) (4 October 2026)
- **≈ 8.5 agent-hours of work remain**: about **2–3 hours of wall-clock time** with five Claude
Opus 5.5 agents in parallel, ≈ 1 hour without learned models for Roto Brush and face tracking.
- What is left: GPU kernels for the remaining visual effects, and learned-model quality for Roto
Brush and face tracking.
Brush and face tracking; in previews, content-keyed RAM preview frames ([parity.md](docs/parity.md#highest-value-gaps-in-order)).
Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).

View File

@ -34,6 +34,8 @@
//! `<comp>` is a comp id or name (`-` = the active comp); `<layer>` an id, `#n` or name; `<value>`
//! is JSON (`50`, `[960,540]`, `"#ff0000"`) or a bare string. See `docs/agents.md`.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use effectcraft_automation::tools::{self, Reply};
use effectcraft_automation::{Backend, McpServer};
use serde_json::{Value, json};

View File

@ -20,6 +20,8 @@
//!
//! [`store`] is portable (unit-tested natively); everything else is `wasm32`-only.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod store;
#[cfg(target_arch = "wasm32")]

View File

@ -167,7 +167,7 @@ pub fn restore(s: &mut Session) -> bool {
}
if meta.dirty {
// Unsaved changes stay unsaved.
s.bump();
s.mark_unsaved();
}
SNAP.with(|c| c.set((s.revision, 0.0)));
log::info!("restored the last session ({})", meta.path.as_deref().unwrap_or("untitled"));

View File

@ -5,6 +5,8 @@
//! `--control <port>` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
//! see `effectcraft_ui_egui::control` for the methods.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod audio_out;
mod control_server;
#[cfg(target_os = "macos")]
@ -26,6 +28,7 @@ fn app_icon() -> egui::IconData {
fn main() -> eframe::Result {
// Help ▸ Enable Logging writes through this logger; warnings feed the compatibility report.
effectcraft_engine::logging::install();
effectcraft_engine::logging::install_panic_hook();
let mut control_port: Option<u16> = std::env::var("EFFECTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut demo = true;

4
clippy.toml Normal file
View File

@ -0,0 +1,4 @@
allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true

View File

@ -13,6 +13,8 @@
//! Layering: depends on `effectcraft-engine` only (never egui); the CLI injects a fully wired
//! session (`effectcraft_host::session()`).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod backend;
pub mod base64;
pub mod bridge;

View File

@ -378,7 +378,13 @@ impl<'a> Decider<'a> {
}
}
}
let (_, y_mode, tx_type_y, ty, recon) = best.expect("an intra mode");
// Every ranked mode tries DCT_DCT, so `best` is always set; without a candidate the block
// can't be coded intra (an infinite cost keeps it from being chosen).
let Some((_, y_mode, tx_type_y, ty, recon)) = best else {
let leaf =
Leaf { bsize, is_inter: false, y_mode: DC_PRED, uv_mode: DC_PRED, tx_type_y: DCT_DCT, mv: [0, 0], skip: true, coefs: Default::default() };
return (leaf, f64::INFINITY);
};
self.put(0, c * 4, r * 4, n, recon.as_deref().unwrap_or(&best_pred[..n * n]));
let tx_type_y = if ty.nonzero { tx_type_y } else { DCT_DCT };
self.mark_decoded(0, r, c, n4);
@ -414,16 +420,17 @@ impl<'a> Decider<'a> {
let uv_mode = best_uv.1;
let uv_tx = tx_size_for(clog2);
let uv_type = intra_uv_tx_type(uv_mode, uv_tx);
let mut tuv = Vec::with_capacity(2);
for i in 0..2 {
let mut code_uv = |i: usize| {
predict_intra(&ced[i], &ce[i], uv_mode, &mut pred);
tuv.push(self.code_tx(1 + i, c * 2, r * 2, clog2, uv_type, &pred[..cn * cn], true));
let t = self.code_tx(1 + i, c * 2, r * 2, clog2, uv_type, &pred[..cn * cn], true);
self.mark_decoded(1 + i, r, c, n4);
}
let skip = !ty.nonzero && !tuv[0].nonzero && !tuv[1].nonzero;
let dist = ty.dist + tuv[0].dist + tuv[1].dist;
let bits = if skip { 1.0 } else { ty.bits + tuv[0].bits + tuv[1].bits + 1.0 } + MODE_BITS_INTRA + 2.0;
let [u, v]: [TxResult; 2] = tuv.try_into().ok().expect("two chroma planes");
t
};
let u = code_uv(0);
let v = code_uv(1);
let skip = !ty.nonzero && !u.nonzero && !v.nonzero;
let dist = ty.dist + u.dist + v.dist;
let bits = if skip { 1.0 } else { ty.bits + u.bits + v.bits + 1.0 } + MODE_BITS_INTRA + 2.0;
let leaf = Leaf { bsize, is_inter: false, y_mode, uv_mode, tx_type_y, mv: [0, 0], skip, coefs: [ty.levels, u.levels, v.levels] };
(leaf, dist + self.lambda * bits)
}
@ -432,8 +439,9 @@ impl<'a> Decider<'a> {
let n4 = NUM_4X4_BLOCKS_WIDE[bsize] as usize;
let n = n4 * 4;
let log2 = n.trailing_zeros();
// Inter blocks are only decided with a reference frame; without one, code intra.
let Some(refr) = self.refr else { return self.intra_leaf(r, c, bsize) };
let (mv, mv_bits) = self.motion_search(r, c, n, parent_mv);
let refr = self.refr.expect("reference frame");
let mut pred = vec![0u16; n * n];
let mut tx = Vec::with_capacity(3);
let mut dist = 0.0;
@ -461,7 +469,16 @@ impl<'a> Decider<'a> {
}
let skip = tx.iter().all(|t| !t.nonzero);
let bits = mode_bits + if skip { 1.0 } else { 1.0 + tx.iter().map(|t| t.bits).sum::<f64>() };
let [y, u, v]: [TxResult; 3] = tx.try_into().ok().expect("three planes");
let [y, u, v]: [TxResult; 3] = match tx.try_into() {
Ok(planes) => planes,
// One transform block per plane was coded above.
Err(_) => {
return (
Leaf { bsize, is_inter: true, y_mode: NEWMV, uv_mode: DC_PRED, tx_type_y: DCT_DCT, mv, skip: true, coefs: Default::default() },
f64::INFINITY,
);
}
};
let leaf = Leaf { bsize, is_inter: true, y_mode: NEWMV, uv_mode: DC_PRED, tx_type_y: DCT_DCT, mv, skip, coefs: [y.levels, u.levels, v.levels] };
(leaf, dist + self.lambda * bits)
}
@ -470,7 +487,7 @@ impl<'a> Decider<'a> {
/// Returns the vector and an estimate of its coding cost in bits.
fn motion_search(&self, r: usize, c: usize, n: usize, parent_mv: Option<[i32; 2]>) -> ([i32; 2], f64) {
let g = self.g;
let refr = self.refr.expect("reference frame");
let Some(refr) = self.refr else { return ([0, 0], 0.0) };
let mi = |rr: usize, cc: usize| self.mvs[rr * g.mi_cols + cc];
let mut pred_mv = [0, 0];
let mut cands: Vec<[i32; 2]> = vec![[0, 0]];
@ -579,7 +596,7 @@ impl<'a> Decider<'a> {
/// Approximate luma prediction for motion search: exact at whole and half-sample
/// positions (precomputed planes), averaged neighbours at quarter-sample positions.
fn search_block(&self, mv: [i32; 2], x: usize, y: usize, n: usize, out: &mut [u16]) {
let refr = self.refr.expect("reference frame");
let Some(refr) = self.refr else { return };
let (w, h) = (self.g.width as i32, self.g.height as i32);
// per axis: up to two (integer offset, half) taps
let axis = |v: i32| -> ([(i32, bool); 2], usize) {

View File

@ -8,6 +8,8 @@
//!
//! The public API below is the contract `crates/export` codes against; keep it stable.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod bitw;
mod cdf;
mod decide;
@ -444,7 +446,16 @@ impl Encoder {
let src = self.load_source(frame);
let (frame_obu, rf) = match self.cfg.rate {
RateControl::ConstantQ(q) => self.encode_frame(&src, key, q.max(1)),
RateControl::Bitrate { .. } => self.encode_rate_controlled(&src, key),
RateControl::Bitrate { .. } => match self.rc.take() {
Some(mut rc) => {
let out = self.encode_rate_controlled(&src, key, &mut rc);
self.rc = Some(rc);
out
}
// The state is created with the encoder for bitrate mode; without it, encode at a
// middle quantizer rather than fail.
None => self.encode_frame(&src, key, 128),
},
};
self.refr = Some(rf);
self.frame_num += 1;
@ -456,9 +467,8 @@ impl Encoder {
Packet { data, keyframe: key }
}
fn encode_rate_controlled(&mut self, src: &[Plane; 3], key: bool) -> (Vec<u8>, RefFrame) {
fn encode_rate_controlled(&mut self, src: &[Plane; 3], key: bool, rc: &mut RateState) -> (Vec<u8>, RefFrame) {
let first = self.frame_num == 0;
let rc = self.rc.as_ref().expect("rate control state");
let (frame_bits, debt, q_inter) = (rc.frame_bits, rc.debt, rc.q);
// key frames get a lower quantizer and a larger share of the budget
let key_boost = 1.0 + (self.cfg.keyint.clamp(1, 60) as f64 - 1.0).sqrt() * 0.75;
@ -481,8 +491,8 @@ impl Encoder {
q = (q + 30.0 * err).clamp(1.0, 255.0);
}
}
let (obu, rf, bits) = best.expect("encoded");
let rc = self.rc.as_mut().expect("rate control state");
// At least one pass ran, so `best` is set.
let Some((obu, rf, bits)) = best else { return self.encode_frame(src, key, q.round().clamp(1.0, 255.0) as u8) };
rc.debt += bits - budget;
// adapt the inter quantizer: about 30 index steps per doubling of the rate
let err = (bits / target).log2().clamp(-1.0, 1.0);

View File

@ -290,7 +290,11 @@ pub(crate) fn kinds(tx_type: usize) -> (Kind, Kind) {
ADST_DCT => (Kind::Adst, Kind::Dct),
DCT_ADST => (Kind::Dct, Kind::Adst),
ADST_ADST => (Kind::Adst, Kind::Adst),
_ => unreachable!("unsupported transform type {tx_type}"),
// The encoder only chooses the four types above.
_ => {
debug_assert!(false, "unsupported transform type {tx_type}");
(Kind::Dct, Kind::Dct)
}
}
}

View File

@ -211,7 +211,8 @@ impl<'a> TileWriter<'a> {
debug_assert_eq!(leaf.bsize, bsize);
self.block(r, c, leaf);
}
Node::Out => unreachable!("decided block inside the frame"),
// Blocks inside the frame are always decided; nothing is coded outside it.
Node::Out => debug_assert!(false, "undecided block inside the frame"),
}
}
@ -460,10 +461,11 @@ impl<'a> TileWriter<'a> {
if set > 0 && self.qidx > 0 {
let sqr = TX_SIZE_SQR[tx_sz] as usize;
if b.is_inter {
let s = TX_TYPE_INTER_INV_SET3.iter().position(|&v| v as usize == t).expect("inter tx type in set");
// The decision only picks types of this set (DCT_DCT otherwise).
let s = TX_TYPE_INTER_INV_SET3.iter().position(|&v| v as usize == t).unwrap_or_default();
self.sw.symbol(s, &mut self.cdf.inter_tx_type_set3[sqr]);
} else {
let s = TX_TYPE_INTRA_INV_SET2.iter().position(|&v| v as usize == t).expect("intra tx type in set");
let s = TX_TYPE_INTRA_INV_SET2.iter().position(|&v| v as usize == t).unwrap_or_default();
self.sw.symbol(s, &mut self.cdf.intra_tx_type_set2[sqr][b.y_mode]);
}
} else {

View File

@ -5,6 +5,8 @@
//! standard definitions of the "classic" photographic modes; they are applied to un-premultiplied
//! colour and composited with Porter-Duff source-over on premultiplied pixels.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod blend;
pub mod icc;
pub mod space;

View File

@ -610,6 +610,9 @@ mod tests {
let g = run_fx("ec.keying.keylight", &[("screenColour", green()), ("screenGain", num(150.0))], plate(), 0.0, EffectEnv::default());
assert!(g.img.get(27, 5)[3] < out.img.get(27, 5)[3]);
assert_eq!(crate::lookup("Keylight (1.2)").map(|s| s.id), Some("ec.keying.keylight"));
// Searches find it by After Effects' name as well as its own.
let spec = crate::find("ec.keying.keylight").unwrap();
assert!(crate::name_matches(spec, "keylight") && crate::name_matches(spec, "key light") && !crate::name_matches(spec, "blur"));
}
/// Instances saved with the flat layout get their parameters moved into the twirl-downs.

View File

@ -6,6 +6,8 @@
//! [`instantiate`]), so parameters animate, take expressions and are addressable like any other
//! property.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod audio_fx;
mod blur2;
mod blur3;
@ -502,14 +504,25 @@ pub fn categories() -> Vec<&'static str> {
v
}
/// After Effects' third-party display names of effects we register under a generic name.
const ALIASES: &[(&str, &[&str])] = &[("ec.keying.keylight", keylight::KEYLIGHT_ALIASES)];
/// Other names effect `id` answers to (in [`lookup`] and searches).
pub fn aliases(id: &str) -> &'static [&'static str] {
ALIASES.iter().find(|(i, _)| *i == id).map_or(&[], |(_, a)| a)
}
/// Search (Effects & Presets, `effect.list`): the display name or one of its [`aliases`]
/// contains `query` (lowercase).
pub fn name_matches(spec: &EffectSpec, query: &str) -> bool {
std::iter::once(spec.name).chain(aliases(spec.id).iter().copied()).any(|n| n.to_lowercase().contains(query))
}
/// Find by id or (case-insensitive) display name.
pub fn lookup(name_or_id: &str) -> Option<&'static EffectSpec> {
find(name_or_id)
.or_else(|| all().into_iter().find(|s| s.name.eq_ignore_ascii_case(name_or_id)))
.or_else(|| {
// After Effects' third-party display names we register under a generic name.
keylight::KEYLIGHT_ALIASES.iter().any(|a| a.eq_ignore_ascii_case(name_or_id)).then(|| find("ec.keying.keylight")).flatten()
})
.or_else(|| ALIASES.iter().find(|(_, names)| names.iter().any(|a| a.eq_ignore_ascii_case(name_or_id))).and_then(|(id, _)| find(id)))
.or_else(|| {
// Display names of earlier versions.
migrate::EFFECT_NAME_ALIASES.iter().find(|(old, _)| old.eq_ignore_ascii_case(name_or_id)).and_then(|(_, id)| find(id))
@ -551,8 +564,8 @@ pub fn instantiate(spec: &EffectSpec, ids: &mut Ids, instance_name: &str, layer_
if matches!(ps.ui, ParamUi::Checkbox | ParamUi::Popup { .. } | ParamUi::Layer) {
pr.hold_only = true;
}
match path {
Some(path) => nested_group(&mut g, ids, path).children.push(pr.into()),
match path.and_then(|path| nested_group(&mut g, ids, path)) {
Some(sub) => sub.children.push(pr.into()),
None => g.children.push(pr.into()),
}
// Layer parameters carry a source choice (Source / Masks / Effects & Masks) unless the
@ -563,8 +576,8 @@ pub fn instantiate(spec: &EffectSpec, ids: &mut Ids, instance_name: &str, layer_
if matches!(ps.ui, ParamUi::Layer) && !spec.params.iter().any(|q| q.id == sid) {
let mut src = Property::new(ids.alloc(), &layer_source_id(leaf), &format!("{} Source", ps.name), Value::Enum(2)).with_ui(ParamUi::Hidden);
src.hold_only = true;
match path {
Some(path) => nested_group(&mut g, ids, path).children.push(src.into()),
match path.and_then(|path| nested_group(&mut g, ids, path)) {
Some(sub) => sub.children.push(src.into()),
None => g.children.push(src.into()),
}
}
@ -765,7 +778,7 @@ fn group_name(m: &str) -> &str {
}
/// The nested group at `path` (`borders/autoScale`) under `g`, created on first use.
fn nested_group<'a>(g: &'a mut PropGroup, ids: &mut Ids, path: &str) -> &'a mut PropGroup {
fn nested_group<'a>(g: &'a mut PropGroup, ids: &mut Ids, path: &str) -> Option<&'a mut PropGroup> {
let (first, rest) = match path.split_once('/') {
Some((a, b)) => (a, Some(b)),
None => (path, None),
@ -773,10 +786,10 @@ fn nested_group<'a>(g: &'a mut PropGroup, ids: &mut Ids, path: &str) -> &'a mut
if g.sub(first).is_none() {
g.children.push(ids.group(first, group_name(first)).into());
}
let sub = g.sub_mut(first).expect("just added");
let sub = g.sub_mut(first)?;
match rest {
Some(r) => nested_group(sub, ids, r),
None => sub,
None => Some(sub),
}
}

View File

@ -192,7 +192,7 @@ fn prop_at<'a>(g: &'a mut PropGroup, id: &str) -> Option<&'a mut Property> {
}
}
fn group_at<'a>(g: &'a mut PropGroup, ids: &mut Ids, path: &str) -> &'a mut PropGroup {
fn group_at<'a>(g: &'a mut PropGroup, ids: &mut Ids, path: &str) -> Option<&'a mut PropGroup> {
let (first, rest) = match path.split_once('/') {
Some((a, b)) => (a, Some(b)),
None => (path, None),
@ -200,10 +200,10 @@ fn group_at<'a>(g: &'a mut PropGroup, ids: &mut Ids, path: &str) -> &'a mut Prop
if g.sub(first).is_none() {
g.children.push(ids.group(first, group_name(first)).into());
}
let sub = g.sub_mut(first).expect("just added");
let sub = g.sub_mut(first)?;
match rest {
Some(r) => group_at(sub, ids, r),
None => sub,
None => Some(sub),
}
}
@ -288,8 +288,8 @@ pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, lay
pr
}
};
match path {
Some(path) => group_at(g, ids, path).children.push(pr.into()),
match path.and_then(|path| group_at(g, ids, path)) {
Some(sub) => sub.children.push(pr.into()),
None => g.children.push(pr.into()),
}
}
@ -310,8 +310,8 @@ pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, lay
src.hold_only = true;
src
});
match path {
Some(path) => group_at(g, ids, path).children.push(src.into()),
match path.and_then(|path| group_at(g, ids, path)) {
Some(sub) => sub.children.push(src.into()),
None => g.children.push(src.into()),
}
}
@ -363,7 +363,7 @@ mod tests {
let removed = spec.params.last().unwrap().id;
g.children.retain(|c| c.match_id() != removed);
let popup = spec.params.iter().find(|p| matches!(p.ui, ParamUi::Popup { .. })).expect("Gaussian Blur has a popup");
let ParamUi::Popup { options } = &popup.ui else { unreachable!() };
let ParamUi::Popup { options } = &popup.ui else { panic!("not a popup") };
{
let pr = g.get_mut(popup.id).unwrap();
pr.name = "Old Name".into();

View File

@ -128,10 +128,14 @@ const fn sp(name: &'static str, prims: [[f64; 2]; 3], white: [f64; 2], tf: Tf) -
}
/// The built-in configuration's colour spaces, in popup order.
const ACES2065_1: Space = sp("ACES2065-1", AP0, ACES_WHITE, Tf::Linear);
const ACES_CG: Space = sp("ACEScg", AP1, ACES_WHITE, Tf::Linear);
const ACES_CCT: Space = sp("ACEScct", AP1, ACES_WHITE, Tf::AcesCct);
pub const SPACES: &[Space] = &[
sp("ACES2065-1", AP0, ACES_WHITE, Tf::Linear),
sp("ACEScg", AP1, ACES_WHITE, Tf::Linear),
sp("ACEScct", AP1, ACES_WHITE, Tf::AcesCct),
ACES2065_1,
ACES_CG,
ACES_CCT,
sp("Linear Rec.709 (sRGB)", REC709, D65, Tf::Linear),
sp("sRGB", REC709, D65, Tf::Srgb),
sp("Gamma 2.4 Rec.709", REC709, D65, Tf::Gamma(2.4)),
@ -147,6 +151,7 @@ pub fn space_names() -> Vec<&'static str> {
SPACES.iter().map(|s| s.name).collect()
}
#[cfg(test)]
pub fn space_by_name(n: &str) -> Option<&'static Space> {
SPACES.iter().find(|s| s.name.eq_ignore_ascii_case(n))
}
@ -778,7 +783,7 @@ fn ocio_look(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
let (src, dst) = (space_at(ctx.params.e("source")), space_at(ctx.params.e("destination")));
let inv = ctx.params.e("direction") == 1;
let cct = space_by_name("ACEScct").expect("built-in");
let cct = &ACES_CCT;
let (a, z) = if inv { (dst, src) } else { (src, dst) };
let to_p = Xform::new(a, cct);
let from_p = Xform::new(cct, z);
@ -805,8 +810,8 @@ fn profile(ctx: &EffectCtx, i: u32) -> (Space, bool) {
ColorSpace::Rec709 => sp("Rec.709", REC709, D65, Tf::Gamma(2.4)),
ColorSpace::Rec2020 => sp("Rec.2020", REC2020, D65, Tf::Gamma(2.4)),
ColorSpace::DisplayP3 => sp("Display P3", P3, D65, Tf::Srgb),
ColorSpace::AcesCg => *space_by_name("ACEScg").expect("built-in"),
ColorSpace::Aces2065 => *space_by_name("ACES2065-1").expect("built-in"),
ColorSpace::AcesCg => ACES_CG,
ColorSpace::Aces2065 => ACES2065_1,
// HDR encodings are output spaces, never a working space.
ColorSpace::Rec2100Pq | ColorSpace::Rec2100Hlg => sp("Rec.2020", REC2020, D65, Tf::Gamma(2.4)),
};
@ -816,8 +821,8 @@ fn profile(ctx: &EffectCtx, i: u32) -> (Space, bool) {
2 => (ws(ColorSpace::Rec709), false),
3 => (ws(ColorSpace::Rec2020), false),
4 => (ws(ColorSpace::DisplayP3), false),
5 => (*space_by_name("ACEScg").expect("built-in"), true),
_ => (*space_by_name("ACES2065-1").expect("built-in"), true),
5 => (ACES_CG, true),
_ => (ACES2065_1, true),
}
}
@ -1089,7 +1094,7 @@ pub fn color_program(id: &str, ctx: &EffectCtx) -> Option<(Vec<ColorOp>, Straigh
if !list.is_empty() {
let (src, dst) = (space_at(pr.e("source")), space_at(pr.e("destination")));
let inv = pr.e("direction") == 1;
let cct = space_by_name("ACEScct").expect("built-in");
let cct = &ACES_CCT;
let (a, z) = if inv { (dst, src) } else { (src, dst) };
if inv {
list.reverse();
@ -1157,7 +1162,7 @@ fn curve3(xs: [f64; 3], ys: [f64; 3], v: f64) -> f64 {
let mut pairs: Vec<(f64, f64)> = xs.iter().cloned().zip(ys.iter().cloned()).collect();
pairs.sort_by(|a, b| a.0.total_cmp(&b.0));
for (x, y) in pairs {
if x > pts.last().expect("non-empty").0 + 1e-4 && x < 1.0 - 1e-4 {
if pts.last().is_some_and(|l| x > l.0 + 1e-4) && x < 1.0 - 1e-4 {
pts.push((x, y));
}
}
@ -1196,7 +1201,7 @@ pub fn color_stabilizer_maps(ctx: &EffectCtx, b: &Buf) -> Option<[Vec<(f64, f64)
let mut pairs: Vec<(f64, f64)> = (0..3).map(|i| (cur[i][k], refv[i][k])).collect();
pairs.sort_by(|a, b| a.0.total_cmp(&b.0));
for (x, y) in pairs {
if x > v.last().expect("non-empty").0 + 1e-4 && x < 1.0 - 1e-4 {
if v.last().is_some_and(|l| x > l.0 + 1e-4) && x < 1.0 - 1e-4 {
v.push((x, y));
}
}

View File

@ -686,13 +686,10 @@ fn path_text_passes(ctx: &EffectCtx, b: &Buf) -> Vec<TextPass> {
let js = if j_scale != 0.0 { (1.0 + rnd(i, 4) * j_scale).max(0.05) } else { 1.0 };
let jb = if j_base != 0.0 { rnd(i, 1) * j_base } else { 0.0 };
let left = ink_left(c, true);
let set = match polys_by_alpha.iter_mut().find(|(a, _)| (*a - alpha).abs() < 1e-4) {
Some((_, v)) => v,
None => {
polys_by_alpha.push((alpha, Vec::new()));
&mut polys_by_alpha.last_mut().expect("pushed").1
}
};
if !polys_by_alpha.iter().any(|(a, _)| (*a - alpha).abs() < 1e-4) {
polys_by_alpha.push((alpha, Vec::new()));
}
let Some((_, set)) = polys_by_alpha.iter_mut().find(|(a, _)| (*a - alpha).abs() < 1e-4) else { continue };
for st in parse_glyph(c) {
set.push(
st.iter()

View File

@ -382,7 +382,7 @@ impl Session {
if !job.is_finished() {
return false;
}
let mut job = self.camera_job.take().expect("job");
let Some(mut job) = self.camera_job.take() else { return false };
if let Some(t) = job.thread.take() {
let _ = t.join();
}

View File

@ -6,7 +6,7 @@ use effectcraft_project::{LayerId, Property, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::prop::{edit_keys, prop_ref};
use super::prop::prop_ref;
use super::{CommandSpec, bad, comp_id, f_p, has_comp, has_keys, layers_p, merge_p, str_p, time_p};
use crate::{EngineError, KeyClip, KeyRef, Result, Session, cmd, query};
@ -73,11 +73,19 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path")?;
comp.layer(*targets.first()?)?.props.prop(path).map(|pr| pr.uid)
});
// Keys copied from several layers, pasted onto as many layers: the first copied layer's keys
// go to the first target and so on (else every target gets every copied property).
let mut sources: Vec<LayerId> = clip.iter().map(|c| c.layer).collect();
sources.dedup();
let paired = sources.len() > 1 && sources.len() == targets.len();
// (layer, prop uid, clip index)
let mut plan: Vec<(LayerId, Uid, usize)> = vec![];
for lid in &targets {
for (ti, lid) in targets.iter().enumerate() {
let Some(l) = comp.layer(*lid) else { continue };
for (ci, c) in clip.iter().enumerate() {
if paired && sources.get(ti) != Some(&c.layer) {
continue;
}
let Some(sample) = c.keys.first().map(|k| &k.value) else { continue };
let target = if clip.len() == 1 {
explicit
@ -131,7 +139,14 @@ fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, mut layers) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let mut sel = vec![];
if p.get("layers").is_none() && p.get("layer").is_none() && !s.state.selected_props.is_empty() {
if let Some(visible) = super::visible_p(p) {
// Ctrl+Alt+A in the Timeline: every key it shows (its revealed properties).
for (lid, uid) in visible {
if let Some(pr) = comp.layer(lid).and_then(|l| l.props.find(uid)) {
sel.extend(pr.keys.iter().map(|k| KeyRef { layer: lid, prop: uid, time: k.time }));
}
}
} else if p.get("layers").is_none() && p.get("layer").is_none() && !s.state.selected_props.is_empty() {
for (lid, uid) in &s.state.selected_props {
if let Some(pr) = comp.layer(*lid).and_then(|l| l.props.find(*uid)) {
sel.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time }));
@ -145,6 +160,37 @@ fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
l.props.walk("", &mut |_, pr| sel.extend(pr.keys.iter().map(|k| KeyRef { layer: l.id, prop: pr.uid, time: k.time })));
}
}
// Keys of locked layers can't be selected.
sel.retain(|k| comp.layer(k.layer).is_some_and(|l| !l.switches.locked));
s.state.selected_keys = sel;
Ok(json!(s.state.selected_keys.len()))
}
/// The keyframe context menu's Select Equal / Previous / Following Keyframes: in every property
/// with a selected key, its keys with the same value as a selected one / before the first
/// selected one / after the last selected one (the selected keys stay selected).
fn select_related(s: &mut Session, which: &str) -> Result<Value> {
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
let mut sel = s.state.selected_keys.clone();
let mut props: Vec<(LayerId, Uid)> = sel.iter().map(|k| (k.layer, k.prop)).collect();
props.sort();
props.dedup();
for (lid, uid) in props {
let Some(pr) = comp.layer(lid).and_then(|l| l.props.find(uid)) else { continue };
let picked: Vec<&Keyframe> = pr.keys.iter().filter(|k| sel.iter().any(|r| r.layer == lid && r.prop == uid && r.time == k.time)).collect();
let (Some(first), Some(last)) = (picked.iter().map(|k| k.time).min(), picked.iter().map(|k| k.time).max()) else { continue };
for k in &pr.keys {
let hit = match which {
"equal" => picked.iter().any(|p| p.value == k.value),
"previous" => k.time < first,
_ => k.time > last,
};
let r = KeyRef { layer: lid, prop: uid, time: k.time };
if hit && !sel.contains(&r) {
sel.push(r);
}
}
}
s.state.selected_keys = sel;
Ok(json!(s.state.selected_keys.len()))
}
@ -153,11 +199,8 @@ fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
let frames = p.get("frames").and_then(Value::as_i64).ok_or_else(|| bad("keys.nudge", "missing `frames`"))?;
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
let d = Tick(comp.frame_duration().0 * frames);
edit_keys(s, "Nudge Keyframes", merge_p(p), move |_, _, t| {
*t += d;
true
})
let d = Tick(comp.frame_duration().0.saturating_mul(frames));
super::prop::shift_keys(s, "Nudge Keyframes", merge_p(p), d)
}
/// Edit one key directly: move it in time and/or set its value (Graph Editor drags).
@ -386,7 +429,15 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("keys.copy", "Copy Keyframes", [], None, "{}", has_keys, copy),
cmd!("keys.paste", "Paste Keyframes", [], None, "{layers?, prop?|path?, time?}", has_key_clip, paste),
cmd!("keys.selectAll", "Select All Keyframes", [], Some("Cmd+Alt+A"), "{layers?}", keys_or_layers, select_all),
cmd!(
"keys.selectAll",
"Select All Keyframes",
[],
Some("Cmd+Alt+A"),
"{layers?, visible?: [{layer, prop}] (every key of these properties)}",
keys_or_layers,
select_all
),
cmd!("keys.nudge", "Nudge Keyframes", [], None, "{frames, merge?}", has_keys, nudge),
cmd!("keys.nudgeForward", "Move Keyframes 1 Frame Later", [], Some("Alt+ArrowRight"), "{}", has_keys, |s, _| nudge(s, &json!({"frames": 1}))),
cmd!("keys.nudgeBackward", "Move Keyframes 1 Frame Earlier", [], Some("Alt+ArrowLeft"), "{}", has_keys, |s, _| nudge(s, &json!({"frames": -1}))),
@ -399,6 +450,9 @@ pub fn specs() -> Vec<CommandSpec> {
&json!({"frames": -10})
)),
cmd!("keys.set", "Edit Keyframe", [], None, "{layer?, path|prop, time (layer s), newTime?, value?, merge?}", has_comp, set_key),
cmd!("keys.selectEqual", "Select Equal Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "equal")),
cmd!("keys.selectPrevious", "Select Previous Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "previous")),
cmd!("keys.selectFollowing", "Select Following Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "following")),
cmd!(
"keys.setEase",
"Set Keyframe Ease",

View File

@ -162,7 +162,7 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
let base = masks.children.len();
for k in 0..n_masks {
// Depth decides the mode: outlines add, holes subtract.
let first = frames.iter().find_map(|f| f.get(k)).expect("mask");
let Some(first) = frames.iter().find_map(|f| f.get(k)) else { continue };
let mode = if first.depth % 2 == 0 { MaskMode::Add } else { MaskMode::Subtract };
let mode = if k == 0 { MaskMode::Add } else { mode };
let mut g = build::mask(
@ -173,14 +173,16 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
build::MASK_COLORS[(base + k) % build::MASK_COLORS.len()],
);
if frames.len() > 1 {
let path = g.get_mut("path").expect("path");
for (f, lt) in frames.iter().zip(&layer_times) {
if let Some(tr) = f.get(k) {
effectcraft_keyframe::set_key(&mut path.keys, Keyframe::new(*lt, KV::Path(tr.path.clone())));
if let Some(path) = g.get_mut("path") {
for (f, lt) in frames.iter().zip(&layer_times) {
if let Some(tr) = f.get(k) {
effectcraft_keyframe::set_key(&mut path.keys, Keyframe::new(*lt, KV::Path(tr.path.clone())));
}
}
}
let op = g.get_mut("opacity").expect("opacity");
if frames.iter().any(|f| f.get(k).is_none()) {
if let Some(op) = g.get_mut("opacity")
&& frames.iter().any(|f| f.get(k).is_none())
{
for (f, lt) in frames.iter().zip(&layer_times) {
let mut key = Keyframe::new(*lt, KV::Scalar(if f.get(k).is_some() { 100.0 } else { 0.0 }));
key.in_interp = Interp::Hold;

View File

@ -12,6 +12,14 @@ pub(crate) fn rate_p(p: &Value) -> Option<FrameRate> {
f_p(p, "frameRate").or(f_p(p, "fps")).map(FrameRate::from_f64)
}
/// 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")),
_ => Ok(()),
}
}
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) {
@ -90,9 +98,16 @@ fn apply_settings(c: &mut Comp, p: &Value) {
if let Some(n) = p.get("adaptiveSampleLimit").and_then(Value::as_u64) {
c.motion_blur_adaptive_limit = (n as u32).clamp(16, 256);
}
if let Some(b) = p.get("preserveFrameRate").and_then(Value::as_bool) {
c.preserve_frame_rate = b;
}
if let Some(b) = p.get("preserveResolution").and_then(Value::as_bool) {
c.preserve_resolution = b;
}
}
fn new_comp(s: &mut Session, p: &Value) -> Result<Value> {
check_rate(p, "comp.new")?;
let name = str_p(p, "name").unwrap_or("Comp 1").to_string();
let rate = rate_p(p).unwrap_or(FrameRate::FPS_29_97);
let mut c = Comp::new(1920, 1080, rate, rate.snap_nearest(Tick::from_seconds_f64(10.0)));
@ -119,6 +134,7 @@ fn new_comp(s: &mut Session, p: &Value) -> Result<Value> {
}
fn settings(s: &mut Session, p: &Value) -> Result<Value> {
check_rate(p, "comp.settings")?;
let cid = comp_id(s, p)?;
s.edit("Composition Settings", None, |proj, _| {
if let Some(n) = str_p(p, "name")
@ -252,7 +268,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Composition...",
["Composition"],
Some("Cmd+N"),
"{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), renderer? classic3D|advanced3D, anchor?, open?}",
"{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool, preserveResolution?: bool, renderer? classic3D|advanced3D, anchor?, open?}",
always,
new_comp
),
@ -261,7 +277,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Composition Settings...",
["Composition"],
Some("Cmd+K"),
"{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), pixelAspect?, renderer? classic3D|advanced3D}",
"{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool (nested or in the render queue it shows only its own frames), preserveResolution?: bool (nested, it renders at full size), pixelAspect?, renderer? classic3D|advanced3D}",
has_comp,
settings
),

View File

@ -299,46 +299,6 @@ fn copy_expression_only(s: &mut Session, _: &Value) -> Result<Value> {
}
/// Paste the keyframe clipboard at the CTI onto the selected layers (same property paths).
fn paste_keys(s: &mut Session, reversed: bool) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let ids = s.state.selected_layers.clone();
if ids.is_empty() {
return Err(EngineError::Other("select a layer to paste keyframes into".into()));
}
let clip = s.state.key_clipboard.clone();
let t = s.time();
let n = s.edit(if reversed { "Paste Reversed Keyframes" } else { "Paste Keyframes" }, None, |proj, st| {
let mut n = 0;
st.selected_keys.clear();
for lid in &ids {
let l = super::layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
for c in &clip {
let Some(pr) = l.props.prop_mut(&c.path) else { continue };
let span = c.keys.last().map(|k| k.time).unwrap_or(Tick::ZERO);
for k in &c.keys {
let mut k = k.clone();
if std::mem::discriminant(&k.value) != std::mem::discriminant(&pr.value) {
continue;
}
if reversed {
k.time = span - k.time;
std::mem::swap(&mut k.in_interp, &mut k.out_interp);
std::mem::swap(&mut k.in_ease, &mut k.out_ease);
std::mem::swap(&mut k.spatial_in, &mut k.spatial_out);
}
k.time += lt;
st.selected_keys.push(crate::KeyRef { layer: *lid, prop: pr.uid, time: k.time });
effectcraft_keyframe::set_key(&mut pr.keys, k);
n += 1;
}
}
}
Ok(n)
})?;
Ok(json!({"keys": n}))
}
fn paste_links(s: &mut Session, clip: LinkClip) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let ids = s.state.selected_layers.clone();
@ -470,8 +430,34 @@ fn label(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
fn paste_reversed(s: &mut Session, _: &Value) -> Result<Value> {
paste_keys(s, true)
/// Edit ▸ Paste Reversed Keyframes: the copied keys mirrored in time over their whole span (the
/// last key first, incoming and outgoing eases swapped), pasted like Edit ▸ Paste
/// (`keys.paste`: at the current time, into the selected or the same properties).
fn paste_reversed(s: &mut Session, p: &Value) -> Result<Value> {
let original = s.state.key_clipboard.clone();
let span = original.iter().flat_map(|c| c.keys.iter().map(|k| k.time)).max().unwrap_or(Tick::ZERO);
s.state.key_clipboard = original
.iter()
.map(|c| crate::KeyClip {
keys: c
.keys
.iter()
.rev()
.map(|k| {
let mut k = k.clone();
k.time = span - k.time;
std::mem::swap(&mut k.in_interp, &mut k.out_interp);
std::mem::swap(&mut k.in_ease, &mut k.out_ease);
std::mem::swap(&mut k.spatial_in, &mut k.spatial_out);
k
})
.collect(),
..c.clone()
})
.collect();
let r = s.execute("keys.paste", p.clone());
s.state.key_clipboard = original;
r
}
/// Lift (leave a gap) or extract (close the gap) the work area from the selected layers (all
@ -667,7 +653,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("edit.copyExpressionOnly", "Copy Expression Only", ["Edit"], None, "{}", has_props_or_layers, copy_expression_only),
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{} (layers, keyframes at the CTI, or property links / expressions)", has_clip, paste),
cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{}", has_key_clip, paste_reversed),
cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{layers?, prop?|path?, time?}", has_key_clip, paste_reversed),
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?}", has_layers, duplicate),
cmd!("edit.splitLayer", "Split Layer", ["Edit"], Some("Cmd+Shift+D"), "{layers?}", has_layers, split),

View File

@ -4,7 +4,7 @@ use effectcraft_project::build::Ids;
use effectcraft_project::{GroupKind, LayerId, PropGroup, Uid};
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layers_p, str_p};
use super::{CommandSpec, b_p, bad, f_p, has_layers, layer_mut, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd};
/// `effect.plugins.list`: the registered plug-in effects (WebAssembly or Rust).
@ -132,6 +132,52 @@ fn find_fx(s: &Session, p: &Value, cmd: &str) -> Result<(effectcraft_project::It
Ok((cid, lid, g.uid))
}
/// A colour parameter's eyedropper on a keyer (Effect Controls): the colour of the effect's input
/// (the layer's pixels before this effect, so a keyed screen can still be picked) at a
/// layer-space point, set on the parameter. `average` takes the 5 × 5 pixels around it.
fn pick_color(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "effect.pickColor";
let (cid, lid, fx_uid) = find_fx(s, p, cmd)?;
let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) };
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let fx = layer.effects().ok_or_else(|| bad(cmd, "no effects"))?;
let index = fx.groups().position(|g| g.uid == fx_uid).ok_or_else(|| bad(cmd, "no such effect"))?;
let g = fx.groups().nth(index).ok_or_else(|| bad(cmd, "no such effect"))?;
let prop = match (p.get("prop").and_then(Value::as_u64), str_p(p, "param")) {
(Some(uid), _) => g.find(uid),
(None, Some(path)) => g.prop(path),
_ => return Err(bad(cmd, "missing `param` (e.g. `screenColour`) or `prop` (uid)")),
}
.filter(|pr| pr.ui == effectcraft_project::ParamUi::Color)
.ok_or_else(|| bad(cmd, "not a colour parameter of this effect"))?;
let t = super::time_p(s, p, Some(comp));
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let mut r = effectcraft_render::Renderer::new(&s.project, &*s.footage, effectcraft_render::RenderOpts::default());
r.expr = s.expr.as_deref();
r.cache = Some(&s.layer_cache);
let buf = r.layer_input(&ctx, layer, index).ok_or_else(|| bad(cmd, "this layer has no pixels"))?;
let (px, py) = buf.to_px([x, y]);
let (cx, cy) = (px.floor() as i64, py.floor() as i64);
let rad = if b_p(p, "average") == Some(true) { 2 } else { 0 };
let mut sum = [0.0f32; 4];
for yy in cy.saturating_sub(rad)..=cy.saturating_add(rad) {
for xx in cx.saturating_sub(rad)..=cx.saturating_add(rad) {
// Outside the layer reads as transparent.
let c = buf.img.get(xx, yy);
(0..4).for_each(|i| sum[i] += c[i]);
}
}
if sum[3] <= 1e-6 {
return Err(bad(cmd, "nothing to sample there (transparent or outside the layer)"));
}
// Straight colour of the (averaged) premultiplied pixels.
let color: Vec<f64> = (0..3).map(|i| (sum[i] / sum[3]).clamp(0.0, 1.0) as f64).chain([1.0]).collect();
let uid = prop.uid;
s.execute("prop.set", json!({"comp": cid.0, "layer": lid.0, "prop": uid, "value": color}))?;
Ok(json!({"prop": uid, "color": color}))
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let cid = super::comp_id(s, p)?;
let fx = effects_p(s, p, "effect.remove")?;
@ -355,11 +401,11 @@ fn last(s: &mut Session, p: &Value) -> Result<Value> {
}
fn list(_: &mut Session, p: &Value) -> Result<Value> {
let filter = str_p(p, "filter").map(str::to_ascii_lowercase);
let filter = str_p(p, "filter").map(str::to_lowercase);
let v: Vec<Value> = effectcraft_effects::all()
.into_iter()
.filter(|e| {
filter.as_ref().is_none_or(|f| e.name.to_ascii_lowercase().contains(f) || e.id.contains(f.as_str()) || e.category.to_ascii_lowercase().contains(f))
filter.as_ref().is_none_or(|f| effectcraft_effects::name_matches(e, f) || e.id.contains(f.as_str()) || e.category.to_ascii_lowercase().contains(f))
})
.map(|e| {
json!({"id": e.id, "name": e.name, "category": e.category, "gpu": e.gpu, "float": e.float, "params": e.params.iter().map(|p| json!({"id": p.id, "name": p.name, "default": p.default.to_json()})).collect::<Vec<_>>()})
@ -371,6 +417,15 @@ fn list(_: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("effect.apply", "Apply Effect", [], None, "{effect: id|name (e.g. Gaussian Blur), layers?}", has_layers, apply),
cmd!(
"effect.pickColor",
"Pick Effect Colour",
[],
None,
"{layer?, effect?: index|uid|name, param: id (e.g. screenColour) | prop: uid, x, y (layer px), average?: bool (5×5), time?} — the colour of the effect's input there",
has_layers,
pick_color
),
cmd!("effect.applyLast", "Last Effect", ["Effect"], Some("Cmd+Alt+Shift+E"), "{layers?}", has_layers, last),
cmd!("effect.removeAll", "Remove All", ["Effect"], Some("Cmd+Shift+E"), "{layers?}", has_layers, remove_all),
cmd!("effect.remove", "Remove Effect", [], None, "{layer?, effect: index|uid|name}", has_layers, remove),

View File

@ -2,7 +2,7 @@
//! Language menu (the ⓕ button next to an expression), written from the public Expression
//! Language Reference.
use effectcraft_project::{ItemId, Layer, Property};
use effectcraft_project::{Expression, ItemId, Layer, Property};
use effectcraft_render::EvalCtx;
use effectcraft_time::Tick;
use serde::Serialize;
@ -45,14 +45,13 @@ pub fn errors(s: &Session, cid: ItemId, t: Tick) -> Vec<ExprError> {
let ctx = EvalCtx { footage: Some(s.footage.as_ref()), expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, t) };
let mut out = vec![];
for (i, l) in comp.layers.iter().enumerate() {
let mut props: Vec<&Property> = vec![];
let mut props: Vec<(&Property, &Expression)> = vec![];
l.props.walk("", &mut |_, p| {
if p.expr.as_ref().is_some_and(|e| !e.text.trim().is_empty()) {
props.push(p);
if let Some(e) = p.expr.as_ref().filter(|e| !e.text.trim().is_empty()) {
props.push((p, e));
}
});
for p in props {
let e = p.expr.as_ref().expect("filtered");
for (p, e) in props {
let msg = if e.enabled {
match &ctx.expr {
Some(h) => h.eval(&ctx, l, p, &p.value_at(l.layer_time(t))).err(),

View File

@ -176,7 +176,7 @@ fn new_comp_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
let sequence = b_p(p, "sequence").unwrap_or(false).then(|| {
json!({
"overlap": b_p(p, "overlap").unwrap_or(false),
"duration": f_p(p, "overlapDuration").unwrap_or(1.0),
"duration": f_p(p, "overlapDuration").filter(|d| d.is_finite() && *d >= 0.0).unwrap_or(1.0),
"transition": str_p(p, "transition").unwrap_or("off"),
})
});
@ -187,7 +187,7 @@ fn new_comp_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
super::app_more::grouped(s, "New Comp from Selection", |s| {
let mut made = vec![];
for g in groups {
let lead = proj.item(if single { g[from] } else { g[0] }).ok_or(EngineError::NoComp)?;
let lead = g.get(if single { from } else { 0 }).and_then(|i| proj.item(*i)).ok_or(EngineError::NoComp)?;
let (w, h) = lead.dimensions().unwrap_or((1920, 1080));
let rate = lead.frame_rate().unwrap_or(FrameRate::FPS_29_97);
let pixel_aspect = match &lead.kind {
@ -199,7 +199,7 @@ fn new_comp_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
let overlap =
sequence.as_ref().filter(|q| q["overlap"] == true).map_or(Tick::ZERO, |q| Tick::from_seconds_f64(q["duration"].as_f64().unwrap_or(1.0)));
let dur = match &sequence {
Some(_) if g.len() > 1 => g.iter().map(|i| duration(*i)).fold(Tick::ZERO, |a, d| a + d) - Tick(overlap.0 * (g.len() as i64 - 1)),
Some(_) if g.len() > 1 => g.iter().map(|i| duration(*i)).fold(Tick::ZERO, |a, d| a + d) - Tick(overlap.0.saturating_mul(g.len() as i64 - 1)),
_ => g.iter().map(|i| duration(*i)).max().unwrap_or(Tick::from_seconds_f64(still)),
}
.max(rate.frame_duration());

View File

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

View File

@ -19,11 +19,25 @@ fn v3(v: Option<&Value>) -> Option<[f64; 3]> {
/// Transform the selected keys: times scale about `timeAnchor` (comp seconds, default the first
/// selected key) by `timeScale` and move by `timeOffset`; values scale about `valueAnchor` by
/// `valueScale` and move by `valueOffset` (every dimension, or only `dim`). Times land on frames.
/// With `fromStart` (drags), each step of one merged gesture gives the whole transform since the
/// drag started and applies to the keys as they were then: keys squeezed onto one frame on the
/// way, or rounded steps, are never lost; only the final position counts.
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let merge = merge_p(p);
let from_start = b_p(p, "fromStart").unwrap_or(false) && merge.is_some();
let gesture = from_start && merge.is_some_and(|m| s.history.merge_key.as_deref() == Some(m)) && s.key_move.as_ref().map(|g| g.merge.as_str()) == merge;
let (sel, base) = match (&s.key_move, s.history.undo.last()) {
(Some(g), Some((_, b))) if gesture => (g.from.clone(), Some(b.clone())),
_ => (s.state.selected_keys.clone(), None),
};
if from_start {
s.key_move = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: sel.clone(), total: Tick::ZERO });
}
// The comp as it was when the drag started (its keys are what the transform applies to).
let start = base.clone().unwrap_or_else(|| s.project.clone());
let comp = start.comp(cid).ok_or(EngineError::NoComp)?;
let fr = comp.frame_rate;
let sel = s.state.selected_keys.clone();
let ts = f_p(p, "timeScale").unwrap_or(1.0);
let toff = f_p(p, "timeOffset").unwrap_or(0.0);
let vs = f_p(p, "valueScale").unwrap_or(1.0);
@ -46,7 +60,10 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
fr.snap_nearest(Tick::from_seconds_f64(x.max(0.0)))
};
let touch_values = vs != 1.0 || voff != 0.0;
s.edit("Transform Keyframes", merge_p(p), |proj, st| {
s.edit("Transform Keyframes", merge, |proj, st| {
if let Some(b) = base {
*proj = (*b).clone();
}
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
for k in &sel {
@ -136,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Transform Keyframes",
[],
None,
"{timeScale?, timeAnchor? (comp s), timeOffset? (s), valueScale?, valueAnchor?, valueOffset?, dim? | dims?: [d…], merge?}",
"{timeScale?, timeAnchor? (comp s), timeOffset? (s), valueScale?, valueAnchor?, valueOffset?, dim? | dims?: [d…], merge?, fromStart?: bool (with merge: values are the whole transform since the drag started)}",
has_keys,
transform
),

View File

@ -1031,8 +1031,8 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
&& let Some(doc) = super::text_edit::layer_doc(s, lid)
&& doc.char_len() > 0
&& let Some(obj) = p.as_object()
&& let Some(e) = s.state.text_edit.as_mut()
{
let e = s.state.text_edit.as_mut().expect("checked");
let mut st = e.pending.clone().unwrap_or_else(|| doc.insertion_style(r.start));
let mut any = false;
for (k, v) in obj {
@ -1197,7 +1197,7 @@ fn layer_settings(s: &mut Session, p: &Value) -> Result<Value> {
/// Layer Settings applies to solids (and adjustment layers), nulls, cameras and lights.
fn has_layer_settings(s: &Session) -> std::result::Result<(), String> {
has_layers(s)?;
let l = s.active_comp().and_then(|c| c.layer(s.state.selected_layers[0])).ok_or("select a layer first")?;
let l = s.active_comp().zip(s.state.selected_layers.first()).and_then(|(c, l)| c.layer(*l)).ok_or("select a layer first")?;
match l.source {
LayerSource::Solid { .. } | LayerSource::Null | LayerSource::Camera | LayerSource::Light { .. } => Ok(()),
_ => Err("this kind of layer has no settings".into()),

View File

@ -517,6 +517,13 @@ pub(crate) fn unlocked(s: &Session, cid: ItemId, ids: Vec<LayerId>, cmd: &str) -
}
}
/// `visible: [{layer, prop}]`: the properties the Timeline shows (J / K and Select All
/// Keyframes use only those, as in After Effects). `None` when not given.
pub(crate) fn visible_p(p: &Value) -> Option<Vec<(LayerId, effectcraft_project::Uid)>> {
let a = p.get("visible")?.as_array()?;
Some(a.iter().filter_map(|v| Some((LayerId(v.get("layer")?.as_u64()?), v.get("prop")?.as_u64()?))).collect())
}
/// `time` (seconds) or `frame` param, else the CTI.
pub(crate) fn time_p(s: &Session, p: &Value, comp: Option<&Comp>) -> Tick {
if let Some(t) = f_p(p, "time") {

View File

@ -335,7 +335,19 @@ fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
sel.push(KeyRef { layer: l, prop: pr.uid, time: kt });
}
}
if b_p(p, "add").unwrap_or(false) {
// Keys of locked layers can't be selected (so nothing edits them).
sel.retain(|k| comp.layer(k.layer).is_some_and(|l| !l.switches.locked));
if b_p(p, "toggle").unwrap_or(false) {
// Shift+click: in and out of the selection.
for k in sel {
match s.state.selected_keys.iter().position(|x| *x == k) {
Some(i) => {
s.state.selected_keys.remove(i);
}
None => s.state.selected_keys.push(k),
}
}
} else if b_p(p, "add").unwrap_or(false) {
for k in sel {
if !s.state.selected_keys.contains(&k) {
s.state.selected_keys.push(k);
@ -400,14 +412,72 @@ pub(crate) fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: im
})
}
/// Move the selected keys by `d` comp seconds, snapped to the comp's frames in comp time (keys
/// of time-stretched layers move with the pointer, not by the layer-time distance). Keys of
/// locked layers stay. A key landing on another key of its property replaces it.
pub(crate) fn shift_keys(s: &mut Session, label: &str, merge: Option<&str>, d: Tick) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
// A drag (steps with one merge key) starts again from the keys before it each step.
let gesture = merge.is_some_and(|m| s.history.merge_key.as_deref() == Some(m)) && s.key_move.as_ref().map(|g| g.merge.as_str()) == merge;
let (from, total, base) = match (&s.key_move, s.history.undo.last()) {
(Some(g), Some((_, base))) if gesture => (g.from.clone(), g.total + d, Some(base.clone())),
_ => (s.state.selected_keys.clone(), d, None),
};
s.key_move = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: from.clone(), total });
s.edit(label, merge, |proj, st| {
if let Some(b) = base {
*proj = (*b).clone();
}
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let fr = comp.frame_rate;
let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
for k in &from {
groups.entry((k.layer, k.prop)).or_default().push(k.time);
}
let mut sel = vec![];
for ((lid, uid), times) in groups {
let Some(l) = comp.layer_mut(lid) else { continue };
let to = |t: Tick| l.layer_time(fr.snap_nearest(l.comp_time(t) + total));
let moves: Vec<(Tick, Tick)> = times.iter().map(|t| (*t, to(*t))).collect();
if l.switches.locked {
sel.extend(times.iter().map(|t| KeyRef { layer: lid, prop: uid, time: *t }));
continue;
}
let Some(pr) = l.props.find_mut(uid) else { continue };
// Take the moving keys out, then put them back at their new times.
let mut moved = vec![];
for (old, new) in &moves {
if let Some(i) = key_at(&pr.keys, *old) {
let mut k = pr.keys.remove(i);
k.time = *new;
moved.push(k);
}
}
for k in moved {
sel.push(KeyRef { layer: lid, prop: uid, time: k.time });
match pr.keys.binary_search_by(|x| x.time.cmp(&k.time)) {
Ok(j) => pr.keys[j] = k,
Err(j) => pr.keys.insert(j, k),
}
}
// Roving keys follow their neighbours: keep the selection on them.
let before: Vec<Tick> = pr.keys.iter().map(|k| k.time).collect();
if effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial) {
for r in sel.iter_mut().filter(|r| r.layer == lid && r.prop == uid) {
if let Some(i) = before.iter().position(|t| *t == r.time) {
r.time = pr.keys[i].time;
}
}
}
}
st.selected_keys = sel;
Ok(json!(st.selected_keys.len()))
})
}
fn move_keys(s: &mut Session, p: &Value) -> Result<Value> {
let d = Tick::from_seconds_f64(f_p(p, "delta").ok_or_else(|| bad("keys.move", "missing `delta` (seconds)"))?);
let fr = s.active_comp().map(|c| c.frame_rate);
edit_keys(s, "Move Keyframes", merge_p(p), move |_, _, t| {
let nt = *t + d;
*t = fr.map(|r| r.snap_nearest(nt)).unwrap_or(nt);
true
})
shift_keys(s, "Move Keyframes", merge_p(p), d)
}
fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
@ -543,7 +613,14 @@ fn interpolation(s: &mut Session, p: &Value) -> Result<Value> {
fn toggle_hold(s: &mut Session, _: &Value) -> Result<Value> {
edit_keys(s, "Toggle Hold Keyframe", None, |keys, i, _| {
let k = &mut keys[i];
k.out_interp = if k.out_interp == Interp::Hold { Interp::Linear } else { Interp::Hold };
// Off again: back to Bezier when the key eases (its other side, auto / continuous, or
// an outgoing ease), else Linear.
let bezier = k.auto_bezier || k.continuous || k.in_interp == Interp::Bezier || !k.out_ease.is_empty();
k.out_interp = match k.out_interp {
Interp::Hold if bezier => Interp::Bezier,
Interp::Hold => Interp::Linear,
_ => Interp::Hold,
};
false
})
}
@ -667,7 +744,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_layers,
convert_expr_to_keys
),
cmd!("keys.select", "Select Keyframes", [], None, "{keys: [{layer, prop: uid | path (or `path`), time (layer s)}], add?}", has_comp, select_keys),
cmd!(
"keys.select",
"Select Keyframes",
[],
None,
"{keys: [{layer, prop: uid | path (or `path`), time (layer s)}], add?, toggle?: bool (Shift+click: in or out of the selection)}",
has_comp,
select_keys
),
cmd!("keys.move", "Move Keyframes", [], None, "{delta (s), merge?}", has_keys, move_keys),
cmd!("keys.delete", "Delete Keyframes", [], None, "{}", has_keys, delete_keys),
cmd!("keys.easyEase", "Easy Ease", ["Animation", "Keyframe Assistant"], Some("F9"), "{which?: both|in|out}", has_keys, ease),

View File

@ -79,6 +79,7 @@ fn comp_info(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({
"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,
"layers": c.layers.iter().enumerate().map(|(i, l)| layer_json(l, i + 1)).collect::<Vec<_>>(),
}))
}

View File

@ -136,7 +136,7 @@ fn apply(s: &mut Session, cid: ItemId, lid: LayerId, mode: Mode, cuts: &[Tick])
let i = cm.layers.iter().position(|l| l.id == lid).ok_or(EngineError::NoComp)?;
let mut ids = vec![lid];
for t in cuts {
let cur = cm.layers.iter().position(|l| l.id == *ids.last().expect("piece")).unwrap_or(i);
let cur = ids.last().and_then(|last| cm.layers.iter().position(|l| l.id == *last)).unwrap_or(i);
let l = &mut cm.layers[cur];
if !(*t > l.in_point && *t < l.out_point) {
continue;

View File

@ -72,14 +72,11 @@ fn animator_uid(s: &Session, l: &Layer, p: &Value, cmd: &str) -> Result<u64> {
/// Add the properties of `kind` to an animator's Properties group (skipping ones it has).
fn add_props(ids: &mut Ids, anim: &mut PropGroup, kind: &str, three_d: bool) -> usize {
let list = build::text_anim_props(ids, kind, three_d);
let props = match anim.sub_mut("properties") {
Some(p) => p,
None => {
let g = ids.group("properties", "Properties");
anim.children.push(g.into());
anim.sub_mut("properties").expect("just added")
}
};
if anim.sub("properties").is_none() {
let g = ids.group("properties", "Properties");
anim.children.push(g.into());
}
let Some(props) = anim.sub_mut("properties") else { return 0 };
let mut n = 0;
for pr in list {
if props.get(&pr.match_id).is_none() {
@ -161,14 +158,11 @@ pub(crate) fn add_selector(s: &mut Session, p: &Value) -> Result<Value> {
let mut ids = Ids(&mut next);
let l = layer_mut(proj, cid, lid)?;
let anim = l.props.find_group_mut(auid).ok_or_else(|| bad(C, "no such animator"))?;
let sels = match anim.sub_mut("selectors") {
Some(g) => g,
None => {
let g = ids.group("selectors", "Selectors");
anim.children.insert(0, g.into());
anim.sub_mut("selectors").expect("just added")
}
};
if anim.sub("selectors").is_none() {
let g = ids.group("selectors", "Selectors");
anim.children.insert(0, g.into());
}
let sels = anim.sub_mut("selectors").ok_or_else(|| bad(C, "the animator has no Selectors group"))?;
let (m, label) = match kind.as_str() {
"range" => ("rangeSelector", "Range Selector"),
"wiggly" => ("wigglySelector", "Wiggly Selector"),

View File

@ -455,8 +455,9 @@ pub(crate) fn sanitize(s: &mut Session) {
match layer_doc(s, e.layer) {
Some(d) => {
let n = d.char_len();
if e.anchor > n || e.caret > n {
let ed = s.state.text_edit.as_mut().expect("checked");
if (e.anchor > n || e.caret > n)
&& let Some(ed) = s.state.text_edit.as_mut()
{
ed.anchor = ed.anchor.min(n);
ed.caret = ed.caret.min(n);
}

View File

@ -71,6 +71,7 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
let t = s.time();
let to = str_p(p, "to").unwrap_or("start");
let sel_layer = s.state.selected_layers.first().and_then(|id| comp.layer(*id));
let visible = super::visible_p(p);
let target = match to {
"start" => Tick::ZERO,
"end" => comp.duration - comp.frame_duration(),
@ -78,8 +79,25 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
"workEnd" => comp.work_area.1 - comp.frame_duration(),
"layerIn" => sel_layer.map(|l| l.in_point).unwrap_or(t),
"layerOut" => sel_layer.map(|l| l.out_point - comp.frame_duration()).unwrap_or(t),
"nextKey" | "prevKey" if visible.is_some() => {
// What the Timeline shows: keys of its revealed properties, layer and comp markers
// and the work area (After Effects' "visible items").
let mut times: Vec<Tick> = comp.markers.iter().map(|m| m.time).collect();
times.extend([comp.work_area.0, comp.work_area.1 - comp.frame_duration()]);
for l in comp.layers.iter().filter(|l| !(comp.hide_shy && l.switches.shy)) {
times.extend(l.markers.iter().map(|m| l.comp_time(m.time)));
}
for (lid, uid) in visible.iter().flatten() {
if let Some(l) = comp.layer(*lid)
&& let Some(pr) = l.props.find(*uid)
{
times.extend(pr.keys.iter().map(|k| l.comp_time(k.time)));
}
}
step_to(times, t, comp.frame_duration(), to == "nextKey")
}
"nextKey" | "prevKey" => {
// Keyframes (of visible/selected layers) and markers, in comp time.
// Keyframes (of visible/selected layers), markers and the work area, in comp time.
let mut times: Vec<Tick> = comp.markers.iter().map(|m| m.time).collect();
let layers: Vec<_> = if s.state.selected_layers.is_empty() {
comp.layers.iter().collect()
@ -90,6 +108,9 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
let only = p.get("prop").and_then(Value::as_u64);
if only.is_some() {
times.clear();
} else {
// The work area's start and end too, as in After Effects.
times.extend([comp.work_area.0, comp.work_area.1 - comp.frame_duration()]);
}
for l in layers {
l.props.walk("", &mut |_, pr| {
@ -104,14 +125,7 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
times.extend(l.markers.iter().map(|m| l.comp_time(m.time)));
}
}
times.sort();
times.dedup();
let half = comp.frame_duration().0 / 2;
if to == "nextKey" {
times.into_iter().find(|x| x.0 > t.0 + half).unwrap_or(t)
} else {
times.into_iter().rev().find(|x| x.0 < t.0 - half).unwrap_or(t)
}
step_to(times, t, comp.frame_duration(), to == "nextKey")
}
_ => return Err(super::bad("time.go", "to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey")),
};
@ -119,6 +133,22 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
Ok(report(s))
}
/// The nearest time in `times` after (or before) `t`, more than half a frame away; `t` itself
/// when there is none.
fn step_to(mut times: Vec<Tick>, t: Tick, frame: Tick, next: bool) -> Tick {
times.sort();
times.dedup();
let half = frame.0 / 2;
if next { times.into_iter().find(|x| x.0 > t.0 + half).unwrap_or(t) } else { times.into_iter().rev().find(|x| x.0 < t.0 - half).unwrap_or(t) }
}
/// `p` with `to` set (J / K pass their other parameters on).
fn with_to(p: &Value, to: &str) -> Value {
let mut p = if p.is_object() { p.clone() } else { json!({}) };
p["to"] = json!(to);
p
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("time.set", "Go to Time...", ["View"], Some("Alt+Shift+J"), "{time? (s) | frame? | timecode?}", has_comp, set),
@ -131,8 +161,27 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("time.end", "Go to End", [], Some("End"), "{}", has_comp, |s, _| go(s, &json!({"to": "end"}))),
cmd!("time.layerIn", "Go to Layer In Point", [], Some("I"), "{}", has_comp, |s, _| go(s, &json!({"to": "layerIn"}))),
cmd!("time.layerOut", "Go to Layer Out Point", [], Some("O"), "{}", has_comp, |s, _| go(s, &json!({"to": "layerOut"}))),
cmd!("time.nextKey", "Go to Next Keyframe or Marker", [], Some("K"), "{}", has_comp, |s, _| go(s, &json!({"to": "nextKey"}))),
cmd!("time.previousKey", "Go to Previous Keyframe or Marker", [], Some("J"), "{}", has_comp, |s, _| go(s, &json!({"to": "prevKey"}))),
cmd!("time.go", "Go To", [], None, "{to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey, prop?: uid}", has_comp, go),
cmd!(
"time.nextKey",
"Go to Next Keyframe or Marker",
[],
Some("K"),
"{visible?: [{layer, prop}] (only these properties' keys, as the Timeline shows them)}",
has_comp,
|s, p| go(s, &with_to(p, "nextKey"))
),
cmd!("time.previousKey", "Go to Previous Keyframe or Marker", [], Some("J"), "{visible?: [{layer, prop}]}", has_comp, |s, p| go(
s,
&with_to(p, "prevKey")
)),
cmd!(
"time.go",
"Go To",
[],
None,
"{to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey, prop?: uid, visible?: [{layer, prop}]}",
has_comp,
go
),
]
}

View File

@ -214,7 +214,7 @@ fn create(s: &mut Session, p: &V, cmd: &str) -> Result<V> {
let mut ids = Ids(&mut next);
let g = tracking::tracker(&mut ids, &name, st, &pts);
let uid = g.uid;
l.motion_trackers_mut(&mut ids).children.push(g.into());
l.motion_trackers_mut(&mut ids).ok_or_else(|| bad(cmd, "the layer has no Motion Trackers group"))?.children.push(g.into());
proj.next_id = next;
state.current_track = Some((lid, uid));
state.selected_layers = vec![lid];

View File

@ -0,0 +1,65 @@
//! The last-resort guard: a panic that escapes a command (a bug, never an expected error) is
//! turned into an [`EngineError`] instead of taking the app, and the user's open project, down
//! with it. Edits work on a copy of the project (see [`crate::Session::edit`]), so an edit that
//! panics leaves the project as it was. This is a safety net, not a substitute for returning
//! errors (AGENTS.md, "Never crash").
use crate::{EngineError, Result};
/// Run `f`; a panic inside it becomes an error naming `what`. The panic itself is logged by
/// the panic hook ([`crate::logging::install_panic_hook`]).
pub fn guarded<T>(what: &str, f: impl FnOnce() -> Result<T>) -> Result<T> {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
Ok(r) => r,
Err(payload) => {
let msg = payload
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown error".into());
log::error!("{what} panicked: {msg}");
Err(EngineError::Other(format!("{what} failed with an internal error ({msg}); it was stopped and your project is still open")))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_panic_becomes_an_error() {
let r: Result<()> = guarded("command `test.panic`", || panic!("boom"));
let e = r.unwrap_err().to_string();
assert!(e.contains("test.panic") && e.contains("boom"), "{e}");
let r: Result<u8> = guarded("x", || {
let v: Vec<u8> = vec![];
Ok(v[3])
});
assert!(r.is_err());
assert_eq!(guarded("ok", || Ok(7)).unwrap(), 7);
assert!(matches!(guarded::<()>("err", || Err(EngineError::NoComp)), Err(EngineError::NoComp)));
}
#[test]
fn the_session_survives_a_panicking_edit() {
let mut s = crate::Session::new();
let before = s.project.clone();
let r: Result<()> = guarded("command `test.edit`", || {
s.edit("Broken", None, |p, _| {
p.next_id += 1;
panic!("bug in the middle of an edit")
})
});
assert!(r.is_err());
assert!(std::sync::Arc::ptr_eq(&before, &s.project), "the project is untouched");
// Later edits still work.
let next = s.project.next_id;
s.edit("Fine", None, |p, _| {
p.next_id += 1;
Ok(())
})
.unwrap();
assert_eq!(s.project.next_id, next + 1);
}
}

View File

@ -8,12 +8,15 @@
//! The project is an `Arc<Project>` edited copy-on-write; undo keeps whole-project snapshots
//! (compositions are `Arc`s, so untouched comps are shared).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod autosave;
pub mod camera_track;
pub mod commands;
pub mod config;
pub mod demo;
pub mod footage_check;
pub mod guard;
pub mod history;
pub mod jobs;
pub mod learn;
@ -131,6 +134,17 @@ pub struct KeyRef {
pub time: Tick,
}
/// A keyframe drag in progress (`keys.move` steps sharing a merge key): the selection it
/// started from and the total offset so far, so every step is applied to the keys as they were
/// before the drag. A key passed over on the way is never overwritten; only the drop position
/// counts, as in After Effects.
#[derive(Clone, Debug)]
pub struct KeyMove {
pub merge: String,
pub from: Vec<KeyRef>,
pub total: Tick,
}
/// Keyframes of one property on the keyframe clipboard.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct KeyClip {
@ -370,6 +384,10 @@ pub struct Session {
/// The last physical-memory reading (Settings ▸ Memory & CPU budgets); `None` = unknown
/// (headless sessions don't query it, see [`Session::memory_tick`]).
pub sys_memory: Option<sysinfo::SysMemory>,
/// The keyframe drag in progress (`keys.move` with a merge key).
pub key_move: Option<KeyMove>,
/// Reads the system's memory in the background for [`Session::memory_tick`].
pub memory_watch: sysinfo::MemoryWatch,
/// Switches Affect Nested Comps as last applied (a change empties the layer cache).
pub applied_nested_switches: Option<bool>,
/// Runs renders and analyses off the UI thread where there are no threads (the web app's
@ -462,6 +480,8 @@ impl Default for Session {
plugin_loader: None,
script_ui: scriptui::ScriptUi::default(),
sys_memory: None,
key_move: None,
memory_watch: Default::default(),
applied_nested_switches: None,
offload: None,
offloaded: vec![],
@ -495,7 +515,8 @@ impl Session {
return Err(EngineError::Disabled(id.to_string(), why));
}
}
let r = (spec.run)(self, &params)?;
// Last-resort guard: a panicking command reports an error instead of crashing the app.
let r = guard::guarded(&format!("command `{id}`"), || (spec.run)(self, &params))?;
self.learn_observe(id, &params);
if spec.journal {
self.journal.push((id.to_string(), params));
@ -631,6 +652,13 @@ impl Session {
self.saved_revision = self.revision;
}
/// The project has changes its file doesn't (a session restored with unsaved changes): it
/// stays modified until it is saved, whatever is undone.
pub fn mark_unsaved(&mut self) {
self.saved_project = Arc::new(Project::default());
self.bump();
}
pub fn active_comp(&self) -> Option<&Comp> {
self.project.comp(self.state.active_comp?)
}
@ -926,6 +954,8 @@ mod tests_fidelity;
#[cfg(test)]
mod tests_frame_export;
#[cfg(test)]
mod tests_keylight;
#[cfg(test)]
mod tests_lottie;
#[cfg(test)]
mod tests_m137;
@ -940,6 +970,8 @@ mod tests_menu_cmds;
#[cfg(test)]
mod tests_model3d;
#[cfg(test)]
mod tests_motion_blur_depth;
#[cfg(test)]
mod tests_project_items;
#[cfg(test)]
mod tests_proxy;

View File

@ -12,7 +12,7 @@ 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"),
("DrawCraft", "drawcraft"),
("VectorCraft", "vectorcraft"),
("FilmCraft", "filmcraft"),
("LightCraft", "lightcraft"),
("PrintCraft", "printcraft"),

View File

@ -62,6 +62,16 @@ pub fn install() {
}
}
/// Log every panic (message and location) through the logger, then run the previous hook
/// (which prints it). Panics in commands are then caught by [`crate::guard::guarded`].
pub fn install_panic_hook() {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
log::error!("panic: {info}");
previous(info);
}));
}
/// The log 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")

View File

@ -439,8 +439,7 @@ impl Session {
}
self.bump();
}
if finished {
let mut job = self.mask_job.take().expect("job");
if finished && let Some(mut job) = self.mask_job.take() {
if let Some(t) = job.thread.take() {
let _ = t.join();
}

View File

@ -196,7 +196,13 @@ pub fn top_level() -> Vec<&'static str> {
/// The menu bar for this platform.
pub fn menu_bar() -> &'static [MenuNode] {
static BAR: OnceLock<Vec<MenuNode>> = OnceLock::new();
BAR.get_or_init(|| parse(TREE, cfg!(target_os = "macos")).expect("menu tree parses"))
BAR.get_or_init(|| {
// The tree is a constant (a test parses it on every platform); never panic over it.
parse(TREE, cfg!(target_os = "macos")).unwrap_or_else(|e| {
log::error!("menu tree: {e}");
Vec::new()
})
})
}
/// Every entry with its submenu path (`["Layer", "Blending Mode"]`), depth first.

View File

@ -3,6 +3,10 @@
//! frame). The UI-side numbers (first UI frame, timeline and Project panel draw) live in
//! `effectcraft-ui-egui`'s `bench` module. See `docs/architecture.md` ▸ Performance.
// A benchmark harness (`effectcraft-cli bench --ops`, tests): a failed step should fail loudly,
// as in tests and benches (AGENTS.md, "Never crash"). Nothing here runs in the app.
#![allow(clippy::expect_used)]
use std::sync::Arc;
use effectcraft_keyframe::Value as KValue;

View File

@ -513,8 +513,8 @@ impl Session {
off.cancel(r.id);
}
self.drain_remote_render();
let j = self.render_job.as_ref().expect("job");
let r = j.remote.as_ref().expect("remote");
let Some(j) = self.render_job.as_ref() else { return true };
let Some(r) = j.remote.as_ref() else { return true };
let finished: Vec<u64> =
lock(&j.shared.updates).iter().filter_map(|u| if let ItemUpdate::Finished { id, .. } = u { Some(*id) } else { None }).collect();
for id in &r.ids {
@ -570,8 +570,7 @@ impl Session {
}
self.bump();
}
if finished {
let mut job = self.render_job.take().expect("job");
if finished && let Some(mut job) = self.render_job.take() {
if let Some(t) = job.thread.take() {
let _ = t.join();
}

View File

@ -587,7 +587,7 @@ impl Session {
if !job.is_finished() {
return false;
}
let mut job = self.roto_job.take().expect("job");
let Some(mut job) = self.roto_job.take() else { return false };
if let Some(t) = job.thread.take() {
let _ = t.join();
}

View File

@ -75,11 +75,13 @@ impl Session {
self.footage.set_cache_budget(b.media);
}
/// Re-read the system's memory (desktop frontends call this every few seconds) and re-apply
/// the cache budgets when the reading changes them. Returns the effective budgets.
/// Take the latest reading of the system's memory and start the next one in the background
/// (desktop frontends call this every few seconds; it never waits for the system), then
/// re-apply the cache budgets when the reading changes them. Returns the effective budgets.
pub fn memory_tick(&mut self) -> crate::prefs::CacheBudgets {
let before = self.prefs.cache_budgets(self.sys_memory);
self.sys_memory = crate::sysinfo::memory().or(self.sys_memory);
self.sys_memory = self.memory_watch.latest().or(self.sys_memory);
self.memory_watch.poll();
let after = self.prefs.cache_budgets(self.sys_memory);
if after != before {
self.apply_cache_budgets();

View File

@ -4,7 +4,11 @@
//! Pure Rust and dependency-free: the values come from the operating system's own reporting
//! (`/proc/meminfo` on Linux, `sysctl`/`vm_stat` on macOS, `wmic`/PowerShell on Windows). Every
//! query may fail (sandboxes, the web): callers treat `None` as "unknown" and keep their
//! configured budgets.
//! configured budgets. The periodic reading behind the cache budgets runs on a background
//! thread ([`MemoryWatch`]): on Windows it starts PowerShell, which takes about a second.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use serde::Serialize;
@ -23,10 +27,51 @@ impl SysMemory {
}
}
/// The system's memory, read in the background: [`MemoryWatch::poll`] starts a reading (unless
/// one is running) and [`MemoryWatch::latest`] returns the last one without waiting.
#[derive(Clone, Default)]
pub struct MemoryWatch {
latest: Arc<Mutex<Option<SysMemory>>>,
busy: Arc<AtomicBool>,
}
impl MemoryWatch {
/// Start a new reading in the background (no threads on the web: read inline, it is
/// unknown there anyway).
pub fn poll(&self) {
if self.busy.swap(true, Ordering::AcqRel) {
return;
}
let (latest, busy) = (self.latest.clone(), self.busy.clone());
let read = move || {
if let Some(m) = memory() {
*latest.lock().unwrap_or_else(PoisonError::into_inner) = Some(m);
}
busy.store(false, Ordering::Release);
};
#[cfg(not(target_arch = "wasm32"))]
if std::thread::Builder::new().name("ec-memory".into()).spawn(read).is_err() {
self.busy.store(false, Ordering::Release);
}
#[cfg(target_arch = "wasm32")]
read();
}
/// The last reading (`None` until one finished).
pub fn latest(&self) -> Option<SysMemory> {
*self.latest.lock().unwrap_or_else(PoisonError::into_inner)
}
}
#[cfg(not(target_arch = "wasm32"))]
#[allow(dead_code)]
fn run(cmd: &str, args: &[&str]) -> Option<String> {
let out = std::process::Command::new(cmd).args(args).output().ok()?;
let mut c = std::process::Command::new(cmd);
c.args(args);
// A console program started from the windowed app would flash a console window.
#[cfg(target_os = "windows")]
std::os::windows::process::CommandExt::creation_flags(&mut c, 0x0800_0000); // CREATE_NO_WINDOW
let out = c.output().ok()?;
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).to_string())
}

View File

@ -301,6 +301,17 @@ fn comp_settings_anchor_start_timecode_renderer() {
assert_eq!(c.renderer, crate::project::Renderer::Advanced3D);
}
#[test]
fn comp_rejects_zero_and_negative_frame_rates() {
let mut s = Session::default();
for fps in [0.0, -30.0, 0.0001] {
assert!(s.execute("comp.new", json!({"frameRate": fps})).is_err(), "{fps}");
}
s.execute("comp.new", json!({"frameRate": 30, "duration": 1})).unwrap();
assert!(s.execute("comp.settings", json!({"frameRate": 0})).is_err());
assert_eq!(s.active_comp().unwrap().frame_rate, crate::time::FrameRate::FPS_30);
}
#[test]
fn render_queue_add_accepts_documented_settings_when_checked() {
let mut s = demo();

View File

@ -185,7 +185,7 @@ fn opening_an_old_project_upgrades_effect_instances() {
{
let comp = std::sync::Arc::make_mut(match &mut old.items.get_mut(&cid).unwrap().kind {
effectcraft_project::ItemKind::Comp(c) => c,
_ => unreachable!(),
_ => panic!("not a composition"),
});
let l = comp.layers.iter_mut().find(|l| l.id == LayerId(a)).unwrap();
for n in &mut l.props.sub_mut("effects").unwrap().children {

View File

@ -175,3 +175,18 @@ fn modified_mark_follows_undo_and_no_op_edits_record_nothing() {
assert!(!s.is_dirty(), "rolled back");
let _ = std::fs::remove_file(path);
}
#[test]
fn a_session_restored_with_unsaved_changes_stays_modified() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "H", "width": 64, "height": 64, "duration": 2})).unwrap();
let p = (*s.project).clone();
// The web app reopens the last visit's project, which had unsaved changes.
s.replace_project(p, None);
assert!(!s.is_dirty());
s.mark_unsaved();
assert!(s.is_dirty());
solid(&mut s, "A");
s.execute("edit.undo", json!({})).unwrap();
assert!(s.is_dirty(), "undo doesn't reach a saved state");
}

View File

@ -0,0 +1,135 @@
//! Key Light end to end: a green-screen plate (uneven screen, a subject with green spill and a
//! soft edge) keyed through the commands an agent uses: `effect.apply` by its After Effects name,
//! `effect.pickColor` on the screen (the effect's input, as the eyedropper does) and a render.
use std::sync::Arc;
use effectcraft_project::{Footage, FootageKind, ItemId};
use effectcraft_raster::Image;
use effectcraft_time::{FrameRate, Tick};
use serde_json::json;
use crate::Session;
use crate::render::{FootageSource, RenderOpts};
const W: u32 = 96;
const H: u32 = 64;
const SKIN: [f32; 3] = [0.86, 0.62, 0.52];
/// The plate's straight colour and alpha (opaque everywhere) at a pixel.
fn plate(x: u32, y: u32) -> [f32; 3] {
// Screen: a slightly uneven, noisy green.
let noise = (((x * 7919 + y * 104_729) % 97) as f32 / 97.0 - 0.5) * 0.02;
let screen = [0.18 + noise, 0.66 + (x as f32 / W as f32 - 0.5) * 0.04 + noise, 0.30 - noise];
let r = ((x as f32 + 0.5 - 48.0).powi(2) + (y as f32 + 0.5 - 32.0).powi(2)).sqrt();
// Subject: skin, with green spill in its outer 3 px and a 3 px soft edge.
let mut subject = SKIN;
if r > 13.0 {
subject[1] += 0.12;
}
let a = ((19.0 - r) / 3.0).clamp(0.0, 1.0);
[0, 1, 2].map(|i| subject[i] * a + screen[i] * (1.0 - a))
}
struct Plate;
impl FootageSource for Plate {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
let mut img = Image::new(W, H);
for y in 0..H {
for x in 0..W {
let c = plate(x, y);
img.set(x, y, [c[0], c[1], c[2], 1.0]);
}
}
Some(Arc::new(img))
}
}
struct Probe;
impl crate::Importer for Probe {
fn probe(&self, path: &str) -> Result<Footage, String> {
Ok(Footage {
path: path.into(),
kind: FootageKind::Still,
width: W,
height: H,
has_video: true,
frame_rate: FrameRate::from_f64(24.0),
duration: Tick::ZERO,
..Default::default()
})
}
}
fn keyed() -> (Session, ItemId) {
let mut s = Session { footage: Arc::new(Plate), importer: Some(Arc::new(Probe)), ..Default::default() };
let item = s.execute("file.import", json!({"paths": ["/plates/greenscreen.png"]})).unwrap()["items"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name": "Key", "width": W, "height": H, "frameRate": 24, "duration": 1})).unwrap();
let c = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": item})).unwrap();
// After Effects' name finds it; MCP's add_effect / list_effects go through the same lookup.
let r = s.execute_checked("effect.apply", json!({"effect": "Keylight (1.2)"})).unwrap();
assert_eq!(r["effect"], "ec.keying.keylight");
(s, c)
}
#[test]
fn key_light_removes_a_green_screen() {
let (mut s, c) = keyed();
let steps = s.history.undo.len();
// The default screen colour (pure green) already keys the screen, so the shown frame is
// transparent there; the eyedropper samples the effect's input instead.
let picked = s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour", "x": 6, "y": 6, "average": true})).unwrap();
let col: Vec<f64> = picked["color"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
let want = plate(6, 6);
assert!((0..3).all(|i| (col[i] - want[i] as f64).abs() < 0.02), "{col:?} vs {want:?}");
let tree = s.execute("layer.tree", json!({})).unwrap().to_string();
assert!(tree.contains("Screen Colour"), "{tree}");
// One undo step.
assert_eq!(s.history.undo.len(), steps + 1);
// Clean the matte as one does in Screen Matte view: Clip Black for the uneven screen, Clip
// White for the spill's partial transparency.
for (path, v) in [("effects/#1/screenMatte/clipBlack", 10), ("effects/#1/screenMatte/clipWhite", 85)] {
s.execute_checked("prop.set", json!({"path": path, "value": v})).unwrap();
}
let img = s.render(c, Tick::ZERO, RenderOpts::default());
let px = |x, y| img.get(x, y);
// The whole screen goes (corners, both ends of the gradient, noise).
for (x, y) in [(1, 1), (94, 1), (1, 62), (94, 62), (20, 32), (76, 32)] {
assert!(px(x, y)[3] < 0.03, "screen at {x},{y}: {:?}", px(x, y));
}
// The subject stays, with its colour.
let fg = px(48, 32);
assert!(fg[3] > 0.99 && (0..3).all(|i| (fg[i] - SKIN[i]).abs() < 0.02), "{fg:?}");
// Spill on the subject's edge is suppressed: no green cast left.
let sp = px(48, 32 - 14);
assert!(sp[3] > 0.99 && sp[1] <= 0.5 * (sp[0] + sp[2]) + 0.01, "spill {sp:?}");
// The soft edge keeps partial alpha, and its recovered colour has no green fringe.
let e = px(48, 32 - 18);
assert!(e[3] > 0.15 && e[3] < 0.85, "edge {e:?}");
let a = e[3];
assert!(e[1] / a <= 0.5 * (e[0] + e[2]) / a + 0.02, "edge fringe {e:?}");
}
#[test]
fn pick_color_rejects_bad_targets_and_screen_matte_view_shows_the_key() {
let (mut s, c) = keyed();
// Not a colour parameter / outside the layer.
assert!(s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenGain", "x": 6, "y": 6})).is_err());
assert!(s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour", "x": -50, "y": 6})).is_err());
assert!(s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour", "x": 1e300, "y": -1e300, "average": true})).is_err());
assert!(s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour"})).is_err());
s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour", "x": 90, "y": 60})).unwrap();
// View ▸ Screen Matte: black screen (once Clip Black takes the uneven screen), white subject.
for (path, v) in [("effects/#1/view", 4), ("effects/#1/screenMatte/clipBlack", 25)] {
s.execute_checked("prop.set", json!({"path": path, "value": v})).unwrap();
}
let img = s.render(c, Tick::ZERO, RenderOpts::default());
assert!(img.get(2, 2)[0] < 0.03 && img.get(48, 32)[0] > 0.97, "{:?} {:?}", img.get(2, 2), img.get(48, 32));
// The Effects & Presets / list_effects search finds it by After Effects' name too.
let list = s.execute("effect.list", json!({"filter": "Keylight (1.2)"})).unwrap();
assert_eq!(list.as_array().map(|a| a.len()), Some(1), "{list}");
assert_eq!(list[0]["id"], "ec.keying.keylight");
}

View File

@ -811,3 +811,246 @@ fn project_files_keep_their_format_warn_about_newer_versions_and_never_save_unre
let e = s.execute("file.open", json!({"path": junk})).unwrap_err().to_string();
assert!(e.contains("is not a project EffectCraft can open"), "{e}");
}
#[test]
fn comp_settings_preserve_frame_rate_and_resolution() {
let mut s = comp();
let info = s.execute("comp.info", json!({})).unwrap();
assert_eq!((info["preserveFrameRate"].clone(), info["preserveResolution"].clone()), (json!(false), json!(false)));
s.execute_checked("comp.settings", json!({"preserveFrameRate": true, "preserveResolution": true})).unwrap();
let info = s.execute("comp.info", json!({})).unwrap();
assert_eq!((info["preserveFrameRate"].clone(), info["preserveResolution"].clone()), (json!(true), json!(true)));
// Pre-compose keeps them; one undo step clears them.
let a = solid(&mut s, "#ff0000");
let r = s.execute("layer.precompose", json!({"layers": [a], "name": "Inner"})).unwrap();
let inner = s.project.comp(effectcraft_project::ItemId(r["comp"].as_u64().unwrap())).unwrap();
assert!(inner.preserve_frame_rate && inner.preserve_resolution);
// Undo Pre-compose and the solid; then one more step undoes the settings.
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_comp().unwrap().preserve_frame_rate);
s.execute("edit.undo", json!({})).unwrap();
assert!(!s.active_comp().unwrap().preserve_frame_rate && !s.active_comp().unwrap().preserve_resolution);
}
#[test]
fn new_comp_from_selection_survives_hostile_sequence_numbers() {
let mut s = Session::default();
let still = |s: &mut Session, name: &str| {
let f = effectcraft_project::Footage {
kind: effectcraft_project::FootageKind::Still,
width: 64,
height: 64,
pixel_aspect: 1.0,
has_video: true,
..Default::default()
};
std::sync::Arc::make_mut(&mut s.project).add_item(name, effectcraft_color::Label::None, None, ItemKind::Footage(f))
};
let a = still(&mut s, "a.png");
let b = still(&mut s, "b.png");
let c = still(&mut s, "c.png");
for overlap in [json!(1e30), json!(-5), json!(f64::MAX)] {
s.state.project_selection = vec![a, b, c];
// A huge overlap overflowed the duration arithmetic (a panic with overflow checks, a
// wrapped, too long comp without them).
let r = s
.execute("file.newCompFromSelection", json!({"single": true, "sequence": true, "overlap": true, "overlapDuration": overlap, "duration": 2}))
.unwrap();
let c = s.project.comp(effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap())).unwrap();
assert!(c.duration.seconds() <= 6.0 && c.duration > effectcraft_time::Tick::ZERO, "{overlap}: {}", c.duration.seconds());
}
s.state.project_selection = vec![a, b];
assert!(s.execute("file.newCompFromSelection", json!({"single": true, "dimensionsFrom": 99})).is_err());
}
fn key_times(s: &Session, l: u64) -> Vec<f64> {
layer(s, l).props.prop("transform/opacity").unwrap().keys.iter().map(|k| (k.time.seconds() * 30.0).round() / 30.0).collect()
}
fn opacity_keys(s: &mut Session, l: u64, times: &[f64]) {
for (i, t) in times.iter().enumerate() {
s.execute("prop.addKey", json!({"layer": l, "path": "transform/opacity", "time": t, "value": i as f64 * 10.0})).unwrap();
}
}
#[test]
fn dragging_a_key_past_another_keeps_it() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
opacity_keys(&mut s, a, &[1.0, 2.0]);
s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap();
// One drag, frame by frame, from 1 s over the 2 s key to 3 s.
for _ in 0..60 {
s.execute("keys.move", json!({"delta": 1.0 / 30.0, "merge": "key-drag"})).unwrap();
}
assert_eq!(key_times(&s, a), vec![2.0, 3.0], "the 2 s key passed over is still there");
// Dropped exactly on a key: it replaces it, as in After Effects.
s.history.merge_key = None;
s.execute("keys.move", json!({"delta": -1.0, "merge": "key-drag-2"})).unwrap();
assert_eq!(key_times(&s, a), vec![2.0]);
// The whole drag was one undo step.
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(key_times(&s, a), vec![1.0, 2.0]);
}
#[test]
fn keys_of_a_stretched_layer_move_with_the_pointer() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
opacity_keys(&mut s, a, &[0.0, 1.0]);
s.execute_checked("layer.timeStretch", json!({"layers": [a], "percent": 200})).unwrap();
assert_eq!(layer(&s, a).stretch, 200.0);
let comp_time = |s: &Session, i: usize| {
let l = layer(s, a);
l.comp_time(l.props.prop("transform/opacity").unwrap().keys[i].time).seconds()
};
let before = comp_time(&s, 1);
s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap();
s.execute("keys.move", json!({"delta": 0.5})).unwrap();
assert!((comp_time(&s, 1) - (before + 0.5)).abs() < 1.0 / 30.0, "{} → {}", before, comp_time(&s, 1));
s.execute("keys.nudge", json!({"frames": 3})).unwrap();
assert!((comp_time(&s, 1) - (before + 0.6)).abs() < 1.0 / 60.0, "three comp frames");
}
#[test]
fn key_selection_toggles_and_skips_locked_layers_and_hold_restores_bezier() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
opacity_keys(&mut s, a, &[0.0, 1.0]);
let k = |t: f64| json!({"layer": a, "path": "transform/opacity", "time": t});
s.execute("keys.select", json!({"keys": [k(0.0)]})).unwrap();
s.execute("keys.select", json!({"keys": [k(1.0)], "toggle": true})).unwrap();
assert_eq!(s.state.selected_keys.len(), 2);
s.execute("keys.select", json!({"keys": [k(0.0)], "toggle": true})).unwrap();
assert_eq!(s.state.selected_keys.len(), 1, "toggled out");
// Hold off returns an eased key to Bezier.
s.execute("keys.easyEase", json!({})).unwrap();
s.execute("keys.toggleHold", json!({})).unwrap();
s.execute("keys.toggleHold", json!({})).unwrap();
let kf = layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].clone();
assert_eq!(kf.out_interp, effectcraft_keyframe::Interp::Bezier);
// Locked: its keys can't be selected.
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "lock", "value": true})).unwrap();
s.execute("keys.select", json!({"keys": [k(0.0)]})).unwrap();
assert!(s.state.selected_keys.is_empty());
s.execute("keys.selectAll", json!({"layers": [a]})).unwrap();
assert!(s.state.selected_keys.is_empty());
}
#[test]
fn transform_drags_apply_their_whole_offset_to_the_keys_they_started_with() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
opacity_keys(&mut s, a, &[0.0, 1.0, 2.0]);
s.execute("keys.selectAll", json!({"layers": [a]})).unwrap();
// Squeezed onto one frame on the way, then back: the three keys survive.
for k in [0.5, 0.01, 0.0001, 1.0] {
s.execute("keys.transform", json!({"timeScale": k, "timeAnchor": 0.0, "merge": "box", "fromStart": true})).unwrap();
}
assert_eq!(key_times(&s, a), vec![0.0, 1.0, 2.0]);
// A slow move in tiny steps still arrives (each step is the whole offset).
s.history.merge_key = None;
for i in 1..=30 {
s.execute("keys.transform", json!({"timeOffset": i as f64 * 0.1 / 30.0, "merge": "move", "fromStart": true})).unwrap();
}
assert_eq!(key_times(&s, a), vec![0.1, 1.1, 2.1]);
}
#[test]
fn select_equal_previous_and_following_keyframes() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
for (t, v) in [(0.0, 20.0), (1.0, 50.0), (2.0, 20.0), (3.0, 70.0)] {
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap();
}
let pick = |s: &mut Session, t: f64| s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": t}]})).unwrap();
let times = |s: &Session| {
let mut v: Vec<f64> = s.state.selected_keys.iter().map(|k| k.time.seconds()).collect();
v.sort_by(f64::total_cmp);
v
};
pick(&mut s, 0.0);
s.execute("keys.selectEqual", json!({})).unwrap();
assert_eq!(times(&s), vec![0.0, 2.0]);
pick(&mut s, 2.0);
s.execute("keys.selectPrevious", json!({})).unwrap();
assert_eq!(times(&s), vec![0.0, 1.0, 2.0]);
pick(&mut s, 1.0);
s.execute("keys.selectFollowing", json!({})).unwrap();
assert_eq!(times(&s), vec![1.0, 2.0, 3.0]);
}
#[test]
fn keys_from_several_layers_paste_in_order_and_paste_reversed_respects_stretch() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
let b = solid(&mut s, "#00ff00");
opacity_keys(&mut s, a, &[0.0, 1.0]);
s.execute("prop.addKey", json!({"layer": b, "path": "transform/opacity", "time": 0.0, "value": 77})).unwrap();
let c = solid(&mut s, "#0000ff");
let d = solid(&mut s, "#ffffff");
// Copy A's and B's Opacity keys, paste onto C and D: A → C, B → D (in order).
s.execute("keys.selectAll", json!({"layers": [a, b]})).unwrap();
s.execute("keys.copy", json!({})).unwrap();
s.execute("time.set", json!({"time": 2.0})).unwrap();
s.execute("layer.select", json!({"layers": [c, d]})).unwrap();
s.execute("keys.paste", json!({})).unwrap();
assert_eq!(key_times(&s, c), vec![2.0, 3.0], "A's two keys");
assert_eq!(key_times(&s, d), vec![2.0], "B's one key");
// Paste Reversed onto a layer stretched to 200 %: the reversed keys land 2 s apart there too.
let e = solid(&mut s, "#808080");
s.execute_checked("layer.timeStretch", json!({"layers": [e], "percent": 200})).unwrap();
s.execute("keys.selectAll", json!({"layers": [a]})).unwrap();
s.execute("keys.copy", json!({})).unwrap();
s.execute("time.set", json!({"time": 0.0})).unwrap();
s.execute("layer.select", json!({"layers": [e]})).unwrap();
s.execute("edit.pasteReversedKeyframes", json!({})).unwrap();
let pr = layer(&s, e).props.prop("transform/opacity").unwrap().clone();
let l = layer(&s, e);
let ct: Vec<f64> = pr.keys.iter().map(|k| l.comp_time(k.time).seconds()).collect();
let vals: Vec<f64> = pr.keys.iter().map(|k| k.value.as_f64()).collect();
assert!(ct.len() == 2 && (ct[1] - ct[0] - 1.0).abs() < 1e-6, "keys 1 s apart in comp time: {ct:?}");
assert_eq!(vals, vec![10.0, 0.0], "reversed");
}
#[test]
fn j_and_k_stop_at_keys_markers_and_the_work_area() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
opacity_keys(&mut s, a, &[2.0]);
s.execute("comp.workArea", json!({"start": 1.0, "end": 3.0})).unwrap();
s.execute("layer.select", json!({"layers": [a]})).unwrap();
let mut stops = vec![];
for _ in 0..4 {
s.execute("time.nextKey", json!({})).unwrap();
stops.push((s.time().seconds() * 30.0).round() / 30.0);
}
let last = (3.0 * 30.0 - 1.0) / 30.0;
assert_eq!(stops, vec![1.0, 2.0, (last * 30.0f64).round() / 30.0, (last * 30.0f64).round() / 30.0]);
}
#[test]
fn j_k_and_select_all_use_only_the_properties_the_timeline_shows() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
opacity_keys(&mut s, a, &[1.0]);
s.execute("prop.addKey", json!({"layer": a, "path": "transform/rotation", "time": 2.0, "value": 45})).unwrap();
let op = layer(&s, a).props.prop("transform/opacity").unwrap().uid;
// Only Opacity is revealed: K skips the hidden Rotation key at 2 s, stopping at 1 s and then
// at the work area's end.
let visible = json!([{"layer": a, "prop": op}]);
s.execute_checked("time.nextKey", json!({"visible": visible})).unwrap();
assert_eq!(s.time().seconds(), 1.0);
s.execute_checked("time.nextKey", json!({"visible": visible})).unwrap();
assert!(s.time().seconds() > 3.9, "work area end, not the hidden key: {}", s.time().seconds());
// Without `visible` (agents), every key of the layers counts, as before.
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("time.nextKey", json!({})).unwrap();
assert_eq!(s.time().seconds(), 2.0);
// Select All Keyframes: the shown properties' keys only.
s.execute_checked("keys.selectAll", json!({"visible": visible})).unwrap();
assert_eq!(s.state.selected_keys.len(), 1);
assert_eq!(s.state.selected_keys[0].prop, op);
}

View File

@ -0,0 +1,122 @@
//! Motion blur of collapsed precomps (the precomp layer's own motion; the containing comp's
//! shutter) and of animated shape content, rendered through the commands.
use effectcraft_raster::Image;
use effectcraft_time::Tick;
use serde_json::{Value, json};
use crate::Session;
use crate::render::RenderOpts;
const W: u32 = 160;
const H: u32 = 90;
fn id(v: &Value, k: &str) -> u64 {
v[k].as_u64().unwrap()
}
fn render(s: &Session, comp: u64) -> Image {
s.render(effectcraft_project::ItemId(comp), Tick::from_seconds_f64(0.5), RenderOpts::default())
}
/// Pixels with partial alpha (motion-blur smear).
fn partial(img: &Image) -> usize {
img.data.iter().filter(|p| p[3] > 0.05 && p[3] < 0.95).count()
}
fn max_diff(a: &Image, b: &Image) -> f32 {
a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..4).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max)
}
/// "Main" holding "Pre" (a 20×20 white square in the middle), the precomp layer moving 480 px/s
/// (20 px a frame at 24 fps) with motion blur on. `inner_move`: the square moves inside Pre too
/// (with its own motion blur switch), and Pre's shutter is closed (0°).
fn scene(collapse: bool, inner_move: bool) -> (Session, u64) {
let mut s = Session::default();
let pre = id(&s.execute("comp.new", json!({"name": "Pre", "width": W, "height": H, "frameRate": 24, "duration": 2})).unwrap(), "comp");
let sq = id(&s.execute("layer.newSolid", json!({"name": "Square", "color": "#ffffff", "width": 20, "height": 20})).unwrap(), "layer");
if inner_move {
s.execute("comp.settings", json!({"shutterAngle": 0})).unwrap();
s.execute("comp.setSwitch", json!({"switch": "motionBlur", "value": true})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [sq], "switch": "motionBlur", "value": true})).unwrap();
for (t, x) in [(0.0, -160.0), (1.0, 320.0)] {
s.execute("prop.addKey", json!({"layer": sq, "path": "transform/position", "time": t, "value": [x, 45.0]})).unwrap();
}
}
let main = id(&s.execute("comp.new", json!({"name": "Main", "width": W, "height": H, "frameRate": 24, "duration": 2})).unwrap(), "comp");
s.execute("comp.setSwitch", json!({"switch": "motionBlur", "value": true})).unwrap();
let l = id(&s.execute("layer.addItem", json!({"item": pre})).unwrap(), "layer");
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "motionBlur", "value": true})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "collapse", "value": collapse})).unwrap();
if !inner_move {
for (t, x) in [(0.0, -160.0), (1.0, 320.0)] {
s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": t, "value": [x, 45.0]})).unwrap();
}
}
(s, main)
}
#[test]
fn a_collapsed_precomp_layer_blurs_with_its_own_motion() {
let (s, main) = scene(true, false);
let collapsed = render(&s, main);
// The square sits at x = 80 at 0.5 s and smears over 10 px (180° of 20 px).
assert!(partial(&collapsed) > 100, "smeared: {}", partial(&collapsed));
// As blurred as the same precomp rendered flat.
let (s, main) = scene(false, false);
let flat = render(&s, main);
assert!(max_diff(&collapsed, &flat) < 0.08, "{}", max_diff(&collapsed, &flat));
// Motion blur off for the render: sharp.
let sharp = s.render(effectcraft_project::ItemId(main), Tick::from_seconds_f64(0.5), RenderOpts { motion_blur: false, ..Default::default() });
assert!(partial(&sharp) < 10, "{}", partial(&sharp));
}
#[test]
fn collapsed_layers_use_the_containing_comps_shutter() {
// Pre's own shutter is closed; drawn into Main (180°) the square's own motion blurs.
let (s, main) = scene(true, true);
assert!(partial(&render(&s, main)) > 100, "{}", partial(&render(&s, main)));
// Rendered flat, Pre uses its own (closed) shutter.
let (s, main) = scene(false, true);
assert!(partial(&render(&s, main)) < 10, "{}", partial(&render(&s, main)));
}
/// A shape layer whose rectangle moves inside its contents (not its transform): motion blur
/// smears it like the same move made with the layer's Position.
#[test]
fn animated_shape_content_is_motion_blurred() {
let shape = |content: bool, blur: bool| {
let mut s = Session::default();
let c = id(&s.execute("comp.new", json!({"name": "S", "width": W, "height": H, "frameRate": 24, "duration": 2})).unwrap(), "comp");
s.execute("comp.setSwitch", json!({"switch": "motionBlur", "value": true})).unwrap();
let l = id(&s.execute("layer.newShape", json!({"kind": "rect", "size": [20, 20], "fill": "#ffffff", "position": [80, 45]})).unwrap(), "layer");
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "motionBlur", "value": blur})).unwrap();
let (target, base) = if content {
let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap().clone();
let mut found = None;
layer.props.group("contents").unwrap().walk("contents", &mut |_, p| {
if found.is_none() && p.match_id == "position" {
found = Some((json!({"prop": p.uid}), p.value.as_vec2()));
}
});
found.unwrap()
} else {
(json!({"path": "transform/position"}), [80.0, 45.0])
};
for (t, dx) in [(0.0, -240.0), (1.0, 240.0)] {
let mut p = target.clone();
p["layer"] = json!(l);
p["time"] = json!(t);
p["value"] = json!([base[0] + dx, base[1]]);
s.execute("prop.addKey", p).unwrap();
}
render(&s, c)
};
let content = shape(true, true);
assert!(partial(&content) > 100, "smeared: {}", partial(&content));
// Like the layer moved by its transform…
let moved = shape(false, true);
assert!(max_diff(&content, &moved) < 0.15, "{}", max_diff(&content, &moved));
// …and sharp with the layer's motion blur switch off.
assert!(partial(&shape(true, false)) < 40, "{}", partial(&shape(true, false)));
}

View File

@ -182,7 +182,7 @@ fn interpret_proxy_settings_apply_to_the_proxy() {
assert!(px.footage.invert_alpha);
let f = match &s.project.item(full).unwrap().kind {
ItemKind::Footage(f) => f.clone(),
_ => unreachable!(),
_ => panic!("not footage"),
};
assert_eq!(f.alpha, AlphaMode::Straight, "the main footage keeps its interpretation");
}
@ -243,7 +243,7 @@ fn guess_alpha_invert_and_linear_light() {
let h = import(&mut s, "/m/half.png");
let foot = |s: &Session, i: ItemId| match &s.project.item(i).unwrap().kind {
ItemKind::Footage(f) => f.clone(),
_ => unreachable!(),
_ => panic!("not footage"),
};
s.execute("file.interpretFootage", json!({"items": [b.0, w.0, h.0], "alpha": "guess"})).unwrap();
assert_eq!(foot(&s, b).alpha, AlphaMode::Premultiplied);

View File

@ -464,3 +464,25 @@ fn open_recent_increment_and_revert() {
assert_eq!(autosave::increment_path("/x/v1.ecproj", |p| p.ends_with("v1 2.ecproj")), "/x/v1 3.ecproj");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn memory_tick_never_waits_for_the_system() {
let mut s = Session::default();
let t0 = std::time::Instant::now();
s.memory_tick();
s.memory_tick();
// Reading the system's memory starts PowerShell on Windows (about a second): the UI thread
// only takes the last reading.
assert!(t0.elapsed() < std::time::Duration::from_millis(200), "{:?}", t0.elapsed());
if crate::sysinfo::memory().is_some() {
let mut got = None;
for _ in 0..200 {
got = s.memory_watch.latest();
if got.is_some() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
assert!(got.is_some_and(|m| m.total > 0), "the reading arrives in the background");
}
}

View File

@ -407,8 +407,7 @@ impl Session {
}
self.bump();
}
if finished {
let mut job = self.track_job.take().expect("job");
if finished && let Some(mut job) = self.track_job.take() {
if let Some(t) = job.thread.take() {
let _ = t.join();
}

View File

@ -375,7 +375,7 @@ impl Session {
if !job.is_finished() {
return false;
}
let mut job = self.warp_job.take().expect("job");
let Some(mut job) = self.warp_job.take() else { return false };
if let Some(t) = job.thread.take() {
let _ = t.join();
}

View File

@ -28,6 +28,8 @@
//! H.264 requires even dimensions: odd output sizes are cropped by one pixel (padded when the
//! output is a single pixel wide/high).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod encode;
mod hevc_av1;
mod out;

View File

@ -24,6 +24,8 @@
//! the project through memoized host requests (see [`host`]), so nothing borrowed ever enters
//! the JS heap. Expressions reading each other nest up to [`MAX_DEPTH`] levels.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod host;
mod noise;
pub mod rewrite;
@ -182,6 +184,7 @@ pub fn eval_property(project: &Project, comp: ItemId, layer: LayerId, path: &str
/// Syntax-check an expression (after the array-maths rewrite) without evaluating it.
pub fn check_syntax(text: &str) -> Result<(), String> {
rewrite::check_nesting(text)?;
runtime::check(text)
}

View File

@ -34,6 +34,15 @@ function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
}
function __finish(r) {
// Rethrow from this frame so the runtime's stack unwinds (see `Runtime::script`).
try {
return __result(r);
} catch (e) {
throw e;
}
}
function __result(r) {
r = __v(r);
if (r !== null && typeof r === 'object' && r.__isLayer) return r.index;
if (r !== null && typeof r === 'object' && r.__isTextStyle) return r.__out();

View File

@ -304,8 +304,11 @@ fn build(toks: Vec<Tok>) -> Vec<Item> {
for t in toks {
if t.kind == Kind::Punct && matches!(t.text.as_str(), "(" | "[" | "{") {
stack.push((t, std::mem::take(&mut cur)));
} else if t.kind == Kind::Punct && matches!(t.text.as_str(), ")" | "]" | "}") && stack.last().is_some_and(|(o, _)| closer(&o.text) == t.text) {
let (open, parent) = stack.pop().unwrap_or_else(|| unreachable!());
} else if t.kind == Kind::Punct
&& matches!(t.text.as_str(), ")" | "]" | "}")
&& stack.last().is_some_and(|(o, _)| closer(&o.text) == t.text)
&& let Some((open, parent)) = stack.pop()
{
let inner = std::mem::replace(&mut cur, parent);
cur.push(Item::Group { open, inner, close: Some(t) });
} else {
@ -545,17 +548,67 @@ fn push_piece(out: &mut String, p: Piece) {
out.push_str(&p.body);
}
/// Rewrite an expression's source for array-aware arithmetic. Line breaks are preserved.
pub fn rewrite(src: &str) -> String {
/// The deepest nesting (brackets, plus chains of prefix operators) an expression may have,
/// before and after the rewrite (which nests a call per arithmetic operator). The JavaScript
/// parser recurses per level and a 2 MiB thread overflows its stack (aborting the app) at
/// about 60 levels, so deeper expressions are rejected with an error instead.
pub const MAX_NESTING: usize = 32;
fn nesting(toks: &[Tok]) -> usize {
let (mut depth, mut prefix, mut max) = (0usize, 0usize, 0usize);
for t in toks {
if t.kind == Kind::Punct && matches!(t.text.as_str(), "(" | "[" | "{") {
depth += 1;
} else if t.kind == Kind::Punct && matches!(t.text.as_str(), ")" | "]" | "}") {
depth = depth.saturating_sub(1);
}
let is_prefix = (t.kind == Kind::Punct && matches!(t.text.as_str(), "!" | "~" | "-" | "+")) || t.is_kw(&["typeof", "void", "delete"]);
prefix = if is_prefix { prefix + 1 } else { 0 };
max = max.max(depth + prefix);
}
max
}
/// An error when `src` nests deeper than [`MAX_NESTING`] levels (before or after the rewrite).
pub fn check_nesting(src: &str) -> Result<(), String> {
let (toks, tail) = tokenize(src);
if nesting(&toks) > MAX_NESTING {
return Err(too_deep());
}
let mut out = rewrite_seq(&build(toks));
out.push_str(&tail);
if nesting(&tokenize(&out).0) > MAX_NESTING {
return Err(too_deep());
}
Ok(())
}
fn too_deep() -> String {
format!("Error: expression is nested too deeply (more than {MAX_NESTING} levels of brackets or chained operators)")
}
/// Rewrite an expression's source for array-aware arithmetic. Line breaks are preserved.
/// An expression nested deeper than [`MAX_NESTING`] becomes a `throw` of that error.
pub fn rewrite(src: &str) -> String {
let (toks, tail) = tokenize(src);
if nesting(&toks) > MAX_NESTING {
return throw_too_deep();
}
let mut out = rewrite_seq(&build(toks));
out.push_str(&tail);
if nesting(&tokenize(&out).0) > MAX_NESTING {
return throw_too_deep();
}
out
}
fn throw_too_deep() -> String {
format!("throw new RangeError({:?})", too_deep().trim_start_matches("Error: "))
}
#[cfg(test)]
mod tests {
use super::rewrite;
use super::{MAX_NESTING, check_nesting, rewrite};
/// Compare ignoring spaces (newlines are significant).
fn n(s: &str) -> String {
@ -599,4 +652,25 @@ mod tests {
assert_eq!(n(&rewrite("i++ + 1")), n("__add(i++, 1)"));
assert_eq!(n(&rewrite("2 ** 3 * 2")), n("__mul(2 ** 3, 2)"));
}
#[test]
fn deep_nesting_is_an_error_not_a_stack_overflow() {
// Each level costs the JavaScript parser stack: ~60 nested brackets overflowed a 2 MiB
// thread and aborted the app; 100 000 overflowed the rewriter itself.
for n in [MAX_NESTING + 1, 100, 100_000] {
let parens = "(".repeat(n) + "1" + &")".repeat(n);
let unclosed = "[".repeat(n);
let sum = vec!["x"; n + 1].join(" + ");
let nots = "!".repeat(n) + "x";
for src in [parens, unclosed, sum, nots] {
assert!(rewrite(&src).starts_with("throw new RangeError("), "{n}");
assert!(check_nesting(&src).is_err());
assert!(crate::check_syntax(&src).unwrap_err().contains("nested too deeply"));
}
}
let ok = "(".repeat(MAX_NESTING - 2) + "a + b" + &")".repeat(MAX_NESTING - 2);
assert!(check_nesting(&ok).is_ok());
assert!(crate::check_syntax(&ok).is_ok());
assert!(check_nesting(&vec!["x"; 20].join(" + ")).is_ok());
}
}

View File

@ -238,8 +238,11 @@ impl Runtime {
self.scripts.clear();
}
// A block keeps `let`/`const` local to one evaluation; `{` shares line 1 with the code so
// error line numbers match the user's text.
let src = format!("{{{}\n}}", crate::rewrite::rewrite(text));
// error line numbers match the user's text. Errors are rethrown from the script's own
// frame: boa leaves the stack of the inner frames behind when an exception leaves the
// script from a nested call (every host request does), and the thread's runtime would
// fail every expression with "reached the maximum stack size" after a few hundred.
let src = format!("try{{{}\n}}catch(__e){{throw __e}}", crate::rewrite::rewrite(text));
let s = Script::parse(Source::from_bytes(src.as_bytes()), None, &mut self.ctx).map_err(|e| format_error(&e.to_string(), line_count(text)));
self.scripts.insert(text.to_string(), s.clone());
s

View File

@ -388,6 +388,22 @@ fn circular_references_terminate() {
assert!(matches!(r, Ok(Value::Scalar(_))) || r.is_err(), "{r:?}");
}
#[test]
fn errors_and_property_reads_keep_the_runtime_usable() {
// Errors thrown inside helpers, and the aborts every property read makes until the host
// answers, must not use up the thread's runtime (it used to fail every expression after a
// few hundred with "reached the maximum stack size").
let f = fx();
for _ in 0..3000 {
assert!(f.ev(f.b, ROT, "thisComp.layer('Ghost').position", 0.0).is_err());
}
for i in 0..1000 {
let t = i as f64 / 1000.0;
assert_close!(f.ev(f.b, ROT, "thisComp.layer('A').transform.rotation", t).unwrap().as_f64(), 90.0 * t);
}
assert_close!(f.num("time * 2", 1.0), 2.0);
}
#[test]
fn effect_slider_reference() {
let f = fx();

View File

@ -5,6 +5,8 @@
//! clockwise on screen, 3D uses a left-handed system with +z pointing into the screen, and a 3D
//! layer's rotation is Orientation (X, Y, Z) followed by X Rotation, Y Rotation, Z Rotation.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::ops::{Add, AddAssign, Div, Mul, MulAssign, Neg, Sub, SubAssign};
use serde::{Deserialize, Serialize};

View File

@ -507,9 +507,12 @@ impl GpuContext {
.map_err(|e| format!("no device: {e}"))?;
device.on_uncaptured_error(Arc::new(|e| {
// Under test a validation error (a WGSL typo invalidates every kernel) fails loudly.
if cfg!(test) {
panic!("wgpu: {e}");
#[cfg(test)]
#[allow(clippy::panic)] // test builds only
{
panic!("wgpu: {e}")
}
#[cfg(not(test))]
log::error!("wgpu: {e}")
}));
GpuContext::new(&adapter, device, queue)

View File

@ -51,6 +51,8 @@
//! skip readback entirely with [`Gpu::render_display`], which leaves an RGBA8 texture for
//! egui-wgpu to draw.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod adv3d;
mod bokeh;
mod classic3d;

View File

@ -380,7 +380,9 @@ impl<'a> FrameCoder<'a> {
return cost;
}
}
let (cost, e, snap, mode) = best.expect("a coding unit decision");
// A coding unit always has at least one candidate mode (intra, or skip/merge in P
// slices); without one, report an infinite cost.
let Some((cost, e, snap, mode)) = best else { return f64::INFINITY };
self.restore(&snap);
*est = e;
decs.push(CuDec { x: x as u32, y: y as u32, log2, mode });
@ -452,7 +454,8 @@ impl<'a> FrameCoder<'a> {
let mv = match mode {
CuMode::Skip { idx } | CuMode::Merge { idx } => self.merge_list(x, y, n)[idx as usize],
CuMode::Amvp { mv, .. } => mv,
CuMode::Intra { .. } => unreachable!(),
// Handled by the arm above.
CuMode::Intra { .. } => [0, 0],
};
let amvp = if let CuMode::Amvp { .. } = mode { self.amvp(x, y, n) } else { [[0; 2]; 2] };
let skip = matches!(mode, CuMode::Skip { .. });
@ -503,7 +506,8 @@ impl<'a> FrameCoder<'a> {
self.write_tt(s, x, y, x, y, log2, 0, 0, [true, true], false, false, &mut ti);
}
}
CuMode::Intra { .. } => unreachable!(),
// Handled by the arm above.
CuMode::Intra { .. } => {}
}
(eff, self.cu_dist(x, y, n))
}
@ -864,7 +868,8 @@ impl<'a> FrameCoder<'a> {
}
fn predict_inter(&mut self, x: usize, y: usize, n: usize, mv: [i16; 2]) {
let refp = self.refp.expect("reference picture");
// Inter modes are only tried in P slices, which have a reference picture.
let Some(refp) = self.refp else { return };
for c in 0..3 {
let (px, py, pn, pw, ph) = if c == 0 { (x, y, n, self.w, self.h) } else { (x / 2, y / 2, n / 2, self.cw, self.ch) };
let pr = PlaneRef { data: &refp[c], w: pw, h: ph };
@ -882,7 +887,7 @@ impl<'a> FrameCoder<'a> {
/// Best merge index by SATD of the (approximate) luma prediction.
fn best_merge(&self, x: usize, y: usize, n: usize, list: &[[i16; 2]; MAX_MERGE_CAND]) -> (usize, u64) {
let me = self.me.expect("motion search reference");
let Some(me) = self.me else { return (0, u64::MAX / 4) };
let mut buf = vec![0u16; n * n];
let mut best = (0, u64::MAX);
for (i, &mv) in list.iter().enumerate() {
@ -901,7 +906,7 @@ impl<'a> FrameCoder<'a> {
/// Integer then fractional motion search. Returns (mv, mvp index, SATD cost).
fn motion_search(&self, x: usize, y: usize, n: usize, amvp: &[[i16; 2]; 2], merge: &[[i16; 2]; MAX_MERGE_CAND]) -> ([i16; 2], u8, u64) {
let me = self.me.expect("motion search reference");
let Some(me) = self.me else { return (amvp[0], 0, u64::MAX / 4) };
let (xi, yi) = (x as i32, y as i32);
let src = &self.src[0][y * self.w + x..];
let ls = self.lambda_sad;
@ -1168,7 +1173,8 @@ fn residual_coding<S: Sink>(s: &mut S, lv: &[i32], log2: u32, c: usize, scan_idx
}
}
}
let (last_sb, last_pos) = last.expect("residual_coding of an all-zero block");
// Callers only code blocks with a nonzero level (coded_block_flag = 1).
let Some((last_sb, last_pos)) = last else { return };
let lx = sb_scan[last_sb].0 as u32 * 4 + pos_scan[last_pos].0 as u32;
let ly = sb_scan[last_sb].1 as u32 * 4 + pos_scan[last_pos].1 as u32;
let (cx, cy) = if scan_idx == 2 { (ly, lx) } else { (lx, ly) };

View File

@ -14,6 +14,7 @@
//! let _hvcc = enc.hvcc();
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
// Index loops over fixed-size blocks read more clearly than iterator chains in codec code.
#![allow(clippy::needless_range_loop, clippy::too_many_arguments)]
@ -287,7 +288,7 @@ impl Encoder {
for y in 0..ch {
let row = &src[y.min(h - 1) * stride..][..w];
plane.extend(row.iter().map(|&v| (v as u32).min(max) as u16));
let last = *plane.last().unwrap();
let last = plane.last().copied().unwrap_or(0);
plane.extend(std::iter::repeat_n(last, cw - w));
}
}

View File

@ -3,6 +3,8 @@
//! Render Queue export through `effectcraft-export` (FilmCraft's encoders). Frontends (desktop,
//! CLI, MCP, web) start here.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::sync::Arc;
use effectcraft_engine::project::render_queue::OutputFormat;

View File

@ -254,7 +254,7 @@ pub fn export(project: &Project, comp: ItemId, opts: &ExportOptions, prerendered
bins: HashMap::new(),
stack: vec![],
};
let seq = ex.sequence(comp);
let seq = ex.sequence(comp).ok_or(Error::NoComp(comp.0))?;
let fc_opts = filmcraft_interchange::ExportOptions { relative_to: opts.relative_to.clone(), name: Some(name), ..Default::default() };
let (bytes, report) = filmcraft_interchange::export(&ex.fp, seq, opts.format.fc(), &fc_opts)?;
for e in report.entries {
@ -390,12 +390,12 @@ impl Exporter<'_> {
id
}
/// The sequence for a comp (created once).
fn sequence(&mut self, cid: ItemId) -> fp::ItemId {
/// The sequence for a comp (created once); None if `cid` is not a composition.
fn sequence(&mut self, cid: ItemId) -> Option<fp::ItemId> {
if let Some(s) = self.seqs.get(&cid) {
return *s;
return Some(*s);
}
let comp = self.p.comp_arc(cid).expect("comp");
let comp = self.p.comp_arc(cid)?;
let it = self.p.item(cid);
let name = it.map(|i| i.name.clone()).unwrap_or_default();
let settings = fp::SequenceSettings {
@ -430,12 +430,13 @@ impl Exporter<'_> {
Plan::Render(why) => match self.pre.get(&(cid, l.id)).cloned() {
Some(r) => {
let item = self.prerendered_item(&format!("{} (pre-rendered)", l.name), &r);
let mut ti = self.track_item(item, &l.name, fp::TrackKind::Video, &tm, rate);
ti.source_in = filmcraft_time::Tick::ZERO;
ti.speed = 1.0;
ti.reverse = false;
self.out.prerendered += 1;
Some(ti)
self.track_item(item, &l.name, fp::TrackKind::Video, &tm, rate).map(|mut ti| {
ti.source_in = filmcraft_time::Tick::ZERO;
ti.speed = 1.0;
ti.reverse = false;
self.out.prerendered += 1;
ti
})
}
None => {
self.warn(format!("layer \"{}\" was left out ({why}; pre-rendering is off or unavailable)", l.name));
@ -457,16 +458,16 @@ impl Exporter<'_> {
Some(ItemKind::Solid(s)) => Some(self.solid_item(*item, s.color, &comp)),
_ => None,
},
LayerSource::Comp { item } if !self.stack.contains(item) => Some(self.sequence(*item)),
LayerSource::Comp { item } if !self.stack.contains(item) => self.sequence(*item),
_ => None,
};
item.map(|item| {
let mut ti = self.track_item(item, &l.name, fp::TrackKind::Video, &tm, rate);
item.and_then(|item| {
let mut ti = self.track_item(item, &l.name, fp::TrackKind::Video, &tm, rate)?;
self.motion(&mut ti, l, &comp, &tm);
if let Some(h) = l.props.get("timeRemap").filter(|p| !p.is_animated()) {
ti.frame_hold = Some(tick_out(Tick::from_seconds_f64(h.value.as_f64())));
}
ti
Some(ti)
})
}
};
@ -486,14 +487,14 @@ impl Exporter<'_> {
_ => None,
},
LayerSource::Comp { item } if self.p.comp(*item).is_some_and(|c| comp_has_audio(self.p, c)) && !self.stack.contains(item) => {
Some(self.sequence(*item))
self.sequence(*item)
}
_ => None,
};
if let Some(item) = sound
&& !matches!(pl, Plan::Skip(_))
&& let Some(mut ti) = self.track_item(item, &l.name, fp::TrackKind::Audio, &tm, rate)
{
let mut ti = self.track_item(item, &l.name, fp::TrackKind::Audio, &tm, rate);
ti.enabled = l.switches.audio;
ti.link = has_video_clip.then_some(link);
self.levels(&mut ti, l, &tm);
@ -529,18 +530,18 @@ impl Exporter<'_> {
seq.work_area = None;
}
self.stack.pop();
sid
Some(sid)
}
fn track_item(&mut self, item: fp::ItemId, name: &str, kind: fp::TrackKind, tm: &Timing, rate: FrameRate) -> fp::TrackItem {
fn track_item(&mut self, item: fp::ItemId, name: &str, kind: fp::TrackKind, tm: &Timing, rate: FrameRate) -> Option<fp::TrackItem> {
let range = filmcraft_time::TimeRange::new(tick_out(tm.source_in), tick_out(Tick((tm.duration.0 as f64 * tm.speed).round() as i64)));
let mut ti = self.fp.make_track_item(item, kind, tick_out(tm.start), range, rate_out(rate)).expect("item exists");
let mut ti = self.fp.make_track_item(item, kind, tick_out(tm.start), range, rate_out(rate))?;
ti.name = name.to_string();
ti.duration = tick_out(tm.duration);
ti.source_in = tick_out(tm.source_in);
ti.speed = tm.speed;
ti.reverse = tm.reverse;
ti
Some(ti)
}
/// Transform → Motion + Opacity (clip-time keyframes).
@ -554,7 +555,7 @@ impl Exporter<'_> {
_ => (comp.width, comp.height),
};
let center = |x: f64, y: f64| -> fp::ParamValue { fp::ParamValue::Vec2(Vec2 { x, y }) };
let mut m = ti.effect("motion").cloned().unwrap_or_else(|| fp::find_effect("motion").expect("motion").instance());
let Some(mut m) = effect_or_new(ti, "motion") else { return };
if let Some(p) = tr.get("position") {
let def = [comp.width as f64 / 2.0, comp.height as f64 / 2.0];
if p.is_animated() || differs(&p.value, def) {
@ -594,8 +595,9 @@ impl Exporter<'_> {
m.params.insert("rotation".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_f64())));
}
set_effect(ti, m);
if let Some(p) = tr.get("opacity") {
let mut o = ti.effect("opacity").cloned().unwrap_or_else(|| fp::find_effect("opacity").expect("opacity").instance());
if let Some(p) = tr.get("opacity")
&& let Some(mut o) = effect_or_new(ti, "opacity")
{
o.params.insert("opacity".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_f64())));
if l.blend_mode != effectcraft_color::BlendMode::Normal
&& let Some(i) = fp::effect::BLEND_MODES.iter().position(|m| m.eq_ignore_ascii_case(l.blend_mode.label()))
@ -615,7 +617,7 @@ impl Exporter<'_> {
if (p.value.as_vec2()[0] - p.value.as_vec2()[1]).abs() > 1e-9 {
self.warn("separate left/right audio levels were exported as the left level");
}
let mut v = ti.effect("volume").cloned().unwrap_or_else(|| fp::find_effect("volume").expect("volume").instance());
let Some(mut v) = effect_or_new(ti, "volume") else { return };
v.params.insert("level".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec2()[0])));
set_effect(ti, v);
}
@ -634,6 +636,11 @@ fn differs(v: &Value, def: [f64; 2]) -> bool {
(a[0] - def[0]).abs() > 1e-6 || (a[1] - def[1]).abs() > 1e-6
}
/// The clip's `id` effect, or a new instance of it (None if the registry has no such effect).
fn effect_or_new(ti: &fp::TrackItem, id: &str) -> Option<fp::EffectInstance> {
ti.effect(id).cloned().or_else(|| fp::find_effect(id).map(|e| e.instance()))
}
fn set_effect(ti: &mut fp::TrackItem, e: fp::EffectInstance) {
match ti.effects.iter_mut().find(|x| x.effect == e.effect) {
Some(x) => *x = e,

View File

@ -45,6 +45,83 @@ pub struct ImportResult {
/// Import a timeline document into `project`. `probe` reads a media file's footage metadata
/// (None = missing or unreadable: the item becomes a placeholder with the document's metadata).
/// The deepest element / array nesting accepted in XML and OTIO documents. Real timelines nest
/// a few levels per nested sequence; parsing several thousand levels overflowed the stack.
pub const MAX_DEPTH: usize = 256;
/// The deepest element nesting of an XML document (a quick scan that skips comments, CDATA,
/// processing instructions, declarations and quoted attribute values).
pub(crate) fn xml_depth(b: &[u8]) -> usize {
let (mut i, mut depth, mut max) = (0usize, 0usize, 0usize);
let skip_to = |from: usize, end: &[u8]| b.get(from..).and_then(|r| r.windows(end.len()).position(|w| w == end)).map_or(b.len(), |p| from + p + end.len());
while let Some(&c) = b.get(i) {
if c != b'<' {
i += 1;
continue;
}
let rest = b.get(i..).unwrap_or_default();
if rest.starts_with(b"<!--") {
i = skip_to(i + 4, b"-->");
} else if rest.starts_with(b"<![CDATA[") {
i = skip_to(i + 9, b"]]>");
} else if rest.starts_with(b"<?") {
i = skip_to(i + 2, b"?>");
} else if rest.starts_with(b"<!") {
i = skip_to(i + 2, b">");
} else if rest.starts_with(b"</") {
depth = depth.saturating_sub(1);
i = skip_to(i + 2, b">");
} else {
// A start tag: find its end outside quotes; `/>` closes it at once.
let mut j = i + 1;
let mut quote = None;
while let Some(&c) = b.get(j) {
match quote {
Some(q) if c == q => quote = None,
Some(_) => {}
None if c == b'"' || c == b'\'' => quote = Some(c),
None if c == b'>' => break,
None => {}
}
j += 1;
}
if b.get(j.wrapping_sub(1)) != Some(&b'/') {
depth += 1;
max = max.max(depth);
}
i = j + 1;
}
}
max
}
/// The deepest array / object nesting of a JSON document (strings skipped).
pub(crate) fn json_depth(b: &[u8]) -> usize {
let (mut depth, mut max, mut in_str, mut esc) = (0usize, 0usize, false, false);
for &c in b {
if in_str {
if esc {
esc = false;
} else if c == b'\\' {
esc = true;
} else if c == b'"' {
in_str = false;
}
continue;
}
match c {
b'"' => in_str = true,
b'[' | b'{' => {
depth += 1;
max = max.max(depth);
}
b']' | b'}' => depth = depth.saturating_sub(1),
_ => {}
}
}
max
}
pub fn import(
project: &mut Project,
bytes: &[u8],
@ -56,6 +133,16 @@ pub fn import(
Some(f) => f,
None => TimelineFormat::detect(bytes, Some(&opts.name)).ok_or(Error::UnknownFormat)?,
};
// The XML and JSON parsers recurse per nesting level: a small file nested thousands of levels
// deep overflowed the stack and aborted the app.
let depth = match format {
TimelineFormat::Fcp7Xml | TimelineFormat::Fcpxml => xml_depth(bytes),
TimelineFormat::Otio => json_depth(bytes),
_ => 0,
};
if depth > MAX_DEPTH {
return Err(Error::TooDeep(MAX_DEPTH));
}
let fc_opts = filmcraft_interchange::ImportOptions {
base_dir: opts.base_dir.clone(),
edl_frame_rate: opts.edl_frame_rate.map(|f| {
@ -434,7 +521,7 @@ impl Importer<'_> {
}
let fit = if ti.scale_to_frame && size.0 > 0 && size.1 > 0 { (comp.width as f64 / size.0 as f64).min(comp.height as f64 / size.1 as f64) } else { 1.0 };
let lc = l.clone();
let tr = l.transform_mut().expect("transform");
let Some(tr) = l.transform_mut() else { return };
// Position / anchor (NaN = centred).
for (src, dst) in [("position", "position"), ("anchor", "anchor")] {
let Some(p) = m.param(src) else { continue };

View File

@ -29,6 +29,8 @@
//! This crate is L3 and does no file I/O: media files are probed through a callback, pre-rendered
//! media come in as paths, and documents are bytes.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod export;
mod import;
#[cfg(test)]
@ -156,6 +158,8 @@ pub enum Error {
UnknownFormat,
#[error("no composition {0}")]
NoComp(u64),
#[error("the timeline document nests more than {0} levels deep")]
TooDeep(usize),
}
pub type Result<T> = std::result::Result<T, Error>;

View File

@ -498,3 +498,42 @@ fn formats_parse() {
assert_eq!(par_fraction(0.9), (9, 10));
assert_eq!(par_fraction(1.0), (1, 1));
}
#[test]
fn export_survives_a_precomp_layer_pointing_at_a_non_comp() {
// A damaged project: the precomp layer's source is a footage item (or a deleted item).
let (mut p, cid) = ae_project();
let footage = p.items.iter().find(|(_, i)| matches!(i.kind, ItemKind::Footage(_))).map(|(id, _)| *id).unwrap();
for bad in [footage, ItemId(9_999)] {
let mut c = (*p.comp_arc(cid).unwrap()).clone();
for l in &mut c.layers {
if matches!(l.source, LayerSource::Comp { .. }) {
l.source = LayerSource::Comp { item: bad };
}
}
p.item_mut(cid).unwrap().kind = ItemKind::Comp(Arc::new(c));
for format in [TimelineFormat::Fcp7Xml, TimelineFormat::Fcpxml, TimelineFormat::Otio] {
let out = export(&p, cid, &ExportOptions { format, prerender: PrerenderMode::None, ..Default::default() }, &HashMap::new()).unwrap();
assert_eq!(out.sequences, 1);
}
}
assert!(matches!(export(&p, footage, &ExportOptions::default(), &HashMap::new()), Err(Error::NoComp(_))));
}
#[test]
fn deeply_nested_documents_error_instead_of_overflowing_the_stack() {
// A few hundred kilobytes nested 100 000 levels deep overflowed the parsers' stack.
let n = 100_000;
let docs = [
(TimelineFormat::Fcp7Xml, format!("<?xml version=\"1.0\"?><xmeml version=\"5\">{}{}</xmeml>", "<a>".repeat(n), "</a>".repeat(n))),
(TimelineFormat::Fcpxml, format!("<?xml version=\"1.0\"?><fcpxml version=\"1.9\">{}{}</fcpxml>", "<a>".repeat(n), "</a>".repeat(n))),
(TimelineFormat::Otio, format!("{{\"OTIO_SCHEMA\":\"Timeline.1\",\"tracks\":{}{}}}", "[".repeat(n), "]".repeat(n))),
];
for (format, doc) in docs {
let mut p = Project::default();
let r = import(&mut p, doc.as_bytes(), Some(format), &ImportOptions::default(), &mut |_| None);
assert!(matches!(r, Err(Error::TooDeep(_))), "{format:?}");
}
assert_eq!(super::import::xml_depth(b"<a><!-- <b><b> --><c x='>'/><![CDATA[<d>]]><e></e></a>"), 2);
assert_eq!(super::import::json_depth(br#"{"a": "[[[{{", "b": [[1], {"c": "\\\"["}]}"#), 3);
}

View File

@ -441,7 +441,9 @@ pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Val
let dur = (t1 - t0).max(1e-12);
let x = ((secs(t) - t0) / dur).clamp(0.0, 1.0);
let lin_out = a.out_interp == Interp::Linear;
let lin_in = b.in_interp == Interp::Linear;
// A Hold in-side only matters for the segment it ends, which the out side above decides:
// it shapes the incoming segment like a linear side, as in the Graph Editor and Lottie export.
let lin_in = b.in_interp != Interp::Bezier;
let is_spatial = spatial && matches!(a.value, Value::Vec2(_) | Value::Vec3(_));
if is_spatial {
let p0 = v3(&a.value);
@ -584,6 +586,17 @@ mod tests {
assert_eq!(evaluate(&held, s(1.999), false), Some(Value::Scalar(0.0)));
}
#[test]
fn hold_key_keeps_its_incoming_segment_linear() {
// Hold on the second key (in and out, as KeyframeInterpolationType.HOLD sets it) holds
// what comes after it, not the linear motion into it.
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0)).hold()];
assert_eq!(evaluate(&keys, s(0.5), false), Some(Value::Scalar(25.0)));
let keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), Keyframe::new(s(2.0), Value::Vec2([100.0, 0.0])).hold()];
let p = evaluate(&keys, s(0.5), true).unwrap().as_vec2();
assert!((p[0] - 25.0).abs() < 0.01, "{p:?}");
}
#[test]
fn easy_ease_is_symmetric_and_slow_at_ends() {
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)).eased(), Keyframe::new(s(1.0), Value::Scalar(100.0)).eased()];

View File

@ -372,5 +372,5 @@ pub(crate) fn to_bool(j: &Json) -> Option<Value> {
/// Lottie 1-based enum → 0-based popup index.
pub(crate) fn to_enum1(j: &Json) -> Option<Value> {
to_scalar(j).map(|v| Value::Enum((v.as_f64().round() as i64 - 1).max(0) as u32))
to_scalar(j).map(|v| Value::Enum((v.as_f64().round() as i64).saturating_sub(1).max(0) as u32))
}

View File

@ -18,6 +18,8 @@
//! Keyframe times in Lottie are frames of the layer's local time, which is EffectCraft's layer
//! time; layer `ip`/`op`/`st` are composition frames and `sr` is the stretch factor.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod anim;
mod base64;
mod export;

View File

@ -359,10 +359,10 @@ fn import_blend(j: &Json, g: &mut PropGroup) {
fn import_stroke(im: &Im, j: &Json, g: &mut PropGroup) {
set(im, g, "width", j.get("w"), &anim::to_scalar);
if let Some(lc) = int(j, "lc") {
set_enum(g, "cap", (lc - 1).clamp(0, 2) as u32);
set_enum(g, "cap", lc.saturating_sub(1).clamp(0, 2) as u32);
}
if let Some(lj) = int(j, "lj") {
set_enum(g, "join", (lj - 1).clamp(0, 2) as u32);
set_enum(g, "join", lj.saturating_sub(1).clamp(0, 2) as u32);
}
if let Some(p) = g.get_mut("miter") {
if let Some(ml) = j.get("ml").and_then(Json::as_f64) {
@ -541,7 +541,7 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec<Pr
"op" => {
set(im, &mut g, "amount", j.get("a"), &anim::to_scalar);
if let Some(lj) = int(j, "lj") {
set_enum(&mut g, "join", (lj - 1).clamp(0, 2) as u32);
set_enum(&mut g, "join", lj.saturating_sub(1).clamp(0, 2) as u32);
}
if let (Some(ml), Some(p)) = (j.get("ml").and_then(Json::as_f64), g.get_mut("miter")) {
p.value = Value::Scalar(ml);
@ -561,7 +561,7 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec<Pr
}
_ => {
if let Some(m) = int(j, "mm") {
set_enum(&mut g, "mode", (m - 1).clamp(0, 4) as u32);
set_enum(&mut g, "mode", m.saturating_sub(1).clamp(0, 4) as u32);
}
}
}

View File

@ -725,3 +725,31 @@ fn text_style_runs_export_as_range_animators() {
// Played back as an animator, it looks the same.
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
}
#[test]
fn extreme_enum_numbers_do_not_overflow() {
// Line cap / join / merge mode / text-selector enums are 1-based; a huge negative number
// (-1e308 rounds to i64::MIN) overflowed the `- 1`.
let path = format!("{}/tests/fixtures/shapes-gradient.json", env!("CARGO_MANIFEST_DIR"));
let mut doc: Json = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
let mut hits = 0;
fn patch(j: &mut Json, hits: &mut usize) {
match j {
Json::Object(o) => {
for k in ["lc", "lj", "mm"] {
if o.contains_key(k) {
o.insert(k.into(), json!(-1e308));
*hits += 1;
}
}
o.values_mut().for_each(|v| patch(v, hits));
}
Json::Array(a) => a.iter_mut().for_each(|v| patch(v, hits)),
_ => {}
}
}
patch(&mut doc, &mut hits);
assert!(hits > 0);
let mut q = Project::default();
import(&mut q, doc.to_string().as_bytes(), "x", "", &mut |_| None).unwrap();
}

View File

@ -363,7 +363,7 @@ pub(crate) fn import_text(im: &mut Im, ids: &mut Ids, t: &Json, tg: &mut PropGro
let mut en = |m: &str, key: &str, max: u32| {
let v = s.get(key).and_then(|v| if v.is_object() { v.get("k") } else { Some(v) }).and_then(anim::nums).and_then(|n| n.first().copied());
if let (Some(v), Some(p)) = (v, adv.get_mut(m)) {
p.value = Value::Enum(((v.round() as i64 - 1).max(0) as u32).min(max));
p.value = Value::Enum((((v.round() as i64).saturating_sub(1)).max(0) as u32).min(max));
}
};
en("units", "r", 1);

View File

@ -73,7 +73,9 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -
if effectcraft_psd::is_psd(bytes) {
let psd = effectcraft_psd::Psd::parse(bytes.to_vec()).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?;
// A smart object's embedded file: an embedded document's merged image, or an image.
if let Some(uuid) = footage.layer.as_ref().and_then(|l| l.embedded.as_deref()) {
if let Some(l) = footage.layer.as_ref()
&& let Some(uuid) = l.embedded.as_deref()
{
let data = psd.linked_data(uuid).ok_or_else(|| MediaError::Decode(format!("{path}: no embedded file {uuid}")))?;
let px = if effectcraft_psd::is_psd(data) {
let inner = effectcraft_psd::Psd::parse(data.to_vec()).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
@ -84,7 +86,6 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -
effectcraft_psd::Pixels { width: rgba.width(), height: rgba.height(), data: rgba.pixels().map(|p| p.0).collect() }
};
// A perspective quad or a warp: baked as placed.
let l = footage.layer.as_ref().expect("embedded implies a layer");
if l.placed
&& let Some(so) = psd.layers.get(l.index as usize).and_then(|pl| pl.smart_object.as_ref())
{

View File

@ -13,6 +13,8 @@
//! (PNG, JPEG, GIF, WebP, TIFF, BMP, OpenEXR); Photoshop documents (merged image or one layer) come
//! from `effectcraft-psd` and SVG from `effectcraft-svg` (rasterised at any scale).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod convert;
pub mod exr_channels;
mod layered;

View File

@ -12,6 +12,8 @@
//! Models are in their own units with +Y up (glTF's convention); the renderer maps them into
//! After Effects' Y-down pixel space.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod extrude;
pub mod gltf;
pub mod obj;

View File

@ -23,6 +23,7 @@
//! assert_eq!(voip.mode(), Mode::Silk);
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
// The bit-allocation code mirrors the normative integer arithmetic; keep C-like shift expressions.
#![allow(clippy::precedence, clippy::int_plus_one)]
@ -317,23 +318,26 @@ impl OpusEncoder {
}
Mode::Silk => {
let x = self.silk_input(pcm);
let sp = self.silk.as_mut().expect("SILK state");
let target = sp.target_bits + 0.25 * sp.reservoir;
let mut enc = RangeEncoder::new();
sp.enc.encode(&x, target.max(24.0), (MAX_FRAME_BYTES * 8) as i32, &mut enc);
let bytes = (enc.tell() as usize).div_ceil(8).max(2);
let lim = 4.0 * sp.target_bits;
sp.reservoir = (sp.reservoir + sp.target_bits - (bytes * 8) as f64).clamp(-lim, lim);
packet.extend_from_slice(&enc.done(bytes));
// The SILK state exists whenever the mode uses SILK (set with the mode).
if let Some(sp) = self.silk.as_mut() {
let target = sp.target_bits + 0.25 * sp.reservoir;
let mut enc = RangeEncoder::new();
sp.enc.encode(&x, target.max(24.0), (MAX_FRAME_BYTES * 8) as i32, &mut enc);
let bytes = (enc.tell() as usize).div_ceil(8).max(2);
let lim = 4.0 * sp.target_bits;
sp.reservoir = (sp.reservoir + sp.target_bits - (bytes * 8) as f64).clamp(-lim, lim);
packet.extend_from_slice(&enc.done(bytes));
}
}
Mode::Hybrid => {
let x = self.silk_input(pcm);
let len = self.frame_bytes;
let total = (len * 8) as i32;
let sp = self.silk.as_mut().expect("SILK state");
let celt_min = (total / 5).max(80);
let mut enc = RangeEncoder::new();
sp.enc.encode(&x, sp.target_bits, total - celt_min, &mut enc);
if let Some(sp) = self.silk.as_mut() {
sp.enc.encode(&x, sp.target_bits, total - celt_min, &mut enc);
}
// No redundancy (RFC 6716 §4.5.1); the flag is present when there is room.
if enc.tell() + 17 + 20 <= total {
enc.bit_logp(false, 12);
@ -349,7 +353,7 @@ impl OpusEncoder {
/// Decimates the input to the SILK internal rate (16-bit scale), one vector per channel.
fn silk_input(&mut self, pcm: &[f32]) -> Vec<Vec<f32>> {
let c = self.channels;
let sp = self.silk.as_mut().expect("SILK state");
let Some(sp) = self.silk.as_mut() else { return vec![vec![]; c] };
let mut out = Vec::with_capacity(c);
let mut tmp = vec![0f32; N];
for ch in 0..c {

View File

@ -1,7 +1,7 @@
//! Curve-preserving boolean operations on filled [`BezPath`]s, built on `linesweeper`'s robust
//! sweep-line topology (MIT OR Apache-2.0).
//!
//! Adapted from DrawCraft's `vectorcraft-pathops` (`crates/pathops/src/boolean.rs`; our own code,
//! Adapted from VectorCraft's `vectorcraft-pathops` (`crates/pathops/src/boolean.rs`; our own code,
//! MIT OR Apache-2.0), reworked to take and return kurbo paths directly.
//!
//! Inputs are closed implicitly (a filled open path is painted as if closed). The sweep splits

View File

@ -1,4 +1,4 @@
//! Adapted from DrawCraft's `vectorcraft-pathops` (`crates/pathops/src/fit.rs`; our own code,
//! Adapted from VectorCraft's `vectorcraft-pathops` (`crates/pathops/src/fit.rs`; our own code,
//! MIT OR Apache-2.0).
//!
//! Least-squares cubic Bézier fitting of point sequences (the classic "fit a cubic with fixed end
@ -72,8 +72,7 @@ fn bern(t: f64) -> [f64; 4] {
/// Solve the 2x2 normal equations for the handle lengths along the fixed tangents.
fn generate(pts: &[Point], u: &[f64], t0: Vec2, t1: Vec2) -> CubicBez {
let p0 = pts[0];
let p3 = *pts.last().unwrap();
let (Some(&p0), Some(&p3)) = (pts.first(), pts.last()) else { return CubicBez::new(Point::ZERO, Point::ZERO, Point::ZERO, Point::ZERO) };
let dir_in = -t1; // handle at the end points backwards
let (mut c00, mut c01, mut c11, mut x0, mut x1) = (0.0, 0.0, 0.0, 0.0, 0.0);
for (p, &t) in pts.iter().zip(u) {

View File

@ -5,6 +5,8 @@
//! pucker & bloat, zig zag, twist, wiggle, offset, merge) live in [`ops`]; curve-preserving booleans
//! (for Merge Paths) in [`boolean`] and curve offsetting in [`offset`].
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod boolean;
pub mod feather;
mod fit;

View File

@ -2,7 +2,7 @@
//! offsetting (kurbo's stroker offsets curves analytically and refits them) and robust overlap
//! removal (the boolean sweep).
//!
//! Adapted from DrawCraft's `vectorcraft-pathops` (`crates/pathops/src/offset.rs`; our own code,
//! Adapted from VectorCraft's `vectorcraft-pathops` (`crates/pathops/src/offset.rs`; our own code,
//! MIT OR Apache-2.0).
use kurbo::{BezPath, PathEl, Stroke, StrokeOpts};

View File

@ -651,7 +651,8 @@ pub fn merge(paths: &[BezPath], mode: MergeMode) -> BezPath {
MergeMode::Subtract => crate::boolean::boolean_n(&inputs, |m| m[0] && !m[1..].iter().any(|&b| b)),
MergeMode::Intersect => crate::boolean::boolean_n(&inputs, |m| m.iter().all(|&b| b)),
MergeMode::Exclude => crate::boolean::boolean_n(&inputs, |m| m.iter().filter(|&&b| b).count() % 2 == 1),
MergeMode::Merge => unreachable!(),
// Handled above (no boolean operation).
MergeMode::Merge => crate::boolean::boolean_n(&inputs, |m| m.iter().any(|&b| b)),
}
}

View File

@ -646,12 +646,21 @@ impl T2<'_> {
self.path.move_to((self.x, self.y));
self.open = true;
}
/// A segment before the first moveto starts at the current point.
fn ensure_open(&mut self) {
if !self.open {
self.path.move_to((self.x, self.y));
self.open = true;
}
}
fn line_to(&mut self, dx: f64, dy: f64) {
self.ensure_open();
self.x += dx;
self.y += dy;
self.path.line_to((self.x, self.y));
}
fn curve(&mut self, d: [f64; 6]) {
self.ensure_open();
let (x1, y1) = (self.x + d[0], self.y + d[1]);
let (x2, y2) = (x1 + d[2], y1 + d[3]);
self.x = x2 + d[4];

View File

@ -7,6 +7,8 @@
//! children are the document's **layers**: one group per top-level optional-content group
//! (Illustrator layers), with content outside them gathered into groups of its own.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod build;
mod ccitt;
mod cff;

View File

@ -332,7 +332,7 @@ impl<'a> Interp<'a> {
// Painting.
"S" => self.paint(false, None, true, depth),
"s" => {
self.path.close_path();
self.close_subpath();
self.paint(false, None, true, depth)
}
"f" | "F" => self.paint(true, Some(FillRule::NonZero), false, depth),
@ -340,11 +340,11 @@ impl<'a> Interp<'a> {
"B" => self.paint(true, Some(FillRule::NonZero), true, depth),
"B*" => self.paint(true, Some(FillRule::EvenOdd), true, depth),
"b" => {
self.path.close_path();
self.close_subpath();
self.paint(true, Some(FillRule::NonZero), true, depth)
}
"b*" => {
self.path.close_path();
self.close_subpath();
self.paint(true, Some(FillRule::EvenOdd), true, depth)
}
"n" => self.paint(false, None, false, depth),
@ -549,6 +549,13 @@ impl<'a> Interp<'a> {
}
}
/// Close the current subpath; nothing to close without a current point.
fn close_subpath(&mut self) {
if self.cur.is_some() {
self.path.close_path();
}
}
fn ensure_start(&mut self) {
if self.cur.is_none() {
self.path.move_to((0.0, 0.0));

View File

@ -278,7 +278,7 @@ impl Src<'_> {
None => {
// Radix numbers (16#FF).
let s = String::from_utf8_lossy(w).into_owned();
match s.split_once('#').and_then(|(r, v)| i64::from_str_radix(v, r.parse().ok()?).ok()) {
match s.split_once('#').and_then(|(r, v)| i64::from_str_radix(v, r.parse::<u32>().ok().filter(|r| (2..=36).contains(r))?).ok()) {
Some(n) => V::Num(n as f64),
None => V::Name(s.into()),
}

View File

@ -295,3 +295,21 @@ fn truncated_and_corrupt_eps_never_panic() {
assert!(r.is_ok(), "{}", String::from_utf8_lossy(prog));
}
}
#[test]
fn closing_an_empty_path_is_a_no_op() {
// "s", "b" and "b*" closed the path without a current point (kurbo asserts on that).
for ops in ["s", "b", "b*", "0 0 m 50 0 l 50 50 l b s b*", "f s 10 10 m 20 20 l s"] {
let doc = parse(&page_pdf(ops, "", vec![])).unwrap();
let _ = render(&doc);
}
}
#[test]
fn postscript_radix_numbers_outside_2_to_36_are_not_numbers() {
// `0#7` asked `i64::from_str_radix` for radix 0, which panics (found by fuzzing).
for n in ["0#7", "1#1", "37#1", "99999999999#1", "16#FF"] {
let src = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n{n} pop 0 0 moveto 10 10 lineto stroke\n");
let _ = parse(src.as_bytes());
}
}

View File

@ -22,6 +22,8 @@
//! differently on another machine. The runtime is the `wasm` feature (on for the desktop app and
//! the CLI); without it [`load_wasm`] says plug-ins are unavailable.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use effectcraft_effects::EffectSpec;
pub use effectcraft_effects::plugin::{EffectPlugin, PLUGIN_API_VERSION, PluginFrame, PluginManifest, PluginParams, register_plugin};
@ -62,5 +64,7 @@ pub fn loader(bytes: &[u8], source: &str) -> Result<serde_json::Value, String> {
}))
}
#[cfg(all(test, feature = "wasm"))]
// Two attributes, so clippy sees `cfg(test)` and treats the module as test code.
#[cfg(test)]
#[cfg(feature = "wasm")]
mod tests;

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