Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8177797bd9 | ||
|
|
36817b6b12 |
No files matched your search
Whitespace-only changes.
@@ -1,210 +0,0 @@
|
||||
---
|
||||
name: 60fps-animation
|
||||
description: This skill should be used when the user asks to "fix janky CSS animation", "make animation 60fps", "stop layout thrashing", "animate width/height/top/left smoothly", "convert animation to transform", "animate box-shadow performantly", "animate height auto", "FLIP animation", or "why is my scroll/hover animation choppy".
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Performant Web Animation
|
||||
|
||||
Eliminate the #1 cause of janky web animation: animating properties that force the browser to recalculate layout (reflow) or repaint on every frame. The browser renders in stages — **layout → paint → composite**. Animating `width`, `height`, `top`, `left`, `margin`, `padding`, `box-shadow`, or `filter` re-runs layout and/or paint each frame, blocking the main thread. Animating **`transform`** and **`opacity`** runs entirely on the compositor (often the GPU), skipping layout and paint, which is what makes animation smooth at 60/120fps.
|
||||
|
||||
## When to use
|
||||
|
||||
Use when an animation stutters or drops frames, when a hover/scroll effect feels heavy, when animating size/position/shadow, when a layout change needs to animate smoothly (cards reordering, an element moving between containers), when animating `height: auto`, or when reviewing animation code for performance.
|
||||
|
||||
## Core rule: animate only transform and opacity
|
||||
|
||||
Map every "expensive" animation to a cheap equivalent.
|
||||
|
||||
| Animating (expensive) | Triggers | Replace with (cheap) |
|
||||
|---|---|---|
|
||||
| `width` / `height` | Layout + Paint | `transform: scaleX()/scaleY()` (+ FLIP for true size) |
|
||||
| `top` / `left` / `margin` | Layout + Paint | `transform: translate()` |
|
||||
| `box-shadow` | Paint | Animate `opacity` of a pseudo-element holding the shadow |
|
||||
| `filter: blur()` | Paint (heavy) | Cross-fade two layers via `opacity`, or accept sparingly |
|
||||
| `background-position` | Paint | `transform: translate()` on a child layer |
|
||||
| `color` / `background` | Paint | Often acceptable; or cross-fade layers |
|
||||
|
||||
### Position and size via transform
|
||||
|
||||
```css
|
||||
/* BAD: animates layout every frame */
|
||||
.box { transition: left 300ms, width 300ms; left: 0; width: 100px; }
|
||||
.box:hover { left: 200px; width: 200px; }
|
||||
|
||||
/* GOOD: compositor-only */
|
||||
.box {
|
||||
transition: transform 300ms ease;
|
||||
transform: translateX(0) scaleX(1);
|
||||
transform-origin: left center;
|
||||
}
|
||||
.box:hover { transform: translateX(200px) scaleX(2); }
|
||||
```
|
||||
|
||||
`scaleX` distorts inner content (text stretches). For true resizing without distortion, use **FLIP**.
|
||||
|
||||
## Cheap box-shadow via pseudo-element opacity
|
||||
|
||||
Animating `box-shadow` repaints a large blurred region every frame. Instead paint the shadow once on a `::after`, then animate only its `opacity`.
|
||||
|
||||
```css
|
||||
.card { position: relative; }
|
||||
.card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
box-shadow: 0 12px 28px rgba(0,0,0,0.35);
|
||||
opacity: 0;
|
||||
transition: opacity 300ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.card:hover::after { opacity: 1; }
|
||||
```
|
||||
|
||||
The blurred shadow is rasterized once; hovering only changes a compositor opacity — smooth at any frame rate.
|
||||
|
||||
## FLIP: animate layout changes cheaply
|
||||
|
||||
FLIP (First, Last, Invert, Play) animates a *layout* change (reorder, resize, move between containers) using only `transform`. Measure where the element was (First) and will be (Last), apply an inverting transform so it visually appears unmoved, then animate the transform back to identity (Play). The DOM ends in its real final layout; the motion is pure compositor work.
|
||||
|
||||
```js
|
||||
function flip(el, mutate) {
|
||||
const first = el.getBoundingClientRect(); // First
|
||||
mutate(); // change the DOM/layout
|
||||
const last = el.getBoundingClientRect(); // Last
|
||||
|
||||
const dx = first.left - last.left;
|
||||
const dy = first.top - last.top;
|
||||
const sx = first.width / last.width;
|
||||
const sy = first.height / last.height;
|
||||
|
||||
el.animate(
|
||||
[
|
||||
{ transformOrigin: 'top left',
|
||||
transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})` }, // Invert
|
||||
{ transformOrigin: 'top left', transform: 'none' }, // Play
|
||||
],
|
||||
{ duration: 300, easing: 'cubic-bezier(0.2, 0, 0, 1)' }
|
||||
);
|
||||
}
|
||||
|
||||
// usage: animate an element moving to a new grid position
|
||||
flip(card, () => targetContainer.appendChild(card));
|
||||
```
|
||||
|
||||
The Web Animations API `animate()` runs the transform on the compositor. For many elements, batch all `getBoundingClientRect()` reads *before* any mutation (see layout thrashing below).
|
||||
|
||||
## Animating height: auto
|
||||
|
||||
`height: auto` is not interpolatable historically. Modern and fallback approaches:
|
||||
|
||||
```css
|
||||
/* Modern (Chrome 129+/supporting browsers): opt size keywords into animation */
|
||||
.panel {
|
||||
interpolate-size: allow-keywords; /* enables animating to/from auto */
|
||||
height: 0;
|
||||
overflow: clip;
|
||||
transition: height 300ms ease;
|
||||
}
|
||||
.panel.open { height: auto; }
|
||||
/* calc-size() also works: height: calc-size(auto, size); */
|
||||
```
|
||||
|
||||
```css
|
||||
/* Robust fallback today: CSS grid 1fr -> 0fr */
|
||||
.wrapper {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr; /* collapsed */
|
||||
transition: grid-template-rows 300ms ease;
|
||||
}
|
||||
.wrapper.open { grid-template-rows: 1fr; }
|
||||
.wrapper > .content { overflow: hidden; min-height: 0; }
|
||||
```
|
||||
|
||||
The grid trick animates the track size (compositor-friendly enough and broadly supported) and needs no JS height measurement. Use `interpolate-size: allow-keywords` / `calc-size(auto, size)` where the target browsers support it; keep the grid technique as the cross-browser default.
|
||||
|
||||
## Avoid layout thrashing (batch reads, then writes)
|
||||
|
||||
Reading a layout property (`offsetWidth`, `getBoundingClientRect`, `scrollTop`, `getComputedStyle`) after a write forces a *synchronous* reflow. Interleaving reads and writes in a loop ("layout thrashing") can run dozens of forced reflows per frame.
|
||||
|
||||
```js
|
||||
// BAD: read, write, read, write... forces reflow each iteration
|
||||
items.forEach((el) => {
|
||||
const w = el.offsetWidth; // read (forces layout)
|
||||
el.style.width = w * 1.5 + 'px'; // write (invalidates layout)
|
||||
});
|
||||
|
||||
// GOOD: batch all reads, then all writes
|
||||
const widths = items.map((el) => el.offsetWidth); // all reads
|
||||
items.forEach((el, i) => { // all writes
|
||||
el.style.width = widths[i] * 1.5 + 'px';
|
||||
});
|
||||
```
|
||||
|
||||
For frame-synced work, read in a `requestAnimationFrame` callback and apply writes; libraries like fastdom formalize this read/write scheduling.
|
||||
|
||||
## will-change: use sparingly
|
||||
|
||||
`will-change: transform` promotes an element to its own compositor layer ahead of time, avoiding a hitch at animation start. But every promoted layer costs GPU memory, and over-use degrades performance.
|
||||
|
||||
```css
|
||||
.menu { will-change: transform; } /* only on elements about to animate */
|
||||
```
|
||||
|
||||
Rules: apply just before the animation (e.g. on hover/parent state), remove it after (`will-change: auto`) when idle, never blanket-apply to many elements, and never leave it permanently on large/numerous nodes. A single `transform: translateZ(0)` hack does the same promotion but is harder to undo — prefer `will-change`.
|
||||
|
||||
## Deliver & verify (standalone HTML)
|
||||
|
||||
> **Packaged helper** (`scripts/`): `scripts/seek-shot.sh anim.html 0 1.5 3` freezes the `?t=N` harness and screenshots each moment; `scripts/contact-sheet.sh sheet.png frame-*.png` tiles them for one-glance review. See `scripts/README.md`.
|
||||
|
||||
The deliverable is **one self-contained `.html`** that opens directly in a browser — the markup, the CSS/JS animation, and a freeze harness in one file. For this skill, verification is two-pronged: the frame must look right *and* be cheap to produce (compositor-only).
|
||||
|
||||
**Output contract:**
|
||||
- One `.html`, deps via CDN if any, animation driven by CSS transitions/`@keyframes` or the Web Animations API.
|
||||
- A freeze mechanism so a screenshot lands on a deterministic frame:
|
||||
- CSS `@keyframes` → `?t=N` sets `el.style.animationDelay = (-N)+'s'; el.style.animationPlayState = 'paused'`.
|
||||
- WAAPI / JS → keep the animation object and `anim.pause(); anim.currentTime = N*1000`.
|
||||
|
||||
**Verify loop — freeze → screenshot, then profile for jank:**
|
||||
1. Open headless at start / mid / end (`?t=0`, mid, end), screenshot each; confirm the motion is visually correct (no clipped/stretched text from `scaleX`, FLIP lands on the real layout, shadow/height transitions look right).
|
||||
2. **Confirm it's compositor-only** — the whole point of this skill. Either:
|
||||
- DevTools → Performance: record the animation, check there is **no purple "Layout" or green "Paint"** band per frame (only "Composite Layers").
|
||||
- Or headless trace: `npx playwright screenshot` for the visual, plus a CDP/`tracing` capture, and assert no `Layout`/`Paint` events fire during the animation window.
|
||||
3. Watch the FPS/“Frame Rendering Stats” overlay stays at 60 — dropped frames mean an expensive property slipped back in.
|
||||
|
||||
```bash
|
||||
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/anim.html?t=0.3" frame.png
|
||||
```
|
||||
|
||||
**Before you finish:**
|
||||
1. Opens standalone — no console errors.
|
||||
2. Only `transform` / `opacity` animate per frame; DevTools shows no per-frame Layout/Paint.
|
||||
3. Screenshotted at start / mid / end — correct, no `scaleX` text distortion, FLIP lands true.
|
||||
4. Holds 60fps; `will-change` applied just-in-time and removed when idle (no leftover layers).
|
||||
5. `prefers-reduced-motion` honored.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Goal | Do this |
|
||||
|---|---|
|
||||
| Move element | `transform: translate()` |
|
||||
| Resize without distortion | FLIP with `transform` |
|
||||
| Shadow on hover | Animate `opacity` of shadow pseudo-element |
|
||||
| Expand to content height | grid `0fr→1fr`, or `interpolate-size: allow-keywords` |
|
||||
| Many elements moving | Batch `getBoundingClientRect` reads, then writes |
|
||||
| Smooth animation start | `will-change` just-in-time, remove when idle |
|
||||
| Verify it's compositor-only | DevTools Performance: no purple "Layout"/green "Paint" per frame |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `scaleX/scaleY` stretches text and children; use FLIP when content must stay crisp.
|
||||
- `transform` percentages are relative to the element's own box, not the parent — different from `left: %`.
|
||||
- Overusing `will-change` or `translateZ(0)` creates too many layers and *hurts* performance; promote only what animates.
|
||||
- `filter` and `backdrop-filter` are compositor-related but still expensive; animate their presence via opacity cross-fades rather than animating the blur radius.
|
||||
- The grid `1fr→0fr` content must have `overflow: hidden` and `min-height: 0` or it won't collapse.
|
||||
- Always gate non-essential motion behind `@media (prefers-reduced-motion: reduce)`.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/patterns-and-profiling.md` — full runnable examples (accordion, reorder list, parallax), DevTools profiling walkthrough to confirm compositor-only frames, reduced-motion patterns, and a property-cost cheat sheet.
|
||||
@@ -1,132 +0,0 @@
|
||||
# Patterns, profiling, and cost cheat sheet
|
||||
|
||||
## Property cost cheat sheet
|
||||
|
||||
| Property | Pipeline stage triggered | Verdict |
|
||||
|---|---|---|
|
||||
| `transform` | Composite only | Cheap — animate freely |
|
||||
| `opacity` | Composite only | Cheap — animate freely |
|
||||
| `filter` | Paint (+composite) | Expensive — avoid animating radius |
|
||||
| `box-shadow` | Paint (large area) | Expensive — use pseudo + opacity |
|
||||
| `width`, `height` | Layout + Paint | Avoid — use scale/FLIP |
|
||||
| `top`, `left`, `right`, `bottom` | Layout + Paint | Avoid — use translate |
|
||||
| `margin`, `padding` | Layout + Paint | Avoid |
|
||||
| `border-width` | Layout + Paint | Avoid |
|
||||
| `color`, `background-color` | Paint | Acceptable in moderation |
|
||||
| `background-position` | Paint | Prefer translate on child |
|
||||
| `clip-path` | Paint | Moderate; cheaper than filter |
|
||||
|
||||
## Accordion with grid height (no JS measurement)
|
||||
|
||||
```html
|
||||
<button class="acc-trigger" aria-expanded="false">Details</button>
|
||||
<div class="acc-wrap"><div class="acc-content">…content…</div></div>
|
||||
```
|
||||
|
||||
```css
|
||||
.acc-wrap {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 280ms cubic-bezier(0.2,0,0,1);
|
||||
}
|
||||
.acc-wrap.open { grid-template-rows: 1fr; }
|
||||
.acc-content { overflow: hidden; min-height: 0; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.acc-wrap { transition: none; }
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
trigger.addEventListener('click', () => {
|
||||
const open = wrap.classList.toggle('open');
|
||||
trigger.setAttribute('aria-expanded', String(open));
|
||||
});
|
||||
```
|
||||
|
||||
## FLIP reorder list (batched reads)
|
||||
|
||||
```js
|
||||
function flipReorder(container, mutate) {
|
||||
const items = [...container.children];
|
||||
const firsts = new Map(items.map((el) => [el, el.getBoundingClientRect()])); // all reads
|
||||
|
||||
mutate(); // e.g. sort/reinsert children
|
||||
|
||||
items.forEach((el) => {
|
||||
const first = firsts.get(el);
|
||||
const last = el.getBoundingClientRect();
|
||||
const dx = first.left - last.left;
|
||||
const dy = first.top - last.top;
|
||||
if (dx || dy) {
|
||||
el.animate(
|
||||
[{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }],
|
||||
{ duration: 300, easing: 'cubic-bezier(0.2,0,0,1)' }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Reading every rect *before* mutating, and using `animate()` (compositor), keeps a 50-item reorder smooth.
|
||||
|
||||
## Just-in-time will-change
|
||||
|
||||
```js
|
||||
el.addEventListener('pointerenter', () => { el.style.willChange = 'transform'; });
|
||||
el.addEventListener('transitionend', () => { el.style.willChange = 'auto'; });
|
||||
```
|
||||
|
||||
Promote on intent, demote when the animation ends, so layers don't accumulate.
|
||||
|
||||
## Reduced-motion gate
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For JS: `const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;` and skip/shorten animations when true.
|
||||
|
||||
## Profiling: prove it's compositor-only
|
||||
|
||||
1. Open DevTools > **Performance**. Record while the animation runs.
|
||||
2. Inspect the flame chart frames during the animation:
|
||||
- **Purple "Layout"** or **green "Paint"** bars on every frame → still hitting layout/paint; the animation is not compositor-only. Find the offending property.
|
||||
- Only **"Composite Layers"** / GPU work per frame → success.
|
||||
3. Use **Rendering** tab tools:
|
||||
- **Paint flashing** — green flashes show repainted regions; a good transform/opacity animation flashes nothing.
|
||||
- **Layer borders** — confirms which elements are on their own compositor layer (watch for too many from over-using `will-change`).
|
||||
- **Frame Rendering Stats / FPS meter** — confirm a stable 60/120fps.
|
||||
4. The **Layers** panel shows layer count and memory; excessive layers indicate `will-change` overuse.
|
||||
|
||||
## Parallax without scroll-jank
|
||||
|
||||
Drive parallax with `transform: translate3d()` updated inside a single `requestAnimationFrame` loop reading `scrollY` once per frame — never read layout per element per frame.
|
||||
|
||||
```js
|
||||
let ticking = false;
|
||||
addEventListener('scroll', () => {
|
||||
if (!ticking) {
|
||||
requestAnimationFrame(() => {
|
||||
const y = window.scrollY; // single read
|
||||
layers.forEach((l, i) => { // writes only
|
||||
l.style.transform = `translate3d(0, ${y * (0.1 * (i + 1))}px, 0)`;
|
||||
});
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
}, { passive: true });
|
||||
```
|
||||
|
||||
`{ passive: true }` lets the browser scroll without waiting on the handler.
|
||||
|
||||
---
|
||||
Stay on transform and opacity, profile the jank away, and motion holds 60fps. Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=web-animation-skills&utm_content=skill_footer&utm_term=60fps-animation)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
Whitespace-only changes.
@@ -1,231 +0,0 @@
|
||||
---
|
||||
name: accessible-animation
|
||||
description: This skill should be used when the user asks to "respect prefers-reduced-motion", "honor reduced motion", "make my animations accessible", "fix vestibular / motion-sickness issues", "add a useReducedMotion hook", "gate GSAP / Framer Motion / Lenis for reduced motion", or "meet WCAG 2.3.3 / C39". Provides tiered (not all-or-nothing) reduced-motion patterns in CSS and JS.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Accessible Animation (Reduced Motion, Tiered)
|
||||
|
||||
Honor `prefers-reduced-motion: reduce` as a *graceful degradation*, not a kill switch. The OS-level setting (macOS: Settings > Accessibility > Display > Reduce motion; Windows: Settings > Accessibility > Visual effects > Animation effects; iOS/Android equivalents) signals that *vestibular-triggering* motion causes nausea, dizziness, or migraines. The correct response is to remove the dangerous motion while keeping orientation cues.
|
||||
|
||||
## When to use
|
||||
|
||||
Use when adding or auditing animation in a web UI, when a user reports motion sickness or WCAG findings, or when integrating animation libraries (GSAP, Framer Motion, Lenis smooth scroll, Anime.js, CSS keyframes) into an accessible product.
|
||||
|
||||
## The core principle: tier motion, do not nuke it
|
||||
|
||||
Removing *all* animation is a common over-correction. An instant state change with no transition can be more disorienting (elements teleport) and removes useful affordances (focus rings, loading spinners that communicate progress). Sort every animation into three tiers:
|
||||
|
||||
- **Tier 1 — Remove entirely (vestibular triggers).** Parallax; large-area slides/translates across the viewport; scale/zoom of large elements; 3D rotation and spin; continuous auto-playing carousels and marquees; smooth-scroll hijacking (Lenis/Locomotive); scroll-jacked pinned scenes; motion-path animation; anything that moves a large portion of the screen or implies depth/self-motion.
|
||||
- **Tier 2 — Soften / shorten.** Replace movement with a short opacity fade (≤200ms). Reduce distance and duration. Keep the *fact* of a transition (so elements don't teleport) but strip the displacement. Replace a 600ms slide-in-from-left with a 150ms cross-fade.
|
||||
- **Tier 3 — Always keep.** Opacity fades, color/background transitions, focus-ring transitions, loading indicators, and motion that is *essential* to meaning (WCAG allows essential animation). These rarely trigger vestibular responses because they imply no self-motion.
|
||||
|
||||
## CSS layer: media-query gating
|
||||
|
||||
Write motion as the default, then override under the reduce query. Prefer a global safety net plus targeted overrides.
|
||||
|
||||
```css
|
||||
/* Global safety net: neutralize runaway motion but DO NOT set 0s blindly,
|
||||
which can break JS that waits for transitionend/animationend. Use 0.01ms. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important; /* stop infinite spins/marquees */
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important; /* kill smooth scroll */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The global net is a backstop. Layer *intentional, tiered* overrides on top so Tier 3 motion survives:
|
||||
|
||||
```css
|
||||
.card {
|
||||
transition: transform 400ms ease, opacity 400ms ease, background-color 200ms ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card {
|
||||
/* Tier 2: drop the transform (movement), keep opacity + color (Tier 3) */
|
||||
transition: opacity 150ms ease, background-color 150ms ease;
|
||||
}
|
||||
.parallax-layer { transform: none !important; } /* Tier 1: remove */
|
||||
.hero-zoom { animation: none !important; } /* Tier 1: remove */
|
||||
}
|
||||
```
|
||||
|
||||
Use the *positive* query `(prefers-reduced-motion: no-preference)` to opt motion IN, which is the safest pattern for elaborate effects:
|
||||
|
||||
```css
|
||||
.hero { opacity: 1; } /* visible, static by default */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.hero { animation: zoom-in 1.2s ease both; } /* only animate when allowed */
|
||||
}
|
||||
```
|
||||
|
||||
## JS layer: matchMedia gating
|
||||
|
||||
CSS cannot gate JS-driven animation (GSAP timelines, Framer's `animate`, canvas/WebGL, Lenis). Read the same signal via `matchMedia` and react to live changes (users toggle the OS setting without reloading).
|
||||
|
||||
```js
|
||||
const REDUCE_QUERY = '(prefers-reduced-motion: reduce)';
|
||||
|
||||
export function prefersReducedMotion() {
|
||||
return typeof window !== 'undefined'
|
||||
&& window.matchMedia
|
||||
&& window.matchMedia(REDUCE_QUERY).matches;
|
||||
}
|
||||
|
||||
// Live updates: listen so toggling the OS setting takes effect immediately.
|
||||
export function onReducedMotionChange(cb) {
|
||||
const mq = window.matchMedia(REDUCE_QUERY);
|
||||
const handler = (e) => cb(e.matches);
|
||||
mq.addEventListener('change', handler); // modern API
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}
|
||||
```
|
||||
|
||||
### Library gating, applied as one reusable layer
|
||||
|
||||
```js
|
||||
// GSAP — use matchMedia(): GSAP reverts conditional setups on change.
|
||||
import gsap from 'gsap';
|
||||
const mm = gsap.matchMedia();
|
||||
mm.add({
|
||||
motionOK: '(prefers-reduced-motion: no-preference)',
|
||||
motionReduce: '(prefers-reduced-motion: reduce)',
|
||||
}, (ctx) => {
|
||||
const { motionOK } = ctx.conditions;
|
||||
if (motionOK) {
|
||||
gsap.from('.hero', { y: 80, opacity: 0, duration: 1 }); // Tier 1 move
|
||||
} else {
|
||||
gsap.from('.hero', { opacity: 0, duration: 0.15 }); // Tier 2 fade
|
||||
}
|
||||
});
|
||||
|
||||
// Lenis smooth scroll — do not instantiate at all when reduced.
|
||||
import Lenis from 'lenis';
|
||||
let lenis = null;
|
||||
if (!prefersReducedMotion()) {
|
||||
lenis = new Lenis();
|
||||
const raf = (t) => { lenis.raf(t); requestAnimationFrame(raf); };
|
||||
requestAnimationFrame(raf);
|
||||
}
|
||||
```
|
||||
|
||||
## React: a `useReducedMotion` hook
|
||||
|
||||
```jsx
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useReducedMotion() {
|
||||
const query = '(prefers-reduced-motion: reduce)';
|
||||
const get = () =>
|
||||
typeof window !== 'undefined' && window.matchMedia
|
||||
? window.matchMedia(query).matches
|
||||
: false;
|
||||
const [reduced, setReduced] = useState(get);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(query);
|
||||
const onChange = () => setReduced(mq.matches);
|
||||
onChange(); // sync after hydration
|
||||
mq.addEventListener('change', onChange);
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
return reduced;
|
||||
}
|
||||
```
|
||||
|
||||
```jsx
|
||||
// Framer Motion has its own useReducedMotion(); the hook above also works.
|
||||
import { motion } from 'framer-motion';
|
||||
function Card() {
|
||||
const reduced = useReducedMotion();
|
||||
return (
|
||||
<motion.div
|
||||
initial={reduced ? { opacity: 0 } : { opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: reduced ? 0.15 : 0.5 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Initialize SSR-rendered state to `false` (motion) and re-sync in `useEffect` to avoid a hydration mismatch; the OS value is only known client-side.
|
||||
|
||||
## WCAG mapping
|
||||
|
||||
- **2.3.3 Animation from Interactions (AAA).** Motion triggered by interaction (scroll, hover) must be disable-able unless essential. Honoring `prefers-reduced-motion` is the standard technique.
|
||||
- **C39 (CSS technique).** Use the `prefers-reduced-motion` query to prevent motion. This is the named sufficient technique.
|
||||
- **2.2.2 Pause, Stop, Hide (A).** Any auto-playing motion lasting >5s must offer pause/stop. Auto-carousels and marquees fail this regardless of the media query — give a control too.
|
||||
|
||||
## Deliver & verify (standalone HTML)
|
||||
|
||||
> **Packaged helper** (`scripts/`): `scripts/seek-shot.sh anim.html 0 1.5 3` freezes the `?t=N` harness and screenshots each moment; `scripts/contact-sheet.sh sheet.png frame-*.png` tiles them for one-glance review. See `scripts/README.md`.
|
||||
|
||||
For a self-contained demo the deliverable is **one HTML file that opens directly in a browser** — markup, CSS, and any JS (GSAP/`matchMedia` from CDN) inline, no build step. For *this* skill the verification is the point: you must screenshot **both** the normal and the reduced-motion path and confirm the tiering is correct.
|
||||
|
||||
**Output contract:**
|
||||
- One `.html` file with the full motion AND its tiered reduced-motion overrides (CSS query + `matchMedia`/`gsap.matchMedia()` for JS-driven motion).
|
||||
- A seek harness to freeze a frame, plus a way to force the reduced-motion code path for screenshots.
|
||||
|
||||
**Seek harness — freeze a frame, force the preference.** `?t=N` freezes the timeline; `?reduce=1` forces the reduced path even when the OS setting is off (CSS can't be toggled from JS, so query the same flag you gate JS on, and screenshot with the OS/browser preference emulated for the CSS layer):
|
||||
|
||||
```html
|
||||
<script>
|
||||
const q = new URLSearchParams(location.search);
|
||||
const reduce = q.get("reduce") === "1"
|
||||
|| window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
// build motion conditionally on `reduce` (Tier-1 removed, Tier-2 softened, Tier-3 kept)
|
||||
const t = q.get("t");
|
||||
if (t !== null) { tl.pause(); tl.seek(parseFloat(t)); } // GSAP; CSS: el.style.animationDelay=(-t)+"s"; playState="paused"
|
||||
window.__ready = true;
|
||||
</script>
|
||||
```
|
||||
|
||||
**Verify loop — screenshot BOTH states:** capture the normal path AND the reduced path, mid-animation each, and confirm Tier-1 motion is gone, Tier-2 is a short fade, Tier-3 survives. Emulate the preference for the CSS layer (Playwright `--reduced-motion=reduce`; the agent's browser tool can emulate it too):
|
||||
|
||||
```bash
|
||||
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/demo.html?t=0.6" normal.png
|
||||
npx playwright screenshot --reduced-motion=reduce --wait-for-timeout=500 "file://$PWD/demo.html?t=0.6&reduce=1" reduced.png
|
||||
```
|
||||
|
||||
**Before you finish:**
|
||||
1. Opens standalone — no console errors, CDN (if any) loads.
|
||||
2. The freeze + `?reduce=1` (and `--reduced-motion=reduce`) reliably exercises both code paths.
|
||||
3. Screenshotted in BOTH states, mid-animation — normal looks intentional, reduced strips Tier-1/softens Tier-2.
|
||||
4. `prefers-reduced-motion` honored as *tiering*, not a kill switch — no teleporting, Tier-3 fades/focus rings/spinners kept; any >5s auto-motion still has a pause control (2.2.2).
|
||||
5. Easing is intentional in both paths — no accidental `linear`, durations sane (reduced fades ≤200ms, `0.01ms` not `0s`).
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Motion type | Tier | Reduced behavior |
|
||||
|---|---|---|
|
||||
| Parallax / depth | 1 | Remove (`transform: none`) |
|
||||
| Large slide / translate | 1 | Remove or replace with fade |
|
||||
| Scale / zoom (large) | 1 | Remove |
|
||||
| Spin / 3D rotate | 1 | Remove (`animation: none`) |
|
||||
| Auto carousel / marquee | 1 | Stop + provide pause control (2.2.2) |
|
||||
| Smooth-scroll (Lenis) | 1 | Don't instantiate |
|
||||
| Small slide / pop-in | 2 | Cross-fade ≤200ms |
|
||||
| Opacity fade | 3 | Keep (shorten if long) |
|
||||
| Color / focus-ring transition | 3 | Keep |
|
||||
| Loading spinner | 3 | Keep (essential feedback) |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `animation-duration: 0s` can break code waiting on `animationend`/`transitionend`; use `0.01ms` so the event still fires.
|
||||
- The global `*` reset alone over-removes Tier 3 motion; always layer intentional overrides.
|
||||
- Forgetting `animation-iteration-count: 1` leaves infinite spins running at near-zero duration (CPU churn, still animating).
|
||||
- No media query covers JS/canvas/WebGL — gate those with `matchMedia` or the loop never stops.
|
||||
- Toggling the OS setting fires no page reload; without a `change` listener the page keeps its stale state.
|
||||
- SSR: reading `matchMedia` during render throws or mismatches; default to motion and sync in an effect.
|
||||
- Auto-playing motion needs a visible pause control even when reduced-motion is off (2.2.2 is Level A).
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/patterns.md` — Full GSAP `matchMedia` teardown, Framer Motion `MotionConfig`, Lenis start/stop on live toggle, a reusable `animateWithMotionPreference` wrapper, marquee with pause control, and a tier-classification audit checklist.
|
||||
@@ -1,196 +0,0 @@
|
||||
# Reduced Motion — Detailed Patterns
|
||||
|
||||
## Reusable wrapper: animate with motion preference
|
||||
|
||||
A single helper that picks a "full" or "reduced" variant and stays live-reactive.
|
||||
|
||||
```js
|
||||
const REDUCE = '(prefers-reduced-motion: reduce)';
|
||||
|
||||
export function createMotionController() {
|
||||
const mq = window.matchMedia(REDUCE);
|
||||
const listeners = new Set();
|
||||
mq.addEventListener('change', () => listeners.forEach((fn) => fn(mq.matches)));
|
||||
|
||||
return {
|
||||
get reduced() { return mq.matches; },
|
||||
subscribe(fn) { listeners.add(fn); fn(mq.matches); return () => listeners.delete(fn); },
|
||||
// Pick a variant object: { full, reduced }
|
||||
pick(variants) { return mq.matches ? variants.reduced : variants.full; },
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
const motion = createMotionController();
|
||||
const opts = motion.pick({
|
||||
full: { translateY: ['40px', '0'], opacity: [0, 1], duration: 500 },
|
||||
reduced: { opacity: [0, 1], duration: 150 },
|
||||
});
|
||||
```
|
||||
|
||||
## GSAP: full matchMedia with teardown
|
||||
|
||||
`gsap.matchMedia()` automatically reverts (kills tweens, restores inline styles) when a condition stops matching, so toggling the OS setting cleans up correctly.
|
||||
|
||||
```js
|
||||
import gsap from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const mm = gsap.matchMedia();
|
||||
|
||||
mm.add({
|
||||
motionOK: '(prefers-reduced-motion: no-preference)',
|
||||
motionReduce: '(prefers-reduced-motion: reduce)',
|
||||
}, (ctx) => {
|
||||
const { motionOK } = ctx.conditions;
|
||||
|
||||
if (motionOK) {
|
||||
// Tier 1 parallax — only when allowed
|
||||
gsap.to('.parallax-layer', {
|
||||
yPercent: -30,
|
||||
ease: 'none',
|
||||
scrollTrigger: { trigger: '.section', scrub: true },
|
||||
});
|
||||
gsap.from('.card', {
|
||||
y: 60, opacity: 0, stagger: 0.1,
|
||||
scrollTrigger: { trigger: '.grid', start: 'top 80%' },
|
||||
});
|
||||
} else {
|
||||
// Reduced: no parallax at all; cards cross-fade in place (Tier 2)
|
||||
gsap.from('.card', {
|
||||
opacity: 0, duration: 0.15, stagger: 0.03,
|
||||
scrollTrigger: { trigger: '.grid', start: 'top 80%' },
|
||||
});
|
||||
}
|
||||
|
||||
return () => { /* optional extra cleanup; mm reverts tweens automatically */ };
|
||||
});
|
||||
|
||||
// Tear everything down (e.g. SPA route change)
|
||||
// mm.revert();
|
||||
```
|
||||
|
||||
## Framer Motion: MotionConfig for app-wide reduction
|
||||
|
||||
`MotionConfig reducedMotion="user"` makes every child `motion` component automatically drop transform/layout animations while keeping opacity. Set it once at the root.
|
||||
|
||||
```jsx
|
||||
import { MotionConfig, motion } from 'framer-motion';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<MotionConfig reducedMotion="user">
|
||||
{/* All descendant motion components honor the OS setting */}
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 80 }} // y is auto-dropped when reduced
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
/>
|
||||
</MotionConfig>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
For per-component control, Framer's own hook:
|
||||
|
||||
```jsx
|
||||
import { useReducedMotion } from 'framer-motion';
|
||||
|
||||
function Hero() {
|
||||
const reduced = useReducedMotion();
|
||||
const variants = reduced
|
||||
? { hidden: { opacity: 0 }, show: { opacity: 1, transition: { duration: 0.15 } } }
|
||||
: { hidden: { opacity: 0, scale: 0.9 }, show: { opacity: 1, scale: 1, transition: { duration: 0.8 } } };
|
||||
return <motion.div variants={variants} initial="hidden" animate="show" />;
|
||||
}
|
||||
```
|
||||
|
||||
## Lenis: start/stop on live toggle
|
||||
|
||||
Lenis hijacks scroll (Tier 1). Destroy it when reduced motion turns on, recreate when it turns off.
|
||||
|
||||
```js
|
||||
import Lenis from 'lenis';
|
||||
|
||||
let lenis = null;
|
||||
let rafId = null;
|
||||
|
||||
function startLenis() {
|
||||
if (lenis) return;
|
||||
lenis = new Lenis({ smoothWheel: true });
|
||||
const loop = (t) => { lenis.raf(t); rafId = requestAnimationFrame(loop); };
|
||||
rafId = requestAnimationFrame(loop);
|
||||
}
|
||||
function stopLenis() {
|
||||
if (!lenis) return;
|
||||
cancelAnimationFrame(rafId);
|
||||
lenis.destroy();
|
||||
lenis = null;
|
||||
// Restore native scroll so the page is still usable
|
||||
document.documentElement.style.removeProperty('scroll-behavior');
|
||||
}
|
||||
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
function sync() { mq.matches ? stopLenis() : startLenis(); }
|
||||
mq.addEventListener('change', sync);
|
||||
sync();
|
||||
```
|
||||
|
||||
## Auto-carousel / marquee with a pause control (WCAG 2.2.2)
|
||||
|
||||
Auto-playing motion longer than 5s needs a pause/stop control regardless of the media query. Reduced-motion users get it stopped by default; everyone gets the control.
|
||||
|
||||
```html
|
||||
<div class="marquee" data-playing="true">
|
||||
<button class="marquee__toggle" aria-pressed="false">Pause</button>
|
||||
<ul class="marquee__track"><!-- items --></ul>
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.marquee__track { animation: scroll-left 20s linear infinite; }
|
||||
.marquee[data-playing="false"] .marquee__track { animation-play-state: paused; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.marquee__track { animation-play-state: paused; } /* paused by default */
|
||||
}
|
||||
@keyframes scroll-left { to { transform: translateX(-50%); } }
|
||||
```
|
||||
|
||||
```js
|
||||
const root = document.querySelector('.marquee');
|
||||
const btn = root.querySelector('.marquee__toggle');
|
||||
// Respect the OS setting for the initial state
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
root.dataset.playing = 'false';
|
||||
btn.textContent = 'Play';
|
||||
btn.setAttribute('aria-pressed', 'true');
|
||||
}
|
||||
btn.addEventListener('click', () => {
|
||||
const playing = root.dataset.playing === 'true';
|
||||
root.dataset.playing = String(!playing);
|
||||
btn.textContent = playing ? 'Play' : 'Pause';
|
||||
btn.setAttribute('aria-pressed', String(playing));
|
||||
});
|
||||
```
|
||||
|
||||
## Tier classification audit checklist
|
||||
|
||||
For each animation in the codebase, ask in order:
|
||||
|
||||
1. Is it essential to meaning (e.g. a progress bar, a diagram step)? -> Tier 3, keep.
|
||||
2. Does a large area move, scale, spin, or imply depth/self-motion (parallax, zoom hero, slide-over)? -> Tier 1, remove under reduce.
|
||||
3. Is it a small entrance with displacement (pop-in, slide-up)? -> Tier 2, replace displacement with a ≤200ms opacity fade.
|
||||
4. Is it only opacity/color/focus? -> Tier 3, keep (shorten if >300ms).
|
||||
5. Does it auto-play >5s (carousel, marquee, looping video bg)? -> Add a pause control (2.2.2) on top of any tier handling.
|
||||
|
||||
## Testing
|
||||
|
||||
- macOS: System Settings > Accessibility > Display > Reduce motion.
|
||||
- Windows 11: Settings > Accessibility > Visual effects > Animation effects (off).
|
||||
- Chrome DevTools: Rendering panel > "Emulate CSS media feature prefers-reduced-motion".
|
||||
- Verify the `change` listener: toggle the OS setting with the page open and confirm motion stops without reload.
|
||||
- Confirm `transitionend`/`animationend`-dependent flows still complete (the `0.01ms` rule).
|
||||
|
||||
---
|
||||
Honor prefers-reduced-motion at every tier and motion stays inclusive by default. Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=web-animation-skills&utm_content=skill_footer&utm_term=accessible-animation)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
Whitespace-only changes.
@@ -1,179 +0,0 @@
|
||||
---
|
||||
name: ascii-animation
|
||||
description: This skill should be used when the user asks to "make an ASCII animation", "build a terminal/CLI intro or loader", "convert an image or video to ASCII art", "add an ASCII shader/post-effect to a canvas or Three.js scene", "create retro/hacker text-character motion", or "animate text characters with a brightness ramp". Covers generative ASCII fields, image/video/3D-to-ASCII, and animated character art for both web and terminal.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# ASCII Animation
|
||||
|
||||
Render motion entirely with text characters. ASCII output is extremely lightweight, distinctive, and works in browsers (`<pre>`/canvas), terminals (ANSI), and over any 3D scene (Three.js `AsciiEffect`).
|
||||
|
||||
## When to use
|
||||
|
||||
- Build terminal/CLI intros, loaders, banners, or a retro/hacker aesthetic.
|
||||
- Convert an image, video frame, or 3D scene into animated ASCII.
|
||||
- Add an ASCII post-effect over an existing canvas/WebGL render.
|
||||
- Create generative text fields (plasma, sine waves, noise, tunnels).
|
||||
|
||||
## Core concept: the brightness ramp
|
||||
|
||||
Map luminance (0..1) to a character whose ink density matches. Order characters dark-to-light. Pick the index with `Math.round(lum * (ramp.length - 1))`.
|
||||
|
||||
Common ramps (dark to light):
|
||||
- Short (10): ` .:-=+*#%@`
|
||||
- Medium (16): ` .'\`^",:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$` truncated — see table below.
|
||||
- Standard 70-level (Paul Bourke), best for photos:
|
||||
`` $@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\|()1{}[]?-_+~<>i!lI;:,"^`'. `` (reverse for dark-on-light).
|
||||
|
||||
Compute relative luminance from sRGB (perceptual):
|
||||
```js
|
||||
const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; // 0..1
|
||||
```
|
||||
|
||||
Invert when drawing dark text on a light background: `lum = 1 - lum`.
|
||||
|
||||
## Character cell aspect correction (the #1 gotcha)
|
||||
|
||||
Monospace character cells are taller than wide — roughly **0.5 width:height**. Sampling a square pixel grid produces a vertically stretched image. Correct by sampling **fewer rows than columns**: for a target of `cols` characters wide, use `rows = Math.round(cols * (imgH / imgW) * fontAspect)` where `fontAspect ≈ 0.5`. Equivalently, when drawing to an offscreen canvas, set its height to `cols * aspect * 0.5`.
|
||||
|
||||
## Web rendering: `<pre>` vs canvas
|
||||
|
||||
- **`<pre>` + textContent**: simplest. One string with `\n` per row. Fine up to ~120×60 chars at 30fps. Set `white-space: pre; font-family: monospace; line-height: 1;`.
|
||||
- **Canvas `fillText`**: needed for per-character color, larger grids, or 60fps. Draw each char at `x * cellW, y * cellH`. Faster than thousands of DOM nodes.
|
||||
|
||||
Generative `<pre>` field (plasma):
|
||||
```js
|
||||
const pre = document.querySelector('pre');
|
||||
const COLS = 100, ROWS = 50, ramp = ' .:-=+*#%@';
|
||||
function frame(t) {
|
||||
let out = '';
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
const v = Math.sin(x * 0.2 + t * 0.001)
|
||||
+ Math.sin(y * 0.3 + t * 0.0013)
|
||||
+ Math.sin((x + y) * 0.15 + t * 0.0007);
|
||||
const lum = (v + 3) / 6; // normalize -3..3 to 0..1
|
||||
out += ramp[Math.round(lum * (ramp.length - 1))];
|
||||
}
|
||||
out += '\n';
|
||||
}
|
||||
pre.textContent = out;
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
```
|
||||
|
||||
## Image / video to ASCII
|
||||
|
||||
Draw the source to a small offscreen canvas at `cols × rows`, read pixels with `getImageContext().getImageData`, then map each pixel to a character. For video, repeat per frame from a `<video>` element.
|
||||
|
||||
```js
|
||||
function videoToAscii(video, cols = 120) {
|
||||
const aspect = video.videoHeight / video.videoWidth;
|
||||
const rows = Math.round(cols * aspect * 0.5); // cell aspect correction
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = cols; cv.height = rows;
|
||||
const ctx = cv.getContext('2d', { willReadFrequently: true });
|
||||
const ramp = ' .:-=+*#%@', pre = document.querySelector('pre');
|
||||
(function tick() {
|
||||
ctx.drawImage(video, 0, 0, cols, rows);
|
||||
const { data } = ctx.getImageData(0, 0, cols, rows);
|
||||
let out = '';
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const lum = (0.2126*data[i] + 0.7152*data[i+1] + 0.0722*data[i+2]) / 255;
|
||||
out += ramp[Math.round(lum * (ramp.length - 1))];
|
||||
if (((i / 4) + 1) % cols === 0) out += '\n';
|
||||
}
|
||||
pre.textContent = out;
|
||||
requestAnimationFrame(tick);
|
||||
})();
|
||||
}
|
||||
```
|
||||
|
||||
For Node image→ASCII, see `scripts/img-to-ascii.mjs`.
|
||||
|
||||
## 3D-to-ASCII with Three.js
|
||||
|
||||
`AsciiEffect` wraps a renderer and renders any scene as ASCII into a DOM element. Render through the effect, not the renderer.
|
||||
|
||||
```js
|
||||
import { AsciiEffect } from 'three/addons/effects/AsciiEffect.js';
|
||||
const effect = new AsciiEffect(renderer, ' .:-=+*#%@', { invert: true });
|
||||
effect.setSize(innerWidth, innerHeight);
|
||||
effect.domElement.style.color = '#0f0';
|
||||
effect.domElement.style.backgroundColor = 'black';
|
||||
document.body.appendChild(effect.domElement);
|
||||
// in loop: effect.render(scene, camera); // NOT renderer.render
|
||||
```
|
||||
|
||||
## Terminal / CLI animation
|
||||
|
||||
Loop with ANSI escape codes: hide the cursor, move to home, print the frame, throttle to 12–24 fps. Show the cursor again on exit.
|
||||
|
||||
```js
|
||||
const ESC = '\x1b[';
|
||||
process.stdout.write(ESC + '?25l'); // hide cursor
|
||||
function frame(t) {
|
||||
process.stdout.write(ESC + 'H'); // cursor to top-left
|
||||
// build and write rows...
|
||||
}
|
||||
const id = setInterval(() => frame(Date.now()), 1000 / 20);
|
||||
process.on('SIGINT', () => {
|
||||
clearInterval(id);
|
||||
process.stdout.write(ESC + '?25h' + ESC + '2J'); // show cursor, clear
|
||||
process.exit();
|
||||
});
|
||||
```
|
||||
Use `ESC + '2J'` to clear the whole screen, `ESC + 'H'` for home (cheaper per frame than clearing). Color with `\x1b[38;2;R;G;Bm` (truecolor) and reset with `\x1b[0m`.
|
||||
|
||||
## Deliver & verify (standalone HTML)
|
||||
|
||||
> **Packaged helper** (`scripts/`): `scripts/seek-shot.sh anim.html 0 1.5 3` freezes the `?t=N` harness and screenshots each moment; `scripts/contact-sheet.sh sheet.png frame-*.png` tiles them for one-glance review. See `scripts/README.md`.
|
||||
|
||||
For a self-contained web ASCII piece (generative field, image/video→ASCII, `AsciiEffect` scene) the deliverable is **one HTML file that opens directly in a browser** — the `<pre>`/canvas, the ramp, and the rAF loop inline (Three.js from CDN if used). No build step. One file is the right tier; don't reach for a bundler. (Terminal/CLI pieces verify differently — capture stdout or a screenshot of the terminal.)
|
||||
|
||||
**Output contract:**
|
||||
- One `.html` file: the render target, the brightness ramp, and the `requestAnimationFrame` loop in one inline `<script>`.
|
||||
- Drive frames from an **injectable time** (not `Date.now()`/`performance.now()` directly) and seed any randomness, so a frame is reproducible.
|
||||
|
||||
**Seek harness — freeze the rAF loop on a deterministic frame.** `?t=N` renders exactly one frame at simulated time `N` instead of looping, so a screenshot is reproducible. Feed `N` where the loop reads time, and fix the seed:
|
||||
|
||||
```html
|
||||
<script>
|
||||
const t = new URLSearchParams(location.search).get("t");
|
||||
// your frame(time){…} reads `time`, not Date.now(); RNG uses a fixed seed
|
||||
if (t !== null) { frame(parseFloat(t)); } // render ONE frame, no rAF
|
||||
else { (function loop(now){ frame(now); requestAnimationFrame(loop); })(0); }
|
||||
window.__ready = true;
|
||||
</script>
|
||||
```
|
||||
|
||||
**Verify loop — render → freeze → screenshot → check:** render at a few simulated times (`?t=0`, `?t=1000`, `?t=2000`), screenshot each, and check **fidelity** (ramp reads dark→light correctly, motion evolves) plus **artifacts** (vertical stretch from missing cell-aspect correction, wrong invert on a light bg, clipped grid, FOUC before the monospace font loads — fonts settle the cell metrics, so wait). Any headless tool works:
|
||||
|
||||
```bash
|
||||
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/ascii.html?t=1000" frame-mid.png
|
||||
```
|
||||
|
||||
**Before you finish:**
|
||||
1. Opens standalone — no console errors, CDN (Three.js, if used) loads, monospace font applied.
|
||||
2. `?t=N` renders one deterministic frame (injected time + fixed seed), no live loop.
|
||||
3. Screenshotted at 3 simulated times — matches the brief, no vertical stretch or wrong-invert.
|
||||
4. `prefers-reduced-motion` honored — stop/slow the rAF loop, show a static frame.
|
||||
5. Easing is intentional — frame rate throttled on purpose (12–24fps for retro feel), ramp ordering deliberate.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Need | Approach |
|
||||
|------|----------|
|
||||
| Simple generative field | `<pre>` + `textContent`, sine/noise → ramp |
|
||||
| Photo fidelity | 70-level Bourke ramp, luminance from sRGB weights |
|
||||
| Per-char color / 60fps | Canvas `fillText` per cell |
|
||||
| Video | `<video>` → offscreen canvas → `getImageData` per frame |
|
||||
| 3D scene | Three.js `AsciiEffect.render(scene, camera)` |
|
||||
| Terminal | ANSI `\x1b[H` home + throttle 12–24fps, hide cursor |
|
||||
| Aspect fix | `rows = cols * imgAspect * 0.5` |
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/rendering.md` — Full brightness ramp tables (10/70/extended), pixel sampling math and cell aspect correction, `<pre>` vs canvas tradeoffs with code, ANSI terminal frame loop with truecolor, and complete `AsciiEffect` wiring.
|
||||
- `scripts/img-to-ascii.mjs` — Runnable Node script that converts a PNG/JPG file to ASCII text, with `--cols`, `--invert`, and `--ramp` flags.
|
||||
@@ -1,194 +0,0 @@
|
||||
# ASCII Rendering Reference
|
||||
|
||||
Detailed tables, sampling math, and complete renderers for ASCII animation.
|
||||
|
||||
## Brightness ramp tables
|
||||
|
||||
Order is **dark → light**. Index into the ramp with `Math.round(lum * (ramp.length - 1))` where `lum` is 0..1.
|
||||
|
||||
### 10-level (compact, generative)
|
||||
```
|
||||
.:-=+*#%@
|
||||
```
|
||||
Good for low-resolution generative fields and terminals. Distinct steps, low banding artifacts.
|
||||
|
||||
### 70-level Paul Bourke ramp (photo fidelity)
|
||||
```
|
||||
$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\|()1{}[]?-_+~<>i!lI;:,"^`'.
|
||||
```
|
||||
This is ordered **light → dark** (densest first). For luminance mapping, either reverse the string or use `index = Math.round((1 - lum) * (len - 1))`. Best results for grayscale photos; whitespace at the end represents black/empty.
|
||||
|
||||
### 16-level extended (balance)
|
||||
```
|
||||
.'`,^:";~-_+<>i!lI?/\|()1{}[]rcvunxzjftLCJUYXZO0Qoahkbdpqwm*WMB8&%$#@
|
||||
```
|
||||
Truncate or downsample to taste. For UI/loaders a hand-picked 8–12 char ramp usually looks cleaner than a 70-char ramp because the steps are visually distinct.
|
||||
|
||||
### Block / shade characters (Unicode, smoother gradient)
|
||||
```
|
||||
░▒▓█
|
||||
```
|
||||
Or with the 1/8 block fills for vertical bars: `▁▂▃▄▅▆▇█`. Renders as a near-continuous gradient in monospace fonts; great for waveforms, meters, and smooth fades. Requires a font with box-drawing glyphs (most monospace fonts have them).
|
||||
|
||||
## Luminance computation
|
||||
|
||||
Perceptual (sRGB-weighted, recommended):
|
||||
```js
|
||||
const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; // 0..1
|
||||
```
|
||||
Fast average (acceptable for stylized output):
|
||||
```js
|
||||
const lum = (r + g + b) / (3 * 255);
|
||||
```
|
||||
Invert for dark-on-light surfaces: `lum = 1 - lum`. Apply gamma to spread midtones: `lum = Math.pow(lum, 0.8)` (lower exponent brightens).
|
||||
|
||||
## Pixel sampling and cell aspect correction
|
||||
|
||||
Monospace cells are about twice as tall as wide. Real cell aspect depends on the font: measure once.
|
||||
|
||||
```js
|
||||
function measureCellAspect(fontFamily = 'monospace', fontPx = 16) {
|
||||
const cv = document.createElement('canvas');
|
||||
const ctx = cv.getContext('2d');
|
||||
ctx.font = `${fontPx}px ${fontFamily}`;
|
||||
const w = ctx.measureText('M').width; // glyph advance width
|
||||
const h = fontPx * 1.0; // with line-height: 1
|
||||
return w / h; // ~0.5 for most fonts
|
||||
}
|
||||
```
|
||||
|
||||
Compute the character grid for a source image, correcting aspect so the picture is not vertically stretched:
|
||||
```js
|
||||
function gridSize(imgW, imgH, cols, cellAspect = 0.5) {
|
||||
const rows = Math.round(cols * (imgH / imgW) * cellAspect);
|
||||
return { cols, rows };
|
||||
}
|
||||
```
|
||||
Draw the source into an offscreen canvas at exactly `cols × rows`, then read all pixels once with `getImageData(0, 0, cols, rows)`. One `getImageData` call per frame is far cheaper than per-pixel reads.
|
||||
|
||||
## `<pre>` vs canvas
|
||||
|
||||
| Aspect | `<pre>` + textContent | Canvas `fillText` |
|
||||
|--------|----------------------|-------------------|
|
||||
| Setup | trivial, one string | per-cell draw loop |
|
||||
| Color | single color (or per-span, slow) | per-char color cheap |
|
||||
| Max size | ~120×60 @ 30fps | thousands of cells @ 60fps |
|
||||
| Selectable text | yes | no |
|
||||
| Best for | generative fields, loaders | video, per-char color, large grids |
|
||||
|
||||
`<pre>` styling that avoids gaps and stretching:
|
||||
```css
|
||||
pre { font-family: ui-monospace, monospace; line-height: 1; letter-spacing: 0;
|
||||
margin: 0; white-space: pre; }
|
||||
```
|
||||
|
||||
Canvas per-char color renderer:
|
||||
```js
|
||||
function drawAsciiCanvas(ctx, chars, colors, cols, rows, cellW, cellH) {
|
||||
ctx.clearRect(0, 0, cols * cellW, rows * cellH);
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.font = `${cellH}px monospace`;
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const i = y * cols + x;
|
||||
ctx.fillStyle = colors[i]; // e.g. `rgb(r,g,b)`
|
||||
ctx.fillText(chars[i], x * cellW, y * cellH);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## ANSI terminal frame loop (Node)
|
||||
|
||||
```js
|
||||
const ESC = '\x1b[';
|
||||
const out = process.stdout;
|
||||
|
||||
function start(render, fps = 20) {
|
||||
out.write(ESC + '?25l'); // hide cursor
|
||||
out.write(ESC + '2J'); // clear screen once
|
||||
const interval = 1000 / fps;
|
||||
let start = Date.now();
|
||||
const id = setInterval(() => {
|
||||
const t = Date.now() - start;
|
||||
out.write(ESC + 'H'); // home (cheap, no full clear)
|
||||
out.write(render(t, out.columns || 80, out.rows || 24));
|
||||
}, interval);
|
||||
const stop = () => {
|
||||
clearInterval(id);
|
||||
out.write(ESC + '0m' + ESC + '?25h' + ESC + '2J' + ESC + 'H');
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGINT', stop);
|
||||
return stop;
|
||||
}
|
||||
|
||||
// Truecolor per character: foreground `\x1b[38;2;R;G;Bm`, reset `\x1b[0m`.
|
||||
function colored(ch, r, g, b) {
|
||||
return `${ESC}38;2;${r};${g};${b}m${ch}${ESC}0m`;
|
||||
}
|
||||
|
||||
// Example: plasma using full terminal size
|
||||
start((t, cols, rows) => {
|
||||
const ramp = ' .:-=+*#%@';
|
||||
let s = '';
|
||||
for (let y = 0; y < rows - 1; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const v = Math.sin(x * 0.15 + t * 0.001)
|
||||
+ Math.sin(y * 0.25 + t * 0.0015)
|
||||
+ Math.sin((x + y) * 0.1 + t * 0.0008);
|
||||
const lum = (v + 3) / 6;
|
||||
s += ramp[Math.round(lum * (ramp.length - 1))];
|
||||
}
|
||||
s += '\n';
|
||||
}
|
||||
return s;
|
||||
});
|
||||
```
|
||||
Notes: writing `ESC + 'H'` (home) and overwriting is much smoother than clearing the screen every frame, which causes flicker. Avoid printing a trailing newline on the last row or the terminal scrolls. `out.columns`/`out.rows` give the live terminal size; recompute the grid on `process.stdout.on('resize', ...)`.
|
||||
|
||||
## Three.js AsciiEffect — complete wiring
|
||||
|
||||
```js
|
||||
import * as THREE from 'three';
|
||||
import { AsciiEffect } from 'three/addons/effects/AsciiEffect.js';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
|
||||
const renderer = new THREE.WebGLRenderer();
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
|
||||
const effect = new AsciiEffect(renderer, ' .:-=+*#%@', { invert: true, resolution: 0.2 });
|
||||
effect.setSize(innerWidth, innerHeight);
|
||||
effect.domElement.style.color = 'white';
|
||||
effect.domElement.style.backgroundColor = 'black';
|
||||
document.body.appendChild(effect.domElement); // append the EFFECT's DOM, not renderer.domElement
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 1, 1000);
|
||||
camera.position.z = 4;
|
||||
scene.add(new THREE.PointLight(0xffffff, 3).position.set(5, 5, 5));
|
||||
scene.add(new THREE.AmbientLight(0x808080));
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.TorusKnotGeometry(1, 0.3, 100, 16),
|
||||
new THREE.MeshStandardMaterial({ flatShading: true })
|
||||
);
|
||||
scene.add(mesh);
|
||||
|
||||
const controls = new OrbitControls(camera, effect.domElement); // attach to effect DOM
|
||||
|
||||
function animate(t) {
|
||||
mesh.rotation.y = t * 0.0005;
|
||||
effect.render(scene, camera); // render through the effect
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
requestAnimationFrame(animate);
|
||||
|
||||
addEventListener('resize', () => {
|
||||
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
|
||||
effect.setSize(innerWidth, innerHeight);
|
||||
});
|
||||
```
|
||||
Key gotchas: append `effect.domElement` (not the renderer's canvas) and call `effect.render`, not `renderer.render`. `resolution` (default ~0.15) controls character density; lower = fewer, larger characters. Lighting matters — ASCII reads luminance, so use strong directional contrast and `flatShading` for crisp edges.
|
||||
|
||||
---
|
||||
Map brightness to the right glyph ramp and the characters come alive. Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=web-animation-skills&utm_content=skill_footer&utm_term=ascii-animation)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// img-to-ascii.mjs — Convert a PNG/JPG image to ASCII text.
|
||||
//
|
||||
// Usage:
|
||||
// node img-to-ascii.mjs <image> [--cols 120] [--invert] [--ramp " .:-=+*#%@"]
|
||||
//
|
||||
// Dependency: sharp (npm i sharp). Sharp decodes PNG/JPG/WebP and resizes.
|
||||
//
|
||||
// Notes:
|
||||
// - Applies monospace cell-aspect correction (cells are ~2x taller than wide),
|
||||
// so rows = cols * (imgH/imgW) * 0.5.
|
||||
// - Luminance uses sRGB perceptual weights (0.2126/0.7152/0.0722).
|
||||
|
||||
import sharp from 'sharp';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { cols: 120, invert: false, ramp: ' .:-=+*#%@', file: null };
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--cols') args.cols = parseInt(argv[++i], 10);
|
||||
else if (a === '--invert') args.invert = true;
|
||||
else if (a === '--ramp') args.ramp = argv[++i];
|
||||
else if (!a.startsWith('--')) args.file = a;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { cols, invert, ramp, file } = parseArgs(process.argv);
|
||||
if (!file) {
|
||||
console.error('Usage: node img-to-ascii.mjs <image> [--cols N] [--invert] [--ramp "..."]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const CELL_ASPECT = 0.5; // monospace width:height
|
||||
const meta = await sharp(file).metadata();
|
||||
const rows = Math.max(1, Math.round(cols * (meta.height / meta.width) * CELL_ASPECT));
|
||||
|
||||
// Resize to the character grid, drop alpha, get raw RGB bytes.
|
||||
const { data } = await sharp(file)
|
||||
.resize(cols, rows, { fit: 'fill' })
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
const last = ramp.length - 1;
|
||||
const lines = [];
|
||||
for (let y = 0; y < rows; y++) {
|
||||
let line = '';
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const i = (y * cols + x) * 3;
|
||||
let lum = (0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]) / 255;
|
||||
if (invert) lum = 1 - lum;
|
||||
line += ramp[Math.round(lum * last)];
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
process.stdout.write(lines.join('\n') + '\n');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Whitespace-only changes.
@@ -1,177 +0,0 @@
|
||||
---
|
||||
name: glassmorphism
|
||||
description: This skill should be used when the user asks to "add a glassmorphism effect", "frosted glass UI", "Apple liquid glass style", "frosted blur card", "translucent glass panel animation", "make a frosted nav bar", "build a glass modal/dialog", "animate a glass card on hover", or "add a refracting liquid-glass hero". Covers frosted translucent panels with backdrop-filter blur, edge/specular highlights, SVG liquid-glass refraction, motion on hover/scroll/enter, and accessible reduced-transparency fallbacks.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Glassmorphism (Frosted & Liquid Glass)
|
||||
|
||||
Translucent, frosted UI: panels you can see through, blurred so the backdrop reads as soft color, rimmed by a bright edge of light, floating on layered shadow. The 2026 "liquid glass" evolution adds refraction — the background visibly bends through the panel — plus moving specular highlights. The goal is depth and material, not just opacity.
|
||||
|
||||
## When to use
|
||||
|
||||
- Frosted cards, nav bars, sidebars, modals/dialogs, and toolbars over busy/photo backgrounds
|
||||
- Login/hero panels and overlays that should feel layered above the page
|
||||
- Apple-style "liquid glass" heroes: refracting, slowly churning surfaces with a specular sweep
|
||||
- Hover/scroll/enter motion on glass — frost deepening, highlight brightening, the panel lifting
|
||||
- Any place where content needs to read as floating glass while staying legible
|
||||
|
||||
## The four ingredients of believable glass
|
||||
|
||||
Glass is never one property. Every panel needs all four, or it reads as a plain translucent box:
|
||||
|
||||
1. **Frost** — `backdrop-filter: blur()` plus `saturate()` to keep backdrop color vivid through the blur.
|
||||
2. **Translucency** — a `background` with alpha (~0.08–0.18), never fully opaque, never fully clear.
|
||||
3. **Edge of glass** — a hairline border *brighter* than the fill, ideally a lit top-left → dim bottom-right highlight, so the rim catches light.
|
||||
4. **Depth** — layered `box-shadow`: an ambient drop shadow to float it, plus an `inset` top highlight and bottom shade to round the surface.
|
||||
|
||||
```css
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(16px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(160%); /* Safari needs the prefix */
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.25), /* float */
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.35), /* top highlight */
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.12); /* bottom shade */
|
||||
}
|
||||
```
|
||||
|
||||
Frost tiers: light UI chrome `blur(8–12px)` / alpha `0.08–0.14`; heavy modal/login `blur(24–40px)` / alpha `0.10–0.18`. Dark glass: fill `rgba(0,0,0,0.3)`, softer top highlight.
|
||||
|
||||
**`backdrop-filter` only blurs content that sits behind the element.** Over a flat background color it does nothing visible — glass needs a busy backdrop (image, gradient, video) to frost.
|
||||
|
||||
## Specular edge highlight and anti-banding grain
|
||||
|
||||
A flat border is dead. Draw a masked 1px ring with a lit corner via `::before`, and dither away gradient **banding** (stepped contour lines under heavy blur) with a faint `feTurbulence` noise overlay via `::after` at `opacity ≤ 0.06`. Full masked-border and inline-noise recipes are in `references/glass-recipes.md`.
|
||||
|
||||
## Motion: animate the glass, not the geometry
|
||||
|
||||
Animate the *qualities* of the glass — frost deepening, highlight brightening, the panel lifting — so it reads as the surface "leaning into the light."
|
||||
|
||||
```css
|
||||
.glass {
|
||||
transition: backdrop-filter .4s ease, background .4s ease,
|
||||
box-shadow .4s ease, transform .4s cubic-bezier(.16,1,.3,1);
|
||||
}
|
||||
.glass:hover {
|
||||
backdrop-filter: blur(24px) saturate(180%);
|
||||
background: rgba(255,255,255,.18);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,.3), inset 0 1px 0 rgba(255,255,255,.5);
|
||||
}
|
||||
```
|
||||
|
||||
- **Entrance** (frost resolving from clear): pair a `[data-enter]` transition with `@starting-style { backdrop-filter: blur(0); opacity: 0; transform: translateY(16px) }`.
|
||||
- **Specular sweep**: a skewed white gradient animated across the surface on hover or as a slow ambient loop.
|
||||
- **Caveat**: animating the blur *radius* is the most expensive thing here — the backdrop is re-blurred every frame. If hover janks, animate `transform`/`box-shadow`/`background` (cheap) and keep the blur delta small or snap it.
|
||||
|
||||
## Liquid glass — SVG refraction (the 2026 look)
|
||||
|
||||
Bend the background through the panel like a thick lens: drive `feDisplacementMap` from animated `feTurbulence`, applied via `filter` (it distorts the element's own pixels) layered over a `backdrop-filter` frost.
|
||||
|
||||
```html
|
||||
<svg width="0" height="0" style="position:absolute"><filter id="liquid-glass">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.008 0.012" numOctaves="2" seed="3" result="n">
|
||||
<animate attributeName="baseFrequency" dur="18s"
|
||||
values="0.008 0.012;0.012 0.008;0.008 0.012" repeatCount="indefinite"/>
|
||||
</feTurbulence>
|
||||
<feDisplacementMap in="SourceGraphic" in2="n" scale="22" xChannelSelector="R" yChannelSelector="G"/>
|
||||
</filter></svg>
|
||||
```
|
||||
|
||||
`scale` = refraction strength (8–14 subtle, 20–30 pronounced, >40 molten). Lower `baseFrequency` = larger, slower waves (more liquid). Keep the `animate` `dur` long (12–20s) so it breathes. These filters are heavy — use on **one** hero element, not a grid.
|
||||
|
||||
## Performance — backdrop-filter is expensive
|
||||
|
||||
- Each glass element re-samples and blurs everything behind it. **Limit the count**; a dozen on screen tanks scroll FPS.
|
||||
- Promote heavy glass to its own layer (`transform: translateZ(0)` or `will-change: transform`), and remove `will-change` after the animation.
|
||||
- Don't stack `backdrop-filter` elements over each other — nested blur re-blurs the blur (quadratic cost).
|
||||
- Big radii (>40px) over large areas are the worst case; cap radius and panel size.
|
||||
|
||||
## Accessibility — non-negotiable
|
||||
|
||||
Glass is decoration; content must stay legible and the effect must be defeatable.
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.glass { background: #1a1a2e; backdrop-filter: none; -webkit-backdrop-filter: none; }
|
||||
.glass::after { display: none; } /* drop grain */
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.glass { transition: none; }
|
||||
.liquid { filter: none; } /* stop the refraction churn */
|
||||
}
|
||||
@supports not (backdrop-filter: blur(1px)) {
|
||||
.glass { background: rgba(30,30,50,.85); } /* opaque-enough fallback */
|
||||
}
|
||||
```
|
||||
|
||||
**Contrast over busy backgrounds**: translucent panels over photos can drop text below WCAG. Add a semi-opaque scrim or darkening gradient *under* the text, optionally `text-shadow: 0 1px 2px rgba(0,0,0,.4)`, and verify ≥ 4.5:1 body / ≥ 3:1 large against the *brightest* region of the backdrop.
|
||||
|
||||
## Deliver & verify (standalone HTML)
|
||||
|
||||
> **Packaged helper** (`scripts/`): `scripts/seek-shot.sh anim.html 0 1.5 3` freezes the `?t=N` harness and screenshots each moment; `scripts/contact-sheet.sh sheet.png frame-*.png` tiles them for one-glance review. See `scripts/README.md`.
|
||||
|
||||
For a self-contained glass demo (card, nav, modal, liquid-glass hero) the deliverable is **one HTML file that opens directly in a browser** — markup, CSS, and (if liquid) one inline SVG filter, no build step. A single file is the right tier for a glass surface; don't reach for a bundler. The panel **must overlap a real backdrop** (drop in a photo or gradient) or there is nothing to frost and the demo lies.
|
||||
|
||||
**Output contract:**
|
||||
- One `.html` file: a busy backdrop, your glass markup, the CSS (frost + edge highlight + grain + shadows), and any inline SVG `feDisplacementMap` filter.
|
||||
- A way to land on the **resolved state** *and* a **frozen frame** of any loop — glass verifies by both state (rest/hover/enter) and time (the sweep/refraction).
|
||||
- Include the freeze harness below so any moment can be pinned for inspection.
|
||||
|
||||
**Seek harness — pin a deterministic state + frame.** `?state=hover` forces the hover look; `?t=N` freezes CSS animations and the SVG refraction at `N` seconds, so a screenshot lands on a still, deterministic frame:
|
||||
|
||||
```html
|
||||
<script>
|
||||
const p = new URLSearchParams(location.search);
|
||||
const state = p.get("state");
|
||||
if (state) document.documentElement.dataset.state = state; // CSS keys off :root[data-state="hover"]
|
||||
const t = p.get("t");
|
||||
if (t !== null) {
|
||||
document.getAnimations().forEach(a => { a.currentTime = parseFloat(t)*1000; a.pause(); }); // freeze CSS sweep
|
||||
const svg = document.querySelector("svg");
|
||||
svg?.pauseAnimations?.(); svg?.setCurrentTime?.(parseFloat(t)); // freeze refraction
|
||||
}
|
||||
window.__ready = true;
|
||||
</script>
|
||||
```
|
||||
|
||||
**Verify loop — render → set state/freeze → screenshot → check:** open each meaningful state (`?state=rest`, `?state=hover`) and a frozen loop frame (`?t=2`), screenshot, and confirm the glass **reads as glass** — blur and edge highlight visibly present, no color banding, text contrast holds over the backdrop — plus check **artifacts** (flat box because the backdrop is plain color, missing `-webkit-` prefix in Safari, refraction too strong/molten, jank from animating blur). Then load `prefers-reduced-transparency` and confirm the **solid fallback renders** legibly. Any headless tool works:
|
||||
|
||||
```bash
|
||||
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/glass.html?state=hover" hover.png
|
||||
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/glass.html?t=2" loop.png
|
||||
```
|
||||
|
||||
To check the fallback headlessly, emulate the media feature (e.g. Playwright `--color-scheme`-style flags, or a `?reduced=1` query that toggles a class mirroring the media query) and screenshot that the panel is solid and readable.
|
||||
|
||||
**Before you finish:**
|
||||
1. Opens standalone — no console errors; the glass overlaps a real busy backdrop, not a flat color.
|
||||
2. Reads as glass — blur + bright edge highlight both visible, layered shadow floats it, no banding (grain present).
|
||||
3. Text contrast holds over the brightest part of the backdrop (≥ 4.5:1 body).
|
||||
4. `?state=` / `?t=N` freeze lands a deterministic, settled frame.
|
||||
5. `prefers-reduced-transparency` → solid opaque panel; `prefers-reduced-motion` → refraction/sweep stopped; `@supports` fallback for no `backdrop-filter`.
|
||||
6. Performance sane — glass count limited, blur not animated on a janky path, liquid refraction on one element only.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Need | Approach |
|
||||
|------|----------|
|
||||
| Base frost | `backdrop-filter: blur(16px) saturate(160%)` + alpha bg |
|
||||
| Edge of glass | brighter `border` + masked `::before` lit-corner ring |
|
||||
| Float / depth | `box-shadow` drop + `inset` top highlight & bottom shade |
|
||||
| Kill banding | `feTurbulence` noise `::after`, `opacity ≤ .06` |
|
||||
| Hover motion | transition `transform`/`box-shadow`/`background`, small blur delta |
|
||||
| Entrance | `@starting-style { backdrop-filter: blur(0); opacity:0 }` |
|
||||
| Liquid refraction | `feDisplacementMap` ← animated `feTurbulence`, via `filter` |
|
||||
| Specular sweep | skewed white gradient animated across surface |
|
||||
| Safari | always pair `-webkit-backdrop-filter` |
|
||||
| Reduced transparency | solid opaque panel, drop grain |
|
||||
| No backdrop-filter | `@supports not (...)` opaque-enough fallback |
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/glass-recipes.md` — full copy-paste recipes: base glass card with tiers, the masked specular edge ring, anti-banding grain, hover/enter/scroll motion, the specular sweep, the SVG `feDisplacementMap`/`feTurbulence` liquid-glass filter with tuning knobs, performance rules, the reduced-transparency/reduced-motion/`@supports` fallbacks, contrast defenses, and the standalone freeze harness.
|
||||
@@ -1,242 +0,0 @@
|
||||
# Glassmorphism Recipes
|
||||
|
||||
Copy-paste recipes for frosted translucent panels, the "edge of glass" highlight, animated liquid-glass refraction, and the reduced-transparency fallback. All standalone CSS + optional inline SVG — no build step.
|
||||
|
||||
## 1. The base glass card
|
||||
|
||||
The four ingredients of believable glass: **blur** (frost), **translucency** (you see through it), **edge highlight** (light catches the rim), **layered shadow** (it floats). Plus a touch of `saturate()` so the backdrop reads vivid through the frost.
|
||||
|
||||
```css
|
||||
.glass {
|
||||
/* translucency — never fully opaque, never fully clear */
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
/* frost: blur the backdrop, lift saturation so color survives the blur */
|
||||
backdrop-filter: blur(16px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(160%); /* Safari still needs the prefix */
|
||||
border-radius: 20px;
|
||||
/* edge of glass: a hairline border slightly brighter than the fill */
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
/* depth: ambient drop + a top inner highlight + a bottom inner shade */
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.25),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.35),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
```
|
||||
|
||||
Tuning by tier:
|
||||
- **Light frost** (UI chrome over content): `blur(8–12px)`, alpha `0.08–0.14`.
|
||||
- **Heavy frost** (modal scrim, login card): `blur(24–40px)`, alpha `0.10–0.18`.
|
||||
- Dark glass: swap the fill to `rgba(0,0,0,0.3)` and the top inner highlight to a softer white.
|
||||
|
||||
`backdrop-filter` only has anything to blur if there is content **behind** the element — it must overlap a busy background (image, gradient, video). On a flat color it looks like a plain translucent box.
|
||||
|
||||
## 2. The specular edge highlight (the rim of light)
|
||||
|
||||
A single border is flat. Real glass catches a bright sweep along the top-left edge and falls off. Layer a soft conic/linear highlight via a pseudo-element with a masked border.
|
||||
|
||||
```css
|
||||
.glass {
|
||||
position: relative;
|
||||
isolation: isolate; /* keep the highlight blend local */
|
||||
}
|
||||
.glass::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 1px; /* border thickness */
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.7),
|
||||
rgba(255, 255, 255, 0) 40%,
|
||||
rgba(255, 255, 255, 0) 60%,
|
||||
rgba(255, 255, 255, 0.25)
|
||||
);
|
||||
/* mask trick: show only the 1px ring, punch out the interior */
|
||||
-webkit-mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
```
|
||||
|
||||
This gives the panel a lit top-left corner and a dim bottom-right, the cue the brain reads as "curved glass under a light."
|
||||
|
||||
## 3. Kill banding with grain
|
||||
|
||||
Heavy `blur()` over a smooth gradient produces visible color **banding** (stepped contour lines). A faint noise overlay dithers it away.
|
||||
|
||||
```css
|
||||
.glass::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
opacity: 0.04; /* barely there */
|
||||
mix-blend-mode: overlay;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
```
|
||||
|
||||
`feTurbulence` generates the noise tile inline — no asset file. Keep opacity ≤ 0.06 or it reads as texture, not dither.
|
||||
|
||||
## 4. Motion: animate the glass on hover / enter / scroll
|
||||
|
||||
Animate the *qualities* of the glass, not its geometry. Frost deepening and the highlight brightening on hover reads as the panel "leaning into the light."
|
||||
|
||||
```css
|
||||
.glass {
|
||||
transition:
|
||||
backdrop-filter 0.4s ease,
|
||||
background 0.4s ease,
|
||||
box-shadow 0.4s ease,
|
||||
transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.glass:hover {
|
||||
backdrop-filter: blur(24px) saturate(180%);
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
transform: translateY(-4px);
|
||||
box-shadow:
|
||||
0 16px 48px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
```
|
||||
|
||||
Caveat: animating `backdrop-filter` itself is the most expensive thing here — the backdrop is re-sampled and re-blurred every frame. If hover feels janky, animate only `transform` + `box-shadow` + `background` (cheaper) and snap the blur, or keep the blur change small.
|
||||
|
||||
**Entrance** (frost resolving in) reads beautifully with `@starting-style`:
|
||||
|
||||
```css
|
||||
.glass[data-enter] {
|
||||
transition: opacity 0.5s ease, backdrop-filter 0.6s ease, transform 0.5s ease;
|
||||
}
|
||||
@starting-style {
|
||||
.glass[data-enter] {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0px) saturate(100%);
|
||||
transform: translateY(16px) scale(0.98);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Specular sweep** — a moving glint across the surface on hover or as an ambient loop:
|
||||
|
||||
```css
|
||||
.glass .sweep {
|
||||
position: absolute; inset: 0; border-radius: inherit; overflow: hidden; pointer-events: none;
|
||||
}
|
||||
.glass .sweep::before {
|
||||
content: ""; position: absolute; top: 0; left: -60%; width: 50%; height: 100%;
|
||||
background: linear-gradient(105deg, transparent, rgba(255,255,255,0.35), transparent);
|
||||
transform: skewX(-15deg);
|
||||
animation: sweep 4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes sweep { 0%,100% { left: -60%; } 50% { left: 110%; } }
|
||||
```
|
||||
|
||||
## 5. Liquid glass — SVG refraction (the 2026 look)
|
||||
|
||||
Apple-style "liquid glass" bends the background as if seen through a thick, wobbling lens. Drive a `feDisplacementMap` from animated `feTurbulence` and apply it via `filter` (note: `filter`, not `backdrop-filter` — it distorts the element's own rendered content; layer it over a `backdrop-filter` frost for the full effect).
|
||||
|
||||
```html
|
||||
<svg width="0" height="0" style="position:absolute">
|
||||
<filter id="liquid-glass">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.008 0.012"
|
||||
numOctaves="2" seed="3" result="noise">
|
||||
<!-- animate the field so the refraction slowly churns -->
|
||||
<animate attributeName="baseFrequency"
|
||||
dur="18s" values="0.008 0.012; 0.012 0.008; 0.008 0.012"
|
||||
repeatCount="indefinite"/>
|
||||
</feTurbulence>
|
||||
<feDisplacementMap in="SourceGraphic" in2="noise"
|
||||
scale="22" xChannelSelector="R" yChannelSelector="G"/>
|
||||
</filter>
|
||||
</svg>
|
||||
```
|
||||
|
||||
```css
|
||||
.liquid {
|
||||
backdrop-filter: blur(6px) saturate(150%); /* light frost under the refraction */
|
||||
filter: url(#liquid-glass); /* the lens distortion */
|
||||
}
|
||||
```
|
||||
|
||||
Knobs:
|
||||
- `scale` on `feDisplacementMap` = refraction strength. 8–14 = subtle ripple; 20–30 = pronounced lens; >40 = molten.
|
||||
- Lower `baseFrequency` = larger, slower waves (more "liquid"); higher = tight, busy ripples.
|
||||
- Animate `baseFrequency` (above) or `scale` for a living surface. Keep `dur` long (12–20s) so it breathes rather than shimmers.
|
||||
|
||||
Cost warning: SVG displacement filters are heavy and force off-GPU paths in some browsers. Use on **one** hero element, not a grid of cards. Test on a mid-range laptop; drop to plain `backdrop-filter` if frames stutter.
|
||||
|
||||
## 6. Performance — backdrop-filter is expensive
|
||||
|
||||
- Each `backdrop-filter` element re-samples and blurs everything behind it. **Limit the count** — a dozen glass cards on screen will tank scroll FPS.
|
||||
- Promote heavy glass to its own compositor layer: `transform: translateZ(0)` or `will-change: transform` (remove `will-change` after the animation; leaving it on wastes memory).
|
||||
- Avoid stacking `backdrop-filter` elements over each other (nested blur re-blurs the blur — quadratic cost).
|
||||
- Prefer animating `transform`/`opacity`/`box-shadow` over animating the blur radius. If you must animate blur, keep the radius delta small.
|
||||
- Big blur radii (>40px) over large areas are the single worst case; cap the radius and the panel size.
|
||||
|
||||
## 7. Accessibility — non-negotiable
|
||||
|
||||
Glass is decorative; the content on it must stay legible and the effect must be defeatable.
|
||||
|
||||
**Honor reduced transparency and reduced motion** — fall back to a solid, opaque panel:
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.glass {
|
||||
background: #1a1a2e; /* solid, accessible panel */
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.glass::after { display: none; } /* drop grain/noise */
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.glass, .glass .sweep::before { transition: none; animation: none; }
|
||||
.liquid { filter: none; } /* stop the churning refraction */
|
||||
}
|
||||
```
|
||||
|
||||
**Contrast over busy backgrounds.** Translucent panels over photos can drop text below WCAG contrast. Defenses:
|
||||
- Put a contrast floor *under* the text: a semi-opaque scrim or a darkening gradient inside the panel.
|
||||
- Add `text-shadow: 0 1px 2px rgba(0,0,0,0.4)` to body text on glass.
|
||||
- Target ≥ 4.5:1 for body text, ≥ 3:1 for large text — and verify against the *brightest* region of the backdrop, not the average.
|
||||
|
||||
**Feature fallback** for browsers without `backdrop-filter`:
|
||||
|
||||
```css
|
||||
@supports not (backdrop-filter: blur(1px)) {
|
||||
.glass { background: rgba(30, 30, 50, 0.85); } /* opaque-enough fallback */
|
||||
}
|
||||
```
|
||||
|
||||
## 8. Standalone freeze harness
|
||||
|
||||
Glass demos verify by *state* (rest / hover / enter) and *time* (the sweep/refraction loop). Wire both so a screenshot lands deterministically:
|
||||
|
||||
```html
|
||||
<script>
|
||||
const p = new URLSearchParams(location.search);
|
||||
const state = p.get("state"); // ?state=hover → force the hover look
|
||||
if (state) document.documentElement.dataset.state = state;
|
||||
const t = p.get("t"); // ?t=2 → freeze CSS animations at 2s
|
||||
if (t !== null) {
|
||||
document.getAnimations().forEach(a => { a.currentTime = parseFloat(t) * 1000; a.pause(); });
|
||||
// SVG SMIL (the <animate>): pin the refraction frame too
|
||||
document.querySelector("svg")?.pauseAnimations?.();
|
||||
document.querySelector("svg")?.setCurrentTime?.(parseFloat(t));
|
||||
}
|
||||
window.__ready = true;
|
||||
</script>
|
||||
```
|
||||
|
||||
CSS drives the forced state: `:root[data-state="hover"] .glass { /* hover values */ }`.
|
||||
|
||||
---
|
||||
Frosted glass that reads as glass — blur, edge light, and refraction, with a clean fallback. Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=web-animation-skills&utm_content=skill_footer&utm_term=glassmorphism)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
Whitespace-only changes.
@@ -1,225 +0,0 @@
|
||||
---
|
||||
name: gsap-web
|
||||
description: This skill should be used when the user asks to "build a scroll animation", "add ScrollTrigger", "pin a section while scrolling", "scrub an animation to scroll", "create a hero timeline", "do a horizontal scroll section", "split text and animate words", "animate a layout change with GSAP Flip", "sync ScrollTrigger with Lenis", "Lenis smooth scroll jittery", "ScrollTrigger markers drift", "ScrollTrigger pins break with Lenis/Locomotive", or "set up smooth scroll with GSAP pinning". Covers GSAP timelines, ScrollTrigger, SplitText, Flip, and Lenis/Locomotive smooth-scroll sync for code-driven web motion.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# GSAP for the Web
|
||||
|
||||
GSAP (GreenSock Animation Platform) is the workhorse for code-driven web motion: sequenced timelines, scroll-driven storytelling, text reveals, and layout transitions. As of GSAP 3.12+, every plugin (ScrollTrigger, SplitText, Flip, MotionPath, MorphSVG, Draggable, Observer) is 100% free, including for commercial use.
|
||||
|
||||
## When to use
|
||||
|
||||
- Scroll-driven storytelling: pinned sections, parallax, progress scrubbing, horizontal scroll
|
||||
- Sequenced hero animations and complex multi-element timelines with precise overlap control
|
||||
- Text reveals split into chars/words/lines (SplitText)
|
||||
- Layout transitions where an element changes position/size/parent (Flip)
|
||||
- Any motion needing fine timing control, easing precision, or imperative orchestration
|
||||
|
||||
## Install and register
|
||||
|
||||
```js
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import { SplitText } from "gsap/SplitText";
|
||||
import { Flip } from "gsap/Flip";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger, SplitText, Flip);
|
||||
```
|
||||
|
||||
Plugins MUST be registered before use or they silently no-op. With a bundler, register once at app entry.
|
||||
|
||||
## Core techniques
|
||||
|
||||
### Timelines first
|
||||
|
||||
Prefer one timeline over many independent tweens — it gives a single playhead, relative positioning, and easy reversal.
|
||||
|
||||
```js
|
||||
const tl = gsap.timeline({ defaults: { ease: "power3.out", duration: 0.6 } });
|
||||
tl.from(".title", { yPercent: 100, opacity: 0 })
|
||||
.from(".sub", { y: 20, opacity: 0 }, "-=0.3") // start 0.3s before prev ends
|
||||
.from(".cta", { scale: 0.9, opacity: 0 }, "<"); // align to prev tween START
|
||||
```
|
||||
|
||||
Position parameter cheat sheet:
|
||||
- `"+=0.5"` / `"-=0.3"` — relative to the end of the timeline (gap / overlap)
|
||||
- `"<"` — start of the previous tween; `">"` — end of the previous tween
|
||||
- `"<0.2"` — 0.2s after the previous tween's start
|
||||
- `"myLabel"` — at a named label added via `tl.addLabel("myLabel")`
|
||||
|
||||
Use `.from()` for entrances (animates FROM the given values TO current CSS), `.to()` for exits, `.fromTo()` when both ends must be explicit (most robust against re-runs).
|
||||
|
||||
### gsap.to vs set vs quickTo
|
||||
|
||||
```js
|
||||
gsap.set(el, { autoAlpha: 0 }); // instant, no tween (autoAlpha = opacity + visibility)
|
||||
const xTo = gsap.quickTo(el, "x", { duration: 0.4, ease: "power3" });
|
||||
window.addEventListener("pointermove", (e) => xTo(e.clientX)); // fast repeated updates
|
||||
```
|
||||
|
||||
`autoAlpha` is preferred over raw `opacity` because it also toggles `visibility:hidden` at 0, removing the element from hit-testing.
|
||||
|
||||
### ScrollTrigger basics
|
||||
|
||||
```js
|
||||
gsap.to(".panel", {
|
||||
xPercent: -100,
|
||||
ease: "none",
|
||||
scrollTrigger: {
|
||||
trigger: ".wrap",
|
||||
start: "top top", // when trigger top hits viewport top
|
||||
end: "+=2000", // 2000px of scroll distance
|
||||
pin: true, // freeze .wrap while the tween plays
|
||||
scrub: 1, // tie progress to scrollbar (1 = 1s catch-up smoothing)
|
||||
markers: true, // dev-only visual markers — remove for prod
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Key semantics:
|
||||
- `start`/`end` take `"triggerPos viewportPos"` (e.g. `"top center"`) or `"+=px"`.
|
||||
- `scrub: true` locks animation progress to scroll exactly; `scrub: <number>` adds smoothing lag.
|
||||
- `toggleActions: "play pause resume reverse"` controls onEnter/onLeave/onEnterBack/onLeaveBack for non-scrubbed triggers.
|
||||
- Use `ease: "none"` for scrubbed tweens so motion tracks scroll linearly.
|
||||
|
||||
### Smooth scroll (Lenis) + ScrollTrigger
|
||||
|
||||
Pairing Lenis (or Locomotive) smooth scrolling with ScrollTrigger desyncs unless both run on one loop. Lenis smooths scroll on its own rAF while ScrollTrigger reads scroll on GSAP's ticker; when they tick independently ScrollTrigger samples a stale position. The fix: drive Lenis from GSAP's ticker and update ScrollTrigger on every Lenis scroll.
|
||||
|
||||
```js
|
||||
import Lenis from "lenis";
|
||||
|
||||
const lenis = new Lenis({ duration: 1.2, smoothWheel: true });
|
||||
|
||||
lenis.on("scroll", ScrollTrigger.update); // 1. update ScrollTrigger on every Lenis scroll
|
||||
gsap.ticker.add((t) => lenis.raf(t * 1000)); // 2. one loop: ticker drives Lenis (seconds → ms)
|
||||
gsap.ticker.lagSmoothing(0); // 3. stop GSAP catch-up jitter on heavy frames
|
||||
```
|
||||
|
||||
Critical: `gsap.ticker` passes time in **seconds**, `lenis.raf()` wants **milliseconds** — multiply by 1000. Do NOT also run a standalone `requestAnimationFrame(raf)` loop for Lenis; that double-drives it.
|
||||
|
||||
Common bugs:
|
||||
- **Jitter/stutter** — a leftover `requestAnimationFrame(raf)` loop competing with the ticker, or missing `lagSmoothing(0)`. Remove the rogue loop; set lag smoothing to 0.
|
||||
- **Markers drift** from their triggers — `ScrollTrigger.update` is not subscribed to `lenis.on("scroll", …)`.
|
||||
- **Pins break** — under Lenis do NOT set a `scrollerProxy` (it scrolls the document). Under Locomotive you MUST wire `scrollerProxy` + `pinType` and set `scroller:` on every trigger.
|
||||
- **Scroll stuck / flipped direction** — missing the `* 1000` unit conversion, or two loops out of order.
|
||||
|
||||
Cleanup on SPA unmount: `gsap.ticker.remove(update)` + `lenis.destroy()`, else loops stack per navigation. Gate behind `prefers-reduced-motion` (skip Lenis, run native scroll). Full Lenis + Locomotive wiring, anchor-link routing, `data-lenis-prevent`, React (`useGSAP`/`useEffect`), and a symptom→cause table are in `references/scrolltrigger-lenis.md`.
|
||||
|
||||
### SplitText (text reveal)
|
||||
|
||||
```js
|
||||
const split = SplitText.create(".headline", { type: "lines, words", linesClass: "line" });
|
||||
gsap.from(split.lines, { yPercent: 100, opacity: 0, stagger: 0.08, duration: 0.7, ease: "power4.out" });
|
||||
```
|
||||
|
||||
Gotchas:
|
||||
- Wrap line-mask reveals: set `overflow: hidden` on the line wrapper so `yPercent: 100` hides cleanly. Add `autoSplit: true` (GSAP 3.13+) to re-split on font load / resize.
|
||||
- Call `split.revert()` before re-splitting or on unmount to restore original DOM and avoid duplicated nodes.
|
||||
- Always split AFTER web fonts load (`document.fonts.ready.then(...)`) to prevent wrong line breaks.
|
||||
|
||||
### Flip (layout transitions)
|
||||
|
||||
Flip records state, lets the DOM change instantly, then animates the visual difference (FLIP technique). Ideal for grid<->list, expanding cards, and reparenting.
|
||||
|
||||
```js
|
||||
const state = Flip.getState(".item"); // 1. capture BEFORE
|
||||
container.classList.toggle("grid"); // 2. mutate DOM/CSS (instant)
|
||||
Flip.from(state, { // 3. animate the delta
|
||||
duration: 0.6, ease: "power2.inOut", stagger: 0.05,
|
||||
absolute: true, // take items out of flow during move (prevents reflow jitter)
|
||||
});
|
||||
```
|
||||
|
||||
### Easing quick guide
|
||||
|
||||
- `power2/3.out` — entrances (fast then settle)
|
||||
- `power2.inOut` — moves between two on-screen states
|
||||
- `back.out(1.7)` — overshoot/pop (number = overshoot amount)
|
||||
- `elastic.out(1, 0.3)` — bouncy, use sparingly
|
||||
- `none` — scrubbed scroll tweens
|
||||
- `steps(n)` — sprite/stepped motion
|
||||
- Custom cubic-bezier equivalent: `CustomEase.create("x", "M0,0 C0.2,0 0,1 1,1")` (CustomEase is free)
|
||||
|
||||
## Performance and cleanup
|
||||
|
||||
- Animate `transform` (`x/y/xPercent/scale/rotation`) and `opacity` only — they are GPU-composited and skip layout/paint. Avoid animating `top/left/width/height`.
|
||||
- Set `will-change: transform` on heavy/pinned elements; remove after.
|
||||
- Batch many similar scroll reveals with `ScrollTrigger.batch()` instead of one trigger per element.
|
||||
- After a viewport/content change, call `ScrollTrigger.refresh()`.
|
||||
|
||||
SPA / framework cleanup is mandatory — orphaned triggers cause memory leaks and ghost pinning. Use `gsap.context()` (or `useGSAP` from `@gsap/react`):
|
||||
|
||||
```js
|
||||
useEffect(() => {
|
||||
const ctx = gsap.context(() => {
|
||||
// all gsap + ScrollTrigger code here
|
||||
}, rootRef);
|
||||
return () => ctx.revert(); // kills tweens, triggers, and reverts inline styles
|
||||
}, []);
|
||||
```
|
||||
|
||||
## Deliver & verify (standalone HTML)
|
||||
|
||||
> **Packaged helper** (`scripts/`): `scripts/seek-shot.sh anim.html 0 1.5 3` freezes the `?t=N` harness and screenshots each moment; `scripts/contact-sheet.sh sheet.png frame-*.png` tiles them for one-glance review. See `scripts/README.md`.
|
||||
|
||||
For a self-contained animation (hero, reveal, loop, micro-scene) the deliverable is **one HTML file that opens directly in a browser** — no build step, no framework, no render pipeline. Match the deliverable to the weight of the work: a single file is the right tier for web motion; don't reach for a bundler when one file does the job.
|
||||
|
||||
**Output contract:**
|
||||
- One `.html` file: GSAP + plugins from CDN, your markup, and the animation in one inline `<script>`.
|
||||
- All motion on **one master timeline** (`const tl = gsap.timeline()`) — a single playhead you can seek.
|
||||
- Include the seek harness below so any moment can be frozen for inspection.
|
||||
|
||||
**Seek harness — freeze an exact moment for screenshots.** The web parallel of a video player's frame-pin: `?t=N` seeks the master timeline to `N` seconds and pauses, so a screenshot lands on a still, deterministic frame.
|
||||
|
||||
```html
|
||||
<script>
|
||||
// ... build your master timeline as `tl` ...
|
||||
const t = new URLSearchParams(location.search).get("t");
|
||||
if (t !== null) { tl.pause(); tl.seek(parseFloat(t)); } // frozen at t seconds
|
||||
// no ?t → plays normally
|
||||
window.__ready = true; // ready signal for headless wait
|
||||
console.log("duration", tl.duration());
|
||||
</script>
|
||||
```
|
||||
|
||||
**Verify loop — render → freeze → screenshot → check:**
|
||||
1. Open the file at three moments across the timeline — start, mid, end:
|
||||
`…/anim.html?t=0`, `?t=<dur/2>`, `?t=<dur>`. Read `tl.duration()` from the console for the end time.
|
||||
2. Screenshot each frozen frame.
|
||||
3. Check both **fidelity** (does it match the brief?) and **artifacts** (clipped text, elements off-canvas, FOUC before fonts load, jank at seams). Output should look intentional and finished.
|
||||
|
||||
Any headless screenshot tool works — your agent's browser tool, or Playwright:
|
||||
|
||||
```bash
|
||||
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/anim.html?t=1.2" frame-mid.png
|
||||
```
|
||||
|
||||
**Before you finish:**
|
||||
1. Opens standalone in a browser — no console errors, no missing CDN.
|
||||
2. All animation on one master timeline; `?t=N` freezes correctly.
|
||||
3. Screenshotted at start / mid / end — matches the brief, no artifacts.
|
||||
4. `prefers-reduced-motion` honored (timeline simplified or skipped).
|
||||
5. Easing is intentional — no accidental `linear` on spatial motion.
|
||||
|
||||
A complete runnable template with the harness wired in is in `examples/standalone-template.html`.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Goal | API |
|
||||
|------|-----|
|
||||
| Sequence with overlap | `tl.from(...).from(..., "-=0.3")` |
|
||||
| Pin while scrolling | `scrollTrigger:{ pin:true, end:"+=N" }` |
|
||||
| Scrub to scroll | `scrollTrigger:{ scrub:1, ease:"none" }` |
|
||||
| Reveal lines of text | `SplitText.create(el,{type:"lines"})` |
|
||||
| Animate layout change | `Flip.getState` → mutate → `Flip.from` |
|
||||
| Fast pointer follow | `gsap.quickTo(el,"x",{...})` |
|
||||
| Kill everything | `ScrollTrigger.getAll().forEach(t=>t.kill())` |
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/scrolltrigger-cookbook.md` — pin, scrub, horizontal scroll, snap, `ScrollTrigger.batch()` reveals, parallax, nested triggers, and SPA cleanup patterns with full code.
|
||||
- `references/scrolltrigger-lenis.md` — full ScrollTrigger + Lenis/Locomotive smooth-scroll sync: canonical wiring, `scrollerProxy`, anchor links, reduced-motion, React (`useGSAP`/`useEffect`), debugging checklist, and symptom→cause table.
|
||||
- `examples/hero-timeline.js` — a complete, runnable hero entrance timeline with SplitText, staggered reveals, and reduced-motion handling.
|
||||
- `examples/standalone-template.html` — the deliverable template: self-contained (CDN GSAP), one master timeline, the `?t=N` seek harness for screenshot verification, and reduced-motion handling.
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* hero-timeline.js — complete, runnable GSAP hero entrance.
|
||||
*
|
||||
* Markup expected:
|
||||
* <section class="hero">
|
||||
* <p class="hero__eyebrow">EYEBROW</p>
|
||||
* <h1 class="hero__title">Big bold headline that wraps</h1>
|
||||
* <p class="hero__sub">Supporting copy.</p>
|
||||
* <div class="hero__actions"><a class="btn">Primary</a><a class="btn btn--ghost">Secondary</a></div>
|
||||
* <img class="hero__art" src="art.svg" alt="" />
|
||||
* </section>
|
||||
*
|
||||
* CSS prerequisite: each split line wrapper needs overflow:hidden for the mask reveal:
|
||||
* .hero__title .line { overflow: hidden; }
|
||||
*
|
||||
* Install: npm i gsap @gsap/react
|
||||
*/
|
||||
|
||||
import gsap from "gsap";
|
||||
import { SplitText } from "gsap/SplitText";
|
||||
|
||||
gsap.registerPlugin(SplitText);
|
||||
|
||||
export function playHeroIntro(root = document) {
|
||||
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const q = gsap.utils.selector(root);
|
||||
|
||||
// Reduced motion: reveal everything instantly, no animation.
|
||||
if (reduceMotion) {
|
||||
gsap.set(q(".hero__eyebrow, .hero__sub, .hero__actions, .hero__art"), { autoAlpha: 1 });
|
||||
gsap.set(q(".hero__title"), { autoAlpha: 1 });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Wait for fonts so SplitText computes correct line breaks.
|
||||
return document.fonts.ready.then(() => {
|
||||
const titleEl = q(".hero__title")[0];
|
||||
const split = new SplitText(titleEl, {
|
||||
type: "lines",
|
||||
linesClass: "line",
|
||||
autoSplit: true, // GSAP 3.13+: re-split on resize/font swap
|
||||
});
|
||||
|
||||
// Hidden start state (prevents flash of unstyled content).
|
||||
gsap.set(q(".hero__eyebrow, .hero__sub, .hero__actions, .hero__art"), { autoAlpha: 0 });
|
||||
gsap.set(titleEl, { autoAlpha: 1 });
|
||||
|
||||
const tl = gsap.timeline({
|
||||
defaults: { ease: "power3.out", duration: 0.8 },
|
||||
onComplete: () => split.revert(), // restore clean DOM once done (optional)
|
||||
});
|
||||
|
||||
tl.from(".hero__eyebrow", { y: 16, autoAlpha: 0, duration: 0.5 })
|
||||
.from(split.lines, { yPercent: 110, stagger: 0.12, duration: 0.9, ease: "power4.out" }, "-=0.2")
|
||||
.from(".hero__sub", { y: 20, autoAlpha: 0 }, "-=0.6")
|
||||
.from(".hero__actions .btn", { y: 12, autoAlpha: 0, stagger: 0.1, duration: 0.5 }, "-=0.4")
|
||||
.from(".hero__art", { scale: 0.92, autoAlpha: 0, ease: "back.out(1.4)", duration: 1.1 }, "<0.1");
|
||||
|
||||
return tl;
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-run on DOM ready when imported directly in a page.
|
||||
if (typeof window !== "undefined" && document.querySelector(".hero")) {
|
||||
if (document.readyState !== "loading") playHeroIntro();
|
||||
else document.addEventListener("DOMContentLoaded", () => playHeroIntro());
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>loading</title>
|
||||
<!-- GSAP core from CDN — no build step. Add plugins the same way if needed:
|
||||
https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/ScrollTrigger.min.js -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/gsap.min.js"></script>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { margin: 0; box-sizing: border-box; }
|
||||
body {
|
||||
min-height: 100svh; display: grid; place-items: center;
|
||||
background: radial-gradient(120% 120% at 50% 0%, #1b1f3b 0%, #0a0a12 60%);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
color: #f4f5ff; overflow: hidden;
|
||||
}
|
||||
.stage { text-align: center; padding: 6vmin; }
|
||||
.title { font-size: clamp(2.5rem, 9vw, 6rem); font-weight: 800; letter-spacing: -0.03em; line-height: 1; }
|
||||
.sub { font-size: clamp(1rem, 3.2vw, 1.6rem); margin-top: 1.2rem; color: #aab; }
|
||||
.cta { display: inline-block; margin-top: 2.4rem; padding: 0.9rem 1.8rem;
|
||||
border-radius: 999px; background: #5b6cff; color: #fff; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="stage">
|
||||
<h1 class="title">Ship it.</h1>
|
||||
<p class="sub">A standalone, screenshot-verifiable GSAP scene.</p>
|
||||
<a class="cta" href="#">Get started</a>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
// ── One master timeline: a single playhead the harness can seek ──────────
|
||||
const tl = gsap.timeline({ defaults: { ease: "power3.out", duration: 0.7 } });
|
||||
|
||||
if (reduce) {
|
||||
// Reduced motion: no spatial movement — just make the final state visible.
|
||||
gsap.set([".title", ".sub", ".cta"], { opacity: 1, y: 0 });
|
||||
} else {
|
||||
tl.from(".title", { yPercent: 110, opacity: 0, ease: "power4.out", duration: 0.9 })
|
||||
.from(".sub", { y: 24, opacity: 0 }, "-=0.45")
|
||||
.from(".cta", { scale: 0.9, opacity: 0, ease: "back.out(1.7)" }, "-=0.3");
|
||||
}
|
||||
|
||||
// ── Seek harness: ?t=N freezes the timeline at N seconds for a clean shot ─
|
||||
const t = new URLSearchParams(location.search).get("t");
|
||||
if (t !== null) { tl.pause(); tl.seek(parseFloat(t)); } // deterministic frozen frame
|
||||
// no ?t → plays normally on load
|
||||
|
||||
// ── Ready signals for headless verification ──────────────────────────────
|
||||
window.__ready = true;
|
||||
document.title = "ready";
|
||||
console.log("duration", tl.duration()); // end time for the ?t=<dur> shot
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,181 +0,0 @@
|
||||
# ScrollTrigger Cookbook
|
||||
|
||||
Runnable patterns for GSAP 3.12+. Register first:
|
||||
|
||||
```js
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
```
|
||||
|
||||
## 1. Pin a section and scrub a timeline through it
|
||||
|
||||
The most common scrollytelling pattern: hold a section fixed and drive a multi-step timeline with scroll.
|
||||
|
||||
```js
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: ".story",
|
||||
start: "top top",
|
||||
end: "+=3000", // total scroll distance the pin lasts
|
||||
pin: true,
|
||||
scrub: 1,
|
||||
anticipatePin: 1, // reduces a 1-frame jump when pinning fast scroll
|
||||
},
|
||||
});
|
||||
|
||||
tl.from(".story .step-1", { autoAlpha: 0, y: 60 })
|
||||
.from(".story .step-2", { autoAlpha: 0, y: 60 })
|
||||
.to(".story .bg", { scale: 1.2, ease: "none" }, 0); // runs across whole timeline
|
||||
```
|
||||
|
||||
Tips:
|
||||
- Each tween occupies an equal slice of the pinned distance unless you set durations/positions.
|
||||
- `pinSpacing: true` (default) adds padding so content below does not jump. Set `false` only when overlaying.
|
||||
- If the pinned element is inside a transformed/`overflow` ancestor, pinning breaks — pin a top-level element or set `pinReparent: true`.
|
||||
|
||||
## 2. Horizontal scroll (panels move sideways as the page scrolls vertically)
|
||||
|
||||
```html
|
||||
<section class="h-wrap"><div class="track">
|
||||
<div class="panel">1</div><div class="panel">2</div><div class="panel">3</div>
|
||||
</div></section>
|
||||
```
|
||||
|
||||
```css
|
||||
.track { display: flex; width: max-content; }
|
||||
.panel { width: 100vw; height: 100vh; flex: 0 0 100vw; }
|
||||
```
|
||||
|
||||
```js
|
||||
const track = document.querySelector(".track");
|
||||
gsap.to(track, {
|
||||
x: () => -(track.scrollWidth - window.innerWidth), // function = recalculated on refresh
|
||||
ease: "none",
|
||||
scrollTrigger: {
|
||||
trigger: ".h-wrap",
|
||||
pin: true,
|
||||
scrub: 1,
|
||||
end: () => "+=" + (track.scrollWidth - window.innerWidth),
|
||||
invalidateOnRefresh: true, // re-run the x function on resize
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`invalidateOnRefresh: true` plus function-based values is the correct way to stay responsive.
|
||||
|
||||
## 3. Snap to sections
|
||||
|
||||
```js
|
||||
gsap.to(".sections", {
|
||||
scrollTrigger: {
|
||||
trigger: ".sections",
|
||||
pin: true,
|
||||
scrub: 1,
|
||||
end: "+=4000",
|
||||
snap: {
|
||||
snapTo: 1 / 4, // 5 evenly spaced points (labels also possible: "labels")
|
||||
duration: { min: 0.2, max: 0.6 },
|
||||
delay: 0.05,
|
||||
ease: "power1.inOut",
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
To snap to timeline labels: build a labeled timeline and use `snap: "labelsDirectional"`.
|
||||
|
||||
## 4. Batch reveals (performant many-element fade-in)
|
||||
|
||||
Instead of creating one ScrollTrigger per card (expensive at scale), batch them:
|
||||
|
||||
```js
|
||||
ScrollTrigger.batch(".card", {
|
||||
start: "top 85%",
|
||||
onEnter: (els) =>
|
||||
gsap.from(els, { autoAlpha: 0, y: 40, stagger: 0.12, overwrite: true }),
|
||||
onLeaveBack: (els) =>
|
||||
gsap.to(els, { autoAlpha: 0, y: 40, stagger: 0.12, overwrite: true }),
|
||||
});
|
||||
```
|
||||
|
||||
Set the initial hidden state to avoid a flash before JS runs: `gsap.set(".card", { autoAlpha: 0 })`.
|
||||
|
||||
## 5. Parallax layers
|
||||
|
||||
```js
|
||||
gsap.utils.toArray(".parallax").forEach((layer) => {
|
||||
const depth = layer.dataset.depth || 0.3; // 0 = static, 1 = scrolls with page
|
||||
gsap.to(layer, {
|
||||
yPercent: -depth * 100,
|
||||
ease: "none",
|
||||
scrollTrigger: { trigger: layer.parentElement, scrub: true, start: "top bottom", end: "bottom top" },
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 6. Linked timelines / containerAnimation (triggers inside horizontal scroll)
|
||||
|
||||
To trigger animations off elements that move horizontally inside a pinned horizontal scroller, pass the horizontal tween as `containerAnimation`:
|
||||
|
||||
```js
|
||||
const scrollTween = gsap.to(track, { x: -2000, ease: "none",
|
||||
scrollTrigger: { trigger: ".h-wrap", pin: true, scrub: 1, end: "+=2000" } });
|
||||
|
||||
gsap.from(".panel-2 .title", {
|
||||
autoAlpha: 0, y: 50,
|
||||
scrollTrigger: { trigger: ".panel-2 .title", containerAnimation: scrollTween, start: "left center" },
|
||||
});
|
||||
```
|
||||
|
||||
Note `start: "left center"` uses horizontal keywords because the trigger moves horizontally.
|
||||
|
||||
## 7. Refresh on dynamic content
|
||||
|
||||
Images and lazy content change layout after triggers are computed. Refresh when ready:
|
||||
|
||||
```js
|
||||
window.addEventListener("load", () => ScrollTrigger.refresh());
|
||||
imagesLoaded(document.body, () => ScrollTrigger.refresh()); // if using imagesLoaded lib
|
||||
```
|
||||
|
||||
For accordions/tabs that change height, call `ScrollTrigger.refresh()` after the layout settles.
|
||||
|
||||
## 8. SPA / React cleanup (critical)
|
||||
|
||||
Orphaned ScrollTriggers cause ghost pins, leaks, and stacking bugs on route changes. Always scope and revert.
|
||||
|
||||
React with `@gsap/react`:
|
||||
|
||||
```jsx
|
||||
import { useGSAP } from "@gsap/react";
|
||||
|
||||
function Section() {
|
||||
const root = useRef(null);
|
||||
useGSAP(() => {
|
||||
gsap.from(".item", {
|
||||
autoAlpha: 0, y: 40, stagger: 0.1,
|
||||
scrollTrigger: { trigger: ".item", start: "top 80%" },
|
||||
});
|
||||
}, { scope: root }); // auto-reverts on unmount
|
||||
return <div ref={root}>...</div>;
|
||||
}
|
||||
```
|
||||
|
||||
Plain SPA / manual:
|
||||
|
||||
```js
|
||||
const ctx = gsap.context(() => { /* all gsap code */ }, rootEl);
|
||||
// on teardown / route leave:
|
||||
ctx.revert();
|
||||
```
|
||||
|
||||
Nuclear option (e.g. global router hook): `ScrollTrigger.getAll().forEach((t) => t.kill());`
|
||||
|
||||
## Debugging checklist
|
||||
|
||||
- Triggers fire at wrong scroll position → likely created before images loaded; call `ScrollTrigger.refresh()`.
|
||||
- Pinned element jumps to top-left → it has a transformed/`will-change`/`overflow:hidden` ancestor; pin higher up.
|
||||
- Animation plays once then never resets → set appropriate `toggleActions` or use `scrub`.
|
||||
- Horizontal scroll not responsive → use function-based `x`/`end` + `invalidateOnRefresh: true`.
|
||||
- Markers misaligned → leftover triggers from previous render; ensure cleanup runs.
|
||||
@@ -1,255 +0,0 @@
|
||||
# ScrollTrigger + Lenis / Locomotive smooth-scroll sync
|
||||
|
||||
Wire GSAP ScrollTrigger together with Lenis (or Locomotive) smooth scrolling so there is no jitter, no marker drift, no broken pins, and no flipped/laggy scroll direction. Every one of those bugs has the same root cause: two animation loops running independently. Lenis transforms the scroll position on its own `requestAnimationFrame` loop while ScrollTrigger reads scroll on GSAP's ticker — when they tick separately, ScrollTrigger samples a stale position and everything desyncs. The fix is to run one loop: let GSAP's ticker drive Lenis, and tell ScrollTrigger to update on every Lenis scroll.
|
||||
|
||||
## The one canonical wiring (Lenis)
|
||||
|
||||
This is the entire correct setup. Do not add a separate `requestAnimationFrame` loop for Lenis — that is the most common mistake and the source of nearly all desync.
|
||||
|
||||
```js
|
||||
import Lenis from 'lenis';
|
||||
import gsap from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const lenis = new Lenis({
|
||||
duration: 1.2,
|
||||
smoothWheel: true,
|
||||
// do NOT enable Lenis's own rAF; GSAP's ticker drives it (below)
|
||||
});
|
||||
|
||||
// 1. Update ScrollTrigger every time Lenis scrolls
|
||||
lenis.on('scroll', ScrollTrigger.update);
|
||||
|
||||
// 2. Drive Lenis from GSAP's ticker (single shared loop).
|
||||
// gsap.ticker time is in SECONDS; Lenis expects MILLISECONDS.
|
||||
gsap.ticker.add((time) => {
|
||||
lenis.raf(time * 1000);
|
||||
});
|
||||
|
||||
// 3. Disable GSAP's lag smoothing so ScrollTrigger never defers/
|
||||
// catches up against Lenis (prevents jitter on heavy frames)
|
||||
gsap.ticker.lagSmoothing(0);
|
||||
```
|
||||
|
||||
Three lines do all the work:
|
||||
|
||||
1. `lenis.on('scroll', ScrollTrigger.update)` — ScrollTrigger recomputes positions the instant Lenis moves the page, so triggers/markers/pins track the smoothed scroll exactly.
|
||||
2. `gsap.ticker.add(time => lenis.raf(time * 1000))` — Lenis advances inside GSAP's loop, guaranteeing both run on the same frame and in the same order. Critical unit conversion: `gsap.ticker` passes seconds, `lenis.raf()` wants milliseconds, so multiply by 1000.
|
||||
3. `gsap.ticker.lagSmoothing(0)` — disables GSAP's catch-up behavior that, combined with Lenis, causes visible jitter when a frame is slow.
|
||||
|
||||
After this, never call `requestAnimationFrame(raf)` for Lenis yourself. If any tutorial code has a standalone `function raf(time){ lenis.raf(time); requestAnimationFrame(raf) }` loop, delete it — it competes with the ticker and reintroduces jitter and direction lag.
|
||||
|
||||
## Locomotive Scroll variant
|
||||
|
||||
Locomotive needs `scrollerProxy` because it scrolls an inner element, not `window`. The principle is identical (one loop, update ScrollTrigger on scroll), but the proxy maps coordinates:
|
||||
|
||||
```js
|
||||
const locoScroll = new LocomotiveScroll({
|
||||
el: document.querySelector('[data-scroll-container]'),
|
||||
smooth: true,
|
||||
});
|
||||
|
||||
locoScroll.on('scroll', ScrollTrigger.update);
|
||||
|
||||
ScrollTrigger.scrollerProxy('[data-scroll-container]', {
|
||||
scrollTop(value) {
|
||||
return arguments.length
|
||||
? locoScroll.scrollTo(value, { duration: 0, disableLerp: true })
|
||||
: locoScroll.scroll.instance.scroll.y;
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return { top: 0, left: 0, width: innerWidth, height: innerHeight };
|
||||
},
|
||||
pinType: document.querySelector('[data-scroll-container]').style.transform
|
||||
? 'transform' : 'fixed',
|
||||
});
|
||||
|
||||
ScrollTrigger.addEventListener('refresh', () => locoScroll.update());
|
||||
ScrollTrigger.refresh();
|
||||
```
|
||||
|
||||
Every ScrollTrigger then needs `scroller: '[data-scroll-container]'`. Lenis is simpler because it scrolls the real document, so no `scrollerProxy` is needed.
|
||||
|
||||
## Pins, refresh, and resize
|
||||
|
||||
- Pinned ScrollTriggers work without `scrollerProxy` under Lenis because Lenis scrolls the document body. If pins still jump, ensure no rogue rAF loop exists and that `ScrollTrigger.update` is wired.
|
||||
- On dynamic content/resize, call `ScrollTrigger.refresh()` after layout settles. With Lenis, also call `lenis.resize()` if container size changes.
|
||||
- For elements that should *not* be smoothed (e.g. a modal that scrolls natively), mark with `data-lenis-prevent` so Lenis ignores them.
|
||||
|
||||
## Anchor links with smooth scroll
|
||||
|
||||
Native `<a href="#section">` jumps bypass Lenis (instant jump, then Lenis fights it). Route anchors through `lenis.scrollTo`:
|
||||
|
||||
```js
|
||||
document.querySelectorAll('a[href^="#"]').forEach((a) => {
|
||||
a.addEventListener('click', (e) => {
|
||||
const id = a.getAttribute('href');
|
||||
if (id.length > 1) {
|
||||
e.preventDefault();
|
||||
lenis.scrollTo(id, { offset: 0 }); // accepts selector, element, or number
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
If a sticky header overlaps the target, pass a negative offset or an offset callback:
|
||||
|
||||
```js
|
||||
lenis.scrollTo(targetEl, { offset: -80 }); // 80px sticky header
|
||||
```
|
||||
|
||||
## Reduced-motion gate
|
||||
|
||||
Respect `prefers-reduced-motion`: skip smooth scrolling entirely (do not instantiate Lenis, or stop it) and let ScrollTrigger run on native scroll.
|
||||
|
||||
```js
|
||||
const prefersReduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
let lenis;
|
||||
if (!prefersReduced) {
|
||||
lenis = new Lenis({ duration: 1.2 });
|
||||
lenis.on('scroll', ScrollTrigger.update);
|
||||
gsap.ticker.add((time) => lenis.raf(time * 1000));
|
||||
gsap.ticker.lagSmoothing(0);
|
||||
}
|
||||
// ScrollTrigger animations are defined normally and work with or without Lenis
|
||||
```
|
||||
|
||||
## Cleanup (SPA / React / route changes)
|
||||
|
||||
Destroy Lenis and remove the ticker callback on unmount, or stacked loops accumulate:
|
||||
|
||||
```js
|
||||
function setup() {
|
||||
const update = (time) => lenis.raf(time * 1000);
|
||||
lenis.on('scroll', ScrollTrigger.update);
|
||||
gsap.ticker.add(update);
|
||||
gsap.ticker.lagSmoothing(0);
|
||||
return () => {
|
||||
gsap.ticker.remove(update);
|
||||
lenis.destroy();
|
||||
ScrollTrigger.getAll().forEach((t) => t.kill());
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Debugging checklist (work top to bottom)
|
||||
|
||||
1. **Is there a rogue rAF loop?** Search the codebase for `requestAnimationFrame` near `lenis.raf`. If Lenis is also driven by `gsap.ticker`, delete the standalone loop. Two loops = jitter, lag, flipped feel.
|
||||
2. **Unit conversion present?** Confirm `lenis.raf(time * 1000)` inside the ticker. Missing `* 1000` makes scroll feel stuck or wildly slow.
|
||||
3. **Is `ScrollTrigger.update` subscribed?** `lenis.on('scroll', ScrollTrigger.update)` must exist. Without it, markers and triggers drift behind the smoothed position.
|
||||
4. **Is `lagSmoothing(0)` set?** Without it, heavy frames cause GSAP to "catch up", visible as jitter.
|
||||
5. **Markers on for diagnosis.** Add `markers: true` to a trigger. If markers sit at the *native* scroll position but content moves with smoothed scroll, the update subscription is missing or a proxy is mis-set.
|
||||
6. **Pins jumping?** Confirm no proxy is set for Lenis (it scrolls the document). For Locomotive, confirm `pinType` and `scroller` are correct.
|
||||
7. **After dynamic content / fonts / images load**, call `ScrollTrigger.refresh()`. Stale measurements cause triggers to fire at wrong offsets.
|
||||
8. **Nested scroll areas** (modals, code blocks) misbehave? Add `data-lenis-prevent` to the scrollable child.
|
||||
9. **Cleanup on route change?** In SPAs, verify `gsap.ticker.remove(update)`, `lenis.destroy()`, and killing ScrollTriggers — otherwise each navigation stacks another loop.
|
||||
|
||||
## Symptom → cause table
|
||||
|
||||
| Symptom | Most likely cause |
|
||||
|---|---|
|
||||
| General jitter/stutter | Standalone rAF loop + ticker both driving Lenis |
|
||||
| Scroll stuck / barely moves | Missing `* 1000` unit conversion |
|
||||
| Markers offset from triggers | `ScrollTrigger.update` not bound to `lenis.on('scroll')` |
|
||||
| Jitter only on heavy frames | `lagSmoothing(0)` not set |
|
||||
| Pins jump / break | Proxy wrongly set under Lenis, or wrong `pinType` (Loco) |
|
||||
| Animations a frame behind | Two loops out of order; consolidate to one ticker |
|
||||
| Anchor links jump then snap back | Native `#hash` jump not routed via `lenis.scrollTo` |
|
||||
| Triggers fire at wrong scroll position | Missing `ScrollTrigger.refresh()` after content/layout change |
|
||||
| Worse after each route change | No cleanup; loops/triggers accumulating |
|
||||
| Inner scroll container won't scroll | Lenis intercepting; add `data-lenis-prevent` |
|
||||
|
||||
## Complete React integration (useGSAP)
|
||||
|
||||
```jsx
|
||||
'use client';
|
||||
import { useRef } from 'react';
|
||||
import Lenis from 'lenis';
|
||||
import gsap from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
import { useGSAP } from '@gsap/react';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger, useGSAP);
|
||||
|
||||
export default function ScrollSection() {
|
||||
const root = useRef(null);
|
||||
|
||||
useGSAP(() => {
|
||||
const prefersReduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
let lenis;
|
||||
let update;
|
||||
if (!prefersReduced) {
|
||||
lenis = new Lenis({ duration: 1.2, smoothWheel: true });
|
||||
lenis.on('scroll', ScrollTrigger.update);
|
||||
update = (time) => lenis.raf(time * 1000);
|
||||
gsap.ticker.add(update);
|
||||
gsap.ticker.lagSmoothing(0);
|
||||
}
|
||||
|
||||
gsap.to('.panel', {
|
||||
xPercent: -100,
|
||||
ease: 'none',
|
||||
scrollTrigger: {
|
||||
trigger: '.panel',
|
||||
start: 'top top',
|
||||
end: '+=1500',
|
||||
scrub: true,
|
||||
pin: true,
|
||||
// markers: true, // enable while debugging
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (lenis) {
|
||||
gsap.ticker.remove(update);
|
||||
lenis.destroy();
|
||||
}
|
||||
};
|
||||
}, { scope: root });
|
||||
|
||||
return (
|
||||
<div ref={root}>
|
||||
<section className="panel">…</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`useGSAP` auto-reverts GSAP tweens/ScrollTriggers created in its scope on unmount; the returned function additionally tears down Lenis and the ticker callback so nothing leaks across navigations.
|
||||
|
||||
## Plain useEffect equivalent
|
||||
|
||||
```jsx
|
||||
useEffect(() => {
|
||||
const lenis = new Lenis({ duration: 1.2 });
|
||||
lenis.on('scroll', ScrollTrigger.update);
|
||||
const update = (t) => lenis.raf(t * 1000);
|
||||
gsap.ticker.add(update);
|
||||
gsap.ticker.lagSmoothing(0);
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
// ...ScrollTrigger animations here...
|
||||
});
|
||||
|
||||
return () => {
|
||||
gsap.ticker.remove(update);
|
||||
lenis.destroy();
|
||||
ctx.revert();
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
## Excluding regions from smooth scroll
|
||||
|
||||
```html
|
||||
<div class="code-block" data-lenis-prevent>…natively scrollable…</div>
|
||||
```
|
||||
|
||||
`data-lenis-prevent` on a scrollable element makes Lenis ignore wheel/touch there, so inner scrolling works normally while the page stays smooth.
|
||||
|
||||
---
|
||||
Sync ScrollTrigger to smooth scroll and the timeline tracks the page perfectly. Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=web-animation-skills&utm_content=skill_footer&utm_term=gsap-web)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
Whitespace-only changes.
@@ -1,180 +0,0 @@
|
||||
---
|
||||
name: lottie-animation
|
||||
description: This skill should be used when the user asks to "add a Lottie animation", "play a .lottie or .json animation", "integrate a Bodymovin/After Effects animation on web or mobile", "control Lottie playback / segments", "make a scroll-driven Lottie", "recolor a Lottie at runtime", or "export an AE animation to Lottie". Covers dotLottie/lottie-web integration, playback control, interactivity, theming, and the AE export checklist.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Lottie Animation
|
||||
|
||||
The bridge from After Effects to product: vector animation shipped as JSON (or zipped `.lottie`), tiny and resolution-independent, played by a runtime on web, iOS, Android, and React Native. This skill covers integrating, controlling, and exporting Lottie.
|
||||
|
||||
## When to use
|
||||
|
||||
- Ship a designer's AE animation to web/iOS/Android/React Native
|
||||
- Looping illustrations, onboarding, empty states, animated icons
|
||||
- Scroll-driven or interaction-driven vector playback (hover, click, cursor)
|
||||
- Runtime theming/recoloring instead of re-exporting per brand
|
||||
|
||||
## Formats: .json vs .lottie
|
||||
|
||||
- `.json` — the raw Bodymovin output. Human-readable, larger.
|
||||
- `.lottie` — a zipped container (often 60–80% smaller) that can bundle multiple animations, images, and theming. Preferred for shipping. Player: `@lottiefiles/dotlottie-web` (modern, canvas + WASM) supersedes the older `lottie-web`.
|
||||
|
||||
Prefer the **dotLottie** runtime for new work; use `lottie-web` only for legacy `.json` + SVG renderer needs.
|
||||
|
||||
## Core workflow (web, dotLottie)
|
||||
|
||||
```bash
|
||||
npm i @lottiefiles/dotlottie-web # vanilla
|
||||
npm i @lottiefiles/dotlottie-react # React wrapper
|
||||
```
|
||||
|
||||
Vanilla:
|
||||
|
||||
```js
|
||||
import { DotLottie } from "@lottiefiles/dotlottie-web";
|
||||
|
||||
const dotLottie = new DotLottie({
|
||||
canvas: document.querySelector("#lottie"), // a <canvas> element
|
||||
src: "/hero.lottie", // or .json
|
||||
loop: true,
|
||||
autoplay: true,
|
||||
// speed: 1, mode: "forward" | "reverse" | "bounce", renderConfig: { autoResize: true }
|
||||
});
|
||||
```
|
||||
|
||||
React:
|
||||
|
||||
```jsx
|
||||
import { DotLottieReact } from "@lottiefiles/dotlottie-react";
|
||||
|
||||
function Hero() {
|
||||
return <DotLottieReact src="/hero.lottie" loop autoplay style={{ width: 320 }} />;
|
||||
}
|
||||
```
|
||||
|
||||
Capture the instance to control it:
|
||||
|
||||
```jsx
|
||||
const [dl, setDl] = useState(null);
|
||||
<DotLottieReact src="/icon.lottie" dotLottieRefCallback={setDl} />;
|
||||
// dl?.play(); dl?.pause(); dl?.setSpeed(2);
|
||||
```
|
||||
|
||||
## Playback control
|
||||
|
||||
```js
|
||||
dotLottie.play();
|
||||
dotLottie.pause();
|
||||
dotLottie.stop();
|
||||
dotLottie.setSpeed(1.5);
|
||||
dotLottie.setMode("bounce"); // forward | reverse | bounce | reverse-bounce
|
||||
dotLottie.setFrame(42); // jump to a frame
|
||||
dotLottie.setSegment(30, 90); // constrain playback to a frame range (e.g. a "loading" loop)
|
||||
```
|
||||
|
||||
Events drive sequencing and UI:
|
||||
|
||||
```js
|
||||
dotLottie.addEventListener("load", () => { /* totalFrames now available */ });
|
||||
dotLottie.addEventListener("complete", () => { /* non-looping playback finished */ });
|
||||
dotLottie.addEventListener("frame", ({ currentFrame }) => { /* per-frame */ });
|
||||
```
|
||||
|
||||
State-machine style (e.g. button that plays "checked" segment then idles): play a segment, listen for `complete`, then `setSegment` to the idle loop.
|
||||
|
||||
## Scroll-driven Lottie (no GSAP needed)
|
||||
|
||||
Map scroll progress (0–1) to a frame. Pause autoplay and drive frames yourself.
|
||||
|
||||
```js
|
||||
const dotLottie = new DotLottie({ canvas, src: "/scroll.lottie", autoplay: false });
|
||||
let total = 0;
|
||||
dotLottie.addEventListener("load", () => { total = dotLottie.totalFrames; });
|
||||
|
||||
window.addEventListener("scroll", () => {
|
||||
const el = canvas.closest(".scrolly");
|
||||
const rect = el.getBoundingClientRect();
|
||||
const p = clamp(-rect.top / (rect.height - window.innerHeight), 0, 1); // 0..1 through the section
|
||||
dotLottie.setFrame(p * (total - 1));
|
||||
}, { passive: true });
|
||||
|
||||
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
||||
```
|
||||
|
||||
For richer interaction (hover-to-play, click toggles, cursor-follow) without writing the wiring, use `@lottiefiles/lottie-interactivity`, which maps `scroll`, `hover`, `click`, `play`, and `cursor` modes to frame ranges declaratively.
|
||||
|
||||
## Runtime theming / recoloring
|
||||
|
||||
Two paths:
|
||||
- **dotLottie themes**: a `.lottie` can embed named color themes; switch with `dotLottie.setTheme("dark")` / `loadTheme(...)`. Authored in LottieFiles tooling — no re-export to recolor.
|
||||
- **Manual color override**: load the JSON, walk `layers[].shapes[]` and patch `c.k` color arrays (normalized 0–1 RGBA), then init the player with the modified object. Brittle (depends on layer structure) — prefer themes or CSS-driven SVG-renderer (`lottie-web` `rendererSettings`) when you must restyle by class.
|
||||
|
||||
## Mobile runtimes (parity)
|
||||
|
||||
- iOS — `lottie-ios` (`LottieAnimationView`): `.play()`, `.currentProgress`, `.play(fromFrame:toFrame:)`.
|
||||
- Android — `lottie-android` (`LottieAnimationView` / Compose `LottieAnimation`): `setMinAndMaxFrame`, `progress`.
|
||||
- React Native — `lottie-react-native` (`<LottieView source={...} progress={anim} />`, drive `progress` with `Animated`).
|
||||
|
||||
The same `.lottie`/`.json` plays across all; segments and progress concepts match the web API.
|
||||
|
||||
## Optimization
|
||||
|
||||
- Ship `.lottie` (zipped) over raw `.json`; lazy-load offscreen players and pause when not visible (`IntersectionObserver` → `play`/`pause`).
|
||||
- Prefer the canvas/WASM dotLottie renderer for many simultaneous animations; SVG renderer (lottie-web) is fine for one crisp icon but costs more DOM for complex files.
|
||||
- Keep AE comps small: fewer layers/keyframes, no large embedded rasters; flatten precomps where possible. File size tracks keyframe and path complexity, not duration.
|
||||
- Recolor at runtime (themes) instead of exporting a file per brand/theme.
|
||||
|
||||
## Deliver & verify (standalone HTML)
|
||||
|
||||
> **Packaged helper** (`scripts/`): `scripts/seek-shot.sh anim.html 0 1.5 3` freezes the `?t=N` harness and screenshots each moment; `scripts/contact-sheet.sh sheet.png frame-*.png` tiles them for one-glance review. See `scripts/README.md`.
|
||||
|
||||
For a self-contained Lottie demo (looping illustration, animated icon, empty state) the deliverable is **one HTML file that opens directly in a browser** — the dotLottie/lottie-web runtime from CDN, a `<canvas>`, and your `.lottie`/`.json` (a relative file or a data URL). No build step. One file is the right tier for shipping a player; don't reach for a bundler.
|
||||
|
||||
**Output contract:**
|
||||
- One `.html` file: the runtime via CDN (`@lottiefiles/dotlottie-web`), the canvas, and `autoplay:false` so the frame is yours to drive.
|
||||
- Include the seek harness so any frame can be frozen for a screenshot.
|
||||
|
||||
**Seek harness — freeze an exact frame.** `?t=N` jumps to a frame and holds it so a screenshot lands on a still, deterministic frame. Lottie's playhead is in frames, so stop on the frame directly:
|
||||
|
||||
```html
|
||||
<script>
|
||||
const dl = new DotLottie({ canvas, src: "/icon.lottie", autoplay: false });
|
||||
dl.addEventListener("load", () => {
|
||||
const t = new URLSearchParams(location.search).get("t");
|
||||
if (t !== null) dl.setFrame(parseFloat(t)); // freeze on frame N (lottie-web: anim.goToAndStop(N, true))
|
||||
else dl.play();
|
||||
console.log("totalFrames", dl.totalFrames); // read for end-frame
|
||||
window.__ready = true;
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
**Verify loop — render → freeze → screenshot → check:** open the file at first / mid / last frame (`?t=0`, `?t=<total/2>`, `?t=<total-1>`; read `totalFrames` from the console), screenshot each, and check **fidelity** (colors/theme correct, segment loops where intended) plus **artifacts** (canvas blurry from missing `autoResize`/DPR, clipped composition, FOUC before the file loads, jank). Any headless tool works:
|
||||
|
||||
```bash
|
||||
npx playwright screenshot --wait-for-timeout=600 "file://$PWD/lottie.html?t=42" frame-mid.png
|
||||
```
|
||||
|
||||
**Before you finish:**
|
||||
1. Opens standalone — no console errors, CDN runtime and the `.lottie`/`.json` load.
|
||||
2. `?t=N` (`setFrame`/`goToAndStop`) freezes the correct, deterministic frame.
|
||||
3. Screenshotted at first / mid / last frame — matches the brief, sharp, no clipping.
|
||||
4. `prefers-reduced-motion` honored — don't `autoplay` a loop; show a static frame or offer a play control.
|
||||
5. Easing is intentional — playback speed/segment chosen on purpose, motion baked in AE reads as designed.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Goal | Call |
|
||||
|------|------|
|
||||
| Play a range | `setSegment(start, end)` |
|
||||
| Jump to frame | `setFrame(n)` |
|
||||
| Reverse / bounce | `setMode("reverse"|"bounce")` |
|
||||
| Scroll-drive | `autoplay:false` + `setFrame(p*totalFrames)` |
|
||||
| Theme swap | `setTheme("dark")` |
|
||||
| Know length | `totalFrames` after `load` event |
|
||||
| Declarative interactivity | `@lottiefiles/lottie-interactivity` |
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/integration-and-export.md` — full dotLottie-web + React setup and event/segment control, `lottie-interactivity` mode configs, scroll/cursor patterns, runtime theming details, and the complete After Effects → Bodymovin export checklist with the list of unsupported AE features and how to work around them.
|
||||
@@ -1,233 +0,0 @@
|
||||
# Lottie Integration & Export (detailed)
|
||||
|
||||
## 1. dotLottie-web full setup
|
||||
|
||||
```bash
|
||||
npm i @lottiefiles/dotlottie-web
|
||||
```
|
||||
|
||||
```js
|
||||
import { DotLottie } from "@lottiefiles/dotlottie-web";
|
||||
|
||||
const dotLottie = new DotLottie({
|
||||
canvas: document.querySelector("#lottie"),
|
||||
src: "/assets/onboarding.lottie", // .lottie or .json (URL or same-origin path)
|
||||
autoplay: true,
|
||||
loop: true,
|
||||
speed: 1,
|
||||
mode: "forward", // forward | reverse | bounce | reverse-bounce
|
||||
useFrameInterpolation: true, // smoother than the source frame rate
|
||||
renderConfig: {
|
||||
autoResize: true, // keep the canvas crisp on resize/DPR changes
|
||||
devicePixelRatio: window.devicePixelRatio,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The canvas MUST be a real `<canvas>` element. For responsiveness, size the canvas via CSS and keep `autoResize: true`; the runtime handles the backing store.
|
||||
|
||||
### Lifecycle and events
|
||||
|
||||
```js
|
||||
dotLottie.addEventListener("load", () => console.log(dotLottie.totalFrames, dotLottie.duration));
|
||||
dotLottie.addEventListener("loadError", (e) => console.error("bad src", e));
|
||||
dotLottie.addEventListener("play", () => {});
|
||||
dotLottie.addEventListener("pause", () => {});
|
||||
dotLottie.addEventListener("complete", () => {}); // fires when a non-looping play finishes
|
||||
dotLottie.addEventListener("loop", ({ loopCount }) => {});
|
||||
dotLottie.addEventListener("frame", ({ currentFrame }) => {});
|
||||
|
||||
// always clean up (SPA/unmount)
|
||||
dotLottie.destroy();
|
||||
```
|
||||
|
||||
`totalFrames`, `duration`, and segment data are only valid AFTER the `load` event — read them there, not synchronously after construction.
|
||||
|
||||
### Multi-animation .lottie files
|
||||
|
||||
A single `.lottie` can hold several animations:
|
||||
|
||||
```js
|
||||
dotLottie.addEventListener("load", () => {
|
||||
console.log(dotLottie.manifest.animations); // list of animation ids
|
||||
});
|
||||
dotLottie.loadAnimation("success"); // switch by id without re-fetching
|
||||
```
|
||||
|
||||
## 2. React wrapper
|
||||
|
||||
```bash
|
||||
npm i @lottiefiles/dotlottie-react
|
||||
```
|
||||
|
||||
```jsx
|
||||
import { DotLottieReact } from "@lottiefiles/dotlottie-react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
function PlayButton() {
|
||||
const [dl, setDl] = useState(null);
|
||||
const [playing, setPlaying] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dl) return;
|
||||
const onComplete = () => setPlaying(false);
|
||||
dl.addEventListener("complete", onComplete);
|
||||
return () => dl.removeEventListener("complete", onComplete);
|
||||
}, [dl]);
|
||||
|
||||
return (
|
||||
<DotLottieReact
|
||||
src="/play.lottie"
|
||||
autoplay
|
||||
loop={false}
|
||||
dotLottieRefCallback={setDl} // capture instance for imperative control
|
||||
onClick={() => (playing ? dl.pause() : dl.play(), setPlaying((p) => !p))}
|
||||
style={{ width: 200, height: 200 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Props mirror the constructor: `src, autoplay, loop, speed, mode, useFrameInterpolation, renderConfig, themeId, segment`.
|
||||
|
||||
## 3. Segments and state machines
|
||||
|
||||
```js
|
||||
// Loop only the "loading" portion (frames 30–90), then jump to a success segment.
|
||||
dotLottie.setSegment(30, 90);
|
||||
dotLottie.setLoop(true);
|
||||
dotLottie.play();
|
||||
|
||||
function resolveSuccess() {
|
||||
dotLottie.setLoop(false);
|
||||
dotLottie.setSegment(91, 140); // success burst
|
||||
dotLottie.play();
|
||||
}
|
||||
```
|
||||
|
||||
Pattern for icon toggles (e.g. like button): keep distinct frame ranges for each state in one file, and `setSegment` + `play` per interaction; listen for `complete` to settle into an idle range.
|
||||
|
||||
dotLottie also supports embedded **state machines** (`.lottie` with a `state-machine` definition): `dotLottie.stateMachineLoad("sm_id"); dotLottie.stateMachineStart();` then fire inputs/events — useful for complex interactive icons authored in LottieFiles without writing JS state logic.
|
||||
|
||||
## 4. lottie-interactivity (declarative)
|
||||
|
||||
```bash
|
||||
npm i @lottiefiles/lottie-interactivity
|
||||
```
|
||||
|
||||
Works with a `lottie-web`/dotLottie player instance (commonly the `<dotlottie-player>` / `<lottie-player>` web component):
|
||||
|
||||
```html
|
||||
<dotlottie-player id="firstLottie" src="/scroll.lottie" style="width:300px"></dotlottie-player>
|
||||
<script>
|
||||
LottieInteractivity.create({
|
||||
player: "#firstLottie",
|
||||
mode: "scroll",
|
||||
actions: [
|
||||
{ visibility: [0, 0.3], type: "stop", frames: [0] },
|
||||
{ visibility: [0.3, 1.0], type: "seek", frames: [0, 120] }, // map scroll → frames 0..120
|
||||
],
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
Modes:
|
||||
- `scroll` — tie frames to scroll position / element visibility (the canonical scrolly use).
|
||||
- `cursor` — drive frames from cursor position over the element (hover scrub).
|
||||
- `hover` — play on hover, control `loop`/`reset` on leave.
|
||||
- `click` — advance to the next sync state on each click.
|
||||
- `chain` — sequence multiple animations.
|
||||
|
||||
Use `type: "seek"` to scrub frames, `type: "loop"` / `"play"` / `"stop"` for segment behaviors.
|
||||
|
||||
## 5. Manual scroll mapping (full control, no extra lib)
|
||||
|
||||
```js
|
||||
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
||||
let total = 0;
|
||||
dotLottie.addEventListener("load", () => { total = dotLottie.totalFrames - 1; });
|
||||
|
||||
const section = document.querySelector(".scrolly");
|
||||
function update() {
|
||||
const r = section.getBoundingClientRect();
|
||||
const scrollable = r.height - window.innerHeight;
|
||||
const p = clamp(-r.top / scrollable, 0, 1);
|
||||
dotLottie.setFrame(p * total);
|
||||
}
|
||||
window.addEventListener("scroll", update, { passive: true });
|
||||
window.addEventListener("resize", update);
|
||||
```
|
||||
|
||||
Pause when offscreen to save CPU:
|
||||
|
||||
```js
|
||||
new IntersectionObserver(([e]) => e.isIntersecting ? dotLottie.unfreeze() : dotLottie.freeze())
|
||||
.observe(canvas);
|
||||
```
|
||||
|
||||
## 6. Runtime theming
|
||||
|
||||
### Embedded themes (recommended)
|
||||
|
||||
A `.lottie` authored with named color themes:
|
||||
|
||||
```js
|
||||
dotLottie.addEventListener("load", () => {
|
||||
console.log(dotLottie.manifest.themes); // available theme ids
|
||||
dotLottie.setTheme("dark"); // swap colors with no re-fetch / re-export
|
||||
});
|
||||
// or per slot:
|
||||
dotLottie.setThemeData({ /* theme JSON */ });
|
||||
```
|
||||
|
||||
### Manual color patching (last resort)
|
||||
|
||||
```js
|
||||
const json = await fetch("/icon.json").then((r) => r.json());
|
||||
// Lottie colors are normalized 0..1 [r,g,b,a] arrays at shapes[].it[].c.k
|
||||
function recolor(layers, hex) {
|
||||
const [r, g, b] = hexToRgb01(hex);
|
||||
for (const layer of layers) {
|
||||
for (const sh of layer.shapes ?? []) {
|
||||
for (const it of sh.it ?? []) {
|
||||
if (it.ty === "fl" || it.ty === "st") { it.c.k = [r, g, b, 1]; } // fill / stroke
|
||||
}
|
||||
}
|
||||
if (layer.layers) recolor(layer.layers, hex); // precomps
|
||||
}
|
||||
}
|
||||
recolor(json.layers, "#ff5500");
|
||||
new DotLottie({ canvas, data: JSON.stringify(json), autoplay: true });
|
||||
```
|
||||
|
||||
This depends on the exact layer/shape structure and breaks if the file changes — prefer embedded themes, or use the `lottie-web` SVG renderer and target paths with CSS where the markup is stable.
|
||||
|
||||
## 7. After Effects → Bodymovin export checklist
|
||||
|
||||
Plugin: **Bodymovin** (or LottieFiles plugin). `Window → Extensions → Bodymovin`. Select the comp, set destination, configure, Render.
|
||||
|
||||
Pre-export checklist:
|
||||
1. Comp set to a sensible size and frame rate (24/30/60); avoid huge resolutions — Lottie is vector, the canvas scales.
|
||||
2. Name layers/comps clearly — names survive into JSON and aid runtime theming/targeting.
|
||||
3. Convert text you want crisp/animated-by-path to shapes if it uses unsupported text features; otherwise enable "Glyphs"/"Fonts" in Bodymovin settings for live text.
|
||||
4. Trim the work area to exactly the frames you ship; trailing frames bloat the file.
|
||||
5. In Bodymovin settings: enable **Compress assets / .lottie** output for the zipped format; enable "Glyphs" if using text; toggle "Standalone"/"Demo" only as needed.
|
||||
6. Replace embedded raster images with vectors where possible; if rasters are required, they inflate `.json` (consider `.lottie` which bundles them).
|
||||
7. Test the exported file in the LottieFiles preview AND your target runtime before handoff — renderers differ.
|
||||
|
||||
### Unsupported / partially-supported AE features (avoid or rework)
|
||||
|
||||
- **Expressions** — not evaluated by runtimes. Bake expression-driven motion to keyframes (Bodymovin can pre-render some) or remove. Heavy-expression rigs are the #1 source of "looks wrong in product".
|
||||
- **Effects** — most AE effects (blurs, glows, distort, particle systems, CC effects) are NOT supported. Only a small set (e.g. some Gaussian blur, drop shadow, tint, fill via supported attributes) survive, and inconsistently across platforms. Recreate looks with shape layers, gradients, and mattes.
|
||||
- **3D layers / camera** — not supported; fake depth with 2D scaling/position.
|
||||
- **Time remapping / nested time stretch** — flaky; pre-compose and bake.
|
||||
- **Auto-Orient, certain merge-paths modes, some matte combinations** — test explicitly.
|
||||
- **Audio** — not part of Lottie.
|
||||
- **Adjustment layers, layer styles (bevel/emboss/inner shadow)** — largely unsupported; convert to shapes/gradients.
|
||||
- **Track mattes** — alpha/luma mattes generally work but increase cost; flatten when possible.
|
||||
- **Gradients on strokes / complex gradient feathering** — limited; verify.
|
||||
|
||||
Workarounds summary: bake expressions to keyframes, replace effects with native shape/gradient/matte equivalents, flatten precomps, drop rasters, and verify on every target runtime (web/iOS/Android render slightly differently). Keep layer/keyframe count low for performance and file size.
|
||||
|
||||
---
|
||||
Export clean from After Effects and the dotLottie plays light everywhere. Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=web-animation-skills&utm_content=skill_footer&utm_term=lottie-animation)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
Whitespace-only changes.
@@ -1,173 +0,0 @@
|
||||
---
|
||||
name: micro-interaction
|
||||
description: This skill should be used when the user asks to "add a hover/press effect", "animate a toggle or switch", "build an animated like button", "make a toast/snackbar slide in", "animate a drawer or modal", "animate list reordering or add/remove", "do a shared-element layout transition", or "polish UI feedback". Covers UI motion with Framer Motion (motion/react) and modern CSS.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Micro-interactions (UI Motion)
|
||||
|
||||
Small, functional motion that makes an interface feel responsive and alive: hover/press/focus feedback, toggles, toasts, drawers, and list/layout animation. The goal is feedback and continuity, not decoration.
|
||||
|
||||
## When to use
|
||||
|
||||
- Hover/press/focus feedback; toggles, checkboxes, like/heart buttons
|
||||
- Toasts/snackbars, drawers, modals, tooltips, accordions (enter/exit)
|
||||
- List add/remove/reorder; shared-element ("magic move") layout transitions
|
||||
- Loading → success → error state transitions
|
||||
|
||||
## Principles (apply to all of it)
|
||||
|
||||
- Duration: UI micro-interactions live in **100–250ms**. Anything over ~400ms feels laggy for a click response.
|
||||
- Animate `transform` and `opacity` only — they are GPU-composited (no layout/paint). Avoid animating `width/height/top/left`; use `scale` or layout animation instead.
|
||||
- Give **instant** press feedback: `scale: 0.96` on tap with a fast spring.
|
||||
- Asymmetric timing: enter slightly slower (ease-out), exit faster (ease-in). Things should arrive gracefully and leave promptly.
|
||||
- Respect `prefers-reduced-motion`: gate non-essential motion; keep opacity changes, drop large movement.
|
||||
- Easing defaults: ease-out for entrances `cubic-bezier(0.16, 1, 0.3, 1)`; a tasteful overshoot is `cubic-bezier(0.34, 1.56, 0.64, 1)`; standard move `cubic-bezier(0.4, 0, 0.2, 1)`.
|
||||
|
||||
## Framer Motion (motion/react) essentials
|
||||
|
||||
As of v11+, the package is imported as `motion/react` (the `framer-motion` name still works).
|
||||
|
||||
### Gestures and springs
|
||||
|
||||
```jsx
|
||||
import { motion } from "motion/react";
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.03 }}
|
||||
whileTap={{ scale: 0.96 }}
|
||||
whileFocus={{ boxShadow: "0 0 0 3px rgba(59,130,246,.5)" }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
/>
|
||||
```
|
||||
|
||||
Spring intuition: higher `stiffness` = faster; higher `damping` = less bounce. `stiffness: 400, damping: 30` is a snappy UI default. For visible bounce, lower damping (e.g. `damping: 12`).
|
||||
|
||||
### Enter / exit with AnimatePresence
|
||||
|
||||
Exit animations require `AnimatePresence` wrapping conditionally-rendered children, each with a stable `key`.
|
||||
|
||||
```jsx
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
key="panel"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 8 }}
|
||||
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
```
|
||||
|
||||
For toasts/lists, use `mode="popLayout"` so removed items don't hold space while exiting.
|
||||
|
||||
### Layout animation (the superpower)
|
||||
|
||||
`layout` automatically animates any layout change (position/size) using transforms — perfect for reordering, expanding cards, and grid<->list.
|
||||
|
||||
```jsx
|
||||
<motion.li layout transition={{ type: "spring", stiffness: 500, damping: 40 }} />
|
||||
```
|
||||
|
||||
Shared-element transition across components: give two elements the same `layoutId` and Framer Motion animates between them as one mounts and the other unmounts.
|
||||
|
||||
```jsx
|
||||
{!open && <motion.div layoutId="card" onClick={() => setOpen(true)} />}
|
||||
<AnimatePresence>
|
||||
{open && <motion.div layoutId="card" />} {/* "magic moves" from the source */}
|
||||
</AnimatePresence>
|
||||
```
|
||||
|
||||
Gotcha: a plain `layout` element distorts `border-radius` and text during scale. Add `layout` to direct children that should counter-scale, and prefer `borderRadius`/`boxShadow` as motion values, or use `layout="position"` to animate position only.
|
||||
|
||||
## Pure CSS path (no JS, including discrete properties)
|
||||
|
||||
Modern CSS can animate enter/exit and even `display` toggles without a library.
|
||||
|
||||
```css
|
||||
.toast {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
/* allow animating to/from display:none and from initial render */
|
||||
transition-behavior: allow-discrete;
|
||||
}
|
||||
.toast[hidden] { display: none; }
|
||||
|
||||
/* animate FROM these values on first render / when entering */
|
||||
@starting-style {
|
||||
.toast { opacity: 0; transform: translateY(8px); }
|
||||
}
|
||||
```
|
||||
|
||||
`@starting-style` defines the "before-open" values so an element animates in on first appearance; `transition-behavior: allow-discrete` lets `display` (and `overlay` for popovers/dialogs) participate so exit animations run before the element is removed. Baseline in Chrome/Edge/Safari; provide a no-animation fallback for older browsers.
|
||||
|
||||
Always include a reduced-motion guard:
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## State, not just decoration
|
||||
|
||||
Loading → success → error should be one continuous motion (e.g. a button morphs spinner → checkmark), not a jump cut. Keep the element mounted and animate between states so the eye tracks the same object.
|
||||
|
||||
## Deliver & verify (standalone HTML)
|
||||
|
||||
> **Packaged helper** (`scripts/`): `scripts/seek-shot.sh anim.html 0 1.5 3` freezes the `?t=N` harness and screenshots each moment; `scripts/contact-sheet.sh sheet.png frame-*.png` tiles them for one-glance review. See `scripts/README.md`.
|
||||
|
||||
For a self-contained interaction demo (toggle, like button, toast, drawer) the deliverable is **one HTML file that opens directly in a browser**. Pure-CSS interactions ship as-is; for a Framer Motion demo, load React + `motion` from CDN (`esm.sh`) into one inline module — no build step. One file is the right tier; don't reach for a bundler.
|
||||
|
||||
**Output contract:**
|
||||
- One `.html` file: your markup plus either CSS transitions/`@starting-style` or an inline `<script type="module">` importing `motion` from CDN.
|
||||
- A way to land on the **resolved end state** for a screenshot — interactions are state-driven, not time-driven, so freeze the state rather than a clock.
|
||||
|
||||
**Seek harness — pin a deterministic state.** A micro-interaction's "frames" are its states (idle / hover / pressed / open). `?state=open` applies the target state on load so a screenshot captures it settled:
|
||||
|
||||
```html
|
||||
<script>
|
||||
const s = new URLSearchParams(location.search).get("state");
|
||||
if (s) document.documentElement.dataset.state = s; // CSS keys off [data-state="open"]
|
||||
// Framer Motion: set the controlled prop from `s` (e.g. const [open]=useState(s==="open"))
|
||||
// For a CSS @keyframes loop instead, freeze it: el.style.animationDelay=(-N)+"s"; el.style.animationPlayState="paused";
|
||||
window.__ready = true;
|
||||
</script>
|
||||
```
|
||||
|
||||
**Verify loop — render → set state → screenshot → check:** open each meaningful state (`?state=idle`, `?state=hover`, `?state=open`), screenshot, and check **fidelity** (press feedback reads instant, exit runs before unmount) plus **artifacts** (`layout` distorting `border-radius`/text, toast holding space after exit, FOUC, jank). Any headless tool works:
|
||||
|
||||
```bash
|
||||
npx playwright screenshot --wait-for-timeout=400 "file://$PWD/demo.html?state=open" open.png
|
||||
```
|
||||
|
||||
**Before you finish:**
|
||||
1. Opens standalone — no console errors, CDN React/`motion` (if used) resolves.
|
||||
2. The `?state=` (or controlled-prop) freeze lands a deterministic, settled state.
|
||||
3. Screenshotted across states — idle / active / open — matches the brief, no artifacts.
|
||||
4. `prefers-reduced-motion` honored — large movement dropped, opacity/feedback kept.
|
||||
5. Easing is intentional — enter ease-out, exit ease-in, durations in the 100–250ms band.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Need | Approach |
|
||||
|------|----------|
|
||||
| Press feedback | `whileTap={{ scale: 0.96 }}` spring 400/30 |
|
||||
| Enter/exit | `AnimatePresence` + `initial/animate/exit` |
|
||||
| Reorder / resize | `layout` prop (spring) |
|
||||
| Magic move | shared `layoutId` |
|
||||
| Toast stack | `AnimatePresence mode="popLayout"` |
|
||||
| No-JS enter | `@starting-style` + transition |
|
||||
| Animate to display:none | `transition-behavior: allow-discrete` |
|
||||
| Accessibility | `prefers-reduced-motion` guard always |
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/framer-motion-recipes.md` — variants + stagger, `AnimatePresence` modes, `layout`/`layoutId` magic move, `Reorder` drag-to-sort, drag constraints, gesture composition, and `useReducedMotion`.
|
||||
- `references/css-recipes.md` — `@starting-style`, `transition-behavior: allow-discrete`, popover/dialog exit animation, keyframe spinners/toggles, `@property` for animatable custom properties, and `prefers-reduced-motion` patterns.
|
||||
@@ -1,142 +0,0 @@
|
||||
# CSS Micro-interaction Recipes
|
||||
|
||||
Modern CSS handles most UI motion with zero JavaScript, including enter/exit and `display` toggles. Baseline-available in current Chrome/Edge/Safari/Firefox (verify `@starting-style` and `allow-discrete` for your support floor; degrade to instant show/hide otherwise).
|
||||
|
||||
## Enter animation on first render: @starting-style
|
||||
|
||||
`@starting-style` supplies the values an element transitions FROM the first time it renders (or becomes shown). Without it, there is no "previous" state to transition from, so newly-inserted elements snap in.
|
||||
|
||||
```css
|
||||
.menu {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
@starting-style {
|
||||
.menu { opacity: 0; transform: translateY(-8px); }
|
||||
}
|
||||
```
|
||||
|
||||
## Animating to/from display:none: transition-behavior: allow-discrete
|
||||
|
||||
`display` and `content-visibility` are discrete properties — normally they flip instantly, killing exit animations. `allow-discrete` lets them flip at the correct moment (kept visible during exit, swapped at the end).
|
||||
|
||||
```css
|
||||
.drawer {
|
||||
transition:
|
||||
transform 0.25s ease,
|
||||
opacity 0.25s ease,
|
||||
display 0.25s allow-discrete; /* keep displayed through the exit */
|
||||
}
|
||||
.drawer[hidden] { /* closed state */
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
display: none;
|
||||
}
|
||||
@starting-style {
|
||||
.drawer:not([hidden]) { opacity: 0; transform: translateX(100%); }
|
||||
}
|
||||
```
|
||||
|
||||
Shorthand alternative: `transition-behavior: allow-discrete;` applies to all discrete properties in the `transition` list.
|
||||
|
||||
## Popover / dialog exit animation (top-layer)
|
||||
|
||||
Native `popover` and `<dialog>` live in the top layer via the `overlay` property, which is also discrete — animate it too:
|
||||
|
||||
```css
|
||||
[popover] {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
transition: opacity 0.2s, transform 0.2s, overlay 0.2s allow-discrete, display 0.2s allow-discrete;
|
||||
}
|
||||
[popover]:popover-open { opacity: 1; transform: scale(1); }
|
||||
@starting-style {
|
||||
[popover]:popover-open { opacity: 0; transform: scale(0.95); }
|
||||
}
|
||||
```
|
||||
|
||||
This gives a fully CSS-driven open AND close animation for `<button popovertarget>` popovers with no JS.
|
||||
|
||||
## Press / hover / focus feedback
|
||||
|
||||
```css
|
||||
.btn {
|
||||
transition: transform 0.12s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.2s ease;
|
||||
}
|
||||
.btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,.15); }
|
||||
.btn:active { transform: translateY(0) scale(0.97); transition-duration: 0.06s; } /* snappy press */
|
||||
.btn:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
|
||||
```
|
||||
|
||||
Use `:focus-visible` (not `:focus`) so keyboard users get a ring but mouse clicks don't.
|
||||
|
||||
## Animated toggle / switch
|
||||
|
||||
```css
|
||||
.switch { width: 44px; height: 24px; border-radius: 999px; background: #cbd5e1;
|
||||
transition: background 0.2s ease; }
|
||||
.switch[aria-checked="true"] { background: #22c55e; }
|
||||
.switch::after {
|
||||
content: ""; width: 20px; height: 20px; border-radius: 50%; background: #fff;
|
||||
transform: translateX(2px);
|
||||
transition: transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
.switch[aria-checked="true"]::after { transform: translateX(22px); }
|
||||
```
|
||||
|
||||
The `cubic-bezier(0.34, 1.56, 0.64, 1)` overshoot gives the knob a satisfying snap.
|
||||
|
||||
## Loading spinner and pulse
|
||||
|
||||
```css
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spinner { animation: spin 0.8s linear infinite; }
|
||||
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
.skeleton { animation: pulse 1.5s ease-in-out infinite; }
|
||||
```
|
||||
|
||||
## Animatable custom properties with @property
|
||||
|
||||
Plain CSS variables animate discretely. Register them with `@property` to give them a type so they interpolate smoothly — enables gradient angle, number counters, and themed motion.
|
||||
|
||||
```css
|
||||
@property --angle { syntax: "<angle>"; inherits: false; initial-value: 0deg; }
|
||||
|
||||
.gradient-border {
|
||||
background: conic-gradient(from var(--angle), #6366f1, #ec4899, #6366f1);
|
||||
transition: --angle 0.4s ease;
|
||||
}
|
||||
.gradient-border:hover { --angle: 360deg; }
|
||||
```
|
||||
|
||||
## Accordion height (modern: interpolate-size)
|
||||
|
||||
Animating `height: auto` historically required JS measurement. With `interpolate-size: allow-keywords`, transitions to/from intrinsic sizes work:
|
||||
|
||||
```css
|
||||
:root { interpolate-size: allow-keywords; }
|
||||
.accordion__panel {
|
||||
height: 0; overflow: hidden;
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
.accordion__panel[data-open] { height: auto; }
|
||||
```
|
||||
|
||||
Fallback for unsupported browsers: animate `grid-template-rows: 0fr -> 1fr` on a wrapper.
|
||||
|
||||
## Reduced motion (always include)
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then selectively re-enable essential, low-motion feedback (e.g. opacity-only fades) inside the same query if needed. The principle: never remove feedback entirely, just remove large/spatial motion.
|
||||
@@ -1,187 +0,0 @@
|
||||
# Framer Motion Recipes
|
||||
|
||||
Import from `motion/react` (v11+). All examples are React + Framer Motion.
|
||||
|
||||
```jsx
|
||||
import { motion, AnimatePresence, Reorder, useReducedMotion, useDragControls } from "motion/react";
|
||||
```
|
||||
|
||||
## Variants and staggered children
|
||||
|
||||
Variants are named animation states a parent can propagate to children, enabling orchestration like staggering without per-child timing math.
|
||||
|
||||
```jsx
|
||||
const list = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.06, delayChildren: 0.1 },
|
||||
},
|
||||
};
|
||||
const item = {
|
||||
hidden: { opacity: 0, y: 12 },
|
||||
show: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 500, damping: 30 } },
|
||||
};
|
||||
|
||||
<motion.ul variants={list} initial="hidden" animate="show">
|
||||
{items.map((t) => (
|
||||
<motion.li key={t.id} variants={item}>{t.label}</motion.li>
|
||||
))}
|
||||
</motion.ul>
|
||||
```
|
||||
|
||||
`staggerDirection: -1` staggers from last to first. `when: "beforeChildren"` / `"afterChildren"` sequences parent vs children.
|
||||
|
||||
## AnimatePresence modes
|
||||
|
||||
```jsx
|
||||
<AnimatePresence mode="wait"> {/* outgoing finishes before incoming starts (route/tab switches) */}
|
||||
<motion.div key={tab} initial={{opacity:0}} animate={{opacity:1}} exit={{opacity:0}} />
|
||||
</AnimatePresence>
|
||||
```
|
||||
|
||||
Modes:
|
||||
- default (`"sync"`) — incoming and outgoing animate simultaneously.
|
||||
- `"wait"` — finish exit before enter (single-element swaps like tabs/pages).
|
||||
- `"popLayout"` — exiting items are popped from layout flow so siblings re-flow immediately (toast stacks, removing list items). Children must be capable of `layout`.
|
||||
|
||||
Require a unique, stable `key` on each child. To run exit on initial mount too, that is not needed; to suppress enter animation on first render set `<AnimatePresence initial={false}>`.
|
||||
|
||||
## Toast stack (popLayout + layout)
|
||||
|
||||
```jsx
|
||||
function Toasts({ toasts }) {
|
||||
return (
|
||||
<div className="toast-region">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{toasts.map((t) => (
|
||||
<motion.div
|
||||
key={t.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 24, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.15 } }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 40 }}
|
||||
>
|
||||
{t.message}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`layout` makes remaining toasts slide up smoothly when one is dismissed.
|
||||
|
||||
## Magic-move / shared-element layout
|
||||
|
||||
```jsx
|
||||
function Gallery() {
|
||||
const [active, setActive] = useState(null);
|
||||
return (
|
||||
<>
|
||||
<div className="grid">
|
||||
{photos.map((p) => (
|
||||
<motion.img key={p.id} layoutId={`photo-${p.id}`} src={p.thumb}
|
||||
onClick={() => setActive(p)} />
|
||||
))}
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{active && (
|
||||
<motion.div className="backdrop" onClick={() => setActive(null)}
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
|
||||
<motion.img layoutId={`photo-${active.id}`} src={active.full} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Matching `layoutId` makes the thumbnail visually fly to the lightbox and back.
|
||||
|
||||
## Drag-to-reorder list
|
||||
|
||||
`Reorder` handles pointer drag, axis locking, and live reordering with layout animation built in.
|
||||
|
||||
```jsx
|
||||
function SortableList({ items, setItems }) {
|
||||
return (
|
||||
<Reorder.Group axis="y" values={items} onReorder={setItems}>
|
||||
{items.map((item) => (
|
||||
<Reorder.Item key={item} value={item}
|
||||
whileDrag={{ scale: 1.03, boxShadow: "0 8px 24px rgba(0,0,0,.18)" }}>
|
||||
{item}
|
||||
</Reorder.Item>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
For a dedicated drag handle, use `useDragControls`:
|
||||
|
||||
```jsx
|
||||
const controls = useDragControls();
|
||||
<Reorder.Item value={item} dragListener={false} dragControls={controls}>
|
||||
<span className="handle" onPointerDown={(e) => controls.start(e)}>⠿</span>
|
||||
{item.label}
|
||||
</Reorder.Item>
|
||||
```
|
||||
|
||||
## Drag with constraints and elastic
|
||||
|
||||
```jsx
|
||||
const ref = useRef(null);
|
||||
<div ref={ref} className="bounds">
|
||||
<motion.div drag dragConstraints={ref} dragElastic={0.2}
|
||||
dragTransition={{ bounceStiffness: 600, bounceDamping: 20 }} />
|
||||
</div>
|
||||
```
|
||||
|
||||
`dragElastic` (0–1) controls how far past constraints it can be pulled; `dragSnapToOrigin` returns it on release.
|
||||
|
||||
## Gesture composition (like button)
|
||||
|
||||
```jsx
|
||||
function Like() {
|
||||
const [liked, setLiked] = useState(false);
|
||||
return (
|
||||
<motion.button onClick={() => setLiked((v) => !v)} whileTap={{ scale: 0.85 }}>
|
||||
<motion.span
|
||||
animate={liked ? { scale: [1, 1.4, 1], color: "#ef4444" } : { scale: 1, color: "#9ca3af" }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
>♥</motion.span>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Arrays of values (`scale: [1, 1.4, 1]`) are keyframes; control their spacing with `times: [0, 0.6, 1]`.
|
||||
|
||||
## Respecting reduced motion
|
||||
|
||||
```jsx
|
||||
function Panel() {
|
||||
const reduce = useReducedMotion();
|
||||
return (
|
||||
<motion.div
|
||||
initial={reduce ? false : { opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Wrap an app in `<MotionConfig reducedMotion="user">` to globally strip transform/layout animations for users who prefer reduced motion while keeping opacity.
|
||||
|
||||
## Performance notes
|
||||
|
||||
- Prefer `transform`/`opacity`; Framer Motion already uses transforms for `x/y/scale/rotate`.
|
||||
- For frequently-updated values (cursor follow, scroll), use `useMotionValue` + `useSpring` instead of React state to avoid re-renders.
|
||||
- `layout` is powerful but measure-heavy; avoid putting it on hundreds of simultaneously-animating nodes.
|
||||
|
||||
---
|
||||
Tune the timing and easing and a micro-interaction feels effortless. Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=web-animation-skills&utm_content=skill_footer&utm_term=micro-interaction)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
Whitespace-only changes.
@@ -1,218 +0,0 @@
|
||||
---
|
||||
name: page-transition-animation
|
||||
description: This skill should be used when the user asks to "animate page or route transitions", "add a page transition or crossfade between views", "animate route changes in Next.js App Router", "use the View Transitions API", "AnimatePresence exit doesn't fire on navigation", or "Framer Motion exit animation not working / exit animation before unmount in Next.js". Covers enter/exit page-transition animation with the View Transitions API and Framer Motion, for Next.js App Router and general SPA routing.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Page Transition Animation (Next.js App Router)
|
||||
|
||||
Implement page enter/exit transitions in the Next.js **App Router**, and fix the single most common failure: **Framer Motion `AnimatePresence` exit animations never fire on navigation**. The reason is structural — in the App Router, when navigating, Next.js unmounts the old route's content and mounts the new one almost immediately. `AnimatePresence` can only animate an exit if the *exiting element stays mounted* under a *persistent* `AnimatePresence` wrapper long enough to run the animation. Because the router swaps children out from under it, the exit is skipped. Two approaches solve this: a pathname-keyed `AnimatePresence` with the **FrozenRouter** pattern, or the **View Transitions API** (native / `next-view-transitions`).
|
||||
|
||||
## When to use
|
||||
|
||||
Use when adding animated page transitions in a Next.js App Router app, when a Framer Motion `exit` prop does nothing on route change, when an old page disappears instantly instead of animating out, when choosing between Framer Motion and the View Transitions API for Next.js, or when debugging why `AnimatePresence` won't animate between routes.
|
||||
|
||||
## Why exit doesn't fire (the core problem)
|
||||
|
||||
`AnimatePresence` detects removal of a *direct keyed child*. On App Router navigation:
|
||||
|
||||
1. The wrapper must persist across the navigation. If `AnimatePresence` lives in a component that itself unmounts, there is nothing left to run the exit.
|
||||
2. The child's `key` must change per route so AnimatePresence sees "old removed, new added".
|
||||
3. During the brief overlap, the *outgoing* subtree must still render its old content — but the App Router has already swapped the route context, so the outgoing tree would otherwise render the *new* page's data. This is what **FrozenRouter** fixes.
|
||||
|
||||
## Solution A: template.tsx + keyed AnimatePresence + FrozenRouter
|
||||
|
||||
`template.tsx` is the App Router's purpose-built hook for this: unlike `layout.tsx` (which persists), a **template remounts on every navigation**, giving each route a fresh instance — ideal for per-route enter animations. Combine it with a pathname-keyed `AnimatePresence` and FrozenRouter for clean exit + enter.
|
||||
|
||||
```tsx
|
||||
// app/template.tsx
|
||||
'use client';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { FrozenRouter } from './frozen-router';
|
||||
import { PageTransition } from './page-transition';
|
||||
|
||||
export default function Template({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{/* key on pathname so AnimatePresence sees old removed / new added */}
|
||||
<PageTransition key={pathname}>
|
||||
{/* FrozenRouter keeps the OUTGOING tree rendering its old content
|
||||
while it animates out */}
|
||||
<FrozenRouter>{children}</FrozenRouter>
|
||||
</PageTransition>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/page-transition.tsx
|
||||
'use client';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export function PageTransition({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/frozen-router.tsx
|
||||
'use client';
|
||||
import { useContext, useRef } from 'react';
|
||||
import {
|
||||
LayoutRouterContext,
|
||||
} from 'next/dist/shared/lib/app-router-context.shared-runtime';
|
||||
|
||||
// Freezes the router context so the exiting page keeps rendering its
|
||||
// OWN content during the exit animation instead of the next route's.
|
||||
export function FrozenRouter({ children }: { children: React.ReactNode }) {
|
||||
const context = useContext(LayoutRouterContext ?? {});
|
||||
const frozen = useRef(context).current;
|
||||
|
||||
return (
|
||||
<LayoutRouterContext.Provider value={frozen}>
|
||||
{children}
|
||||
</LayoutRouterContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
How it fits together:
|
||||
|
||||
- `mode="wait"` makes AnimatePresence fully finish the exit before mounting the new page. (Use the default mode if enter/exit should overlap/crossfade.)
|
||||
- `initial={false}` skips the enter animation on first load (optional).
|
||||
- `key={pathname}` is what makes AnimatePresence treat each route as a distinct presence.
|
||||
- `FrozenRouter` snapshots `LayoutRouterContext` so the outgoing subtree renders the *previous* route's content throughout the exit, instead of flashing the new route's content. Without it, exit either skips or shows the wrong content.
|
||||
|
||||
Note: `LayoutRouterContext` is a Next.js internal; its import path can change between Next versions. If the import breaks after an upgrade, that path is the thing to update.
|
||||
|
||||
## Solution B: native View Transitions API
|
||||
|
||||
The View Transitions API animates between two DOM states with the browser capturing before/after snapshots — no per-element exit components.
|
||||
|
||||
```css
|
||||
/* globals.css */
|
||||
@view-transition { navigation: auto; } /* opt MPA-style in (where supported) */
|
||||
|
||||
::view-transition-old(root) { animation: fade 0.25s both reverse; }
|
||||
::view-transition-new(root) { animation: fade 0.25s both; }
|
||||
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
|
||||
```
|
||||
|
||||
For SPA-style App Router navigations, wrap the router update:
|
||||
|
||||
```ts
|
||||
if (document.startViewTransition) {
|
||||
document.startViewTransition(() => router.push(href));
|
||||
} else {
|
||||
router.push(href);
|
||||
}
|
||||
```
|
||||
|
||||
### next-view-transitions library
|
||||
|
||||
`next-view-transitions` integrates the View Transitions API with the App Router's client navigation so it works out of the box:
|
||||
|
||||
```tsx
|
||||
// app/layout.tsx
|
||||
import { ViewTransitions } from 'next-view-transitions';
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ViewTransitions>
|
||||
<html lang="en"><body>{children}</body></html>
|
||||
</ViewTransitions>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// use the library's Link instead of next/link
|
||||
import { Link } from 'next-view-transitions';
|
||||
<Link href="/about">About</Link>;
|
||||
```
|
||||
|
||||
Choose View Transitions for simple cross-page fades/morphs and shared-element transitions with minimal JS; choose Framer Motion when fine-grained, orchestrated, interruptible, or staggered exit animations are needed. View Transitions API browser support is still uneven, so provide a graceful fallback (the `if (document.startViewTransition)` guard).
|
||||
|
||||
## AnimatePresence debug checklist
|
||||
|
||||
When exit still won't fire, verify in order:
|
||||
|
||||
1. **Wrapper stays mounted.** `AnimatePresence` must live in something persistent — ideally `template.tsx`, or a layout-level client component — not inside the page that's unmounting.
|
||||
2. **Stable, changing key.** The animated child needs `key={pathname}` (or similar) so removal is detected. No key, or a key that doesn't change → no exit.
|
||||
3. **Direct motion descendant.** The element with the `exit` prop must be a `motion.*` component and a child of `AnimatePresence` (a non-motion wrapper in between can block detection).
|
||||
4. **`mode="wait"`** if old and new should not overlap; without it both render simultaneously and may jump.
|
||||
5. **FrozenRouter present** if the outgoing page flashes the new route's content during exit.
|
||||
6. **`'use client'`** on every file using AnimatePresence/usePathname/motion.
|
||||
7. **Single child at a time** under `mode="wait"` — AnimatePresence expects one keyed presence to swap.
|
||||
|
||||
## Deliver & verify (standalone HTML)
|
||||
|
||||
> **Packaged helper** (`scripts/`): `scripts/seek-shot.sh anim.html 0 1.5 3` freezes the `?t=N` harness and screenshots each moment; `scripts/contact-sheet.sh sheet.png frame-*.png` tiles them for one-glance review. See `scripts/README.md`.
|
||||
|
||||
The real target is a Next.js app, but to *prove the transition shape* (the enter/exit curve, direction, crossfade) you can ship **one HTML file that opens directly in a browser** — two stub "pages" you toggle, animated with `motion` from CDN or the native View Transitions API. One file is the right tier for validating motion before wiring the router; don't stand up Next just to eyeball a fade.
|
||||
|
||||
**Output contract:**
|
||||
- One `.html` file: two view stubs and a toggle, animated with either an inline `<script type="module">` importing `motion`/React from CDN, or `document.startViewTransition` + `::view-transition-*` CSS.
|
||||
- A way to freeze the **resolved end state** — a route transition is state-driven (which view, mid-swap), so screenshot the state, not a clock.
|
||||
|
||||
**Seek harness — pin a transition phase.** `?view=b` mounts the destination view (or `?phase=exit` to hold the outgoing tree mid-animation) so a screenshot lands on a settled phase:
|
||||
|
||||
```html
|
||||
<script>
|
||||
const p = new URLSearchParams(location.search);
|
||||
document.documentElement.dataset.view = p.get("view") || "a"; // CSS/React keys off this
|
||||
// Framer Motion: set the controlled key/route from ?view; for mode="wait" the resolved end is the new page
|
||||
// View Transitions: screenshot the END state (snapshots are transient); or pause via emulate slow animations
|
||||
window.__ready = true;
|
||||
</script>
|
||||
```
|
||||
|
||||
**Verify loop — render → set phase → screenshot → check:** open source, mid, and destination (`?view=a`, `?phase=exit`, `?view=b`), screenshot each, and check **fidelity** (exit actually runs, direction/crossfade matches the brief, no `mode="wait"` lag) plus **artifacts** (outgoing tree flashing the new page's content, double-mount, FOUC, jank). Any headless tool works:
|
||||
|
||||
```bash
|
||||
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/transition.html?view=b" dest.png
|
||||
```
|
||||
|
||||
**Before you finish:**
|
||||
1. Opens standalone — no console errors, CDN `motion`/React (if used) resolves, `startViewTransition` guarded.
|
||||
2. The `?view=`/`?phase=` freeze lands a deterministic transition phase.
|
||||
3. Screenshotted at source / mid / destination — matches the brief, no content flash or skipped exit.
|
||||
4. `prefers-reduced-motion` honored — slide/translate dropped to a short crossfade.
|
||||
5. Easing is intentional — entrances ease-out, exits ease-in, durations ~0.2–0.4s (not laggy under `mode="wait"`).
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Need | Approach |
|
||||
|---|---|
|
||||
| Per-route enter animation | `app/template.tsx` (remounts each nav) |
|
||||
| Exit animation on nav | keyed `AnimatePresence mode="wait"` + FrozenRouter |
|
||||
| Outgoing page shows old content | FrozenRouter (snapshot `LayoutRouterContext`) |
|
||||
| Detect route change | `usePathname()` as the `key` |
|
||||
| Simple cross-page fade/morph | View Transitions API / next-view-transitions |
|
||||
| Shared element transition | `view-transition-name` CSS |
|
||||
| Skip first-load animation | `initial={false}` |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `layout.tsx` persists and will NOT remount per route — use `template.tsx` for per-route enter animations.
|
||||
- Omitting FrozenRouter makes the exiting page render the *new* route's content mid-animation (visible flash) or skip exit entirely.
|
||||
- `LayoutRouterContext` import path is a Next internal; it may change across versions — first thing to fix after an upgrade.
|
||||
- Without `key={pathname}`, AnimatePresence sees the same child and never triggers exit.
|
||||
- `mode="wait"` waits for exit before enter; overusing it on slow exits makes navigation feel laggy — tune durations (~0.2-0.4s).
|
||||
- View Transitions API lacks full browser support; always guard `document.startViewTransition`.
|
||||
- Every transition file needs `'use client'`; server components can't use AnimatePresence/usePathname.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/full-examples.md` — directional/slide transitions, shared-element View Transitions with `view-transition-name`, a non-`mode="wait"` crossfade variant, loading-state transitions with `loading.tsx`, and a complete working App Router folder layout.
|
||||
@@ -1,176 +0,0 @@
|
||||
# Full examples and variants
|
||||
|
||||
## Working folder layout
|
||||
|
||||
```
|
||||
app/
|
||||
layout.tsx # persistent shell (html/body, providers)
|
||||
template.tsx # remounts per route -> hosts AnimatePresence
|
||||
page-transition.tsx # motion wrapper (enter/exit variants)
|
||||
frozen-router.tsx # FrozenRouter (LayoutRouterContext snapshot)
|
||||
page.tsx # home
|
||||
about/page.tsx
|
||||
globals.css
|
||||
```
|
||||
|
||||
## Directional slide transitions
|
||||
|
||||
Track direction by comparing route depth or a route order map, then feed it to variants.
|
||||
|
||||
```tsx
|
||||
// app/page-transition.tsx
|
||||
'use client';
|
||||
import { motion } from 'framer-motion';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useRef } from 'react';
|
||||
|
||||
const order = ['/', '/about', '/work', '/contact'];
|
||||
|
||||
export function PageTransition({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const prev = useRef(pathname);
|
||||
const dir =
|
||||
order.indexOf(pathname) > order.indexOf(prev.current) ? 1 : -1;
|
||||
prev.current = pathname;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
custom={dir}
|
||||
variants={{
|
||||
initial: (d: number) => ({ opacity: 0, x: 40 * d }),
|
||||
animate: { opacity: 1, x: 0 },
|
||||
exit: (d: number) => ({ opacity: 0, x: -40 * d }),
|
||||
}}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Crossfade variant (no mode="wait")
|
||||
|
||||
Omit `mode="wait"` so old and new overlap. Stack them absolutely to avoid layout jump.
|
||||
|
||||
```tsx
|
||||
// app/template.tsx
|
||||
'use client';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { FrozenRouter } from './frozen-router';
|
||||
|
||||
export default function Template({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.div
|
||||
key={pathname}
|
||||
style={{ gridArea: '1 / 1' }} // overlap in a 1-cell grid parent
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.35 }}
|
||||
>
|
||||
<FrozenRouter>{children}</FrozenRouter>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Wrap the template output's parent in `display: grid` so both stacked children occupy `grid-area: 1 / 1`.
|
||||
|
||||
## Shared-element transition (View Transitions API)
|
||||
|
||||
Give the same logical element the same `view-transition-name` on both pages; the browser morphs between them.
|
||||
|
||||
```tsx
|
||||
// list page
|
||||
<img src={cover} style={{ viewTransitionName: `cover-${id}` }} />
|
||||
// detail page
|
||||
<img src={cover} style={{ viewTransitionName: `cover-${id}` }} />
|
||||
```
|
||||
|
||||
```css
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) { animation-duration: 0.3s; }
|
||||
/* the named element animates automatically between its two positions */
|
||||
```
|
||||
|
||||
With `next-view-transitions`, use its `<Link>` and the morph happens on navigation. Each `view-transition-name` must be unique on the page at a time.
|
||||
|
||||
## loading.tsx + transitions
|
||||
|
||||
`loading.tsx` shows an instant Suspense fallback during server work. To animate it, make the fallback a motion component; it mounts/unmounts within the same template-driven AnimatePresence flow.
|
||||
|
||||
```tsx
|
||||
// app/work/loading.tsx
|
||||
'use client';
|
||||
import { motion } from 'framer-motion';
|
||||
export default function Loading() {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
||||
Loading…
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Reduced-motion
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { useReducedMotion, motion } from 'framer-motion';
|
||||
|
||||
export function PageTransition({ children }: { children: React.ReactNode }) {
|
||||
const reduce = useReducedMotion();
|
||||
return (
|
||||
<motion.div
|
||||
initial={reduce ? false : { opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={reduce ? { opacity: 1 } : { opacity: 0, y: -12 }}
|
||||
transition={{ duration: reduce ? 0 : 0.3 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
For the View Transitions API, gate with CSS:
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) { animation: none; }
|
||||
}
|
||||
```
|
||||
|
||||
## FrozenRouter import note across versions
|
||||
|
||||
The internal context import has shifted across Next versions, e.g.:
|
||||
|
||||
```ts
|
||||
// commonly:
|
||||
import { LayoutRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime';
|
||||
```
|
||||
|
||||
If an upgrade breaks the import, search `node_modules/next/dist/shared/lib/` for `app-router-context` and update the path. Because it is internal, pin awareness of this in any upgrade checklist.
|
||||
|
||||
## Framer Motion vs View Transitions decision
|
||||
|
||||
| Factor | Framer Motion | View Transitions API |
|
||||
|---|---|---|
|
||||
| Browser support | Broad (JS lib) | Uneven; needs fallback |
|
||||
| Orchestration/stagger | Excellent | Limited |
|
||||
| Interruptible exits | Yes | Limited |
|
||||
| Shared element morph | Manual (layoutId) | Native, simple |
|
||||
| Bundle cost | Adds library | Minimal/native |
|
||||
| Setup complexity | Medium (FrozenRouter) | Low (next-view-transitions) |
|
||||
|
||||
---
|
||||
Freeze the outgoing route and exit transitions finally fire as intended. Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=web-animation-skills&utm_content=skill_footer&utm_term=page-transition-animation)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
Whitespace-only changes.
@@ -1,167 +0,0 @@
|
||||
---
|
||||
name: svg-animation
|
||||
description: This skill should be used when the user asks to "animate an SVG", "make a line draw itself on", "do a stroke draw-on / signature animation", "morph one shape into another", "move an element along a path", "animate an icon/logo", or "animate an SVG gradient or filter". Covers stroke-dashoffset draw-on, path morphing, motion-along-path, and animated icons/gradients/filters via CSS, SMIL, and GSAP.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# SVG Animation
|
||||
|
||||
Crisp, lightweight, infinitely scalable vector motion — ideal for icons, illustrations, logos, and data marks. SVG can be animated three ways: CSS (declarative, simple), SMIL (`<animate>` inside the SVG), and JS (GSAP/Web Animations, for control and morphing). Choose per task; the techniques below say which.
|
||||
|
||||
## When to use
|
||||
|
||||
- Stroke "draw-on" of icons, illustrations, signatures, maps
|
||||
- Shape/path morphing and animated icon state changes (menu ↔ close, play ↔ pause)
|
||||
- Moving an element along a path (motion path)
|
||||
- Animated gradients, filters (glow, displacement), and animated logos
|
||||
|
||||
## Core techniques
|
||||
|
||||
### Stroke draw-on (the staple)
|
||||
|
||||
Draw the dash array as long as the path, offset it fully (invisible), then animate the offset to 0.
|
||||
|
||||
```css
|
||||
.path {
|
||||
stroke-dasharray: var(--len);
|
||||
stroke-dashoffset: var(--len);
|
||||
animation: draw 1.4s ease forwards;
|
||||
}
|
||||
@keyframes draw { to { stroke-dashoffset: 0; } }
|
||||
```
|
||||
|
||||
Getting the length:
|
||||
- JS (most reliable): `const len = path.getTotalLength(); path.style.setProperty("--len", len);`
|
||||
- No-JS trick: set `pathLength="1"` on the `<path>`, then `stroke-dasharray: 1; stroke-dashoffset: 1;` and animate to `0`. This normalizes any path to a 0–1 length so no measurement is needed.
|
||||
|
||||
Reverse (erase) by animating offset from 0 back to `len`. Stagger multiple paths with `animation-delay`. Direction of drawing follows the path's point order; reverse it in the editor or negate the offset sign if it draws "backwards".
|
||||
|
||||
### Morphing one path into another
|
||||
|
||||
Paths interpolate point-by-point, so a naive morph requires both `d` attributes to have the **same number and type of commands**. Two robust approaches:
|
||||
|
||||
- **GSAP MorphSVG** (free as of GSAP 3.12) — handles mismatched point counts automatically and finds a good mapping:
|
||||
|
||||
```js
|
||||
gsap.registerPlugin(MorphSVGPlugin);
|
||||
gsap.to("#start", { morphSVG: "#end", duration: 0.8, ease: "power2.inOut" });
|
||||
// Convert any shape to a morph-able path:
|
||||
MorphSVGPlugin.convertToPath("circle, rect, ellipse, line, polygon");
|
||||
```
|
||||
|
||||
- **Flubber** (small standalone lib) — generates interpolators without GSAP, good with React/Framer Motion:
|
||||
|
||||
```js
|
||||
import { interpolate } from "flubber";
|
||||
const interpolator = interpolate(pathA, pathB, { maxSegmentLength: 2 });
|
||||
// interpolator(0) === pathA, interpolator(1) === pathB; feed t into a tween.
|
||||
```
|
||||
|
||||
For hand-authored morphs (icon toggles), keep both paths with identical command structure and animate `d` directly via Web Animations or CSS (`d` is animatable in modern browsers via `path("...")`).
|
||||
|
||||
### Motion along a path
|
||||
|
||||
- **GSAP MotionPath** (preferred — control, alignment, scrub):
|
||||
|
||||
```js
|
||||
gsap.registerPlugin(MotionPathPlugin);
|
||||
gsap.to("#rocket", {
|
||||
duration: 4, repeat: -1, ease: "none",
|
||||
motionPath: { path: "#track", align: "#track", autoRotate: true, alignOrigin: [0.5, 0.5] },
|
||||
});
|
||||
```
|
||||
|
||||
`autoRotate: true` orients the object to the path tangent; `align` makes coordinates relative to the path element.
|
||||
|
||||
- **SMIL** (no JS):
|
||||
|
||||
```svg
|
||||
<path id="track" d="M10,80 C40,10 120,10 150,80" fill="none"/>
|
||||
<circle r="6" fill="#3b82f6">
|
||||
<animateMotion dur="3s" repeatCount="indefinite" rotate="auto">
|
||||
<mpath href="#track"/>
|
||||
</animateMotion>
|
||||
</circle>
|
||||
```
|
||||
|
||||
- **CSS** offset-path (modern, declarative): `offset-path: path("M10,80 C..."); animation: move 3s linear infinite;` with `@keyframes move { to { offset-distance: 100%; } }` and `offset-rotate: auto`.
|
||||
|
||||
### Animated gradients and filters
|
||||
|
||||
Gradients: animate `gradientTransform` or stop offsets. A sheen sweep:
|
||||
|
||||
```svg
|
||||
<linearGradient id="sheen">
|
||||
<stop offset="0%" stop-color="#fff" stop-opacity="0"/>
|
||||
<stop offset="50%" stop-color="#fff" stop-opacity=".8"/>
|
||||
<stop offset="100%" stop-color="#fff" stop-opacity="0"/>
|
||||
<animateTransform attributeName="gradientTransform" type="translate"
|
||||
from="-1 0" to="1 0" dur="2s" repeatCount="indefinite"/>
|
||||
</linearGradient>
|
||||
```
|
||||
|
||||
Filters: animate `feDisplacementMap` `scale` for gooey/wobble, `feGaussianBlur` `stdDeviation` for focus pulls, or `feColorMatrix`/`feFlood` for glow. Filters are paint-heavy — animate sparingly and prefer `transform`/`opacity` where possible.
|
||||
|
||||
## Implementation choice (pick fast)
|
||||
|
||||
| Need | Use |
|
||||
|------|-----|
|
||||
| Single declarative draw/fade | CSS |
|
||||
| Self-contained, no JS bundle | SMIL (`<animate*>` in the SVG) |
|
||||
| Coordinated, scrubbable, scroll-tied | GSAP |
|
||||
| Mismatched-point morph | GSAP MorphSVG or Flubber |
|
||||
| Path following with rotation | GSAP MotionPath / CSS offset-path |
|
||||
|
||||
SMIL caveat: not supported in IE/old Edge and historically deprecation-flagged; for max reach or scroll-syncing, prefer CSS or JS. SMIL is still fine for self-contained icon assets in evergreen browsers.
|
||||
|
||||
## Authoring and optimization
|
||||
|
||||
- Build/clean with **SVGO**: keep `viewBox`, drop editor metadata, but disable `cleanupIds`/`removeViewBox` and any plugin that renames IDs you reference from CSS/JS/SMIL. Disable `mergePaths` and `convertShapeToPath` if you animate individual sub-paths or shapes.
|
||||
- Inline the SVG in the DOM (not `<img src>`) so CSS/JS can reach its internals; `<img>`-embedded SVG can only self-animate via internal SMIL/CSS.
|
||||
- Set explicit `viewBox` and avoid fixed `width`/`height` so the asset scales fluidly.
|
||||
- For draw-on, ensure paths are actual strokes (`fill:none; stroke:...`), not filled outlines — dashoffset only affects strokes.
|
||||
- Respect `prefers-reduced-motion`: gate looping/large motion; keep a static final state.
|
||||
|
||||
## Deliver & verify (standalone HTML)
|
||||
|
||||
> **Packaged helper** (`scripts/`): `scripts/seek-shot.sh anim.html 0 1.5 3` freezes the `?t=N` harness and screenshots each moment; `scripts/contact-sheet.sh sheet.png frame-*.png` tiles them for one-glance review. See `scripts/README.md`.
|
||||
|
||||
For a self-contained icon/logo/draw-on the deliverable is **one HTML file that opens directly in a browser** — inline the SVG in the markup, drive the animation with one mechanism, no build step. One file is the right tier for a vector asset; don't reach for a bundler.
|
||||
|
||||
**Output contract:**
|
||||
- One `.html` file: inline `<svg>`, plus CSS `@keyframes` / a `<script>` with GSAP from CDN / SMIL `<animate*>` — pick one driver.
|
||||
- Include the seek harness matching that driver so any moment can be frozen for a screenshot.
|
||||
|
||||
**Seek harness — freeze an exact moment.** `?t=N` seeks and pauses so a screenshot lands on a still frame. Use the mechanism that matches how the SVG animates:
|
||||
|
||||
```html
|
||||
<script>
|
||||
const t = new URLSearchParams(location.search).get("t");
|
||||
if (t !== null) {
|
||||
const N = parseFloat(t);
|
||||
// SMIL: pause the SVG's own clock and scrub it
|
||||
const svg = document.querySelector("svg");
|
||||
svg.pauseAnimations(); svg.setCurrentTime(N);
|
||||
// CSS @keyframes draw-on: el.style.animationDelay=(-N)+"s"; el.style.animationPlayState="paused";
|
||||
// GSAP timeline: tl.pause(); tl.seek(N);
|
||||
}
|
||||
window.__ready = true;
|
||||
</script>
|
||||
```
|
||||
|
||||
**Verify loop — render → freeze → screenshot → check:** open the file at start / mid / end (`?t=0`, `?t=<dur/2>`, `?t=<dur>`), screenshot each, and check **fidelity** (stroke draws in the right direction, morph endpoints clean) plus **artifacts** (path clipped by `viewBox`, stroke vanishing from a stale dashoffset, FOUC, jank at the morph seam). Any headless tool works:
|
||||
|
||||
```bash
|
||||
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/icon.html?t=0.7" frame-mid.png
|
||||
```
|
||||
|
||||
**Before you finish:**
|
||||
1. Opens standalone — no console errors, inline SVG reachable, CDN (if any) loads.
|
||||
2. The seek mechanism for your driver freezes a deterministic frame.
|
||||
3. Screenshotted at start / mid / end — matches the brief, no clipping or off-`viewBox` strokes.
|
||||
4. `prefers-reduced-motion` honored — looping/large motion gated, static final state kept.
|
||||
5. Easing is intentional — `ease`/GSAP ease chosen on purpose, no accidental `linear` draw-on.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/svg-techniques.md` — full dashoffset math and `getTotalLength` gotchas, the `pathLength="1"` normalization, GSAP MorphSVG vs Flubber decision guide with code, an icon-toggle morph (hamburger↔close), MotionPath/offset-path details, SMIL-vs-CSS-vs-JS tradeoffs, and an SVGO config tuned for animation.
|
||||
@@ -1,214 +0,0 @@
|
||||
# SVG Animation Techniques (detailed)
|
||||
|
||||
## 1. Stroke draw-on: the math
|
||||
|
||||
`stroke-dasharray` defines a dash pattern; `stroke-dashoffset` shifts where the pattern starts. Set the dash equal to the whole path length and offset it by that same length → the visible dash is pushed entirely off the path (invisible). Animating offset to 0 slides the dash into view, revealing the stroke progressively.
|
||||
|
||||
```
|
||||
visible fraction = 1 - (dashoffset / pathLength)
|
||||
```
|
||||
|
||||
So offset = length → 0% drawn; offset = 0 → 100% drawn; offset = length/2 → 50% drawn (useful for scroll mapping: `offset = length * (1 - progress)`).
|
||||
|
||||
### getTotalLength gotchas
|
||||
|
||||
```js
|
||||
const path = document.querySelector("#sig");
|
||||
const len = path.getTotalLength(); // returns user units of the path geometry
|
||||
path.style.strokeDasharray = len;
|
||||
path.style.strokeDashoffset = len;
|
||||
requestAnimationFrame(() => { // next frame so the transition has a start value
|
||||
path.style.transition = "stroke-dashoffset 1.6s ease";
|
||||
path.style.strokeDashoffset = "0";
|
||||
});
|
||||
```
|
||||
|
||||
- `getTotalLength()` measures the path's own coordinate system, ignoring CSS transforms/scaling — which is what you want for dasharray.
|
||||
- It only works on geometry elements (`path, line, polyline, polygon, circle, ellipse, rect`). For `<text>` draw-on you must convert text to paths first.
|
||||
- Rounding: browsers can clip the last sub-pixel; pad slightly (`len + 1`) if the end never fully closes.
|
||||
- Compound paths (multiple `M` subpaths in one `d`) draw all subpaths simultaneously. Split into separate `<path>`s with staggered `animation-delay` for sequential drawing.
|
||||
|
||||
### No-JS normalization with pathLength
|
||||
|
||||
`pathLength="1"` tells the browser to treat the path's length as exactly 1, regardless of real geometry. Then dash math is unit-free:
|
||||
|
||||
```svg
|
||||
<path d="..." pathLength="1" fill="none" stroke="#111"
|
||||
style="stroke-dasharray:1; stroke-dashoffset:1; animation:draw 1.4s ease forwards"/>
|
||||
```
|
||||
```css
|
||||
@keyframes draw { to { stroke-dashoffset: 0; } }
|
||||
```
|
||||
|
||||
This is the cleanest pure-CSS draw-on and avoids hardcoding measured lengths that break when the path is edited. Use `pathLength="100"` if you prefer to think in percentages.
|
||||
|
||||
### Scroll-driven draw-on (no library, via offset)
|
||||
|
||||
```js
|
||||
const len = path.getTotalLength();
|
||||
path.style.strokeDasharray = len;
|
||||
function onScroll() {
|
||||
const p = Math.min(1, Math.max(0, scrollProgress())); // 0..1
|
||||
path.style.strokeDashoffset = len * (1 - p);
|
||||
}
|
||||
document.addEventListener("scroll", onScroll, { passive: true });
|
||||
```
|
||||
|
||||
## 2. Morphing
|
||||
|
||||
### Why naive morphs break
|
||||
|
||||
Path interpolation walks the `d` command list of A and B in lockstep. If A has 4 cubic curves and B has 7, or A uses `L` where B uses `C`, the browser/animator either refuses or produces garbage. Fixes: match commands by hand, or use a library that resamples.
|
||||
|
||||
### GSAP MorphSVG (best for arbitrary shapes)
|
||||
|
||||
```js
|
||||
import gsap from "gsap";
|
||||
import { MorphSVGPlugin } from "gsap/MorphSVGPlugin";
|
||||
gsap.registerPlugin(MorphSVGPlugin);
|
||||
|
||||
// any shape -> path so it can morph
|
||||
MorphSVGPlugin.convertToPath("#circ, #star");
|
||||
|
||||
gsap.to("#circ", {
|
||||
duration: 0.9, ease: "power2.inOut",
|
||||
morphSVG: { shape: "#star", shapeIndex: "auto", type: "rotational" },
|
||||
});
|
||||
```
|
||||
|
||||
- `shapeIndex` rotates the starting point mapping to minimize twisting; `"auto"` lets GSAP pick, or pass a number to tune.
|
||||
- `type: "rotational"` interpolates angles/lengths instead of raw coordinates — usually smoother for organic morphs.
|
||||
- `map: "size" | "position" | "complexity"` changes how sub-paths in compound shapes are matched.
|
||||
|
||||
### Flubber (standalone, framework-friendly)
|
||||
|
||||
```js
|
||||
import { interpolate, separate, combine } from "flubber";
|
||||
|
||||
// 1 -> 1 shape
|
||||
const lerp = interpolate(pathA, pathB, { maxSegmentLength: 2 });
|
||||
|
||||
// splitting one shape into many (or vice versa)
|
||||
const split = separate(oneShape, [shape1, shape2, shape3], { single: true });
|
||||
```
|
||||
|
||||
Drive `lerp(t)` with any tween/`requestAnimationFrame`, or inside Framer Motion:
|
||||
|
||||
```jsx
|
||||
import { motion, useMotionValue, useTransform, animate } from "motion/react";
|
||||
const progress = useMotionValue(0);
|
||||
const d = useTransform(progress, [0, 1], [0, 1], { clamp: false });
|
||||
const path = useTransform(d, (v) => interpolate(A, B)(v));
|
||||
// animate(progress, 1, { duration: 0.6 }) then <motion.path d={path} />
|
||||
```
|
||||
|
||||
Use Flubber when you can't add GSAP or need React-native value plumbing; use MorphSVG for the highest-quality mapping and convenience.
|
||||
|
||||
### Hand-authored icon toggle (hamburger ↔ close) — no morph library
|
||||
|
||||
When you control both shapes, give them identical structure and animate transforms instead of `d`:
|
||||
|
||||
```svg
|
||||
<svg viewBox="0 0 24 24" class="menu" aria-label="Menu">
|
||||
<line class="top" x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line class="mid" x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line class="bot" x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
```
|
||||
```css
|
||||
.menu line { stroke:#111; stroke-width:2; transition: transform .25s ease, opacity .2s ease;
|
||||
transform-origin: center; transform-box: fill-box; }
|
||||
.menu.open .top { transform: translateY(6px) rotate(45deg); }
|
||||
.menu.open .mid { opacity: 0; }
|
||||
.menu.open .bot { transform: translateY(-6px) rotate(-45deg); }
|
||||
```
|
||||
|
||||
`transform-box: fill-box` makes `transform-origin: center` resolve to the element's own bounding box — essential for SVG rotation; without it the origin is the SVG viewport origin.
|
||||
|
||||
## 3. Motion along a path — details
|
||||
|
||||
### GSAP MotionPath
|
||||
|
||||
```js
|
||||
gsap.to("#dot", {
|
||||
duration: 5, repeat: -1, ease: "none",
|
||||
motionPath: {
|
||||
path: "#route",
|
||||
align: "#route", // coordinates relative to the path element
|
||||
alignOrigin: [0.5, 0.5], // pin the object's center to the path
|
||||
autoRotate: true, // or a number to add a constant offset angle
|
||||
start: 0, end: 1, // animate a sub-segment, e.g. 0.2..0.8
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Convert a series of points into a smooth path: `MotionPathPlugin.convertToPath(...)`, or pass `motionPath: [{x:0,y:0},{x:100,y:50},...]` and GSAP draws a curve through them.
|
||||
|
||||
### CSS offset-path (declarative, modern)
|
||||
|
||||
```css
|
||||
.dot {
|
||||
offset-path: path("M10,80 C40,10 120,10 150,80");
|
||||
offset-rotate: auto; /* face direction of travel */
|
||||
animation: travel 3s linear infinite;
|
||||
}
|
||||
@keyframes travel { to { offset-distance: 100%; } }
|
||||
```
|
||||
|
||||
Newer syntax also accepts `offset-path: url(#route)` referencing a `<path>` id and shapes like `ray()`. Good support in evergreen browsers; no JS needed.
|
||||
|
||||
### SMIL animateMotion
|
||||
|
||||
Self-contained inside the SVG; use `rotate="auto"` and `<mpath href="#id"/>`. Best for portable icon assets where you don't want external CSS/JS. Avoid when you need scroll-sync or broad legacy support.
|
||||
|
||||
## 4. SMIL vs CSS vs JS — tradeoffs
|
||||
|
||||
| Axis | SMIL | CSS | JS (GSAP/WAAPI) |
|
||||
|------|------|-----|-----------------|
|
||||
| Bundle cost | none (inline) | none | library (GSAP ~JS) |
|
||||
| Works in `<img>` SVG | yes | yes (internal) | no |
|
||||
| Reach (legacy IE/Edge) | no | partial | yes |
|
||||
| Scroll/scrub sync | hard | hard | easy |
|
||||
| Morph mismatched paths | no | no | yes (MorphSVG/Flubber) |
|
||||
| Timeline orchestration | limited | limited | full |
|
||||
| Animate `d`/gradients/filters | yes | partial | yes |
|
||||
|
||||
Default: CSS for simple declarative effects, GSAP for anything coordinated/scrubbable/morphing, SMIL only for portable self-animating assets in evergreen targets.
|
||||
|
||||
## 5. SVGO config tuned for animation
|
||||
|
||||
Aggressive SVGO defaults will strip the very things you animate. Use a config like:
|
||||
|
||||
```js
|
||||
// svgo.config.js
|
||||
module.exports = {
|
||||
multipass: true,
|
||||
plugins: [
|
||||
{ name: "preset-default", params: { overrides: {
|
||||
removeViewBox: false, // keep responsive scaling
|
||||
cleanupIds: false, // DO NOT rename ids referenced by CSS/JS/SMIL/gradients
|
||||
mergePaths: false, // keep sub-paths separate for staggered draw-on
|
||||
convertShapeToPath: false, // keep <circle>/<rect> if SMIL/CSS targets them
|
||||
removeHiddenElems: false, // keep elements you toggle visible via animation
|
||||
inlineStyles: false, // preserve class hooks
|
||||
} } },
|
||||
{ name: "removeDimensions" }, // drop width/height, keep viewBox -> fluid scaling
|
||||
{ name: "prefixIds" }, // namespace ids if inlining many SVGs to avoid id collisions
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
If multiple inlined SVGs share gradient/filter ids, id collisions make the LAST definition win for everyone — `prefixIds` (or unique authoring) prevents this classic bug.
|
||||
|
||||
## 6. Reduced motion
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.path { animation: none; stroke-dashoffset: 0; } /* show final drawn state instantly */
|
||||
}
|
||||
```
|
||||
|
||||
For looping decorative SVG (spinners, ambient gradients), pause or hide them under reduced-motion; for functional icon toggles, keep the state change but drop the easing duration.
|
||||
|
||||
---
|
||||
Pick the right method per shape and SVG draws, morphs, and travels smoothly. Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=web-animation-skills&utm_content=skill_footer&utm_term=svg-animation)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/60fps-animation
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/accessible-animation
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/ascii-animation
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/glassmorphism
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/gsap-web
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/lottie-animation
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/micro-interaction
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/page-transition-animation
|
||||
@@ -1,102 +0,0 @@
|
||||
---
|
||||
name: remotion-video
|
||||
description: Create videos from React code with Remotion. Use when the user asks to make, animate, preview, or render a video, motion graphic, intro, caption, or social clip using code.
|
||||
---
|
||||
|
||||
# Remotion Video
|
||||
|
||||
Remotion makes videos out of React components. Each frame is a web page. Render them in order and you get an MP4.
|
||||
|
||||
## Core ideas
|
||||
- **Composition**: one video. Has `width`, `height`, `fps`, `durationInFrames`.
|
||||
- **Frame**: `useCurrentFrame()` returns the current frame number. All animation is a function of the frame.
|
||||
- **`interpolate`**: maps a frame range to a value range (fade, slide).
|
||||
- **`spring`**: natural bouncy motion.
|
||||
- **`Sequence`**: shows children only during a frame range.
|
||||
- **`AbsoluteFill`**: full-screen layer; stack them for backgrounds.
|
||||
- **`Audio` / `OffthreadVideo` / `Img`**: media. Put files in `public/` and load with `staticFile("name.mp3")`.
|
||||
|
||||
## Workflow
|
||||
1. **Plan first.** Ask or decide: length (seconds), size (1920x1080, or 1080x1920 for vertical), fps (30), and the scenes. Keep scenes to a short list.
|
||||
2. **Set up** (skip if the project exists):
|
||||
```
|
||||
npx create-video@latest
|
||||
cd <project> && npm install
|
||||
```
|
||||
3. **Write** compositions in `src/`, register them in `src/Root.tsx`.
|
||||
4. **Preview**: `npm run dev` (opens Remotion Studio).
|
||||
5. **Render**: `npx remotion render <CompositionId> out/video.mp4`
|
||||
Still frame: `npx remotion still <CompositionId> out/frame.png --frame=30`
|
||||
6. **Verify**: render one still first, check it looks right, then render the full video.
|
||||
|
||||
## Starter example
|
||||
`src/Root.tsx`
|
||||
```tsx
|
||||
import { Composition } from "remotion";
|
||||
import { Hello } from "./Hello";
|
||||
|
||||
export const RemotionRoot = () => (
|
||||
<Composition
|
||||
id="Hello"
|
||||
component={Hello}
|
||||
durationInFrames={150}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
defaultProps={{ title: "Hello, world" }}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
`src/Hello.tsx`
|
||||
```tsx
|
||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
export const Hello = ({ title }: { title: string }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const opacity = interpolate(frame, [0, 30], [0, 1], { extrapolateRight: "clamp" });
|
||||
const scale = spring({ frame, fps, config: { damping: 12 } });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "#0b1020", justifyContent: "center", alignItems: "center" }}>
|
||||
<h1 style={{ color: "white", fontSize: 120, opacity, transform: `scale(${scale})` }}>{title}</h1>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Rules of thumb
|
||||
- Never animate with CSS transitions, `setTimeout`, or CSS animations. They don't render per frame. Drive everything from `useCurrentFrame()`.
|
||||
- Always pass `extrapolateRight: "clamp"` to `interpolate` unless you want values to keep growing.
|
||||
- Seconds to frames: `seconds * fps`.
|
||||
- Use `<Sequence from={..} durationInFrames={..}>` to order scenes; `<Series>` to chain them.
|
||||
- Load fonts with `@remotion/google-fonts` so they are ready before rendering.
|
||||
- Keep props in `defaultProps` so the user can edit text in the Studio.
|
||||
- Rendering needs Chromium and ffmpeg (Remotion installs/handles these); first render can be slow.
|
||||
|
||||
## Fonts
|
||||
Google Fonts: `npm i @remotion/google-fonts`, then
|
||||
```tsx
|
||||
import { loadFont } from "@remotion/google-fonts/Inter";
|
||||
const { fontFamily } = loadFont(); // use as style={{ fontFamily }}
|
||||
```
|
||||
It downloads the font while rendering, so the machine needs internet access to fonts.googleapis.com. If the render fails with "Failed to load font", the network is blocking it: use a local font file in `public/` with `@remotion/fonts` (`loadFont({ family, url: staticFile("x.woff2") })`) or a system font. (Not verified in the sandbox where this skill was tested, which had no access to Google.)
|
||||
|
||||
## Common problems
|
||||
- **Can't download the browser (no internet / locked-down machine)**: point Remotion at an installed Chromium:
|
||||
`npx remotion render <Id> out/video.mp4 --browser-executable=/path/to/chromium --chrome-mode=chrome-for-testing`
|
||||
- **Blank/white frames**: an asset path is wrong; use `staticFile()`.
|
||||
- **Flicker**: an image or font not loaded; use `<Img>` and `@remotion/google-fonts`.
|
||||
- **Slow render**: lower resolution/fps while testing, or use `--concurrency`.
|
||||
|
||||
## Templates
|
||||
Copy-paste starting points. Read the one that fits:
|
||||
- `templates/captions.md` - words highlighted in time with speech
|
||||
- `templates/charts.md` - animated bar chart
|
||||
- `templates/intro-outro.md` - title card + chaining scenes with `Series`
|
||||
- `templates/audio-voiceover.md` - background music, voiceover, auto video length
|
||||
- `templates/vertical.md` - 1080x1920 clips for Reels/TikTok/Shorts
|
||||
|
||||
## Docs
|
||||
https://www.remotion.dev/docs
|
||||
@@ -1,62 +0,0 @@
|
||||
# Template: Audio, music and voiceover
|
||||
|
||||
Put sound files in `public/` (mp3 or wav). Load them with `staticFile()`.
|
||||
|
||||
`src/Narrated.tsx`
|
||||
```tsx
|
||||
import { AbsoluteFill, Audio, Sequence, interpolate, staticFile, useVideoConfig } from "remotion";
|
||||
import { getAudioDurationInSeconds } from "@remotion/media-utils";
|
||||
import type { CalculateMetadataFunction } from "remotion";
|
||||
|
||||
type Props = { title: string; voice: string; music: string };
|
||||
|
||||
// Make the video exactly as long as the voiceover (+1 second of padding).
|
||||
export const calcNarratedMetadata: CalculateMetadataFunction<Props> = async ({ props }) => {
|
||||
const seconds = await getAudioDurationInSeconds(staticFile(props.voice));
|
||||
return { durationInFrames: Math.ceil((seconds + 1) * 30) };
|
||||
};
|
||||
|
||||
export const Narrated = ({ title, voice, music }: Props) => {
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
return (
|
||||
<AbsoluteFill style={{ background: "#0b1020", justifyContent: "center", alignItems: "center" }}>
|
||||
<h1 style={{ color: "white", fontSize: 110 }}>{title}</h1>
|
||||
|
||||
{/* Background music: quiet, fades out over the last second. */}
|
||||
<Audio
|
||||
src={staticFile(music)}
|
||||
volume={(f) => interpolate(f, [durationInFrames - fps, durationInFrames], [0.15, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" })}
|
||||
/>
|
||||
|
||||
{/* Voiceover starts half a second in, full volume. */}
|
||||
<Sequence from={Math.round(fps * 0.5)}>
|
||||
<Audio src={staticFile(voice)} volume={1} />
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Register it (note `calculateMetadata`):
|
||||
```tsx
|
||||
<Composition
|
||||
id="Narrated"
|
||||
component={Narrated}
|
||||
calculateMetadata={calcNarratedMetadata}
|
||||
durationInFrames={300} // placeholder, replaced by calculateMetadata
|
||||
fps={30} width={1920} height={1080}
|
||||
defaultProps={{ title: "My story", voice: "voice.mp3", music: "music.mp3" }}
|
||||
/>
|
||||
```
|
||||
|
||||
Install: `npm i @remotion/media-utils`
|
||||
|
||||
Getting a voiceover
|
||||
- Record yourself, or generate speech with a text-to-speech service, and save as `public/voice.mp3`. Never commit API keys; keep them in environment variables.
|
||||
- Pair with `templates/captions.md`: feed it word timings from a transcription of the same file.
|
||||
|
||||
Rules
|
||||
- Music volume 0.1-0.2 so the voice stays clear.
|
||||
- `<Sequence from={...}>` delays when a sound starts.
|
||||
- Audio is only in the final render (mp4). Stills (`remotion still`) have no sound.
|
||||
- Check the sound made it in: `ffprobe out/video.mp4` should list an `Audio:` stream.
|
||||
@@ -1,38 +0,0 @@
|
||||
# Template: Captions
|
||||
|
||||
Words appear in time with speech. Give each word a start time in seconds.
|
||||
|
||||
`src/Captions.tsx`
|
||||
```tsx
|
||||
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
type Word = { text: string; start: number; end: number }; // seconds
|
||||
|
||||
export const Captions = ({ words, background = "#111" }: { words: Word[]; background?: string }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const t = frame / fps;
|
||||
|
||||
// Show a window of up to 5 words around the current one.
|
||||
const current = words.findIndex((w) => t >= w.start && t < w.end);
|
||||
const i = current === -1 ? 0 : current;
|
||||
const visible = words.slice(Math.floor(i / 5) * 5, Math.floor(i / 5) * 5 + 5);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: background, justifyContent: "flex-end", alignItems: "center", paddingBottom: 200 }}>
|
||||
<div style={{ fontSize: 72, fontWeight: 800, color: "white", textShadow: "0 4px 12px black", textAlign: "center", maxWidth: "80%" }}>
|
||||
{visible.map((w, k) => (
|
||||
<span key={k} style={{ color: t >= w.start && t < w.end ? "#ffd400" : "white", marginRight: 16 }}>
|
||||
{w.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Tips
|
||||
- Get word times from a transcription tool (e.g. `@remotion/captions` or Whisper) and save them as JSON in `public/`.
|
||||
- `background` is the backdrop when there is no video. Pass `"transparent"` when layering over a video.
|
||||
- Layer it over the video: `<AbsoluteFill><OffthreadVideo src={staticFile("clip.mp4")} /><Captions words={words} background="transparent" /></AbsoluteFill>`.
|
||||
@@ -1,37 +0,0 @@
|
||||
# Template: Animated bar chart
|
||||
|
||||
Bars grow one after another.
|
||||
|
||||
`src/BarChart.tsx`
|
||||
```tsx
|
||||
import { AbsoluteFill, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
type Bar = { label: string; value: number };
|
||||
|
||||
export const BarChart = ({ data, color = "#4f8cff" }: { data: Bar[]; color?: string }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const max = Math.max(...data.map((d) => d.value));
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "#0b1020", padding: 120, justifyContent: "flex-end" }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 40, height: 700 }}>
|
||||
{data.map((d, i) => {
|
||||
const grow = spring({ frame: frame - i * 6, fps, config: { damping: 15 } });
|
||||
return (
|
||||
<div key={d.label} style={{ flex: 1, textAlign: "center", color: "white", fontSize: 36 }}>
|
||||
<div style={{ height: (d.value / max) * 600 * grow, background: color, borderRadius: 8 }} />
|
||||
<div style={{ marginTop: 16 }}>{d.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Tips
|
||||
- Delay each bar with `frame - i * 6` (6 frames apart).
|
||||
- For lines, animate `strokeDashoffset` on an SVG path using `interpolate`.
|
||||
- Keep numbers readable: show the value as text, rounded.
|
||||
@@ -1,34 +0,0 @@
|
||||
# Template: Title intro / outro card
|
||||
|
||||
A short card (2-3 seconds) with a title and subtitle. Use at the start or end.
|
||||
|
||||
`src/TitleCard.tsx`
|
||||
```tsx
|
||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
export const TitleCard = ({ title, subtitle }: { title: string; subtitle?: string }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
const rise = spring({ frame, fps, config: { damping: 14 } });
|
||||
const fadeOut = interpolate(frame, [durationInFrames - 15, durationInFrames], [1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ background: "linear-gradient(135deg,#1b2a6b,#0b1020)", justifyContent: "center", alignItems: "center", opacity: fadeOut }}>
|
||||
<h1 style={{ color: "white", fontSize: 130, margin: 0, transform: `translateY(${(1 - rise) * 60}px)`, opacity: rise }}>{title}</h1>
|
||||
{subtitle && <p style={{ color: "#9fb3ff", fontSize: 48, opacity: rise }}>{subtitle}</p>}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Chain with the main video using `<Series>`:
|
||||
```tsx
|
||||
<Series>
|
||||
<Series.Sequence durationInFrames={90}><TitleCard title="My Video" /></Series.Sequence>
|
||||
<Series.Sequence durationInFrames={600}><MainScene /></Series.Sequence>
|
||||
<Series.Sequence durationInFrames={90}><TitleCard title="Thanks for watching" /></Series.Sequence>
|
||||
</Series>
|
||||
```
|
||||
@@ -1,23 +0,0 @@
|
||||
# Template: Vertical clips (Reels / TikTok / Shorts)
|
||||
|
||||
Same code, different size. Register a vertical composition:
|
||||
|
||||
```tsx
|
||||
<Composition
|
||||
id="Vertical"
|
||||
component={MyScene}
|
||||
width={1080}
|
||||
height={1920}
|
||||
fps={30}
|
||||
durationInFrames={30 * 30} // 30 seconds
|
||||
/>
|
||||
```
|
||||
|
||||
Render: `npx remotion render Vertical out/vertical.mp4`
|
||||
|
||||
Rules
|
||||
- Keep text inside the **safe zone**: about 150px from the top, 350px from the bottom (apps cover those parts with buttons and captions), and 60px from the sides.
|
||||
- Use big text (60px+). Phone screens are small.
|
||||
- Keep clips under 60 seconds (Shorts limit); 15-30 seconds does best.
|
||||
- Use `useVideoConfig()` for width/height instead of hard-coding, so the same scene works wide and tall.
|
||||
- Put captions in the lower-middle, above the bottom safe zone (see captions.md).
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/svg-animation
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
out/
|
||||
@@ -0,0 +1,461 @@
|
||||
# Animating Clawd
|
||||
|
||||
Read this whole file before you draw anything. It covers how to make a short, hand-painted cartoon, starring Clawd or any character you design: the rules and animation principles that make it look good, the workflow that catches mistakes, and the full reference for the character and the engine.
|
||||
|
||||
The person prompting you decides **what** the video is about. This guide decides **how** it's made. If they ask for something the rules below forbid (a caption, a 3D spin), do what they ask.
|
||||
|
||||
**No design here is final.** Clawd, the emotions, the props and the helpers are a starting point, not a limit. Change any of them, Clawd's own design included, and add whatever new characters, props or emotions the idea needs. Paint new things with the same tools and rules, so they belong with the rest.
|
||||
|
||||
Look at the model sheets first:
|
||||
- [docs/emotions.jpg](docs/emotions.jpg): all 31 emotions.
|
||||
- [docs/views.jpg](docs/views.jpg): the five key views, the motion helpers and the hats.
|
||||
|
||||
---
|
||||
|
||||
## The three goals
|
||||
|
||||
Every rule below serves one of three goals.
|
||||
|
||||
- **Handmade.** The video should look like someone painted it by hand, frame by frame. That means brush strokes, ink lines that boil, flat 2D and no lettering.
|
||||
- **Alive.** Something is always moving and something is always happening. Faces act instead of snapping, and characters are big enough to feel.
|
||||
- **One piece.** It's one short film, not a pile of clips. Plan it before you draw it, and link every scene to the next.
|
||||
|
||||
Underneath all three, the viewer has to be able to follow it. Timing (rule 4) is the rule models get wrong most often.
|
||||
|
||||
## The rules
|
||||
|
||||
### 1. The medium is solid: brush strokes, flat 2D, boil
|
||||
|
||||
- **Paint everything with p5.brush through `paint()` and `inkLine()`.** Characters get flat `wash` colour plus an ink outline. Backgrounds get soft watercolour `fill` shapes, usually with no outline or a thin one. Never use plain p5 shapes (`rect`, `ellipse`, `fill()`): they look like 2000s Flash.
|
||||
- **The linework boils.** `jit()` and `random()` are reseeded 12 times a second (`BOIL`), so every drawing wobbles slightly, like hand-drawn animation. That's the look; don't fight it. For anything that must stay put from frame to frame (star positions, tuft heights), use `hash(i)`. Give each separate element its own seed with `boilSeed(key)` (see Engine), or one moving thing makes everything drawn after it jitter.
|
||||
- **Everything is flat 2D. Never project 3D.** Don't rotate a box in perspective, don't use `rotateY` or WEBGL 3D and don't fake depth with math. Clawd turns through **drawn key views** (front → 3/4 → side → back 3/4 → back), exactly like a cartoon model sheet: see `turn()` and `spinView()`. Depth comes from overlap, scale and colour (farther = smaller, bluer, paler), never from a projection.
|
||||
- **Light is the one exception.** p5.brush mixes colour like pigment, so a yellow glow painted over blue turns green, and a thin wash over it turns grey. Use `glow()` for anything that shines: it adds real light, under the paper grain.
|
||||
- **Soft palette, no pure black or white.** Use `PAL.ink` for black and `PAL.cream` or `PAL.paper` for white. Keep colours soft and harmonious, and keep Clawd clearly readable against the background.
|
||||
|
||||
### 2. No text
|
||||
|
||||
- **Show it, don't write it.** Models overuse text. No captions, no titles, no labels on objects, no signs, no speech bubbles with words, no words on screens, no "ZZZ" typed in a font.
|
||||
- **Clawd's reactions are painted marks, never letters**: `!`, `?`, zzz, sweat, hearts, a bulb, a rain cloud. Use the emotes (see the reference).
|
||||
- **A sign that repeats the story is the classic failure.** If Clawd holds a sign saying "I'm lost", the shot has failed. Show Clawd being lost: looking left, then right, the map upside down, a sweat drop.
|
||||
- If the prompt truly needs a word (a name, a shop sign that is the joke), use `letter()`. Paint it into the scene, keep it to one or two words and use it once.
|
||||
|
||||
### 3. Something happens in every scene
|
||||
|
||||
- **Every shot needs an event:** something changes between its first frame and its last. Clawd wants something, finds something, tries, fails, reacts or gets it. "Clawd stands in a meadow being cute" is not a shot.
|
||||
- **One focal action at a time.** Stage it with a clear silhouette and nothing competing for attention, so it reads at a glance.
|
||||
- **Cause, then reaction.** When something happens, Clawd reacts to it: a take, an emotion change, a turn toward it. The reaction is often the funniest part, so give it time.
|
||||
- **Pay it off.** Whatever you set up in a shot (a door, a sandwich, a strange noise) gets resolved on screen, in that shot or a later one.
|
||||
|
||||
### 4. Timing: model the viewer
|
||||
|
||||
Timing turns a set of drawings into a story. It's also where generated animation fails most often: everything moves at one brisk speed, events pile on top of each other, and moments are over before anyone understands them.
|
||||
|
||||
You know what happens because you wrote the code. The viewer doesn't: they see it once, at full speed, for the first time. **For every moment, ask what the viewer needs to understand and how long that will take them, and time it for that.**
|
||||
|
||||
- **Write the reads.** For each shot, list in order what the viewer has to understand. Each item is a *read*. Every read needs time for the eye to find it, time to understand it, and a moment to register before the next thing starts. Small, distant, fast or subtle things take longer to find and understand than big, central, obvious ones.
|
||||
- **One read at a time.** Don't start a new read while the viewer is still taking in the last one. When two things happen at once, the viewer sees only one of them. Put a cause and its reaction in sequence, not on top of each other.
|
||||
- **Fast actions, slow meanings.** A motion can be very quick if it's anticipated, but what it means needs held time. Anticipation tells the viewer where to look before the action, and the hold after it lets them understand it. Move quickly through what doesn't matter to the story, and spend time on what does. That contrast between quick and held is what gives a film rhythm; one constant speed, fast or slow, makes it flat and hard to follow.
|
||||
- **Lead the eye.** The viewer looks at whatever moves, is bright, is big or is being looked at. Before an important read, get their eye to the right place (a character looks at it, the camera moves to it, it moves or lights up first), and give the eye time to get there.
|
||||
- **Let the reads set the length.** A shot is as long as its reads need. A shot with many reads can't be short, and a shot whose reads have all landed shouldn't be padded. That includes the last shot: its final read needs time to land before the video ends.
|
||||
|
||||
For a worked example, see how the demo times its ending, at the end of this guide.
|
||||
|
||||
### 5. Alive
|
||||
|
||||
- **Nothing is ever still.** Every emotion has its own idle motion (`feel()`), cameras drift or push, grass sways, stars twinkle and the linework boils. A frozen frame reads as a bug.
|
||||
- **Faces act, they never snap.** Change moods with `emotions()`. It does anticipation, a squint, a take and overshoot around every change. Never swap `eyes`/`mouth` by hand between two frames.
|
||||
- **Move like a cartoon, not a machine.** Every move follows the animation principles in the next section.
|
||||
- **Clawd is big.** In a medium shot, `u` is about 20–28 (Clawd is 10u wide, 8u tall). In a close-up it's 40–70. Tiny Clawds (u < 12) are for wide establishing shots only, and never for the whole video.
|
||||
- **Everything moves on a beat.** `PROJECT.bpm` drives every idle, bounce and dance, so the whole film shares one pulse. Put the hits on beats (`pulse()`, `beatN()`), even with no music.
|
||||
|
||||
### 6. Transitions always
|
||||
|
||||
- **Every seam gets a transition:** into the first shot, between every pair of shots and out of the last one. Never start on a hard frame, and never just stop.
|
||||
- **Pick a transition that belongs to the story**, and don't default to the same one every time. Some options:
|
||||
- a brush wipe (`brushWipe`)
|
||||
- an iris or shaped iris (`iris`, `irisShape`)
|
||||
- a whip pan with a smear
|
||||
- a match cut (the same shape or motion across the cut)
|
||||
- a cut on action (cut mid-move, and finish the move in the next shot)
|
||||
- a camera move that carries through into the next shot
|
||||
- a fade or push from paper or black
|
||||
- A plain cut is fine only when it's on action or a deliberate smash cut.
|
||||
- **Changes inside a shot are transitions too:** emotions go through `emotions()` and turns go through `turn()`. Props arrive and leave on arcs, never popping in.
|
||||
|
||||
### 7. One piece: a vision before any code
|
||||
|
||||
- **Storyboard first**, in writing, before you write any scene code (the workflow below has the format). If you're working with a person, show them the storyboard and let them react before you build.
|
||||
- **One world.** Pick a palette and a setting that carries through, with a colour arc across the video (e.g. cold night → warm dawn as Clawd's mood lifts).
|
||||
- **One thread.** The story has a beginning, a middle and an end, and Clawd's emotional arc follows it. Plan the emotion keys across the whole video, not per shot.
|
||||
- **Rhyme the ending with the opening:** the same place, pose or motif, changed. It makes the film feel whole.
|
||||
- **Link scenes:** motion continues across cuts, and screen direction stays consistent (if Clawd travels right, keep travelling right). Props and characters carry over.
|
||||
|
||||
---
|
||||
|
||||
## Animation principles
|
||||
|
||||
These are the classic principles of character animation, as they apply here. Most of them fix one problem: motion written as code comes out mechanical, because code moves every part at once, on the same curve, by the same amount.
|
||||
|
||||
- **Anticipation.** Before a big move, make a small move the opposite way: a crouch before a jump, a wind-up before a throw, a squint before a take. It tells the viewer something is coming and where to look. `jump()` and `emotions()` build it in.
|
||||
- **Squash and stretch.** Bodies squash on impact and stretch when they move fast, keeping their volume (`sq`).
|
||||
- **Slow in, slow out.** Almost nothing moves at a constant speed. Things ease out of one pose and into the next. A plain `lerp` over time looks mechanical, so run its progress through an easing (`ease`, `easeIn`, `easeOut`, `backOut`).
|
||||
- **Weight.** How something starts and stops says what it weighs. Heavy things take longer to get going and to stop, and land with little bounce. Light things snap into motion, bounce and flutter to rest.
|
||||
- **Arcs.** Living things move on arcs, not straight lines: thrown props, hops, arm swings, head turns (`arcPt`).
|
||||
- **Overlapping action and follow-through.** Don't move every part at once. The eyes lead, the body follows, and arms, hats, props and tails drag behind, overshoot and settle last. Offset each part's timing a little from the one it hangs off (`spring`, `ring` and `backOut` for the settle).
|
||||
- **Avoid twinning.** Code copies values, so both arms end up at the same angle, both eyes blink together and a crowd bounces in unison. Give one arm the action and the other something smaller, and offset timings, phases and `seed`s between characters.
|
||||
- **Exaggeration.** Push poses, takes, squash and leans further than feels natural. In a short cartoon, subtle reads as nothing. If it looks like too much on the sheet, pull it back.
|
||||
- **Strong key poses.** Each shot's storytelling poses should read as stills, with a clear silhouette and the body leaning into what it's doing, before any motion goes between them. If the key poses don't read, the motion won't fix it.
|
||||
- **Show the thought.** A character notices, thinks, then acts, and the eyes move first. The viewer understands a choice when they see it being made.
|
||||
- **Secondary action.** Small actions that support the main one (a hat bobbing, an emote popping, grass stirring) add life, but they never compete with it.
|
||||
|
||||
---
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Storyboard
|
||||
|
||||
Write `STORYBOARD.md` before any scene code:
|
||||
|
||||
```
|
||||
Logline: one sentence. Clawd wants ___, but ___, so ___.
|
||||
World: setting, a small palette, light, how the colour changes across the video.
|
||||
Motif: the thing that recurs and pays off.
|
||||
Clawd's arc: the emotion keys across the whole video.
|
||||
Shots:
|
||||
A start–end [transition in: ___] what's seen · the EVENT · Clawd's reaction · camera
|
||||
reads: start–end the first thing the viewer must understand
|
||||
start–end the next one (where is the viewer's eye when it starts?)
|
||||
...
|
||||
B start–end [transition: ___] ...
|
||||
...
|
||||
[transition out: ___]
|
||||
```
|
||||
|
||||
The reads are the timing sheet. Give each one a start and an end, make sure each has time to be found and understood, and make sure no two important reads overlap. If a shot's reads don't fit its length, lengthen the shot or cut a read; don't squeeze them.
|
||||
|
||||
Check the storyboard against the rules:
|
||||
- Is there an event in every shot?
|
||||
- Does every read have time to land before the next one starts?
|
||||
- Is there a transition at every seam?
|
||||
- Is there any text anywhere?
|
||||
- Does the ending rhyme with the opening?
|
||||
|
||||
### 2. Build
|
||||
|
||||
- Set `duration` (and `bpm`) in [src/config.js](src/config.js).
|
||||
- Put your scene in a new file (e.g. `src/scenes/my_video.js`), wrapped in an IIFE, and end it with `shots([...])`. In [studio.html](studio.html), **replace** the `demo.js` script tag with yours.
|
||||
- Build and check one shot at a time, in order.
|
||||
- Within a shot, block the key poses first and check them as stills (`--sheet` at the key times). Add the motion between them once they read.
|
||||
|
||||
```js
|
||||
// src/scenes/my_video.js
|
||||
(() => {
|
||||
function park(t, lt, dur) { // t = video time, lt = time in this shot, dur = shot length
|
||||
camBegin(960 + 20 * Math.sin(lt * .6), 540, 1 + .02 * lt); // slow drift and push: the camera is never dead
|
||||
paint(rectPts(-200, -200, W + 400, H + 400), { wash: PAL.sky, ink: null }); // background
|
||||
paint(ellPts(960, 1150, 1400, 380, 40, 2), { wash: PAL.sap, ink: PAL.ink, sw: 1 }); // ground
|
||||
const mood = emotions(lt, [[0, 'bored'], [1.2, 'surprised'], [1.7, 'excited']]); // acted changes
|
||||
const hop = jump(lt, 2.2, 2.7, 3); // add poses that share fields
|
||||
clawd(960, 860, 26, { ...mood, dy: mood.dy + hop.dy, sq: mood.sq + hop.sq });
|
||||
const at = toScreen(960, 860 - 4 * 26); // Clawd's screen position, for the iris
|
||||
camEnd();
|
||||
if (lt < .45) iris(...at, lerp(0, 1500, easeIn(lt / .45))); // transition in
|
||||
if (lt > dur - .3) brushWipe((lt - (dur - .3)) / .6); // transition out (next shot finishes it)
|
||||
}
|
||||
shots([[0, park] /*, [3.5, nextShot], ... */]);
|
||||
})();
|
||||
```
|
||||
|
||||
### 3. Look at it: the review loop
|
||||
|
||||
You can't see motion by reading code. Render and look at every shot, several times, at three zoom levels:
|
||||
|
||||
```bash
|
||||
# contact sheet: the shape of the whole piece (every shot's first, middle and last frames)
|
||||
node render.mjs --sheet=0.1,0.8,1.6,2.4,3.1,3.9 --cols=6 --w=320 --out=out/check/sheet.jpg
|
||||
# strip: EVERY frame of a moment (turns, takes, jumps, throws, transitions)
|
||||
node render.mjs --strip=2.1:2.6 --cols=6 --w=320 --out=out/check/strip.jpg
|
||||
# crop: full-resolution detail (faces, hands, contacts, glows); crop=x,y,w,h in frame pixels
|
||||
node render.mjs --sheet=2.3,2.4 --crop=760,420,500,400 --w=500 --out=out/check/face.jpg
|
||||
# crop-at: the same, following a WORLD point through each frame's camera (a foot or a prop on a moving shot);
|
||||
# x,y in world pixels (or an expression evaluated in the page), w,h in frame pixels
|
||||
node render.mjs --strip=2.1:2.6 --crop-at=960,700,500,400 --out=out/check/feet.jpg
|
||||
```
|
||||
|
||||
Open each image and actually look at it. Check:
|
||||
|
||||
- **Read:** is the event of each shot clear from its sheet alone? Is Clawd big enough, and does Clawd separate from the background?
|
||||
- **Timing.** You can't judge timing from single frames, so read it like a viewer:
|
||||
- Render the shot as a sheet at a fixed step (every 0.1–0.15 s) and read it in order.
|
||||
- At each frame ask: where is the viewer looking right now, and do they understand it yet?
|
||||
- Count the frames each read gets (24 frames = 1 s). A read that flashes by in a few frames, or shares its frames with another read, will be missed.
|
||||
- After each important moment, is there time to take it in before the next thing starts?
|
||||
- **Motion:** in strips, does every move have anticipation and follow-through? Are there any pops, jumps or snaps between frames? Do the parts move at different times, or all at once? Is anything moving at a constant speed, or mirrored left and right? Are the poses pushed far enough to read?
|
||||
- **Boil:** in a strip, each pair of frames that share a boil drawing should match except where something moves. Anything still that changes every frame needs its own `boilSeed()`.
|
||||
- **Contacts:** do feet touch the ground? Do held things touch the arm tips? Do thrown things leave from the hand?
|
||||
- **Transitions:** check the first and last 0.5 s of every shot and every seam. Does it open and close with a transition?
|
||||
- **Rules:** is there any text? Is there any 3D? Is there any dead stretch where nothing is happening?
|
||||
- **Colour:** any muddy glows (use `glow()`), pure black or pure white?
|
||||
|
||||
Fix what you find, then look again. **Budget:** at least one sheet per shot, a strip for every key motion and transition, and a crop for every face that carries the story. Contact sheets run about 0.1–1 s per frame, so this is cheap: don't skip it.
|
||||
|
||||
### 4. Render
|
||||
|
||||
```bash
|
||||
node render.mjs --clip --out=out/video.mp4 # the whole video, straight to MP4
|
||||
node render.mjs --frames --workers=4 # or: parallel + resumable JPEG frames into out/frames …
|
||||
node render.mjs --encode --out=out/video.mp4 # … then encode them
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Engine
|
||||
|
||||
### Files
|
||||
|
||||
| file | what's in it |
|
||||
|---|---|
|
||||
| `src/config.js` | `PROJECT = { duration, bpm, offset, audio? }` |
|
||||
| `src/core.js` | canvas, palette, timing and motion helpers, `paint()`, camera, full-frame effects, `glow()`, lettering, paper, render hooks |
|
||||
| `src/clawd.js` | Clawd: views, emotions, eyes, mouths, hats, emotes, moves |
|
||||
| `src/timeline.js` | `shots()`, `LOOPS`, `brushWipe()` |
|
||||
| `src/sheets.js` | the model sheets as loops (`?loop=emotions`, `?loop=views`) |
|
||||
| `src/scenes/demo.js` | an 11-second example. **Don't copy it** (see the end of this guide) |
|
||||
| `studio.html` | open it in Chrome to scrub the video (`?t=2.5` jumps to a time, `?loop=emotions` shows a loop) |
|
||||
| `render.mjs` | headless renderer: sheets, strips, crops, stills, PNG loops, MP4 |
|
||||
|
||||
### Frames are pure functions of time
|
||||
|
||||
- **Frames render in parallel and out of order.** A shot is `fn(t, lt, dur)` and must draw the same frame for the same `t`, every time. No state carried between frames, no counters, no `Math.random()`, no physics that integrates frame by frame. Compute everything from `t`, in closed form (the helpers below do this for you).
|
||||
- Randomness: `hash(i)` for stable per-object values, and `jit(a)`/`random()` for boil (they change 12 times a second).
|
||||
- **Seed each element with `boilSeed(key)`.** Each boil drawing holds for two frames, so anything that isn't moving must draw the same in both. But a moving thing uses a different amount of randomness each frame, which shifts the stream for everything drawn after it, and all of that re-boils every frame and looks jittery.
|
||||
- `boilSeed(key)` restarts the stream from the boil frame and a key that stays the same every frame (any string or number, unique within the frame).
|
||||
- Call it before each separate element: each background layer, prop and effect.
|
||||
- `clawd()` seeds itself and each of its parts, then reseeds when it's done, so nothing drawn after it depends on its pose. Its key is its call order; set `boilKey` if characters come and go mid-shot.
|
||||
- **Each shot paints the whole frame,** background included. The paper texture is under everything and the grain is multiplied over the top, so leaving paper showing is a valid look.
|
||||
- **Canvas:** 1920×1080, origin top-left, y down.
|
||||
- `LOOPS.name = t => {...}; LOOPS.name.len = 4;` makes a standalone loop (tests, GIFs, sheets), rendered with `--loop=name`.
|
||||
|
||||
### Painting
|
||||
|
||||
`paint(pts, o)` paints one shape from a point list `[[x, y], ...]`:
|
||||
|
||||
| option | meaning |
|
||||
|---|---|
|
||||
| `wash, washOp` | flat colour (opacity 0–255, default 255). For characters, props, anything solid. |
|
||||
| `fill, fillOp, bleed, tex, border` | watercolour fill with bleeding edges and pigment texture. For skies, hills, shading and shadows. `bleed` ~.05–.3, `tex` ~.3–.9. |
|
||||
| `hatch: { d, a, o, b, c, w }` | hatching (distance, angle, `{rand, gradient}`, brush e.g. `'charcoal'`/`'HB'`, colour, weight). Texture, sparingly. |
|
||||
| `ink, sw, br` | outline colour (default `PAL.ink`), weight (~.4–2), brush. **`ink: null` means no outline.** |
|
||||
| `curv` | 0–1: smooth the outline through the points. |
|
||||
|
||||
- **Lines:** `inkLine(pts, sw, colour, brush = 'ink', curvature)`. The kit's brushes are `'ink'`, `'inkfine'` and `'dry'` (bristly). p5.brush's built-ins also work: `'2B'`, `'HB'`, `'charcoal'`, `'marker'`, `'pen'`, `'cpencil'`, `'rotring'`, `'spray'`.
|
||||
- **Shapes:**
|
||||
- `rectPts(x, y, w, h, jitter)`
|
||||
- `ellPts(cx, cy, rx, ry, n, jitter, rot)`
|
||||
- `rrPts(x, y, w, h, r, jitter)`: a rounded rectangle
|
||||
- `starPts(cx, cy, r, inner, n, rot)`
|
||||
- `heartPts(cx, cy, r)`
|
||||
- `through(P)`: a smooth curve through the points
|
||||
- `ribbon(P, w0, w1)`: a tapered ribbon along a path, as one outline. Use it for tails, trails, vines and noodly arms.
|
||||
- **One shape, one outline.** Build a creature or prop from as few outlines as you can, so it doesn't look like glued-on stickers: a tail is one `ribbon`, not five circles.
|
||||
- **Light:** `glow(x, y, r, colour, a)`. It's additive, so it stays warm on dark grounds and barely shows on light ones (as real light would). Draw it before the things that sit in front of the light.
|
||||
- **Palette** `PAL`: `paper, ink, clay, clayDk, clayLt, night, indigo, rose, ochre, sap, teal, violet, cream, sky`. `mixCol(a, b, k)` mixes two hex colours in RGB. Any hex colour works: pick a small palette per video.
|
||||
- **p5.brush quirks:**
|
||||
- Colours mix like pigment: yellow over blue makes green. Layer light colours over dark ones with a full-opacity `wash`, or use `glow()`.
|
||||
- `wash` at 255 is exact colour; lower opacities mix.
|
||||
- Strokes drawn far from the origin under a zoomed camera collapse: from zoom ~2, an outline or a line at large
|
||||
world coordinates leaves only a dot at its first vertex. `paint()` and `inkLine()` draw each shape around its own
|
||||
centre, which avoids it. A shape much bigger than the canvas can still lose its outline: draw long edges as
|
||||
`inkLine`s no bigger than the canvas.
|
||||
- Outline weight is in world units, so it grows with the camera's zoom: a fine outline on a small shape becomes a
|
||||
dark blob in a close-up. Scale `sw` down with the zoom for small shapes.
|
||||
- A NaN in a point list throws `Failed to construct 'OffscreenCanvas': Value is not of type 'unsigned long'`, with
|
||||
a stack pointing at your scene rather than the NaN. Guard geometry that can degenerate (`Math.acos` of a ratio > 1).
|
||||
- p5 `push()/pop()/translate()/rotate()/scale()` work with all brush calls.
|
||||
- Cost is the number of `fill` shapes and strokes: hundreds are fine, thousands are not. Aim for ≤ 1.5 s per frame. The render log prints ms/frame.
|
||||
- Some scenes make p5.brush log five `WebGL: INVALID_OPERATION ... not from the associated program` warnings once per page. They're harmless (frames come out identical). Any other page error is real.
|
||||
|
||||
### Time and motion (all pure functions of t)
|
||||
|
||||
- **Progress and keys:**
|
||||
- `seg(t, a, b)`: 0..1 progress through [a, b]
|
||||
- `kf(t, [[t0, v0], [t1, v1], ...], ease)`: keyframes; values may be arrays
|
||||
- `lerp`, `clamp`, `frac`, `wob(t, freq, phase)`, `TAU`
|
||||
- **Easing:** `ease`, `easeIn`, `easeOut`, `backOut` (overshoot) and `elasticOut`.
|
||||
- **Rhythm:** `BEAT` (seconds per beat), `bpOf(t)` (beat position), `beatN(t)` (beat number), and `pulse(t, k)` / `pulse2(t, k)`, which are 1 on each beat (or eighth) and then decay.
|
||||
- **Acting:**
|
||||
- `jump(t, t0, t1, h)`: crouch, stretch, arc and squash-land. Returns `{dy, sq}`.
|
||||
- `take(t, t0, amt)`: a surprise take, returns `{sq, dy}`.
|
||||
- `stroll(t, t0, t1, x0, x1, u)`: an eased walk. Returns `{x, walk, view, flip, dy}`.
|
||||
- `spring(t, t0, k, w)`: a damped wobble after an event, for settles and follow-through.
|
||||
- `ring(t, [t0, t1, ...])`: one `spring` kick per event time.
|
||||
- `arcPt(p0, p1, h, k)`: a point on a thrown arc.
|
||||
- `onTwos(t)`: holds each drawing for two frames. Wrap a shot's `t` in it for a snappier, hand-drawn feel.
|
||||
- **Camera:**
|
||||
- `camBegin(cx, cy, zoom, rot)` … `camEnd()`: world point (cx, cy) lands at screen centre. One level only; always pair them.
|
||||
- `toScreen(x, y)`: world → screen while a camera is active. Use it to aim an iris at a character.
|
||||
- `shakeXY(t, amount)`: [dx, dy] to add to the camera on impacts.
|
||||
- **Full-frame effects** (screen space, after `camEnd()`):
|
||||
- `brushWipe(p, [c1, c2])`: fat strokes cover the frame (p 0 → .5) and then drag off (.5 → 1). Cut under full cover. Its comment in timeline.js shows the two calls.
|
||||
- `iris(cx, cy, r, colour)` and `irisShape(pts, colour)`: shaped reveals.
|
||||
- `flash(k, colour)`: a full-frame flash.
|
||||
- **Lettering** (only if you must, see "No text"):
|
||||
- `letter(txt, x, y, size, colour, {pop, rot, alpha, screen})`
|
||||
- `sfx(txt, x, y, size, colour, age)`
|
||||
- Both are composited at `flushLetters()`, after the shot. If a wipe or iris must cover them, call it yourself first.
|
||||
|
||||
---
|
||||
|
||||
## Clawd
|
||||
|
||||
Clawd is the Claude Code mascot: a terracotta block (`PAL.clay`), 10 units wide and 6 tall, with four stubby legs, two little arm nubs and two tall slit eyes. There's no mouth at rest. It's drawn in flat wash with an ink outline and boils like everything else. That's the default design, not a rule: change it if the idea needs it. Within one video, though, keep every character on model, with the same shape and features in every shot.
|
||||
|
||||
```js
|
||||
clawd(x, y, u, options) // (x, y) = ground point between the feet; u = size unit
|
||||
```
|
||||
|
||||
**Sizes:** Clawd is 10u × 8u (legs included).
|
||||
|
||||
| shot | u | on screen |
|
||||
|---|---|---|
|
||||
| wide | 10–16 | small in the landscape |
|
||||
| medium | 20–28 | the usual acting size |
|
||||
| close-up | 40–70 | face acting |
|
||||
| extreme close-up | 90+ | camera pushed in on the eyes |
|
||||
|
||||
### Options
|
||||
|
||||
| group | options |
|
||||
|---|---|
|
||||
| pose | `dx`, `dy` (in u; −dy = up), `sq` (squash; negative stretches), `rot` (pivots at the feet), `flip`, `sx`, `sy`, `aL`, `aR` (arm angle: 0 = straight out, + = up, − = down; ±1.5 is vertical), `walk` (leg phase), `noLegs`, `noShadow` |
|
||||
| view | `view`: front, q, side, qback, back. `smear` 0..1 + `smearDir` ±1 (0 = both sides) for fast moves |
|
||||
| face | `eyes`, `mouth`, `lookX`/`lookY` (−1..1), `squint` 0..1, `blush` 0..1, `gloom` 0..1, `lid` 0..1, `seed` (blink timing) |
|
||||
| colour | `tint` (pale, flush, blue, rosy, green, gold, or any hex) + `tintK`, or `col`/`dk`/`lt` directly |
|
||||
| extras | `hat`, `emote` + `emoteK` (0..1 pop) + `emoteAge`, `draw(u, sw)`, `armL(u, sw)`, `armR(u, sw)` |
|
||||
| boil | `boilKey`: a stable id for its boil seeds (default: call order) |
|
||||
|
||||
Options compose by spreading: `clawd(x, y, u, { ...feel('happy', t), ...turn(t, 1, 1.15, 0, .25), hat: 'party' })`. Later spreads win, so put the emotion first and the pose after it. If both an emotion and a pose move the same field (`dy`, `sq`), add them together rather than letting one silently replace the other.
|
||||
|
||||
### Views and turns
|
||||
|
||||
These are drawn key views. Every view faces screen-right; add `flip: true` to face left.
|
||||
|
||||
| view | what you see |
|
||||
|---|---|
|
||||
| `front` | the face, both arms, four legs |
|
||||
| `q` | 3/4: the face shifted toward the heading, the side face as a darker strip behind it |
|
||||
| `side` | profile: one eye near the leading edge, one arm |
|
||||
| `qback` | 3/4 from behind: no face |
|
||||
| `back` | no face |
|
||||
|
||||
- **Turn with `turn(t, t0, t1, a0, a1)`.** Headings are in turns: 0 = front, .25 = facing right, .5 = back, −.25 = facing left. It steps through the key views over 0.12–0.25 s with smears, like a drawn turn. For a single heading, use `spinView(a)`.
|
||||
- **Walks across the screen** read best in `side` view with `walk` driving the legs. `stroll()` gives `q`; override it with `view: 'side'` for a trot.
|
||||
- **Facing right, `aL` is the near arm** (drawn in front of the body), and the far arm is behind and darker.
|
||||
|
||||
### Emotions
|
||||
|
||||
31 emotions. Each is a face, a colour and a way of moving, all locked to the beat. `feel(name, t, over)` returns all of it at time t:
|
||||
|
||||
```js
|
||||
clawd(x, y, u, feel('happy', t)); // one emotion, alive
|
||||
clawd(x, y, u, feel('sad', t, { view: 'q', lookX: -1 })); // with overrides
|
||||
clawd(x, y, u, emotions(t, [[0, 'sleepy'], [1.9, 'surprised', { lookX: .8 }], [2.5, 'idea']])); // acted changes
|
||||
```
|
||||
|
||||
| family | emotions |
|
||||
|---|---|
|
||||
| joy | happy, excited, laugh, love, proud, starstruck, playful, hopeful, relieved |
|
||||
| sly | smug, cool, mischief, suspicious |
|
||||
| low | sad, cry, bored, sleepy, ko |
|
||||
| hot | angry, furious (the lunchbox lid opens), determined, disgusted |
|
||||
| alarm | scared, nervous, surprised, confused, dizzy |
|
||||
| mind | neutral, thinking, idea, shy |
|
||||
|
||||
- **`emotions(t, keys)`** is how moods change in a shot. Around each key it squints and squashes just before the change (anticipation), swaps the face under the squint, and fires a take sized to the new emotion. The body then settles into the new motion with overshoot, colour cross-fades and the new emote pops in.
|
||||
- `emotions(t, keys, { take: .5 })` scales every take.
|
||||
- The third element of a key overrides fields for that stretch (e.g. `{ lookX: .8, emote: 'music' }`).
|
||||
- To make a new emotion, add an entry to `EMO` in clawd.js. It needs eyes, mouth, optional tint/blush/gloom/lid/emote, `take` (reaction size) and `body(t)` (its idle motion, locked to the beat through `_b(t)`).
|
||||
|
||||
### Parts
|
||||
|
||||
- **Eyes:** normal, look, wide, happy, closed, sleepy, wink, narrow, angry, determined, sad, teary, cry, squeeze, shine, scared, blank, spark, red, heart, x, swirl, dot, shades.
|
||||
- A pair gives mismatched eyes, e.g. `['narrow', 'wide']`.
|
||||
- `lookX`/`lookY` aim the pupils, and `squint` closes the eyes from any shape.
|
||||
- **Mouths:** o, O, smile, grin, flat, wobble, cat, frown, smirk, laugh, open, wail, teeth, tongue, pout, yawn. Use `null` for none (Clawd's resting face).
|
||||
- **Lunchbox lid:** `lid` 0..1 hinges the top of the body open, with teeth pointing into the mouth. It's for fury, chomping and shouting, in the front view only.
|
||||
- **Hats:** party, hard, crown, halo, wizard, hood, top, fedora, band, sweatband, beanie, bow, flower, headphones, cat (ears and whiskers). Face pieces: masq, mask, bowtie.
|
||||
- **Emotes** are painted marks that pop in by the head: `!` `?` `!!` `!?` zzz, sweat, spark, heart, hearts, anger, steam, bulb, dots, scribble, music, swirl, stars, cloud.
|
||||
- `emoteK` is the 0..1 pop and `emoteAge` drives the looping ones; `emotions()` sets both.
|
||||
- `emote(kind, x, y, s, k, age)` draws one anywhere, for example over a prop.
|
||||
- **Body colour:**
|
||||
- `tint` shifts it with the mood: pale (fear), flush (anger), blue (sadness), rosy (love), green (disgust), gold (pride).
|
||||
- `gloom` adds a dark forehead with hanging lines, and `blush` adds cheeks (hatched when above .6).
|
||||
|
||||
### Hooks: props and accessories
|
||||
|
||||
- **`draw(u, sw)`** paints in body-local space, on top of everything else on the body (face, hat, near arm). It squashes, flips and rotates with Clawd. Front-view coordinates:
|
||||
- the body spans x −5u..5u and y −8u..−2u
|
||||
- the eyes are at (±2.5u, −6u)
|
||||
- the mouth is near (0, −4.3u)
|
||||
- **`armL(u, sw)` / `armR(u, sw)`** are called at the arm tip, in arm space (+x runs outward along the arm), so a held prop just draws around (0, 0) and follows the arm automatically:
|
||||
|
||||
```js
|
||||
clawd(x, y, 24, { ...feel('proud', t), aR: 1.2, armR: (u, sw) => paint(starPts(u * .8, 0, u * 1.4, .5, 5), { wash: '#FFE27A', sw }) });
|
||||
```
|
||||
|
||||
- Anything the arms can't carry, like a big object overhead, is drawn separately at a point computed from the same pose. The demo's star does this. Make sure it touches: check it with a crop.
|
||||
|
||||
### Dances
|
||||
|
||||
`move(style, t, seed)` returns beat-locked pose offsets. The styles are bounce, hop, roof (arms up), sway, spin (a drawn spin through the key views once a bar), wave, walk, run, idle, stomp and shimmy. `mix` changes style every two bars. `dancer(x, y, u, style, t, extra)` is `clawd` + `move`. You can combine a dance with a face: `{ ...move('bounce', t), eyes: 'happy', mouth: 'grin' }`.
|
||||
|
||||
---
|
||||
|
||||
## Music (optional)
|
||||
|
||||
The kit doesn't need music, but it's built for it:
|
||||
|
||||
1. Set `bpm` to the song's tempo in [src/config.js](src/config.js), and set `offset` to the time of its first downbeat in seconds. Every idle, dance and `pulse()` then locks to the song.
|
||||
2. Put the audio in `assets/` and set `PROJECT.audio` (or pass `--audio=`). `--clip` and `--encode` mux it in.
|
||||
3. Land hits, cuts and takes on beats (`beatN`, `pulse`). Cut on bar lines for big changes, and give each musical phrase its own visual.
|
||||
4. **Lyrics are not text.** Don't put words on screen. Act the meaning of a line instead.
|
||||
|
||||
## Common failures
|
||||
|
||||
These are the things that make a Clawd video look generated. Check your storyboard and sheets against them:
|
||||
|
||||
- signs, captions, labels or speech bubbles with words
|
||||
- Clawd standing still and smiling while nothing happens
|
||||
- everything moving at one brisk speed, with events stacked on top of each other and no holds
|
||||
- moments that are over before the viewer understands them
|
||||
- a tiny Clawd in a big empty landscape for the whole video
|
||||
- faces that snap from one expression to another
|
||||
- mechanical motion: linear moves, every part moving at once, both arms or several characters in sync
|
||||
- timid poses and takes that barely read
|
||||
- jittery linework: still things re-boiling every frame because something moving before them shifted the random stream (`boilSeed`)
|
||||
- hard cuts everywhere, or a video that just starts and stops
|
||||
- 3D rotation, perspective boxes or projected turns
|
||||
- plain p5 shapes, gradients or digital glows mixed into the paint (use `paint`/`inkLine`/`glow`)
|
||||
- muddy green-grey glows from painting yellow over blue
|
||||
- props floating near a hand instead of touching it
|
||||
- every shot a different world with nothing linking them
|
||||
|
||||
## About the demo
|
||||
|
||||
[src/scenes/demo.js](src/scenes/demo.js) ("The fallen star") exists to show the kit working: an acted emotion timeline, a drawn turn, a trot, a pickup, a throw on an arc, `glow`, a brush wipe and an iris in and out. **It's one idea, not a template.** Don't reuse its story, night sky, hills, star or shot structure. Start from the prompt and your own storyboard, and replace its script tag in `studio.html` with yours.
|
||||
|
||||
### Worked example: how the demo times its ending
|
||||
|
||||
This shows rule 4 applied to one shot of one video. The reads, the numbers and the way it ends are specific to this story; yours will be different. Read it for the reasoning, not the numbers. It's the demo's last shot, from the throw on, in video time:
|
||||
|
||||
| time | read | why it's timed this way |
|
||||
|---|---|---|
|
||||
| 6.95–7.1 | wind-up and throw | fast motion; it reads because the wind-up anticipates it |
|
||||
| 7.1–7.9 | the star flies home | long enough for the eye to follow it from Clawd to the top of the frame; the camera eases back to give it sky |
|
||||
| 7.9–8.35 | it arrives: a flare, a sparkle, the sky twinkles | the payoff. Clawd only watches, so nothing competes with it |
|
||||
| 8.35–8.95 | Clawd falls in love | the reaction starts only after the viewer has seen the cause |
|
||||
| 8.95–9.4 | Clawd waves goodbye | a new action, on its own |
|
||||
| 9.4–9.8 | the star twinkles back | the answer to the wave |
|
||||
| 9.8–11.0 | iris to Clawd, hold, shut | the last read gets time to land before the video ends |
|
||||
|
||||
The first version packed all of this into about 1.3 s, and nobody could tell what had happened.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 John Heibel
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Claude Animation Base
|
||||
|
||||
This is a small starter kit with code, instructions and assets for animating a character in [p5.js](https://p5js.org) and [p5.brush](https://github.com/acamposuribe/p5.brush) with Claude Opus 5.5. It's based on the code from the music video [I'm Upping My P(doom)](https://github.com/JohnHeibel/PDoomVideo) and an analysis of what the model did and didn't do well. I highly recommend playing around with your prompting: make it give you the storyboard before coding, give it very broad instructions, try being very specific, ask for subagents, and try a bunch of other fun ways of testing the model's capabilities. In my testing, it can do a lot with very little, but it's also quite accurate when you give it more requirements. Also try asking the model to swap out the character or make new emotions or costumes, give it your own reference images, and try many other fun things like that. I've found that the reasoning level corresponds to how "extravagant" and detail-oriented the model makes the scene. All test videos were generated with Opus 5.5 on xhigh reasoning in Claude Code.
|
||||
|
||||

|
||||
|
||||
## Make a video
|
||||
|
||||
Clone it, open it in Claude Code (or any coding agent) and ask for what you want:
|
||||
|
||||
> Read ANIMATION_GUIDE.md, then make a 15-second video of Clawd trying to catch a butterfly.
|
||||
|
||||
The model storyboards first, builds shot by shot, renders contact sheets to check its own work, and writes `out/video.mp4`. [ANIMATION_GUIDE.md](ANIMATION_GUIDE.md) holds the rules it follows: handmade, alive, one piece, no text, transitions always, something happens in every scene, and a solid medium of brush strokes, flat 2D and boiling linework. It also covers timing for the viewer and the core principles of character animation.
|
||||
|
||||
## Run it yourself
|
||||
|
||||
You need Node.js, Google Chrome and ffmpeg.
|
||||
|
||||
Without a dedicated GPU, p5.brush's watercolour fills make render times fairly slow, measured in seconds per frame. If you're running on integrated graphics, I recommend asking the model to avoid those fills and replace them with something else appropriate. (I love the look of the watercolours, though.)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
node render.mjs --clip --out=out/video.mp4
|
||||
```
|
||||
|
||||
That renders the 11-second demo in [src/scenes/demo.js](src/scenes/demo.js). Open [studio.html](studio.html) in Chrome to scrub through it. Add `?loop=emotions` or `?loop=views` to see the model sheets. If Chrome isn't in a standard location, pass `--chrome=<path>` or set `CHROME_PATH`.
|
||||
|
||||
On Linux, `render.mjs` starts Chrome with `--no-sandbox` (Ubuntu 23.10+ blocks Chrome's sandbox in headless use) and also finds a Chromium installed by Playwright. With no GPU at all, add `--soft-gl` to render WebGL in software: slow on watercolour fills, but it works. On a headless Linux machine with an NVIDIA GPU (a cloud or cluster node), add `--gpu-angle=gl-egl` (or `vulkan`); `node gpu_probe.mjs <chrome path>` shows which renderer each set of flags gets.
|
||||
|
||||
## What's here
|
||||
|
||||
| path | what it is |
|
||||
|---|---|
|
||||
| [ANIMATION_GUIDE.md](ANIMATION_GUIDE.md) | The rules, the workflow and the full API. Read it first. |
|
||||
| [src/clawd.js](src/clawd.js) | Clawd: views, emotions, eyes, mouths, hats, emotes, dances |
|
||||
| [src/core.js](src/core.js) | Painting, timing, motion helpers, camera, light, paper |
|
||||
| [src/timeline.js](src/timeline.js) | Shots, loops and the brush-wipe transition |
|
||||
| [src/config.js](src/config.js) | Length and tempo |
|
||||
| [src/scenes/](src/scenes/) | Your video goes here (the demo is an example) |
|
||||
| [render.mjs](render.mjs) | Headless renderer: contact sheets, frame strips, crops, stills, MP4 |
|
||||
| [docs/](docs/) | Model sheets: [emotions](docs/emotions.jpg) (also [animated](docs/emotions.webp)) and [views, motion and hats](docs/views.jpg) |
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 545 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 480 KiB |
@@ -0,0 +1,30 @@
|
||||
// Which GPU does headless Chrome's WebGL land on? Prints the unmasked renderer for each flag set.
|
||||
// node gpu_probe.mjs <chrome path>
|
||||
import puppeteer from 'puppeteer-core';
|
||||
|
||||
const chrome = process.argv[2];
|
||||
const base = ['--no-sandbox', '--ignore-gpu-blocklist', '--enable-gpu-rasterization'];
|
||||
const candidates = {
|
||||
'angle-vulkan': ['--use-angle=vulkan', '--enable-features=Vulkan'],
|
||||
'gl-egl': ['--use-gl=egl'],
|
||||
'angle-gl-egl': ['--use-angle=gl-egl'],
|
||||
'kit-default': ['--use-gl=angle'],
|
||||
};
|
||||
for (const [name, flags] of Object.entries(candidates)) {
|
||||
let browser;
|
||||
try {
|
||||
browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: [...base, ...flags] });
|
||||
const page = await browser.newPage();
|
||||
const info = await page.evaluate(() => {
|
||||
const gl = document.createElement('canvas').getContext('webgl2') || document.createElement('canvas').getContext('webgl');
|
||||
if (!gl) return 'no WebGL context';
|
||||
const ext = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
return ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
|
||||
});
|
||||
console.log(`${name.padEnd(13)} ${info}`);
|
||||
} catch (e) {
|
||||
console.log(`${name.padEnd(13)} FAILED: ${e.message.split('\n')[0]}`);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
}
|
||||
}
|
||||
Generated
+550
@@ -0,0 +1,550 @@
|
||||
{
|
||||
"name": "claude-animation-base",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-animation-base",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p5": "^2.3.3",
|
||||
"p5.brush": "^2.2.3",
|
||||
"puppeteer-core": "^25.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@davepagurek/bezier-path": {
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@davepagurek/bezier-path/-/bezier-path-0.0.7.tgz",
|
||||
"integrity": "sha512-CVlnCOrV1iy4Z12T756i9l4G6kF7r8uhlnb+xqDemAMmWQB+8Q0b+8VEqIiUfywgZDSiDr18Rm7pZlnA69rE8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@japont/unicode-range": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@japont/unicode-range/-/unicode-range-1.0.0.tgz",
|
||||
"integrity": "sha512-BckHvA2XdjRBVAWe2uceNuRf78lBeI28kyWEbfr/Q2pE17POkwuZ6WWY/UMv8FL9iBxhW4xfDoNLM9UVZaTeUQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@puppeteer/browsers": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.2.tgz",
|
||||
"integrity": "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"modern-tar": "^0.8.4",
|
||||
"yargs": "^18.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"browsers": "lib/main-cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"proxy-agent": ">=8.0.1",
|
||||
"yauzl": "^2.10.0 || ^3.4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"proxy-agent": {
|
||||
"optional": true
|
||||
},
|
||||
"yauzl": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
||||
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
|
||||
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
|
||||
"integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chromium-bidi": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz",
|
||||
"integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"mitt": "^3.0.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0 <22.0.0 || >=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"devtools-protocol": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/chromium-bidi/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
|
||||
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^7.2.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/colorjs.io": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.6.1.tgz",
|
||||
"integrity": "sha512-8lyR2wHzuIykCpqHKgluGsqQi5iDm3/a2IgP2GBZrasn2sBRkE4NOGsglZxWLs/jZQoNkmA/KM/8NV16rLUdBg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/color"
|
||||
}
|
||||
},
|
||||
"node_modules/devtools-protocol": {
|
||||
"version": "0.0.1680125",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1680125.tgz",
|
||||
"integrity": "sha512-rVARKAvOFJCFkvgX01VLvYqKIvt+cRCqvUxIhB2oAqVGPalxk3w3sb5TUGDagpow/EFblBpm/Zl/29QA2dd+jA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/escodegen": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
|
||||
"integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"esprima": "^4.0.1",
|
||||
"estraverse": "^5.2.0",
|
||||
"esutils": "^2.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"escodegen": "bin/escodegen.js",
|
||||
"esgenerate": "bin/esgenerate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"source-map": "~0.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/estraverse": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
|
||||
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esutils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
|
||||
"integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/gifenc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/gifenc/-/gifenc-1.0.3.tgz",
|
||||
"integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "19.9.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-19.9.2.tgz",
|
||||
"integrity": "sha512-0i6cuo6ER6usEOtKajUUDj92zlG+KArFia0857xxiEHAQcUwh/RtOQocui1LPJwunSYT574Pk64aNva1kwtxZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-browser-languagedetector": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-4.3.1.tgz",
|
||||
"integrity": "sha512-KIToAzf8zwWvacgnRwJp63ase26o24AuNUlfNVJ5YZAFmdGhsJpmFClxXPuk9rv1FMI4lnc8zLSqgZPEZMrW4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/libtess": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/libtess/-/libtess-1.2.2.tgz",
|
||||
"integrity": "sha512-Nps8HPeVVcsmJxUvFLKVJcCgcz+1ajPTXDVAVPs6+giOQP4AHV31uZFFkh+CKow/bkB7GbZWKmwmit7myaqDSw==",
|
||||
"license": "SGI-B-2.0"
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/modern-tar": {
|
||||
"version": "0.8.5",
|
||||
"resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.5.tgz",
|
||||
"integrity": "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/omggif": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz",
|
||||
"integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/p5": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/p5/-/p5-2.3.3.tgz",
|
||||
"integrity": "sha512-+zVz5JFp3sCLpLscTMOLoA0UEHV0dsHxngKVfMmeW1J2/IXvfFqCjNYZeIOtG4Lhfnpm7cu6446P6Ho64ihXfA==",
|
||||
"license": "LGPL-2.1",
|
||||
"dependencies": {
|
||||
"@davepagurek/bezier-path": "^0.0.7",
|
||||
"@japont/unicode-range": "^1.0.0",
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-walk": "^8.3.4",
|
||||
"colorjs.io": "^0.6.0",
|
||||
"escodegen": "^2.1.0",
|
||||
"gifenc": "^1.0.3",
|
||||
"i18next": "^19.0.2",
|
||||
"i18next-browser-languagedetector": "^4.0.1",
|
||||
"libtess": "^1.2.2",
|
||||
"omggif": "^1.0.10",
|
||||
"pako": "^2.1.0",
|
||||
"zod": "^4.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/p5.brush": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/p5.brush/-/p5.brush-2.2.3.tgz",
|
||||
"integrity": "sha512-Jxl7Rl01StakZN7OnaY+HYLXvemeIiCnDzVz6qnvsABF8GunCBcQON7xUKZUjV1E/Ab7VIFSMOspmVzLO/ljTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"simplex-noise": "^4.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"p5": "^2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
|
||||
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/puppeteer-core": {
|
||||
"version": "25.11.0",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.11.0.tgz",
|
||||
"integrity": "sha512-Ujym7JbxoAqanaxGcHgmTomfhC01HfDCSuGZ8Hs9boHrDjzbNVMnFRJo2KtbDT9LCRgeJoFnkn9JQZ41eEUrLw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@puppeteer/browsers": "3.2.2",
|
||||
"chromium-bidi": "17.0.2",
|
||||
"devtools-protocol": "0.0.1680125",
|
||||
"typed-query-selector": "^2.12.2",
|
||||
"webdriver-bidi-protocol": "0.4.3",
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/simplex-noise": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/simplex-noise/-/simplex-noise-4.0.3.tgz",
|
||||
"integrity": "sha512-qSE2I4AngLQG7BXqoZj51jokT4WUXe8mOBrvfOXpci8+6Yu44+/dD5zqDpOx3Ux792eamTd2lLcI8jqFntk/lg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
|
||||
"integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.5.0",
|
||||
"strip-ansi": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/typed-query-selector": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
|
||||
"integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webdriver-bidi-protocol": {
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz",
|
||||
"integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz",
|
||||
"integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^9.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"string-width": "^8.2.1",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^22.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
|
||||
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.6.5",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
|
||||
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "claude-animation-base",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"description": "Clawd, painted in p5.brush: a character kit and guide for making hand-painted Clawd animations",
|
||||
"scripts": {
|
||||
"sheet": "node render.mjs --sheet=0,1,2,3,4,5 --out=out/sheet.jpg",
|
||||
"video": "node render.mjs --clip --out=out/video.mp4"
|
||||
},
|
||||
"dependencies": {
|
||||
"p5": "^2.3.3",
|
||||
"p5.brush": "^2.2.3",
|
||||
"puppeteer-core": "^25.11.0"
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
// render.mjs: renders studio.html in headless Chrome. Length and fps come from the page (PROJECT in src/config.js).
|
||||
//
|
||||
// Look at it (open the images with your image viewer / Read tool):
|
||||
// node render.mjs --sheet=0.5,1,1.5,2 [--cols=4] [--w=480] --out=out/check/a.jpg contact sheet of chosen times
|
||||
// node render.mjs --strip=2.0:2.5 [--cols=6] [--w=320] --out=out/check/strip.jpg EVERY frame in a stretch (motion)
|
||||
// node render.mjs --sheet=2.1,2.2 --crop=760,300,400,400 --w=600 --out=out/check/face.jpg full-res crops (details)
|
||||
// node render.mjs --strip=2.0:2.5 --crop-at=960,780,500,400 --out=out/check/feet.jpg crops that follow a WORLD point
|
||||
// (x,y in world px, may be page expressions like PLK.MX(1.38); w,h in screen px) through each frame's camera
|
||||
// node render.mjs --stills=1.2,3.4 --out=out/stills full-res PNGs
|
||||
// Make the video:
|
||||
// node render.mjs --clip [--range=0:4] --out=out/video.mp4 straight to MP4 (one worker)
|
||||
// node render.mjs --frames [--range=0:8] --workers=4 JPEG frames → out/frames (parallel, resumable)
|
||||
// node render.mjs --encode --out=out/video.mp4 out/frames → MP4
|
||||
// Standalone loops (LOOPS in the page): add --loop=<name> to any of the above (times are then loop times), or
|
||||
// node render.mjs --loop=emotions --png --out=out/loop_emotions one cycle as PNGs (for GIFs)
|
||||
// Music: --audio=assets/song.mp3 (or PROJECT.audio) is muxed into --clip and --encode. Other flags: --fps=24,
|
||||
// --chrome=<path to Chrome/Chromium>.
|
||||
import puppeteer from 'puppeteer-core';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdirSync, writeFileSync, existsSync, statSync, renameSync, readdirSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, v] = a.replace(/^--/, '').split('='); return [k, v ?? true]; }));
|
||||
const CHROMES = [args.chrome, process.env.CHROME_PATH, 'C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
|
||||
...playwrightChromes()];
|
||||
// Chromium builds Playwright downloaded (~/.cache/ms-playwright/chromium-NNNN), newest first
|
||||
function playwrightChromes() {
|
||||
const dir = `${homedir()}/.cache/ms-playwright`;
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir).filter(n => /^chromium-\d+$/.test(n)).sort((a, b) => b.split('-')[1] - a.split('-')[1])
|
||||
.map(n => `${dir}/${n}/chrome-linux64/chrome`);
|
||||
}
|
||||
const CHROME = CHROMES.find(p => p && existsSync(p));
|
||||
if (!CHROME) { console.error('Chrome not found: pass --chrome=<path> or set CHROME_PATH'); process.exit(1); }
|
||||
const fps = +(args.fps || 24), FRAMES_DIR = 'out/frames';
|
||||
const run = (cmd, a) => new Promise((ok, bad) => { const p = spawn(cmd, a, { stdio: 'inherit' }); p.on('close', c => c ? bad(new Error(cmd + ' exited ' + c)) : ok()); });
|
||||
const times = s => String(s).split(',').map(Number);
|
||||
const span = s => String(s).split(':').map(Number);
|
||||
// comma-separated fields, keeping commas inside parentheses ('PLK.MX(1.38),PLK.WL,500,300'); numbers stay numbers
|
||||
const fields = s => { const out = []; let d = 0, cur = ''; for (const ch of String(s)) { if (ch === ',' && !d) { out.push(cur); cur = ''; continue; } d += ch === '(' ? 1 : ch === ')' ? -1 : 0; cur += ch; } out.push(cur); return out.map(v => isNaN(+v) ? v : +v); };
|
||||
|
||||
if (args.encode) {
|
||||
const out = args.out || 'out/video.mp4', n = readdirSync(FRAMES_DIR).filter(f => f.endsWith('.jpg')).length, audio = args.audio;
|
||||
console.log(`encoding ${n} frames → ${out}${audio ? ' with ' + audio : ''}`);
|
||||
await run('ffmpeg', ['-y', '-loglevel', 'error', '-stats', '-framerate', String(fps), '-i', `${FRAMES_DIR}/f%05d.jpg`,
|
||||
...(audio ? ['-i', audio, '-map', '0:v', '-map', '1:a', '-c:a', 'aac', '-b:a', '192k', '-shortest'] : []),
|
||||
'-c:v', 'libx264', '-preset', 'slow', '-crf', '17', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', out]);
|
||||
console.log('wrote ' + out);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// --soft-gl: no GPU on this machine; render WebGL in software (SwiftShader), which Chrome only allows when asked.
|
||||
// --gpu-angle=vulkan|gl-egl: headless Linux on an NVIDIA GPU (e.g. a cloud or cluster node); plain --use-gl=angle gets
|
||||
// no WebGL context there. Check which GPU Chrome actually lands on with gpu_probe.mjs.
|
||||
const ANGLE = { vulkan: ['--use-angle=vulkan', '--enable-features=Vulkan'], 'gl-egl': ['--use-angle=gl-egl'] };
|
||||
if (args['gpu-angle'] && !ANGLE[args['gpu-angle']]) { console.error(`--gpu-angle must be one of ${Object.keys(ANGLE)}`); process.exit(1); }
|
||||
const gpu = args['soft-gl'] ? ['--use-angle=swiftshader', '--enable-unsafe-swiftshader']
|
||||
: args['gpu-angle'] ? ANGLE[args['gpu-angle']]
|
||||
: process.platform === 'win32' ? ['--use-angle=d3d11'] : process.platform === 'darwin' ? ['--use-angle=metal'] : ['--use-gl=angle'];
|
||||
// Ubuntu 23.10+ blocks Chrome's user-namespace sandbox; headless rendering of local files doesn't need it.
|
||||
const sandbox = process.platform === 'linux' ? ['--no-sandbox'] : [];
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: CHROME, headless: true, protocolTimeout: 0,
|
||||
args: [...sandbox, '--allow-file-access-from-files', '--ignore-gpu-blocklist', ...gpu, '--enable-gpu-rasterization', '--window-size=1920,1080', '--disable-renderer-backgrounding', '--disable-background-timer-throttling']
|
||||
});
|
||||
async function openPage(tag = '') {
|
||||
const page = await browser.newPage();
|
||||
page.on('console', m => { if (['error', 'warn'].includes(m.type())) console.log(`[page${tag}]`, m.text()); });
|
||||
page.on('pageerror', e => console.log(`[page error${tag}]`, e.message));
|
||||
await page.goto(pathToFileURL(resolve('studio.html')).href + '?render', { waitUntil: 'networkidle0', timeout: 300000 });
|
||||
await page.waitForFunction('window.ready === true', { timeout: 600000 });
|
||||
if (args.loop) {
|
||||
const ok = await page.evaluate(name => { if (!LOOPS[name]) return false; window.LOOP = LOOPS[name]; return true; }, args.loop);
|
||||
if (!ok) { console.error(`no loop named "${args.loop}"`); process.exit(1); }
|
||||
}
|
||||
return page;
|
||||
}
|
||||
const frameOf = async (page, t, type, q) => {
|
||||
const url = await page.evaluate((t, type, q) => window.renderAt(t, type, q), t, type, q);
|
||||
return Buffer.from(url.slice(url.indexOf(',') + 1), 'base64');
|
||||
};
|
||||
// the length of whatever is being rendered: a loop's .len, or the video's duration
|
||||
const lengthOf = page => page.evaluate(() => window.LOOP ? window.LOOP.len : DUR);
|
||||
|
||||
if (args.sheet || args.strip) {
|
||||
const page = await openPage(), out = args.out || 'out/sheet.jpg'; mkdirSync(dirname(out), { recursive: true });
|
||||
let ts;
|
||||
if (args.strip) { const [a, b] = span(args.strip); ts = []; for (let i = Math.round(a * fps); i <= Math.round(b * fps); i++) ts.push(i / fps); }
|
||||
else ts = times(args.sheet);
|
||||
const crop = args.crop ? times(args.crop) : null, at = args['crop-at'] ? fields(args['crop-at']) : null;
|
||||
const { url, ms } = await page.evaluate((ts, c, w, crop, at) => window.renderSheet(ts, c, w, crop, at), ts, +(args.cols || (args.strip ? 6 : 3)), +(args.w || (args.strip ? 320 : 640)), crop, at);
|
||||
writeFileSync(out, Buffer.from(url.slice(url.indexOf(',') + 1), 'base64'));
|
||||
console.log(`${out} (${ts.length} frames) ms/frame: ${ms.join(' ')}`);
|
||||
} else if (args.stills) {
|
||||
const page = await openPage(), out = args.out || 'out/stills'; mkdirSync(out, { recursive: true });
|
||||
console.log('GPU:', await page.evaluate(() => window.gpuInfo()));
|
||||
for (const s of times(args.stills)) {
|
||||
const t0 = Date.now(), buf = await frameOf(page, s, 'image/png');
|
||||
const f = `${out}/t${s.toFixed(2).replace('.', '_')}.png`; writeFileSync(f, buf);
|
||||
console.log(`${f} ${Date.now() - t0} ms`);
|
||||
}
|
||||
} else if (args.png) {
|
||||
// PNG sequence (for GIFs): a loop's full cycle (frame n equals frame 0, so it isn't rendered), or --range=a:b.
|
||||
const probe = await openPage(), len = await lengthOf(probe); await probe.close();
|
||||
const [a, b] = args.range ? span(args.range) : [0, len], n = Math.round((b - a) * fps);
|
||||
const out = args.out || `out/${args.loop ? 'loop_' + args.loop : 'png'}`, workers = +(args.workers || 3); mkdirSync(out, { recursive: true });
|
||||
let next = 0; const start = Date.now();
|
||||
await Promise.all(Array.from({ length: workers }, async (_, w) => {
|
||||
const page = await openPage('#' + w);
|
||||
while (next < n) { const i = next++; writeFileSync(`${out}/f${String(i).padStart(4, '0')}.png`, await frameOf(page, a + i / fps, 'image/png')); }
|
||||
}));
|
||||
console.log(`${n} frames → ${out} (${((Date.now() - start) / n).toFixed(0)} ms/frame)`);
|
||||
} else if (args.frames) {
|
||||
// Parallel and resumable: each worker pulls the next missing frame; files are written atomically.
|
||||
const probe = await openPage(), len = await lengthOf(probe); await probe.close();
|
||||
const [a, b] = args.range ? span(args.range) : [0, len], workers = +(args.workers || 4);
|
||||
mkdirSync(FRAMES_DIR, { recursive: true });
|
||||
const first = Math.round(a * fps), last = Math.min(Math.ceil(len * fps) - 1, Math.round(b * fps) - 1);
|
||||
const todo = []; for (let i = first; i <= last; i++) { const f = `${FRAMES_DIR}/f${String(i).padStart(5, '0')}.jpg`; if (!existsSync(f) || statSync(f).size < 1000) todo.push(i); }
|
||||
console.log(`${todo.length} frames to render (${last - first + 1 - todo.length} already done), ${workers} workers`);
|
||||
let next = 0, done = 0; const start = Date.now();
|
||||
await Promise.all(Array.from({ length: workers }, async (_, w) => {
|
||||
const page = await openPage('#' + w);
|
||||
while (next < todo.length) {
|
||||
const i = todo[next++], f = `${FRAMES_DIR}/f${String(i).padStart(5, '0')}.jpg`;
|
||||
const buf = await frameOf(page, i / fps, 'image/jpeg', .94);
|
||||
writeFileSync(f + '.tmp', buf); renameSync(f + '.tmp', f);
|
||||
if (++done % 24 === 0 || done === todo.length) {
|
||||
const el = (Date.now() - start) / 1000;
|
||||
console.log(`frame ${done}/${todo.length} ${(el / done * 1000).toFixed(0)} ms/frame effective eta ${((todo.length - done) * el / done / 60).toFixed(1)} min`);
|
||||
}
|
||||
}
|
||||
}));
|
||||
} else if (args.clip) {
|
||||
const page = await openPage(), len = await lengthOf(page);
|
||||
const [a, b] = args.range ? span(args.range) : typeof args.clip === 'string' ? span(args.clip) : [0, len];
|
||||
const audio = args.audio || await page.evaluate(() => PROJECT.audio || '');
|
||||
const out = args.out || 'out/clip.mp4'; mkdirSync(dirname(out), { recursive: true });
|
||||
const ff = spawn('ffmpeg', ['-y', '-loglevel', 'error', '-f', 'image2pipe', '-framerate', String(fps), '-c:v', 'mjpeg', '-i', '-',
|
||||
...(audio ? ['-ss', String(a), '-t', String(b - a), '-i', audio, '-map', '0:v', '-map', '1:a', '-c:a', 'aac', '-b:a', '192k', '-shortest'] : []),
|
||||
'-c:v', 'libx264', '-preset', 'medium', '-crf', '18', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', out],
|
||||
{ stdio: ['pipe', 'inherit', 'inherit'] });
|
||||
const n = Math.round((b - a) * fps), start = Date.now();
|
||||
for (let i = 0; i < n; i++) {
|
||||
const buf = await frameOf(page, a + i / fps, 'image/jpeg', .93);
|
||||
if (!ff.stdin.write(buf)) await new Promise(r => ff.stdin.once('drain', r));
|
||||
if (i % 24 === 0 || i === n - 1) console.log(`frame ${i + 1}/${n} ${((Date.now() - start) / (i + 1)).toFixed(0)} ms/frame`);
|
||||
}
|
||||
ff.stdin.end(); await new Promise(r => ff.on('close', r));
|
||||
console.log(`wrote ${out}`);
|
||||
} else {
|
||||
console.log('nothing to do: see the usage notes at the top of render.mjs');
|
||||
}
|
||||
await browser.close();
|
||||
@@ -1,59 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"60fps-animation": {
|
||||
"source": "iart-ai/web-animation-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/60fps-animation/SKILL.md",
|
||||
"computedHash": "d832ea4a0dc07c18483d4fd39352c65e4daa2f298998290730cf159df30f400e"
|
||||
},
|
||||
"accessible-animation": {
|
||||
"source": "iart-ai/web-animation-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/accessible-animation/SKILL.md",
|
||||
"computedHash": "46695e422851b64f2b680924584ba9f7690309c109b6d98925ef3021b139167c"
|
||||
},
|
||||
"ascii-animation": {
|
||||
"source": "iart-ai/web-animation-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/ascii-animation/SKILL.md",
|
||||
"computedHash": "cc219b1851f713f76934a2738ccdcec37d4a266f1d20ecc346487290cc40d2fb"
|
||||
},
|
||||
"glassmorphism": {
|
||||
"source": "iart-ai/web-animation-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/glassmorphism/SKILL.md",
|
||||
"computedHash": "c98e77044561fea5e51116156d0ba600a56f9961ed18b498d72504d49aff7882"
|
||||
},
|
||||
"gsap-web": {
|
||||
"source": "iart-ai/web-animation-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/gsap-web/SKILL.md",
|
||||
"computedHash": "11585ce3142acae8db34c9d0eb57e03834972f1095643266eb2c41b432564b1d"
|
||||
},
|
||||
"lottie-animation": {
|
||||
"source": "iart-ai/web-animation-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/lottie-animation/SKILL.md",
|
||||
"computedHash": "3b9838145f5af19d03014a5eca9041ae0ba267b77e312332a129f3fe40078f68"
|
||||
},
|
||||
"micro-interaction": {
|
||||
"source": "iart-ai/web-animation-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/micro-interaction/SKILL.md",
|
||||
"computedHash": "f236bc97a9f4c0e9c9a045d0b591e3ca6b6b2aeb26e42b661c15211f652ac131"
|
||||
},
|
||||
"page-transition-animation": {
|
||||
"source": "iart-ai/web-animation-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/page-transition-animation/SKILL.md",
|
||||
"computedHash": "62e103e2313433e77db26c67fdcae1c425030e1a51d0f5ffc0fb6fdac6d36132"
|
||||
},
|
||||
"svg-animation": {
|
||||
"source": "iart-ai/web-animation-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/svg-animation/SKILL.md",
|
||||
"computedHash": "b6902577f093a1cd724052b94e633154cb42020b5001138185982fb4ce230404"
|
||||
}
|
||||
}
|
||||
}
|
||||
+644
@@ -0,0 +1,644 @@
|
||||
// clawd.js: Clawd, painted in watercolour and ink. The silhouette is blocky: a 10×6 body, four stubby legs, two little
|
||||
// arm nubs and two slit eyes. `u` is the body unit (the body is 10u wide and 8u tall with legs). (x, y) is the point on
|
||||
// the ground between the feet.
|
||||
//
|
||||
// Body-local coordinates in the front view, used by the o.draw / o.armL / o.armR hooks:
|
||||
// body x -5u..5u, y -8u..-2u; eye centres (±2.5u, -6u), each 1u wide and 2u tall; mouth near (0, -4.3u); legs reach y 0.
|
||||
// Arms pivot at (±4.9u, -4.5u) and are 2.2u long. o.armL / o.armR are called at the arm TIP in arm space (x runs
|
||||
// outward along the arm), so a held prop just draws around (0, 0).
|
||||
//
|
||||
// Everything here draws one frame. Motion comes from what you pass in: feel() / emotions() for moods, move() for
|
||||
// dances, turn() for turns, and jump() / take() / stroll() / spring() from core.js for acting.
|
||||
|
||||
// ---------- views ----------
|
||||
// Turns are drawn KEY VIEWS, like a cartoon model sheet, never a rotated 3D box. Every view faces screen-right;
|
||||
// flip: true mirrors it to face left. Numbers are in u.
|
||||
// L, R: silhouette edges. strip: the darker side face. seam: the edge between faces.
|
||||
// face: where the features go. A front-view x maps to cx + x·fw; sides = which eyes show; mx = mouth x; bx = cheek x.
|
||||
// legs: [x, far] (far legs are darker). arms: [pivotX, dir, which, layer] ('L' = o.aL / o.armL; layer 0 = behind the
|
||||
// body, 1 = in front of it, 2 = behind and in shadow). hat / hw: hat offset and width.
|
||||
const VIEWS = {
|
||||
front: { L: -5, R: 5, face: { cx: 0, fw: 1, sides: [-1, 1], mx: 0, bx: 3.6 }, hat: 0, hw: 1,
|
||||
legs: [[-4, 0], [-2, 0], [1, 0], [3, 0]], arms: [[-4.9, -1, 'L', 0], [4.9, 1, 'R', 0]] },
|
||||
q: { L: -5, R: 5.1, strip: [-5, -2.3], seam: -2.3, face: { cx: 1.5, fw: .74, sides: [-1, 1], mx: .3, bx: 3.6 }, hat: .6, hw: 1,
|
||||
legs: [[-4.3, 1], [-1.8, 0], [.8, 0], [3.3, 0]], arms: [[5, 1, 'R', 2], [-4.7, -1, 'L', 1]] },
|
||||
side: { L: -3.1, R: 3.1, face: { cx: 1.35, fw: .55, sides: [1], mx: 1.7, bx: 1 }, hat: .3, hw: .66,
|
||||
legs: [[-2.1, 1], [1.3, 1], [-2.6, 0], [.9, 0]], arms: [[1.6, 0, 'L', 1]] },
|
||||
qback: { L: -5.1, R: 5, strip: [2.3, 5], seam: 2.3, face: null, hat: -.4, hw: 1,
|
||||
legs: [[3.3, 1], [.8, 0], [-1.8, 0], [-4.3, 0]], arms: [[-5, -1, 'R', 2], [4.7, 1, 'L', 1]] },
|
||||
back: { L: -5, R: 5, face: null, hat: 0, hw: 1, back: true,
|
||||
legs: [[-4, 0], [-2, 0], [1, 0], [3, 0]], arms: [[-4.9, -1, 'R', 0], [4.9, 1, 'L', 0]] },
|
||||
};
|
||||
// Key view for a heading in turns: 0 = front, .25 = facing right, .5 = back, .75 (or -.25) = facing left.
|
||||
// Snaps to the nearest eighth, which is how a drawn turn works: front → q → side, one drawing per step.
|
||||
function spinView(a) {
|
||||
const K = [['front', 0], ['q', 0], ['side', 0], ['qback', 0], ['back', 0], ['qback', 1], ['side', 1], ['q', 1]];
|
||||
const [view, f] = K[((Math.round(a * 8) % 8) + 8) % 8]; return { view, flip: !!f };
|
||||
}
|
||||
// A turn from heading a0 to a1 (in turns) between t0 and t1. It steps through the key views and smears the
|
||||
// in-between drawings. Spread it into clawd(): clawd(x, y, u, { ...feel('happy', t), ...turn(t, 2, 2.2, 0, .25) }).
|
||||
function turn(t, t0, t1, a0, a1) {
|
||||
const k = ease(seg(t, t0, t1)), v = spinView(lerp(a0, a1, k));
|
||||
return { ...v, smear: t > t0 && t < t1 ? .55 * Math.sin(k * Math.PI) : 0, smearDir: 0 };
|
||||
}
|
||||
|
||||
// ---------- colour ----------
|
||||
// Tints shift the body colour with the mood. tint: a name here or any hex colour; tintK: 0..1 strength.
|
||||
const TINT = { pale: '#F6E6D2', flush: '#E23E36', blue: '#6D86BE', rosy: '#EF8EA8', green: '#98B25E', gold: '#F0BE46' };
|
||||
function tintCols(o) {
|
||||
const c = { col: o.col || PAL.clay, dk: o.dk || PAL.clayDk, lt: o.lt || '#F5B394' };
|
||||
const tc = o.tint && (TINT[o.tint] || o.tint), k = clamp(o.tintK ?? 1);
|
||||
if (!tc || k <= 0) return c;
|
||||
return { col: mixCol(c.col, tc, .55 * k), dk: mixCol(c.dk, mixCol(tc, PAL.ink, .35), .5 * k), lt: mixCol(c.lt, mixCol(tc, '#FFFFFF', .4), .45 * k) };
|
||||
}
|
||||
|
||||
// ---------- Clawd ----------
|
||||
// Options (all optional):
|
||||
// pose: dx, dy (in u; negative dy = up), sq (squash; negative stretches), rot (pivots at the feet), flip, sx, sy,
|
||||
// aL, aR (arm raise angles: 0 = straight out, + = up, - = down), walk (phase; the legs step), noLegs, noShadow
|
||||
// view: front | q | side | qback | back (see VIEWS). smear 0..1 + smearDir ±1 (0 = both sides) for fast moves
|
||||
// face: eyes (a name, or [left, right] for mismatched eyes), mouth, lookX / lookY (-1..1), squint 0..1, blush 0..1,
|
||||
// gloom 0..1 (dark forehead with gloom lines), lid 0..1 (lunchbox mouth, front view only), seed (blink timing)
|
||||
// colour: col / dk / lt, or tint + tintK
|
||||
// extras: hat, emote + emoteK (0..1 pop) + emoteAge (s since it appeared), draw(u, sw), armL(u, sw), armR(u, sw)
|
||||
// boil: boilKey (a stable id for its boil seeds; defaults to call order, so set it if characters come and go mid-shot)
|
||||
function clawd(x, y, u, o = {}) {
|
||||
// each part boils from its own seed (see boilSeed), so a moving arm never re-boils the body, or the next character
|
||||
const id = o.boilKey ?? ++CLAWD_N, rs = part => boilSeed(`clawd ${id} ${part}`);
|
||||
x += (o.dx || 0) * u;
|
||||
const V = VIEWS[o.view] || VIEWS.front;
|
||||
const dy = (o.dy || 0) * u, sq = (o.sq || 0) + (o.take || 0), sm = clamp(o.smear || 0);
|
||||
const sw = clamp(u / 15, .45, 2.4) * (o.swMul || 1), J = u * .07;
|
||||
const { col, dk, lt } = tintCols(o), far = mixCol(dk, PAL.ink, .22);
|
||||
|
||||
rs('shadow');
|
||||
if (!o.noShadow) {
|
||||
const f = 1 - Math.min(.5, Math.abs(o.dy || 0) * .06), w = (V.R - V.L) / 10;
|
||||
paint(ellPts(x, y + u * .15, u * 5.6 * f * w, u * f, 22), { fill: PAL.ink, fillOp: 90, bleed: .25, tex: .3, border: .1, ink: null });
|
||||
}
|
||||
if (sm > .05) smearTrail(x, y + dy, u, V, sm, o.smearDir ?? (o.flip ? -1 : 1), col);
|
||||
|
||||
push();
|
||||
translate(x, y + dy);
|
||||
if (o.rot) rotate(o.rot);
|
||||
scale((o.flip ? -1 : 1) * (o.sx ?? 1) * (1 + sq * .6) * (1 + sm * .35), (o.sy ?? 1) * (1 - sq));
|
||||
|
||||
const arm = ([px, dir, which, layer]) => {
|
||||
rs('arm' + which);
|
||||
const a = which === 'L' ? (o.aL ?? .2) : (o.aR ?? .2), hook = which === 'L' ? o.armL : o.armR;
|
||||
// a raised (or dropped) arm slides its root out to the body's edge, so it stands beside the body instead of behind it
|
||||
push(); translate((px + dir * .55 * clamp((Math.abs(a) - .7) / .9)) * u, -4.5 * u);
|
||||
if (dir === 0) { // side view: the near arm reaches forward past the front edge; a raises it, a < 0 lowers it
|
||||
translate(0, .3 * u); rotate(.7 - a);
|
||||
paint(rectPts(-.2 * u, -.45 * u, 2.3 * u, .9 * u, J * .6), { wash: col, washOp: 255, fill: dk, fillOp: 60, tex: .5, ink: PAL.ink, sw: sw * .8 });
|
||||
if (hook) { translate(2.1 * u, 0); hook(u, sw); }
|
||||
} else {
|
||||
rotate(dir < 0 ? a : -a);
|
||||
paint(rectPts(dir < 0 ? -2.2 * u : 0, -.5 * u, 2.2 * u, u, J * .6), { wash: layer === 2 ? mixCol(col, dk, .4) : col, washOp: 255, fill: dk, fillOp: 60, tex: .5, ink: PAL.ink, sw: sw * .8 });
|
||||
if (hook) { translate(dir * 2.2 * u, 0); if (dir < 0) scale(-1, 1); hook(u, sw); }
|
||||
}
|
||||
pop();
|
||||
};
|
||||
|
||||
// arms behind, then legs (under the body), then the body, then arms in front
|
||||
V.arms.filter(a => a[3] !== 1).forEach(arm);
|
||||
if (!o.noLegs) V.legs.forEach(([lx, isFar], i) => {
|
||||
rs('leg' + i);
|
||||
let h = 2.2, sx = 0;
|
||||
if (o.walk != null) {
|
||||
if (o.view === 'side') { // profile: diagonal pairs swing together, lifting as they swing forward
|
||||
const ph = (o.walk + [0, .5, .5, 0][i]) * TAU;
|
||||
sx = Math.sin(ph) * .55; h = 2.2 - Math.max(0, Math.cos(ph)) * .8;
|
||||
} else { const ph = Math.sin((o.walk + (i % 2 ? .5 : 0)) * TAU); if (ph > 0) h = 2.2 - ph * .9; }
|
||||
}
|
||||
paint(rectPts((lx + sx) * u, -2.4 * u, u, h * u, J * .6), { wash: isFar ? far : dk, washOp: 255, ink: PAL.ink, sw: sw * .8 });
|
||||
});
|
||||
|
||||
rs('body');
|
||||
const lid = V === VIEWS.front ? o.lid || 0 : 0;
|
||||
if (lid > .01) lunchbox(u, o, sw, J, lid, col, dk, lt);
|
||||
else {
|
||||
const L = V.L * u, R = V.R * u, wk = (V.R - V.L) / 10, body = rectPts(L, -8 * u, R - L, 6 * u, J);
|
||||
paint(body, { wash: col, washOp: 255, ink: null });
|
||||
const px = V.strip ? (V.strip[0] < 0 ? (V.seam + V.R) / 2 - 1.2 : (V.L + V.seam) / 2 - .6) : -1.6 * wk;
|
||||
paint(ellPts(px * u, -6.4 * u, 3.4 * u * (V.strip ? .75 : wk), 1.5 * u, 18, J * 2, -.08), { fill: lt, fillOp: V.back ? 80 : 120, bleed: .2, tex: .85, border: .8, ink: null });
|
||||
paint(rectPts(L + .2 * u, -3.8 * u, R - L - .4 * u, 1.6 * u, J), { fill: dk, fillOp: 120, bleed: .03, tex: .7, border: .5, ink: null });
|
||||
if (V.strip) {
|
||||
paint(rectPts(V.strip[0] * u, -8 * u, (V.strip[1] - V.strip[0]) * u, 6 * u, J * .5), { fill: dk, fillOp: 150, bleed: .04, tex: .6, border: .4, ink: null });
|
||||
inkLine([[V.seam * u + jit(J), -7.9 * u], [V.seam * u + jit(J), -5 * u], [V.seam * u + jit(J), -2.1 * u]], sw * .55, PAL.ink, 'inkfine', 0);
|
||||
}
|
||||
paint(body, { ink: PAL.ink, sw });
|
||||
if (o.gloom > .02) gloom(u, sw, V, o.gloom);
|
||||
if (V.face) {
|
||||
const F = V.face;
|
||||
push(); translate(F.cx * u, 0); scale(F.fw, 1);
|
||||
if (o.blush) blush(u, sw, F, o.blush === true ? 1 : o.blush);
|
||||
if (o.hat === 'mask' && F.sides.length > 1) paint([[-5.5 * u, -7.7 * u], [5.5 * u, -7.7 * u], [4.4 * u, -4.7 * u], [.6 * u, -5.4 * u], [-.6 * u, -5.4 * u], [-4.4 * u, -4.7 * u]], { wash: PAL.violet, ink: PAL.ink, sw: sw * .7 });
|
||||
rs('eyes'); eyes(u, o, sw, F.sides, sm); rs('mouth');
|
||||
push(); translate(F.mx * u, 0); mouth(u, o.mouth, sw); pop();
|
||||
if (['cat', 'masq', 'bowtie'].includes(o.hat)) faceHat(u, o.hat, sw, F.sides);
|
||||
pop();
|
||||
}
|
||||
rs('hat'); push(); translate(V.hat * u, 0); scale(V.hw, 1); hat(u, o.hat, sw); pop();
|
||||
}
|
||||
V.arms.filter(a => a[3] === 1).forEach(arm);
|
||||
rs('draw'); if (o.draw) o.draw(u, sw);
|
||||
pop();
|
||||
|
||||
rs('emote');
|
||||
if (o.emote) {
|
||||
const top = EMOTE_TOP.includes(o.emote), dir = o.flip ? -1 : 1;
|
||||
const ex = top ? x + dir * V.hat * u : x + dir * (V.R + .4) * u, ey = y + dy + (top ? -10.4 : -8.6) * u * (1 - sq);
|
||||
emote(o.emote, ex, ey, u * .9, o.emoteK ?? 1, o.emoteAge ?? T);
|
||||
}
|
||||
rs('after'); // so whatever is drawn next doesn't depend on this pose
|
||||
}
|
||||
|
||||
// The lunchbox mouth: the top 2.9u of the body hinges open at the back-left corner (front view only). The mouth is the
|
||||
// dark wedge between jaw and lid; both rows of teeth point INTO it, staggered, and each tooth is sized to the gap at
|
||||
// its spot, so they never cross, even when the lid is only open a crack.
|
||||
function lunchbox(u, o, sw, J, lid, col, dk, lt) {
|
||||
const hy = -5.1 * u, A = lid * 1.25, gap = x => (x + 5 * u) * Math.sin(A);
|
||||
const tooth = (tx, dir) => { // base on the jaw line from tx to tx + 1.3u, tip pointing up (dir -1) or down (dir 1)
|
||||
const h = Math.min(.8 * u, .42 * gap(tx + .65 * u)); if (h < .12 * u) return;
|
||||
paint([[tx, hy], [tx + 1.3 * u, hy], [tx + .65 * u, hy + dir * h]], { wash: PAL.cream, ink: PAL.ink, sw: sw * .45 });
|
||||
};
|
||||
// lower jaw
|
||||
paint(rectPts(-5 * u, hy, 10 * u, 3.1 * u, J), { wash: col, washOp: 255, ink: null });
|
||||
paint(rectPts(-4.8 * u, -3.8 * u, 9.6 * u, 1.6 * u, J), { fill: dk, fillOp: 120, bleed: .03, tex: .7, border: .5, ink: null });
|
||||
// the open mouth: a dark wedge from the hinge to the lid's far corner, with the tongue lying in it
|
||||
paint([[-5 * u, hy], [5 * u, hy], [-5 * u + 10 * u * Math.cos(A), hy - 10 * u * Math.sin(A)]], { wash: '#4A1F2A', ink: null });
|
||||
const th = Math.min(.5 * u, .3 * gap(1.8 * u));
|
||||
if (th > .08 * u) paint(ellPts(1.8 * u, hy - th * .7, 2.2 * u, th, 16), { wash: PAL.rose, fill: '#E2476E', fillOp: 60, ink: null });
|
||||
for (let i = 0; i < 6; i++) tooth(-4.4 * u + i * 1.6 * u, -1);
|
||||
paint(rectPts(-5 * u, hy, 10 * u, 3.1 * u, J), { ink: PAL.ink, sw });
|
||||
// lid
|
||||
push(); translate(-5 * u, hy); rotate(-A); translate(5 * u, -hy);
|
||||
paint(rectPts(-5 * u, -8 * u, 10 * u, 2.9 * u, J), { wash: col, washOp: 255, ink: null });
|
||||
paint(ellPts(-1.6 * u, -6.9 * u, 3.2 * u, 1 * u, 16, J), { fill: lt, fillOp: 110, bleed: .15, tex: .8, border: .8, ink: null });
|
||||
for (let i = 0; i < 5; i++) tooth(-3.6 * u + i * 1.6 * u, 1);
|
||||
paint(rectPts(-5 * u, -8 * u, 10 * u, 2.9 * u, J), { ink: PAL.ink, sw });
|
||||
if (o.gloom > .02) gloom(u, sw, VIEWS.front, o.gloom);
|
||||
eyes(u, o, sw, [-1, 1], 0);
|
||||
hat(u, o.hat, sw);
|
||||
pop();
|
||||
}
|
||||
|
||||
// A smear drawing: dry-brush streaks and speed lines trailing a fast move (dir ±1), or on both sides for a spin (0).
|
||||
function smearTrail(x, y, u, V, k, dir, col) {
|
||||
const sides = dir === 0 ? [-1, 1] : [-dir];
|
||||
for (const s of sides) for (let i = 0; i < 4; i++) {
|
||||
const yy = y - (7.2 - i * 1.5) * u, x0 = x + s * (V.R - .5) * u, len = (3 + 3 * hash(i + 7 * s)) * u * k;
|
||||
inkLine([[x0, yy], [x0 + s * len * .5, yy + jit(u * .1)], [x0 + s * len, yy]], 2.2 * k, col, 'dry', .3);
|
||||
if (i % 2) inkLine([[x0 + s * len * .3, yy + .5 * u], [x0 + s * len * 1.1, yy + .5 * u]], .6, PAL.ink, 'inkfine', 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Gloom: a dark wash over the forehead with hanging gloom lines (despair, dread, a guilty conscience).
|
||||
function gloom(u, sw, V, g) {
|
||||
const L = V.L * u, R = V.R * u;
|
||||
paint(rectPts(L + .15 * u, -7.9 * u, R - L - .3 * u, 2.6 * u, u * .05), { fill: PAL.indigo, fillOp: 150 * g, bleed: .08, tex: .5, border: .6, ink: null });
|
||||
const n = Math.round((V.R - V.L) * .7);
|
||||
for (let i = 0; i < n; i++) { const gx = lerp(L + .7 * u, R - .7 * u, i / Math.max(1, n - 1)); inkLine([[gx, -7.8 * u], [gx + jit(u * .05), (-7.8 + 2.4 * g * (.7 + .3 * hash(i))) * u]], sw * .45, PAL.ink, 'inkfine', 0); }
|
||||
}
|
||||
|
||||
function blush(u, sw, F, b) {
|
||||
for (const s of F.sides) {
|
||||
const bx = s * F.bx * u;
|
||||
paint(ellPts(bx, -4.6 * u, u * .85, u * .42, 14), { fill: PAL.rose, fillOp: 170 * clamp(b), bleed: .2, ink: null });
|
||||
if (b > .6) for (let k = 0; k < 3; k++) inkLine([[bx - .5 * u + k * .4 * u, -4.35 * u], [bx - .3 * u + k * .4 * u, -4.85 * u]], sw * .4, mixCol(PAL.rose, PAL.ink, .3), 'inkfine', 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- eyes ----------
|
||||
// normal, look, wide, happy, closed, sleepy, wink, narrow, angry, determined, sad, teary, cry, squeeze, shine,
|
||||
// scared, blank, spark, red, heart, x, swirl, dot, shades. o.eyes may be a pair: ['narrow', 'wide'].
|
||||
function eyes(u, o, sw, sides, smear = 0) {
|
||||
const kinds = Array.isArray(o.eyes) ? o.eyes : o.eyes === 'wink' ? ['happy', 'normal'] : [o.eyes || 'normal', o.eyes || 'normal'];
|
||||
const sqz = clamp(o.squint || 0);
|
||||
if (smear > .5) { for (const s of sides) inkLine([[s * 2.5 * u - .9 * u, -6 * u], [s * 2.5 * u + .9 * u, -6 * u]], sw * 1.4, PAL.ink, 'ink', 0); return; }
|
||||
if (sqz > .8) { for (const s of sides) inkLine([[s * 2.5 * u - .8 * u, -5.9 * u], [s * 2.5 * u + .8 * u, -5.9 * u]], sw, PAL.ink, 'ink', 0); return; }
|
||||
if (kinds[0] === 'shades') { // sunglasses: two dark lenses, a bridge, arms running back along the head
|
||||
const P = pts => pts.map(([a, b]) => [a * u, b * u]);
|
||||
const lens = cx => P([[cx - 1.4, -7.1], [cx + 1.4, -7.1], [cx + 1.3, -6.2], [cx + .8, -5.35], [cx, -5.2], [cx - .8, -5.35], [cx - 1.3, -6.2]]);
|
||||
if (sides.length > 1) {
|
||||
for (const s of [-1, 1]) inkLine(P([[s * 3.9, -6.85], [s * 5, -6.95]]), sw * .8, PAL.ink, 'ink', 0);
|
||||
inkLine(P([[-1.15, -6.75], [0, -7.05], [1.15, -6.75]]), sw * .8, PAL.ink, 'ink', .5);
|
||||
} else inkLine(P([[1.1, -6.85], [-5, -6.95]]), sw * .8, PAL.ink, 'ink', 0); // profile: the arm runs back to the ear
|
||||
for (const s of sides) {
|
||||
const cx = s * 2.5;
|
||||
paint(lens(cx), { wash: '#2A2740', ink: PAL.ink, sw: sw * .9, curv: .3 });
|
||||
inkLine(P([[cx - .85, -6.35], [cx - .25, -6.85]]), sw * .45, PAL.cream, 'inkfine', 0); // glint
|
||||
inkLine(P([[cx - .45, -5.85], [cx - .05, -6.2]]), sw * .3, PAL.cream, 'inkfine', 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (sqz > 0) { push(); translate(0, -6 * u); scale(1 + sqz * .15, 1 - sqz); translate(0, 6 * u); }
|
||||
for (const s of sides) { push(); translate(s * 2.5 * u, -6 * u); eye(kinds[s < 0 ? 0 : 1], s, u, o, sw); pop(); }
|
||||
if (sqz > 0) pop();
|
||||
}
|
||||
|
||||
// One eye, drawn around its centre. s = -1 for the left eye, 1 for the right.
|
||||
function eye(e, s, u, o, sw) {
|
||||
const lx = (o.lookX || 0) * u * .5, ly = (o.lookY || 0) * u * .4;
|
||||
const slit = (w, h) => {
|
||||
paint(rectPts(-w / 2 * u + lx, -h / 2 * u + ly, w * u, h * u, u * .04), { wash: PAL.ink, ink: null });
|
||||
if (u > 9) paint(ellPts(lx - w * .18 * u, ly - h * .29 * u, u * .17 * w, u * .24 * w, 10), { wash: PAL.cream, washOp: 230, ink: null });
|
||||
};
|
||||
const blinkOn = ['normal', 'look', 'wide'].includes(e) && ((T * .9 + (o.seed || 0) * 1.7) % 3.3) < .12;
|
||||
if (blinkOn) { inkLine([[-.7 * u, .5 * u], [.7 * u, .5 * u]], sw, PAL.ink, 'ink', 0); return; }
|
||||
const lineEye = (pts, w = 1.3, c = .2) => inkLine(pts.map(([a, b]) => [a * u, b * u]), sw * w, PAL.ink, 'ink', c);
|
||||
switch (e) {
|
||||
case 'normal': case 'look': slit(1, 2); break;
|
||||
case 'wide': slit(1.25, 2.7); break;
|
||||
case 'happy': lineEye([[-.9, .7], [0, -.5], [.9, .7]]); break;
|
||||
case 'closed': lineEye([[-.9, .2], [0, .6], [.9, .2]], 1.2, .4); break;
|
||||
case 'sleepy':
|
||||
paint(rectPts(-.5 * u + lx, .05 * u, u, .95 * u, u * .03), { wash: PAL.ink, ink: null });
|
||||
lineEye([[-.8, .05], [0, -.1], [.8, .1]], 1.1, .4); break;
|
||||
case 'narrow': paint(rectPts(-.6 * u + lx, -.1 * u + ly, 1.2 * u, .7 * u, u * .03), { wash: PAL.ink, ink: null }); break;
|
||||
case 'angry': case 'determined': { // top edge slants DOWN toward the middle
|
||||
const hi = e === 'angry' ? .75 : .5, top = e === 'angry' ? -.7 : -.55;
|
||||
const P = [[-.65, s < 0 ? top : top + hi], [.65, s < 0 ? top + hi : top], [.65, 1], [-.65, 1]];
|
||||
paint(P.map(([a, b]) => [a * u + lx, b * u + ly]), { wash: PAL.ink, ink: null });
|
||||
if (e === 'determined' && u > 9) paint(ellPts(lx - .2 * u, ly + .25 * u, u * .16, u * .2, 8), { wash: PAL.cream, ink: null });
|
||||
break;
|
||||
}
|
||||
case 'sad': case 'teary': { // top edge slants UP toward the middle (worried)
|
||||
const P = [[-.6, s < 0 ? -.2 : -.85], [.6, s < 0 ? -.85 : -.2], [.6, .9], [-.6, .9]];
|
||||
paint(P.map(([a, b]) => [a * u + lx, b * u + ly]), { wash: PAL.ink, ink: null });
|
||||
if (u > 9) paint(ellPts(lx - .18 * u, ly + .05 * u, u * .16, u * .22, 8), { wash: PAL.cream, ink: null });
|
||||
if (e === 'teary') {
|
||||
const w = Math.sin(T * 9 + s) * .06 * u;
|
||||
paint(ellPts(0, .95 * u + w, .95 * u, .38 * u, 14), { wash: PAL.sky, washOp: 210, fill: '#FFFFFF', fillOp: 60, ink: PAL.ink, sw: sw * .4 });
|
||||
paint(ellPts(-.35 * u, .85 * u + w, .18 * u, .1 * u, 8), { wash: '#FFFFFF', ink: null });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'cry': { // squeezed shut, with tear streams running down the body and drops flicking off
|
||||
lineEye([[-.9, .45], [-.3, -.05], [.3, .15], [.9, -.2]].map(([a, b]) => [a * -s, b]), 1.3, .3);
|
||||
const R = []; for (let k = 0; k <= 5; k++) R.push([(s * .55 + Math.sin(T * 8 + k * 1.3 + s) * .12 * k / 5) * u, (.4 + k * .72) * u]);
|
||||
paint(ribbon(R, .45 * u, .8 * u), { wash: PAL.sky, washOp: 230, fill: '#FFFFFF', fillOp: 70, ink: PAL.ink, sw: sw * .45 });
|
||||
for (let k = 0; k < 2; k++) {
|
||||
const ph = frac(T * 1.8 + k * .5 + (s > 0 ? .25 : 0)), p = arcPt([s * .9 * u, .2 * u], [s * 3.6 * u, 1.8 * u], 1.6 * u, ph);
|
||||
paint(ellPts(p[0], p[1], .22 * u * (1 - ph * .5), .3 * u * (1 - ph * .5), 8), { wash: PAL.sky, ink: PAL.ink, sw: sw * .35 });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'squeeze': lineEye([[-.55 * -s, -.7], [.55 * -s, 0], [-.55 * -s, .7]], 1.3, 0); break; // > <
|
||||
case 'shine':
|
||||
paint(ellPts(lx * .6, ly * .6, u * .78, u * 1.12, 16), { wash: PAL.ink, ink: null });
|
||||
paint(ellPts(lx * .6 - .25 * u, ly * .6 - .45 * u, u * .3, u * .38, 10), { wash: PAL.cream, ink: null });
|
||||
paint(ellPts(lx * .6 + .25 * u, ly * .6 + .45 * u, u * .14, u * .14, 8), { wash: PAL.cream, ink: null });
|
||||
break;
|
||||
case 'scared':
|
||||
paint(ellPts(0, 0, u * .95, u * 1.15, 16), { wash: PAL.cream, ink: PAL.ink, sw: sw * .6 });
|
||||
paint(ellPts((o.lookX || 0) * u * .3 + Math.sin(T * 40) * .04 * u, .1 * u, u * .3, u * .4, 10), { wash: PAL.ink, ink: null });
|
||||
break;
|
||||
case 'blank': paint(ellPts(0, 0, u * .8, u * 1.05, 14), { wash: PAL.cream, ink: PAL.ink, sw: sw * .7 }); break;
|
||||
case 'spark':
|
||||
paint(ellPts(0, 0, u * 1.5, u * 1.5, 18), { fill: PAL.ochre, fillOp: 90, bleed: .3, ink: null });
|
||||
paint(starPts(0, 0, u * 1.35 * (1 + .12 * Math.sin(T * 14))), { wash: PAL.cream, fill: PAL.ochre, fillOp: 80, ink: PAL.ink, sw: sw * .55 });
|
||||
break;
|
||||
case 'red':
|
||||
paint(ellPts(0, 0, u * 1.7, u * 1.7, 18), { fill: '#E0283F', fillOp: 110, bleed: .35, ink: null });
|
||||
paint(rectPts(-.6 * u, -u, 1.2 * u, 2 * u, u * .05), { wash: '#FF2F4A', ink: PAL.ink, sw: sw * .5 });
|
||||
break;
|
||||
case 'heart': paint(heartPts(0, .1 * u, u * .9 * (1 + .1 * pulse(T))), { wash: '#E2476E', ink: PAL.ink, sw: sw * .5 }); break;
|
||||
case 'x': lineEye([[-.8, -.8], [.8, .8]], 1, 0); lineEye([[.8, -.8], [-.8, .8]], 1, 0); break;
|
||||
case 'swirl': {
|
||||
const sp = []; for (let k = 0; k < 16; k++) { const a = k * .7 + T * 6 * s, r = k * .06 * u; sp.push([Math.cos(a) * r, Math.sin(a) * r]); }
|
||||
inkLine(sp, sw * .6, PAL.ink, 'inkfine', .6); break;
|
||||
}
|
||||
case 'dot': paint(ellPts(lx * .5, ly * .5, u * .45, u * .55, 12), { wash: PAL.ink, ink: null }); break;
|
||||
default: slit(1, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- mouths ----------
|
||||
// o, O, smile, grin, flat, wobble, cat, frown, smirk, laugh, open, wail, teeth, tongue, pout, yawn
|
||||
function mouth(u, m, sw) {
|
||||
if (!m) return;
|
||||
const P = pts => pts.map(([a, b]) => [a * u, b * u]), dark = '#4A1F2A';
|
||||
const line = (pts, w = .8, c = .6) => inkLine(P(pts), sw * w, PAL.ink, 'ink', c);
|
||||
switch (m) {
|
||||
case 'o': paint(ellPts(0, -4.3 * u, u * .45, u * .5, 12), { wash: PAL.ink, ink: null }); break;
|
||||
case 'O': paint(ellPts(0, -4.1 * u, u * .8, u * .95, 14), { wash: dark, ink: PAL.ink, sw: sw * .6 }); break;
|
||||
case 'smile': line([[-.8, -4.6], [0, -4.1], [.8, -4.6]]); break;
|
||||
case 'frown': line([[-.8, -4.1], [0, -4.6], [.8, -4.1]]); break;
|
||||
case 'grin': paint(P([[-1.3, -4.8], [1.3, -4.8], [.9, -3.9], [-.9, -3.9]]), { wash: dark, ink: PAL.ink, sw: sw * .6, curv: .3 }); break;
|
||||
case 'flat': line([[-.7, -4.4], [.7, -4.4]], .8, 0); break;
|
||||
case 'wobble': line([[-1, -4.4], [-.5, -4.7], [0, -4.4], [.5, -4.7], [1, -4.4]], .7, .3); break;
|
||||
case 'cat': line([[-.9, -4.5], [-.45, -4.1], [0, -4.5], [.45, -4.1], [.9, -4.5]], .7, .5); break;
|
||||
case 'smirk': line([[-.8, -4.35], [.2, -4.3], [.9, -4.75]], .8, .5); break;
|
||||
case 'laugh':
|
||||
paint(P([[-1.4, -4.9], [1.4, -4.9], [1, -4], [0, -3.4], [-1, -4]]), { wash: dark, ink: PAL.ink, sw: sw * .6, curv: .4 });
|
||||
paint(ellPts(0, -3.85 * u, .7 * u, .32 * u, 12), { wash: PAL.rose, ink: null }); break;
|
||||
case 'open':
|
||||
paint(ellPts(0, -4.25 * u, u * .75, u * .6, 14), { wash: dark, ink: PAL.ink, sw: sw * .6 });
|
||||
paint(ellPts(0, -3.95 * u, u * .45, u * .2, 10), { wash: PAL.rose, ink: null }); break;
|
||||
case 'wail': {
|
||||
const w = Math.sin(T * 30) * .06;
|
||||
paint(P([[-1.5, -4.5 + w], [-.5, -4.9], [.5, -4.9 - w], [1.5, -4.5], [1.2, -3.3], [-1.2, -3.3]]), { wash: dark, ink: PAL.ink, sw: sw * .6, curv: .3 });
|
||||
paint(ellPts(0, -3.6 * u, .8 * u, .25 * u, 12), { wash: PAL.rose, ink: null }); break;
|
||||
}
|
||||
case 'teeth':
|
||||
paint(rectPts(-1.3 * u, -4.85 * u, 2.6 * u, .95 * u, u * .04), { wash: PAL.cream, ink: PAL.ink, sw: sw * .6 });
|
||||
line([[-1.25, -4.38], [1.25, -4.38]], .4, 0);
|
||||
for (const tx of [-.65, 0, .65]) line([[tx, -4.8], [tx, -3.95]], .35, 0);
|
||||
break;
|
||||
case 'tongue':
|
||||
paint(P([[.05, -4.3], [.75, -4.3], [.72, -3.75], [.4, -3.55], [.08, -3.75]]), { wash: PAL.rose, ink: PAL.ink, sw: sw * .5, curv: .5 });
|
||||
line([[-.8, -4.6], [0, -4.2], [.8, -4.5]]); break;
|
||||
case 'pout': line([[-.45, -4.2], [0, -4.5], [.45, -4.2]], 1, .6); line([[-.25, -4.0], [0, -3.9], [.25, -4.0]], .6, .6); break;
|
||||
case 'yawn':
|
||||
paint(ellPts(0, -4.1 * u, u * .6, u * 1.05, 14), { wash: dark, ink: PAL.ink, sw: sw * .6 });
|
||||
paint(ellPts(0, -3.45 * u, u * .38, u * .25, 10), { wash: PAL.rose, ink: null }); break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- hats ----------
|
||||
// party, hard, crown, halo, wizard, hood, top, fedora, band, sweatband, beanie, bow, flower, headphones, cat (ears; the
|
||||
// whiskers show in the front and 3/4 views), plus face pieces for the front and 3/4 views: masq, mask, bowtie
|
||||
function hat(u, h, sw) {
|
||||
if (!h || h === 'mask' || h === 'masq' || h === 'bowtie') return;
|
||||
const P = pts => pts.map(([a, b]) => [a * u, b * u]);
|
||||
if (h === 'party') {
|
||||
paint(P([[-1.8, -7.9], [0, -12.8], [1.8, -7.9]]), { wash: PAL.rose, fill: PAL.violet, fillOp: 50, ink: PAL.ink, sw: sw * .8 });
|
||||
paint(ellPts(0, -12.8 * u, u * .75, u * .75, 12), { wash: PAL.ochre, ink: PAL.ink, sw: sw * .6 });
|
||||
} else if (h === 'hard') {
|
||||
const d = []; for (let i = 0; i <= 12; i++) { const a = Math.PI + i / 12 * Math.PI; d.push([Math.cos(a) * 3.5 * u, -8 * u + Math.sin(a) * 3.3 * u]); }
|
||||
paint(d, { wash: '#F2C53D', ink: PAL.ink, sw: sw * .8 });
|
||||
paint(rectPts(-4.9 * u, -8.6 * u, 9.8 * u, .9 * u), { wash: '#F2C53D', ink: PAL.ink, sw: sw * .8 });
|
||||
} else if (h === 'crown') {
|
||||
paint(P([[-3, -7.9], [-3, -10.8], [-1.5, -9.3], [0, -11.2], [1.5, -9.3], [3, -10.8], [3, -7.9]]), { wash: '#F2C53D', fill: PAL.ochre, fillOp: 90, ink: PAL.ink, sw: sw * .8 });
|
||||
for (const gx of [-1.5, 0, 1.5]) paint(ellPts(gx * u, -8.7 * u, u * .3, u * .3, 8), { wash: gx ? PAL.teal : PAL.rose, ink: null });
|
||||
} else if (h === 'halo') {
|
||||
brush.noFill(); brush.noWash(); brush.noHatch(); brush.set('ink', PAL.ochre, sw * 1.4);
|
||||
brush.beginShape(0); for (const p of ellPts(0, -10.4 * u, 3.2 * u, .8 * u, 20)) brush.vertex(p[0], p[1]); brush.endShape(true);
|
||||
} else if (h === 'wizard' || h === 'hood') {
|
||||
paint(P([[-3.9, -7.9], [.9, -15], [3.9, -7.9]]), { wash: h === 'hood' ? PAL.violet : PAL.indigo, fill: PAL.violet, fillOp: 80, ink: PAL.ink, sw: sw * .8 });
|
||||
paint(starPts(.2 * u, -10.6 * u, u * .9, .4, 5), { wash: PAL.ochre, ink: null });
|
||||
} else if (h === 'top') {
|
||||
paint(rectPts(-2.6 * u, -12.5 * u, 5.2 * u, 4.3 * u), { wash: PAL.ink, ink: null });
|
||||
paint(rectPts(-2.6 * u, -9.4 * u, 5.2 * u, .8 * u), { wash: '#C8324A', ink: null });
|
||||
paint(rectPts(-3.9 * u, -8.6 * u, 7.8 * u, .8 * u), { wash: PAL.ink, ink: null });
|
||||
} else if (h === 'fedora') {
|
||||
paint(P([[-2.8, -8.3], [-2.3, -10.8], [0, -10.2], [2.3, -10.8], [2.8, -8.3]]), { wash: '#3B3550', ink: PAL.ink, sw: sw * .8, curv: .3 });
|
||||
paint(rectPts(-2.8 * u, -9.2 * u, 5.6 * u, .8 * u), { wash: '#C8324A', ink: null });
|
||||
paint(ellPts(0, -8.2 * u, 4.6 * u, .6 * u, 20), { wash: '#3B3550', ink: PAL.ink, sw: sw * .8 });
|
||||
} else if (h === 'band' || h === 'sweatband') {
|
||||
paint(rectPts(-5.1 * u, -8.2 * u, 10.2 * u, .95 * u), { wash: h === 'band' ? '#D8394E' : PAL.cream, ink: PAL.ink, sw: sw * .5 });
|
||||
if (h === 'sweatband') for (let i = 0; i < 4; i++) inkLine([[-4 * u + i * 2.6 * u, -8.1 * u], [-3.6 * u + i * 2.6 * u, -7.4 * u]], sw * .4, '#D8394E', 'inkfine', 0);
|
||||
} else if (h === 'beanie') {
|
||||
const d = []; for (let i = 0; i <= 12; i++) { const a = Math.PI + i / 12 * Math.PI; d.push([Math.cos(a) * 3.9 * u, -8.4 * u + Math.sin(a) * 2.6 * u]); }
|
||||
paint(d, { wash: PAL.teal, fill: PAL.sky, fillOp: 60, ink: PAL.ink, sw: sw * .8 });
|
||||
paint(rectPts(-4.2 * u, -8.9 * u, 8.4 * u, 1.1 * u), { wash: mixCol(PAL.teal, PAL.ink, .2), ink: PAL.ink, sw: sw * .7 });
|
||||
paint(ellPts(0, -11.2 * u, .8 * u, .8 * u, 12), { wash: PAL.cream, ink: PAL.ink, sw: sw * .6 });
|
||||
} else if (h === 'bow') {
|
||||
for (const s of [-1, 1]) paint(P([[2.6, -8.1], [2.6 + s * 1.7, -9.3], [2.6 + s * 1.7, -7.1]]), { wash: PAL.rose, fill: '#E2476E', fillOp: 70, ink: PAL.ink, sw: sw * .6, curv: .3 });
|
||||
paint(ellPts(2.6 * u, -8.2 * u, .45 * u, .45 * u, 10), { wash: '#E2476E', ink: PAL.ink, sw: sw * .5 });
|
||||
} else if (h === 'flower') {
|
||||
const fx = -2.6 * u, fy = -8.6 * u;
|
||||
inkLine([[fx, fy], [fx + .3 * u, -7.9 * u]], sw * .6, PAL.sap, 'ink', 0);
|
||||
for (let i = 0; i < 5; i++) { const a = i / 5 * TAU + .3; paint(ellPts(fx + Math.cos(a) * .7 * u, fy + Math.sin(a) * .7 * u, .55 * u, .55 * u, 10), { wash: PAL.cream, ink: PAL.ink, sw: sw * .4 }); }
|
||||
paint(ellPts(fx, fy, .45 * u, .45 * u, 10), { wash: PAL.ochre, ink: PAL.ink, sw: sw * .4 });
|
||||
} else if (h === 'headphones') {
|
||||
inkLine(P([[-5.1, -5.8], [-4.6, -9.4], [0, -10.6], [4.6, -9.4], [5.1, -5.8]]), sw * 2.4, PAL.ink, 'ink', .6);
|
||||
for (const s of [-1, 1]) paint(rrPts((s < 0 ? -6.1 : 4.6) * u, -7.4 * u, 1.5 * u, 2.8 * u, .6 * u), { wash: PAL.violet, fill: PAL.rose, fillOp: 50, ink: PAL.ink, sw: sw * .7 });
|
||||
} else if (h === 'cat') {
|
||||
for (const s of [-1, 1]) {
|
||||
paint([[s * 4.9 * u, -7.9 * u], [s * 4.3 * u, -11 * u], [s * 1.9 * u, -7.9 * u]], { wash: PAL.clay, ink: PAL.ink, sw: sw * .8 });
|
||||
paint([[s * 4.2 * u, -8.1 * u], [s * 4 * u, -10 * u], [s * 2.8 * u, -8.1 * u]], { wash: PAL.rose, ink: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
function faceHat(u, h, sw, sides) {
|
||||
if (h === 'cat') {
|
||||
for (const s of sides) for (const k of [-.3, .3]) inkLine([[s * 4 * u, -4.7 * u + k * u], [s * 6.4 * u, -4.9 * u + k * 2 * u]], sw * .4, PAL.ink, 'inkfine', 0);
|
||||
} else if (h === 'masq') {
|
||||
paint([[-5.2, -7.3], [-2.4, -8], [0, -7.1], [2.4, -8], [5.2, -7.3], [4.2, -5.2], [1.4, -5.3], [0, -6], [-1.4, -5.3], [-4.2, -5.2]].map(([a, b]) => [a * u, b * u]), { wash: PAL.violet, fill: PAL.rose, fillOp: 60, ink: PAL.ink, sw: sw * .7, curv: .3 });
|
||||
for (const ex of [-2.5, 2.5]) paint(ellPts(ex * u, -6.4 * u, 1.1 * u, .6 * u, 12), { wash: PAL.ink, ink: null });
|
||||
} else if (h === 'bowtie') {
|
||||
for (const s of [-1, 1]) paint([[0, -2.9 * u], [s * 1.5 * u, -3.6 * u], [s * 1.5 * u, -2.1 * u]], { wash: PAL.rose, ink: PAL.ink, sw: sw * .5 });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- emotes ----------
|
||||
// Painted reaction marks, never lettering: ! ? !! !? zzz sweat spark heart hearts anger steam bulb dots scribble
|
||||
// music swirl stars cloud. k = 0..1 pop-in progress (with overshoot); age = seconds since it appeared (drives the
|
||||
// looping ones). Emotes in EMOTE_TOP sit over the head; the rest sit by its top corner.
|
||||
const EMOTE_TOP = ['steam', 'stars', 'cloud', 'bulb', 'scribble'];
|
||||
function emote(kind, x, y, s, k = 1, age = T) {
|
||||
const p = backOut(k); if (p < .02) return;
|
||||
const sw = clamp(s / 15, .4, 2), P = pts => pts.map(([a, b]) => [a * s, b * s]);
|
||||
const dot = (dx, col) => paint(ellPts(dx, 1.25 * s, .42 * s, .42 * s, 12), { wash: col, ink: PAL.ink, sw: sw * .7 });
|
||||
const bang = (dx, col) => {
|
||||
push(); translate(dx, 0); rotate(.1 + .08 * Math.sin(age * 16) * Math.exp(-age * 3));
|
||||
paint(P([[-.62, -2.3], [.62, -2.3], [.22, .45], [-.22, .45]]), { wash: col, fill: PAL.cream, fillOp: 70, ink: PAL.ink, sw: sw * .8, curv: .25 }); dot(0, col);
|
||||
pop();
|
||||
};
|
||||
const quest = (dx, col) => {
|
||||
push(); translate(dx, 0); rotate(-.08 + .08 * Math.sin(age * 12) * Math.exp(-age * 3));
|
||||
paint(ribbon(P([[-1, -1.3], [-.55, -2.15], [.35, -2.3], [1, -1.6], [.7, -.75], [.05, -.3], [0, .4]]), .75 * s, .5 * s), { wash: col, fill: PAL.cream, fillOp: 60, ink: PAL.ink, sw: sw * .8 });
|
||||
dot(0, col); pop();
|
||||
};
|
||||
push(); translate(x, y); scale(p);
|
||||
switch (kind) {
|
||||
case '!': bang(0, PAL.ochre); break;
|
||||
case '?': quest(0, PAL.sky); break;
|
||||
case '!!': bang(-.8 * s, PAL.ochre); bang(.9 * s, PAL.ochre); break;
|
||||
case '!?': bang(-.9 * s, PAL.ochre); quest(1 * s, PAL.sky); break;
|
||||
case 'zzz': for (let i = 0; i < 3; i++) { // painted Z shapes drifting up and away
|
||||
const ph = frac(age * .4 + i / 3), a = Math.sin(ph * Math.PI), zs = (.8 + ph * .7) * s * Math.min(1, a * 1.6);
|
||||
if (a < .12) continue;
|
||||
const zx = ph * 2.6 * s, zy = -ph * 4.2 * s, Z = [[-.6, -.6], [.6, -.6], [.6, -.28], [-.12, .3], [.6, .3], [.6, .6], [-.6, .6], [-.6, .28], [.12, -.3], [-.6, -.3]];
|
||||
push(); translate(zx, zy); rotate(-.15 + .1 * Math.sin(age * 2 + i));
|
||||
paint(Z.map(([a, b]) => [a * zs, b * zs]), { wash: PAL.cream, fill: PAL.sky, fillOp: 70, ink: PAL.ink, sw: sw * .7 });
|
||||
pop();
|
||||
} break;
|
||||
case 'sweat': for (const [dx, dy, r] of [[0, 0, 1], [1.6, 1.4, .7]])
|
||||
paint(P([[dx, dy - 1.6 * r], [dx + .9 * r, dy + .2], [dx, dy + .9 * r], [dx - .9 * r, dy + .2]]), { wash: PAL.sky, fill: '#FFFFFF', fillOp: 60, ink: PAL.ink, sw: sw * .6, curv: .7 });
|
||||
break;
|
||||
case 'spark': {
|
||||
const tw = 1 + .15 * Math.sin(age * 12);
|
||||
paint(starPts(0, 0, 1.6 * s * tw), { wash: PAL.cream, fill: PAL.ochre, fillOp: 80, ink: PAL.ink, sw: sw * .5 });
|
||||
paint(starPts(1.9 * s, 1.2 * s, .8 * s / tw), { wash: PAL.ochre, ink: PAL.ink, sw: sw * .4 });
|
||||
break;
|
||||
}
|
||||
case 'heart': paint(heartPts(0, 0, s * 1.8 * (1 + .12 * pulse(age + OFF))), { wash: '#E2476E', fill: PAL.rose, fillOp: 90, ink: PAL.ink, sw: sw * .6 }); break;
|
||||
case 'hearts': for (let i = 0; i < 3; i++) {
|
||||
const ph = frac(age * .55 + i / 3), a = Math.sin(ph * Math.PI); if (a < .1) continue;
|
||||
paint(heartPts((Math.sin(ph * 6 + i * 2) * .7 + i * .7 - .7) * s, -ph * 3.8 * s, s * (.5 + .6 * a)), { wash: '#E2476E', fill: PAL.rose, fillOp: 90, ink: PAL.ink, sw: sw * .5 });
|
||||
} break;
|
||||
case 'anger': { // the comic "vein pop": four curved strokes, each bowed in toward the centre
|
||||
const b = 1 + .15 * pulse(age + OFF, 8);
|
||||
for (let i = 0; i < 4; i++) { push(); rotate(i * Math.PI / 2 + Math.PI / 4); scale(b); inkLine(P([140, 160, 180, 200, 220].map(d => [2.1 + 1.3 * Math.cos(d * Math.PI / 180), 1.3 * Math.sin(d * Math.PI / 180)])), sw * 1.1, '#D8394E', 'ink', .6); pop(); }
|
||||
break;
|
||||
}
|
||||
case 'steam': for (const sd of [-1, 1]) for (let j = 0; j < 2; j++) {
|
||||
const ph = frac(age / .7 + j * .5 + (sd > 0 ? .25 : 0)), r = (.55 + ph * .9) * s, cx = sd * (3.2 + ph * 1.8) * s, cy = (1 - ph * 3) * s;
|
||||
const puff = []; for (let i = 0; i < 18; i++) { const a = i / 18 * TAU, bump = 1 + .22 * Math.abs(Math.sin(a * 2.5)); puff.push([cx + Math.cos(a) * r * bump, cy + Math.sin(a) * r * .8 * bump]); }
|
||||
paint(puff, { wash: PAL.cream, washOp: 255 * (1 - ph * .8), ink: ph < .6 ? PAL.ink : null, sw: sw * .5 });
|
||||
} break;
|
||||
case 'bulb': {
|
||||
const gl = .5 + .5 * Math.sin(age * 10);
|
||||
glow(0, -1.4 * s, 4.5 * s, '#FFD27A', .75 + .25 * gl);
|
||||
for (let i = 0; i < 7; i++) { const a = -Math.PI / 2 + (i - 3) * .45, r0 = 1.6 * s, r1 = (2.2 + .4 * gl) * s; inkLine([[Math.cos(a) * r0, -1.4 * s + Math.sin(a) * r0], [Math.cos(a) * r1, -1.4 * s + Math.sin(a) * r1]], sw * .8, PAL.ochre, 'ink', 0); }
|
||||
paint(ellPts(0, -1.4 * s, 1.15 * s, 1.2 * s, 16), { wash: '#FFE68A', fill: PAL.cream, fillOp: 90, ink: PAL.ink, sw: sw * .7 });
|
||||
paint(rectPts(-.5 * s, -.3 * s, s, .8 * s), { wash: '#9A93A8', ink: PAL.ink, sw: sw * .6 });
|
||||
break;
|
||||
}
|
||||
case 'dots': for (let i = 0; i < 3; i++) {
|
||||
const ph = frac(age / 1.8), q = backOut(clamp((ph - i * .22) * 6)); if (q < .02) continue;
|
||||
paint(ellPts((i - 1) * 1.3 * s, 0, .42 * s * q, .42 * s * q, 10), { wash: PAL.ink, ink: null });
|
||||
} break;
|
||||
case 'scribble': {
|
||||
const pts = []; for (let i = 0; i < 34; i++) { const a = i * .95, r = (1.1 + .5 * Math.sin(i * 1.7)) * s; pts.push([Math.cos(a) * r * 1.5 + jit(.2 * s), Math.sin(a) * r * .8 + jit(.2 * s)]); }
|
||||
inkLine(pts, sw * .8, PAL.ink, 'ink', .7); break;
|
||||
}
|
||||
case 'music': {
|
||||
const b = Math.sin(age * 5) * .3 * s;
|
||||
push(); translate(0, b); rotate(.1 * Math.sin(age * 5));
|
||||
paint(ellPts(0, 1.2 * s, .7 * s, .5 * s, 12, 0, -.3), { wash: PAL.ink, ink: null });
|
||||
inkLine(P([[.6, 1.1], [.6, -1.6], [1.6, -1]]), sw * .8, PAL.ink, 'ink', 0);
|
||||
pop();
|
||||
const b2 = Math.sin(age * 5 + 2) * .3 * s;
|
||||
paint(ellPts(2.2 * s, 2.2 * s + b2, .45 * s, .33 * s, 10, 0, -.3), { wash: PAL.ink, ink: null });
|
||||
inkLine([[2.6 * s, 2.15 * s + b2], [2.6 * s, .6 * s + b2]], sw * .6, PAL.ink, 'ink', 0);
|
||||
break;
|
||||
}
|
||||
case 'swirl': {
|
||||
const sp = []; for (let i = 0; i < 18; i++) { const a = i * .6 + age * 5, r = i * .09 * s; sp.push([Math.cos(a) * r, Math.sin(a) * r]); }
|
||||
inkLine(sp, sw * .7, PAL.violet, 'inkfine', .6); break;
|
||||
}
|
||||
case 'stars': for (let i = 0; i < 3; i++) { // little stars circling the head
|
||||
const a = age * 5 + i * TAU / 3, d = .75 + .25 * Math.sin(a);
|
||||
paint(starPts(Math.cos(a) * 4.2 * s, Math.sin(a) * s, .75 * s * d, .45, 5, age * 3), { wash: PAL.ochre, fill: PAL.cream, fillOp: 60, ink: PAL.ink, sw: sw * .5 });
|
||||
} break;
|
||||
case 'cloud': { // a little rain cloud of its own
|
||||
const bob = Math.sin(age * 2) * .15 * s, c = [];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const a = i / 40 * TAU, up = Math.sin(a) < 0, r = up ? 1 + .38 * Math.pow(Math.abs(Math.sin(a * 3)), .6) : 1;
|
||||
c.push([Math.cos(a) * 2.8 * s * (up ? r * .92 : 1), bob - .2 * s + Math.sin(a) * (up ? 1.35 : .7) * s * r]);
|
||||
}
|
||||
for (let i = 0; i < 6; i++) { const ph = frac(age * 2.4 + hash(i)), rx = (-2 + i * .8) * s, ry = (.7 + ph * 2) * s; inkLine([[rx, ry], [rx - .12 * s, ry + .7 * s]], sw * 1.1, mixCol(PAL.sky, PAL.indigo, .3), 'ink', 0); }
|
||||
paint(c, { wash: '#A3A8C4', fill: PAL.indigo, fillOp: 70, bleed: .1, ink: PAL.ink, sw: sw * .8, curv: .4 });
|
||||
break;
|
||||
}
|
||||
}
|
||||
pop();
|
||||
}
|
||||
// Heart outline points, centred at (cx, cy), about 2r wide.
|
||||
function heartPts(cx, cy, r, n = 22) {
|
||||
const p = []; for (let i = 0; i < n; i++) { const a = i / n * TAU; p.push([cx + 16 * Math.pow(Math.sin(a), 3) * r / 16, cy - (13 * Math.cos(a) - 5 * Math.cos(2 * a) - 2 * Math.cos(3 * a) - Math.cos(4 * a)) * r / 16]); } return p;
|
||||
}
|
||||
|
||||
// ---------- emotions ----------
|
||||
// Each emotion is a face AND a way of moving. body(t) returns pose offsets for clawd(), and every one is alive: it
|
||||
// moves on its own, at its own energy, locked to the beat (PROJECT.bpm). take = how big the reaction is when Clawd
|
||||
// switches INTO this emotion. fade = the emote is a one-off that fades out instead of staying.
|
||||
const _b = t => { const bp = bpOf(t), s1 = Math.sin(bp * Math.PI); return { bp, s1, ab: Math.abs(s1), hit: pulse(t), s2: Math.sin(bp * TAU), f: frac(bp) }; };
|
||||
const EMO = {
|
||||
neutral: { eyes: 'normal', take: .3, body: t => { const b = _b(t); return { dy: -.25 * b.ab, sq: .03 * b.hit, aL: .15 + .05 * b.s1, aR: .15 - .05 * b.s1 }; } },
|
||||
happy: { eyes: 'happy', mouth: 'smile', blush: .3, take: .6, body: t => { const b = _b(t); return { dy: -1.2 * b.ab, sq: .1 * b.hit, aL: .4 + .4 * b.s1, aR: .4 - .4 * b.s1 }; } },
|
||||
excited: { eyes: 'wide', mouth: 'open', emote: 'spark', take: 1, body: t => { const b = _b(t), h = Math.abs(b.s2); return { dy: -2.4 * h, sq: .16 * pulse2(t) - .06 * h, aL: 1 + .5 * Math.sin(b.bp * TAU * 2), aR: 1 - .5 * Math.sin(b.bp * TAU * 2) }; } },
|
||||
laugh: { eyes: 'squeeze', mouth: 'laugh', blush: .45, take: .8, body: t => { const c = Math.abs(Math.sin(t * TAU * 5)); return { dy: -.45 * c, sq: .08 * c - .04, rot: -.07 + .03 * Math.sin(t * TAU * 5), aL: -.35 + .12 * c, aR: -.35 + .12 * c }; } },
|
||||
love: { eyes: 'heart', mouth: 'cat', blush: .8, tint: 'rosy', tintK: .5, emote: 'hearts', take: .7, body: t => { const b = _b(t), s = Math.sin(b.bp * Math.PI / 2); return { rot: .08 * s, dx: .5 * s, dy: -.6 * b.ab, sq: .05 * b.hit, aL: -.1 + .15 * b.s1, aR: -.1 - .15 * b.s1 }; } },
|
||||
shy: { eyes: 'look', mouth: 'wobble', blush: 1, take: .3, body: t => { const b = _b(t), s = Math.sin(b.bp * Math.PI / 2); return { lookX: -.7, lookY: .8, sq: .06, rot: -.05 + .02 * s, dx: .15 * s, aL: -.55 + .15 * Math.sin(t * TAU * 1.5), aR: -.6 - .12 * Math.sin(t * TAU * 1.5) }; } },
|
||||
proud: { eyes: 'closed', mouth: 'smile', tint: 'gold', tintK: .3, emote: 'spark', take: .5, body: t => { const b = _b(t); return { sq: -.1 - .03 * b.ab, dy: -.3 * b.hit, aL: -.9, aR: -.9, rot: .02 * b.s1 }; } },
|
||||
smug: { eyes: 'narrow', mouth: 'smirk', take: .4, body: t => { const b = _b(t), s = Math.sin(b.bp * Math.PI / 2); return { lookX: .4, rot: -.06 + .03 * s, dy: -.2 * b.ab, aL: -.5, aR: .5 + .15 * s }; } },
|
||||
relieved: { eyes: 'closed', mouth: 'smile', emote: 'sweat', take: .4, body: t => { const b = _b(t), br = Math.sin(t * TAU * .35); return { sq: .05 + .05 * br, dy: -.15 * b.ab, aL: -.7 + .1 * br, aR: -.7 + .1 * br }; } },
|
||||
sad: { eyes: 'sad', mouth: 'frown', tint: 'blue', tintK: .35, gloom: .35, emote: 'cloud', take: .3, body: t => { const b = _b(t), s = Math.sin(b.bp * Math.PI / 4); return { sq: .08 + .02 * s, rot: .03 * s, aL: -.75, aR: -.75, lookY: .5 }; } },
|
||||
cry: { eyes: 'cry', mouth: 'wail', tint: 'blue', tintK: .2, take: .8, body: t => { const b = _b(t), sob = Math.sin(b.f * Math.PI) * Math.exp(-b.f * 2); return { sq: .14 * sob - .02, dy: -.7 * sob, aL: .9 + .15 * Math.sin(t * TAU * 6), aR: .9 - .15 * Math.sin(t * TAU * 6), rot: .03 * b.s1 }; } },
|
||||
angry: { eyes: 'angry', mouth: 'teeth', tint: 'flush', tintK: .45, emote: 'anger', take: .8, body: t => { const b = _b(t); return { dx: .1 * Math.sin(t * TAU * 18), sq: .12 * b.hit, aL: -.3 + .1 * b.hit, aR: -.3 + .1 * b.hit }; } },
|
||||
furious: { eyes: 'red', lid: .28, tint: 'flush', tintK: .85, emote: 'steam', take: 1.2, body: t => { const b = _b(t); return { dx: .22 * Math.sin(t * TAU * 20), rot: .025 * Math.sin(t * TAU * 13), sq: .2 * b.hit, dy: -.8 * Math.sin(b.f * Math.PI) * (1 - b.f), aL: 1 + .2 * Math.sin(t * TAU * 9), aR: 1 - .2 * Math.sin(t * TAU * 9) }; } },
|
||||
scared: { eyes: 'scared', mouth: 'wobble', tint: 'pale', tintK: .6, emote: 'sweat', take: 1.1, body: t => ({ dx: .1 * Math.sin(t * TAU * 22), sq: -.06, aL: 1 + .08 * Math.sin(t * TAU * 17), aR: 1 + .08 * Math.sin(t * TAU * 19), lookX: .6 * Math.sign(Math.sin(t * 2.3)) }) },
|
||||
surprised: { eyes: 'wide', mouth: 'O', emote: '!', fade: true, take: 1.3, body: t => { const b = _b(t); return { sq: -.12, dy: -.3 - .15 * b.ab, aL: 1.1 + .05 * b.s2, aR: 1.1 - .05 * b.s2 }; } },
|
||||
confused: { eyes: ['narrow', 'wide'], mouth: 'wobble', emote: '?', take: .6, body: t => { const b = _b(t); return { rot: .1 * Math.sin(b.bp * Math.PI / 4), aL: .1, aR: 1.5 + .15 * Math.sin(t * TAU * 3), dy: -.15 * b.ab }; } },
|
||||
thinking: { eyes: 'look', mouth: 'flat', emote: 'dots', take: .4, body: t => { const b = _b(t); return { lookX: .5, lookY: -.8, rot: .05, aR: .9, aL: -.3 + .25 * Math.sin(b.f * Math.PI), dy: -.1 * b.ab }; } },
|
||||
idea: { eyes: 'shine', mouth: 'grin', emote: 'bulb', take: 1.1, body: t => { const b = _b(t); return { dy: -1 * b.ab, sq: -.05 + .1 * b.hit, aR: 1.55 + .1 * b.s2, aL: .2 + .2 * b.s1 }; } },
|
||||
determined: { eyes: 'determined', mouth: 'flat', take: .7, body: t => { const b = _b(t), p1 = Math.sin(b.f * Math.PI), p2 = Math.abs(Math.cos(b.f * Math.PI)); return { rot: .06, sq: -.04 + .06 * b.hit, dy: -.3 * b.ab, aL: .1 + .7 * p1, aR: .1 + .7 * p2 }; } },
|
||||
sleepy: { eyes: 'sleepy', mouth: 'o', emote: 'zzz', take: .2, body: t => { const br = Math.sin(t * TAU * .3); return { sq: .05 + .05 * br, rot: .06 * Math.sin(t * .8), aL: -.6, aR: -.6 }; } },
|
||||
bored: { eyes: 'narrow', mouth: 'flat', take: .2, body: t => { const f = frac(bpOf(t) / 4), sigh = f < .3 ? ease(f / .3) : 1 - ease((f - .3) / .7); return { lookX: -.3, lookY: .4, sq: .08 - .14 * sigh, rot: -.04, aL: -.85 + .05 * Math.sin(t * 2), aR: -.85 - .05 * Math.sin(t * 2) }; } },
|
||||
nervous: { eyes: 'look', mouth: 'wobble', tint: 'pale', tintK: .2, emote: 'sweat', take: .5, body: t => { const b = _b(t); return { lookX: beatN(t) % 2 ? .8 : -.8, dx: .3 * b.s1, sq: .04, aL: -.1 + .2 * Math.sin(t * TAU * 5), aR: -.1 + .2 * Math.sin(t * TAU * 5 + 1) }; } },
|
||||
suspicious: { eyes: 'narrow', mouth: 'flat', take: .4, body: t => { const s = Math.sin(t * TAU * .25); return { lookX: s, rot: -.08 * s, dx: .4 * s, sq: .04, aL: -.4, aR: -.4 }; } },
|
||||
disgusted: { eyes: 'squeeze', mouth: 'wobble', tint: 'green', tintK: .5, take: .8, body: t => { const a = frac(bpOf(t) / 2) * BEAT * 2, sh = Math.exp(-a * 6) * Math.sin(a * 45); return { rot: -.1, dx: -.3 + .15 * sh, sq: .05, aL: .7 + .2 * sh, aR: .7 - .2 * sh }; } },
|
||||
dizzy: { eyes: 'swirl', mouth: 'wobble', emote: 'stars', take: .7, body: t => { const a = t * TAU * .8; return { rot: .12 * Math.sin(a), dx: .7 * Math.sin(a), dy: -.3 * Math.abs(Math.cos(a)), aL: .3 + .6 * Math.sin(a * 1.3), aR: .3 - .6 * Math.sin(a * 1.3) }; } },
|
||||
cool: { eyes: 'shades', mouth: 'smirk', emote: 'music', take: .4, body: t => { const b = _b(t); return { rot: .04 * b.hit, dy: -.35 * b.hit, sq: .06 * b.hit, aL: -.4, aR: .7 + .1 * b.s1 }; } },
|
||||
starstruck: { eyes: 'spark', mouth: 'open', tint: 'gold', tintK: .3, take: 1, body: t => { const b = _b(t), h = Math.abs(b.s2); return { dy: -1.6 * h, sq: .1 * pulse2(t), aL: 1.1 + .35 * Math.sin(b.bp * TAU * 2), aR: 1.1 + .35 * Math.sin(b.bp * TAU * 2) }; } },
|
||||
ko: { eyes: 'x', mouth: 'wobble', tint: 'pale', tintK: .3, emote: 'stars', take: 1, body: t => ({ sq: .28 + .02 * Math.sin(t * 3), rot: .12, aL: -1, aR: -.9 }) },
|
||||
playful: { eyes: 'wink', mouth: 'tongue', take: .6, body: t => { const b = _b(t), side = beatN(t) % 2 ? 1 : -1, k = lerp(-side, side, easeOut(clamp(b.f * 3))); return { dx: .8 * k, dy: -1.4 * b.ab, rot: .1 * k, sq: .1 * b.hit, aL: .65 + .55 * k, aR: .65 - .55 * k }; } },
|
||||
mischief: { eyes: 'narrow', mouth: 'grin', gloom: .3, take: .5, body: t => { const r = Math.sin(t * TAU * 4); return { lookX: .3, sq: .07, rot: .04, aL: -.05 + .15 * r, aR: -.05 - .15 * r, dy: -.1 * Math.abs(r) }; } },
|
||||
hopeful: { eyes: 'shine', mouth: 'cat', blush: .35, take: .5, body: t => { const b = _b(t); return { sq: -.06 - .02 * b.ab, dy: -.25 * b.ab, lookY: -.4, aL: .5 + .05 * b.s1, aR: .5 - .05 * b.s1, rot: .03 * Math.sin(b.bp * Math.PI / 2) }; } },
|
||||
};
|
||||
|
||||
// One emotion, alive at time t: face, colour and body motion together. Spread it into clawd():
|
||||
// clawd(x, y, u, feel('happy', t)) clawd(x, y, u, { ...feel('sad', t), view: 'q', hat: 'beanie' })
|
||||
function feel(name, t, over = {}) {
|
||||
const E = EMO[name] || EMO.neutral;
|
||||
return { eyes: E.eyes, mouth: E.mouth, tint: E.tint, tintK: E.tintK ?? 1, blush: E.blush || 0, gloom: E.gloom || 0,
|
||||
lid: E.lid || 0, emote: E.emote, ...(E.body ? E.body(t) : {}), ...over };
|
||||
}
|
||||
|
||||
// An emotion timeline with ACTED changes: keys = [[t0, 'neutral'], [t1, 'surprised'], [t2, 'happy', { emote: 'music' }]].
|
||||
// Around each change the eyes squeeze shut and the body squashes just before (anticipation), and the face swaps
|
||||
// under the squint. Then a take fires (a squash-stretch the size of the new emotion's `take`), the body settles into
|
||||
// its new motion with overshoot, colour, blush and gloom cross-fade, and the new emote pops in. o.take scales every take.
|
||||
// Spread the result into clawd() and add any other pose: clawd(x, y, u, { ...emotions(t, keys), view: 'q' }).
|
||||
function emotions(t, keys, o = {}) {
|
||||
let i = 0; while (i + 1 < keys.length && t >= keys[i + 1][0]) i++;
|
||||
const [tc, name, over] = keys[i], age = t - tc, cur = feel(name, t, over);
|
||||
const tn = i + 1 < keys.length ? keys[i + 1][0] : Infinity, tkS = o.take ?? 1;
|
||||
const E = EMO[name] || EMO.neutral;
|
||||
let squint = cur.squint || 0;
|
||||
if (tn - t < .1) squint = Math.max(squint, 1 - (tn - t) / .1);
|
||||
if (i > 0 && age < .14) squint = Math.max(squint, 1 - age / .14);
|
||||
const prev = i > 0 ? feel(keys[i - 1][1], t, keys[i - 1][2]) : null;
|
||||
if (prev && age < .5) {
|
||||
const base = { dy: 0, sq: 0, aL: .2, aR: .2, rot: 0, dx: 0, lookX: 0, lookY: 0 };
|
||||
const k = backOut(seg(age, 0, .4)), kc = ease(seg(age, 0, .3));
|
||||
for (const f in base) cur[f] = lerp(prev[f] ?? base[f], cur[f] ?? base[f], k);
|
||||
const a = tintCols(prev), b = tintCols(cur);
|
||||
cur.col = mixCol(a.col, b.col, kc); cur.dk = mixCol(a.dk, b.dk, kc); cur.lt = mixCol(a.lt, b.lt, kc); cur.tint = null;
|
||||
for (const f of ['blush', 'gloom', 'lid']) cur[f] = lerp(prev[f] || 0, cur[f] || 0, kc);
|
||||
}
|
||||
// takes: the one this key fires, plus the anticipation squash of the next one
|
||||
const t1 = prev ? take(t, tc, (E.take ?? .6) * tkS) : { sq: 0, dy: 0 };
|
||||
const En = i + 1 < keys.length ? EMO[keys[i + 1][1]] || EMO.neutral : null, t2 = En ? take(t, tn, (En.take ?? .6) * tkS) : { sq: 0, dy: 0 };
|
||||
cur.sq = (cur.sq || 0) + t1.sq + t2.sq; cur.dy = (cur.dy || 0) + t1.dy + t2.dy;
|
||||
cur.squint = squint;
|
||||
// the emote pops in after the swap (it carries on if the emote didn't change); one-off emotes fade out
|
||||
const same = prev && prev.emote === cur.emote;
|
||||
cur.emoteK = same ? 1 : seg(age, .06, .32) * (E.fade && !(over && over.emote) ? 1 - seg(age, 1.4, 1.8) : 1);
|
||||
cur.emoteAge = age;
|
||||
return cur;
|
||||
}
|
||||
|
||||
// ---------- moves ----------
|
||||
// Beat-locked dances: bounce, hop, roof (arms up), sway, spin (a drawn spin through the key views, once a bar), wave,
|
||||
// walk, run, idle, stomp, shimmy, mix (changes every two bars). Returns pose offsets; spread into clawd().
|
||||
function move(style, t, seed = 0) {
|
||||
const bp = bpOf(t), bi = Math.floor(bp), bf = bp - bi, hit = Math.max(0, 1 - bf * 3.5), s1 = Math.sin(bp * Math.PI), ab = Math.abs(s1);
|
||||
const o = { dy: 0, sq: 0, aL: .2, aR: .2, rot: 0, walk: null, dx: 0 };
|
||||
if (style === 'mix') style = ['bounce', 'roof', 'sway', 'spin', 'hop', 'wave'][(Math.floor(bp / 8) + seed) % 6];
|
||||
switch (style) {
|
||||
case 'bounce': o.dy = -ab * 1.6; o.sq = hit * .12; o.aL = .4 + s1; o.aR = .4 - s1; break;
|
||||
case 'hop': o.dy = -ab * 4; o.sq = hit * .18; o.aL = o.aR = .3 + ab * 1.1; break;
|
||||
case 'roof': o.dy = -ab * 1.2; o.sq = hit * .1; o.aL = o.aR = 1.25 + .3 * Math.sin(bp * TAU); break;
|
||||
case 'sway': o.dx = s1 * 3; o.rot = s1 * .12; o.aL = .5 + .6 * s1; o.aR = .5 - .6 * s1; o.sq = hit * .08; break;
|
||||
case 'spin': {
|
||||
const ph = (((bi % 4) + 4) % 4 === 3) ? bf : 0;
|
||||
Object.assign(o, spinView(ph)); o.smear = ph > .1 && ph < .9 ? .5 : 0; o.smearDir = 0;
|
||||
o.dy = -Math.sin(ph * Math.PI) * 3 - ab; o.aL = o.aR = .6 + ph; o.sq = hit * .1; break;
|
||||
}
|
||||
case 'wave': o.dy = -ab; o.aL = 1.1 + .5 * Math.sin(bp * TAU * 2); o.aR = -.2; o.sq = hit * .08; break;
|
||||
case 'walk': o.walk = bp / 2; o.dy = -ab * .6; o.aL = .3 * s1; o.aR = -o.aL; break;
|
||||
case 'run': o.walk = bp * 1.5; o.dy = -Math.abs(Math.sin(bp * TAU)); o.aL = .8 * Math.sin(bp * TAU * 1.5); o.aR = -o.aL; o.rot = -.08; break;
|
||||
case 'idle': o.dy = -ab * .5; o.sq = hit * .05; break;
|
||||
case 'stomp': o.dy = -Math.max(0, Math.sin(bp * TAU)) * 1.4; o.sq = hit * .2; o.rot = (bi % 2 ? 1 : -1) * .06 * hit; o.aL = o.aR = -.3 + hit * .9; break;
|
||||
case 'shimmy': o.dx = Math.sin(bp * TAU * 2) * .6; o.rot = Math.sin(bp * TAU * 2) * .05; o.aL = .9 + .4 * Math.sin(bp * TAU * 2); o.aR = .9 - .4 * Math.sin(bp * TAU * 2); o.dy = -ab * .5; break;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
function dancer(x, y, u, style, t, extra = {}) { clawd(x, y, u, { ...move(style, t, extra.seed || 0), ...extra }); }
|
||||
@@ -0,0 +1,5 @@
|
||||
// config.js: project settings.
|
||||
// duration: the video's length in seconds.
|
||||
// bpm: the rhythm that bounces, dances and pulse() follow. Clawd always moves to some beat; if the video has music,
|
||||
// set this to the song's tempo, and set offset to the time in seconds of its first downbeat.
|
||||
const PROJECT = { duration: 11, bpm: 120, offset: 0 };
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
// core.js: constants, helpers, paper, paint wrapper, compositing and render hooks.
|
||||
// Length and rhythm come from PROJECT in config.js.
|
||||
const W = 1920, H = 1080;
|
||||
const BPM = PROJECT.bpm, BEAT = 60 / BPM, OFF = PROJECT.offset || 0, BOIL = 12, DUR = PROJECT.duration;
|
||||
const TAU = Math.PI * 2;
|
||||
const PAL = {
|
||||
paper: '#F3EBDC', ink: '#2B2233', clay: '#D97757', clayDk: '#A84D33', clayLt: '#F2A283',
|
||||
night: '#1F2550', indigo: '#2F3C7A', rose: '#E27A92', ochre: '#E8AA38', sap: '#6E9F58',
|
||||
teal: '#3A9C98', violet: '#7B5CA8', cream: '#FFF5E2', sky: '#8EC3E6'
|
||||
};
|
||||
|
||||
const clamp = (x, a = 0, b = 1) => Math.max(a, Math.min(b, x));
|
||||
const lerp = (a, b, x) => a + (b - a) * x;
|
||||
const ease = x => { x = clamp(x); return x * x * (3 - 2 * x); };
|
||||
const easeOut = x => 1 - Math.pow(1 - clamp(x), 3);
|
||||
const backOut = x => { x = clamp(x); const s = 1.9; return 1 + (s + 1) * Math.pow(x - 1, 3) + s * Math.pow(x - 1, 2); };
|
||||
const hash = i => { const x = Math.sin(i * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); };
|
||||
const bpOf = t => (t - OFF) / BEAT;
|
||||
// Seeded by the boil frame, so linework "boils" at BOIL fps like hand-drawn animation.
|
||||
const jit = a => (random() * 2 - 1) * a;
|
||||
// Each boil drawing holds for several frames, so whatever isn't moving must draw the same until the next one. But a moving
|
||||
// thing uses a different amount of randomness each frame, which shifts the stream for everything drawn after it and makes
|
||||
// that re-boil every frame (jitter). boilSeed(key) restarts the stream from the boil frame and a key (any string or
|
||||
// number) that's the same every frame: call it before each separate element. clawd() does this for itself and its parts.
|
||||
let BOILN = 0, CLAWD_N = 0;
|
||||
const boilSeed = key => { let h = 2166136261; for (const c of key + '|' + BOILN) h = Math.imul(h ^ c.charCodeAt(0), 16777619); randomSeed(h >>> 0); };
|
||||
|
||||
// ---------- timing helpers (everything is a pure function of t; no state survives between frames) ----------
|
||||
const seg = (t, a, b) => clamp((t - a) / (b - a)); // 0..1 progress of t through [a, b]
|
||||
const frac = x => x - Math.floor(x);
|
||||
const beatN = t => Math.floor(bpOf(t)); // integer beat index
|
||||
const pulse = (t, k = 6) => Math.exp(-frac(bpOf(t)) * k); // 1 exactly on each beat, decays after
|
||||
const pulse2 = (t, k = 6) => Math.exp(-frac(bpOf(t) * 2) * k); // same on eighth notes
|
||||
const wob = (t, f = 1, ph = 0) => Math.sin((t * f + ph) * TAU);
|
||||
const easeIn = x => Math.pow(clamp(x), 3);
|
||||
const elasticOut = x => { x = clamp(x); return x === 0 || x === 1 ? x : Math.pow(2, -10 * x) * Math.sin((x * 10 - .75) * (TAU / 3)) + 1; };
|
||||
// keyframes: kf(t, [[t0, v0], [t1, v1], ...], easeFn). Values may be numbers or arrays of numbers.
|
||||
function kf(t, keys, e = ease) {
|
||||
if (t <= keys[0][0]) return keys[0][1];
|
||||
for (let i = 1; i < keys.length; i++) {
|
||||
if (t < keys[i][0]) {
|
||||
const [a, va] = keys[i - 1], [b, vb] = keys[i], k = e((t - a) / (b - a));
|
||||
return Array.isArray(va) ? va.map((v, j) => lerp(v, vb[j], k)) : lerp(va, vb, k);
|
||||
}
|
||||
}
|
||||
return keys[keys.length - 1][1];
|
||||
}
|
||||
// hex color mix
|
||||
function mixCol(a, b, k) {
|
||||
const pa = parseInt(a.slice(1), 16), pb = parseInt(b.slice(1), 16), c = i => Math.round(lerp((pa >> i) & 255, (pb >> i) & 255, clamp(k)));
|
||||
return '#' + ((1 << 24) + (c(16) << 16) + (c(8) << 8) + c(0)).toString(16).slice(1);
|
||||
}
|
||||
// small deterministic camera shake, changes at 24 fps
|
||||
const shakeXY = (t, amt) => { const f = Math.floor(t * 24); return [(hash(f * 1.7) - .5) * 2 * amt, (hash(f * 2.3 + 9) - .5) * 2 * amt]; };
|
||||
|
||||
// ---------- motion principles, as pure functions of t ----------
|
||||
// Damped spring kicked at t0: 0 before, then a wobble that dies away. Use it for secondary motion and settles: a body
|
||||
// after landing, a hat that jiggles, a stack that sways, a tail that drags. k = damping, w = wobble speed (rad/s).
|
||||
const spring = (t, t0, k = 6, w = 18) => t < t0 ? 0 : Math.exp(-k * (t - t0)) * Math.sin(w * (t - t0));
|
||||
const ring = (t, evs, k = 6, w = 18) => evs.reduce((s, e) => s + spring(t, e, k, w), 0); // one kick per event time
|
||||
// Hold each drawing for two frames (12 drawings a second), like hand-drawn animation "on twos". Wrap a shot's t in it.
|
||||
const onTwos = t => Math.floor(t * 12 + 1e-6) / 12;
|
||||
// Point on a thrown or jumping arc from p0 to p1, peaking h px above the straight line; k = 0..1 along the flight.
|
||||
const arcPt = (p0, p1, h, k) => [lerp(p0[0], p1[0], k), lerp(p0[1], p1[1], k) - h * 4 * k * (1 - k)];
|
||||
// A hop that takes off at t0 and lands at t1, h body units high: crouch (anticipation), stretch on takeoff,
|
||||
// round at the top, squash on landing and spring back. Returns { dy, sq } to spread into clawd().
|
||||
function jump(t, t0, t1, h = 3) {
|
||||
if (t < t0 - .12) return { dy: 0, sq: 0 };
|
||||
if (t < t0) return { dy: 0, sq: .18 * ease(seg(t, t0 - .12, t0)) };
|
||||
if (t < t1) { const k = (t - t0) / (t1 - t0); return { dy: -h * 4 * k * (1 - k), sq: -.16 * Math.abs(1 - 2 * k) }; }
|
||||
const a = t - t1; return { dy: 0, sq: .22 * Math.exp(-8 * a) * Math.cos(20 * a) };
|
||||
}
|
||||
// A surprise "take" peaking at t0: a quick squash, then a big stretch up that springs back. amt scales it.
|
||||
function take(t, t0, amt = 1) {
|
||||
if (t < t0 - .1) return { sq: 0, dy: 0 };
|
||||
if (t < t0) return { sq: .12 * amt * ease(seg(t, t0 - .1, t0)), dy: 0 };
|
||||
const a = t - t0; return { sq: -.26 * amt * Math.exp(-6 * a) * Math.cos(16 * a), dy: -1.2 * amt * Math.exp(-7 * a) * Math.max(0, Math.cos(9 * a)) };
|
||||
}
|
||||
// Walk from x0 to x1 (px) between t0 and t1, for a character of unit u: eases in and out, faces the way it's
|
||||
// going in 3/4 view, and faces front when it stops. Returns { x, walk, view, flip, dy } for clawd().
|
||||
function stroll(t, t0, t1, x0, x1, u) {
|
||||
const x = lerp(x0, x1, ease(seg(t, t0, t1))), d = Math.abs(x - x0) / (4 * u), moving = t > t0 && t < t1;
|
||||
return { x, walk: d, view: moving ? 'q' : 'front', flip: x1 < x0, dy: moving ? -Math.abs(Math.sin(d * Math.PI)) * .5 : 0 };
|
||||
}
|
||||
|
||||
// ---------- camera ----------
|
||||
// camBegin(cx, cy, zoom, rot): world point (cx, cy) lands at screen centre. Letters queued while a camera is
|
||||
// active are placed through it automatically (pass {screen:true} to opt out). One level only: always pair with camEnd().
|
||||
// LAST_CAM stays set after camEnd(), until the next frame: renderSheet's crops that follow a world point use it.
|
||||
let CAM = null, LAST_CAM = null;
|
||||
function camBegin(cx = W / 2, cy = H / 2, zoom = 1, rot = 0) { push(); translate(W / 2, H / 2); rotate(rot); scale(zoom); translate(-cx, -cy); CAM = LAST_CAM = { cx, cy, zoom, rot }; }
|
||||
function camEnd() { pop(); CAM = null; }
|
||||
function toScreen(x, y, cam = CAM) {
|
||||
if (!cam) return [x, y];
|
||||
const c = Math.cos(cam.rot), s = Math.sin(cam.rot), dx = (x - cam.cx) * cam.zoom, dy = (y - cam.cy) * cam.zoom;
|
||||
return [W / 2 + dx * c - dy * s, H / 2 + dx * s + dy * c];
|
||||
}
|
||||
|
||||
// ---------- full-frame effects (call outside a camera, in screen space) ----------
|
||||
function flash(k, col = '#FFFDF6') { if (k > .01) paint(rectPts(-60, -60, W + 120, H + 120), { wash: col, washOp: 255 * clamp(k), ink: null }); }
|
||||
// Light: glow(x, y, r, col, a) ADDS a soft halo of light for anything that shines (stars, lamps, fireflies, magic).
|
||||
// p5.brush mixes every colour like pigment, so yellow painted over blue turns green and light can't be painted; this
|
||||
// is the one non-paint mark in the kit. It lands on what's painted so far, under anything painted after it, follows
|
||||
// the camera, and boils a little. Keep a = 1 on dark grounds; on light grounds it barely shows (as light would).
|
||||
function glow(x, y, r, col = '#FFC766', a = 1) {
|
||||
if (a <= 0 || r < 1) return;
|
||||
flushBrush();
|
||||
const c = color(col), rr = r * (1 + jit(.03));
|
||||
push(); blendMode(ADD); tint(red(c), green(c), blue(c), 150 * clamp(a)); image(glowTex, x - rr, y - rr, 2 * rr, 2 * rr); noTint(); blendMode(BLEND); pop();
|
||||
}
|
||||
function makeGlowTex() {
|
||||
const g = createGraphics(256, 256); g.pixelDensity(1); const c = g.drawingContext, gr = c.createRadialGradient(128, 128, 0, 128, 128, 128);
|
||||
[[0, 1], [.18, .8], [.45, .32], [.75, .08], [1, 0]].forEach(([s, a]) => gr.addColorStop(s, `rgba(255,255,255,${a})`));
|
||||
c.fillStyle = gr; c.fillRect(0, 0, 256, 256);
|
||||
return g;
|
||||
}
|
||||
// Paint everything OUTSIDE a star-shaped hole (irises, mouth-shaped reveals, keyholes).
|
||||
function irisShape(pts, col = PAL.ink, far = 4000) {
|
||||
const n = pts.length; let cx = 0, cy = 0; for (const p of pts) { cx += p[0]; cy += p[1]; } cx /= n; cy /= n;
|
||||
const out = p => { const dx = p[0] - cx, dy = p[1] - cy, d = Math.hypot(dx, dy) || 1; return [cx + dx / d * far, cy + dy / d * far]; };
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = pts[i], b = pts[(i + 1) % n], ex = (b[0] - a[0]) * .06, ey = (b[1] - a[1]) * .06;
|
||||
const a2 = [a[0] - ex, a[1] - ey], b2 = [b[0] + ex, b[1] + ey];
|
||||
paint([a2, b2, out(b2), out(a2)], { wash: col, washOp: 255, ink: null });
|
||||
}
|
||||
}
|
||||
function iris(cx, cy, r, col = PAL.ink) { if (r < 4) paint(rectPts(-60, -60, W + 120, H + 120), { wash: col, ink: null }); else irisShape(ellPts(cx, cy, r, r, 40), col); }
|
||||
|
||||
let T = 0, paperG = null, grainC = null, letG = null, glowTex = null, outC = null, outX = null;
|
||||
let LETTERS = [];
|
||||
|
||||
// ---------- geometry ----------
|
||||
function rectPts(x, y, w, h, j = 0) {
|
||||
return [[x + jit(j), y + jit(j)], [x + w / 2 + jit(j), y + jit(j) * .5], [x + w + jit(j), y + jit(j)],
|
||||
[x + w + jit(j) * .5, y + h / 2], [x + w + jit(j), y + h + jit(j)], [x + w / 2 + jit(j), y + h + jit(j) * .5],
|
||||
[x + jit(j), y + h + jit(j)], [x + jit(j) * .5, y + h / 2]];
|
||||
}
|
||||
function ellPts(cx, cy, rx, ry, n = 28, j = 0, rot = 0) {
|
||||
const p = []; for (let i = 0; i < n; i++) { const a = rot + i / n * TAU; p.push([cx + Math.cos(a) * rx + jit(j), cy + Math.sin(a) * ry + jit(j)]); } return p;
|
||||
}
|
||||
function rrPts(x, y, w, h, r, j = 0) {
|
||||
const p = [], seg = 5, corner = (cx, cy, a0) => { for (let i = 0; i <= seg; i++) { const a = a0 + i / seg * Math.PI / 2; p.push([cx + Math.cos(a) * r + jit(j), cy + Math.sin(a) * r + jit(j)]); } };
|
||||
corner(x + w - r, y + r, -Math.PI / 2); corner(x + w - r, y + h - r, 0); corner(x + r, y + h - r, Math.PI / 2); corner(x + r, y + r, Math.PI);
|
||||
return p;
|
||||
}
|
||||
function starPts(cx, cy, r, inner = .38, n = 4, rot = -Math.PI / 2) {
|
||||
const p = []; for (let i = 0; i < n * 2; i++) { const a = rot + i * Math.PI / n, q = i % 2 ? r * inner : r; p.push([cx + Math.cos(a) * q, cy + Math.sin(a) * q]); } return p;
|
||||
}
|
||||
// Smooth curve through the points (Catmull-Rom), n samples per span.
|
||||
function through(P, n = 6) {
|
||||
if (P.length < 3) return P.slice();
|
||||
const out = [];
|
||||
for (let i = 0; i < P.length - 1; i++) {
|
||||
const p0 = P[Math.max(0, i - 1)], p1 = P[i], p2 = P[i + 1], p3 = P[Math.min(P.length - 1, i + 2)];
|
||||
for (let k = 0; k < n; k++) {
|
||||
const u = k / n, u2 = u * u, u3 = u2 * u;
|
||||
out.push([0, 1].map(d => .5 * (2 * p1[d] + (p2[d] - p0[d]) * u + (2 * p0[d] - 5 * p1[d] + 4 * p2[d] - p3[d]) * u2 + (3 * p1[d] - p0[d] - 3 * p2[d] + p3[d]) * u3)));
|
||||
}
|
||||
}
|
||||
out.push(P[P.length - 1]);
|
||||
return out;
|
||||
}
|
||||
// Tapered ribbon around a path (w0 wide at the start, w1 at the end), as one closed outline for paint().
|
||||
// Tails, tentacles, noodly arms, painted glyphs: one shape with one outline, so nothing looks glued on.
|
||||
function ribbon(P, w0, w1 = w0) {
|
||||
const C = through(P), n = C.length, L = [], R = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = C[Math.max(0, i - 1)], b = C[Math.min(n - 1, i + 1)], dx = b[0] - a[0], dy = b[1] - a[1], d = Math.hypot(dx, dy) || 1, w = lerp(w0, w1, i / Math.max(1, n - 1)) / 2;
|
||||
L.push([C[i][0] - dy / d * w, C[i][1] + dx / d * w]); R.push([C[i][0] + dy / d * w, C[i][1] - dx / d * w]);
|
||||
}
|
||||
return L.concat(R.reverse());
|
||||
}
|
||||
|
||||
// ---------- paint wrapper ----------
|
||||
// One call = one painted shape: optional flat wash, optional watercolor fill, optional hatch, optional ink outline.
|
||||
// p5.brush 2.2.3 loses strokes drawn far from the origin under a zoomed camera (from zoom ~2, an outline or a line
|
||||
// leaves only a dot at its first vertex), so every shape and line is drawn around its own centre.
|
||||
function centred(pts, draw) {
|
||||
if (!pts.length) return;
|
||||
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
|
||||
for (const [x, y] of pts) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; }
|
||||
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
|
||||
push(); translate(cx, cy); draw(pts.map(([x, y]) => [x - cx, y - cy])); pop();
|
||||
}
|
||||
function paint(pts, o = {}) { centred(pts, (P) => paintAt(P, o)); }
|
||||
function paintAt(pts, o) {
|
||||
if (o.wash || o.fill || o.hatch) {
|
||||
if (o.wash) brush.wash(o.wash, o.washOp ?? 255); else brush.noWash();
|
||||
if (o.fill) { brush.fill(o.fill, o.fillOp ?? 170); brush.fillBleed(o.bleed ?? .1); brush.fillTexture(o.tex ?? .4, o.border ?? .35); } else brush.noFill();
|
||||
if (o.hatch) { brush.hatch(o.hatch.d, o.hatch.a, o.hatch.o || { rand: .15 }); brush.hatchStyle(o.hatch.b || 'HB', o.hatch.c || PAL.ink, o.hatch.w || 1); } else brush.noHatch();
|
||||
brush.noStroke();
|
||||
if (o.curv) { brush.beginShape(o.curv); for (const p of pts) brush.vertex(p[0], p[1]); brush.endShape(true); }
|
||||
else brush.polygon(pts);
|
||||
}
|
||||
// the outline is one continuous tapered stroke, not a stroke per side
|
||||
if (o.ink !== null) {
|
||||
brush.noWash(); brush.noFill(); brush.noHatch(); brush.set(o.br || 'ink', o.ink || PAL.ink, o.sw ?? 1);
|
||||
brush.beginShape(o.curv || 0); for (const p of pts) brush.vertex(p[0], p[1]); brush.endShape(true);
|
||||
}
|
||||
}
|
||||
function inkLine(pts, sw = 1, col = PAL.ink, br = 'ink', curv = .5) {
|
||||
centred(pts, (P) => { brush.noFill(); brush.noWash(); brush.noHatch(); brush.set(br, col, sw); brush.spline(P, curv); });
|
||||
}
|
||||
|
||||
// ---------- lettering (drawn on the 2D compositor, under the paper grain) ----------
|
||||
// Use sparingly: see "No text" in ANIMATION_GUIDE.md. Clawd's emotes are painted and never need these.
|
||||
function letter(txt, x, y, size, color, o = {}) {
|
||||
if (CAM && !o.screen) { [x, y] = toScreen(x, y); size *= CAM.zoom; o = { ...o, rot: (o.rot || 0) + CAM.rot }; if (o.font) o.font = o.font.replace(/(\d+(\.\d+)?)px/, (m, v) => (v * CAM.zoom) + 'px'); }
|
||||
LETTERS.push({ txt, x, y, size, color, ...o });
|
||||
}
|
||||
// Comic sound effect: pops in at age 0, wobbles, fades by `life` seconds.
|
||||
function sfx(txt, x, y, size, color, age, o = {}) {
|
||||
const life = o.life ?? 1.2; if (age < 0 || age > life) return;
|
||||
letter(txt, x, y, size, color, { pop: age * 5, rot: (o.rot ?? -.08) + Math.sin(age * 20) * .03 * (1 - age / life), alpha: 1 - seg(age, life - .25, life), ...o });
|
||||
}
|
||||
function drawLetters(c) {
|
||||
for (const L of LETTERS) {
|
||||
const k = L.pop != null ? backOut(L.pop) : 1; if (k <= .01) continue;
|
||||
c.save(); c.translate(L.x, L.y); c.rotate(L.rot || 0); c.scale(k, k); c.globalAlpha = L.alpha ?? 1;
|
||||
c.font = L.font || `${L.size}px "Permanent Marker", "Comic Sans MS", cursive`;
|
||||
c.textAlign = L.align || 'center'; c.textBaseline = 'middle';
|
||||
if (L.stroke) { c.lineJoin = 'round'; c.lineWidth = L.size * .12; c.strokeStyle = L.stroke; c.strokeText(L.txt, 0, 0); }
|
||||
if (L.ink !== false) { c.fillStyle = PAL.ink; c.fillText(L.txt, L.size * .045, L.size * .055); }
|
||||
c.fillStyle = L.color; c.fillText(L.txt, 0, 0);
|
||||
c.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// p5.brush defers washes and strokes into a mask layer; a (tiny, off-screen) watercolor fill forces it to composite
|
||||
// now, so everything painted before this call really lands under whatever p5 draws next (letters, glow).
|
||||
function flushBrush() {
|
||||
push(); resetMatrix(); translate(-W / 2, -H / 2);
|
||||
brush.noStroke(); brush.noHatch(); brush.noWash(); brush.fill('#000000', 1); brush.fillBleed(0); brush.fillTexture(0, 0);
|
||||
brush.polygon([[-50, -50], [-40, -50], [-40, -40]]); brush.noFill(); pop();
|
||||
}
|
||||
// Paint the queued lettering into the scene itself, so later layers (wipes) cover it. drawWorld calls this after each
|
||||
// frame; call it yourself before a wipe or iris if the shot has lettering, or the letters will sit on top of it.
|
||||
function flushLetters() {
|
||||
if (!LETTERS.length) return;
|
||||
letG.clear(); drawLetters(letG.drawingContext); LETTERS = [];
|
||||
flushBrush();
|
||||
// Letters are already in screen space, so composite them with the base transform even inside camBegin().
|
||||
push(); resetMatrix(); translate(-W / 2, -H / 2); image(letG, 0, 0); pop();
|
||||
}
|
||||
|
||||
// ---------- paper ----------
|
||||
function lcg(seed) { let s = seed; return () => (s = (s * 16807) % 2147483647) / 2147483647; }
|
||||
function makePaper() {
|
||||
const g = createGraphics(W, H); g.pixelDensity(1); const c = g.drawingContext, rnd = lcg(11);
|
||||
c.fillStyle = PAL.paper; c.fillRect(0, 0, W, H);
|
||||
for (let i = 0; i < 70; i++) { const x = rnd() * W, y = rnd() * H, r = 120 + rnd() * 380, gr = c.createRadialGradient(x, y, 0, x, y, r), a = .045 * rnd(); gr.addColorStop(0, `rgba(160,125,80,${a})`); gr.addColorStop(1, 'rgba(160,125,80,0)'); c.fillStyle = gr; c.fillRect(x - r, y - r, 2 * r, 2 * r); }
|
||||
c.lineWidth = 1;
|
||||
for (let i = 0; i < 1400; i++) { const x = rnd() * W, y = rnd() * H, l = 6 + rnd() * 26, a = rnd() * TAU; c.strokeStyle = `rgba(110,88,60,${.035 + rnd() * .06})`; c.beginPath(); c.moveTo(x, y); c.quadraticCurveTo(x + Math.cos(a + .6) * l * .5, y + Math.sin(a + .6) * l * .5, x + Math.cos(a) * l, y + Math.sin(a) * l); c.stroke(); }
|
||||
return g;
|
||||
}
|
||||
// Static grain + vignette, multiplied over the painted frame so pigment sits "in" the paper.
|
||||
function makeGrain() {
|
||||
const cv = document.createElement('canvas'); cv.width = W; cv.height = H; const c = cv.getContext('2d'), rnd = lcg(5);
|
||||
const id = c.createImageData(W, H), d = id.data;
|
||||
for (let i = 0; i < d.length; i += 4) { const v = 255 - (rnd() < .55 ? rnd() * rnd() * 34 : 0); d[i] = v; d[i + 1] = v - 1; d[i + 2] = v - 3; d[i + 3] = 255; }
|
||||
c.putImageData(id, 0, 0);
|
||||
const g = c.createRadialGradient(W / 2, H / 2, H * .45, W / 2, H / 2, H * 1.05); g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(1, 'rgba(120,95,70,.35)');
|
||||
c.fillStyle = g; c.fillRect(0, 0, W, H);
|
||||
return cv;
|
||||
}
|
||||
|
||||
// ---------- custom brushes ----------
|
||||
function defineBrushes() {
|
||||
brush.add('ink', { type: 'default', weight: 5, scatter: .25, sharpness: .8, grain: 40, opacity: 235, spacing: .2, pressure: [1.15, .75], rotate: 'natural', noise: .15 });
|
||||
brush.add('inkfine', { type: 'default', weight: 2.6, scatter: .15, sharpness: .85, grain: 40, opacity: 230, spacing: .2, pressure: [1.1, .8], rotate: 'natural', noise: .1 });
|
||||
brush.add('dry', { type: 'default', weight: 14, scatter: 3, sharpness: .3, grain: 6, opacity: 90, spacing: .6, pressure: [1, .6], rotate: 'natural', noise: .4 });
|
||||
}
|
||||
|
||||
// ---------- frame ----------
|
||||
async function setup() {
|
||||
createCanvas(W, H, WEBGL); pixelDensity(1); noLoop();
|
||||
brush.scaleBrushes(5); defineBrushes();
|
||||
paperG = makePaper(); grainC = makeGrain(); glowTex = makeGlowTex(); letG = createGraphics(W, H); letG.pixelDensity(1);
|
||||
outC = document.getElementById('out'); outX = outC.getContext('2d');
|
||||
await document.fonts.load('100px "Permanent Marker"');
|
||||
window.ready = true;
|
||||
if (!location.search.includes('render')) devUI();
|
||||
}
|
||||
function draw() {
|
||||
if (!window.ready) return;
|
||||
LETTERS = []; CAM = LAST_CAM = null;
|
||||
push(); translate(-W / 2, -H / 2);
|
||||
BOILN = Math.floor(T * BOIL); CLAWD_N = 0; boilSeed('frame'); noiseSeed(77);
|
||||
image(paperG, 0, 0);
|
||||
drawWorld(T);
|
||||
pop();
|
||||
}
|
||||
function composite(t) {
|
||||
const c = outX;
|
||||
c.globalCompositeOperation = 'source-over'; c.globalAlpha = 1;
|
||||
c.drawImage(drawingContext.canvas, 0, 0, W, H);
|
||||
drawLetters(c);
|
||||
c.globalCompositeOperation = 'multiply'; c.drawImage(grainC, 0, 0);
|
||||
c.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
window.renderAt = async (t, type = 'image/png', q = .92) => { T = t; await redraw(); composite(t); return outC.toDataURL(type, q); };
|
||||
// Contact sheet of several times, for visual checks: returns { url, ms[] }. crop = [x, y, w, h] fills each cell with just
|
||||
// that region of the frame, at full resolution (for checking faces, hands and contacts up close). at = [x, y, w, h]
|
||||
// instead crops w × h around the WORLD point (x, y), wherever each frame's camera put it (a foot, a splash, a prop on
|
||||
// a moving shot); x and y may be expressions evaluated in the page.
|
||||
window.renderSheet = async (times, cols = 3, w = 640, crop = null, at = null) => {
|
||||
if (at) at = at.map((v) => typeof v === 'string' ? (0, eval)(v) : v);
|
||||
const [, , cw, ch] = at || crop || [0, 0, W, H], h = Math.round(w * ch / cw), rows = Math.ceil(times.length / cols), sc = document.createElement('canvas');
|
||||
sc.width = cols * w; sc.height = rows * h; const c = sc.getContext('2d'), ms = [];
|
||||
for (let i = 0; i < times.length; i++) {
|
||||
const t0 = performance.now(); T = times[i]; await redraw(); composite(times[i]); ms.push(Math.round(performance.now() - t0));
|
||||
const x = (i % cols) * w, y = Math.floor(i / cols) * h;
|
||||
const [cx, cy] = at ? toScreen(at[0], at[1], LAST_CAM).map((v, j) => v - (j ? ch : cw) / 2) : crop || [0, 0];
|
||||
c.drawImage(outC, cx, cy, cw, ch, x, y, w, h); c.fillStyle = 'rgba(0,0,0,.65)'; c.fillRect(x, y, 84, 24); c.fillStyle = '#fff'; c.font = '15px sans-serif'; c.fillText(times[i].toFixed(2) + 's', x + 6, y + 17);
|
||||
}
|
||||
return { url: sc.toDataURL('image/jpeg', .9), ms };
|
||||
};
|
||||
window.gpuInfo = () => { const gl = drawingContext, e = gl.getExtension('WEBGL_debug_renderer_info'); return e ? gl.getParameter(e.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER); };
|
||||
|
||||
function devUI() {
|
||||
const s = document.getElementById('scrub'), lab = document.getElementById('tt'); s.max = window.LOOP ? window.LOOP.len : DUR;
|
||||
let busy = false, want = null;
|
||||
const go = async () => { if (busy) return; busy = true; while (want != null) { const t = want; want = null; const t0 = performance.now(); await window.renderAt(t); lab.textContent = `${t.toFixed(2)}s · ${Math.round(performance.now() - t0)} ms/frame`; } busy = false; };
|
||||
s.addEventListener('input', () => { want = +s.value; go(); });
|
||||
want = +(new URLSearchParams(location.search).get('t') || 0); s.value = want; go();
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// demo.js: "The fallen star", an 11-second example that exercises the kit. It is one idea, not a template: don't
|
||||
// copy its story, staging or palette into your own video (see ANIMATION_GUIDE.md).
|
||||
// Shot A (0–4.4 s): Clawd dozes on a hill at night. A star falls behind the next hill. Clawd wakes, has an idea,
|
||||
// turns (drawn key views) and trots off after it. Brush wipe.
|
||||
// Shot B (4.4–11 s): Clawd finds the star dim and sad in the grass, lifts it, and throws it back into the sky, where
|
||||
// it lights up again and twinkles goodbye. Iris out.
|
||||
(() => {
|
||||
const NIGHT = [PAL.indigo, PAL.violet]; // wipe colours, the same on both sides of the cut
|
||||
|
||||
// ---------- set pieces ----------
|
||||
// ripple (optional): { x, y, t0 }, a wave of bright twinkles spreading out from (x, y) at time t0.
|
||||
function sky(t, horizon = 1.2, ripple = null) {
|
||||
boilSeed('sky');
|
||||
paint(rectPts(-600, -400, W + 1200, H + 800), { wash: PAL.night, ink: null }); // oversized: the camera pans
|
||||
paint(ellPts(W / 2, H * .95, W * .75, H * .45 * horizon, 30, 10), { fill: PAL.violet, fillOp: 110, bleed: .3, tex: .6, ink: null });
|
||||
paint(ellPts(W * .3, H * .15, W * .35, H * .25, 24, 10), { fill: PAL.indigo, fillOp: 90, bleed: .3, tex: .5, ink: null });
|
||||
for (let i = 0; i < 42; i++) { // twinkling stars, each on its own clock and its own boil seed
|
||||
boilSeed('star' + i);
|
||||
const x = hash(i) * (W + 200) - 100, y = hash(i + 100) * H * .6 - 40;
|
||||
let tw = .55 + .45 * Math.sin(t * (2 + 2 * hash(i + 300)) + i);
|
||||
if (ripple) { const a = t - ripple.t0 - Math.hypot(x - ripple.x, y - ripple.y) / 1100; if (a > 0) tw += 1.3 * Math.exp(-a * 5) * Math.min(1, a * 25); }
|
||||
paint(starPts(x, y, (3 + 5 * hash(i + 200)) * tw, .35, 4), { wash: PAL.cream, washOp: Math.min(255, 150 + 100 * tw), ink: null });
|
||||
}
|
||||
boilSeed('moon');
|
||||
paint(ellPts(260, 170, 70, 70, 24, 1.5), { wash: PAL.cream, fill: PAL.ochre, fillOp: 40, ink: PAL.ink, sw: .8 });
|
||||
paint(ellPts(245, 150, 16, 12, 10), { fill: mixCol(PAL.cream, PAL.ochre, .5), fillOp: 120, ink: null });
|
||||
}
|
||||
function hill(cx, cy, rx, ry, col, t, tufts = 0) {
|
||||
boilSeed('hill' + cx); // keyed by its position, which never changes
|
||||
paint(ellPts(cx, cy, rx, ry, 40, 2), { wash: col, fill: mixCol(col, PAL.night, .35), fillOp: 90, bleed: .08, tex: .6, ink: PAL.ink, sw: 1 });
|
||||
for (let i = 0; i < tufts; i++) { // grass tufts along the top, swaying
|
||||
const a = -Math.PI / 2 + (hash(i + cx) - .5) * 1.6, x = cx + Math.cos(a) * rx, y = cy + Math.sin(a) * ry + 4, sw = wob(t, .5, hash(i) * 3) * 5;
|
||||
for (const k of [-1, 0, 1]) inkLine([[x + k * 7, y], [x + k * 11 + sw, y - 22 - 8 * hash(i + k)]], .7, mixCol(col, PAL.cream, .35), 'inkfine', .4);
|
||||
}
|
||||
}
|
||||
const hillY = (x, cx, cy, rx, ry) => cy - ry * Math.sqrt(Math.max(0, 1 - ((x - cx) / rx) ** 2));
|
||||
|
||||
// The star: a five-point pal with a face. glow 0..1 (dim → bright), sad true/false.
|
||||
function starling(x, y, r, o = {}) {
|
||||
const g = clamp(o.glow ?? .6), tw = 1 + .06 * Math.sin(T * 9);
|
||||
glow(x, y, r * (1.6 + 1.4 * g) * tw, '#FFD96A', .25 + .75 * g);
|
||||
push(); translate(x, y); rotate(o.rot || 0);
|
||||
paint(starPts(0, 0, r, .52, 5), { wash: mixCol('#B9A98A', '#FFE27A', g), fill: PAL.cream, fillOp: 30 + 80 * g, ink: PAL.ink, sw: clamp(r / 40, .5, 1.2) });
|
||||
const e = r * .16;
|
||||
if (o.sad) {
|
||||
for (const s of [-1, 1]) inkLine([[s * e * 1.9 - e * .7, -e * .3 + s * e * .25], [s * e * 1.9 + e * .7, -e * .3 - s * e * .25]], clamp(r / 50, .4, 1), PAL.ink, 'ink', 0);
|
||||
inkLine([[-e, e * 1.6], [0, e * 1.1], [e, e * 1.6]], clamp(r / 60, .35, .9), PAL.ink, 'ink', .6);
|
||||
} else {
|
||||
for (const s of [-1, 1]) paint(ellPts(s * e * 1.9, -e * .3, e * .45, e * .6, 10), { wash: PAL.ink, ink: null });
|
||||
inkLine([[-e, e * .9], [0, e * 1.6], [e, e * .9]], clamp(r / 60, .35, .9), PAL.ink, 'ink', .6);
|
||||
if (r > 20) for (const s of [-1, 1]) paint(ellPts(s * e * 3.1, e * 1.1, e * .6, e * .3, 10), { fill: PAL.rose, fillOp: 150, ink: null });
|
||||
}
|
||||
pop();
|
||||
}
|
||||
const sparkle = (x, y, r, k) => { if (k > 0 && k < 1) paint(starPts(x, y, r * backOut(k) * (1 - k * .6), .25, 4, k * 2), { wash: PAL.cream, washOp: 255 * (1 - k * k), ink: null }); };
|
||||
|
||||
// ---------- shot A: the fall ----------
|
||||
const FRONT = [700, 1010, 900, 220]; // front hill ellipse (cx, cy, rx, ry)
|
||||
const tFall0 = 1.05, tLand = 1.9;
|
||||
const fallAt = t => { const k = easeIn(seg(t, tFall0, tLand)); return [lerp(1680, 1545, k), lerp(40, 700, k)]; };
|
||||
|
||||
function shotFall(t, lt, dur) {
|
||||
const shake = t > tLand ? shakeXY(t, 7 * Math.exp(-(t - tLand) * 7)) : [0, 0];
|
||||
camBegin(kf(t, [[0, 900], [2.9, 900], [4.4, 1230]]) + shake[0], kf(t, [[0, 560], [2, 540]]) + shake[1], kf(t, [[0, 1], [1.85, 1.07], [2.05, 1.03], [4.4, 1.01]]));
|
||||
sky(t);
|
||||
// the falling star, its trail, and the glow where it lands
|
||||
boilSeed('fall');
|
||||
if (t > tFall0 && t < tLand + .05) {
|
||||
const P = []; for (let k = 6; k >= 0; k--) P.push(fallAt(Math.max(tFall0, t - k * .045)));
|
||||
if (Math.hypot(P[6][0] - P[0][0], P[6][1] - P[0][1]) > 8) paint(ribbon(P, 2, 26), { wash: PAL.cream, fill: PAL.ochre, fillOp: 90, ink: null });
|
||||
const [sx, sy] = fallAt(t); starling(sx, sy, 22, { glow: 1, rot: t * 9 });
|
||||
}
|
||||
if (t > tLand) {
|
||||
const a = t - tLand, k = Math.exp(-a * 1.2);
|
||||
glow(1545, 690, 60 + 240 * (1 - Math.exp(-a * 5)), '#FFD96A', .3 + .9 * k);
|
||||
for (let i = 0; i < 7; i++) { const q = seg(a, i * .03, .5 + i * .03), ang = -Math.PI / 2 + (i - 3) * .35; sparkle(1545 + Math.cos(ang) * 190 * q, 660 + Math.sin(ang) * 170 * q, 18, q); }
|
||||
}
|
||||
hill(1560, 900, 820, 260, mixCol(PAL.teal, PAL.night, .45), t, 10);
|
||||
hill(...FRONT, mixCol(PAL.sap, PAL.night, .35), t, 16);
|
||||
|
||||
// Clawd: dozing → startled → the idea → a turn through the key views → a trot down the hill after it
|
||||
const u = 17, x0 = 760, walk = stroll(t, 3.05, 4.7, x0, 1520, u);
|
||||
const x = t < 3.05 ? x0 : walk.x, gy = hillY(x, ...FRONT) + 4;
|
||||
const mood = emotions(t, [[0, 'sleepy'], [tLand + .05, 'surprised', { lookX: .8 }], [2.55, 'idea'], [3.1, 'excited']]);
|
||||
const facing = t < 2.9 ? {} : t < 3.05 ? turn(t, 2.9, 3.05, 0, .25) : { view: 'side', walk: walk.walk, dy: (mood.dy || 0) * .4 + walk.dy };
|
||||
const slope = (hillY(x + 5, ...FRONT) - hillY(x - 5, ...FRONT)) / 10;
|
||||
clawd(x, gy, u, { ...mood, ...facing, rot: (mood.rot || 0) + Math.atan(slope) * .8 });
|
||||
const eye = toScreen(x, gy - 4 * u); // screen position of Clawd, for the iris
|
||||
camEnd();
|
||||
if (lt < .5) iris(...eye, lerp(0, 1500, easeIn(lt / .5))); // open on Clawd
|
||||
boilSeed('transition');
|
||||
if (lt > dur - .3) brushWipe((lt - (dur - .3)) / .6, NIGHT);
|
||||
}
|
||||
|
||||
// ---------- shot B: the return ----------
|
||||
// The ending is timed as a sequence of reads, one at a time, each held long enough to land:
|
||||
// the throw (fast) → the flight (0.8 s: the eye follows the star up, the camera eases back to give it sky) →
|
||||
// ARRIVAL, the payoff (the star flares and a twinkle ripples across the sky; Clawd only watches, held ~0.45 s) →
|
||||
// Clawd's REACTION (love, 0.45 s after the arrival: cause, then effect; held ~0.6 s) →
|
||||
// goodbye (Clawd waves; 0.45 s later the star twinkles back) → the iris closes to Clawd, holds, and shuts.
|
||||
function shotReturn(t, lt, dur) {
|
||||
const G = 880, u = 24;
|
||||
const tStop = 1.0, tLift = 1.9, tHold = 2.25, tWind = 2.55, tThrow = 2.7, tHome = 3.5, tLove = 3.95, tWave = 4.55, tReply = 5.0, tIris = 5.4;
|
||||
camBegin(960 + 18 * Math.sin(lt * .8), kf(lt, [[0, 530], [tThrow, 515], [tHome + .1, 470]]), kf(lt, [[0, 1], [tThrow, 1.04], [tHome + .1, 1], [dur, 1.06]]));
|
||||
sky(t, 1.4, { x: 1440, y: 150, t0: t - lt + tHome }); // the sky twinkles hello when the star gets home
|
||||
hill(1300, 1180, 1300, 330, mixCol(PAL.teal, PAL.night, .45), t, 0);
|
||||
boilSeed('ground');
|
||||
paint(rectPts(-200, G - 20, W + 400, 400, 3), { wash: mixCol(PAL.sap, PAL.night, .3), fill: mixCol(PAL.sap, PAL.night, .55), fillOp: 100, bleed: .05, tex: .6, ink: null });
|
||||
inkLine([[-200, G - 18], [W / 2, G - 24], [W + 200, G - 16]], 1, PAL.ink, 'ink', .5);
|
||||
|
||||
const walk = stroll(lt, 0, tStop, 300, 950, u), x = walk.x;
|
||||
const mood = emotions(lt, [[0, 'excited'], [1.05, 'surprised', { lookX: .8, lookY: .6 }], [1.45, 'hopeful', { lookX: .6, lookY: .5 }],
|
||||
[tHold, 'starstruck'], [tThrow + .05, 'hopeful', { lookX: .6, lookY: -.9 }], [tLove, 'love']]);
|
||||
let pose;
|
||||
if (lt < tStop) pose = { view: 'side', walk: walk.walk, dy: walk.dy };
|
||||
else if (lt < tLift - .1) pose = turn(lt, tStop, tStop + .12, .25, .125);
|
||||
else if (lt < tLift) pose = { ...turn(lt, tLift - .1, tLift, .125, 0), sq: .18 * ease(seg(lt, tLift - .1, tLift)), aL: -.2, aR: -.2 }; // crouch to pick it up
|
||||
else if (lt < tThrow) {
|
||||
const up = backOut(seg(lt, tLift, tHold)), wind = ease(seg(lt, tWind, tThrow));
|
||||
pose = { aL: lerp(-.2, 1.45, up) - .5 * wind, aR: lerp(-.2, 1.45, up) - .5 * wind, sq: .2 * wind };
|
||||
} else { // follow-through: arms fly up with the throw, then settle while Clawd watches
|
||||
const a = lt - tThrow, arms = 1.5 - 1.0 * ease(seg(a, .3, .8));
|
||||
pose = { aL: arms, aR: arms, sq: -.22 * Math.exp(-a * 7) * Math.cos(a * 18), dy: -1.2 * Math.exp(-a * 6) * Math.max(0, Math.cos(a * 9)) };
|
||||
}
|
||||
const cl = { ...mood, ...pose, sq: (mood.sq || 0) + (pose.sq || 0), dy: (mood.dy || 0) * (lt < tStop ? .3 : 1) + (pose.dy || 0) };
|
||||
if (lt >= tLift) { // arms and height belong to the lift and throw; the mood takes them back once the star is home
|
||||
const back = seg(lt, tHome, tLove);
|
||||
cl.dy = (pose.dy || 0) + (mood.dy || 0) * seg(lt, tThrow + .3, tHome);
|
||||
cl.aL = lerp(pose.aL, mood.aL ?? .2, back); cl.aR = lerp(pose.aR, mood.aR ?? .2, back);
|
||||
}
|
||||
if (lt > tWave) cl.aL = lerp(cl.aL, 1.2 + .5 * Math.sin((lt - tWave) * 13), ease(seg(lt, tWave, tWave + .2))); // waving goodbye, with the left arm: the hearts sit by the right one
|
||||
clawd(x, G, u, cl);
|
||||
|
||||
// the star: sad in the grass → lifted overhead, brightening → thrown home along an arc → home, twinkling back
|
||||
const head = [x, G + (cl.dy || 0) * u - 8 * u * (1 - (cl.sq || 0)) - 46];
|
||||
const ground = [1080, G - 30], home = [1440, 150];
|
||||
if (lt < tLift) starling(...ground, 38, { glow: .15 + .5 * seg(lt, 1.45, 1.9), sad: lt < 1.6, rot: -.15 + .04 * Math.sin(lt * 3) });
|
||||
else if (lt < tThrow) {
|
||||
const k = easeOut(seg(lt, tLift, tHold)), p = arcPt(ground, head, 80, k);
|
||||
starling(p[0], p[1] + 14 * ease(seg(lt, tWind, tThrow)), 38, { glow: .65 + .35 * seg(lt, tLift, tHold + .2), rot: .2 * Math.sin(lt * 7) });
|
||||
} else if (lt < tHome) {
|
||||
const k = easeOut(seg(lt, tThrow, tHome)), p = arcPt(head, home, 260, k);
|
||||
for (let i = 1; i < 9; i++) { const kk = Math.max(0, k - i * .045), q = arcPt(head, home, 260, kk); sparkle(q[0] + jit(4), q[1], 16 - i, .3 + i * .08); }
|
||||
starling(p[0], p[1], lerp(38, 22, k), { glow: 1, rot: lt * 10 * (1 - k) });
|
||||
} else {
|
||||
const a = lt - tHome, flare = Math.exp(-a * 4) + (lt > tReply ? Math.exp(-(lt - tReply) * 5) : 0);
|
||||
glow(...home, 90 + 120 * flare, '#FFE9A8', .6 * clamp(flare));
|
||||
sparkle(...home, 120, seg(a, 0, .55));
|
||||
sparkle(...home, 80, seg(lt, tReply, tReply + .45));
|
||||
starling(...home, 22 * (1 + .3 * clamp(flare)), { glow: .85 + .15 * Math.sin(a * 6), rot: .1 * Math.sin(a * 3) });
|
||||
}
|
||||
const eye = toScreen(x, G - 4 * u);
|
||||
camEnd();
|
||||
boilSeed('transition');
|
||||
if (lt < .3) brushWipe(.5 + lt / .6, NIGHT);
|
||||
if (lt > tIris) { // iris to a small circle on Clawd, hold a moment, then shut
|
||||
const r = lt < tIris + .5 ? lerp(1500, 250, ease(seg(lt, tIris, tIris + .5))) : lt < dur - .3 ? lerp(250, 232, seg(lt, tIris + .5, dur - .3)) : lerp(232, 0, easeIn(seg(lt, dur - .3, dur - .05)));
|
||||
iris(...eye, r);
|
||||
}
|
||||
}
|
||||
|
||||
shots([[0, shotFall], [4.4, shotReturn]]);
|
||||
})();
|
||||
@@ -0,0 +1,43 @@
|
||||
// sheets.js: Clawd's model sheets, as standalone loops. They're reference for you (and the model), not part of a video,
|
||||
// so they're the one place labels are fine. Scrub them at studio.html?loop=views or ?loop=emotions, or render:
|
||||
// node render.mjs --loop=emotions --sheet=1 --cols=1 --w=1920 --out=docs/emotions.jpg
|
||||
(() => {
|
||||
const label = (txt, x, y, size = 20) => letter(txt, x, y, size, PAL.ink, { ink: false, alpha: .8 });
|
||||
const floor = (y, x0 = 0, x1 = W) => inkLine([[x0 + 40, y + 6], [W / 2, y + 4], [x1 - 40, y + 7]], .6, mixCol(PAL.paper, PAL.ink, .35), 'inkfine', .5);
|
||||
|
||||
// Every emotion in EMO, alive. Order follows EMO.
|
||||
LOOPS.emotions = t => {
|
||||
const names = Object.keys(EMO), cols = 8, cw = W / cols, ch = H / 4;
|
||||
names.forEach((name, i) => {
|
||||
const cx = cw * (i % cols) + cw / 2, gy = ch * Math.floor(i / cols) + ch - 58;
|
||||
clawd(cx, gy, 11, feel(name, t, { seed: i }));
|
||||
label(name, cx, gy + 32);
|
||||
});
|
||||
for (let r = 0; r < 4; r++) floor(ch * r + ch - 58);
|
||||
};
|
||||
LOOPS.emotions.len = 4;
|
||||
|
||||
// Views, motion helpers, hats.
|
||||
LOOPS.views = t => {
|
||||
// row 1: the five key views (flip any of them to face left)
|
||||
const views = ['front', 'q', 'side', 'qback', 'back'];
|
||||
views.forEach((v, i) => { const x = 240 + i * 360; clawd(x, 420, 19, { ...feel('neutral', t, { seed: i }), view: v }); label(v, x, 462, 24); });
|
||||
floor(420);
|
||||
// row 2: motion
|
||||
const y2 = 730, u2 = 13, row = [
|
||||
['walk (side)', x => clawd(x, y2, u2, { ...move('walk', t), view: 'side' })],
|
||||
['turn / spinView', x => clawd(x, y2, u2, { ...feel('happy', t), ...spinView(Math.floor(t * 2) / 8) })],
|
||||
['smear', x => clawd(x, y2, u2, { ...feel('excited', t), view: 'side', smear: .8, smearDir: 1, dx: Math.sin(t * TAU / 2) * .3 })],
|
||||
['jump', x => { const k = t % 1.5; clawd(x, y2, u2, { ...feel('happy', t), ...jump(k, .35, .95, 3) }); }],
|
||||
['take', x => clawd(x, y2, u2, { ...feel('surprised', t), ...take(t % 2, .5, 1.2), emoteAge: (t % 2) - .5, emoteK: seg(t % 2, .5, .7) })],
|
||||
['lid', x => clawd(x, y2, u2, { ...feel('laugh', t), mouth: null, lid: .35 + .3 * Math.abs(Math.sin(t * TAU * 1.5)) })],
|
||||
];
|
||||
row.forEach(([name, f], i) => { const x = 180 + i * 312; f(x); label(name, x, y2 + 38); });
|
||||
floor(y2);
|
||||
// row 3: hats
|
||||
const hats = ['party', 'hard', 'crown', 'halo', 'wizard', 'top', 'fedora', 'band', 'beanie', 'bow', 'flower', 'headphones', 'cat', 'masq', 'bowtie'];
|
||||
hats.forEach((h, i) => { const x = 82 + i * 125.5; clawd(x, 985, 8, { ...feel('neutral', t, { seed: i }), hat: h }); label(h, x, 1015, 17); });
|
||||
floor(985);
|
||||
};
|
||||
LOOPS.views.len = 4;
|
||||
})();
|
||||
@@ -0,0 +1,54 @@
|
||||
// timeline.js: the shot list, standalone loops, and a brush-wipe transition.
|
||||
//
|
||||
// shots([[t0, fn], [t1, fn], ...]) registers shots in time order. Each fn(t, lt, dur) is called with t = video time,
|
||||
// lt = time since the shot started, dur = the shot's length. It paints the WHOLE frame, background included, and must be
|
||||
// a pure function of t: frames render in parallel and out of order, so nothing may carry over from one frame to the next.
|
||||
|
||||
const SHOTS = [];
|
||||
function shots(list) { SHOTS.push(...list); SHOTS.sort((a, b) => a[0] - b[0]); }
|
||||
|
||||
// Standalone loops (model sheets, GIFs, tests), outside the main timeline: window.LOOP = LOOPS[name] swaps the whole
|
||||
// frame for that function, called with loop time. Give each a length: LOOPS.x = t => { ... }; LOOPS.x.len = 4;
|
||||
const LOOPS = {};
|
||||
|
||||
function drawWorld(t) {
|
||||
if (window.LOOP) window.LOOP(t);
|
||||
else if (!SHOTS.length) placeholder(t);
|
||||
else {
|
||||
let i = 0; while (i + 1 < SHOTS.length && t >= SHOTS[i + 1][0]) i++;
|
||||
const t0 = SHOTS[i][0], end = i + 1 < SHOTS.length ? SHOTS[i + 1][0] : DUR;
|
||||
SHOTS[i][1](t, t - t0, end - t0);
|
||||
CAM = null;
|
||||
}
|
||||
flushLetters();
|
||||
}
|
||||
|
||||
function placeholder(t) {
|
||||
paint(ellPts(960, 520, 520, 300, 30, 20), { fill: PAL.sky, fillOp: 90, bleed: .3, ink: null });
|
||||
clawd(960, 820, 20, feel('happy', t));
|
||||
}
|
||||
|
||||
// ---------- brush wipe ----------
|
||||
// A transition: fat paint strokes sweep across to cover the frame (p 0 → .5), then drag off (p .5 → 1).
|
||||
// Cut to the next shot at p = .5, under full cover. Call it last in both shots, in screen space (outside a camera):
|
||||
// end of shot A: if (lt > dur - .3) brushWipe((lt - (dur - .3)) / .6);
|
||||
// start of shot B: if (lt < .3) brushWipe(.5 + lt / .6);
|
||||
function brushWipe(p, cols = [PAL.clayDk, PAL.clay]) {
|
||||
if (p <= 0 || p >= 1) return;
|
||||
const [c1, c2] = cols, n = 5, bh = (H + 420) / n + 40;
|
||||
push(); translate(W / 2, H / 2); rotate(-.1); translate(-W / 2, -H / 2);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const y0 = -230 + i * (H + 420) / n, d = [0, .14, .06, .18, .1][i];
|
||||
const q = p < .5 ? easeOut(clamp((p * 2 - d) / (1 - d))) : ease(clamp(((p - .5) * 2 - d) / (1 - d)));
|
||||
const x0 = p < .5 ? -300 : lerp(-300, W + 400, q), x1 = p < .5 ? lerp(-300, W + 400, q) : W + 400;
|
||||
if (x1 - x0 < 30) continue;
|
||||
const pts = [], rag = k => 40 + 50 * hash(i * 31 + k) + jit(12);
|
||||
for (let k = 0; k <= 8; k++) pts.push([lerp(x0, x1, k / 8), y0 + Math.sin(k * .9 + i) * 14 + jit(5)]);
|
||||
for (let k = 1; k < 9; k++) pts.push([x1 + rag(k) - 40, y0 + bh * k / 9]);
|
||||
for (let k = 8; k >= 0; k--) pts.push([lerp(x0, x1, k / 8), y0 + bh + Math.sin(k * .8 + i * 2) * 14 + jit(5)]);
|
||||
if (p >= .5) for (let k = 8; k > 0; k--) pts.push([x0 - rag(k + 20) + 40, y0 + bh * k / 9]);
|
||||
paint(pts, { wash: i % 2 ? c1 : c2, washOp: 255, fill: i % 2 ? c2 : c1, fillOp: 70, bleed: .05, tex: .8, border: .6, ink: null,
|
||||
hatch: { d: 44, a: 0, o: { rand: .6, gradient: .5 }, b: 'charcoal', c: i % 2 ? c2 : PAL.cream, w: .8 } });
|
||||
}
|
||||
pop();
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Clawd studio</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Permanent+Marker&display=block">
|
||||
<style>
|
||||
html, body { margin: 0; background: #1b1820; color: #eee; font: 13px system-ui, sans-serif; }
|
||||
main { padding: 12px; display: grid; gap: 8px; }
|
||||
#out { width: min(100%, 1280px); aspect-ratio: 16 / 9; height: auto; background: #F3EBDC; }
|
||||
.p5Canvas { display: none !important; }
|
||||
.bar { display: flex; gap: 10px; align-items: center; max-width: 1280px; }
|
||||
#scrub { flex: 1; }
|
||||
#tt { font-variant-numeric: tabular-nums; min-width: 190px; }
|
||||
</style>
|
||||
<script src="node_modules/p5/lib/p5.min.js"></script>
|
||||
<script src="node_modules/p5.brush/dist/p5.brush.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<canvas id="out" width="1920" height="1080"></canvas>
|
||||
<div class="bar"><input id="scrub" type="range" min="0" max="11" step="0.0417" value="0"><span id="tt">loading…</span></div>
|
||||
</main>
|
||||
<!-- engine -->
|
||||
<script src="src/config.js"></script>
|
||||
<script src="src/core.js"></script>
|
||||
<script src="src/clawd.js"></script>
|
||||
<script src="src/timeline.js"></script>
|
||||
<!-- reference sheets (standalone loops) -->
|
||||
<script src="src/sheets.js"></script>
|
||||
<!-- your video: add a script per scene file, in order -->
|
||||
<script src="src/scenes/demo.js"></script>
|
||||
<script>
|
||||
// Open studio.html?loop=emotions (or views) to scrub a standalone loop instead of the video.
|
||||
const q = new URLSearchParams(location.search).get('loop'); if (q && LOOPS[q]) window.LOOP = LOOPS[q];
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user