A web-based animation studio — "After Effects for inspo." Pick a template, tweak its controls, drop in images (bundled placeholders or uploads from your computer), scrub the timeline, and export to MP4. Built for Savee.
Matches the Savee www app conventions:
- Next.js 15 (Pages Router) · React 18 · TypeScript
- Tailwind 3 · zustand 5 · lucide-react ·
clsx+tailwind-merge - pnpm
pnpm install
pnpm dev # http://localhost:3100
pnpm build # production build
pnpm type # typecheckEverything renders deterministically from a single playhead time t, which is what
makes scrubbing, looping, and export all consistent.
- Templates (
src/animator/templates/) are pure functions(ctx) => Layer[]— given the time, params, canvas size, and assets, they return a flat list of positioned layers. Each template declares its own param schema, and the controls panel is generated automatically from it. - Scene model (
src/animator/scene.ts) merges template layers with the global text overlay and sorts by paint order. - One renderer (
renderers/canvasRenderer.ts) draws theLayer[]to a 2D canvas, and both the preview and the export go through it — so what you see is what you get. The preview draws at screen resolution (capped at the export resolution); the export draws at full size. - Export (
exporter.ts) writes MP4 (H.264) by default via deterministic, frame-by-frame WebCodecs encoding (30fps), falling back to a real-timeMediaRecorderWebM where WebCodecs is unavailable. The muxers are imported on demand, so they stay out of the initial bundle. The selected aspect ratio sets the output resolution (predefined instore.ts: 1:1→1080², 9:16→1080×1920, 3:4→1080×1440, 4:3→1440×1080, 16:9→1920×1080). - State lives in a single zustand store (
src/animator/store.ts): selection, per-template params, canvas settings, text overlay, assets, playback. None of it is persisted yet, so a reload starts over.
The animator itself is client-only (dynamic with ssr: false), so pages/index.tsx
gives it a static skeleton to render as the loading state — the server HTML carries
the layout instead of an empty div. Inter is self-hosted through next/font, so the
first paint does not wait on a stylesheet from another origin.
The preview and the timeline subscribe to the playback loop (usePlayback.ts)
and write to the DOM themselves, so a frame costs no render. Nothing subscribes
to time; a component that needs it reads useAnimator.getState().
The starter assets are bundled images in the repo (no network calls):
public/assets/*.avif— AVIF, long side 1200px, served same-origin so canvas export never hits CORS tainting. AVIF at quality 70 is 70% smaller than the equivalent JPEG and stays above 41 dB PSNR on the worst image in the set.src/animator/placeholderAssets.json— the manifest (file, name, aspect).
To swap them, drop new images in public/assets/ and update the manifest. Uploads
from the right panel still work alongside these.
The sidebar list points straight at the full-size file. It paints each asset in a 32×40 box, so the set decodes about 61MB of bitmap to fill a few thousand pixels — worth a downscaled variant once the asset source settles.
Carousel · Stories (vertical filmstrip) · Grid · Orbit · Marquee · Hero.
- Create
src/animator/templates/yours.tsexporting aTemplate(id, name, group,defaultDuration,params,render). - Register it in
src/animator/templates/index.ts.
The controls panel, timeline, preview, and export all pick it up with no extra work.
Keep render a pure function of t. It is what makes scrubbing, looping and
export agree, and it means a template can be checked frame by frame without a
browser.
- Real Savee inspo picker (currently bundled placeholders + uploads).
- More templates (3D, parallax, spin, wheel variants) and per-layer keyframing.
- A "Save as custom" preset library (the Custom tab + button are stubbed).
- Per-asset focal point / cropping. Every template currently draws at 3:4, which
discards on average 15% of an asset and up to 43% of a landscape one —
Asset.aspectis recorded but nothing reads it yet. - Seamless loops. A scrolling or rotating template only repeats after a whole
cycle, so the loop cuts unless
speed × durationlands on one — Orbit jumps a third of the canvas diagonal at its default settings. - Undo/redo, keyboard transport shortcuts, MP4/WebM and frame-rate pickers
(
canvas.formatandcanvas.fpsexist in the store with no UI). - Audio track + waveform.