chore(skills+remotion): add flat-artist skill bundle; register 3D templates
- .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:
@@ -0,0 +1,95 @@
|
||||
---
|
||||
name: kinetic-typography
|
||||
description: How to build animated-text systems for FlatRender Remotion templates — word/line/char reveals, mask wipes, typewriter, scale-pops, highlight sweeps, text-on-path, and number counters — Persian/RTL-aware and reusable. Use whenever a template's hero, caption, quote, title, price, or any text is the thing that moves. Persian is the priority; split by WORD, never by character.
|
||||
---
|
||||
|
||||
# Kinetic typography (animated text systems)
|
||||
|
||||
Type is a first-class motion element here, not a label. A masterpiece text shot is ~5 layers: the right split, eased per-unit timing, a hold sized to a real read, legibility over the background, and a single hero word. Amateurs stop at "the text fades in."
|
||||
|
||||
## The one rule
|
||||
Every value is a pure function of `useCurrentFrame()`. **Never** `useFrame`, `Math.random()`, `Date.now()`, `setState`, or `useEffect`-driven motion — the headless renderer samples frames out of order. For "random" jitter use `rand(seed)` from `lib/anim.ts`. Drive timing off `useVideoConfig().fps`; define `const sec = (s: number) => Math.round(s * fps)` — never hardcode `30`.
|
||||
|
||||
## Persian / RTL — get this right first (it's an Iran-facing product)
|
||||
- **Split by WORD, not character.** Persian script is connected/cursive — splitting on chars shatters letterforms and joins. Latin char-reveals are fine; Persian is word- or line-only. A safe split is `text.split(/\s+/).filter(Boolean)` — this **preserves ZWNJ** (نیمفاصله, ``) inside words like «میشود» because ZWNJ is not whitespace. Never `.split("")` or `.replace(//g, …)` on Persian.
|
||||
- Every text node: `fontFamily: FONT` (Vazirmatn, from `lib/fonts.ts`), `direction: "rtl"`, align right or center. The existing `KineticQuote.tsx` hardcodes Georgia/serif + pixel sizes + no RTL — **do not copy that**; it's a Latin-only relic.
|
||||
- Persian needs weight (headings 700–900) and `lineHeight: 1.4–1.6`. Numerals: pick Persian (۱۲۳ via `toLocaleString('fa-IR')`) or Latin and stay consistent; prices/years are usually Persian digits. See `../persian-fonts/SKILL.md`.
|
||||
- For RTL word reveals, the wrapping container does the ordering — keep `flexWrap: "wrap"` + `direction: "rtl"` and let words flow; don't manually reverse the array.
|
||||
|
||||
## Size & position from layout tokens, never pixels
|
||||
Read `useLayout()` from `lib/aspect.ts`: `vmin(n)`, `unit`, `isWide/isSquare/isTall`. Hero type ≈ `vmin(80–110)`, body ≈ `vmin(28–40)`. Tune timing/scale per aspect — wider reads faster (tighter stagger), tall reads slower (looser). Add this `pick` helper to `Layout` (per R&D Tier-0) and use it:
|
||||
```ts
|
||||
const pick = <T,>(w: T, s: T, t: T) => (L.isWide ? w : L.isSquare ? s : t);
|
||||
const stagger = pick(2, 3, 4); // frames between units
|
||||
```
|
||||
|
||||
## Animation patterns (all driven by `frame - start`)
|
||||
|
||||
| Pattern | Recipe | Persian-safe? |
|
||||
|---|---|---|
|
||||
| **Word reveal** (default) | split words; per word `start = i*stagger`; `spring({frame: frame-start, fps})` → `translateY(vmin)` + `opacity` | ✅ word-split |
|
||||
| **Line reveal** | wrap by line in `<Sequence>`s; each line springs up behind a `clip-path` edge | ✅ |
|
||||
| **Char reveal / scatter** | split chars, per-char delay; rotate/scale in | ❌ Latin only |
|
||||
| **Mask wipe** | `clipPath: inset(0 ${100-p}% 0 0)` (RTL: wipe from right → `0 0 0 ${100-p}%`); `p = interpolate(frame,[a,b],[0,100],{extrapolateRight:"clamp", easing: Easing.out(Easing.cubic)})` | ✅ |
|
||||
| **Typewriter** | `text.slice(0, Math.floor(interpolate(frame,[a,b],[0, words.length])))` joined — **slice by WORD for Persian**, by char only for Latin; add a blinking caret `frame % sec(0.8) < sec(0.4)` | ✅ word-slice |
|
||||
| **Scale-pop ("ta-da")** | `scale = spring({config:{damping:12,mass:0.6,stiffness:180}})` or `Easing.bezier(0.34,1.56,0.64,1)` overshoot→settle | ✅ |
|
||||
| **Highlight sweep** | gradient bar/`background-clip:text` shifting `background-position` per frame, or an accent rect growing under a key word | ✅ |
|
||||
| **Text-on-path** | SVG `<textPath href="#p">`; animate `startOffset` by frame — Latin/numeric only (RTL on a path is unreliable) | ❌ |
|
||||
| **Number counter** | `Math.round(interpolate(frame,[a,b],[0, target],{extrapolateRight:"clamp", easing: Easing.out(Easing.cubic)}))` then `toLocaleString('fa-IR')` | ✅ (format fa) |
|
||||
| **Variable-weight pulse** | Vazirmatn ships a variable axis: `fontVariationSettings: \`'wght' ${interpolate(frame,[a,b],[300,900])}\`` (needs the variable woff2 registered in `fonts.ts`) | ✅ |
|
||||
|
||||
### Reusable word-reveal component (the workhorse — Persian-correct, aspect-aware)
|
||||
```tsx
|
||||
const RevealText: React.FC<{ text: string; start: number; color: string }> = ({ text, start, color }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const L = useLayout();
|
||||
const pick = <T,>(w: T, s: T, t: T) => (L.isWide ? w : L.isSquare ? s : t);
|
||||
const stagger = pick(2, 3, 4);
|
||||
const words = text.split(/\s+/).filter(Boolean); // keeps ZWNJ
|
||||
return (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", justifyContent: "center",
|
||||
direction: "rtl", fontFamily: FONT, fontWeight: 800, fontSize: L.vmin(96),
|
||||
lineHeight: 1.4, color, gap: `0 ${L.vmin(18)}px`, maxWidth: "86%",
|
||||
textShadow: `0 ${L.vmin(2)}px ${L.vmin(20)}px rgba(0,0,0,.6)` }}>
|
||||
{words.map((w, i) => {
|
||||
const s = spring({ frame: frame - start - i * stagger, fps,
|
||||
config: { damping: 16, mass: 0.7, stiffness: 120 } });
|
||||
return (
|
||||
<span key={i} style={{ display: "inline-block", opacity: s,
|
||||
transform: `translateY(${interpolate(s, [0, 1], [L.vmin(28), 0])}px)` }}>
|
||||
{w}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
Follow-through upgrade: give a trailing accent word a *looser* spring (`damping: 6`) so it settles last.
|
||||
|
||||
## Easing & spring (linear is the sound of an amateur)
|
||||
- Entrances → **ease-out** default (`Easing.out(Easing.cubic)`); hero titles → `Easing.bezier(0.16,1,0.3,1)`. Exits → **ease-in, sharper than the entrance**. Snappy pop → back bezier `(0.34,1.56,0.64,1)`.
|
||||
- `interpolate` for exact marks — **always `extrapolateLeft/Right: "clamp"`** (forgetting it is the #1 drift bug). `spring` for organic feel. Combine: `interpolate(spring(...), [0,1], [vmin(28), 0])`.
|
||||
- Spring cheats: clean reveal `{damping:200,mass:0.5,stiffness:200}` · default pop `{damping:12,mass:0.6,stiffness:180}` · bouncy `{damping:8,mass:1,stiffness:120}` · trailing wobble `{damping:6,mass:1,stiffness:80}`.
|
||||
|
||||
## Timing budgets (@ whatever `fps` is)
|
||||
Micro pop 8–14f · word stagger 2–4f · standard reveal 18–28f · hero entrance 28–40f · **hold = a comfortable read** (≥ `sec(0.7)` per text element before the next competes). Cut frames before adding them — over-animating reads as amateur. Anticipation: dip below start before launch (`interpolate(frame,[0,6,30],[0,-0.12,1])`).
|
||||
|
||||
## Legibility over busy / 3D / video backgrounds
|
||||
- Scrim or `textShadow: 0 0 vmin(20) rgba(0,0,0,.7)`, or a semi-transparent panel behind text.
|
||||
- Gradient text: `WebkitBackgroundClip: "text"`, transparent fill, plus a `drop-shadow` for edge separation.
|
||||
- Colors come from `colorSchema` props (`accentColor/secondaryColor/backgroundColor/textColor` via `lib/branding.ts`) — pass user hex through `mixHex`/`hexToRgba` so a garish value doesn't break the look. Never hardcode `#fff`.
|
||||
- Captions (TikTok/Reels/Shorts) = high-contrast white/yellow + black outline, lower-middle third, inside the tightest safe zone. See `../remotion-aspect-ratios/SKILL.md`.
|
||||
|
||||
## Checklist
|
||||
- [ ] Persian text split by WORD; ZWNJ preserved; `direction:"rtl"` + `fontFamily: FONT`.
|
||||
- [ ] All sizes via `vmin`/`unit`; timing/stagger via `pick(...)` per aspect — verified in 16:9, 1:1, 9:16.
|
||||
- [ ] No linear easing; ≥1 overshoot-and-settle; staggered, not all on frame 0.
|
||||
- [ ] Every `interpolate` clamps both ends; no `useFrame`/`random`/`Date.now`; `fps` not `30`.
|
||||
- [ ] Numbers formatted (`fa-IR`) and consistent; counter eases out.
|
||||
- [ ] Legible over the background (scrim/shadow); colors from props.
|
||||
- [ ] A real hold sized to reading; longest Persian string doesn't overflow, shortest doesn't look empty.
|
||||
- [ ] Re-render twice → identical pixels (deterministic).
|
||||
|
||||
Related: `../remotion-template-composition/SKILL.md`, `../persian-fonts/SKILL.md`, `../remotion-aspect-ratios/SKILL.md`, `../remotion-design-styles/SKILL.md`, `../remotion-svg-colors/SKILL.md`, `../remotion-sound-effects/SKILL.md`, `../remotion-music-picker/SKILL.md`, `../flatrender-template-seo/SKILL.md`.
|
||||
Reference in New Issue
Block a user