Skip to content

Add frame animation: Timeline panel, onion skins, GIF/APNG/WebP export - #203

Open
IAmJSD wants to merge 11 commits into
mainfrom
feat/frame-animation
Open

IAmJSD wants to merge 11 commits into
mainfrom
feat/frame-animation

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 3, 2026

Copy link
Copy Markdown
Member

What

Photoshop/Aseprite-style frame animation for layered documents:

  • Frame model in the kernel (schist_core::animation): each frame records per-layer visibility, opacity and an integer position offset; delays, loop count (once / N / forever) and Photoshop's "New layers visible in all frames". Create, New (duplicate) Frame, Delete, reorder, reverse, select/step, per-frame or all-frames delay, loop, per-frame layer offset, Make Frames From Layers, Flatten Frames Into Layers, Delete Animation. Every operation is one undoable edit (EditOp::TimelineSet plus the layer-property changes it implies).
  • Timeline panel (timeline in the dock, draggable/resizable/hideable and saved in workspace presets like the others; shown when the document has an animation): frame thumbnails (click to select, drag to reorder), transport (first/prev/play-pause/next/last), new/delete frame, delay + apply to all, loop count, onion skin controls, the active layer's offset in this frame, new-layer rule.
  • Playback with real timing (scheduled against a clock so slow renders don't drift), honouring the loop count. Display-only: rendered frames are drawn over the canvas; the document, history and dirty flag are untouched. Any command or canvas click stops it.
  • Onion skinning: up to 3 previous (red) / next (blue) frames, opacity adjustable, display-only.
  • Export (File ▸ Export ▸ Animation…): animated GIF (per-frame or global palette, exact palettes when colours fit, NeuQuant + optional Floyd–Steinberg otherwise, transparency index, disposal Auto/Keep/Background/Previous), APNG (lossless RGBA), animated WebP (lossless; container written per Google's public RIFF spec around image-webp's VP8L output). New crate schist-animation.
  • Persistence: private document-level ScAn PSD/PSB block (JSON), following the ScIn pattern. Layers referenced by tree-walk index with name check, falling back to unique-name match, dropping rather than guessing. The current frame is also the layers' normal visibility/opacity. Photoshop's own animation resources are preserved verbatim (not interpreted, no reverse engineering).
  • Commands / MCP / actions: 11 animation.* commands registered through the shared registry (so they appear as cmd_animation_* MCP tools and in Spotlight), allowed in recorded actions. Layer ▸ Animation submenu.

Design notes

  • The live layers are the current frame. Ordinary edits (eye toggle, opacity, their undo) change the selected frame with no hooks; Timeline::synced reads the live layers back before switching frames, saving or rendering. New layers are materialised into other frames on the next sync using the new-layer rule.
  • Offsets have no persistent layer property, so the timeline owns them and render_offset mirrors the current frame's offsets (re-derived whenever TimelineSet is applied, including undo/redo). The Move tool now previews on top of an existing render offset and restores it, so moving pixels in an animated document keeps the frame offset.
  • Frame images (thumbnails, playback, onion) are cached on a per-frame content key (resolved layer states + tile fingerprints), not the document revision, so switching frames doesn't invalidate them.
  • Crates: gif, png, image-webp, color_quant — all pure Rust, MIT/Apache-2.0 (color_quant MIT), already in Cargo.lock via image, build offline and for wasm. Small binary impact (gif + color_quant are the only newly compiled ones).

Verification done

  • cargo test for schist-core (frame ops + undo/redo, offsets through undo, new-layer rule, frame keys), schist-codec-psd (tests/animation.rs: PSD and PSB round trip, re-save, unmatched/renamed layers, unknown block versions and a stand-in Photoshop resource preserved verbatim), schist-commands-core (commands, refusals, everything undoes), schist-animation (encode → decode with the gif, png, image-webp decoders: frame count, delays, loop count, disposal, transparency, lossless pixels), schist-tools-basic (Move keeps the frame offset), schist-app-settings, schist-editor.
  • Cross-checked exported files: ffprobe reports 24×16 / 3 frames for GIF and APNG (codec apng); ImageMagick identify reports delays 10/25/4 cs for GIF and WebP and decodes 3 WebP frames (libwebp, independent of our writer).
  • cargo clippy -D warnings and cargo fmt on touched crates; make check-i18n.
  • i18n: new animation.lang + command.animation.* keys in all 150 locales (AI translations, strict audit passes; human review pending; no/sh generated by tools/sync-i18n.py).

Not verified

  • No headless GUI check. Disk on the build box was down to ~11 GB, so the app binary was not built and the Timeline panel, playback, onion skins and the export dialog have not been exercised in a running window. They are covered only by cargo check/clippy and the editor unit tests (424 pass). This is the main thing to try by hand: open a layered document, Layer ▸ Animation ▸ Make Frames From Layers, play, toggle onion skin, drag a frame, File ▸ Export ▸ Animation… (cargo test -p schist-animation also writes anim.psd to $SCHIST_ANIMATION_ARTIFACT_DIR as a ready-made sample).

  • Not tested in a real browser build (make check-app-web not run); the code paths are shared and the encoders are pure Rust, but the wasm export path is untested.

  • No interactive test of Photoshop opening a Schist-animated PSD (Photoshop shows the selected frame as plain layers; its own timeline is not written).

Limits (documented in docs/frame-animation.md)

  • Painting on a layer whose current-frame offset is non-zero paints in layer coordinates (appears shifted).
  • Playback/onion skins hidden while the view is rotated; rendered at ≤2048 px.
  • GIF delays < 20 ms written as 20 ms; WebP lossless only; no import of animated files as timelines; Photoshop animation data not read; Cloud collaborative sessions don't sync the timeline; no tweening.

🤖 Generated with Claude Code

IAmJSD and others added 11 commits October 4, 2026 17:44
Frames record per-layer visibility, opacity and position offset. The live
layers are the current frame; offsets are mirrored in render_offset. Every
frame operation is one undoable edit (EditOp::TimelineSet plus the layer
property changes it implies). The Move tool keeps a frame's offset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Layers are referenced by tree-walk index with their names as a check, so
files edited elsewhere fall back to unique-name matching. Unknown block
versions and Photoshop's own animation resources stay verbatim.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AI translations for all 149 non-English locales (no/sh generated by
tools/sync-i18n.py); strict audit passes, human review pending.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD
IAmJSD force-pushed the feat/frame-animation branch from 8057359 to bc966a6 Compare October 4, 2026 17:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant