chore(skills+remotion): add flat-artist skill bundle; register 3D templates
CI/CD / CI · Web (tsc) (push) Successful in 1m19s
CI/CD / Deploy · full stack (push) Failing after 12s

- .claude/skills/flat-artist: the bundled FlatRender template-creation suite
  (orchestrator + 16 sub-skills + design/motion R&D), mirrors the Gitea AISkills repo.
- services/remotion Root.tsx/templates.tsx: register the 3D templates + Three3DTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-21 19:39:25 +03:30
parent cb11c177a7
commit f83d657844
20 changed files with 1750 additions and 2 deletions
@@ -0,0 +1,167 @@
---
name: motion-design-principles
description: The foundation motion-craft reference for FlatRender Remotion templates — easing curves and when to reach for each, timing & spacing, the 12 animation principles applied to Remotion, anticipation/overshoot/follow-through/settle, staggering & choreography, secondary motion, spring() vs interpolate(), and the blocking→timing→polish workflow. Use whenever animating ANY element in a template, reviewing motion quality, or deciding how something should enter, move, or leave. Read this BEFORE writing animation code.
---
# Motion design principles (the FlatRender craft floor)
Project: `services/remotion/` (Remotion 4 + `@remotion/three`, R3F v9, `gl="angle"`). Three aspects (16:9 / 1:1 / 9:16), Persian-first (Vazirmatn, RTL). Helpers: `src/lib/anim.ts` (`hexToRgba`, `mixHex`, `rand`), `src/lib/aspect.ts` (`useLayout``isWide/isSquare/isTall`, `vmin`, `unit`, `pick`), `src/lib/branding.ts` (`colorSchema`, `BRAND`), `src/lib/fonts.ts` (`FONT` = Vazirmatn), `src/lib/three-kit.tsx` (`StudioEnv/Lights/Floor/Effects`, `Confetti3D`).
**Linear motion is the sound of an amateur. Almost nothing in a FlatRender template should move at a constant rate.** This skill is the floor every template stands on.
## The one rule everything hangs on
A Remotion frame is **pure**: `frame → pixels`, sampled at an arbitrary `t` (the After Effects mental model — a keyframe graph read at time `t`). The renderer samples frames **out of order and in parallel**.
- Derive every value from `useCurrentFrame()`. If a value can't be, it doesn't belong in the render.
- **Never** `useFrame` (R3F), `Math.random()`, `Date.now()`, `setState`, or `useEffect`-driven motion. For "randomness" use `rand(seed)` from `anim.ts`.
- **Never hardcode 30fps.** `const { fps } = useVideoConfig(); const sec = (s: number) => Math.round(s * fps);`
## `spring()` vs `interpolate()` — pick deliberately
| | `interpolate()` | `spring()` |
|---|---|---|
| Who authors the curve | **you** (explicit easing) | **physics** (mass/damping/stiffness) |
| Reach for it when | a value must hit an exact mark on an exact frame — storyboard reveals, crossfades, value remaps, color/blur sweeps | organic entrances, pops, bounces, anything that should "feel" alive |
| The trap | forgetting `extrapolate*: "clamp"` → elements drift off-screen / opacity goes negative | trying to land a value on an exact frame |
**Always combine them** — spring drives the *feel* (0→1), interpolate *remaps* it to real px/units in the layout's own scale:
```tsx
const L = useLayout();
const p = spring({ frame: frame - start, fps, config: { mass: 0.6, damping: 12, stiffness: 180 } });
const y = interpolate(p, [0, 1], [L.vmin(80), 0]); // remap into layout units
const opacity = interpolate(p, [0, 1], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
```
### Spring config cheat-sheet
Lower `damping` = more overshoot · higher `mass` = heavier/slower · higher `stiffness` = faster snap.
| Feel | mass | damping | stiffness | Use for |
|---|--:|--:|--:|---|
| Snappy, no overshoot | 0.5 | 200 | 200 | Clean UI / logo reveals |
| **Natural pop (default)** | 0.6 | 12 | 180 | Cards, badges, icons |
| Bouncy / playful | 1 | 8 | 120 | Kids, birthday, mascots |
| Heavy / weighty | 2.5 | 26 | 90 | Big titles, 3D objects landing |
| Loose wobble (follow-through) | 1 | 6 | 80 | Secondary / trailing parts |
## Easing cheat-sheet (`import { Easing } from "remotion"`)
| Situation | Curve | Why |
|---|---|---|
| **Entrances (default)** | `Easing.out(Easing.cubic)` | things arrive and decelerate |
| Hero title entrance | `Easing.out(Easing.quint)` or `Easing.bezier(0.16, 1, 0.3, 1)` | dramatic deceleration |
| **Exits** | `Easing.in(Easing.cubic)`**always sharper than the entrance** | things leave faster than they arrive |
| A→B on-screen move / camera | `Easing.inOut(Easing.cubic)` | smooth both ends |
| "Ta-da" overshoot | `Easing.bezier(0.34, 1.56, 0.64, 1)` | snappy pop past target |
| Wind-up / anticipation | `Easing.bezier(0.36, 0, 0.66, -0.56)` | dips below before launch |
| **Linear ONLY** | `Easing.linear` | rotation, scroll, conveyor, marquee — mechanical continuous motion |
```tsx
const t = interpolate(frame, [start, start + 24], [0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) });
```
## Timing & spacing (30fps baseline — but always derive with `sec()`)
Spacing (the easing) sets *feel*; timing (frame count) sets *weight & mood*. **Cut frames before you add them — amateurs over-animate.**
| Beat | Frames @30fps |
|---|---|
| Micro pop (icon, badge) | 814 |
| Standard reveal | 1828 |
| Hero entrance | 2840 |
| Scene transition | 1220 |
| Hold | a comfortable read of the text (size to the longest Persian string) |
Symptoms: robotic = linear spacing · floaty/late = timing too long · jittery = no hold between moves.
## The 12 principles → Remotion (the four in **bold** you reach for every shot)
| Principle | Remotion expression |
|---|---|
| Squash & stretch | `scaleX`/`scaleY` inversely around an impact frame, conserve volume (`sx = 1/sy`) |
| **Anticipation** | dip the value below its start before the main move |
| Staging | stagger reveals; dim/blur everything but the hero — one idea per beat |
| Straight-ahead vs pose-to-pose | `interpolate` between keyed frames vs per-frame formula (sim, e.g. `Confetti3D`) |
| **Follow-through & overlapping** | same trigger, **delayed per child** + a *looser* spring so parts settle later |
| **Slow in & slow out** | `Easing.bezier` / `spring()` — the single biggest quality lever |
| Arcs | drive `y` with `sin`/parabola while `x` moves linearly |
| Secondary action | a small `sin` bob/shimmer alongside the primary reveal |
| Timing | frame count + spring `mass`/`damping` = weight & mood |
| **Exaggeration / overshoot** | overshoot > 1.0, then settle to 1.0 |
| Solid drawing | `StudioLights` + reflective material + floor shadows (3D) |
| Appeal | choreography + `StudioEffects` (bloom/DOF/vignette) + good type |
## The four quality multipliers (concrete, reusable)
**Anticipation** — a small negative dip before launch:
```tsx
const scale = interpolate(frame, [start, start + 6, start + 30], [0, -0.12, 1],
{ extrapolateRight: "clamp", easing: Easing.bezier(0.36, 0, 0.66, -0.56) });
```
**Overshoot + settle** — reach past, then land. Ensure the curve *holds* the target (clamp) or it micro-drifts forever:
```tsx
const pop = interpolate(frame, [start, start + 18], [0, 1],
{ extrapolateRight: "clamp", easing: Easing.bezier(0.34, 1.56, 0.64, 1) });
// or: spring with low damping (config { mass: 0.6, damping: 10, stiffness: 170 })
```
**Follow-through** — drive children from the *same* trigger, delay each, looser spring so they settle after the parent. The biggest "feels professional" upgrade for grouped elements:
```tsx
function Child({ i, start }: { i: number; start: number }) {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const p = spring({ frame: frame - start - i * 4, fps, config: { mass: 1, damping: 6, stiffness: 80 } });
return <g style={{ transform: `translateY(${interpolate(p, [0, 1], [24, 0])}px)`, opacity: p }} />;
}
```
**Secondary motion** — never let a held element go dead. Add a tiny `sin` breathe/shimmer:
```tsx
const bob = Math.sin(frame / fps * Math.PI) * L.vmin(4); // gentle float during the hold
```
## Staggering & choreography
Default to a **cascade**, and **tune the stagger per aspect** — wider frames read faster (tighter stagger), tall frames read slower (looser):
```tsx
const L = useLayout();
const stagger = L.pick(/*wide*/ 3, /*square*/ 4, /*tall*/ 5); // pick(wide, square, tall)
const start = i * stagger;
```
Patterns: **cascade** (lists/features) · **center-out** (logo/hero rows: `delay = Math.abs(i - mid) * stagger`) · **deterministic random** (particles: `rand(i)` for delay/offset) · **beat-synced** (snap `start` to music beat frames — see `../remotion-music-picker/SKILL.md`). **One thing enters the eye at a time.**
> `pick` is the standard per-aspect selector on `useLayout()`. If it isn't on `Layout` yet, add it in `aspect.ts`: `pick: <T,>(wide: T, square: T, tall: T): T => kind === "wide" ? wide : kind === "tall" ? tall : square,`
## 3D motion (`@remotion/three`)
Drive every transform off `useCurrentFrame()` (deterministic under ANGLE) — **never `useFrame`**. Rotation/orbit = `linear` (mechanical); entrances/landings = `spring` with **high mass** for weight. Keep crisp Persian text as a 2D `<AbsoluteFill>` overlay above `<ThreeCanvas>`. Let `StudioEffects` (bloom + DOF + vignette) carry the cinematic polish in one component; tune `camera.fov`/`position.z` per aspect so the subject fills the frame.
## The pro workflow — 5 passes, IN ORDER
Polishing before timing is locked wastes the most time.
1. **Reference** — decide the feel before code; pick style (`../remotion-design-styles/SKILL.md`), type (`../persian-fonts/SKILL.md`), composition (`../remotion-template-composition/SKILL.md`), per-aspect rules (`../remotion-aspect-ratios/SKILL.md`). Write the beat list ("logo in → tagline → 3 features cascade → CTA → out").
2. **Blocking** — every element at its final position with crude `interpolate` fades, no easing. Fix off-screen/cropping in all three aspects NOW.
3. **Timing** — lock frame counts, stagger, beats, holds, transitions. Watch at full speed repeatedly. Mood lives here.
4. **Polish** — swap linear for easing/springs; add anticipation + overshoot/settle, follow-through, secondary motion, arcs, squash/stretch; `StudioEffects` for 3D; wire SFX (`../remotion-sound-effects/SKILL.md`) + music sync (`../remotion-music-picker/SKILL.md`) to the locked frames.
5. **Review** — scrub frame-by-frame + full speed against the checklist below.
## Top amateur mistakes → fixes (review gate)
- Linear motion → ease/spring · no anticipation/overshoot → dip-then-launch / back bezier
- Everything on one frame → stagger · forgot `clamp` → clamp both ends
- Hardcoded 30fps → `useVideoConfig().fps` + `sec()`
- `useFrame`/`random`/`Date.now()``useCurrentFrame` + `rand`
- Pixel-hardcoded sizes → `vmin`/`unit` + `pick`/`isWide/isSquare/isTall`
- Over-animating → one idea per beat · no hold → real hold sized to reading
- Exit speed = entrance speed → exits sharper · dead holds → `sin` bob/breathe/shimmer
- Color hardcoded → read from `colorSchema` props
## Pre-ship motion checklist
- [ ] No linear easing anywhere except mechanical continuous motion (rotation/marquee).
- [ ] Entrances ease-out; exits ease-in **and sharper** than entrances.
- [ ] Every `interpolate` that could overshoot has `extrapolateLeft/Right: "clamp"`.
- [ ] At least one anticipation (dip) and one overshoot-and-settle in the piece.
- [ ] Grouped elements stagger; trailing parts follow through (looser spring).
- [ ] No dead holds — held heroes have a subtle `sin` breathe/shimmer.
- [ ] Stagger/scale tuned per aspect via `pick`; verified in 16:9 / 1:1 / 9:16.
- [ ] All timing from `sec()`/`fps`; no hardcoded 30; no `useFrame`/`random`/`Date.now`.
- [ ] One clear hero moment with the biggest motion; the eye always knows where to look.
- [ ] Re-render twice → pixel-identical (deterministic).
Related: `../remotion-design-styles/SKILL.md`, `../remotion-aspect-ratios/SKILL.md`, `../remotion-template-composition/SKILL.md`, `../remotion-character-design/SKILL.md`, `../remotion-sound-effects/SKILL.md`, `../remotion-music-picker/SKILL.md`, `../persian-fonts/SKILL.md`, `../flatrender-template-seo/SKILL.md`.