Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8177797bd9 | ||
|
|
36817b6b12 |
No files matched your search
Whitespace-only changes.
@@ -1,84 +0,0 @@
|
||||
---
|
||||
name: after-effects
|
||||
description: This skill should be used when the user asks anything about Adobe After Effects — to "write an After Effects expression", "make a wiggle / loopOut / inertial bounce", "rig a control with a slider / null", "build a MOGRT / Essential Graphics template", "export AE to Lottie or Media Encoder"; OR to "export from After Effects", "export with transparency / an alpha channel", "fix a black background on export", "choose ProRes vs H.264", "which codec / what bitrate for 1080p / 4K", "stop H.264 disappearing from the Render Queue"; OR when "After Effects is slow / laggy / crashing / froze", "RAM preview won't play in real time", "AE is using all my memory / render takes forever", "how do I speed up After Effects"; OR to "organize my After Effects project", "relink missing footage", "fix broken links after moving files", "set a folder structure / naming conventions", "batch rename layers", or "write an ExtendScript / .jsx script". Covers expressions & rigging, export recipes, performance triage, and project hygiene.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# After Effects
|
||||
|
||||
After Effects is GUI software, so this skill delivers knowledge and ready-to-paste assets — expressions, rig setups, export checklists, diagnostic flows, naming schemes, and ExtendScript — rather than a rendered file. It is a hub covering four domains; route to the matching reference for depth and complete recipes.
|
||||
|
||||
## Routing
|
||||
|
||||
| If the user wants to… | Go to |
|
||||
|---|---|
|
||||
| Write/debug an expression, rig sliders & nulls, build a MOGRT, export to Lottie | `references/expressions.md` (deep cookbook in `references/expression-library.md`) |
|
||||
| Choose a codec/container, export with alpha, fix a black background, set bitrate, recover H.264 | `references/export-recipes.md` |
|
||||
| Fix slow/laggy previews, slow renders, crashes, freezes, out-of-memory | `references/performance-triage.md` |
|
||||
| Organize the project, relink missing footage, set naming/folders, batch-automate with .jsx | `references/project-hygiene.md` |
|
||||
|
||||
The `.jsx` automation scripts live in `scripts/`.
|
||||
|
||||
## Expressions & rigging
|
||||
|
||||
Expressions are JavaScript evaluated on the property they are pasted on; `value` is that property's value, `time` is comp time in seconds, and `index`, `thisLayer`, `thisComp` are in scope. An expression overrides keyframes unless it incorporates `value`.
|
||||
|
||||
```js
|
||||
wiggle(2, 30); // 2 wiggles/sec, amplitude 30 (property units)
|
||||
loopOut("cycle"); // repeat keyframed range forever; "pingpong" / "offset" / "continue"
|
||||
seedRandom(index, true); // stable-but-unique per layer (timeless) before random()/wiggle()
|
||||
thisComp.layer("Lead").transform.position.valueAtTime(time - 0.2 * index); // staggered echo
|
||||
```
|
||||
|
||||
Inertial bounce (overshoot/settle after the last keyframe) and the full loop/wiggle/rigging set are in `references/expressions.md`.
|
||||
|
||||
Rig controllable motion: add a null with **Slider / Checkbox / Color / Angle / Point** control effects, then reference them and parent transforms to drive many layers from one.
|
||||
|
||||
```js
|
||||
ctrl = thisComp.layer("Controls");
|
||||
amt = ctrl.effect("Intensity")("Slider"); // Checkbox → 1/0, Color → [r,g,b,a]
|
||||
value * amt;
|
||||
```
|
||||
|
||||
Expose those controls via **Window → Essential Graphics** and **Export Motion Graphics Template** (.mogrt) for Premiere/AE editors.
|
||||
|
||||
## Export recipes
|
||||
|
||||
Two engines, and choosing wrong is the root of most export confusion:
|
||||
- **Render Queue (RQ)** — masters/intermediates: ProRes, image sequences, lossless, alpha.
|
||||
- **Adobe Media Encoder (AME)** — delivery: H.264 MP4, H.265, social presets.
|
||||
|
||||
Since AE CC 2014/2015 the native **H.264 output module was removed from the RQ** — make MP4s in AME, not the RQ. **H.264/MP4 cannot store alpha**, so a transparent comp exported to H.264 always fills with black; that is the classic "black background" complaint and not a bug. For transparency use **ProRes 4444**, a **PNG/TIFF sequence**, or **HEVC-alpha (Safari) + VP9/WebM-alpha (Chrome)**, with Output Module **Channels: RGB + Alpha**.
|
||||
|
||||
Quick targets: editor handoff → **ProRes 422 HQ**; master/grade or alpha → **ProRes 4444**; web/social/client → **H.264 via AME**, VBR 2-pass (~10–16 Mbps 1080p, ~35–45 Mbps 4K). Full matrix, social specs, and step-by-step alpha exports in `references/export-recipes.md`.
|
||||
|
||||
## Performance triage
|
||||
|
||||
Work top-down by symptom, cheapest fix first, and stop when it clears. Most settings are in `Edit > Preferences` (Win) / `After Effects > Settings` (Mac).
|
||||
|
||||
- **Slow previews:** Half/Third resolution → enable **Multi-Frame Rendering** → raise RAM allocation (leave ~6 GB for OS) → Adaptive fast previews → purge cache → Disk Cache on SSD/NVMe → solo heavy layers/effects → HW-accelerated decode → proxy heavy comps.
|
||||
- **Slow renders:** confirm MFR on → RQ for masters / AME for delivery → HW decode H.264 → pre-render slow precomps → cheaper effects → transcode long-GOP to ProRes.
|
||||
- **Crashes/freezes:** purge + empty disk cache → update **NVIDIA Studio** (not Game-Ready) drivers → reset prefs (`Ctrl/Cmd+Alt+Shift` on launch) → pull third-party plugins → free disk space → disable MFR if MFR-specific → roll back AE after a bad update.
|
||||
|
||||
MFR needs several GB RAM per active core. Ordered flows, exact preference paths, and the proxy workflow are in `references/performance-triage.md`.
|
||||
|
||||
## Project hygiene
|
||||
|
||||
Keep projects organized, portable, and automatable.
|
||||
|
||||
- **Folders:** numeric-prefixed bins (`01_Comps`, `02_Precomps`, `03_Footage`, …, `99_Old`) for stable sort order.
|
||||
- **Naming:** `PROJECT_section_descriptor_v01`; precomps `PRE_`; rename solids/nulls to their role (`CTRL_master`); no spaces/special chars for farm/cross-OS safety.
|
||||
- **Broken links:** AE references footage by path, so moving files breaks links. Double-click a missing item → **Replace Footage > File** and AE relinks the whole folder at once; enumerate with `File > Dependencies > Find Missing Footage`. **Always hand off projects via `File > Dependencies > Collect Files`**, never a bare `.aep`.
|
||||
- **ExtendScript (.jsx):** wrap batches in `app.beginUndoGroup(...) / app.endUndoGroup()`; apply effects by **matchName** (e.g. `"ADBE Gaussian Blur 2"`), not the display name. Ready scripts: `scripts/batch-rename.jsx`, `scripts/sequential-offset.jsx`, `scripts/apply-effect-to-layers.jsx`.
|
||||
|
||||
Full naming scheme, label-color system, relink/Collect-Files/Reduce workflows, and scripting setup in `references/project-hygiene.md`.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/expressions.md` — expressions, rigging, MOGRT, Lottie/Media Encoder basics.
|
||||
- `references/expression-library.md` — deeper expression cookbook: tuned inertial bounce, all loop variants, advanced wiggle, valueAtTime echoes, seedRandom grids, full rigging + coordinate spaces, MOGRT and Lottie/Bodymovin steps, Media Encoder presets.
|
||||
- `references/export-recipes.md` — full codec/alpha/bitrate matrix, per-platform social specs, step-by-step transparency exports, AME preset cautions.
|
||||
- `references/performance-triage.md` — ordered preview/render/crash flows, preference paths and values, MFR/GPU/disk-cache tuning, pre-render & proxy workflow.
|
||||
- `references/project-hygiene.md` — folder template, naming and label systems, relink/Collect Files/Reduce details, scripting setup.
|
||||
- `scripts/batch-rename.jsx`, `scripts/sequential-offset.jsx`, `scripts/apply-effect-to-layers.jsx` — ExtendScript automations.
|
||||
</content>
|
||||
@@ -1,168 +0,0 @@
|
||||
# Export Recipes — Codec, Container, Alpha, Bitrate
|
||||
|
||||
Pick the correct codec, container, alpha handling, and bitrate for a given delivery target, and avoid the classic failures: black backgrounds where transparency was expected, H.264 that vanished from the Render Queue, and bloated or muddy files.
|
||||
|
||||
## The two export engines
|
||||
|
||||
After Effects has two output paths. Choosing the wrong one is the root of most export confusion.
|
||||
|
||||
- **Render Queue (RQ)** — `Composition > Add to Render Queue`. Best for mastering/intermediate formats: ProRes, image sequences, lossless, alpha. Renders inside AE.
|
||||
- **Adobe Media Encoder (AME)** — `Composition > Add to Adobe Media Encoder Queue` (or the RQ "Queue in AME" button). Best for delivery/compressed formats: H.264 MP4, H.265, social presets. Renders in a separate app so AE stays usable.
|
||||
|
||||
**Why H.264 disappeared from the Render Queue:** Since AE CC 2014/2015, Adobe removed the native H.264, H.264 Blu-ray, and MPEG-2 output modules from the RQ. To make an MP4, use AME — not the RQ. There is no setting to restore it in the RQ; that workflow is intentional.
|
||||
|
||||
## Decision tree
|
||||
|
||||
```
|
||||
Need transparency (alpha) in the final file?
|
||||
├─ YES, for an editor/compositor (NLE) → ProRes 4444 (.mov), RGB+Alpha, RQ
|
||||
│ └─ smaller, still alpha? → PNG sequence or TIFF sequence (RGB+Alpha)
|
||||
├─ YES, for the web/browser → dual export:
|
||||
│ • HEVC with alpha (.mov, Safari/iOS)
|
||||
│ • VP9/WebM with alpha (Chrome/Firefox/Edge)
|
||||
└─ NO alpha needed
|
||||
├─ Final delivery / upload (YouTube, social, client review) → H.264 MP4 via AME
|
||||
├─ Editorial / intermediate / grading → ProRes 422 HQ or 422 (.mov), RQ
|
||||
├─ Mastering / archival, highest fidelity → ProRes 4444 or lossless / image seq
|
||||
└─ Tiny looping clip, limited colors → GIF (via AME / Photoshop), or WebM
|
||||
```
|
||||
|
||||
## Transparency — the black background problem
|
||||
|
||||
H.264/MP4 **cannot store an alpha channel**. Exporting a comp with transparent areas to H.264 always fills them with black (or white). This is the single most common export complaint and it is not a bug.
|
||||
|
||||
To actually keep transparency:
|
||||
|
||||
1. Use a format that supports alpha: **ProRes 4444**, **PNG/TIFF sequence**, **HEVC-with-alpha**, or **VP9/WebM-with-alpha**.
|
||||
2. In the RQ Output Module, set **Channels: RGB + Alpha** and **Depth** to Millions of Colors+ (the "+" is the alpha).
|
||||
3. Choose a matte type: **Premultiplied (Matted with color)** for most cases; **Straight (Unmatted)** if the compositor explicitly wants straight alpha. Straight alpha avoids dark/light fringing on edges when re-composited, but only some apps import it cleanly.
|
||||
|
||||
For web transparent video, no single file plays everywhere. Ship two and use the HTML `<video>` element with multiple `<source>` tags: HEVC-alpha for Safari/iOS, VP9/WebM-alpha for Chromium/Firefox.
|
||||
|
||||
## Bitrate guidance (H.264 via AME)
|
||||
|
||||
Set in AME Export Settings → Video → Bitrate Settings.
|
||||
|
||||
- **Encoding:** VBR, 2 pass (better quality per byte than CBR or 1-pass).
|
||||
- **1080p30:** Target ~10–16 Mbps, Max ~20 Mbps.
|
||||
- **1080p60:** Target ~16–24 Mbps.
|
||||
- **4K30:** Target ~35–45 Mbps, Max ~55 Mbps.
|
||||
- **4K60:** Target ~50–68 Mbps.
|
||||
- Higher target/max for high-motion or grainy footage; lower for flat graphics/motion design.
|
||||
- For uploading to YouTube/Vimeo, lean to the high end — the platform re-encodes, so feed it a clean, high-bitrate master rather than over-compressing twice.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Target | Engine | Codec / Container | Channels | Bitrate / Notes |
|
||||
|---|---|---|---|---|
|
||||
| YouTube / Vimeo upload | AME | H.264 .mp4 | RGB | VBR 2-pass, 1080p ~16 Mbps / 4K ~45 Mbps |
|
||||
| Instagram / TikTok / Reels | AME | H.264 .mp4 | RGB | 1080×1920 or 1080×1080, ~10–14 Mbps |
|
||||
| Client review | AME | H.264 .mp4 | RGB | VBR 2-pass ~10–16 Mbps |
|
||||
| Editorial / hand to editor | RQ | ProRes 422 HQ .mov | RGB | Mezzanine; large but edit-friendly |
|
||||
| Mastering / grading | RQ | ProRes 4444 .mov | RGB(+A) | Highest fidelity, optional alpha |
|
||||
| Transparency → NLE | RQ | ProRes 4444 .mov | RGB+Alpha | Millions+; premultiplied usually |
|
||||
| Transparency → frames | RQ | PNG or TIFF sequence | RGB+Alpha | Lossless per-frame, robust |
|
||||
| Transparent web (Safari) | AME/RQ | HEVC w/ alpha .mov | RGB+Alpha | Pair with VP9 below |
|
||||
| Transparent web (Chrome) | AME/3rd-party | VP9 / WebM w/ alpha | RGB+Alpha | `yuva420p` alpha mode |
|
||||
| Looping clip, few colors | AME/PS | GIF or WebM | — | GIF = large/limited; prefer WebM |
|
||||
| Archival lossless | RQ | Lossless / PNG seq | RGB(+A) | Huge files |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **H.264 in the RQ is gone** — use AME. Don't search for a missing module.
|
||||
- **Black background = wrong codec.** Switching matte/straight settings won't help if the codec has no alpha. Change the codec first.
|
||||
- **"Millions of Colors" has no alpha; "Millions of Colors+" does.** The `+` is the alpha channel in RQ Depth.
|
||||
- **ProRes on Windows** is fully supported for export in modern AE; no extra codec install needed.
|
||||
- **GIF is rarely the right answer** — large files, dithered color, no real alpha softness. Prefer WebM/VP9 or short H.264 loops.
|
||||
- **Don't double-compress.** Master to ProRes/lossless, then make one H.264 from that — not H.264 from H.264.
|
||||
- **AME "Match Source"** presets can silently downscale or drop the bitrate; verify resolution and bitrate before queuing.
|
||||
- **Render multiple outputs efficiently:** in the RQ, one render item can have several Output Modules (e.g. ProRes master + PNG seq) from a single render pass.
|
||||
|
||||
---
|
||||
|
||||
# Codec & Export Matrix — Detailed Reference
|
||||
|
||||
## ProRes family — when to use which
|
||||
|
||||
| Variant | Use for | Alpha | Relative size |
|
||||
|---|---|---|---|
|
||||
| ProRes 422 Proxy | Offline edit, rough cuts | No | Smallest |
|
||||
| ProRes 422 LT | Lightweight editorial | No | Small |
|
||||
| ProRes 422 | General editorial | No | Medium |
|
||||
| ProRes 422 HQ | High-quality editorial / delivery to editor | No | Large |
|
||||
| ProRes 4444 | Mastering, grading, **transparency** | Yes | Larger |
|
||||
| ProRes 4444 XQ | Highest fidelity, HDR, max-quality alpha | Yes | Largest |
|
||||
|
||||
Rule of thumb: hand an editor **422 HQ**; master/keep alpha with **4444**.
|
||||
|
||||
## Full target → settings matrix
|
||||
|
||||
| Delivery target | Engine | Container | Codec | Resolution | Channels / Depth | Bitrate / Quality | Audio |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| YouTube 1080p | AME | .mp4 | H.264 | 1920×1080 | RGB, 8-bit | VBR 2-pass, Tgt 16 / Max 20 Mbps | AAC 320 kbps 48 kHz |
|
||||
| YouTube 4K | AME | .mp4 | H.264 | 3840×2160 | RGB, 8-bit | VBR 2-pass, Tgt 45 / Max 55 Mbps | AAC 384 kbps |
|
||||
| Vimeo 1080p | AME | .mp4 | H.264 | 1920×1080 | RGB | VBR 2-pass, Tgt 20 Mbps | AAC 320 kbps |
|
||||
| Instagram Feed | AME | .mp4 | H.264 | 1080×1080 or 1080×1350 | RGB | ~10–12 Mbps | AAC 128–256 kbps |
|
||||
| Instagram Reels / TikTok | AME | .mp4 | H.264 | 1080×1920 | RGB | ~12–14 Mbps | AAC 256 kbps |
|
||||
| X / Twitter | AME | .mp4 | H.264 | 1280×720 or 1080p | RGB | ~6–10 Mbps | AAC |
|
||||
| Broadcast delivery | RQ | .mov | ProRes 422 HQ | as spec (often 1080i/p) | RGB, 10-bit | — | PCM 48 kHz |
|
||||
| Editorial handoff | RQ | .mov | ProRes 422 HQ | comp size | RGB | — | PCM |
|
||||
| Color grading master | RQ | .mov | ProRes 4444 | comp size | RGB, 12-bit | — | PCM |
|
||||
| VFX plate / alpha to NLE | RQ | .mov | ProRes 4444 | comp size | RGB+Alpha, Millions+ | premultiplied | — |
|
||||
| Compositing frames | RQ | seq | PNG or 16-bit TIFF | comp size | RGB+Alpha | lossless | — |
|
||||
| EXR for VFX pipeline | RQ | seq | OpenEXR | comp size | RGB+Alpha, 32-bit float | linear/ACES | — |
|
||||
| Transparent web (Apple) | AME or RQ | .mov | HEVC w/ alpha | comp size | RGB+Alpha | quality ~ 60–80% | optional |
|
||||
| Transparent web (Chrome) | 3rd-party/ffmpeg | .webm | VP9 (yuva420p) | comp size | RGB+Alpha | CRF ~30 | optional |
|
||||
| Animated icon / loop | AME or PS | .gif / .webm | GIF / VP9 | small | indexed / RGBA | minimize frames | — |
|
||||
|
||||
## Transparency — step by step
|
||||
|
||||
### ProRes 4444 with alpha (Render Queue)
|
||||
1. `Composition > Add to Render Queue`.
|
||||
2. Click the blue **Output Module** text.
|
||||
3. Format: **QuickTime**. Click **Format Options** → Codec: **Apple ProRes 4444**.
|
||||
4. **Channels: RGB + Alpha**. **Depth: Millions of Colors+**.
|
||||
5. **Color: Premultiplied (Matted)** for most uses; choose **Straight (Unmatted)** only if the downstream app asks for straight alpha.
|
||||
6. Set Output To path, then **Render**.
|
||||
|
||||
### PNG / TIFF sequence with alpha (Render Queue)
|
||||
1. Add to RQ → Output Module.
|
||||
2. Format: **PNG Sequence** (or **TIFF Sequence**).
|
||||
3. **Channels: RGB + Alpha**, Depth **Millions of Colors+** (PNG) or 16-bit (TIFF).
|
||||
4. Output To a **dedicated empty folder** — sequences write one file per frame.
|
||||
|
||||
### HEVC with alpha (Apple/Safari)
|
||||
- In AME, choose **H.265 (HEVC)** and enable an alpha/transparency option where available, or use the QuickTime HEVC export with RGB+Alpha. Verify playback in Safari; Chrome will not show its alpha.
|
||||
|
||||
### VP9 / WebM with alpha (Chrome)
|
||||
- AME does not reliably produce alpha WebM. Render a ProRes 4444 or PNG sequence from AE, then encode with ffmpeg:
|
||||
- `ffmpeg -i master.mov -c:v libvpx-vp9 -pix_fmt yuva420p -b:v 0 -crf 30 out.webm`
|
||||
- `yuva420p` is the alpha-carrying pixel format; without it WebM has no transparency.
|
||||
|
||||
### HTML for cross-browser transparent video
|
||||
```html
|
||||
<video autoplay loop muted playsinline>
|
||||
<source src="clip.mov" type="video/quicktime"> <!-- HEVC alpha, Safari/iOS -->
|
||||
<source src="clip.webm" type="video/webm"> <!-- VP9 alpha, Chrome/Firefox -->
|
||||
</video>
|
||||
```
|
||||
|
||||
## Alpha matte types explained
|
||||
- **Straight (Unmatted):** color stored independent of alpha; cleanest edges when re-composited, but fewer apps import correctly.
|
||||
- **Premultiplied (Matted with color):** color blended with a matte color (usually black). Most compatible; can show fringing if the wrong matte color is assumed downstream.
|
||||
- When in doubt for NLE handoff, **premultiplied with black** is the safe default.
|
||||
|
||||
## AME preset cautions
|
||||
- **Match Source – High bitrate** can downscale to the source clip rather than the comp; confirm output resolution.
|
||||
- Always check **VBR 2-pass** is selected for final masters; presets sometimes default to 1-pass.
|
||||
- Use **Render at Maximum Depth** and **Use Maximum Render Quality** for scaling-heavy comps (slower but cleaner).
|
||||
- For social verticals, set the resolution explicitly — don't trust a 16:9 preset.
|
||||
|
||||
## Bitrate cheat sheet (H.264 VBR 2-pass)
|
||||
| Resolution / fps | Target Mbps | Max Mbps |
|
||||
|---|---|---|
|
||||
| 720p30 | 5–8 | 10 |
|
||||
| 1080p30 | 10–16 | 20 |
|
||||
| 1080p60 | 16–24 | 30 |
|
||||
| 4K30 | 35–45 | 55 |
|
||||
| 4K60 | 50–68 | 80 |
|
||||
@@ -1,184 +0,0 @@
|
||||
# After Effects Expression Library, Rigging & Export
|
||||
|
||||
A deeper cookbook. All expressions are JavaScript (the modern AE expression engine). They evaluate on the property they are pasted on, where `value`, `time`, `index`, `thisLayer`, `thisComp` are in scope.
|
||||
|
||||
## Inertial bounce (parameterized)
|
||||
|
||||
```js
|
||||
// Overshoot/settle after the most recent keyframe. Tune amp/freq/decay.
|
||||
amp = 0.10; // 0.05 = subtle, 0.2 = bouncy
|
||||
freq = 3.0; // oscillations per second
|
||||
decay = 5.0; // higher = settles faster
|
||||
|
||||
n = 0;
|
||||
if (numKeys > 0) {
|
||||
n = nearestKey(time).index;
|
||||
if (key(n).time > time) n--;
|
||||
}
|
||||
if (n === 0) {
|
||||
value;
|
||||
} else {
|
||||
t = time - key(n).time;
|
||||
v = velocityAtTime(key(n).time - thisComp.frameDuration / 10);
|
||||
value + v * amp * Math.sin(freq * t * 2 * Math.PI) / Math.exp(decay * t);
|
||||
}
|
||||
```
|
||||
|
||||
Inertial bounce works on multi-dimensional properties because `velocityAtTime` returns a vector; the arithmetic is per-component.
|
||||
|
||||
## Loop variants
|
||||
|
||||
```js
|
||||
loopOut("cycle"); // A B C A B C ...
|
||||
loopOut("pingpong"); // A B C B A B C ...
|
||||
loopOut("offset"); // continues accumulating the delta each cycle
|
||||
loopOut("continue"); // linear extension of the final velocity (no loop)
|
||||
loopOut("cycle", 3); // loop only the last 3 keyframes
|
||||
loopIn("cycle", 2); // loop the first 2 keyframes, backwards in time
|
||||
loopOutDuration("cycle", 2); // loop the last 2 SECONDS of keyframes
|
||||
```
|
||||
|
||||
Use `offset` for endless conveyor/scroll motion; `continue` for inertia past the last keyframe without oscillation.
|
||||
|
||||
## Wiggle — advanced
|
||||
|
||||
```js
|
||||
// Full signature: wiggle(freq, amp, octaves, amp_mult, time)
|
||||
wiggle(2, 30, 3, 0.5); // 3 octaves adds finer detail
|
||||
|
||||
// Per-axis wiggle (different amplitude X vs Y)
|
||||
x = wiggle(2, 50)[0];
|
||||
y = wiggle(2, 10)[1];
|
||||
[x, y];
|
||||
|
||||
// Wiggle only rotation while keeping keyframed position
|
||||
wiggle(1, 5); // on Rotation
|
||||
|
||||
// Smooth, looping ambient motion without true wiggle (loopable):
|
||||
amp = 20; period = 4; // seconds
|
||||
[Math.sin(time / period * 2 * Math.PI) * amp,
|
||||
Math.cos(time / period * 2 * Math.PI) * amp] + value;
|
||||
```
|
||||
|
||||
`wiggle` is non-looping noise; for seamless loops use periodic sin/cos as above.
|
||||
|
||||
## Stable randomness across a grid
|
||||
|
||||
```js
|
||||
seedRandom(index, true); // timeless=true → constant over time, varies per layer
|
||||
startDelay = random(0, 0.5); // each layer gets a stable random delay
|
||||
opacity = ease(time, startDelay, startDelay + 0.4, 0, 100);
|
||||
```
|
||||
|
||||
`seedRandom(index, false)` (timeless=false) lets `random()` vary over time — use for flicker; `true` for stable offsets.
|
||||
|
||||
## Time remap & valueAtTime echoes
|
||||
|
||||
```js
|
||||
// Trailing/echo: sample the lead layer in the past, staggered by index
|
||||
delay = 0.08 * (index - 1);
|
||||
thisComp.layer("Lead").transform.position.valueAtTime(time - delay);
|
||||
|
||||
// Speed-ramp a time-remapped layer using easing on the source time
|
||||
src = thisComp.layer("Source");
|
||||
linear(time, 0, 2, 0, src.source.duration); // play a 2s window of the source
|
||||
|
||||
// Sample own value in the past for motion smear / trail
|
||||
valueAtTime(time - 0.1);
|
||||
```
|
||||
|
||||
## Rigging walkthrough
|
||||
|
||||
### Control types and how to read them
|
||||
|
||||
```js
|
||||
ctrl = thisComp.layer("Controls");
|
||||
ctrl.effect("Intensity")("Slider"); // number
|
||||
ctrl.effect("Visible")("Checkbox"); // 1 or 0
|
||||
ctrl.effect("Brand")("Color"); // [r,g,b,a] 0..1
|
||||
ctrl.effect("Spin")("Angle"); // degrees
|
||||
ctrl.effect("Anchor")("Point"); // [x,y]
|
||||
```
|
||||
|
||||
### One slider, staggered reveal across N layers
|
||||
|
||||
```js
|
||||
// On each layer's Opacity:
|
||||
master = thisComp.layer("Controls").effect("Reveal")("Slider"); // 0..100 driver
|
||||
per = 6; // frames of stagger per layer
|
||||
shift = framesToTime(per * (index - 1));
|
||||
ease(master, 0, 100, 0, 100) * (time > shift ? 1 : 0);
|
||||
```
|
||||
|
||||
### Coordinate-space conversions
|
||||
|
||||
```js
|
||||
// Place this layer where another parented layer appears in comp space
|
||||
target = thisComp.layer("Marker");
|
||||
fromComp(target.toComp(target.anchorPoint));
|
||||
|
||||
// World position of a point on this layer
|
||||
toComp([0, 0]);
|
||||
```
|
||||
|
||||
`toComp` converts layer space → composition space; `fromComp` does the inverse. Use them whenever layers are parented or live in different (nested) comps.
|
||||
|
||||
### Linking many layers to a master null
|
||||
|
||||
1. Select all children → set Parent to the null.
|
||||
2. Animate only the null; children inherit transform.
|
||||
3. For non-transform links (effects, opacity), pick-whip to the control layer's effect.
|
||||
|
||||
## Essential Graphics / MOGRT export steps
|
||||
|
||||
1. **Window → Essential Graphics**, choose the master comp.
|
||||
2. Drag exposed properties (Slider, Checkbox, Color, Text Source, Media replacement) into the panel.
|
||||
3. Add **Group** headers, rename controls, fill comment fields for editors.
|
||||
4. Use **Drop-Zone / Media Replacement** for swappable footage/logos.
|
||||
5. Click **Export Motion Graphics Template**, choose destination (Local Templates Folder / Library / file). Toggle "Make fonts available" and check "Raster/vector" compatibility for Premiere.
|
||||
6. Test in Premiere Pro's Essential Graphics → Browse panel.
|
||||
|
||||
Keep all motion driven by expressions referencing the exposed controls so editors only touch the exposed params; hide internal layers with shy.
|
||||
|
||||
## Lottie / Bodymovin export
|
||||
|
||||
Supported reliably:
|
||||
- Shape layers, solids, transforms (position/scale/rotation/opacity/anchor).
|
||||
- Masks and track mattes (alpha), parenting, basic gradients.
|
||||
- Trim Paths, simple repeaters.
|
||||
- Expressions: enable in Bodymovin settings, but verify on the target renderer — `lottie-web`, lottie-ios, and lottie-android differ in expression and effect support.
|
||||
|
||||
Not supported / risky (bake or avoid):
|
||||
- Blend modes, most native Effects, layer blurs, 3D layers/cameras, time remapping, adjustment-layer effects.
|
||||
|
||||
Workflow:
|
||||
1. Install Bodymovin extension; **Window → Extensions → Bodymovin**.
|
||||
2. Select comp(s), set destination, choose Standard/Demo.
|
||||
3. Settings: enable "Glyphs"/"Hidden", "Expressions" only if needed; "Assets" → export images or use base64.
|
||||
4. Render `data.json`. Preview in a Lottie player; check file size and that masks/mattes survived.
|
||||
5. For web, load with `lottie-web`:
|
||||
|
||||
```js
|
||||
import lottie from 'lottie-web';
|
||||
lottie.loadAnimation({
|
||||
container: document.getElementById('lottie'),
|
||||
renderer: 'svg', // 'canvas' for heavy fills, better perf
|
||||
loop: true, autoplay: true,
|
||||
path: '/data.json',
|
||||
});
|
||||
```
|
||||
|
||||
## Media Encoder presets
|
||||
|
||||
| Use | Codec / format | Settings |
|
||||
|---|---|---|
|
||||
| Web/social delivery | H.264 (MP4) | VBR 2-pass, 1080p ~12–16 Mbps; 4K ~35–50 Mbps |
|
||||
| Master/intermediate | ProRes 422 HQ (MOV) | full range, matched fps |
|
||||
| With alpha | ProRes 4444 (MOV) | enable alpha channel |
|
||||
| Lossless interchange | PNG sequence or ProRes 4444 | per-frame, alpha |
|
||||
| GIF | via Media Encoder/Photoshop | reduce palette, dither, cap dimensions |
|
||||
|
||||
Tips:
|
||||
- Match the comp frame rate; avoid frame-blending unless intentionally retiming.
|
||||
- H.264 cannot store alpha — composite onto a background or use ProRes 4444 / PNG.
|
||||
- For crisp text, render at the delivery resolution (don't upscale); set quality to maximum and use 2-pass VBR.
|
||||
@@ -1,134 +0,0 @@
|
||||
# Expressions, Rigging & MOGRT
|
||||
|
||||
After Effects is GUI software, so this delivers knowledge: ready-to-paste JavaScript expressions, rigging setups that make motion controllable, and template/export pipelines. Expressions read on the property they are pasted on; `value` is that property's keyframed/static value, `time` is the composition time in seconds, and `index`, `thisLayer`, `thisComp` are in scope.
|
||||
|
||||
For a deeper cookbook (tuned inertial bounce, every loop variant, advanced per-axis wiggle, `valueAtTime` echoes, `seedRandom` grids, full rigging walkthrough, Essential Graphics → MOGRT steps, Lottie/Bodymovin + Media Encoder presets), see `expression-library.md`.
|
||||
|
||||
## Core expressions
|
||||
|
||||
### Inertial bounce (overshoot after the last keyframe)
|
||||
|
||||
Paste on a Position, Scale, or Rotation property. It continues the velocity at the final keyframe into a damped sine — the classic "settle" motion.
|
||||
|
||||
```js
|
||||
amp = 0.08; // overshoot amount
|
||||
freq = 2.5; // oscillations per second
|
||||
decay = 6.0; // how fast it settles
|
||||
n = 0;
|
||||
if (numKeys > 0) {
|
||||
n = nearestKey(time).index;
|
||||
if (key(n).time > time) n--;
|
||||
}
|
||||
if (n > 0) {
|
||||
t = time - key(n).time;
|
||||
v = velocityAtTime(key(n).time - 0.001);
|
||||
value + v * (amp * Math.sin(freq * t * 2 * Math.PI) / Math.exp(decay * t));
|
||||
} else {
|
||||
value;
|
||||
}
|
||||
```
|
||||
|
||||
### Looping
|
||||
|
||||
```js
|
||||
loopOut("cycle"); // repeat keyframed range forever
|
||||
loopOut("pingpong"); // bounce back and forth
|
||||
loopOut("offset"); // continue, accumulating (e.g. continuous travel)
|
||||
loopOut("continue"); // extend final velocity linearly
|
||||
loopOut("cycle", 2); // loop only the last 2 keyframes
|
||||
loopIn("cycle"); // mirror, before the first keyframe
|
||||
```
|
||||
|
||||
### Wiggle and stable randomness
|
||||
|
||||
```js
|
||||
wiggle(2, 30); // 2 wiggles/sec, amplitude 30 (units match the property)
|
||||
seedRandom(index, true); // unique-but-stable per layer; timeless=true
|
||||
random(0, 100); // stable after seedRandom
|
||||
wiggle(freq, amp, octaves=1, amp_mult=0.5, t=time); // full signature
|
||||
```
|
||||
|
||||
`seedRandom(index, true)` before `random()` or `wiggle()` makes each layer differ but stay constant across renders — essential for grids of layers that must not flicker.
|
||||
|
||||
### Time remap & value-at-time
|
||||
|
||||
```js
|
||||
// Sample another property/layer at a shifted time (echo/trailing motion)
|
||||
delay = 0.2 * index;
|
||||
thisComp.layer("Lead").transform.position.valueAtTime(time - delay);
|
||||
|
||||
// Speed up a precomp via time remap
|
||||
linear(time, 0, thisComp.duration, 0, 6); // 6s source mapped over comp duration
|
||||
```
|
||||
|
||||
## Rigging
|
||||
|
||||
Make motion controllable instead of hand-keyed:
|
||||
|
||||
1. Add a null/control layer (often a "Controls" layer). Add **Slider Control**, **Checkbox Control**, **Color Control**, **Angle Control**, or **Point Control** effects to it.
|
||||
2. Reference them with the pick-whip or by path:
|
||||
|
||||
```js
|
||||
ctrl = thisComp.layer("Controls");
|
||||
amt = ctrl.effect("Intensity")("Slider"); // Slider Control → ADBE Slider Control-0001
|
||||
on = ctrl.effect("Enable")("Checkbox"); // Checkbox: 1 or 0
|
||||
col = ctrl.effect("Tint")("Color");
|
||||
|
||||
if (on == 1) value * amt else value;
|
||||
```
|
||||
|
||||
3. Parent transforms to a null so one move drives many layers. Use `toComp`/`fromComp` to convert coordinate spaces when layers are parented or in different comps.
|
||||
|
||||
```js
|
||||
// Stagger N layers off one master slider
|
||||
master = thisComp.layer("Controls").effect("Reveal")("Slider");
|
||||
delay = 4 * index; // 4 frames per layer
|
||||
master.valueAtTime(time - framesToTime(delay));
|
||||
```
|
||||
|
||||
## Essential Graphics / MOGRT
|
||||
|
||||
1. Open **Window → Essential Graphics**, pick the comp as the Master.
|
||||
2. Drag the Slider/Checkbox/Color/Text source properties into the panel to expose them.
|
||||
3. Group, rename, and set comment fields for editors.
|
||||
4. **Export Motion Graphics Template** → a `.mogrt` usable in Premiere Pro / AE. Keep driving expressions referencing the exposed controls so editors change only the exposed params.
|
||||
|
||||
Set "Allow fonts to substitute" off and embed/package fonts where possible so the template renders consistently elsewhere.
|
||||
|
||||
## Export from expressions/template work
|
||||
|
||||
### To Lottie (Bodymovin / `@lottiefiles/bodymovin`)
|
||||
|
||||
- Stick to Lottie-supported features: shape layers, transforms, masks, track mattes, basic expressions converted to keyframes.
|
||||
- Avoid blur-heavy effects, blend modes, 3D layers, and most native effects — they don't translate.
|
||||
- Bake expressions to keyframes if the renderer is strict, or use the `expressions` flag in Bodymovin and test on the target player (web/iOS/Android renderers differ).
|
||||
- Run **Bodymovin → Render** to produce `data.json` (+ optional images). Verify in a Lottie preview before handoff.
|
||||
|
||||
### To video (Media Encoder)
|
||||
|
||||
- **File → Export → Add to Adobe Media Encoder Queue** (or render queue for AE-native).
|
||||
- Delivery: **H.264** (MP4), bitrate ~10–20 Mbps for 1080p, VBR 2-pass.
|
||||
- Master/intermediate: **ProRes 422 HQ** (or ProRes 4444 when alpha is needed).
|
||||
- Transparency: ProRes 4444 or PNG/QuickTime with an alpha channel; H.264 cannot carry alpha. (See `export-recipes.md` for the full codec/alpha/bitrate matrix.)
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Goal | Expression |
|
||||
|---|---|
|
||||
| Loop keyframes | `loopOut("cycle")` |
|
||||
| Continuous travel | `loopOut("offset")` |
|
||||
| Subtle life | `wiggle(2, 20)` |
|
||||
| Stable per-layer random | `seedRandom(index,true); random(...)` |
|
||||
| Settle/overshoot | inertial bounce (above) |
|
||||
| Echo another layer | `valueAtTime(time - delay)` |
|
||||
| Read a slider | `effect("Name")("Slider")` |
|
||||
| Frames ↔ seconds | `framesToTime(n)` / `timeToFrames(t)` |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- An expression overrides keyframes on that property unless it incorporates `value`; start most expressions from `value`.
|
||||
- `index` is the layer's stacking number — it changes if layers are reordered; prefer named lookups for stable rigs.
|
||||
- Expression errors disable the expression and show a yellow banner; check the property's path names (effect names must match exactly, including duplicates' suffixes).
|
||||
- Pre-compose for reuse; name layers; use shy/lock and guide layers for safe areas before handoff.
|
||||
</content>
|
||||
</invoke>
|
||||
@@ -1,138 +0,0 @@
|
||||
# Performance Triage — Slow, Laggy, Crashing, Frozen
|
||||
|
||||
Diagnose slow previews, slow renders, freezes, and crashes in a fixed order, from cheapest fix to most invasive. Work top-down and stop when the symptom clears — most issues are solved in the first few steps.
|
||||
|
||||
Most fixes live in Preferences. Open `Edit > Preferences` (Windows) or `After Effects > Settings/Preferences` (macOS).
|
||||
|
||||
## First: identify the symptom
|
||||
|
||||
- **Slow / non-real-time previews (RAM preview)** → Flow A
|
||||
- **Slow renders (final output)** → Flow B
|
||||
- **Crashes / freezes / hangs** → Flow C
|
||||
|
||||
## Flow A — Slow previews (do in order)
|
||||
|
||||
1. **Set Resolution to Auto/Half/Third.** In the Composition viewer, change Resolution from Full to **Half** or **Third**. Adaptive/Draft resolution gives the biggest instant win.
|
||||
2. **Enable Multi-Frame Rendering (MFR).** `Preferences > Memory & Performance > Enable Multi-Frame Rendering`. Uses all CPU cores for preview and render (AE 2022+). Confirm "CPUs reserved for other applications" isn't set too high.
|
||||
3. **Check RAM allocation.** `Preferences > Memory & Performance`. Leave ~6 GB for the OS; give the rest to AE. Too little RAM = a tiny green preview bar.
|
||||
4. **Set Fast Previews to Adaptive Resolution.** Viewer's lightning-bolt icon → **Adaptive Resolution** (degrades resolution only while interacting).
|
||||
5. **Purge the cache.** `Edit > Purge > All Memory & Disk Cache`. Stale/full cache stalls previews.
|
||||
6. **Verify the Disk Cache is on a fast drive.** `Preferences > Media & Disk Cache` → Enable Disk Cache, point it at a fast **SSD/NVMe**, give it 50–100+ GB. Never on a slow external HDD.
|
||||
7. **Solo the offending layer/effect.** Toggle effects off (the fx switch) or solo layers to find the bottleneck. Heavy culprits: blurs, glows, Mocha/tracking, particles, expressions, large 3D, and many third-party plugins.
|
||||
8. **Enable GPU / hardware-accelerated decoding.** `Preferences > Video Preview` (Mercury GPU) and `Preferences > Import > Hardware Accelerate H.264/HEVC decoding`. Helps long-GOP source footage.
|
||||
9. **Pre-render or proxy heavy comps.** Pre-render a finished/slow precomp to ProRes and set it as a **proxy** (`File > Create Proxy`, or right-click footage → Set Proxy). Frees compute for the part being worked on.
|
||||
10. **Trim the work area** to just the section in progress so RAM preview fills faster.
|
||||
|
||||
## Flow B — Slow renders (do in order)
|
||||
|
||||
1. **Confirm Multi-Frame Rendering is on** (`Memory & Performance`) — the single largest render speedup on multi-core CPUs.
|
||||
2. **Render with the Render Queue for masters, AME for delivery** — and don't run heavy work in AE while AME renders.
|
||||
3. **Enable hardware-accelerated decoding** for H.264/HEVC sources (`Preferences > Import`).
|
||||
4. **Pre-render slow precomps** once and reuse them, instead of recomputing every frame. Heavy effect stacks, time-remapping, and 3rd-party renderers benefit most.
|
||||
5. **Replace effects with cheaper equivalents** where possible (e.g., Fast Box Blur over Gaussian; native over plugin).
|
||||
6. **Convert long-GOP source to an intermediate** (ProRes/DNxHR). Decoding H.264 every frame is slow; ProRes is cheap to read.
|
||||
7. **Check the renderer:** `Composition > Composition Settings > 3D Renderer`. Classic 3D is fastest; Cinema 4D/Ray-traced renderers are far slower.
|
||||
8. **Disk Cache on NVMe** so repeated frames aren't recomputed.
|
||||
9. **Close other apps**, especially other Adobe apps and browsers, to free RAM/CPU for MFR.
|
||||
|
||||
## Flow C — Crashes / freezes / hangs (do in order)
|
||||
|
||||
1. **Purge cache** (`Edit > Purge > All`). A full/corrupt cache causes hangs.
|
||||
2. **Clear the Disk Cache folder** in `Preferences > Media & Disk Cache > Empty Disk Cache`. Corrupt cache files cause crash-on-open.
|
||||
3. **Update GPU drivers** (NVIDIA Studio driver / AMD). Outdated/Game-Ready drivers cause Mercury GPU crashes. As a test, switch Project renderer GPU→CPU (`File > Project Settings > Video Rendering and Effects: Mercury Software Only`).
|
||||
4. **Reset Preferences:** hold `Ctrl+Alt+Shift` (Win) / `Cmd+Opt+Shift` (Mac) while launching AE. Corrupt prefs are a top crash cause.
|
||||
5. **Disable third-party plugins** by temporarily moving them out of the plug-ins folder, then reintroduce to find the offender. Old plugins are a leading crash/freeze source after AE updates.
|
||||
6. **Check Disk Cache and Output drives for free space.** A full scratch/cache disk causes freezes mid-render.
|
||||
7. **Lower or disable MFR** if crashes appear only during multi-frame render — some plugins are not MFR-safe; reserve more CPUs or disable MFR for those projects.
|
||||
8. **Roll back the AE version** if crashes started right after an update. Install a previous version via Creative Cloud (Other Versions) — many crash waves are version-specific regressions.
|
||||
9. **Look for a single corrupt asset** — import comps one at a time, or replace footage, to isolate a bad file.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Symptom | First three moves |
|
||||
|---|---|
|
||||
| Choppy RAM preview | Half resolution → enable MFR → purge cache |
|
||||
| Tiny green preview bar | Raise RAM allocation → SSD disk cache → trim work area |
|
||||
| Slow final render | Enable MFR → HW decode H.264 → pre-render precomps |
|
||||
| Crash on launch | Reset prefs (modifier keys) → empty disk cache → update GPU driver |
|
||||
| Crash mid-render | Free disk space → disable/reserve CPUs for MFR → disable 3rd-party plugins |
|
||||
| Started after update | Roll back AE version → update plugins → reset prefs |
|
||||
| Laggy with footage | HW-accelerated decode → transcode to ProRes → proxy heavy comps |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **MFR needs RAM per core.** With many cores but little RAM, MFR can underperform or stall — aim for several GB per active core.
|
||||
- **Game-Ready drivers crash AE.** Prefer the **Studio** driver branch on NVIDIA.
|
||||
- **Disk Cache on an external HDD is worse than none** — it must be SSD/NVMe to help.
|
||||
- **Plugins lag behind AE updates.** After a major AE upgrade, a sudden crash wave usually means a plugin needs updating or is not MFR-safe.
|
||||
- **Purging clears the green bar.** Previews must rebuild afterward; that initial slowness is expected, not a new problem.
|
||||
- **CPU/GPU effects are not equal.** A few GPU-accelerated effects are fast; mixing many CPU-only effects forces round-trips that kill preview speed.
|
||||
- **Don't render in AE and AME simultaneously** on the same machine — they fight for cores and RAM.
|
||||
|
||||
---
|
||||
|
||||
# AE Performance — Detailed Diagnostics Reference
|
||||
|
||||
## Preference locations and recommended values
|
||||
|
||||
| Setting | Path | Recommended |
|
||||
|---|---|---|
|
||||
| RAM for AE | Preferences > Memory & Performance | Total RAM minus ~6 GB for OS |
|
||||
| Multi-Frame Rendering | Memory & Performance > Enable MFR | On; reserve 0–2 CPUs for other apps |
|
||||
| Disk Cache | Media & Disk Cache > Enable Disk Cache | On, SSD/NVMe, 50–150 GB |
|
||||
| Conformed media cache | Media & Disk Cache | On SSD, separate from project if possible |
|
||||
| HW decode (H.264/HEVC) | Import > Hardware Accelerate Decoding | On |
|
||||
| Mercury GPU | Project Settings > Video Rendering and Effects | GPU (CPU as crash test) |
|
||||
| Fast Previews | Viewer lightning icon | Adaptive Resolution |
|
||||
|
||||
## Multi-Frame Rendering (MFR) tuning
|
||||
- Introduced in AE 2022 (22.x); renders multiple frames in parallel across CPU cores for both preview and final render.
|
||||
- **RAM is the limiter.** Each rendering frame needs memory; with too little RAM, AE quietly reduces the cores it uses. Target several GB of free RAM per logical core you want active.
|
||||
- "CPUs reserved for other applications" trades render speed for system responsiveness. Set 0–2 on a dedicated machine.
|
||||
- Some legacy/3rd-party effects are **not MFR-safe** and can crash or render incorrectly under MFR. If a specific project crashes only with MFR on, disable MFR for that project or update the plugin.
|
||||
|
||||
## GPU and drivers
|
||||
- Use the **NVIDIA Studio Driver** (not Game Ready) for stability with Adobe apps; keep AMD drivers current too.
|
||||
- Mercury GPU acceleration speeds some effects and the renderer. If AE crashes on launch or while playing back, switch `Project Settings > Video Rendering and Effects` to **Mercury Software Only** to confirm GPU/driver fault, then update drivers.
|
||||
- Hardware-accelerated **decoding** (separate from rendering) speeds reading H.264/HEVC source footage; enable in `Preferences > Import`.
|
||||
|
||||
## Disk cache sizing and placement
|
||||
- Put the disk cache on the **fastest local drive** (internal NVMe ideal). Never on a slow USB HDD or a network drive.
|
||||
- 50–150 GB is typical; more helps long projects retain cached frames between sessions.
|
||||
- Symptoms of a bad cache: hangs on open, garbage frames, refusal to play. Fix via `Empty Disk Cache` and `Edit > Purge > All`.
|
||||
- Keep the cache drive from filling up — a full cache/scratch disk causes mid-render freezes.
|
||||
|
||||
## Pre-render and proxy workflow
|
||||
1. Identify a slow, finished precomp (heavy effects, particles, 3D, tracking).
|
||||
2. Add it to the Render Queue and render to **ProRes 422/422 HQ** (or PNG sequence).
|
||||
3. Right-click the footage/comp in the Project panel → **Set Proxy > File**, select the rendered file. A box icon shows the proxy is active.
|
||||
4. Toggle the proxy off for final render if you want to re-render at full quality, or keep it if the pre-render is final.
|
||||
5. Alternatively `File > Create Proxy > Movie/Still` to queue a proxy directly.
|
||||
- Proxies let AE substitute a cheap-to-read file for an expensive comp/footage during editing.
|
||||
|
||||
## Heavy effect culprits (preview/render cost)
|
||||
- Gaussian Blur, Camera Lens Blur, Glow → use Fast Box Blur where acceptable.
|
||||
- Particle systems (Particular, Form), Element 3D, Plexus.
|
||||
- Mocha tracking, Warp Stabilizer (analysis), motion tracking.
|
||||
- Expressions that read many layers/time, especially per-frame heavy ones.
|
||||
- Nested precomps with collapsed transformations and continuous rasterization.
|
||||
- Ray-traced / Cinema 4D 3D renderer vs Classic 3D.
|
||||
|
||||
## Version-specific culprits
|
||||
- After a **major AE update**, a sudden crash/slowdown wave usually traces to a third-party plugin not yet updated for that AE version — update plugins or remove them to test.
|
||||
- If problems began immediately after updating AE, install the **previous version** from Creative Cloud → app → Other Versions, and report the regression.
|
||||
- Reset corrupt preferences by holding `Ctrl+Alt+Shift` (Win) / `Cmd+Opt+Shift` (Mac) during launch; this resolves many post-update oddities.
|
||||
|
||||
## Isolation checklist for crashes
|
||||
1. Reset preferences (modifier-key launch).
|
||||
2. Empty disk cache + Purge All.
|
||||
3. Update GPU driver (Studio branch).
|
||||
4. Move 3rd-party plugins out, relaunch, reintroduce one at a time.
|
||||
5. Switch Mercury to Software Only to rule out GPU.
|
||||
6. Import comps/footage incrementally to find a corrupt asset.
|
||||
7. Disable MFR for the project if crashes are MFR-specific.
|
||||
8. Roll back AE version if the crash wave is version-correlated.
|
||||
|
||||
## When to pre-compose vs pre-render
|
||||
- **Pre-compose** to organize and to apply an effect to a group — does not by itself speed anything up (it can even add overhead).
|
||||
- **Pre-render** (render to a file + use as proxy/footage) when a precomp is finished and slow — this is the actual performance win.
|
||||
@@ -1,188 +0,0 @@
|
||||
# Project Hygiene — Organize, Relink, ExtendScript
|
||||
|
||||
Keep AE projects organized, portable, and automatable: consistent naming, a clear folder/bin structure, label discipline, pre-comp discipline, reliable relinking when files move, and ExtendScript (.jsx) automation for repetitive tasks.
|
||||
|
||||
## Folder (bin) structure
|
||||
|
||||
Create folders in the Project panel (the folder icon at the bottom). A reliable layout:
|
||||
|
||||
```
|
||||
01_Comps/ finished/active compositions
|
||||
02_Precomps/ nested precomps
|
||||
03_Footage/ imported video
|
||||
04_Images/ stills, logos, textures
|
||||
05_Audio/
|
||||
06_Solids/ (AE's auto "Solids" folder — keep it here)
|
||||
07_Assets_3D/
|
||||
99_Old/ deprecated comps/layers, not deleted yet
|
||||
```
|
||||
|
||||
Numeric prefixes force a stable sort order. After importing, immediately drag items into the right bin — `File > Reduce Project` and `File > Consolidate All Footage` help later, but discipline up front prevents chaos.
|
||||
|
||||
## Naming conventions
|
||||
|
||||
- **Comps:** `PROJECT_section_descriptor_v01` (e.g., `BRAND_intro_logoBuild_v03`). Version with zero-padded `v01`, `v02`.
|
||||
- **Precomps:** prefix `PRE_` or suffix `_pre` so they're obvious in lists.
|
||||
- **Layers:** rename solids/nulls to their role (`CTRL_master`, `BG_gradient`, `MATTE_text`). Never ship "Black Solid 12".
|
||||
- **No spaces / special chars** in filenames destined for render farms or cross-OS sharing — use `_` and `-`.
|
||||
- Keep names short but unique; long names truncate in the timeline.
|
||||
|
||||
## Label colors
|
||||
|
||||
Use the Label color (the swatch left of each Project/Timeline item) as a visual system. Pick a convention and apply it consistently, e.g.:
|
||||
- Red = control/null layers
|
||||
- Blue = footage
|
||||
- Green = precomps
|
||||
- Yellow = text
|
||||
- Purple = adjustment/effects layers
|
||||
|
||||
Right-click an item → **Label** to set. Select multiple, then `Edit > Label > Select Label Group` to act on a whole color at once. Customize label names/colors in `Preferences > Labels`.
|
||||
|
||||
## Pre-comp discipline
|
||||
|
||||
- Pre-compose (`Layer > Pre-compose`, `Ctrl/Cmd+Shift+C`) to **group** related layers and to apply one effect to a group — not reflexively for everything.
|
||||
- When pre-composing a single layer, choose **"Leave all attributes in"** vs **"Move all attributes into the new composition"** deliberately: "Move" carries transforms/effects inside; "Leave" keeps them on the outer layer.
|
||||
- Name the precomp immediately and file it in `02_Precomps`.
|
||||
- Avoid deep nesting beyond a few levels — it slows navigation and makes relinking/debugging painful.
|
||||
- Match precomp duration and frame rate to need; an over-long precomp wastes cache.
|
||||
|
||||
## The "moved one file and everything broke" rescue
|
||||
|
||||
AE references footage by file path. Moving, renaming, or sending a project without its assets breaks links (shown as **colored bars / "Missing Footage"** placeholders).
|
||||
|
||||
### Fix options, in order
|
||||
1. **Relink one, fix many:** double-click a missing item (or right-click → **Replace Footage > File**). Point it at the moved file; AE offers to relink *all* missing items from the same folder automatically. This single step usually fixes the whole project.
|
||||
2. **Find Missing Footage:** `File > Dependencies > Find Missing Footage` lists every broken link to resolve.
|
||||
3. **Replace Footage:** right-click footage → **Replace Footage > File** to swap an asset while keeping every use, effect, and keyframe intact.
|
||||
|
||||
### Prevent it: Collect Files
|
||||
Before moving or sharing a project, use `File > Dependencies > Collect Files`. This copies the project plus every used asset into one new folder with relative structure, so links survive on any machine. Always hand off projects as a Collect Files package, not a bare `.aep`.
|
||||
|
||||
Related: `File > Dependencies > Consolidate All Footage` (merge duplicate imports), `Remove Unused Footage`, and `Reduce Project` (strip everything not used by selected comps) to slim a project before collecting.
|
||||
|
||||
## Batch automation with ExtendScript (.jsx)
|
||||
|
||||
AE scripts (ExtendScript / JSX) automate repetitive work. Run via `File > Scripts > Run Script File...`, or place in the **ScriptUI Panels** folder to dock as a panel. Always wrap edits in `app.beginUndoGroup("name") ... app.endUndoGroup()` so one Undo reverts the whole batch.
|
||||
|
||||
Common automations (full code in `../scripts/`):
|
||||
- **Batch rename** selected layers with a base name + auto-increment (`../scripts/batch-rename.jsx`).
|
||||
- **Sequential offset** — stagger selected layers in time by N frames each, a staggered "domino" start (`../scripts/sequential-offset.jsx`).
|
||||
- **Apply an effect** (e.g., a Gaussian Blur, or any matchName) across all selected layers at once (`../scripts/apply-effect-to-layers.jsx`).
|
||||
|
||||
Minimal pattern:
|
||||
```javascript
|
||||
app.beginUndoGroup("My Batch Op");
|
||||
var comp = app.project.activeItem;
|
||||
if (comp && comp instanceof CompItem) {
|
||||
var sel = comp.selectedLayers;
|
||||
for (var i = 0; i < sel.length; i++) {
|
||||
// operate on sel[i]
|
||||
}
|
||||
}
|
||||
app.endUndoGroup();
|
||||
```
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Task | How |
|
||||
|---|---|
|
||||
| New bin | Folder icon at bottom of Project panel |
|
||||
| Set label color | Right-click item → Label |
|
||||
| Relink missing footage | Double-click item / Replace Footage > File |
|
||||
| List broken links | File > Dependencies > Find Missing Footage |
|
||||
| Package for handoff | File > Dependencies > Collect Files |
|
||||
| Swap an asset everywhere | Replace Footage > File |
|
||||
| Slim project | Reduce Project / Remove Unused Footage |
|
||||
| Run a script | File > Scripts > Run Script File... |
|
||||
| Pre-compose | Layer > Pre-compose (Ctrl/Cmd+Shift+C) |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Collect Files before moving anything.** Moving the `.aep` alone always breaks links.
|
||||
- **Relinking one item often relinks the rest** from the same folder — try that before fixing each by hand.
|
||||
- **Replace Footage preserves effects/keyframes;** deleting + re-importing does not.
|
||||
- **AE auto-creates a "Solids" folder** — don't scatter solids elsewhere or it regenerates duplicates.
|
||||
- **Scripts need `app.beginUndoGroup`/`endUndoGroup`** or a batch becomes many separate undos.
|
||||
- **Applying effects by name needs the `matchName`,** not the display name (e.g., `"ADBE Gaussian Blur 2"`), to be reliable across AE language versions.
|
||||
- **Allow Scripts to Write Files** must be enabled in `Preferences > Scripting & Expressions` for scripts that save/render.
|
||||
- **Deep precomp nesting** makes both performance and relinking worse — keep it shallow.
|
||||
|
||||
---
|
||||
|
||||
# AE Project Organization — Detailed Reference
|
||||
|
||||
## Full folder (bin) template
|
||||
```
|
||||
PROJECT.aep
|
||||
├── 00_Output/ (final renders live next to project on disk)
|
||||
01_Comps/ master & active comps
|
||||
02_Precomps/ nested precomps (prefix PRE_)
|
||||
03_Footage/ video clips
|
||||
04_Images/ stills, logos, vector, textures
|
||||
05_Audio/ music, VO, SFX
|
||||
06_Solids/ AE auto-folder; leave it here
|
||||
07_Assets_3D/ OBJ/C4D/3D model assets
|
||||
08_Reference/ storyboards, briefs, temp ref
|
||||
99_Old/ deprecated, kept until final
|
||||
```
|
||||
Numeric prefixes lock sort order. Keep the on-disk asset folders mirroring these bins so Collect Files stays clean.
|
||||
|
||||
## Naming scheme
|
||||
| Item | Pattern | Example |
|
||||
|---|---|---|
|
||||
| Master comp | `PROJ_section_v##` | `BRAND_main_v04` |
|
||||
| Sub/shot comp | `PROJ_section_shot_v##` | `BRAND_intro_logo_v02` |
|
||||
| Precomp | `PRE_descriptor` | `PRE_textReveal` |
|
||||
| Null/control | `CTRL_role` | `CTRL_master` |
|
||||
| Solid/BG | `BG_role` | `BG_gradient` |
|
||||
| Matte | `MATTE_role` | `MATTE_logoTrack` |
|
||||
| Adjustment | `ADJ_role` | `ADJ_grade` |
|
||||
|
||||
Rules: zero-pad versions; no spaces/special characters for farm/cross-OS safety; keep unique and short.
|
||||
|
||||
## Label color system (example)
|
||||
| Color | Meaning |
|
||||
|---|---|
|
||||
| Red | Control / null layers |
|
||||
| Blue | Footage |
|
||||
| Green | Precomps |
|
||||
| Yellow | Text |
|
||||
| Purple | Adjustment / effects |
|
||||
| Orange | Audio |
|
||||
| Gray | Disabled / old |
|
||||
|
||||
Set via right-click → Label. Customize names/colors in `Preferences > Labels`. Use `Edit > Label > Select Label Group` to select everything of one color.
|
||||
|
||||
## Relink / missing footage — full workflow
|
||||
1. **Identify:** missing items show as colored bars with "Missing" placeholders; the Project panel marks them.
|
||||
2. **Relink one to fix many:** right-click a missing item → **Replace Footage > File** (or double-click it). Choose the relocated file. AE prompts to relink all other missing files found in the same folder — accept to fix the batch at once.
|
||||
3. **Enumerate:** `File > Dependencies > Find Missing Footage`, `Find Missing Fonts`, `Find Missing Effects` to locate every broken dependency.
|
||||
4. **Swap assets:** **Replace Footage > File** swaps the source while preserving all layer instances, effects, masks, and keyframes.
|
||||
|
||||
## Collect Files (portable handoff)
|
||||
- `File > Dependencies > Collect Files`.
|
||||
- Options: Collect Source Files = **All**; enable **Reduce Project** to drop unused; optionally **Generate Report**.
|
||||
- Produces a folder containing the `.aep` plus a `(Footage)` subfolder with every used asset, preserving relative links so the project opens anywhere.
|
||||
- Always deliver/move projects as a Collect Files package, never a lone `.aep`.
|
||||
|
||||
## Slimming a project
|
||||
- `File > Reduce Project` — removes all items not used by the currently selected comp(s). Destructive to unused items; save first.
|
||||
- `File > Dependencies > Consolidate All Footage` — merges duplicate imports of the same file.
|
||||
- `File > Dependencies > Remove Unused Footage` — drops footage no comp references.
|
||||
- Run these before Collect Files to keep the package small.
|
||||
|
||||
## Scripting setup
|
||||
- Enable `Preferences > Scripting & Expressions > Allow Scripts to Write Files and Access Network` for scripts that save or render.
|
||||
- Run a script: `File > Scripts > Run Script File...`.
|
||||
- Dock a panel script: place the `.jsx` in `(AE install)/Scripts/ScriptUI Panels/`, restart AE, open from the `Window` menu.
|
||||
- Wrap every batch in `app.beginUndoGroup("...")` / `app.endUndoGroup()` so it undoes in one step.
|
||||
- Apply effects by **matchName** (internal id like `"ADBE Gaussian Blur 2"`), not the localized display name, for cross-version reliability. Use `layer.property("ADBE Effect Parade").addProperty(matchName)`.
|
||||
|
||||
## Pre-comp discipline details
|
||||
- "Leave all attributes in the new composition" keeps the outer layer's transform; "Move all attributes" pushes them inside the precomp. Choosing wrong shifts/rescales the result.
|
||||
- Trim precomp duration to the content; over-long precomps waste RAM/disk cache.
|
||||
- Name and file precomps immediately; never leave "Pre-comp 1".
|
||||
- Keep nesting shallow (2–3 levels) for performance and easier relinking/debugging.
|
||||
|
||||
---
|
||||
Keep the project tidy and named well and footage relinks without a fight. Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=motion-design-skills&utm_content=skill_footer&utm_term=after-effects)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
@@ -1,49 +0,0 @@
|
||||
/*
|
||||
* apply-effect-to-layers.jsx
|
||||
* Applies an effect (by internal matchName) to every selected layer.
|
||||
*
|
||||
* Why matchName: display names are localized and change between AE language
|
||||
* versions; matchName is stable. Examples:
|
||||
* "ADBE Gaussian Blur 2" -> Gaussian Blur
|
||||
* "ADBE Fast Box Blur" -> Fast Box Blur
|
||||
* "ADBE Tint" -> Tint
|
||||
* "ADBE Drop Shadow" -> Drop Shadow
|
||||
* "ADBE Glo2" -> Glow
|
||||
*
|
||||
* Usage: select layers in a comp, then File > Scripts > Run Script File...
|
||||
* Edit EFFECT_MATCHNAME (and the optional parameter block) below.
|
||||
*/
|
||||
|
||||
(function applyEffectToLayers() {
|
||||
var EFFECT_MATCHNAME = "ADBE Gaussian Blur 2";
|
||||
|
||||
var comp = app.project.activeItem;
|
||||
if (!(comp && comp instanceof CompItem)) {
|
||||
alert("Open a composition and select one or more layers first.");
|
||||
return;
|
||||
}
|
||||
|
||||
var sel = comp.selectedLayers;
|
||||
if (sel.length === 0) {
|
||||
alert("No layers selected.");
|
||||
return;
|
||||
}
|
||||
|
||||
app.beginUndoGroup("Apply Effect To Layers");
|
||||
try {
|
||||
for (var i = 0; i < sel.length; i++) {
|
||||
var layer = sel[i];
|
||||
if (!(layer instanceof AVLayer)) continue; // effects need an AVLayer
|
||||
|
||||
var effects = layer.property("ADBE Effect Parade");
|
||||
if (!effects.canAddProperty(EFFECT_MATCHNAME)) continue;
|
||||
|
||||
var fx = effects.addProperty(EFFECT_MATCHNAME);
|
||||
|
||||
// --- Optional: set a parameter. Example for Gaussian Blur radius ---
|
||||
// try { fx.property("Blurriness").setValue(25); } catch (e) {}
|
||||
}
|
||||
} finally {
|
||||
app.endUndoGroup();
|
||||
}
|
||||
})();
|
||||
@@ -1,41 +0,0 @@
|
||||
/*
|
||||
* batch-rename.jsx
|
||||
* Renames the selected layers in the active comp to: baseName_##
|
||||
* with a zero-padded, incrementing index.
|
||||
*
|
||||
* Usage: select layers in a comp, then File > Scripts > Run Script File...
|
||||
* Edit BASE_NAME, START_INDEX, and PAD below.
|
||||
*/
|
||||
|
||||
(function batchRename() {
|
||||
var BASE_NAME = "layer"; // base name applied to every selected layer
|
||||
var START_INDEX = 1; // first number
|
||||
var PAD = 2; // zero-padding width (2 -> 01, 02 ...)
|
||||
|
||||
var comp = app.project.activeItem;
|
||||
if (!(comp && comp instanceof CompItem)) {
|
||||
alert("Open a composition and select one or more layers first.");
|
||||
return;
|
||||
}
|
||||
|
||||
var sel = comp.selectedLayers;
|
||||
if (sel.length === 0) {
|
||||
alert("No layers selected.");
|
||||
return;
|
||||
}
|
||||
|
||||
function pad(n, width) {
|
||||
var s = String(n);
|
||||
while (s.length < width) s = "0" + s;
|
||||
return s;
|
||||
}
|
||||
|
||||
app.beginUndoGroup("Batch Rename Layers");
|
||||
try {
|
||||
for (var i = 0; i < sel.length; i++) {
|
||||
sel[i].name = BASE_NAME + "_" + pad(START_INDEX + i, PAD);
|
||||
}
|
||||
} finally {
|
||||
app.endUndoGroup();
|
||||
}
|
||||
})();
|
||||
@@ -1,47 +0,0 @@
|
||||
/*
|
||||
* sequential-offset.jsx
|
||||
* Staggers the selected layers in time so each starts OFFSET_FRAMES after
|
||||
* the previous one (a "domino" / cascade start), based on the first
|
||||
* selected layer's start time.
|
||||
*
|
||||
* Usage: select layers in a comp, then File > Scripts > Run Script File...
|
||||
* Edit OFFSET_FRAMES and ORDER below.
|
||||
*/
|
||||
|
||||
(function sequentialOffset() {
|
||||
var OFFSET_FRAMES = 2; // frames between each layer's start
|
||||
var ORDER_BY_SELECTION = true; // true = order of selection; false = by current startTime
|
||||
|
||||
var comp = app.project.activeItem;
|
||||
if (!(comp && comp instanceof CompItem)) {
|
||||
alert("Open a composition and select two or more layers first.");
|
||||
return;
|
||||
}
|
||||
|
||||
var sel = comp.selectedLayers;
|
||||
if (sel.length < 2) {
|
||||
alert("Select at least two layers.");
|
||||
return;
|
||||
}
|
||||
|
||||
var frameDur = comp.frameDuration; // seconds per frame
|
||||
var offsetSec = OFFSET_FRAMES * frameDur;
|
||||
|
||||
// Build the working order.
|
||||
var layers = [];
|
||||
for (var i = 0; i < sel.length; i++) layers.push(sel[i]);
|
||||
if (!ORDER_BY_SELECTION) {
|
||||
layers.sort(function (a, b) { return a.startTime - b.startTime; });
|
||||
}
|
||||
|
||||
var base = layers[0].startTime;
|
||||
|
||||
app.beginUndoGroup("Sequential Offset");
|
||||
try {
|
||||
for (var j = 0; j < layers.length; j++) {
|
||||
layers[j].startTime = base + (j * offsetSec);
|
||||
}
|
||||
} finally {
|
||||
app.endUndoGroup();
|
||||
}
|
||||
})();
|
||||
Whitespace-only changes.
@@ -1,114 +0,0 @@
|
||||
---
|
||||
name: animation-principles
|
||||
description: This skill should be used when the user asks to "make this animation feel natural", "fix motion that feels stiff/floaty/cheap", "my animation looks robotic", "choose an easing curve", "how do I use the Graph Editor", "add overshoot or bounce", "Easy Ease isn't enough", "make snappy motion", "pick a duration for this transition", "stagger a list animation", "sync animation to a beat", or "review motion for good timing". It is the tech-agnostic foundation for deciding how something should move.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Motion Principles
|
||||
|
||||
Decide how something should move so it feels right — independent of the tool used to build it. This skill answers timing, easing, spacing, weight, and rhythm. It does not pick a tech stack; it produces concrete numbers (durations, cubic-bezier curves, stagger offsets, spring configs) ready to hand to any implementation.
|
||||
|
||||
## When to use
|
||||
|
||||
- Motion feels stiff, floaty, mechanical, robotic, or cheap and needs diagnosis.
|
||||
- Choosing a clip or transition's duration, easing curve, and stagger offset.
|
||||
- Reviewing whether motion respects physics intuition (weight, follow-through, arcs).
|
||||
- Synchronizing keyframes to a musical beat or edit accent.
|
||||
|
||||
## Three pillars — settle these before any number
|
||||
|
||||
Every piece of motion should answer three questions *before* you pick a duration or curve. Skip them and you get technically-correct motion that feels like nothing. These are the shared vocabulary the rest of the collection refers back to.
|
||||
|
||||
| Pillar | The question | What it drives |
|
||||
|---|---|---|
|
||||
| **Emotional intent** | What should the viewer *feel*? (joy, calm, urgency, trust, elegance) | Easing, duration, amplitude/overshoot |
|
||||
| **Visual narrative** | What's the micro-story? Setup → action → resolution | Sequencing, staging, what enters when |
|
||||
| **Motion craft** | How do we make it believable? | Physics, secondary motion, arcs, follow-through |
|
||||
|
||||
## Three motion layers — depth, not flatness
|
||||
|
||||
Flat motion = only the main thing moves. Believable motion runs three layers at once:
|
||||
|
||||
- **Primary** — the main action the eye follows (the hero move).
|
||||
- **Secondary** — supporting richness reacting to the primary (a shadow shifting, an icon nudging, a label settling late).
|
||||
- **Ambient** — background life that never demands attention (slow gradient drift, a low-contrast pulse).
|
||||
|
||||
> The judgment layer (`motion-art-direction`) calls these **Hero / Support / Texture** — same three layers, ranked for *what earns motion*. Primary≈Hero, Secondary≈Support, Ambient≈Texture.
|
||||
|
||||
## Core principles
|
||||
|
||||
### Easing is the single biggest lever
|
||||
|
||||
Linear motion reads as robotic. Reserve `linear` for continuous loops (spinners, marquees, infinite scroll) only. Everything an eye perceives as a discrete event needs acceleration.
|
||||
|
||||
Match the curve to the action:
|
||||
- **Enter / appear** -> ease-out (fast start, gentle settle). Object arrives with energy and decelerates into place. `cubic-bezier(0.16, 1, 0.3, 1)`.
|
||||
- **Exit / disappear** -> ease-in (gentle start, fast end). Object accelerates away. `cubic-bezier(0.7, 0, 0.84, 0)`.
|
||||
- **Move / reposition (stays on screen)** -> ease-in-out (symmetric). `cubic-bezier(0.65, 0, 0.35, 1)`.
|
||||
- **Playful / branded** -> slight overshoot. `cubic-bezier(0.34, 1.56, 0.64, 1)` (the `1.56` pushes past 1.0, creating an overshoot-and-settle that reads as "weight").
|
||||
|
||||
Rule of thumb: the eye forgives a slow start far less than a slow end. When in doubt, decelerate into rest.
|
||||
|
||||
**Hand-shaping easing (After Effects).** When keyframing by hand rather than supplying a curve, the same principles map to the Graph Editor: shape the Speed Graph so the ends flatten toward zero, and push deceleration influence to 80–90% for a snappy launch and smooth settle. `F9` (Easy Ease) is only a 33% symmetric starting point — make it asymmetric. See `references/easing-graph-editor.md` for Graph Editor reading, handle/influence techniques, and step-by-step overshoot, anticipation, and bounce recipes (including a decaying-bounce expression).
|
||||
|
||||
### Timing communicates weight and importance
|
||||
|
||||
| Element | Duration |
|
||||
|---|---|
|
||||
| Micro-interaction (hover, toggle, tap feedback) | 100-200ms |
|
||||
| UI transition (panel, modal, page region) | 200-400ms |
|
||||
| Hero / large element / full-screen | 400-800ms |
|
||||
| Cinematic camera move | 800-2000ms |
|
||||
|
||||
Distance and size scale duration. A small icon moving 20px and a full-bleed panel moving 800px should NOT share a duration — the panel needs more time or it looks weightless. A practical scaling: keep perceived velocity roughly constant by adding ~30-50% duration when distance or area roughly doubles. Heavy objects start slowly and overshoot less; light objects snap and may bounce.
|
||||
|
||||
### Spacing and stagger create rhythm
|
||||
|
||||
Never reveal a list, grid, or group all at once — it reads as a single flat event. Stagger each child's start.
|
||||
|
||||
- Lists / sequential items: **40-80ms** between items.
|
||||
- Dense grids: **20-40ms** (more items, less per-item delay, or the tail drags).
|
||||
- Cap total reveal at roughly **600-800ms** for a group; if `count * offset` exceeds that, shrink the offset or switch to a distance-based stagger (items further from an origin point start later).
|
||||
- Stagger direction should follow the eye: top-to-bottom, or radiating from a focal point.
|
||||
|
||||
**The 1/3 Rule (two forms, both universal):**
|
||||
- *Distance* — no element travels more than ~1/3 of the screen without an intermediate keyframe or a scale/opacity change. Long unbroken slides read as "the template moved," not "something happened."
|
||||
- *Simultaneity* — with 3+ elements, keep no more than ~1/3 in active motion at once. The rest hold or move as ambient. Everything moving together = noise with no focal point.
|
||||
|
||||
### Anticipation and follow-through sell physical motion
|
||||
|
||||
- **Anticipation**: a tiny counter-move before the main action (a button dips `scale 0.95` before popping, a character crouches before jumping). 60-120ms is enough.
|
||||
- **Follow-through / overlapping action**: trailing parts keep moving after the main body stops (a hero card lands, then its shadow and label settle 40-80ms later). Stagger the settle of attached elements rather than stopping everything on the same frame.
|
||||
- **Arcs**: natural movement curves; pure straight-line translation of an organic object looks mechanical. Add a slight arc to x/y or animate a path.
|
||||
|
||||
### Beat-sync and rhythm
|
||||
|
||||
When motion accompanies audio, land impact keyframes (a hit, a cut, a reveal) on the beat, not between beats. At 120 BPM a beat is 500ms; an eighth-note grid is 250ms. Quantize key moments to that grid. For edits without music, establish a visual pulse (e.g. one major event per ~500-800ms) so the piece breathes consistently.
|
||||
|
||||
### Spring vs. duration-based motion
|
||||
|
||||
Springs (mass / stiffness / damping) self-determine duration and feel more physical for interactive, interruptible motion (drag-release, gestures). Duration+easing is better for choreographed, timeline-locked sequences (video, scroll-tied reveals) where exact sync matters. A snappy default spring: `stiffness 300, damping 30, mass 1`. See `references/easing-curves.md` for a full spring table.
|
||||
|
||||
## Diagnosing common failures
|
||||
|
||||
- **Feels stiff/robotic** -> using `linear` or symmetric easing on an enter; switch to ease-out.
|
||||
- **Feels floaty/sluggish** -> duration too long or ease-out too gentle; cut duration 30%, sharpen the curve.
|
||||
- **Feels cheap/janky** -> everything appears at once (no stagger), or all elements share one duration regardless of size.
|
||||
- **Feels mechanical despite easing** -> no anticipation, no follow-through, straight-line paths; add arcs and overlap.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Need | Use |
|
||||
|---|---|
|
||||
| Enter | `cubic-bezier(0.16, 1, 0.3, 1)`, 300-500ms |
|
||||
| Exit | `cubic-bezier(0.7, 0, 0.84, 0)`, 200-300ms |
|
||||
| Move | `cubic-bezier(0.65, 0, 0.35, 1)`, 300-400ms |
|
||||
| Branded pop | `cubic-bezier(0.34, 1.56, 0.64, 1)`, 400-600ms |
|
||||
| List stagger | 40-80ms per item, cap ~700ms total |
|
||||
| Loop only | `linear` |
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/easing-curves.md` — named cubic-bezier library with exact values, spring configs (Framer/react-spring/iOS), and per-element-type duration guidance.
|
||||
- `references/easing-graph-editor.md` — After Effects Graph Editor craft: speed graph vs value graph, easing handles and influence/velocity numbers, and step-by-step snappy, overshoot, anticipation, and bounce recipes.
|
||||
- `references/twelve-principles.md` — all 12 Disney principles, each with a concrete, numeric motion-graphics example.
|
||||
@@ -1,136 +0,0 @@
|
||||
# Easing curves, springs, and durations
|
||||
|
||||
A reference library of exact values. Copy directly.
|
||||
|
||||
## Named cubic-bezier library
|
||||
|
||||
These are battle-tested curves. The four numbers are `(x1, y1, x2, y2)` control points.
|
||||
|
||||
### Ease-out family (use for ENTER / appear / settle)
|
||||
|
||||
| Name | cubic-bezier | Feel |
|
||||
|---|---|---|
|
||||
| easeOutQuad | `0.5, 1, 0.89, 1` | gentle, subtle |
|
||||
| easeOutCubic | `0.33, 1, 0.68, 1` | balanced, safe default |
|
||||
| easeOutQuart | `0.25, 1, 0.5, 1` | snappy |
|
||||
| easeOutQuint | `0.22, 1, 0.36, 1` | very snappy, decisive |
|
||||
| easeOutExpo | `0.16, 1, 0.3, 1` | dramatic, "premium" arrival |
|
||||
| easeOutCirc | `0, 0.55, 0.45, 1` | strong late deceleration |
|
||||
|
||||
### Ease-in family (use for EXIT / disappear / accelerate away)
|
||||
|
||||
| Name | cubic-bezier | Feel |
|
||||
|---|---|---|
|
||||
| easeInQuad | `0.11, 0, 0.5, 0` | gentle pull-away |
|
||||
| easeInCubic | `0.32, 0, 0.67, 0` | balanced exit |
|
||||
| easeInQuart | `0.5, 0, 0.75, 0` | sharp exit |
|
||||
| easeInExpo | `0.7, 0, 0.84, 0` | dramatic, fast vanish |
|
||||
|
||||
### Ease-in-out family (use for MOVE / reposition while on screen)
|
||||
|
||||
| Name | cubic-bezier | Feel |
|
||||
|---|---|---|
|
||||
| easeInOutQuad | `0.45, 0, 0.55, 1` | smooth, neutral |
|
||||
| easeInOutCubic | `0.65, 0, 0.35, 1` | balanced default |
|
||||
| easeInOutQuart | `0.76, 0, 0.24, 1` | punchy in and out |
|
||||
| easeInOutExpo | `0.87, 0, 0.13, 1` | very dramatic, long hold mid |
|
||||
|
||||
### Overshoot / back family (use for PLAYFUL / branded / "weighty")
|
||||
|
||||
| Name | cubic-bezier | Notes |
|
||||
|---|---|---|
|
||||
| easeOutBack | `0.34, 1.56, 0.64, 1` | overshoots past target then settles |
|
||||
| easeInBack | `0.36, 0, 0.66, -0.56` | dips below before launching (anticipation) |
|
||||
| easeInOutBack | `0.68, -0.6, 0.32, 1.6` | anticipate + overshoot |
|
||||
|
||||
The `y` value above 1 (e.g. `1.56`) or below 0 (e.g. `-0.56`) is what creates overshoot/anticipation. Bigger excursion = more bounce.
|
||||
|
||||
## Material Design standard curves (good for product UI)
|
||||
|
||||
| Token | cubic-bezier | Use |
|
||||
|---|---|---|
|
||||
| Standard | `0.2, 0, 0, 1` | most transitions |
|
||||
| Decelerate (enter) | `0, 0, 0, 1` | elements entering screen |
|
||||
| Accelerate (exit) | `0.3, 0, 1, 1` | elements leaving screen |
|
||||
| Emphasized | `0.2, 0, 0, 1` (300-500ms) | hero moments |
|
||||
|
||||
## iOS / Apple feel
|
||||
|
||||
Apple favors springs, but the closest bezier is `0.25, 0.1, 0.25, 1` (the legacy `ease`) sharpened toward `0.33, 1, 0.68, 1`. For modal presentation Apple uses a spring close to `stiffness 280, damping 28`.
|
||||
|
||||
## Spring configurations
|
||||
|
||||
Springs are defined by stiffness (tension), damping (friction), and mass. Higher stiffness = faster; higher damping = less bounce; higher mass = more sluggish/heavy.
|
||||
|
||||
### Framer Motion (`type: "spring"`)
|
||||
|
||||
```js
|
||||
// Snappy default — interactive UI
|
||||
{ type: "spring", stiffness: 300, damping: 30, mass: 1 }
|
||||
|
||||
// Bouncy — playful, branded
|
||||
{ type: "spring", stiffness: 400, damping: 17, mass: 1 }
|
||||
|
||||
// Gentle — large/heavy elements, modals
|
||||
{ type: "spring", stiffness: 120, damping: 20, mass: 1 }
|
||||
|
||||
// Stiff/no bounce — precise, critically damped
|
||||
{ type: "spring", stiffness: 500, damping: 50, mass: 1 }
|
||||
|
||||
// Alternative API: duration-based spring (Framer Motion v10+)
|
||||
{ type: "spring", duration: 0.5, bounce: 0.25 } // bounce 0 = no overshoot, 0.5 = lively
|
||||
```
|
||||
|
||||
### react-spring presets
|
||||
|
||||
```js
|
||||
import { config } from '@react-spring/web'
|
||||
config.default // { tension: 170, friction: 26 }
|
||||
config.gentle // { tension: 120, friction: 14 }
|
||||
config.wobbly // { tension: 180, friction: 12 } — visible bounce
|
||||
config.stiff // { tension: 210, friction: 20 }
|
||||
config.slow // { tension: 280, friction: 60 } — heavy, no bounce
|
||||
config.molasses // { tension: 280, friction: 120 }
|
||||
```
|
||||
|
||||
### SwiftUI / iOS
|
||||
|
||||
```swift
|
||||
.spring(response: 0.4, dampingFraction: 0.8) // response = approx duration, dampingFraction 1.0 = no bounce
|
||||
.interactiveSpring() // for gesture-driven
|
||||
```
|
||||
|
||||
## Duration guidance per element type
|
||||
|
||||
| Element / event | Duration | Easing |
|
||||
|---|---|---|
|
||||
| Hover state, tap highlight | 100-150ms | easeOutQuad |
|
||||
| Toggle, checkbox, switch | 150-200ms | easeOutCubic |
|
||||
| Tooltip, dropdown open | 150-250ms | easeOutCubic |
|
||||
| Button press feedback | 80-120ms down / 200ms up | easeOut + back on release |
|
||||
| Modal / dialog enter | 300-400ms | easeOutExpo or spring(gentle) |
|
||||
| Modal / dialog exit | 200-250ms | easeInQuart |
|
||||
| Page / route region transition | 300-500ms | easeInOutCubic |
|
||||
| Card / list item reveal | 400-500ms each | easeOutExpo |
|
||||
| Hero headline / full-screen | 500-800ms | easeOutExpo |
|
||||
| Cinematic camera push/pan | 800-2000ms | easeInOutQuad |
|
||||
| Loading spinner | continuous | linear |
|
||||
| Number count-up | 600-1200ms | easeOutExpo |
|
||||
|
||||
## Distance-to-duration scaling
|
||||
|
||||
To keep perceived velocity natural, scale duration with travel distance:
|
||||
|
||||
```
|
||||
duration_ms ≈ base_ms * (distance_px / base_distance_px) ^ 0.5
|
||||
```
|
||||
|
||||
The `^0.5` (square root) prevents long moves from feeling tediously slow while still granting them more time than short moves. Example: base 300ms at 200px -> a 800px move ≈ 300 * sqrt(4) = 600ms.
|
||||
|
||||
## Stagger math
|
||||
|
||||
```
|
||||
total = (count - 1) * offset + per_item_duration
|
||||
```
|
||||
|
||||
Keep `total` under ~800ms for group reveals. If it exceeds that, reduce `offset` or use distance-based staggering (each item's delay proportional to its distance from a focal origin), which keeps a constant feel regardless of count.
|
||||
@@ -1,153 +0,0 @@
|
||||
# Easing in the After Effects Graph Editor
|
||||
|
||||
Tool-specific craft for turning the abstract easing principles into hand-shaped After Effects keyframes. The concepts here (slow-in/slow-out, asymmetric easing, overshoot, bounce) apply everywhere; the controls are AE's Graph Editor.
|
||||
|
||||
## The principle: slow-in / slow-out
|
||||
|
||||
Natural motion accelerates from rest and decelerates to rest — "slow-in, slow-out". Constant-speed (linear) motion reads as robotic because nothing physical starts and stops instantly. Easing means shaping how fast a property changes over time, not just its start and end values.
|
||||
|
||||
## Graph Editor basics
|
||||
|
||||
Open with the **Graph Editor** button (the curve icon) in the Timeline (or Shift+F3). It replaces the keyframe strip with curves.
|
||||
|
||||
Two graph modes, switched via the **graph type menu** at the bottom of the Graph Editor:
|
||||
|
||||
- **Value Graph** — plots the property's value over time (e.g., X position in pixels). The line's height = the value; its slope = the speed.
|
||||
- **Speed Graph** — plots the rate of change (speed) over time. The line's height = speed; a hump = fast, a valley near zero = slow/stopped.
|
||||
|
||||
**How to tell which is shown:** Open the graph type menu → "Edit Value Graph" / "Edit Speed Graph" shows the current choice (the active one is checked). Quick tell: for a single moving property, the **Speed Graph** reads as a hill (accelerate up, decelerate down); the **Value Graph** reads as an S-curve climbing from start value to end value. Spatial properties (Position) usually default to the Speed Graph; single-dimension properties to the Value Graph.
|
||||
|
||||
For shaping ease feel, the **Speed Graph** is the most intuitive: flatten the ends toward zero (slow start/stop), let the middle rise (fast).
|
||||
|
||||
### Display tips
|
||||
- Enable "Show Reference Graph" to see the other graph faintly behind the editable one.
|
||||
- "Snap" and "Fit selection to view" make handle work easier.
|
||||
|
||||
## Why Easy Ease is rarely enough
|
||||
|
||||
`F9` (Easy Ease) applies a symmetric ease with ~33% influence on both sides. It's a starting point, not a finished move. Two reasons it falls flat:
|
||||
|
||||
1. **Symmetry.** Most compelling motion is asymmetric — e.g., snappy UI eases *out* hard (fast start, long glide to stop) far more than it eases in.
|
||||
2. **Low influence.** 33% is gentle. Strong, modern motion-design easing pushes influence to **80–90%** on the deceleration side, producing a sharp launch and a smooth settle.
|
||||
|
||||
So: start with `F9`, then open the Graph Editor and **drag the bezier handles** to make it asymmetric and increase influence.
|
||||
|
||||
## Handle techniques
|
||||
|
||||
In the Graph Editor each keyframe has bezier handles:
|
||||
|
||||
- **Pull a handle horizontally (longer)** → more influence → the ease extends further in time → smoother, slower approach.
|
||||
- **Pull a handle down toward zero speed** → the property comes more fully to rest at that keyframe.
|
||||
- **Ease out 80–90%:** select the final keyframe, drag its incoming handle nearly flat and long so speed glides to near-zero over most of the move. This is the core "snappy" feel.
|
||||
- **Overshoot:** let the value go past the target, then settle back. In the Value Graph, push the curve above the end value briefly, then return. Or add a small keyframe past the target and ease back.
|
||||
- **Bounce:** repeated decaying overshoots — the value hits, overshoots less each time, settling. Build with successive keyframes of decreasing amplitude, each eased.
|
||||
|
||||
## Influence and velocity numbers
|
||||
|
||||
Right-click a keyframe → **Keyframe Velocity** to set exact values:
|
||||
|
||||
- **Incoming/Outgoing Velocity** — the speed at the keyframe (units/sec). Zero = full stop.
|
||||
- **Influence %** — how far the ease extends toward the neighboring keyframe. Higher = smoother, longer ease.
|
||||
|
||||
Targets:
|
||||
- Snappy UI move: outgoing influence ~15–25% (quick launch), **incoming influence 80–90%** (long settle).
|
||||
- Smooth symmetric float: both sides ~50–65%.
|
||||
- Anticipation: a small reverse move before the main action (pull back, then go).
|
||||
|
||||
## Recipe table
|
||||
|
||||
| Feel | Speed Graph shape | Outgoing inf. | Incoming inf. | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Snappy UI | sharp rise, long tail to 0 | 10–25% | 80–90% | The default "good" motion-design ease |
|
||||
| Smooth float | gentle symmetric hill | 50–65% | 50–65% | Calm, even, no snap |
|
||||
| Anticipation | dip below 0 then rise | add pre-keyframe | 70–85% | Tiny reverse move first |
|
||||
| Overshoot | rise, overshoot value, settle | 20–40% | 85–95% | 1 extra keyframe past target |
|
||||
| Bounce | repeated decaying humps | per-bounce | per-bounce | Decreasing amplitude keyframes |
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Action | How |
|
||||
|---|---|
|
||||
| Open Graph Editor | Curve icon in Timeline (or Shift+F3) |
|
||||
| Apply Easy Ease | `F9` (Ease In `Shift+F9`, Ease Out `Ctrl/Cmd+Shift+F9`) |
|
||||
| Switch Value/Speed graph | Graph type menu (bottom of Graph Editor) |
|
||||
| Set exact velocity | Right-click keyframe → Keyframe Velocity |
|
||||
| Convert to bezier handles | Easy Ease, then drag handles |
|
||||
| Strong settle | Incoming influence 80–90% on last keyframe |
|
||||
|
||||
## Step-by-step recipes
|
||||
|
||||
### Snappy UI ease (the workhorse)
|
||||
1. Set two Position (or Scale) keyframes for the move.
|
||||
2. Select both, press `F9` (Easy Ease).
|
||||
3. Open Graph Editor → Speed Graph.
|
||||
4. Select the **first** keyframe; shorten its outgoing handle (low influence, ~15–25%) so it launches fast.
|
||||
5. Select the **last** keyframe; pull its incoming handle long and nearly flat to the time axis — influence **80–90%** — so it glides to a stop.
|
||||
6. Result: fast start, long smooth settle. This is the standard "premium" motion-design feel.
|
||||
|
||||
Exact via Keyframe Velocity (right-click keyframe):
|
||||
- First keyframe: Outgoing influence 18%, velocity 0.
|
||||
- Last keyframe: Incoming influence 85%, velocity 0.
|
||||
|
||||
### Overshoot
|
||||
Goal: element flies in, slightly passes its target, settles back.
|
||||
1. Animate from start to target value (2 keyframes), ease.
|
||||
2. Add a **third keyframe ~6–10 frames before** the final, set its value **past** the target (e.g., target 100% → set 112%).
|
||||
3. Final keyframe returns to target (100%).
|
||||
4. Ease all keyframes; give the settle keyframes high incoming influence (85–95%).
|
||||
5. Tune overshoot amount (how far past) and settle time for snappiness.
|
||||
|
||||
Value Graph view: the curve rises above the target line, then dips back down to it.
|
||||
|
||||
### Anticipation
|
||||
Goal: a small reverse move before the main action (wind-up).
|
||||
1. Before the main motion's first keyframe, add a keyframe that moves **slightly opposite** the main direction (e.g., a jump-up preceded by a small crouch-down).
|
||||
2. Ease the wind-up gently, then the main move fast (low outgoing influence).
|
||||
3. Optionally combine with overshoot on the landing for a full squash-stretch feel.
|
||||
|
||||
### Bounce (hand-keyed)
|
||||
1. First keyframe: rest/start.
|
||||
2. Impact keyframe: the object hits the floor value.
|
||||
3. Bounce keyframes: each successive peak is **lower** than the last (decaying amplitude), spaced **closer** in time (decreasing period).
|
||||
4. At each floor contact, set incoming/outgoing velocity sharp (near-instant direction change); at each peak, ease to near-zero speed (apex).
|
||||
5. 3–4 decaying bounces usually read as natural.
|
||||
|
||||
Amplitude example for a drop settling to 0: 0 → -200 (rise) → 0 → -90 → 0 → -35 → 0 → -12 → 0, with shrinking time gaps.
|
||||
|
||||
### Bounce (expression)
|
||||
Apply to a property (e.g., Position) after a single ease-in keyframe to auto-generate decaying bounce:
|
||||
```javascript
|
||||
amp = 0.12; // bounce strength
|
||||
freq = 2.5; // bounces per second
|
||||
decay = 5.0; // how fast it settles
|
||||
n = 0;
|
||||
if (numKeys > 0) { n = nearestKey(time).index; if (key(n).time > time) n--; }
|
||||
if (n > 0) {
|
||||
t = time - key(n).time;
|
||||
v = velocityAtTime(key(n).time - thisComp.frameDuration/10);
|
||||
value + v*(amp*Math.sin(freq*t*2*Math.PI)/Math.exp(decay*t));
|
||||
} else { value; }
|
||||
```
|
||||
- `amp` raises the overshoot height; `freq` the number of wobbles; `decay` how quickly it stops.
|
||||
- Place a single eased keyframe (or two) where the motion arrives; the expression adds the bounce after.
|
||||
|
||||
## Velocity / influence cheat sheet
|
||||
|
||||
| Feel | Out influence | In influence | Velocity at stops |
|
||||
|---|---|---|---|
|
||||
| Snappy UI | 15–25% | 80–90% | 0 |
|
||||
| Smooth float | 50–65% | 50–65% | 0 |
|
||||
| Mechanical/linear | 0% | 0% | constant |
|
||||
| Overshoot settle | 20–40% | 85–95% | 0 at final |
|
||||
| Bounce apex | high | high | 0 at apex |
|
||||
| Bounce impact | low | low | sharp/non-zero |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Easy Ease ≠ finished.** It's a 33% symmetric starting point; almost always refine in the Graph Editor.
|
||||
- **Know your graph.** Editing a Value Graph thinking it's a Speed Graph produces confusing results — confirm in the graph type menu.
|
||||
- **Influence is per-side.** Asymmetric influence (low out, high in) is what makes UI motion feel snappy.
|
||||
- **Spatial vs temporal.** Position has both a motion path (spatial bezier in the Composition viewer) and speed easing (Graph Editor). A floaty arc may be a spatial handle issue, not temporal. Right-click a Position keyframe for **Rove Across Time** and spatial interpolation (Linear/Auto/Continuous Bezier).
|
||||
- **Auto-Bezier roving keyframes** can smooth speed across multiple keyframes but remove precise per-keyframe timing — use deliberately.
|
||||
- **Overshoot needs room past the target** — clamp/limits or layout edges can hide it.
|
||||
- **Bounce by hand is tedious;** expression-driven bounce (decaying sine) is common, but hand-keyed gives the most control.
|
||||
@@ -1,82 +0,0 @@
|
||||
# The 12 principles of animation, applied to motion graphics
|
||||
|
||||
The Disney principles, translated to UI/motion-graphics with concrete numeric examples. Ordered by how often they matter in screen motion (most-used first).
|
||||
|
||||
## 1. Slow in and slow out (easing)
|
||||
|
||||
The most important. Objects accelerate and decelerate; they don't snap to constant velocity.
|
||||
|
||||
Example: a card sliding in from the right travels 600px over 450ms with `cubic-bezier(0.16, 1, 0.3, 1)` (easeOutExpo) — fast at first, settling gently into place.
|
||||
|
||||
## 2. Timing
|
||||
|
||||
Number of frames / duration defines weight and mood. Fast = light/urgent, slow = heavy/calm.
|
||||
|
||||
Example: a 24px notification badge pops in over 150ms; a full-screen onboarding panel slides over 500ms. Same easing, different timing communicates different mass.
|
||||
|
||||
## 3. Follow-through and overlapping action
|
||||
|
||||
Parts don't all stop at once; trailing elements continue and settle after the lead.
|
||||
|
||||
Example: a hero card lands at 400ms; its drop-shadow finishes settling at 450ms, its title text at 460ms, its subtitle at 500ms — a 40-60ms cascade rather than a synchronized stop.
|
||||
|
||||
## 4. Anticipation
|
||||
|
||||
A small opposite move telegraphs the main action.
|
||||
|
||||
Example: a "send" button scales to `0.95` over 80ms (the dip), then springs to `1.0` and the message launches. Or a modal dips `y: +8px` for 60ms before sliding up — the recoil makes the launch feel powered.
|
||||
|
||||
## 5. Staging (focal direction)
|
||||
|
||||
Direct attention to one thing at a time; the motion makes the important element unambiguous.
|
||||
|
||||
Example: dim background to 40% opacity over 200ms while the target element scales `0.9 -> 1.0` and brightens — the eye has exactly one place to land.
|
||||
|
||||
## 6. Arcs
|
||||
|
||||
Natural movement follows curved paths, not straight lines.
|
||||
|
||||
Example: a floating action button moving from bottom-right to center-screen animates along a quadratic bezier path instead of a diagonal line; the slight arc reads as organic rather than mechanical.
|
||||
|
||||
## 7. Squash and stretch
|
||||
|
||||
Volume-preserving deformation conveys impact and elasticity.
|
||||
|
||||
Example: a bouncing dot stretches `scaleY 1.2 / scaleX 0.85` at the top of its arc and squashes `scaleY 0.8 / scaleX 1.15` on landing impact. Keep the product of scales ≈ 1 to preserve perceived volume. Use sparingly in product UI; common in playful loaders and mascots.
|
||||
|
||||
## 8. Exaggeration
|
||||
|
||||
Push the key pose beyond literal realism for clarity and appeal.
|
||||
|
||||
Example: a success checkmark overshoots to `scale 1.15` before settling to `1.0` (using easeOutBack `0.34, 1.56, 0.64, 1`); the overshoot makes success feel emphatic.
|
||||
|
||||
## 9. Secondary action
|
||||
|
||||
A subordinate motion supporting the primary one.
|
||||
|
||||
Example: while a panel slides up (primary), its icon rotates 90deg and a subtle particle shimmer fades in (secondary) — supporting, never competing for attention.
|
||||
|
||||
## 10. Straight-ahead vs. pose-to-pose
|
||||
|
||||
Pose-to-pose (define keyframes, interpolate between) is the norm for choreographed motion graphics; straight-ahead (frame-by-frame, momentum-driven) suits chaotic/organic effects like particles, smoke, and procedural noise.
|
||||
|
||||
Example: a logo build is pose-to-pose (start pose, end pose, eased between); a confetti burst is straight-ahead/physics-driven.
|
||||
|
||||
## 11. Solid drawing (depth and weight)
|
||||
|
||||
Maintain consistent perspective, volume, and lighting so motion respects 3D space.
|
||||
|
||||
Example: when a 2.5D card tilts on hover (`rotateY 8deg`), its shadow shifts and lengthens consistently and a subtle specular highlight sweeps — selling that it's a solid object catching light, not a flat sticker.
|
||||
|
||||
## 12. Appeal
|
||||
|
||||
The composite charm — clean silhouettes, confident timing, no jank. Achieved by applying the other 11 with restraint: correct easing, deliberate timing, follow-through, a touch of overshoot, and removing anything that competes.
|
||||
|
||||
Example: a polished toggle = 180ms easeOutCubic slide, a 40ms follow-through on the label color, a hairline overshoot on the knob — small, coherent, satisfying.
|
||||
|
||||
## Practical priority for screen motion
|
||||
|
||||
For most product/web work, nailing **slow-in/slow-out, timing, follow-through, anticipation, staging, and arcs** delivers 90% of the quality. Squash/stretch, exaggeration, and secondary action add personality where the brand calls for it.
|
||||
|
||||
---
|
||||
Apply the twelve principles with restraint and UI motion reads as intentional. Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=motion-design-skills&utm_content=skill_footer&utm_term=animation-principles)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
Whitespace-only changes.
@@ -1,229 +0,0 @@
|
||||
---
|
||||
name: beat-sync-editing
|
||||
description: This skill should be used when the user asks to "cut to the beat", "fix the pacing", "edit this sequence", "sync cuts to music/BPM", "add a match cut / J-cut / L-cut", "build a speed ramp", or "plan a shot sequence / edit timeline". Covers editing rhythm, transitions, retiming, and translating an edit plan to an NLE or a code timeline (Remotion/AE frames).
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Beat-Sync Editing (Cuts & Rhythm)
|
||||
|
||||
The timeline craft: how clips and beats assemble into a piece that *feels* right. Editing is temporal design — when a cut lands matters as much as what is on screen. This skill turns musical and narrative structure into concrete cut points, transitions, and retimes, expressible in an NLE or as frame numbers in code.
|
||||
|
||||
## When to use
|
||||
|
||||
- Assembling a sequence or cutting footage to a music track.
|
||||
- Pacing drags or feels choppy; transitions feel arbitrary.
|
||||
- Building speed ramps, match cuts, or tension/release structure.
|
||||
- Converting an edit plan into a frame-accurate timeline (Remotion, AE, NLE).
|
||||
|
||||
## Decision tree
|
||||
|
||||
```
|
||||
What's the editing problem?
|
||||
├─ Cuts feel off the music
|
||||
│ └─ get BPM + offset → frames_per_beat = (60/BPM)*fps
|
||||
│ → cut on PHRASES (2/4/8 beats), biggest visual on the drop
|
||||
├─ A cut feels jarring / visible
|
||||
│ └─ cut on action (mid-motion), match direction + velocity
|
||||
│ → still rough? motivate a transition (match / J / L), else hard cut
|
||||
├─ Pacing drags or feels flat
|
||||
│ └─ shape an arc: Establish → Develop → Climax → Resolve
|
||||
│ → shorten avg shot length toward climax, hold after it
|
||||
├─ A hit needs impact
|
||||
│ └─ speed ramp INTO it (slow-mo), snap back; land slowest frame on the beat
|
||||
└─ Need to hand it to code
|
||||
└─ express every cut as a frame number, clip as from/duration,
|
||||
transition as overlap, retime as eased time-remap (Remotion/AE)
|
||||
```
|
||||
|
||||
**Cross-reference — apply the shared frameworks.**
|
||||
|
||||
- **Motion Personality** (`motion-art-direction`) sets cut rhythm: the chosen energy maps straight to phrase length and transition family. Pick one and commit (see the table below).
|
||||
- **Three Pillars** + beat-sync (`animation-principles`): settle *emotional intent* first (hype vs. calm) — it decides whether you cut every bar or hold for four. `animation-principles` defines the BPM→beat relationship; this skill turns it into frame-accurate cut points and the transition/retime craft on top.
|
||||
|
||||
### Motion Personality → cut rhythm
|
||||
|
||||
| Personality | Phrase (cut every) | Transition family | Retime | Reads as |
|
||||
|---|---|---|---|---|
|
||||
| **Playful** | 4 beats, syncopated accents | match cuts, quick whips | light ramps, bouncy | fun, kinetic |
|
||||
| **Premium** | 8–16 beats, long holds | hard cut + occasional dissolve | slow, smooth ramps | elegant, unhurried |
|
||||
| **Corporate** | 8 beats, steady | hard cut, clean | minimal | clean, trustworthy |
|
||||
| **Energetic** | 1–2 beats at climax | hard cut, whips, hard snaps | aggressive ramps on beats | bold, hype |
|
||||
|
||||
## Core workflow
|
||||
|
||||
### 1. Find the grid: BPM → frames
|
||||
|
||||
Cut on the music's rhythmic grid, not on arbitrary timecodes. Convert BPM to frames so cut points are frame-accurate.
|
||||
|
||||
```
|
||||
frames_per_beat = (60 / BPM) * fps
|
||||
```
|
||||
|
||||
At 120 BPM, 30 fps: `(60/120)*30 = 15` frames per beat (500ms/beat) — a cut every 15 frames is on-beat. Cut on *phrases* (every 2/4/8 beats), not every beat, or the edit feels frantic. Land the single biggest visual moment on the musical drop.
|
||||
|
||||
The **eighth-grid**: subdivisions of the beat give finer accent points without going off-grid. At 120 BPM / 30 fps:
|
||||
|
||||
| Subdivision | ms | frames @30fps | Use |
|
||||
|---|---|---|---|
|
||||
| 1 beat (quarter) | 500 | 15 | main cut grid |
|
||||
| ½ beat (eighth) | 250 | 7.5 | accent / quick flash cut |
|
||||
| ¼ beat (sixteenth) | 125 | 3.75 | hits, stutter, transient FX |
|
||||
| 1 bar (4 beats) | 2000 | 60 | phrase / section boundary |
|
||||
|
||||
Snap accents to the eighth-grid; keep structural cuts on the beat or bar.
|
||||
|
||||
```js
|
||||
// Generate on-beat frame numbers (JS / Remotion / AE-friendly)
|
||||
const bpm = 120, fps = 30;
|
||||
const framesPerBeat = (60 / bpm) * fps; // 15
|
||||
const phrase = 4; // cut every 4 beats
|
||||
const cuts = Array.from({length: 16}, (_, i) =>
|
||||
Math.round(i * framesPerBeat * phrase)); // [0,60,120,180,...]
|
||||
```
|
||||
|
||||
### 2. Hide the seam: cut on action / motion
|
||||
|
||||
A cut is invisible when motion carries the eye across it. Cut *during* a movement (a turn, a hand reaching, a whip) so the brain tracks the action through the cut. Match the motion's direction and speed across the cut. For motion-graphics, cut on a transform peak — at the moment a shape is mid-travel, not at rest.
|
||||
|
||||
### 3. Choose transitions with intent
|
||||
|
||||
Default to the **hard cut**. Reach for anything else only when motivated:
|
||||
|
||||
- **Match cut** — outgoing and incoming frames share a shape, position, or motion vector, so the cut reads as a transformation (a circle becomes the moon; a wipe of an arm reveals the next scene). The strongest transition in motion design.
|
||||
- **J-cut** — audio of the *next* shot starts *before* its picture (sound leads). Pulls the viewer forward; great for dialogue and reveals.
|
||||
- **L-cut** — audio of the *current* shot continues *under* the next picture (sound trails). Smooths scene changes.
|
||||
- **Whip / motion blur transition** — fast pan blends two shots; needs matched blur direction.
|
||||
- **Luma/wipe/dissolve** — signals time passing or a softer mood; overuse reads as amateur.
|
||||
|
||||
Cut-type quick table:
|
||||
|
||||
| Cut type | What aligns | Signals | Typical offset | Reach for it when |
|
||||
|---|---|---|---|---|
|
||||
| Hard cut | nothing — instant | neutral, default | 0 | 90% of cuts |
|
||||
| Match cut | shared shape / position / motion vector | transformation | 0 (1–2f tolerance) | two shots rhyme visually |
|
||||
| J-cut | audio of B leads its picture | pulls viewer forward | audio 4–12f early | dialogue, reveals |
|
||||
| L-cut | audio of A trails under B | smooths the change | audio 12–24f late | scene changes, ambient |
|
||||
| Whip / motion blur | matched blur direction across pan | energy, "swoosh" | ~6–10f blend | hype, scene jumps |
|
||||
| Speed ramp | retime, not a cut | impact / emphasis | lands on beat | a hit needs to land |
|
||||
|
||||
### 4. Retime: speed ramps
|
||||
|
||||
Speed ramps (gradual time-warp) add impact: ramp *into* a hit with slow-motion, then snap back to speed. Keep motion blur consistent through the ramp or the slow section looks strobed.
|
||||
|
||||
```
|
||||
Speed ramp on an impact (frames, 30fps):
|
||||
0–20 100% speed (run-up)
|
||||
20–28 ease down to 25% (anticipation, slow-mo on the hit)
|
||||
28 IMPACT lands on the beat
|
||||
28–40 ease back to 100% (release)
|
||||
```
|
||||
|
||||
In an NLE use time-remap keyframes with smooth interpolation and enable frame-blending/optical-flow for clean slow-mo. In Remotion/AE, drive a `timeRemap`/`valueAtTime` curve with eased interpolation and keep a consistent shutter/motion-blur setting.
|
||||
|
||||
### 5. Shape the arc: sequence pacing
|
||||
|
||||
A piece needs a shape, not one constant intensity:
|
||||
|
||||
```
|
||||
Establish → Develop → Climax → Resolve
|
||||
longer holds shortening cuts fastest + the drop release/breathe
|
||||
```
|
||||
|
||||
Vary shot length deliberately: fast cuts build energy, longer holds let key moments land. Build toward the climax by shortening average shot length, then give a beat of rest after it. The classic mistake is uniform pacing — it reads as flat regardless of content.
|
||||
|
||||
Pacing arc — tension → release (energy is the *derivative* of cut frequency):
|
||||
|
||||
| Phase | Cut every | Avg shot length | Tension | Note |
|
||||
|---|---|---|---|---|
|
||||
| Establish | 8–16 beats | longest | low | set place/mood, let it breathe |
|
||||
| Develop | 8 → 4 beats | shortening | rising | accelerate cuts to build |
|
||||
| Climax | 1–2 beats | shortest | peak | the drop; biggest visual + speed ramp |
|
||||
| Resolve | one long hold | longest | release | logo/CTA breathes after the peak |
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Goal | Move |
|
||||
|---|---|
|
||||
| On-beat cut | `(60/BPM)*fps` frames per beat; cut on phrases |
|
||||
| Land the drop | biggest visual on the musical drop |
|
||||
| Invisible cut | cut on action / matched motion |
|
||||
| Transform feel | match cut (shared shape/motion) |
|
||||
| Pull forward | J-cut (audio leads) |
|
||||
| Smooth scene change | L-cut (audio trails) |
|
||||
| Impact | speed ramp into the hit, snap out |
|
||||
| Build energy | shorten average shot length |
|
||||
| Let it breathe | hold after the climax |
|
||||
|
||||
## Worked examples
|
||||
|
||||
### GOOD — 30s energetic product spot, 128 BPM, 30 fps
|
||||
|
||||
> **Brief**: hype sneaker drop, Energetic personality, drop at beat 32.
|
||||
>
|
||||
> - **Grid**: 14.06 f/beat. Drop = beat 32 ≈ frame 450 (rounded cumulatively to avoid drift).
|
||||
> - **Arc**: Establish 0–120 (two 2s holds). Develop 120–450 (cuts shortening 8→4→2 beats, rising energy — matches Energetic). Climax at 450 (the drop). Resolve 700–900 (one long CTA hold).
|
||||
> - **Cuts**: structural cuts on the bar; two accent flash-cuts snapped to the eighth-grid in Develop.
|
||||
> - **Craft**: f112 is a match cut (shared circular logo → wheel). The drop at 450 carries the biggest visual *and* a speed ramp 100%→25%→100% landing the slowest frame on the downbeat. CTA enters under an L-cut (audio trails ~18f).
|
||||
> - **To code**: each cut a frame number, each clip `<Sequence from/durationInFrames>`, ramp = eased time-remap, beats verified `frame/fps` against detected beat seconds.
|
||||
>
|
||||
> Why it works: phrase-based grid, an arc, one big moment on the drop, motivated transitions, frame-accurate and portable.
|
||||
|
||||
### ANTI-PATTERN — "cut on every beat with a dissolve each time"
|
||||
|
||||
> - A hard cut on all 32 beats → relentless, no focal moment, viewer fatigues by beat 8.
|
||||
> - A 12-frame crossfade on *every* cut to "smooth it" → mushy, amateur, and now nothing is on the beat because the dissolve midpoint drifts off-grid.
|
||||
> - Uniform 15-frame shots start to finish → flat; no establish, no climax, the drop lands on nothing.
|
||||
> - BPM math done from frame 0 ignoring the track's offset → every cut sits a few frames early, feels "almost right" but wrong.
|
||||
>
|
||||
> Fix: cut on 4/8-beat phrases, hard cut by default, build an arc, anchor the grid to beat-1 offset, and put the one biggest visual on the drop.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
| Symptom | Why | Fix |
|
||||
|---|---|---|
|
||||
| Edit feels frantic / busy | cutting on every beat | cut on 2/4/8-beat phrases; accent on the eighth-grid sparingly |
|
||||
| Cuts feel "almost on beat" | grid started at 0:00, ignored track offset | capture beat-1 offset; anchor cumulative frame math to it |
|
||||
| Drift over a long edit | rounding frames_per_beat per step | round each *cumulative* beat, not per-step |
|
||||
| Cut feels jarring | placed at rest, between motions | cut on action / mid-motion; match direction + velocity |
|
||||
| Slow-mo strobes | sub-100% speed without interpolated frames | enable optical-flow / frame-blending; consistent shutter |
|
||||
| Whole piece feels flat | uniform pacing | shape Establish→Develop→Climax→Resolve; shorten toward the drop |
|
||||
| Transitions look amateur | dissolves/wipes used as a crutch | default to hard cut; motivate match/J/L; fix timing first |
|
||||
| Nothing lands | no single climax | put the biggest visual on the drop; hold after it |
|
||||
|
||||
## Deliverable — an edit-timing plan
|
||||
|
||||
A good plan is renderer-agnostic and hands off to an NLE or a code timeline (`remotion-video`, `after-effects`):
|
||||
|
||||
- **Track spec**: BPM, fps, beat-1 offset, drop frame(s), frames_per_beat.
|
||||
- **Arc**: the four phases with frame ranges and target average shot length per phase.
|
||||
- **Cut list**: every cut as an absolute **frame number** (from the BPM math), the clip at that cut, and the cut type.
|
||||
- **Transitions**: type (hard/match/J/L/whip), and for J/L the audio offset in frames.
|
||||
- **Retimes**: each speed ramp as eased time-remap keyframes (from→to %, frames, optical-flow on).
|
||||
- **Beat markers**: the on-beat/eighth-grid frame array for verification.
|
||||
- **Cross-refs**: the Motion Personality that set the rhythm.
|
||||
|
||||
> For Remotion/AE this plan IS the frame timing: cuts → `from`/`durationInFrames` (or layer in/out), ramps → eased `interpolate`/time-remap, J/L → offset `<Audio from>` / audio layer. See `references/editing-techniques.md` for the NLE↔code mapping table.
|
||||
|
||||
### Before you finish — checklist
|
||||
|
||||
- [ ] BPM + fps + beat-1 offset captured; frames_per_beat computed and cumulatively rounded.
|
||||
- [ ] Cuts on phrases (2/4/8 beats), not every beat; accents on the eighth-grid only.
|
||||
- [ ] The single biggest visual lands on the drop.
|
||||
- [ ] An arc exists (Establish→Develop→Climax→Resolve); avg shot length shortens toward the climax, then a hold.
|
||||
- [ ] Hard cut is the default; every other transition is motivated; J/L audio offsets specified in frames.
|
||||
- [ ] Speed ramps land the slowest frame on the beat; optical-flow/frame-blending enabled.
|
||||
- [ ] Cut rhythm matches the chosen Motion Personality.
|
||||
- [ ] Every cut, transition, and retime expressed as a frame number / from-duration so it ports to Remotion/AE/NLE.
|
||||
- [ ] Audio sync verified: `frame/fps` against detected beat seconds.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Cutting on *every* beat is busy; cut on 2/4/8-beat phrases and accent sparingly.
|
||||
- Speed ramps without consistent motion blur look strobed; enable optical-flow/frame-blending for slow-mo.
|
||||
- Crossfades/dissolves are not a fix for a bad cut — fix the timing first.
|
||||
- Audio sync drift: in code timelines verify `frame/fps` against detected beat seconds; in NLEs lock audio to a frame-accurate guide track.
|
||||
- One pacing throughout = flat. Always shape an arc.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/editing-techniques.md` — BPM→frame math with phrase tables, cut-on-action breakdowns, building a match cut step by step, J-cut/L-cut audio-lead/trail diagrams with frame offsets, full speed-ramp recipe with motion-blur/optical-flow settings, sequence-arc templates with shot-length curves, and mapping an edit plan between an NLE timeline and a code timeline (Remotion `<Sequence>` / AE time-remap).
|
||||
@@ -1,137 +0,0 @@
|
||||
# Editing Techniques — Detailed Cookbook
|
||||
|
||||
Concrete recipes for rhythm, transitions, and retiming, with the math to make cuts frame-accurate.
|
||||
|
||||
## BPM → frame math
|
||||
|
||||
```
|
||||
frames_per_beat = (60 / BPM) * fps
|
||||
seconds_per_beat = 60 / BPM
|
||||
```
|
||||
|
||||
Reference (frames per beat):
|
||||
|
||||
| BPM | 24 fps | 25 fps | 30 fps | 60 fps |
|
||||
|----|----|----|----|----|
|
||||
| 90 | 16.0 | 16.7 | 20.0 | 40.0 |
|
||||
| 100 | 14.4 | 15.0 | 18.0 | 36.0 |
|
||||
| 120 | 12.0 | 12.5 | 15.0 | 30.0 |
|
||||
| 128 | 11.25| 11.7 | 14.06| 28.13|
|
||||
| 140 | 10.3 | 10.7 | 12.86| 25.7 |
|
||||
|
||||
When `frames_per_beat` is fractional (e.g. 128 BPM at 30 fps = 14.06), round each *cumulative* beat to avoid accumulating drift:
|
||||
|
||||
```js
|
||||
const cut = (i) => Math.round(i * (60 / bpm) * fps); // round at the end, not per-step
|
||||
```
|
||||
|
||||
Cut on phrases, not beats:
|
||||
|
||||
| Energy | Cut every |
|
||||
|---|---|
|
||||
| Frantic / climax | 1–2 beats |
|
||||
| Energetic | 4 beats (1 bar in 4/4) |
|
||||
| Moderate | 8 beats (2 bars) |
|
||||
| Calm / establishing | 16 beats (4 bars) |
|
||||
|
||||
Detect BPM offline (aubio, `web-audio-beat-detector`, or tap-tempo) and also capture the *offset* of beat 1 — the grid rarely starts at 0:00.
|
||||
|
||||
## Cut on action / cut on motion
|
||||
|
||||
The eye tracks moving subjects; a cut placed mid-motion is masked by that movement.
|
||||
|
||||
- Identify a continuous action spanning two shots (door opening, head turn, object thrown).
|
||||
- Place the cut roughly one-third into the action, and continue the *same* action and direction in the next shot.
|
||||
- Match screen direction (subject moving left exits left, enters from right) and velocity.
|
||||
- In motion graphics: cut while a shape is mid-travel (transform velocity high), not when it has settled — the motion blur and momentum hide the splice.
|
||||
|
||||
## Match cut, step by step
|
||||
|
||||
A match cut equates two shots via a shared visual so the cut reads as a transformation.
|
||||
|
||||
1. Find or design a shape/position/motion shared by the end of shot A and the start of shot B (a round clock → a round wheel; an arm wiping frame → next scene revealed).
|
||||
2. Align them in screen space: same size, same position, same orientation at the cut frame.
|
||||
3. Match motion vector and speed across the cut so momentum carries through.
|
||||
4. Cut on the exact frame where the two forms register; a 1–2 frame mismatch breaks the illusion.
|
||||
5. Optionally add a 1–3 frame motion-blur bridge to fuse them.
|
||||
|
||||
Common forms: graphic match (shape), motion match (direction/velocity), audio match (a sound bridges the cut), and the "invisible wipe" where a foreground object wipes the frame and the next shot is revealed behind it.
|
||||
|
||||
## J-cut and L-cut
|
||||
|
||||
Audio and picture do not have to cut at the same frame.
|
||||
|
||||
```
|
||||
J-CUT (audio of B leads its picture — pulls forward)
|
||||
Picture: AAAAAAAA | BBBBBBBB
|
||||
Audio: AAAAA BBBBBBBBBBBBB
|
||||
^ B's audio starts ~6–12 frames early
|
||||
|
||||
L-CUT (audio of A trails under B's picture — smooths)
|
||||
Picture: AAAAAAAA | BBBBBBBB
|
||||
Audio: AAAAAAAAAAAA BBBBBB
|
||||
^ A's audio continues ~6–18 frames past the picture cut
|
||||
```
|
||||
|
||||
Typical offsets: dialogue J-cuts 4–12 frames; ambient L-cuts 12–24 frames. In an NLE, unlink audio/video and trim only the audio edge. In a code timeline, place the `<Audio>` for shot B with a `from` earlier than B's picture sequence.
|
||||
|
||||
## Speed ramp recipe (with motion blur)
|
||||
|
||||
```
|
||||
Impact ramp, 30 fps, drop on frame 28:
|
||||
Frame Speed Note
|
||||
0–20 100% run-up, normal motion blur
|
||||
20–28 100%→25% ease-OUT deceleration into the hit
|
||||
28 25% IMPACT, lands on the downbeat
|
||||
28–34 25%→100% ease-IN re-acceleration
|
||||
34+ 100% release
|
||||
```
|
||||
|
||||
Settings:
|
||||
- Use smooth (Bezier) interpolation on time-remap keyframes; linear ramps look mechanical.
|
||||
- Enable **optical flow** (or frame-blending) for sub-100% speeds so slow-mo isn't strobed; optical flow generates intermediate frames.
|
||||
- Keep motion-blur shutter consistent across the ramp — if the source has motion blur, the slow section keeps it; if synthetic, hold a constant shutter angle (e.g. 180°).
|
||||
- Land the slowest frame *on the beat*; the ramp is felt, the impact is heard.
|
||||
|
||||
In AE: enable time remapping, keyframe the source time, ease the keyframes, set frame blending to Pixel Motion. In Remotion: drive a remapped source time via eased `interpolate` and keep effects deterministic.
|
||||
|
||||
## Sequence arc templates
|
||||
|
||||
```
|
||||
30s social spot (≈ 900 frames @30fps):
|
||||
Establish 0–120 2 shots, ~2s each (longer holds)
|
||||
Develop 120–540 shots shortening 1.5s → 0.8s, rising energy
|
||||
Climax 540–720 fastest cuts 0.3–0.5s, the drop at ~600
|
||||
Resolve 720–900 one long hold, logo/CTA breathes
|
||||
```
|
||||
|
||||
Plot average shot length as a curve: high at the start, descending to the climax, then a single long shot to release. Energy is the *derivative* of cut frequency — accelerate cuts to build, decelerate to land.
|
||||
|
||||
## NLE timeline vs code timeline
|
||||
|
||||
| Concept | NLE | Remotion | After Effects |
|
||||
|---|---|---|---|
|
||||
| Clip placement | drag to track at TC | `<Sequence from durationInFrames>` | layer in/out points |
|
||||
| Back-to-back shots | adjacent clips | `<Series>` | trimmed layers |
|
||||
| Crossfade | overlap + dissolve | overlap `Series` + opacity interp | opacity keyframes / overlap |
|
||||
| Speed ramp | time-remap keyframes | eased source-time `interpolate` | time remap + frame blending |
|
||||
| J/L cut | unlink + trim audio | `<Audio from>` ≠ picture seq | offset audio layer |
|
||||
| Beat marker | timeline markers | beat array `frame/fps` | comp markers / expression |
|
||||
|
||||
To port an edit plan: express each cut as a frame number (from the BPM math), each clip as `from`/`duration`, each transition as an overlap, and each retime as an eased time-remap. The plan is renderer-agnostic; only the placement syntax differs.
|
||||
|
||||
## Example edit plan (portable)
|
||||
|
||||
```
|
||||
Track: 128 BPM, 30fps → 14.06 f/beat, drop at beat 32 (frame 450)
|
||||
Cut on 4-beat phrases (≈56 frames):
|
||||
f0 ShotA establish, hold
|
||||
f56 ShotB cut on action (subject turns)
|
||||
f112 ShotC match cut from B (shared circle)
|
||||
f168 ... cuts shorten toward climax
|
||||
f450 DROP biggest visual; speed ramp 100→25→100 around f450
|
||||
f700 CTA long hold, L-cut audio trails in
|
||||
```
|
||||
|
||||
---
|
||||
Cut on the beat and ramp with purpose and the edit moves with the music. Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=motion-design-skills&utm_content=skill_footer&utm_term=beat-sync-editing)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
Whitespace-only changes.
@@ -1,175 +0,0 @@
|
||||
---
|
||||
name: color-motion
|
||||
description: This skill should be used when the user asks to "build a color palette for a motion piece", "make a gradient background", "animate a color transition", "interpolate colors smoothly", "fix muddy/gray gradients", "convert colors to OKLCH", "color-grade a video for mood", "fix washed-out After Effects renders", "gamma shift", "Rec.709 vs sRGB", "colors shifted after rendering", or "match preview to final video". Covers palette construction, gradients, perceptual interpolation (OKLCH/Lab), grading order, and video/render color management.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Color in Motion
|
||||
|
||||
Choose, pair, and animate color for motion design: building restrained palettes, constructing gradients that read as premium, interpolating colors in perceptually-uniform spaces, and grading a piece for mood and consistency. Produces palettes with exact OKLCH/hex values, gradient CSS/code, and transition parameters.
|
||||
|
||||
## When to use
|
||||
|
||||
- Picking or pairing a palette for a motion or video piece.
|
||||
- Building gradient backgrounds (mesh, noise, animated drift).
|
||||
- Animating color transitions (button states, theme switch, loaders).
|
||||
- Grading a video for mood, or fixing dull/gray gradient interpolation.
|
||||
- Fixing washed-out, gamma-shifted, or platform-inconsistent After Effects renders, and choosing correct per-platform export/color tags.
|
||||
|
||||
## Core techniques
|
||||
|
||||
### 1. Build a restrained palette
|
||||
|
||||
Limit hues. A strong motion palette is **1 primary + 1 accent + 2-3 neutrals**. Derive contrast and depth from lightness and chroma, not from adding more hues. Extra hues fight for attention and read as amateur.
|
||||
|
||||
In dark scenes, make the accent pop with **low-to-moderate chroma and high lightness** rather than maximum saturation — a blown-out saturated accent vibrates and looks cheap on dark backgrounds.
|
||||
|
||||
Use OKLCH to reason about color: `oklch(L C H)` where L is perceptual lightness (0-1), C is chroma (0 ≈ 0.37 max), H is hue angle (0-360). Holding L and C constant while rotating H gives hues of genuinely equal visual weight — impossible to do reliably in HSL.
|
||||
|
||||
```
|
||||
Primary: oklch(0.62 0.19 264) /* confident blue */
|
||||
Accent: oklch(0.78 0.17 50) /* warm amber, lighter so it pops */
|
||||
Neutral-0: oklch(0.97 0.005 264) /* near-white, faint hue tint */
|
||||
Neutral-1: oklch(0.55 0.01 264)
|
||||
Neutral-2: oklch(0.18 0.015 264) /* near-black, same hue family */
|
||||
```
|
||||
|
||||
Tinting neutrals slightly toward the primary hue (tiny chroma) unifies the frame; pure gray neutrals next to colored elements look disconnected.
|
||||
|
||||
### 2. Interpolate in OKLCH or Lab — never raw sRGB
|
||||
|
||||
Linearly interpolating two colors in sRGB passes through a desaturated, often gray or muddy midpoint (blue->yellow goes through gray; blue->red through muddy purple). Perceptual spaces (OKLCH, OKLab, CIE Lab) keep midpoints vivid and lightness even.
|
||||
|
||||
CSS gradients support this natively:
|
||||
|
||||
```css
|
||||
/* Vivid, even midpoint */
|
||||
background: linear-gradient(in oklch, #2b6cff, #ffb02e);
|
||||
|
||||
/* Control hue path around the wheel for two-stop gradients */
|
||||
background: linear-gradient(in oklch longer hue, #2b6cff, #ffb02e);
|
||||
background: conic-gradient(in oklch, red, blue, red);
|
||||
```
|
||||
|
||||
For animated transitions interpolate with a library (see references) and emit per-frame colors, or for two-color CSS transitions register a typed custom property so the browser interpolates in the chosen space.
|
||||
|
||||
### 3. Gradients that read as premium
|
||||
|
||||
- **Mesh / multi-radial**: stack several `radial-gradient`s at different positions over a base color. Soft, organic, expensive-looking.
|
||||
- **Add grain**: a faint noise overlay (SVG `feTurbulence` or a tiled PNG at ~3-5% opacity) kills banding and adds texture.
|
||||
- **Animate slowly**: drift gradient positions over 8-20s with `ease-in-out alternate`. Slow is premium; fast gradient motion looks like a screensaver.
|
||||
|
||||
```css
|
||||
.mesh {
|
||||
background-color: #0b0f1a;
|
||||
background-image:
|
||||
radial-gradient(at 20% 25%, oklch(0.62 0.19 264 / 0.7), transparent 50%),
|
||||
radial-gradient(at 80% 30%, oklch(0.70 0.16 320 / 0.6), transparent 55%),
|
||||
radial-gradient(at 50% 85%, oklch(0.78 0.17 50 / 0.5), transparent 50%);
|
||||
animation: drift 16s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes drift {
|
||||
to { background-position: 8% -6%, -8% 6%, 4% 8%; }
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Color transition animation
|
||||
|
||||
Inline timing essentials so this skill stands alone: enter/highlight with ease-out (`cubic-bezier(0.16, 1, 0.3, 1)`), state changes 150-300ms, theme/full-screen washes 400-800ms. Use `linear` only for continuous loops.
|
||||
|
||||
```css
|
||||
@property --c { syntax: "<color>"; initial-value: #2b6cff; inherits: false; }
|
||||
.btn { background: var(--c); transition: --c 200ms cubic-bezier(0.16,1,0.3,1); }
|
||||
.btn:hover { --c: #ffb02e; }
|
||||
```
|
||||
|
||||
`@property` registration is what makes the color actually interpolate (unregistered custom properties jump instantly).
|
||||
|
||||
### 5. Grade for mood — correct order
|
||||
|
||||
Apply grading operations in this order; reordering changes the result:
|
||||
|
||||
1. **Exposure / white balance** — fix overall brightness and neutralize/intentionally set the cast first.
|
||||
2. **Contrast** — set black and white points; expand or compress the tonal range.
|
||||
3. **Midtones / saturation** — adjust overall saturation and midtone color.
|
||||
4. **Split-tone** — tint shadows and highlights separately. The classic cinematic look is **teal shadows + orange highlights** (complementary, flatters skin). Keep it subtle.
|
||||
5. **Unify** — a final overall tint/LUT and a subtle vignette to bind the frame.
|
||||
|
||||
In code, grade by chaining CSS filters or a fragment shader; for video, an actual LUT or grading panel. Always grade the whole piece consistently so shots cut together.
|
||||
|
||||
### 6. Video / render color management
|
||||
|
||||
When a render looks correct in the After Effects comp viewer but washed-out, too bright, milky, or gamma-shifted in QuickTime, a browser, or across macOS/Windows, the cause is a gamma/transfer mismatch across four stages — AE working space, the render's embedded tag, the player's interpretation, and the display/OS gamma — not the artwork.
|
||||
|
||||
Core rules:
|
||||
|
||||
- **Set an explicit working space.** File > Project Settings > Color tab: Working Space = **Rec.709 Gamma 2.4** for SDR video (or **sRGB** for UI/stills); "None" disables color management and the viewer lies about the final look. Set Depth to **16 bpc** for grades/gradients to avoid banding.
|
||||
- **Rec.709 vs sRGB.** They share primaries but differ in transfer (sRGB ~2.2 piecewise vs Rec.709 display 2.4 / BT.1886). Using the wrong one shifts midtone brightness — Rec.709 for video, sRGB for screenshots/PNG/UI.
|
||||
- **Embed a color tag on export.** The shift across macOS/Windows/players comes from untagged files; each player guesses its own default. Tag Rec.709 (or sRGB) explicitly via Render Queue > **Output Module > Color Management** (do not leave "Preserve RGB" when a shift occurs).
|
||||
- **The QuickTime gamma bug.** QuickTime/AVFoundation apply an extra ~1.96 gamma, so a file can look fine in QuickTime but dark/contrasty in Chrome. **Never grade or approve to QuickTime** — Chrome, Premiere, or Resolve is the correct reference for web delivery.
|
||||
- **Match preview to final.** Toggle "Use Display Color Management" in the comp viewer while reviewing, and verify the export numerically: place a 50% grey (RGB 128) patch, render, re-sample — a value that rises (128 → 150+) confirms a washed-out gamma shift. Confirm the embedded tag with `ffprobe` (`color_primaries`/`color_transfer`/`color_space` = bt709).
|
||||
|
||||
Per-platform export at a glance: **ProRes 422 HQ/4444** tagged Rec.709 for editing masters; **H.264 (MP4)** tagged Rec.709 for web/`<video>`/social (verify in Chrome); ProRes 4444 + alpha → PNG (sRGB) for transparency; ProRes 4444 / HEVC 10-bit Rec.2020 PQ/HLG for HDR (needs 32 bpc float).
|
||||
|
||||
See `references/color-management.md` for the full diagnosis decision tree, exact menu paths, per-platform export table, the QuickTime workarounds, per-OS/player gamma values, the numeric test-patch method, `ffprobe`/`ffmpeg` tag commands, limited-vs-full-range pitfalls, and the pre-export checklist.
|
||||
|
||||
## 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 web palette/gradient/color-transition deliverable, ship **one `.html` file that opens directly in a browser** — no build step. CSS handles the color work; the file is self-contained.
|
||||
|
||||
**Output contract:**
|
||||
- One `.html` file: your markup + the gradient/transition in inline `<style>` (CDN only if a JS interpolation lib is needed).
|
||||
- One animation driver — a CSS `@keyframes` drift or a single `@property` transition; nothing competing.
|
||||
- Include the freeze harness below so any moment can be screenshotted deterministically.
|
||||
|
||||
**Freeze harness — pin a CSS-animation frame for screenshots.** `?t=N` sets a negative `animation-delay` and pauses, so the gradient/transition lands on the exact frame at `N` seconds.
|
||||
|
||||
```html
|
||||
<script>
|
||||
const t = new URLSearchParams(location.search).get("t");
|
||||
if (t !== null) {
|
||||
document.querySelectorAll(".bg, .btn").forEach(el => { // animated elements
|
||||
el.style.animationDelay = (-parseFloat(t)) + "s";
|
||||
el.style.animationPlayState = "paused";
|
||||
});
|
||||
}
|
||||
window.__ready = true; // ready signal for headless wait
|
||||
</script>
|
||||
```
|
||||
|
||||
**Verify loop — render → freeze → screenshot → check:**
|
||||
1. Open the file frozen at start / mid / end: `…/color.html?t=0`, `?t=<period/2>`, `?t=<period>`.
|
||||
2. Screenshot each frozen frame.
|
||||
3. Check **fidelity** (matches the palette/brief) and **color artifacts** — at each frozen frame confirm the gradient/transition has **no banding and no muddy or gray midpoint** (the OKLCH/Lab path stays vivid). Add grain if banding appears.
|
||||
4. **Iterate:** if banding or a muddy/gray midpoint shows up, adjust the OKLCH/Lab chroma & lightness (or add grain), re-render the **same** `?t=N`, and re-screenshot to confirm the fix — loop until every frozen frame is clean.
|
||||
|
||||
```bash
|
||||
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/color.html?t=8" frame-mid.png
|
||||
```
|
||||
|
||||
**Before you finish:**
|
||||
1. Opens standalone in a browser — no console errors, no missing CDN.
|
||||
2. One color driver; `?t=N` freezes the exact frame correctly.
|
||||
3. Screenshotted at start / mid / end — no banding, no muddy/gray midpoints, lightness even.
|
||||
4. `prefers-reduced-motion` honored (drift paused on a static, readable frame).
|
||||
5. Interpolation is in OKLCH/Lab (gradients/`@property`), never raw sRGB.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Need | Do |
|
||||
|---|---|
|
||||
| Palette size | 1 primary + 1 accent + 2-3 neutrals |
|
||||
| Equal-weight hues | hold L,C in OKLCH, vary H |
|
||||
| Vivid gradient | `linear-gradient(in oklch, ...)` |
|
||||
| Smooth color anim | interpolate in OKLCH/Lab, register via `@property` |
|
||||
| Premium bg | stacked radials + grain + 8-20s slow drift |
|
||||
| Cinematic grade | teal shadows / orange highlights, subtle |
|
||||
| Fix washed-out AE render | tag Rec.709 in Output Module > Color Management; verify in Chrome |
|
||||
| Avoid | raw-sRGB interpolation (muddy midpoints); grading to QuickTime |
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/palettes-and-interpolation.md` — palette construction rules with OKLCH values and named recipes, gradient cookbook (mesh, conic, grain), culori and d3-interpolate code for perceptual interpolation and scales, and the full grading-order checklist with filter/shader examples.
|
||||
- `references/color-management.md` — full video/render color management: diagnosis decision tree, AE project/output color settings with exact menu paths, per-platform export table, QuickTime gamma-bug workarounds, per-OS/player gamma values, the numeric test-patch method, `ffprobe`/`ffmpeg` tag verification, limited-vs-full-range pitfalls, and the pre-export checklist.
|
||||
@@ -1,165 +0,0 @@
|
||||
# Video / render color management (After Effects)
|
||||
|
||||
How to diagnose and prevent the most common After Effects color complaint: a render that looks correct inside the AE composition viewer but appears washed-out, too bright, milky, or gamma-shifted when played in QuickTime Player, uploaded to a browser, or moved between macOS and Windows. The root cause is almost always a mismatch in how gamma / transfer functions are interpreted across AE, the codec metadata, the OS player, and the display pipeline — not a problem with the artwork.
|
||||
|
||||
## Mental model: where shifts come from
|
||||
|
||||
A pixel value travels through four stages, each of which can apply or assume a gamma. A shift is a mismatch between two of them:
|
||||
|
||||
1. **AE project working space** — the math AE uses internally (linear vs a tagged display space). Set in File > Project Settings > Color tab.
|
||||
2. **The render's embedded tag** — what transfer/primaries metadata the codec writes (or omits) into the file.
|
||||
3. **The player's interpretation** — QuickTime, Chrome, VLC, and Windows Media each guess differently when the tag is missing or when they apply ColorSync.
|
||||
4. **The display + OS gamma** — macOS historically assumed ~1.8, now ~2.2/2.4-ish via ColorSync; the long-standing "QuickTime gamma bug" comes from QuickTime/AVFoundation applying an extra gamma curve (~1.96) that Chrome and most editors do not.
|
||||
|
||||
Key fact: most web video is **Rec.709**, whose camera/encode side is roughly gamma 2.2-ish but whose *standard display* gamma is **2.4** (BT.1886). macOS QuickTime effectively renders nearer **1.96**, which is why a file can look fine in QuickTime but slightly dark/contrasty in a browser, or vice-versa. The fix is never "eyeball it in QuickTime" — QuickTime is the least trustworthy reference.
|
||||
|
||||
## Decision tree: which problem is this?
|
||||
|
||||
```
|
||||
Render looks DIFFERENT from comp preview?
|
||||
├─ Looks washed-out / milky / brighter, low contrast
|
||||
│ └─ Almost always: untagged or mistagged H.264/ProRes +
|
||||
│ player applying its own gamma (QuickTime gamma bug).
|
||||
│ → Use ProRes for masters; tag Rec.709; verify in
|
||||
│ Chrome, NOT QuickTime.
|
||||
├─ Looks fine in QuickTime, WRONG (dark/contrasty) in Chrome
|
||||
│ └─ QuickTime is applying ~1.96 gamma; Chrome honors the
|
||||
│ 2.4/sRGB pipeline. The browser is the correct reference
|
||||
│ for web delivery.
|
||||
├─ Looks fine on macOS, wrong on Windows (or reverse)
|
||||
│ └─ Missing color tag → each OS assumes a default. Embed an
|
||||
│ explicit Rec.709 (or sRGB) tag on export.
|
||||
└─ Banding / posterization in gradients
|
||||
└─ 8-bit project on heavy grades. Set Project > Depth to
|
||||
16 bpc and/or enable Linearize Working Space.
|
||||
```
|
||||
|
||||
## Project Color Settings (exact menu paths)
|
||||
|
||||
Open **File > Project Settings… > Color** tab.
|
||||
|
||||
- **Depth**: set to **16 bits per channel** for grades/gradients (8 bpc is fine for simple cuts; 32 bpc float only for HDR/heavy linear comps).
|
||||
- **Working Space**: For standard web/SDR delivery choose **Rec.709 Gamma 2.4** (or **sRGB IEC61966-2.1** if the deliverable is screenshots/UI). Setting any working space (not "None") turns AE color management ON, so the viewer now shows display-referred color consistently.
|
||||
- **Linearize Working Space** / **Blend Colors Using 1.0 Gamma**: enable when doing glows, blurs, and additive composites so blending math is physically correct. This changes the look of feathered edges and screen blends — enable it early, not at the end.
|
||||
- **Compensate for Scene-referred Profiles**: leave default unless mixing log/cineon footage.
|
||||
|
||||
To preview what a viewer/OS will actually see, use the composition viewer's **bottom toolbar > "Use Display Color Management"** toggle, and the eyedropper/Info panel to read numeric RGB values rather than trusting the on-screen brightness.
|
||||
|
||||
## Export settings per platform
|
||||
|
||||
Prefer the **Render Queue** (Composition > Add to Render Queue) for ProRes masters, or **File > Export > Add to Adobe Media Encoder Queue** for H.264 web files.
|
||||
|
||||
| Target | Codec / format | Color tag to embed | Notes |
|
||||
|---|---|---|---|
|
||||
| Editing master (FCP/Premiere/Resolve) | **ProRes 422 HQ / 4444** (QuickTime) | Rec.709 | Highest fidelity; avoids H.264 gamma guessing |
|
||||
| Web / `<video>` / social | **H.264** (MP4) via Media Encoder | **Rec.709**, tagged | Set Match Source then override profile; verify in Chrome |
|
||||
| YouTube / Vimeo upload | H.264 high bitrate or ProRes | Rec.709 | Platforms re-encode; correct tag prevents their guess |
|
||||
| Transparency for web | **PNG / ProRes 4444 + alpha** then convert | sRGB | Browsers treat PNG as sRGB |
|
||||
| HDR | ProRes 4444 / HEVC 10-bit | Rec.2020 PQ/HLG | Requires 32 bpc float project |
|
||||
|
||||
In **Adobe Media Encoder**, the QuickTime/ProRes color tag is governed by the AE **Output Module > Color Management** sub-tab. In the Render Queue, click the **Output Module** name > **Color Management** tab and set the output profile to **Rec.709 Gamma 2.4** (do not leave "Preserve RGB" if a shift is occurring).
|
||||
|
||||
## The QuickTime gamma bug — workarounds
|
||||
|
||||
QuickTime Player and any AVFoundation-based player can apply an extra ~1.96 gamma to ProRes/H.264, making files look darker/contrastier than they do in Chrome, Premiere, or Resolve. It is a *player* problem, not a render problem.
|
||||
|
||||
1. **Never grade or approve to QuickTime.** Use Chrome, Premiere, or Resolve as the reference, since those match how the web and other editors display the file.
|
||||
2. For ProRes masters that must look right in QuickTime too, **embed the Rec.709 (Gamma 2.4) tag** via Output Module > Color Management. Correctly tagged ProRes is interpreted consistently by modern macOS.
|
||||
3. If a legacy pipeline still shifts, render a **second 1:1 reference still** (Composition > Save Frame As > File) as PNG and compare numeric values — pixels, not perception.
|
||||
4. Avoid the old "nudge gamma by 1.08 to cancel QuickTime" hacks for new work; they bake a wrong curve that breaks on every correct player. Only use a compensating curve if delivering *exclusively* to a known-broken legacy QuickTime target.
|
||||
|
||||
## Per-player / per-OS gamma reference
|
||||
|
||||
| Environment | Effective transfer assumption | Notes |
|
||||
|---|---|---|
|
||||
| Rec.709 standard display (BT.1886) | **2.4** | The correct broadcast/web display gamma |
|
||||
| sRGB display | **~2.2** (piecewise) | UI, screenshots, PNG |
|
||||
| macOS QuickTime / AVFoundation | **~1.96** | Source of the "QuickTime gamma bug"; brighter/contrast-shifted vs browser |
|
||||
| Chrome / Firefox `<video>` | honors 2.4 / sRGB pipeline | Correct reference for web delivery |
|
||||
| macOS classic (pre-10.6) | 1.8 | Legacy only; ignore for new work |
|
||||
| Windows default | 2.2 | Assumed when file is untagged |
|
||||
|
||||
Takeaway: the *only* reliable cross-environment guarantee is an **explicit embedded tag**. Without it, every player above falls back to its own default and they disagree.
|
||||
|
||||
## Numeric test-patch method (perception-free)
|
||||
|
||||
Eyes adapt; numbers do not. To prove whether a shift exists:
|
||||
|
||||
1. In the comp, add a Solid (Layer > New > Solid) filled with **50% grey: RGB 128,128,128** (or 188,188,188 for a known mid-light patch).
|
||||
2. Note the value AE reports in the **Info panel** when hovering with managed display on.
|
||||
3. Render the file with the intended export settings.
|
||||
4. Re-import the rendered file into a fresh comp (or open in an app with a pixel readout) and sample the same patch.
|
||||
5. If the sampled value differs from 128 by more than ~2-3 levels, a gamma transform is being applied somewhere in the chain. A washed-out look corresponds to the value rising (e.g. 128 → 150+).
|
||||
|
||||
This isolates whether the shift is in the *render* (values changed in the file) vs the *playback* (file values correct, player re-curving).
|
||||
|
||||
## Verify the embedded tag with ffprobe
|
||||
|
||||
```bash
|
||||
ffprobe -v error -select_streams v:0 \
|
||||
-show_entries stream=color_transfer,color_primaries,color_space,color_range \
|
||||
-of default=nk=0 export.mp4
|
||||
```
|
||||
|
||||
Desired for SDR web (Rec.709):
|
||||
|
||||
```
|
||||
color_space=bt709
|
||||
color_primaries=bt709
|
||||
color_transfer=bt709
|
||||
color_range=tv # (limited) for video; pc/full for some web pipelines
|
||||
```
|
||||
|
||||
If `color_transfer=unknown` or fields are absent, the file is untagged → re-export with the tag, or stamp it without re-encoding:
|
||||
|
||||
```bash
|
||||
ffmpeg -i export.mp4 -c copy \
|
||||
-color_primaries bt709 -color_trc bt709 -colorspace bt709 \
|
||||
export_tagged.mp4
|
||||
```
|
||||
|
||||
Note: `-c copy` only writes container/stream metadata; it does not fix pixels that were already baked wrong — use it to *tag*, not to *correct*.
|
||||
|
||||
## Limited vs full range pitfall
|
||||
|
||||
H.264 for video is typically **limited/TV range** (luma 16-235). If AE/AME outputs full-range pixels but the file is tagged limited (or vice versa), blacks crush or wash. Symptoms: milky blacks = full data read as limited; crushed blacks = limited data read as full. Keep range consistent end-to-end and tag it (`color_range`).
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| Washed-out / milky blacks | Untagged H.264, player adds gamma | Tag Rec.709 in Output Module > Color Management |
|
||||
| Fine in QuickTime, dark in Chrome | QuickTime ~1.96 gamma | Trust Chrome; QuickTime is wrong for web |
|
||||
| Differs macOS vs Windows | No embedded color tag | Embed explicit Rec.709/sRGB tag |
|
||||
| Banding in gradients | 8 bpc project | Project Settings > Depth = 16 bpc |
|
||||
| Glows/blurs look wrong | Non-linear blending | Enable Linearize Working Space |
|
||||
| Screenshots/UI look off | Wrong working space | Use sRGB working space, not Rec.709 |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- "Working Space = None" disables AE color management entirely — the viewer then lies about the final look. Always set an explicit space for managed delivery.
|
||||
- Toggling Linearize Working Space late changes the look of every feather, glow, and screen blend; decide at project start.
|
||||
- AME "Match Source" can silently drop the color tag; verify the exported file's tag (e.g. with `ffprobe -show_streams`, look for `color_transfer`/`color_primaries`).
|
||||
- ProRes is not magic — an untagged ProRes still shifts. The *tag* is what fixes consistency, not the codec.
|
||||
- sRGB and Rec.709 share primaries but differ in transfer (sRGB ~2.2 piecewise vs Rec.709 display 2.4); using the wrong one shifts midtone brightness.
|
||||
|
||||
## Pre-export checklist
|
||||
|
||||
- [ ] Project Settings > Color: Working Space set to the deliverable's space (Rec.709 Gamma 2.4 for SDR video; sRGB for UI/stills).
|
||||
- [ ] Project Settings > Color: Depth = 16 bpc (or 32 for HDR/heavy linear).
|
||||
- [ ] Linearize Working Space decision made at project start (on for glow/blur-heavy comps).
|
||||
- [ ] Display Color Management toggle ON while reviewing, so the viewer reflects final look.
|
||||
- [ ] 50% grey test patch placed and noted before render.
|
||||
- [ ] Output Module > Color Management: explicit output profile = Rec.709 Gamma 2.4 (not "Preserve RGB" when a shift exists).
|
||||
- [ ] Codec chosen per target (ProRes 422 HQ/4444 master, H.264 web).
|
||||
- [ ] Reviewed final in **Chrome / Premiere / Resolve**, not QuickTime.
|
||||
- [ ] `ffprobe` confirms color_primaries/transfer/space = bt709 (or sRGB target) and color_range consistent.
|
||||
- [ ] Re-imported render and re-sampled the grey patch; value matches within ~2-3 levels.
|
||||
|
||||
## When the client only has QuickTime
|
||||
|
||||
If the approval environment is genuinely locked to macOS QuickTime and the file must look right *there*:
|
||||
|
||||
1. Deliver correctly tagged ProRes (modern macOS QuickTime respects Rec.709 tags fairly well).
|
||||
2. If a residual shift remains and cannot be avoided, produce a *separate* QuickTime-targeted file with a measured compensating gamma adjustment — and clearly label it "QuickTime-only; do not use for web." Never let this compensated file become the web master.
|
||||
3. Educate: provide the same file opened in Chrome side-by-side so the difference is attributed to the player, not the work.
|
||||
@@ -1,180 +0,0 @@
|
||||
# Palettes, gradients, interpolation, and grading
|
||||
|
||||
Exact recipes and runnable code. OKLCH notation: `oklch(L C H)`, L in 0-1 perceptual lightness, C chroma (0 to ~0.37), H hue degrees.
|
||||
|
||||
## Palette construction rules
|
||||
|
||||
1. **Count**: 1 primary, 1 accent, 2-3 neutrals. Resist adding hues.
|
||||
2. **Contrast from lightness**: build a 5-9 step ramp by varying L while holding C and H roughly constant, nudging chroma down at the extremes (very light/dark colors can't hold high chroma).
|
||||
3. **Accent placement**: accent hue should sit 90-180deg from the primary on the OKLCH wheel for clean separation; complementary (~180deg) is boldest, analogous (~30deg) is calmest.
|
||||
4. **Neutral tinting**: give neutrals a tiny chroma (0.005-0.02) in the primary's hue family so the frame feels unified, not assembled from separate kits.
|
||||
5. **Dark-scene accents**: high L (0.7-0.85), moderate C (0.12-0.18). Avoid max chroma — it vibrates on dark backgrounds.
|
||||
|
||||
### Generating a tonal ramp (hold hue, vary lightness)
|
||||
|
||||
```
|
||||
oklch(0.97 0.03 264) // 50
|
||||
oklch(0.92 0.06 264) // 100
|
||||
oklch(0.84 0.10 264) // 200
|
||||
oklch(0.74 0.14 264) // 300
|
||||
oklch(0.66 0.18 264) // 400
|
||||
oklch(0.58 0.19 264) // 500 (base)
|
||||
oklch(0.50 0.17 264) // 600
|
||||
oklch(0.40 0.14 264) // 700
|
||||
oklch(0.28 0.10 264) // 800
|
||||
oklch(0.18 0.06 264) // 900
|
||||
```
|
||||
|
||||
Note chroma rises toward the mid-lightness band and falls at both ends — this matches what's physically representable and looks natural.
|
||||
|
||||
### Named starter palettes (OKLCH)
|
||||
|
||||
```
|
||||
// "Midnight product" — confident, techy
|
||||
primary oklch(0.62 0.19 264) accent oklch(0.78 0.17 50)
|
||||
bg oklch(0.16 0.02 264) fg oklch(0.97 0.005 264)
|
||||
|
||||
// "Warm editorial" — premium, soft
|
||||
primary oklch(0.55 0.12 25) accent oklch(0.80 0.10 90)
|
||||
bg oklch(0.96 0.01 60) fg oklch(0.25 0.03 25)
|
||||
|
||||
// "Neon dark" — energetic, bold
|
||||
primary oklch(0.72 0.20 150) accent oklch(0.68 0.24 330)
|
||||
bg oklch(0.14 0.03 280) fg oklch(0.95 0.02 150)
|
||||
```
|
||||
|
||||
## Gradient cookbook
|
||||
|
||||
### Two-stop, vivid (OKLCH)
|
||||
|
||||
```css
|
||||
background: linear-gradient(135deg in oklch, #2b6cff, #ffb02e);
|
||||
/* control the hue arc explicitly */
|
||||
background: linear-gradient(in oklch shorter hue, oklch(0.6 0.2 264), oklch(0.8 0.17 50));
|
||||
```
|
||||
|
||||
`shorter hue` / `longer hue` / `increasing hue` / `decreasing hue` pick the direction around the wheel — `longer hue` produces rainbow sweeps, `shorter` the direct path.
|
||||
|
||||
### Mesh background (stacked radials)
|
||||
|
||||
```css
|
||||
.mesh {
|
||||
background-color: #0b0f1a;
|
||||
background-image:
|
||||
radial-gradient(at 18% 22%, oklch(0.62 0.19 264 / 0.7), transparent 50%),
|
||||
radial-gradient(at 82% 28%, oklch(0.70 0.16 320 / 0.6), transparent 55%),
|
||||
radial-gradient(at 55% 88%, oklch(0.78 0.17 50 / 0.5), transparent 50%),
|
||||
radial-gradient(at 40% 50%, oklch(0.55 0.14 200 / 0.4), transparent 60%);
|
||||
}
|
||||
```
|
||||
|
||||
### Animated drift
|
||||
|
||||
```css
|
||||
.mesh { background-size: 140% 140%; animation: drift 16s ease-in-out infinite alternate; }
|
||||
@keyframes drift {
|
||||
0% { background-position: 0% 0%; }
|
||||
100% { background-position: 12% -8%; }
|
||||
}
|
||||
```
|
||||
|
||||
### Conic
|
||||
|
||||
```css
|
||||
background: conic-gradient(in oklch from 90deg, #2b6cff, #b02eff, #ffb02e, #2b6cff);
|
||||
```
|
||||
|
||||
### Grain overlay (kills banding)
|
||||
|
||||
```html
|
||||
<svg width="0" height="0">
|
||||
<filter id="grain"><feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="2"/></filter>
|
||||
</svg>
|
||||
```
|
||||
```css
|
||||
.grain::after {
|
||||
content: ""; position: absolute; inset: 0; pointer-events: none;
|
||||
filter: url(#grain); opacity: 0.04; mix-blend-mode: overlay;
|
||||
}
|
||||
```
|
||||
|
||||
## Perceptual interpolation in code
|
||||
|
||||
### culori (JS) — interpolate and sample
|
||||
|
||||
```js
|
||||
import { interpolate, formatHex, formatCss } from "culori";
|
||||
|
||||
// Interpolate in OKLCH (vivid midpoints, even lightness)
|
||||
const mix = interpolate(["#2b6cff", "#ffb02e"], "oklch");
|
||||
formatHex(mix(0.5)); // vivid mid, not gray
|
||||
|
||||
// Build N stops for an animated transition
|
||||
const stops = Array.from({ length: 60 }, (_, i) => formatCss(mix(i / 59)));
|
||||
|
||||
// Control hue direction
|
||||
import { fixupHueLonger } from "culori";
|
||||
const rainbow = interpolate(["#2b6cff", "#ffb02e"], "oklch", {
|
||||
h: { use: fixupHueLonger },
|
||||
});
|
||||
```
|
||||
|
||||
### d3-interpolate / d3-scale-chromatic
|
||||
|
||||
```js
|
||||
import { interpolateLab, interpolateHcl, quantize } from "d3-interpolate";
|
||||
|
||||
const lab = interpolateLab("#2b6cff", "#ffb02e"); // perceptual, smooth lightness
|
||||
lab(0.5); // mid color
|
||||
const ramp = quantize(interpolateHcl("#2b6cff", "#ffb02e"), 8); // 8 even stops
|
||||
```
|
||||
|
||||
`interpolateLab` and `interpolateHcl` avoid the sRGB gray-midpoint problem; `interpolateRgb` does not — only use RGB interpolation for near-identical colors.
|
||||
|
||||
### Animating a color with @property (CSS, browser interpolates)
|
||||
|
||||
```css
|
||||
@property --from { syntax: "<color>"; initial-value: oklch(0.62 0.19 264); inherits: false; }
|
||||
.swatch { background: var(--from); transition: --from 400ms cubic-bezier(0.16,1,0.3,1); }
|
||||
.swatch.active { --from: oklch(0.78 0.17 50); }
|
||||
```
|
||||
|
||||
Without `@property` registration the color snaps instead of interpolating. The browser interpolates registered `<color>` properties in OKLab by default — perceptually clean for free.
|
||||
|
||||
## Grading order checklist
|
||||
|
||||
Apply in this exact order; reordering changes the result.
|
||||
|
||||
1. **Exposure / white balance** — set overall brightness, neutralize or intentionally tint the cast.
|
||||
2. **Contrast** — set black/white points, expand or compress tonal range.
|
||||
3. **Midtones / saturation** — overall saturation and midtone color shift.
|
||||
4. **Split-tone** — tint shadows and highlights separately. Cinematic default: teal shadows (`hue ~190`), orange highlights (`hue ~40`), kept subtle.
|
||||
5. **Unify** — final overall tint / LUT and a gentle vignette.
|
||||
|
||||
### CSS-filter grade (quick, for web video/canvas)
|
||||
|
||||
```css
|
||||
.graded {
|
||||
filter:
|
||||
brightness(1.02) /* 1: exposure */
|
||||
contrast(1.12) /* 2: contrast */
|
||||
saturate(1.08); /* 3: saturation */
|
||||
}
|
||||
/* 4 split-tone: overlay a teal->orange gradient at low opacity with mix-blend-mode */
|
||||
```
|
||||
|
||||
### Fragment-shader split-tone (GLSL sketch)
|
||||
|
||||
```glsl
|
||||
vec3 graded = color;
|
||||
float lum = dot(graded, vec3(0.2126, 0.7152, 0.0722));
|
||||
vec3 shadowTint = vec3(0.0, 0.45, 0.5); // teal
|
||||
vec3 highlightTint = vec3(1.0, 0.6, 0.2); // orange
|
||||
graded = mix(graded, graded * shadowTint, (1.0 - lum) * 0.12);
|
||||
graded = mix(graded, graded + highlightTint * 0.06, lum);
|
||||
```
|
||||
|
||||
Keep split-tone strength under ~0.15; over-tinting reads as a cheap Instagram filter. Grade the whole piece with the same chain so cuts match.
|
||||
|
||||
---
|
||||
Interpolate color in perceptual space and gradients shift without muddy mids. Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=motion-design-skills&utm_content=skill_footer&utm_term=color-motion)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
Whitespace-only changes.
@@ -1,148 +0,0 @@
|
||||
---
|
||||
name: logo-animation
|
||||
description: This skill should be used when the user asks to "animate our logo", "make a logo intro/stinger", "build a brand reveal", "create an app splash animation", "loop a loader logo", "draw on our SVG mark", "morph an icon into the wordmark", or "sync the logo to a sound-logo". Covers web (SVG/Lottie), video, and app-splash delivery.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Logo Animation
|
||||
|
||||
Bring a brand mark to life with one clear idea, on-brand geometry, and a crisp settle. Animate for web (SVG/Lottie), video intros/outros, or app splash screens, and always ship a static end-frame fallback.
|
||||
|
||||
## When to use
|
||||
|
||||
- Build an intro stinger, sign-off, app splash, or loader idle loop.
|
||||
- Reveal a brand mark on a landing hero or sizzle reel.
|
||||
- Draw on an SVG mark, wipe/build it in piece by piece, or morph an icon into a wordmark.
|
||||
- Sync the final settle of the mark to a sound-logo beat.
|
||||
|
||||
## Core principle
|
||||
|
||||
Pick ONE technique per logo — draw OR build OR morph — not all at once. Reveals run 0.8–2.5s. Never distort brand geometry; scale and fade are safe, skew and squash usually are not. Respect clearspace (keep the mark's minimum margin clear of motion debris). Always provide a `prefers-reduced-motion` path that shows the final mark with no motion.
|
||||
|
||||
## Technique map
|
||||
|
||||
| Technique | Best for | Mechanism |
|
||||
|---|---|---|
|
||||
| Stroke draw-on | Line marks, monograms, signatures | `stroke-dashoffset` 1→0 |
|
||||
| Mask wipe / reveal | Filled shapes, gradients | animate a `<clipPath>` / `<mask>` rect |
|
||||
| Build-on (stagger) | Multi-piece marks, grids | per-group opacity+transform, staggered |
|
||||
| Morph | Icon ↔ wordmark, shape→logo | GSAP MorphSVG or matched path interpolation |
|
||||
| Idle loop | Loaders, ambient splash | seamless cyclic transform (rotate/pulse) |
|
||||
| Wordmark | Logotypes | per-letter mask/translate stagger |
|
||||
|
||||
## Quick start: SVG stroke draw-on
|
||||
|
||||
The fastest on-brand web reveal. Each path draws itself, then fills settle in.
|
||||
|
||||
```html
|
||||
<svg viewBox="0 0 200 80" id="logo">
|
||||
<path class="ink" d="M10 60 L40 20 L70 60" fill="none"
|
||||
stroke="#111" stroke-width="6" stroke-linecap="round"
|
||||
pathLength="1" stroke-dasharray="1" stroke-dashoffset="1"/>
|
||||
</svg>
|
||||
<style>
|
||||
@keyframes draw { to { stroke-dashoffset: 0; } }
|
||||
#logo .ink { animation: draw 1.1s cubic-bezier(.65,0,.35,1) forwards; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#logo .ink { animation: none; stroke-dashoffset: 0; }
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
`pathLength="1"` normalizes every path to length 1, so one `stroke-dasharray`/`offset` value works regardless of geometry. Stagger multiple strokes with `animation-delay`.
|
||||
|
||||
## Quick start: build-on with stagger (GSAP)
|
||||
|
||||
```js
|
||||
import gsap from "gsap";
|
||||
// each <g class="piece"> is one mark component
|
||||
gsap.from(".piece", {
|
||||
opacity: 0, scale: 0.85, transformOrigin: "50% 50%",
|
||||
duration: 0.5, ease: "back.out(1.6)", stagger: 0.08,
|
||||
onComplete: () => gsap.to("#mark", { scale: 1, duration: 0.2, ease: "power2.out" })
|
||||
});
|
||||
```
|
||||
|
||||
Group order matters: reveal the base/container first, then accents, then the wordmark last so the eye lands on the name.
|
||||
|
||||
## Sound-logo sync
|
||||
|
||||
When audio exists, the brand's final settle frame must land exactly on the sonic accent (the "ding"/whoosh peak). Decide the audio first, find the peak timecode, then time the reveal so the mark snaps to full opacity/scale on that frame. A 1.0s reveal landing a 1.2s mnemonic feels premium; a settle that floats free of the sound feels cheap. Add a tiny overshoot-and-settle (scale 1.04→1.00 over ~0.15s) on the beat for impact.
|
||||
|
||||
## Delivery targets
|
||||
|
||||
- **Web, interactive / hero**: inline SVG + CSS or GSAP. Smallest payload, crisp at any size, scriptable.
|
||||
- **Product / app, designer-made**: Lottie (`.json` exported from After Effects via Bodymovin). Use `lottie-web` or `@lottiefiles/dotlottie-web`; set `loop`, `autoplay`, and a poster fallback frame.
|
||||
- **Video intro/outro**: Remotion (React) or After Effects render to ProRes/H.264. Pre-render at 2x for crispness.
|
||||
|
||||
```js
|
||||
// Lottie delivery with reduced-motion guard
|
||||
import { DotLottie } from "@lottiefiles/dotlottie-web";
|
||||
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const dl = new DotLottie({
|
||||
canvas: document.querySelector("#logo-canvas"),
|
||||
src: "/logo.lottie", autoplay: !reduce, loop: false,
|
||||
});
|
||||
if (reduce) dl.addEventListener("load", () => dl.setFrame(dl.totalFrames - 1));
|
||||
```
|
||||
|
||||
## Output checklist
|
||||
|
||||
- Animated mark in the target format (SVG/Lottie/video).
|
||||
- Static final-frame fallback (SVG or PNG) for reduced-motion and posters.
|
||||
- One clear technique, reveal ≤2.5s, brand geometry intact, clearspace respected.
|
||||
- Sound-logo settle aligned to the audio peak (if audio present).
|
||||
|
||||
## 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`.
|
||||
|
||||
Logo animation spans web/SVG/Lottie, video, and app splash. **For web/SVG/Lottie delivery use this standalone-HTML loop; for a video logo sting, render via the remotion-video verify loop instead.**
|
||||
|
||||
For web/SVG/Lottie, ship **one `.html` file that opens directly in a browser** — inline SVG markup plus the reveal (CSS or one CDN GSAP `<script>`), self-contained. One file is the right tier.
|
||||
|
||||
**Output contract:**
|
||||
- One `.html` file: the inline SVG (or Lottie canvas) + the reveal in inline `<style>`/`<script>`.
|
||||
- One animation driver — the CSS `@keyframes` draw/build, or a single GSAP timeline; not both.
|
||||
- Include the freeze harness below, matched to the technique, so any moment can be screenshotted deterministically.
|
||||
|
||||
**Freeze harness — pin a frame for screenshots.** Match the mechanism to the technique:
|
||||
|
||||
```html
|
||||
<script>
|
||||
const t = new URLSearchParams(location.search).get("t");
|
||||
if (t !== null) {
|
||||
const T = parseFloat(t);
|
||||
// CSS stroke-draw / build-on:
|
||||
document.querySelectorAll("#logo *").forEach(el => {
|
||||
el.style.animationDelay = (-T) + "s";
|
||||
el.style.animationPlayState = "paused";
|
||||
});
|
||||
// GSAP timeline instead? → tl.pause(); tl.seek(T);
|
||||
// SMIL <animate>? → const s = document.querySelector("svg"); s.pauseAnimations(); s.setCurrentTime(T);
|
||||
// Lottie? → dl.setFrame(Math.round(T * fps));
|
||||
}
|
||||
window.__ready = true; // ready signal for headless wait
|
||||
</script>
|
||||
```
|
||||
|
||||
**Verify loop — render → freeze → screenshot → check:**
|
||||
1. Open the file frozen at start / mid / end of the reveal (≤2.5s): `…/logo.html?t=0`, `?t=<dur/2>`, `?t=<dur>`.
|
||||
2. Screenshot each frozen frame.
|
||||
3. Check **fidelity** (matches the mark, one clear technique) and **artifacts** — brand geometry undistorted, clearspace clear of motion debris, no half-drawn strokes left at the end frame, the static final frame is the true logo.
|
||||
4. **Iterate:** if any check fails (e.g. a half-drawn stroke at `?t=<dur>` or distorted geometry mid-reveal), fix the stroke-dashoffset/duration/easing, re-render the **same** `?t=N`, and re-screenshot to confirm the fix — loop until every frame is clean.
|
||||
|
||||
```bash
|
||||
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/logo.html?t=1.1" frame-mid.png
|
||||
```
|
||||
|
||||
**Before you finish:**
|
||||
1. Opens standalone in a browser — no console errors, no missing CDN.
|
||||
2. One technique/driver; `?t=N` freezes the exact frame correctly.
|
||||
3. Screenshotted at start / mid / end — geometry intact, clearspace respected, final frame is the clean mark.
|
||||
4. `prefers-reduced-motion` honored (final static mark shown, no motion) + static end-frame fallback shipped.
|
||||
5. Reveal ≤2.5s; if audio present, the settle lands on the sound-logo peak.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/logo-patterns.md` — runnable code for every technique: stroke draw-on, mask wipe build-on, GSAP MorphSVG morph, seamless idle loop, Lottie delivery, plus timing tables (0.8–2.5s), brand clearspace rules, and reduced-motion fallbacks.
|
||||
@@ -1,199 +0,0 @@
|
||||
# Logo Animation Patterns — Cookbook
|
||||
|
||||
Self-contained, runnable recipes for every logo technique. Pick ONE per logo.
|
||||
|
||||
## Timing guidance
|
||||
|
||||
| Beat | Duration | Easing |
|
||||
|---|---|---|
|
||||
| Stroke draw-on (per path) | 0.6–1.2s | `cubic-bezier(.65,0,.35,1)` (easeInOutCubic) |
|
||||
| Mask wipe reveal | 0.5–0.9s | `cubic-bezier(.22,1,.36,1)` (easeOutQuint) |
|
||||
| Build-on per piece | 0.4–0.6s, stagger 0.06–0.10s | `back.out(1.5)` |
|
||||
| Morph | 0.6–1.0s | `power2.inOut` |
|
||||
| Final settle / overshoot | 0.12–0.20s | scale 1.04→1.00, `power2.out` |
|
||||
| Full reveal envelope | 0.8–2.5s total | — |
|
||||
| Idle loop cycle | 1.5–4s, infinite | `sine.inOut` / `linear` for spin |
|
||||
|
||||
Rule of thumb: longer marks (wordmarks, detailed monograms) sit near 2.0–2.5s; simple icons near 0.8–1.4s. Hold the settled mark at least 0.4s before any sign-off cut.
|
||||
|
||||
## Brand clearspace & geometry rules
|
||||
|
||||
- **Clearspace**: keep a margin equal to the mark's cap-height (or the brand's defined unit) clear of moving particles, wipes, or overshoot. Motion must not visually crop into the safe margin.
|
||||
- **No distortion**: scale uniformly, fade, translate, rotate-on-axis, and mask are safe. Avoid non-uniform `scaleX≠scaleY`, skew, and squash on the final mark — they read as off-brand. A squash-and-settle is acceptable ONLY as a sub-0.2s micro-overshoot that returns to 1.0.
|
||||
- **Color**: never animate brand color away from spec mid-reveal. Reveal in brand color, or grayscale→brand as an intentional, approved beat.
|
||||
- **Lockup integrity**: animate the symbol and wordmark with a clear hierarchy (symbol leads, wordmark follows) but land both in the exact locked relationship.
|
||||
|
||||
## 1. Stroke draw-on (SVG + CSS)
|
||||
|
||||
```html
|
||||
<svg viewBox="0 0 240 80" id="mark">
|
||||
<path class="ink" pathLength="1" fill="none" stroke="#0A84FF"
|
||||
stroke-width="7" stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M12 64 L48 16 L84 64 M120 16 L120 64 M156 16 H204"/>
|
||||
</svg>
|
||||
<style>
|
||||
#mark .ink {
|
||||
stroke-dasharray: 1; stroke-dashoffset: 1;
|
||||
animation: draw 1.1s cubic-bezier(.65,0,.35,1) forwards;
|
||||
}
|
||||
@keyframes draw { to { stroke-dashoffset: 0; } }
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
#mark .ink { animation: none; stroke-dashoffset: 0; }
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
Draw-then-fill (line settles into a solid mark):
|
||||
|
||||
```css
|
||||
#mark .ink { fill: transparent; }
|
||||
@keyframes fill { to { fill: #0A84FF; stroke-width: 0; } }
|
||||
#mark .ink { animation: draw 1.1s cubic-bezier(.65,0,.35,1) forwards,
|
||||
fill .3s ease .9s forwards; }
|
||||
```
|
||||
|
||||
GSAP equivalent with per-path stagger via DrawSVGPlugin:
|
||||
|
||||
```js
|
||||
import gsap from "gsap";
|
||||
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";
|
||||
gsap.registerPlugin(DrawSVGPlugin);
|
||||
gsap.fromTo("#mark .ink",
|
||||
{ drawSVG: "0%" },
|
||||
{ drawSVG: "100%", duration: 1.0, ease: "power2.inOut", stagger: 0.15 });
|
||||
```
|
||||
|
||||
## 2. Mask wipe / build-on (clipPath)
|
||||
|
||||
Reveal a filled mark by sweeping a clip rect across it.
|
||||
|
||||
```html
|
||||
<svg viewBox="0 0 200 80">
|
||||
<defs>
|
||||
<clipPath id="wipe"><rect id="wiper" x="0" y="0" width="0" height="80"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#wipe)">
|
||||
<!-- the full filled logo art here -->
|
||||
<path d="M10 10 H190 V70 H10 Z" fill="#111"/>
|
||||
</g>
|
||||
</svg>
|
||||
<style>
|
||||
#wiper { animation: wipe .8s cubic-bezier(.22,1,.36,1) forwards; }
|
||||
@keyframes wipe { to { width: 200px; } }
|
||||
@media (prefers-reduced-motion: reduce){ #wiper{ animation:none; width:200px; } }
|
||||
</style>
|
||||
```
|
||||
|
||||
Directional variants: animate `y`/`height` for a top-down build, or use a radial `<clipPath>` with a growing `<circle r>` for an iris reveal.
|
||||
|
||||
## 3. Build-on with stagger (GSAP timeline)
|
||||
|
||||
```js
|
||||
import gsap from "gsap";
|
||||
const tl = gsap.timeline({ defaults: { ease: "back.out(1.6)" } });
|
||||
tl.from("#base", { scale: .8, opacity: 0, transformOrigin: "50% 50%", duration: .5 })
|
||||
.from(".accent", { scale: .7, opacity: 0, transformOrigin: "50% 50%",
|
||||
duration: .45, stagger: 0.08 }, "-=0.2")
|
||||
.from(".word > *", { yPercent: 120, opacity: 0, duration: .4,
|
||||
stagger: 0.04, ease: "power3.out" }, "-=0.15")
|
||||
.to("#mark", { scale: 1.04, duration: .12, ease: "power2.out" })
|
||||
.to("#mark", { scale: 1.0, duration: .14, ease: "power2.inOut" });
|
||||
```
|
||||
|
||||
## 4. Morph (GSAP MorphSVG)
|
||||
|
||||
Morph requires two paths; MorphSVG handles differing point counts.
|
||||
|
||||
```js
|
||||
import gsap from "gsap";
|
||||
import { MorphSVGPlugin } from "gsap/MorphSVGPlugin";
|
||||
gsap.registerPlugin(MorphSVGPlugin);
|
||||
// #shape: a simple start shape (e.g. circle path) -> #logoPath: the brand mark
|
||||
gsap.to("#shape", {
|
||||
duration: .9, ease: "power2.inOut",
|
||||
morphSVG: { shape: "#logoPath", shapeIndex: "auto" }
|
||||
});
|
||||
```
|
||||
|
||||
No-plugin fallback (Flubber for interpolation + requestAnimationFrame):
|
||||
|
||||
```js
|
||||
import { interpolate } from "flubber";
|
||||
const from = "M10,10 ...", to = "M0,0 ...";
|
||||
const lerp = interpolate(from, to, { maxSegmentLength: 2 });
|
||||
const path = document.querySelector("#shape");
|
||||
const dur = 900, t0 = performance.now();
|
||||
(function frame(now){
|
||||
const k = Math.min(1, (now - t0) / dur);
|
||||
const e = k < .5 ? 2*k*k : 1 - Math.pow(-2*k+2, 2)/2; // easeInOutQuad
|
||||
path.setAttribute("d", lerp(e));
|
||||
if (k < 1) requestAnimationFrame(frame);
|
||||
})(t0);
|
||||
```
|
||||
|
||||
## 5. Seamless idle loop (loader)
|
||||
|
||||
Loop must have identical start and end frames — design the cycle so frame 0 == frame N.
|
||||
|
||||
```css
|
||||
#loader { animation: spin 1.4s linear infinite, pulse 2.8s ease-in-out infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes pulse { 0%,100%{ opacity:.85 } 50%{ opacity:1 } }
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
#loader { animation: pulse 2.8s ease-in-out infinite; } /* drop rotation */
|
||||
}
|
||||
```
|
||||
|
||||
GSAP `yoyo` loop for a breathing mark:
|
||||
|
||||
```js
|
||||
gsap.to("#mark", { scale: 1.03, duration: 1.4, ease: "sine.inOut",
|
||||
yoyo: true, repeat: -1 });
|
||||
```
|
||||
|
||||
## 6. Lottie delivery (designer-made)
|
||||
|
||||
Export from After Effects via Bodymovin to `.json`, or compressed `.lottie`.
|
||||
|
||||
```html
|
||||
<canvas id="logo-canvas" width="512" height="512"></canvas>
|
||||
<script type="module">
|
||||
import { DotLottie } from "https://cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-web/+esm";
|
||||
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const dl = new DotLottie({
|
||||
canvas: document.querySelector("#logo-canvas"),
|
||||
src: "/logo.lottie", autoplay: !reduce, loop: false, speed: 1,
|
||||
});
|
||||
// Reduced motion: jump to the final settled frame, no playback.
|
||||
if (reduce) dl.addEventListener("load", () => dl.setFrame(dl.totalFrames - 1));
|
||||
// Sound-logo sync: fire audio when the settle frame is reached.
|
||||
dl.addEventListener("frame", ({ currentFrame }) => {
|
||||
if (currentFrame >= SETTLE_FRAME && !audioFired) { audio.play(); audioFired = true; }
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
Classic `lottie-web` (SVG renderer, sharpest for vector logos):
|
||||
|
||||
```js
|
||||
import lottie from "lottie-web";
|
||||
const anim = lottie.loadAnimation({
|
||||
container: document.getElementById("logo"),
|
||||
renderer: "svg", loop: false, autoplay: !reduce, path: "/logo.json",
|
||||
});
|
||||
if (reduce) anim.goToAndStop(anim.totalFrames - 1, true);
|
||||
```
|
||||
|
||||
Lottie tips: keep layers/effects Bodymovin-supported (no native blurs/expressions unless baked), name layers cleanly, and request a poster PNG of the final frame from the designer for the static fallback.
|
||||
|
||||
## 7. Reduced-motion fallback (always ship)
|
||||
|
||||
Every delivery must degrade to the static, fully-revealed mark:
|
||||
|
||||
- **CSS/SVG**: `@media (prefers-reduced-motion: reduce)` sets the final state (`stroke-dashoffset:0`, `width:full`, `opacity:1`) with `animation:none`.
|
||||
- **Lottie**: `goToAndStop(totalFrames-1)` / `setFrame(last)`.
|
||||
- **Video**: serve a poster image of the last frame; only autoplay motion when motion is allowed.
|
||||
- **JS gate**: branch on `matchMedia("(prefers-reduced-motion: reduce)").matches` before building any timeline.
|
||||
|
||||
---
|
||||
Match the reveal to the delivery target and a logo lands across web, video, and app. Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=motion-design-skills&utm_content=skill_footer&utm_term=logo-animation)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
Whitespace-only changes.
@@ -1,114 +0,0 @@
|
||||
---
|
||||
name: motion-art-direction
|
||||
description: This skill should be used when the user asks to "direct a motion graphics video", "do creative/art direction for a video", "define a motion language", "set the tone and pacing", "make the animation feel consistent", "decide what to animate (and what not to)", "turn a brief/brand into motion choices", or needs the senior creative judgment that sits above specific techniques (gsap/lottie/shader). Covers motion language, tone & energy, pacing/rhythm, motion hierarchy, restraint, and cross-shot consistency.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Motion Direction
|
||||
|
||||
Make the creative decisions for a motion piece before anyone animates a frame. Direction is the judgment layer: it turns a brief and a brand into a small, repeatable set of motion rules — one easing vocabulary, one timing scale, one transition family — so every shot feels like it came from the same hand. Good direction is mostly subtraction: deciding what NOT to move.
|
||||
|
||||
## When to use
|
||||
|
||||
- Starting any piece — explainer, launch film, UI demo, data story, loop — and the look/feel isn't decided yet.
|
||||
- Animation feels "busy", "cheap", or "inconsistent" and the cause is decisions, not craft.
|
||||
- Orchestrating other skills (gsap/lottie/shader/three.js): direction sets the rules they execute.
|
||||
- Translating a brand or brief into directable, written choices a team (or an agent) can follow.
|
||||
|
||||
## The one rule
|
||||
|
||||
**Define the motion language first, then animate to it.** A motion language is a short spec — pick *one* of each: easing family, base timing unit, transition family, stagger rhythm, motion intensity. Everything in the piece obeys it. Consistency reads as confidence; variety reads as noise. When in doubt, reuse, don't invent.
|
||||
|
||||
## Direction workflow
|
||||
|
||||
| Step | Decision | Output |
|
||||
|---|---|---|
|
||||
| 1. Brief | Audience, one message, platform, duration, mood (3 adjectives) | One-line creative intent |
|
||||
| 2. Tone & energy | Where it sits on calm↔kinetic and soft↔sharp | A point on the matrix below |
|
||||
| 3. Motion language | Lock the spec table | The motion-language spec (filled) |
|
||||
| 4. Hierarchy | Rank what moves: hero / support / texture | Motion hierarchy list |
|
||||
| 5. Restraint pass | Cut motion that doesn't carry meaning | List of what stays static |
|
||||
| 6. Shot direction | Note intent per shot, not keyframes | Direction notes (see template) |
|
||||
| 7. Consistency check | Audit against the spec | Pass/fail per shot |
|
||||
|
||||
## Tone & energy matrix
|
||||
|
||||
Place the piece on two axes; the cell dictates the motion language defaults.
|
||||
|
||||
| | Soft (organic, eased) | Sharp (precise, snappy) |
|
||||
|---|---|---|
|
||||
| **Calm** | Luxury, wellness, editorial — slow, long eases, generous holds, minimal stagger | Premium tech, fintech — deliberate, clean linear-ish moves, tight but unhurried |
|
||||
| **Kinetic** | Playful, lifestyle, kids — bouncy/overshoot, springy, loose timing | Sports, hype, gaming — fast cuts, hard snaps, aggressive overshoot on beats |
|
||||
|
||||
Pick one cell and commit. Mixing cells within a piece is the most common cause of "inconsistent" motion.
|
||||
|
||||
## Motion personality (named presets)
|
||||
|
||||
The tone matrix tells you *where* the piece sits; a **motion personality** is the named, numeric preset you hand to whoever animates. Pick ONE per project and apply it everywhere. This is shared vocabulary — other skills (e.g. `gsap-web`, `micro-interaction`, the video packs) can just say "use the Premium personality."
|
||||
|
||||
| Personality | Duration | Signature easing | Overshoot | Reads as |
|
||||
|---|---|---|---|---|
|
||||
| **Playful** | 150–300ms | `ease-out-back` / `cubic-bezier(0.34,1.56,0.64,1)` | 10–20% | fun, bouncy, whimsical |
|
||||
| **Premium** | 350–600ms | `cubic-bezier(0.4,0,0.2,1)` | 0% | elegant, minimal, luxury |
|
||||
| **Corporate** | 200–400ms | `cubic-bezier(0.2,0,0,1)` | 0–3% | clean, professional, trustworthy |
|
||||
| **Energetic** | 100–250ms | `ease-out-expo` | 15–30% | bold, dynamic, hype |
|
||||
|
||||
Default: **Corporate** for UI/product, **Playful** for illustration/lifestyle. Maps onto the matrix — Premium≈calm-soft, Corporate≈calm-sharp, Playful≈kinetic-soft, Energetic≈kinetic-sharp. The personality fills the "easing family / base timing unit / motion intensity" rows of the spec below.
|
||||
|
||||
## The motion-language spec
|
||||
|
||||
The core artifact. Fill every row with ONE choice and reuse it everywhere.
|
||||
|
||||
| Property | Choose one | Example (calm-sharp / premium tech) |
|
||||
|---|---|---|
|
||||
| Easing family | The signature curve for ~90% of moves | `cubic-bezier(0.22, 1, 0.36, 1)` (decelerate, soft landing) |
|
||||
| Base timing unit | The atomic duration; build others as multiples | 0.4s (enters 0.4s, big moves 0.8s, micro 0.2s) |
|
||||
| Transition family | How shots connect | Match-cut + crossfade only; no wipes/spins |
|
||||
| Stagger rhythm | Delay between grouped elements | 60ms cascade, top-to-bottom |
|
||||
| Motion intensity | Distance/scale/overshoot budget | Travel ≤ 24px, scale 0.96→1.0, overshoot ≤ 2% |
|
||||
| Hold discipline | Minimum rest between moves | ≥ 0.3s of stillness after each beat |
|
||||
|
||||
Two eases maximum: one "in" (exits) and one "out" (entrances/landings). A third curve must justify its existence.
|
||||
|
||||
## Motion hierarchy
|
||||
|
||||
Not everything earns motion. Rank every element, then animate down the list — and stop early. These three tiers are the universal **three motion layers** (see `animation-principles`): Hero = **Primary**, Support = **Secondary**, Texture = **Ambient**.
|
||||
|
||||
| Tier | What it is | How it moves |
|
||||
|---|---|---|
|
||||
| Hero | The one thing the shot is about | Gets the boldest, slowest, most-eased move; lands on the beat |
|
||||
| Support | Context that helps the hero land | Smaller, faster, eased *out* of the way; never competes |
|
||||
| Texture | Background, grain, ambient drift | Subliminal only — slow loops, low contrast, no hard cuts |
|
||||
|
||||
If two elements compete for the eye in one frame, the direction has failed. Demote one to support.
|
||||
|
||||
## Restraint — what NOT to animate
|
||||
|
||||
The senior move. For each element ask: *does the motion carry meaning?* If not, hold it still.
|
||||
|
||||
- Don't animate the entire frame at once — stagger or hold a anchor so the eye has a home.
|
||||
- Don't transition the transition (no animated wipe *and* a spin *and* a fade).
|
||||
- Don't loop-animate text the viewer is still reading.
|
||||
- Don't add overshoot to serious/financial content — it reads as toy-like.
|
||||
- One "wow" moment per piece. A second one cancels the first.
|
||||
|
||||
## Pacing & rhythm
|
||||
|
||||
Pacing is the edit's heartbeat. Vary it on purpose: tension (faster, tighter) → release (a hold). Map energy across the timeline before timing individual moves — most pieces want a low-energy open, a build, a peak, and a settled end card. Cut and land key moments on the audio (see launch-video for beat-syncing); silence and stillness are pacing tools, not gaps to fill.
|
||||
|
||||
## Consistency check
|
||||
|
||||
Before sign-off, audit every shot against the spec table:
|
||||
|
||||
- Same easing family on comparable moves? (No stray `linear` or `ease`.)
|
||||
- All durations are multiples of the base unit?
|
||||
- Only the chosen transition family between shots?
|
||||
- Stagger rhythm identical across grouped reveals?
|
||||
- Hold discipline respected — no two moves stacked without rest?
|
||||
- Exactly one hero per frame?
|
||||
|
||||
Any "no" is a direction defect, not a craft preference — fix it before polishing.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/direction-playbook.md` — worked examples of the motion-language spec for five tone cells, a full brief template, a shot-by-shot direction-notes template with example entries, brand→motion translation tables, an easing-vocabulary cheat sheet, and a complete pre-render direction checklist.
|
||||
@@ -1,184 +0,0 @@
|
||||
# Motion Direction Playbook
|
||||
|
||||
Depth behind the SKILL.md frameworks: a brief template, filled motion-language specs for five tone cells, brand→motion translation, a direction-notes template with worked entries, an easing cheat sheet, and a pre-render checklist.
|
||||
|
||||
## Creative brief template
|
||||
|
||||
Fill before any other decision. One line per row — if a row can't be answered, the piece isn't ready to direct.
|
||||
|
||||
```
|
||||
PIECE: <name / working title>
|
||||
AUDIENCE: <who, and what they already know>
|
||||
ONE MESSAGE: <the single thing they remember after watching>
|
||||
PLATFORM: <YouTube / Reels / in-app / landing hero — sets aspect, sound, duration>
|
||||
DURATION: <seconds; shorter = fewer ideas, not faster ideas>
|
||||
MOOD: <exactly 3 adjectives, e.g. "confident, precise, warm">
|
||||
BRAND CUES: <colors, type, logo motion rules, any existing motion system>
|
||||
CONSTRAINTS: <must-include / must-avoid; legal supers; localization>
|
||||
REFERENCE: <1–3 links + one line on WHAT to borrow from each>
|
||||
```
|
||||
|
||||
The 3 adjectives are load-bearing: they resolve every later judgment call. "Confident, precise, warm" rules out bouncy overshoot (not precise) and cold linear snaps (not warm) on its own.
|
||||
|
||||
## Filled motion-language specs (five tone cells)
|
||||
|
||||
Each is internally consistent — copy the closest one and adjust. Easings are CSS `cubic-bezier`; the same curves map to GSAP eases and Lottie keyframe handles.
|
||||
|
||||
### Calm + Soft — luxury / wellness / editorial
|
||||
| Property | Choice |
|
||||
|---|---|
|
||||
| Easing | `cubic-bezier(0.4, 0, 0.2, 1)` in/out, long tails |
|
||||
| Base unit | 0.6s (micro 0.3s, hero 1.2s) |
|
||||
| Transition | Slow crossfade + gentle scale; no hard cuts |
|
||||
| Stagger | 120ms, generous |
|
||||
| Intensity | Travel ≤ 16px, scale 0.98→1.0, zero overshoot |
|
||||
| Hold | ≥ 0.6s stillness; let frames breathe |
|
||||
|
||||
### Calm + Sharp — premium tech / fintech
|
||||
| Property | Choice |
|
||||
|---|---|
|
||||
| Easing | `cubic-bezier(0.22, 1, 0.36, 1)` decel; exits `cubic-bezier(0.4, 0, 1, 1)` |
|
||||
| Base unit | 0.4s |
|
||||
| Transition | Match-cut + crossfade only |
|
||||
| Stagger | 60ms cascade |
|
||||
| Intensity | Travel ≤ 24px, scale 0.96→1.0, overshoot ≤ 2% |
|
||||
| Hold | ≥ 0.3s |
|
||||
|
||||
### Kinetic + Sharp — sports / hype / gaming / launch
|
||||
| Property | Choice |
|
||||
|---|---|
|
||||
| Easing | `cubic-bezier(0.16, 1, 0.3, 1)` hard-out; snaps near-instant |
|
||||
| Base unit | 0.25s (micro 0.12s) |
|
||||
| Transition | Hard cuts on beats; speed-ramp into drops |
|
||||
| Stagger | 40ms, tight machine-gun |
|
||||
| Intensity | Big travel, scale 1.18→1.0 overshoot on impact |
|
||||
| Hold | ≥ 0.2s, brief — energy over rest |
|
||||
|
||||
### Kinetic + Soft — playful / lifestyle / kids
|
||||
| Property | Choice |
|
||||
|---|---|
|
||||
| Easing | Spring / `cubic-bezier(0.34, 1.56, 0.64, 1)` overshoot |
|
||||
| Base unit | 0.4s, loose |
|
||||
| Transition | Bouncy pushes, squash-and-stretch wipes |
|
||||
| Stagger | 80ms, irregular feels human |
|
||||
| Intensity | Generous bounce, secondary follow-through |
|
||||
| Hold | ≥ 0.3s |
|
||||
|
||||
### Neutral data-story — explainer / dashboard / report
|
||||
| Property | Choice |
|
||||
|---|---|
|
||||
| Easing | `cubic-bezier(0.25, 0.1, 0.25, 1)` calm in/out |
|
||||
| Base unit | 0.5s; count-ups 0.8–1.2s |
|
||||
| Transition | Cross-dissolve between data states; never wipe a chart |
|
||||
| Stagger | 80ms per series/bar |
|
||||
| Intensity | Number eases to value; axes static; no overshoot on quantities |
|
||||
| Hold | ≥ 0.5s on each datapoint so it can be read |
|
||||
|
||||
## Brand → motion translation
|
||||
|
||||
Brands describe themselves in adjectives; direction converts those to motion parameters.
|
||||
|
||||
| Brand word | Easing | Timing | Intensity | What to avoid |
|
||||
|---|---|---|---|---|
|
||||
| Premium / luxury | Long, soft decel | Slow, generous holds | Minimal travel, no overshoot | Bounce, fast cuts, spins |
|
||||
| Trustworthy / precise | Clean decel, symmetric | On-grid, multiples of base | Small, controlled | Wobble, irregular stagger |
|
||||
| Energetic / bold | Hard-out, snappy | Fast, syncopated | Big scale, overshoot on beats | Slow fades, dead air |
|
||||
| Friendly / human | Slight overshoot/spring | Loose, slightly irregular | Soft bounce, follow-through | Robotic linear, perfect symmetry |
|
||||
| Innovative / techy | Decel with subtle anticipation | Tight, layered staggers | Crisp, geometric | Skeuomorphic squash, grunge |
|
||||
|
||||
Map only the 2–3 strongest brand words. Trying to honor every adjective produces mush.
|
||||
|
||||
## Direction-notes template (per shot)
|
||||
|
||||
Direct *intent*, not keyframes — let the technique skill choose exact values within the spec.
|
||||
|
||||
```
|
||||
SHOT <n> [t = 0.0–0.0s]
|
||||
PURPOSE: <why this shot exists — what the viewer should feel/learn>
|
||||
HERO: <the one element; how it enters and lands>
|
||||
SUPPORT: <secondary elements; how they yield to the hero>
|
||||
TEXTURE: <ambient/background behaviour, if any>
|
||||
TRANSITION IN/OUT: <from the locked transition family only>
|
||||
BEAT: <audio mark the key move lands on, if scored>
|
||||
NOTE: <any deliberate break from the spec + the reason>
|
||||
```
|
||||
|
||||
### Worked example (Calm + Sharp, premium tech)
|
||||
|
||||
```
|
||||
SHOT 3 [t = 6.0–9.5s]
|
||||
PURPOSE: Reveal the dashboard as effortless and accurate.
|
||||
HERO: Dashboard card scales 0.96→1.0 over 0.8s on the decel ease, soft landing.
|
||||
SUPPORT: Three metric chips cascade in, 60ms stagger, 0.4s each, eased out so the
|
||||
eye finishes on the card, not the chips.
|
||||
TEXTURE: Grid lines drift 2px over 8s, 8% opacity — subliminal only.
|
||||
TRANSITION IN/OUT: Match-cut from the prior logo mark / crossfade to shot 4.
|
||||
BEAT: Card lands on the downbeat at 6.4s.
|
||||
NOTE: No overshoot anywhere — this is financial data; overshoot reads as imprecise.
|
||||
```
|
||||
|
||||
### Worked example (Neutral data-story, count-up)
|
||||
|
||||
```
|
||||
SHOT 5 [t = 12.0–15.0s]
|
||||
PURPOSE: Land the headline number ($1.4M) so it's read and believed.
|
||||
HERO: Number eases from 0 to 1,400,000 over 1.1s; decelerates so the final
|
||||
digits settle slowly and feel exact.
|
||||
SUPPORT: Caption fades in after the count completes (not during) — don't make
|
||||
the viewer read while a number is still moving.
|
||||
TEXTURE: None. The frame holds still for 0.7s after — stillness sells accuracy.
|
||||
TRANSITION IN/OUT: Cross-dissolve from the bar chart; cross-dissolve out.
|
||||
NOTE: Hold the final value on screen ≥ 0.5s before any exit.
|
||||
```
|
||||
|
||||
## Easing vocabulary cheat sheet
|
||||
|
||||
| Curve | `cubic-bezier` | Feel | Use for |
|
||||
|---|---|---|---|
|
||||
| Decelerate (ease-out) | `0, 0, 0.2, 1` | Arrives, settles softly | Entrances, landings — the workhorse |
|
||||
| Accelerate (ease-in) | `0.4, 0, 1, 1` | Leaves with intent | Exits, things flying off |
|
||||
| Standard (in-out) | `0.4, 0, 0.2, 1` | Smooth both ends | On-screen repositioning |
|
||||
| Emphasized decel | `0.22, 1, 0.36, 1` | Confident, premium landing | Hero reveals (calm-sharp) |
|
||||
| Hard out | `0.16, 1, 0.3, 1` | Snappy, energetic | Kinetic pieces, hype |
|
||||
| Overshoot | `0.34, 1.56, 0.64, 1` | Springy, playful, alive | Friendly/kinetic-soft only |
|
||||
|
||||
Rules of thumb: entrances ease *out* (decelerate in), exits ease *in* (accelerate away). Avoid `linear` except for continuous loops (rotation, marquees, ambient drift). Never ship default `ease` — it's the un-decided curve and reads as unconsidered.
|
||||
|
||||
## Pacing map
|
||||
|
||||
Sketch energy across the timeline before timing any single move. A typical 30s arc:
|
||||
|
||||
```
|
||||
energy
|
||||
^ ____ peak (the one "wow")
|
||||
| / \
|
||||
| build/ \ settle
|
||||
| / \____ end card hold
|
||||
|__/
|
||||
+----------------------------------> time
|
||||
open build peak resolve
|
||||
```
|
||||
|
||||
Vary deliberately: a hold after a fast sequence makes the next move hit harder. Density of cuts is a dial — turn it up for tension, down for gravity. Match key landings to audio beats where scored.
|
||||
|
||||
## Pre-render direction checklist
|
||||
|
||||
- [ ] Brief complete; one message and exactly 3 mood adjectives written down.
|
||||
- [ ] Tone placed in a single cell of the energy matrix — not straddling two.
|
||||
- [ ] Motion-language spec filled, every row a single choice.
|
||||
- [ ] ≤ 2 primary easing curves used across the whole piece.
|
||||
- [ ] All durations are multiples of the base unit.
|
||||
- [ ] Only the chosen transition family appears between shots.
|
||||
- [ ] Stagger rhythm identical across all grouped reveals.
|
||||
- [ ] Every frame has exactly one hero; support never competes.
|
||||
- [ ] Restraint pass done — listed what stays static and why.
|
||||
- [ ] Hold discipline respected; no two moves stacked without rest.
|
||||
- [ ] Exactly one "wow" moment.
|
||||
- [ ] Numbers/quantities ease to value, hold ≥ 0.5s, and don't overshoot.
|
||||
- [ ] Pacing map drawn; key moments land on beats (if scored).
|
||||
- [ ] Brand cues honored; nothing animated that the brand wouldn't.
|
||||
|
||||
---
|
||||
## Built by the team behind iart.ai
|
||||
|
||||
This skill is part of an open motion-graphics collection from iart.ai — the AI motion agent that turns data, scripts, and designs into editable motion graphics (Remotion → MP4). If you'd rather not hand-build this, iart.ai can act as the motion director — apply one consistent motion language and your brand across every shot, then render the piece. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=motion-design-skills&utm_content=ref_footer&utm_term=motion-art-direction)
|
||||
Whitespace-only changes.
@@ -1,279 +0,0 @@
|
||||
---
|
||||
name: motion-background
|
||||
description: This skill should be used when the user asks to "add an animated background", "build a mesh/gradient background", "make an aurora/shader background", "add constellation/particle background", "animated hero background", or "a subtle looping background behind content". Covers CSS mesh gradients, GLSL shader gradients (Three.js), canvas particle constellations, seamless loops, and reduced-motion/performance handling — fully self-contained.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Motion Background
|
||||
|
||||
Ambient, living backgrounds that sit behind content without stealing focus. The goal is subtle, seamlessly looping motion that stays readable and performant. This skill is self-contained: every technique below ships its own runnable code — CSS, GLSL, and canvas — with no external dependencies beyond Three.js where a shader is involved.
|
||||
|
||||
## When to use
|
||||
|
||||
- Hero/landing background that subtly moves; section ambience.
|
||||
- Login/splash/empty-state living backdrops.
|
||||
- A gradient, mesh, aurora, shader, or particle/constellation field behind text or UI.
|
||||
|
||||
## Pick an approach
|
||||
|
||||
| Look | Technique | Cost |
|
||||
|---|---|---|
|
||||
| Soft animated gradient/mesh | CSS gradients + keyframes | Cheapest, no JS |
|
||||
| Flowing aurora / organic noise | GLSL fragment shader (Three.js full-screen quad) | GPU, moderate |
|
||||
| Constellation / drifting dots | Canvas 2D particles | CPU, scales with count |
|
||||
| Depth / parallax | Same shader/particles with mouse-driven offset | Moderate |
|
||||
|
||||
Default to CSS if a gradient suffices — it is the cheapest and most reliable. Use a shader for organic flowing color; use canvas particles for a constellation/network look.
|
||||
|
||||
## Design rules
|
||||
|
||||
- **Subtle**: low contrast versus content, slow motion (long periods, 8–30s loops), nothing that competes with text.
|
||||
- **Readable**: keep contrast for foreground text; add a scrim (`linear-gradient` overlay or `backdrop`) if needed.
|
||||
- **Seamless loop**: drive motion with periodic functions so time wraps with no visible jump (see below).
|
||||
- **Respect motion preferences and battery**: honor `prefers-reduced-motion`, and pause when offscreen or the tab is hidden.
|
||||
|
||||
## Core techniques (inlined, runnable)
|
||||
|
||||
### 1. Animated CSS mesh gradient (no JS)
|
||||
|
||||
Layered radial gradients whose positions drift. Animating `background-position` on a larger-than-viewport gradient loops seamlessly.
|
||||
|
||||
```css
|
||||
.bg {
|
||||
position: fixed; inset: 0; z-index: -1;
|
||||
background:
|
||||
radial-gradient(40% 50% at 20% 30%, #5b8cff55, transparent 60%),
|
||||
radial-gradient(45% 55% at 80% 20%, #b05bff55, transparent 60%),
|
||||
radial-gradient(50% 60% at 50% 80%, #2de1c255, transparent 60%),
|
||||
#0b0e1a;
|
||||
background-size: 200% 200%;
|
||||
animation: meshmove 24s ease-in-out infinite;
|
||||
}
|
||||
@keyframes meshmove {
|
||||
0%, 100% { background-position: 0% 0%, 100% 0%, 50% 100%; }
|
||||
50% { background-position: 30% 20%, 70% 30%, 60% 70%; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bg { animation: none; }
|
||||
}
|
||||
```
|
||||
|
||||
The `0%` and `100%` keyframes are identical, so the loop has no seam.
|
||||
|
||||
### 2. Full-screen GLSL gradient + noise shader (Three.js)
|
||||
|
||||
A flowing aurora/gradient using value noise in a fragment shader on a full-screen plane. `uTime` advances each frame; to loop, feed it a wrapped time (section 4).
|
||||
|
||||
```js
|
||||
import * as THREE from 'three';
|
||||
|
||||
const canvas = document.querySelector('#bg');
|
||||
const renderer = new THREE.WebGLRenderer({canvas, antialias: true});
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // cap for perf
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
|
||||
const uniforms = {
|
||||
uTime: {value: 0},
|
||||
uRes: {value: new THREE.Vector2()},
|
||||
uColorA: {value: new THREE.Color('#5b8cff')},
|
||||
uColorB: {value: new THREE.Color('#b05bff')},
|
||||
};
|
||||
|
||||
const material = new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: `void main(){ gl_Position = vec4(position, 1.0); }`,
|
||||
fragmentShader: `
|
||||
precision highp float;
|
||||
uniform float uTime; uniform vec2 uRes;
|
||||
uniform vec3 uColorA, uColorB;
|
||||
|
||||
// hash + value noise
|
||||
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); }
|
||||
float noise(vec2 p){
|
||||
vec2 i = floor(p), f = fract(p);
|
||||
float a = hash(i), b = hash(i+vec2(1,0));
|
||||
float c = hash(i+vec2(0,1)), d = hash(i+vec2(1,1));
|
||||
vec2 u = f*f*(3.0-2.0*f);
|
||||
return mix(mix(a,b,u.x), mix(c,d,u.x), u.y);
|
||||
}
|
||||
float fbm(vec2 p){
|
||||
float v=0.0, a=0.5;
|
||||
for(int i=0;i<5;i++){ v += a*noise(p); p*=2.0; a*=0.5; }
|
||||
return v;
|
||||
}
|
||||
void main(){
|
||||
vec2 uv = gl_FragCoord.xy / uRes.xy;
|
||||
float n = fbm(uv*3.0 + vec2(uTime*0.05, uTime*0.03));
|
||||
vec3 col = mix(uColorA, uColorB, smoothstep(0.2, 0.8, n + uv.y*0.3));
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
}`,
|
||||
});
|
||||
|
||||
const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material);
|
||||
scene.add(quad);
|
||||
|
||||
function resize(){
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
uniforms.uRes.value.set(window.innerWidth, window.innerHeight);
|
||||
}
|
||||
window.addEventListener('resize', resize); resize();
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
let running = true;
|
||||
function loop(){
|
||||
if (running){
|
||||
uniforms.uTime.value = clock.getElapsedTime();
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
loop();
|
||||
```
|
||||
|
||||
### 3. Canvas constellation particles
|
||||
|
||||
Drifting points connected by lines when near — the classic "network" background. Pure canvas 2D, no dependencies.
|
||||
|
||||
```js
|
||||
const canvas = document.querySelector('#stars');
|
||||
const ctx = canvas.getContext('2d');
|
||||
let W, H, pts;
|
||||
const COUNT = 80, LINK = 120;
|
||||
|
||||
function init(){
|
||||
W = canvas.width = innerWidth; H = canvas.height = innerHeight;
|
||||
pts = Array.from({length: COUNT}, () => ({
|
||||
x: Math.random()*W, y: Math.random()*H,
|
||||
vx: (Math.random()-0.5)*0.3, vy: (Math.random()-0.5)*0.3,
|
||||
}));
|
||||
}
|
||||
addEventListener('resize', init); init();
|
||||
|
||||
function frame(){
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
for (const p of pts){
|
||||
p.x += p.vx; p.y += p.vy;
|
||||
if (p.x<0||p.x>W) p.vx*=-1;
|
||||
if (p.y<0||p.y>H) p.vy*=-1;
|
||||
ctx.fillStyle = '#9db4ff'; ctx.fillRect(p.x, p.y, 2, 2);
|
||||
}
|
||||
for (let i=0;i<COUNT;i++) for (let j=i+1;j<COUNT;j++){
|
||||
const dx=pts[i].x-pts[j].x, dy=pts[i].y-pts[j].y;
|
||||
const d=Math.hypot(dx, dy);
|
||||
if (d<LINK){
|
||||
ctx.strokeStyle = `rgba(157,180,255,${1-d/LINK})`;
|
||||
ctx.beginPath(); ctx.moveTo(pts[i].x,pts[i].y); ctx.lineTo(pts[j].x,pts[j].y); ctx.stroke();
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
frame();
|
||||
```
|
||||
|
||||
The O(n²) link loop is fine to ~120 points; above that, spatial-hash into a grid and only test neighboring cells.
|
||||
|
||||
### 4. Seamless loop technique
|
||||
|
||||
For canvas/JS, wrap time into a fixed period so motion repeats exactly. Use the loop *phase* (0→2π) as the argument to periodic functions:
|
||||
|
||||
```js
|
||||
const PERIOD = 12; // seconds
|
||||
const phase = (t % PERIOD) / PERIOD * Math.PI * 2;
|
||||
const offset = Math.sin(phase) * amp; // returns to start at t = PERIOD
|
||||
```
|
||||
|
||||
Any motion built only from `sin`/`cos` of `phase` (or integer multiples) loops seamlessly. In the shader, feed `uTime = phase` and use only `sin`/`cos` of it; for noise-scrolled gradients, scroll by an integer number of noise cells per period so the field tiles.
|
||||
|
||||
### 5. Reduced motion + offscreen/hidden pause
|
||||
|
||||
```js
|
||||
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (reduce) {
|
||||
running = false; // render one static frame, then stop
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
// Pause when the tab is hidden
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
running = !document.hidden && !reduce;
|
||||
});
|
||||
|
||||
// Pause when the canvas scrolls offscreen
|
||||
new IntersectionObserver(([e]) => {
|
||||
running = e.isIntersecting && !document.hidden && !reduce;
|
||||
}).observe(canvas);
|
||||
```
|
||||
|
||||
When `running` is false, skip the render inside the rAF loop (as in section 2) — the loop stays alive to resume cheaply, but does no GPU/CPU work.
|
||||
|
||||
## 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`.
|
||||
|
||||
A motion background ships as **one `.html` file that opens directly in a browser** — markup, the background, and (for shader/canvas) Three.js or canvas JS from CDN, all inline. One file is the right tier.
|
||||
|
||||
**Output contract:**
|
||||
- One `.html` file: the background layer + a sample of foreground text on top to check readability/contrast.
|
||||
- One animation driver — the CSS `@keyframes`, the shader rAF loop, or the canvas rAF loop; just one.
|
||||
- Include the freeze harness below, matched to the technique, so any moment can be screenshotted deterministically.
|
||||
|
||||
**Freeze harness — pin a frame for screenshots.** Match the mechanism to the technique:
|
||||
|
||||
```html
|
||||
<script>
|
||||
const t = new URLSearchParams(location.search).get("t");
|
||||
if (t !== null) {
|
||||
const T = parseFloat(t);
|
||||
// CSS mesh gradient:
|
||||
document.querySelectorAll(".bg").forEach(el => {
|
||||
el.style.animationDelay = (-T) + "s";
|
||||
el.style.animationPlayState = "paused";
|
||||
});
|
||||
// GLSL shader instead? → render exactly one frame at fixed time:
|
||||
// uniforms.uTime.value = T; renderer.render(scene, camera); running = false;
|
||||
// Canvas particles? → seed deterministically, step the sim to T, draw once, stop the loop.
|
||||
}
|
||||
window.__ready = true; // ready signal for headless wait
|
||||
</script>
|
||||
```
|
||||
|
||||
**Verify loop — render → freeze → screenshot → check:**
|
||||
1. Open the file frozen at start / mid / end across one loop period: `…/bg.html?t=0`, `?t=<period/2>`, `?t=<period>`.
|
||||
2. Screenshot each frozen frame.
|
||||
3. Check **fidelity** (subtle, on-brand, seamless) and **artifacts** — text contrast holds at every frame, no banding in gradients/shader, the loop seam (`t=0` vs `t=period`) matches, no GPU/console errors.
|
||||
|
||||
```bash
|
||||
npx playwright screenshot --wait-for-timeout=600 "file://$PWD/bg.html?t=12" frame-mid.png
|
||||
```
|
||||
|
||||
**Before you finish:**
|
||||
1. Opens standalone in a browser — no console/WebGL errors, no missing CDN.
|
||||
2. One driver; `?t=N` freezes the exact frame (shader renders one frame, canvas sim stepped deterministically).
|
||||
3. Screenshotted at start / mid / end — foreground text stays readable, no banding, loop seam invisible.
|
||||
4. `prefers-reduced-motion` honored (one static frame rendered, loop stopped) + pauses offscreen/hidden.
|
||||
5. `devicePixelRatio` capped at 2; motion is slow and subtle, never out-contrasting content.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Need | Do |
|
||||
|---|---|
|
||||
| Cheapest gradient | CSS layered `radial-gradient` + `background-position` keyframes |
|
||||
| Organic flow | GLSL `fbm` noise, scroll `uTime` |
|
||||
| Network/dots | canvas particles + distance-linked lines |
|
||||
| No seam | drive motion by `sin/cos(phase)`, identical first/last keyframe |
|
||||
| Perf cap | `setPixelRatio(min(dpr, 2))`, throttle, reduce particle count on mobile |
|
||||
| Accessibility | `prefers-reduced-motion` static fallback |
|
||||
| Save battery | pause on `visibilitychange` + `IntersectionObserver` |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Never let the background out-contrast the foreground text — add a scrim if it does.
|
||||
- Uncapped `devicePixelRatio` on retina/4K murders the GPU; cap at 2.
|
||||
- `background-position` loops only if first and last keyframes match exactly.
|
||||
- Noise-scrolled shaders loop only if the scroll is an integer number of cells per period; otherwise the loop visibly jumps.
|
||||
- Forgetting the offscreen/hidden pause drains battery on mobile even when the user can't see it.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/background-recipes.md` — fuller drop-in implementations: a richer multi-blob CSS mesh with blur, the complete Three.js aurora shader with mouse parallax and a true seamless-loop time wrap, a grid-optimized constellation field with mouse repulsion, a perfectly-looping noise-flow technique, and a complete reduced-motion + offscreen-pause manager wrapping all of them.
|
||||
@@ -1,233 +0,0 @@
|
||||
# Background Recipes — Drop-in Implementations
|
||||
|
||||
Fuller, production-ready versions of each technique. All are self-contained (Three.js is the only external dependency, and only for the shader recipe).
|
||||
|
||||
## 1. Rich CSS mesh gradient (blurred blobs)
|
||||
|
||||
Animated blobs behind a blur layer give a soft "mesh gradient" look without WebGL. Uses absolutely-positioned radial blobs and `filter: blur`.
|
||||
|
||||
```html
|
||||
<div class="mesh">
|
||||
<span class="blob b1"></span>
|
||||
<span class="blob b2"></span>
|
||||
<span class="blob b3"></span>
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.mesh{ position:fixed; inset:0; z-index:-1; overflow:hidden; background:#0b0e1a; }
|
||||
.mesh .blob{
|
||||
position:absolute; width:50vmax; height:50vmax; border-radius:50%;
|
||||
filter:blur(80px); opacity:.55; mix-blend-mode:screen;
|
||||
}
|
||||
.b1{ background:#5b8cff; top:-10%; left:-5%; animation:drift1 26s ease-in-out infinite; }
|
||||
.b2{ background:#b05bff; top:30%; left:55%; animation:drift2 30s ease-in-out infinite; }
|
||||
.b3{ background:#2de1c2; top:60%; left:10%; animation:drift3 22s ease-in-out infinite; }
|
||||
|
||||
@keyframes drift1{ 0%,100%{transform:translate(0,0)} 50%{transform:translate(20vmax,10vmax)} }
|
||||
@keyframes drift2{ 0%,100%{transform:translate(0,0)} 50%{transform:translate(-18vmax,12vmax)} }
|
||||
@keyframes drift3{ 0%,100%{transform:translate(0,0)} 50%{transform:translate(12vmax,-14vmax)} }
|
||||
|
||||
@media (prefers-reduced-motion: reduce){ .mesh .blob{ animation:none } }
|
||||
```
|
||||
|
||||
`mix-blend-mode: screen` blends overlapping blobs into bright color fields; `blur` smooths them into a continuous mesh. Identical 0%/100% keyframes guarantee a seamless loop.
|
||||
|
||||
## 2. Three.js aurora shader — full, with parallax and seamless loop
|
||||
|
||||
```js
|
||||
import * as THREE from 'three';
|
||||
|
||||
export function createAurora(canvas){
|
||||
const renderer = new THREE.WebGLRenderer({canvas, antialias:true});
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.OrthographicCamera(-1,1,1,-1,0,1);
|
||||
|
||||
const PERIOD = 18.0; // seconds for a full seamless loop
|
||||
const u = {
|
||||
uPhase: {value:0}, // 0..2π, wraps each PERIOD
|
||||
uRes: {value:new THREE.Vector2()},
|
||||
uMouse: {value:new THREE.Vector2(0.5,0.5)},
|
||||
uColorA: {value:new THREE.Color('#0b1d4d')},
|
||||
uColorB: {value:new THREE.Color('#5b8cff')},
|
||||
uColorC: {value:new THREE.Color('#b05bff')},
|
||||
};
|
||||
|
||||
const material = new THREE.ShaderMaterial({
|
||||
uniforms:u,
|
||||
vertexShader:`void main(){ gl_Position=vec4(position,1.0); }`,
|
||||
fragmentShader:`
|
||||
precision highp float;
|
||||
uniform float uPhase; uniform vec2 uRes, uMouse;
|
||||
uniform vec3 uColorA, uColorB, uColorC;
|
||||
|
||||
float hash(vec2 p){ return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453); }
|
||||
float noise(vec2 p){
|
||||
vec2 i=floor(p), f=fract(p);
|
||||
float a=hash(i), b=hash(i+vec2(1,0)), c=hash(i+vec2(0,1)), d=hash(i+vec2(1,1));
|
||||
vec2 w=f*f*(3.0-2.0*f);
|
||||
return mix(mix(a,b,w.x), mix(c,d,w.x), w.y);
|
||||
}
|
||||
float fbm(vec2 p){ float v=0.,a=.5; for(int i=0;i<6;i++){v+=a*noise(p);p*=2.;a*=.5;} return v; }
|
||||
|
||||
void main(){
|
||||
vec2 uv = gl_FragCoord.xy/uRes.xy;
|
||||
uv += (uMouse-0.5)*0.06; // subtle parallax
|
||||
// loop the flow by moving along a circle in noise space:
|
||||
vec2 flow = vec2(cos(uPhase), sin(uPhase))*0.6;
|
||||
float n = fbm(uv*3.0 + flow);
|
||||
float bands = sin((uv.y*4.0 + n*2.0)) * 0.5 + 0.5;
|
||||
vec3 col = mix(uColorA, uColorB, smoothstep(0.2,0.7,n));
|
||||
col = mix(col, uColorC, smoothstep(0.5,1.0,bands)*0.6);
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
}`,
|
||||
});
|
||||
|
||||
const quad = new THREE.Mesh(new THREE.PlaneGeometry(2,2), material);
|
||||
scene.add(quad);
|
||||
|
||||
function resize(){
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
u.uRes.value.set(innerWidth, innerHeight);
|
||||
}
|
||||
addEventListener('resize', resize); resize();
|
||||
addEventListener('pointermove', e => {
|
||||
u.uMouse.value.set(e.clientX/innerWidth, 1 - e.clientY/innerHeight);
|
||||
});
|
||||
|
||||
const start = performance.now();
|
||||
let running = true;
|
||||
function loop(){
|
||||
if (running){
|
||||
const t = (performance.now() - start)/1000;
|
||||
u.uPhase.value = (t % PERIOD)/PERIOD * Math.PI * 2; // wraps → seamless
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
loop();
|
||||
return {
|
||||
set running(v){ running = v; if (v) loop(); },
|
||||
renderOnce(){ renderer.render(scene, camera); },
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The seamless loop trick: drive the noise offset with `vec2(cos(uPhase), sin(uPhase))`, a closed circle in noise space — at `uPhase = 2π` the field is identical to `uPhase = 0`.
|
||||
|
||||
## 3. Constellation field — grid-optimized + mouse repulsion
|
||||
|
||||
```js
|
||||
export function createConstellation(canvas, {count=120, link=130, repel=140} = {}){
|
||||
const ctx = canvas.getContext('2d');
|
||||
let W,H,pts; const mouse = {x:-9999, y:-9999};
|
||||
|
||||
function init(){
|
||||
W = canvas.width = innerWidth; H = canvas.height = innerHeight;
|
||||
pts = Array.from({length:count}, () => ({
|
||||
x:Math.random()*W, y:Math.random()*H,
|
||||
vx:(Math.random()-0.5)*0.25, vy:(Math.random()-0.5)*0.25,
|
||||
}));
|
||||
}
|
||||
addEventListener('resize', init); init();
|
||||
addEventListener('pointermove', e => { mouse.x=e.clientX; mouse.y=e.clientY; });
|
||||
addEventListener('pointerleave', () => { mouse.x=mouse.y=-9999; });
|
||||
|
||||
// spatial hash to avoid O(n^2)
|
||||
const cell = link;
|
||||
function key(x,y){ return ((x/cell)|0)+','+((y/cell)|0); }
|
||||
|
||||
let running = true;
|
||||
function frame(){
|
||||
if (!running){ requestAnimationFrame(frame); return; }
|
||||
ctx.clearRect(0,0,W,H);
|
||||
const grid = new Map();
|
||||
for (const p of pts){
|
||||
p.x+=p.vx; p.y+=p.vy;
|
||||
if (p.x<0||p.x>W) p.vx*=-1;
|
||||
if (p.y<0||p.y>H) p.vy*=-1;
|
||||
// mouse repulsion
|
||||
const dx=p.x-mouse.x, dy=p.y-mouse.y, d=Math.hypot(dx,dy);
|
||||
if (d<repel){ p.x+=dx/d*1.5; p.y+=dy/d*1.5; }
|
||||
const k=key(p.x,p.y); (grid.get(k)||grid.set(k,[]).get(k)).push(p);
|
||||
ctx.fillStyle='#9db4ff'; ctx.fillRect(p.x,p.y,2,2);
|
||||
}
|
||||
// link only within neighboring cells
|
||||
for (const p of pts){
|
||||
const cx=(p.x/cell)|0, cy=(p.y/cell)|0;
|
||||
for (let gx=cx-1;gx<=cx+1;gx++) for (let gy=cy-1;gy<=cy+1;gy++){
|
||||
const bucket = grid.get(gx+','+gy); if (!bucket) continue;
|
||||
for (const q of bucket){
|
||||
if (q===p) continue;
|
||||
const dx=p.x-q.x, dy=p.y-q.y, d=Math.hypot(dx,dy);
|
||||
if (d<link){
|
||||
ctx.strokeStyle=`rgba(157,180,255,${(1-d/link)*0.6})`;
|
||||
ctx.beginPath(); ctx.moveTo(p.x,p.y); ctx.lineTo(q.x,q.y); ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
frame();
|
||||
return { set running(v){ running=v; } };
|
||||
}
|
||||
```
|
||||
|
||||
Spatial hashing keeps the link test near O(n) and scales to several hundred points.
|
||||
|
||||
## 4. Perfectly looping noise flow (canvas)
|
||||
|
||||
To loop drifting noise, sample it at a position that travels a closed path:
|
||||
|
||||
```js
|
||||
const PERIOD = 10; // seconds
|
||||
const phase = (t % PERIOD)/PERIOD * Math.PI*2;
|
||||
const sx = Math.cos(phase)*R; // closed circle → returns to start
|
||||
const sy = Math.sin(phase)*R;
|
||||
// feed (baseX + sx, baseY + sy) into your noise function
|
||||
```
|
||||
|
||||
Because `(sx,sy)` returns exactly to its start at `t = PERIOD`, the whole field repeats with no jump. For 1-D scroll loops instead, advance by an *integer* number of noise cells per period (`scroll = floor(cells) * (t % PERIOD)/PERIOD`) so the tile aligns.
|
||||
|
||||
## 5. Reduced-motion + lifecycle manager
|
||||
|
||||
A single controller that wraps any of the above (anything exposing a `running` setter and a one-frame render).
|
||||
|
||||
```js
|
||||
export function attachLifecycle(canvas, controller){
|
||||
const mq = matchMedia('(prefers-reduced-motion: reduce)');
|
||||
|
||||
function apply(){
|
||||
const reduced = mq.matches;
|
||||
if (reduced){
|
||||
controller.running = false;
|
||||
controller.renderOnce?.(); // one static frame
|
||||
return;
|
||||
}
|
||||
controller.running = !document.hidden && visible;
|
||||
}
|
||||
|
||||
let visible = true;
|
||||
const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; apply(); });
|
||||
io.observe(canvas);
|
||||
document.addEventListener('visibilitychange', apply);
|
||||
mq.addEventListener?.('change', apply);
|
||||
apply();
|
||||
|
||||
return () => { io.disconnect(); document.removeEventListener('visibilitychange', apply); };
|
||||
}
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```js
|
||||
const aurora = createAurora(document.querySelector('#bg'));
|
||||
attachLifecycle(document.querySelector('#bg'), aurora);
|
||||
```
|
||||
|
||||
This guarantees: a static frame for reduced-motion users, no CPU/GPU work when the tab is hidden or the canvas is scrolled out of view, and automatic resume when conditions change.
|
||||
|
||||
---
|
||||
Keep the loop seamless and the cost low and a background lives behind content. Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=motion-design-skills&utm_content=skill_footer&utm_term=motion-background)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
Whitespace-only changes.
@@ -1,194 +0,0 @@
|
||||
---
|
||||
name: remotion-video
|
||||
description: This skill should be used when the user asks to "make a video with Remotion", "create a programmatic/data-driven video in React", "render an MP4/GIF from code", "animate with useCurrentFrame/interpolate/spring", "sync video to audio/beats", or "render a templated video per record headlessly". Covers building React compositions and rendering them via CLI or @remotion/renderer.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Remotion (Programmatic Video)
|
||||
|
||||
Build real MP4/GIF/WebM videos in React. Every frame is a pure function of `useCurrentFrame()`, so output is deterministic, scrubbable, diffable, and renderable in CI. The code-first alternative to After Effects for templated and data-driven motion graphics.
|
||||
|
||||
## When to use
|
||||
|
||||
- Render MP4/GIF/WebM from code (social clips, title cards, explainers).
|
||||
- Templated/data-driven videos: one composition, many outputs from props (per-user, per-record, per-row of a CSV/DB).
|
||||
- Motion graphics that must be versioned, code-reviewed, and rendered in CI without a GUI.
|
||||
- Programmatic audio sync, charts that animate from data, or embedding shaders/Three.js into video.
|
||||
|
||||
## Core techniques
|
||||
|
||||
### Frame-driven animation
|
||||
|
||||
Animation is derived from the current frame, never from `setState` or `requestAnimationFrame`. `interpolate` maps an input range to an output range; `spring` produces physically natural motion.
|
||||
|
||||
```jsx
|
||||
import {useCurrentFrame, useVideoConfig, interpolate, spring} from 'remotion';
|
||||
|
||||
export const Title = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const {fps} = useVideoConfig();
|
||||
|
||||
// Fade in over frames 0-30, then stay (clamp prevents over/undershoot).
|
||||
const opacity = interpolate(frame, [0, 30], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
|
||||
// Spring entrance; divide by spring duration is unnecessary — drive transforms directly.
|
||||
const scale = spring({frame, fps, config: {damping: 200, mass: 1, stiffness: 100}});
|
||||
|
||||
return <h1 style={{opacity, transform: `scale(${scale})`}}>Hello</h1>;
|
||||
};
|
||||
```
|
||||
|
||||
Always clamp `interpolate` unless an intentional overshoot is desired — by default it extrapolates linearly past the range, which produces opacity > 1 or negative values.
|
||||
|
||||
### Scheduling: Sequence and Series
|
||||
|
||||
`<Sequence>` shifts time: children see `frame` reset to 0 at the sequence's `from`. Use it to place clips on a timeline. `<Series>` lays out back-to-back segments without manual offset math.
|
||||
|
||||
```jsx
|
||||
import {Sequence, Series, AbsoluteFill} from 'remotion';
|
||||
|
||||
export const Timeline = () => (
|
||||
<AbsoluteFill style={{backgroundColor: 'black'}}>
|
||||
<Sequence from={0} durationInFrames={60}><Intro /></Sequence>
|
||||
<Sequence from={60} durationInFrames={90}><Body /></Sequence>
|
||||
|
||||
{/* Series auto-sequences; offset overlaps the previous segment for crossfades */}
|
||||
<Series>
|
||||
<Series.Sequence durationInFrames={60}><ShotA /></Series.Sequence>
|
||||
<Series.Sequence durationInFrames={60} offset={-15}><ShotB /></Series.Sequence>
|
||||
</Series>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
```
|
||||
|
||||
### Composition registration + parametric props
|
||||
|
||||
The `<Composition>` declares id, size, fps, duration, and `defaultProps`. A zod schema makes props type-safe and editable in the Studio sidebar.
|
||||
|
||||
```jsx
|
||||
import {Composition} from 'remotion';
|
||||
import {z} from 'zod';
|
||||
|
||||
export const schema = z.object({
|
||||
title: z.string(),
|
||||
accent: z.string(),
|
||||
fps: z.number().default(30),
|
||||
});
|
||||
|
||||
export const Root = () => (
|
||||
<Composition
|
||||
id="Promo"
|
||||
component={Promo}
|
||||
durationInFrames={150}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1920}
|
||||
schema={schema}
|
||||
defaultProps={{title: 'Launch', accent: '#5b8cff', fps: 30}}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
To make duration data-dependent, use `calculateMetadata` on the Composition to compute `durationInFrames` from props (e.g. number of rows × frames per row) before render.
|
||||
|
||||
### Audio and beat sync
|
||||
|
||||
```jsx
|
||||
import {Audio, staticFile, useCurrentFrame, useVideoConfig} from 'remotion';
|
||||
|
||||
const BEATS_SEC = [0.5, 1.0, 1.5, 2.0]; // detected offline
|
||||
export const Music = ({children}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const {fps} = useVideoConfig();
|
||||
const t = frame / fps;
|
||||
const onBeat = BEATS_SEC.some((b) => Math.abs(t - b) < 1 / fps);
|
||||
return (
|
||||
<>
|
||||
<Audio src={staticFile('track.mp3')} />
|
||||
<div style={{transform: `scale(${onBeat ? 1.06 : 1})`}}>{children}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Detect beats offline (e.g. with `web-audio-beat-detector` or aubio) and bake the timestamps into props — never analyze audio at render time, since headless rendering has no realtime audio clock.
|
||||
|
||||
### Rendering
|
||||
|
||||
Preview in the browser-based Studio; render headlessly via CLI or the programmatic API.
|
||||
|
||||
```bash
|
||||
npx remotion studio # interactive preview
|
||||
npx remotion render Promo out/promo.mp4 \
|
||||
--props='{"title":"Launch","accent":"#f43"}' # pass parametric props
|
||||
npx remotion render Promo out.gif --codec=gif # GIF output
|
||||
```
|
||||
|
||||
For batch/data-driven pipelines, render in Node with `@remotion/renderer` (bundle once, render many) — see the reference file.
|
||||
|
||||
## Deliver & verify (rendered stills → MP4)
|
||||
|
||||
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
|
||||
|
||||
Remotion is frame-deterministic by construction — every frame is a pure function of `useCurrentFrame()`, so you can render any exact frame headlessly with **no seek harness** (this is the heavy-tier counterpart to a web scene's `?t=N`). Use this tier when the output must be an MP4/GIF, must carry exact numbers/text, or batches from data; for a lightweight web animation, deliver standalone HTML instead.
|
||||
|
||||
**Output contract:**
|
||||
- A Remotion project with the composition registered (`<Composition>` + zod `schema` + `defaultProps`), all motion frame-driven (no timers / `Date.now()` / `Math.random()`).
|
||||
- Deliverable = the rendered `out/*.mp4` (plus the project, so the user can re-render with new props/data).
|
||||
- Duration data-dependent? compute it in `calculateMetadata`, not by hand.
|
||||
|
||||
**Verify loop — render stills → inspect → encode.** Render single frames first (cheap, no video encode), inspect them, and encode the full video only once the frames are right.
|
||||
|
||||
```bash
|
||||
# 1. Frame-exact stills at start / mid / end (PNG, headless, fast)
|
||||
npx remotion still Promo out/f-start.png --frame=0
|
||||
npx remotion still Promo out/f-mid.png --frame=75
|
||||
npx remotion still Promo out/f-end.png --frame=149 # last frame = durationInFrames - 1
|
||||
# render with the SAME props you'll ship, not just defaultProps:
|
||||
# npx remotion still Promo out/f-mid.png --frame=75 --props='{"title":"Launch"}'
|
||||
|
||||
# 2. Inspect each PNG — fidelity (matches brief; numbers/text correct) AND
|
||||
# artifacts (text overflow, off-canvas, clipped safe-area, missing font, wrong data binding).
|
||||
|
||||
# 3. Only after the stills check out, encode the video:
|
||||
npx remotion render Promo out/promo.mp4 --props='{"title":"Launch"}'
|
||||
```
|
||||
|
||||
- Use `npx remotion compositions` to read each composition's `durationInFrames`/`fps` and pick the end frame.
|
||||
- **Data-driven / batch**: verify ONE representative props set via stills *before* batch-rendering all rows — catch a layout bug once instead of N times.
|
||||
- **README demo GIF for free**: `npx remotion render Promo out/demo.gif --codec=gif` produces the first-screen proof clip (Direction D).
|
||||
|
||||
**Before you finish:**
|
||||
1. `npx remotion still` renders cleanly at frame 0, mid, and last — no errors, no missing assets/fonts.
|
||||
2. Numbers/text are exact and inside safe areas at every checked frame.
|
||||
3. Frame-driven only — no `Date.now()` / `Math.random()` / timers (determinism holds in CI).
|
||||
4. Props are zod-typed; the **shipped** props render correctly (not just `defaultProps`).
|
||||
5. Full MP4 encoded and plays; (optional) GIF rendered for the README.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Need | Use |
|
||||
|---|---|
|
||||
| Map time → value | `interpolate(frame, [a,b], [x,y], {extrapolateRight:'clamp'})` |
|
||||
| Natural easing | `spring({frame, fps, config})` |
|
||||
| Place a clip at t | `<Sequence from durationInFrames>` |
|
||||
| Back-to-back shots | `<Series>` |
|
||||
| Audio | `<Audio src={staticFile(...)} />` |
|
||||
| Frame ↔ seconds | `frame / fps` |
|
||||
| Loop a value | `frame % period` then interpolate |
|
||||
| Editable props | zod `schema` on `<Composition>` |
|
||||
| CI render | `npx remotion render` or `@remotion/renderer` |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Never use `Math.random()`, `Date.now()`, or animation timers — they break determinism. Use `random(seed)` from Remotion for stable per-frame randomness.
|
||||
- Load fonts and assets via `staticFile()` and wait with `delayRender`/`continueRender`, or fonts pop in mid-render.
|
||||
- Default `interpolate` extrapolates — clamp it.
|
||||
- `useCurrentFrame` inside a `<Sequence>` is local (starts at 0); use `useVideoConfig().durationInFrames` for absolute timing.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/api-and-patterns.md` — `interpolate` options and `Easing`, spring config recipes, Sequence/Series scheduling, `<Audio>` + beat-sync, parametric `defaultProps` with zod + `calculateMetadata`, CLI flags, programmatic `@remotion/renderer` batch rendering, and embedding GLSL shaders / Three.js (`@remotion/three`).
|
||||
@@ -1,224 +0,0 @@
|
||||
# Remotion API & Patterns
|
||||
|
||||
Detailed cookbook for building and rendering Remotion compositions.
|
||||
|
||||
## interpolate: full options
|
||||
|
||||
```jsx
|
||||
import {interpolate, Easing} from 'remotion';
|
||||
|
||||
interpolate(frame, [0, 30, 60], [0, 100, 0], {
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1), // CSS ease equivalent
|
||||
extrapolateLeft: 'clamp', // 'extend' (default) | 'clamp' | 'identity' | 'wrap'
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
```
|
||||
|
||||
- Input ranges must be strictly monotonically increasing.
|
||||
- Multi-stop ranges (`[0,30,60]`) create keyframe chains.
|
||||
- `extrapolate: 'extend'` (default) continues the linear slope past the range — almost always clamp instead.
|
||||
- `extrapolate: 'wrap'` repeats the range — handy for loops.
|
||||
|
||||
Common easings: `Easing.linear`, `Easing.ease`, `Easing.in(Easing.quad)`, `Easing.out(Easing.cubic)`, `Easing.inOut(Easing.ease)`, `Easing.elastic(1)`, `Easing.bezier(x1,y1,x2,y2)`.
|
||||
|
||||
## spring: config recipes
|
||||
|
||||
```jsx
|
||||
spring({frame, fps, config, durationInFrames, delay, from, to});
|
||||
```
|
||||
|
||||
| Feel | config |
|
||||
|---|---|
|
||||
| Smooth, no overshoot | `{damping: 200}` |
|
||||
| Gentle bounce | `{damping: 12, stiffness: 100, mass: 1}` |
|
||||
| Snappy UI pop | `{damping: 20, stiffness: 200, mass: 0.6}` |
|
||||
| Heavy / slow | `{mass: 3, damping: 40, stiffness: 80}` |
|
||||
|
||||
- `from`/`to` remap the 0→1 output to any range without an extra `interpolate`.
|
||||
- `durationInFrames` time-stretches the spring to fit a fixed window.
|
||||
- `delay` postpones the start (in frames).
|
||||
- `measureSpring({fps, config})` returns how many frames the spring needs to settle — use it to size sequences.
|
||||
|
||||
```jsx
|
||||
// Spring from 100px down to 0, settling in 40 frames, starting at frame 10
|
||||
const y = spring({frame, fps, from: 100, to: 0, durationInFrames: 40, delay: 10});
|
||||
```
|
||||
|
||||
## Sequence & Series scheduling
|
||||
|
||||
```jsx
|
||||
<Sequence from={30} durationInFrames={60} name="Caption" layout="none">
|
||||
<Caption />
|
||||
</Sequence>
|
||||
```
|
||||
|
||||
- `from` offsets local time; inside, `useCurrentFrame()` returns `globalFrame - from`.
|
||||
- `durationInFrames` clips visibility; omit for infinite.
|
||||
- `layout="none"` removes the default absolute-fill wrapper (use when the child manages its own layout, e.g. inline text).
|
||||
- `name` labels the sequence in the Studio timeline.
|
||||
|
||||
Crossfade between two Series segments by overlapping with negative `offset` and fading:
|
||||
|
||||
```jsx
|
||||
<Series>
|
||||
<Series.Sequence durationInFrames={90}><Slide src="a.jpg" /></Series.Sequence>
|
||||
<Series.Sequence durationInFrames={90} offset={-20}>
|
||||
<Slide src="b.jpg" fadeInFrames={20} />
|
||||
</Series.Sequence>
|
||||
</Series>
|
||||
```
|
||||
|
||||
## Audio + beat sync
|
||||
|
||||
```jsx
|
||||
import {Audio, staticFile} from 'remotion';
|
||||
|
||||
<Audio
|
||||
src={staticFile('music.mp3')}
|
||||
startFrom={30} // trim: start 30 frames into the file
|
||||
endAt={300}
|
||||
volume={(f) => interpolate(f, [0, 30], [0, 1], {extrapolateRight: 'clamp'})} // fade-in
|
||||
/>
|
||||
```
|
||||
|
||||
Detect beats offline and store them as props:
|
||||
|
||||
```js
|
||||
// build step (Node) — produce beats.json
|
||||
import {guess} from 'web-audio-beat-detector';
|
||||
// ...decode audio buffer, then:
|
||||
const {bpm, offset} = await guess(audioBuffer);
|
||||
const period = 60 / bpm;
|
||||
const beats = Array.from({length: 64}, (_, i) => offset + i * period);
|
||||
```
|
||||
|
||||
Then in the component, snap motion to the nearest beat using `frame / fps`.
|
||||
|
||||
## Parametric defaultProps + zod + calculateMetadata
|
||||
|
||||
```jsx
|
||||
import {Composition} from 'remotion';
|
||||
import {z} from 'zod';
|
||||
import {zColor} from '@remotion/zod-types';
|
||||
|
||||
export const promoSchema = z.object({
|
||||
title: z.string(),
|
||||
rows: z.array(z.object({label: z.string(), value: z.number()})),
|
||||
accent: zColor(), // gives a color picker in the Studio
|
||||
});
|
||||
|
||||
export const Root = () => (
|
||||
<Composition
|
||||
id="DataPromo"
|
||||
component={DataPromo}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1080}
|
||||
schema={promoSchema}
|
||||
defaultProps={{title: 'Q3', rows: [], accent: '#5b8cff'}}
|
||||
// Compute duration from data BEFORE rendering:
|
||||
calculateMetadata={({props}) => ({
|
||||
durationInFrames: 30 + props.rows.length * 20,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
`zColor()` from `@remotion/zod-types` renders a color picker; plain `z.string()` renders a text field.
|
||||
|
||||
## CLI rendering
|
||||
|
||||
```bash
|
||||
# Render with inline props
|
||||
npx remotion render DataPromo out.mp4 --props='{"title":"Q3"}'
|
||||
|
||||
# Render from a props file (per-record batch)
|
||||
npx remotion render DataPromo out/$ID.mp4 --props=./data/$ID.json
|
||||
|
||||
# Quality / format flags
|
||||
npx remotion render Promo out.mp4 --codec=h264 --crf=18 --jpeg-quality=90
|
||||
npx remotion render Promo out.webm --codec=vp8
|
||||
npx remotion render Promo still.png --frame=45 # single still
|
||||
npx remotion render Promo out.gif --codec=gif --every-nth-frame=2
|
||||
|
||||
# Concurrency / scale
|
||||
npx remotion render Promo out.mp4 --concurrency=4 --scale=2
|
||||
```
|
||||
|
||||
## Programmatic render with @remotion/renderer
|
||||
|
||||
Bundle once, then render many outputs — the right pattern for data-driven pipelines.
|
||||
|
||||
```js
|
||||
import {bundle} from '@remotion/bundler';
|
||||
import {renderMedia, selectComposition} from '@remotion/renderer';
|
||||
import path from 'path';
|
||||
|
||||
const serveUrl = await bundle({entryPoint: path.resolve('src/index.ts')});
|
||||
|
||||
for (const record of records) {
|
||||
const composition = await selectComposition({
|
||||
serveUrl,
|
||||
id: 'DataPromo',
|
||||
inputProps: record, // drives calculateMetadata too
|
||||
});
|
||||
|
||||
await renderMedia({
|
||||
composition,
|
||||
serveUrl,
|
||||
codec: 'h264',
|
||||
outputLocation: `out/${record.id}.mp4`,
|
||||
inputProps: record,
|
||||
crf: 18,
|
||||
concurrency: 4,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
`selectComposition` resolves `calculateMetadata`, so per-record duration works automatically. Reusing one `serveUrl` across renders avoids re-bundling.
|
||||
|
||||
For stills, use `renderStill({composition, serveUrl, output, frame})`.
|
||||
|
||||
## Embedding shaders / Three.js
|
||||
|
||||
Use `@remotion/three` to drive a Three.js scene by frame:
|
||||
|
||||
```jsx
|
||||
import {ThreeCanvas, useVideoTexture} from '@remotion/three';
|
||||
import {useCurrentFrame, useVideoConfig} from 'remotion';
|
||||
|
||||
const Scene = () => {
|
||||
const frame = useCurrentFrame();
|
||||
return (
|
||||
<mesh rotation={[0, frame * 0.02, 0]}>
|
||||
<boxGeometry args={[2, 2, 2]} />
|
||||
<meshStandardMaterial color="#5b8cff" />
|
||||
</mesh>
|
||||
);
|
||||
};
|
||||
|
||||
export const ThreeComp = () => {
|
||||
const {width, height} = useVideoConfig();
|
||||
return (
|
||||
<ThreeCanvas width={width} height={height}>
|
||||
<ambientLight intensity={0.6} />
|
||||
<pointLight position={[10, 10, 10]} />
|
||||
<Scene />
|
||||
</ThreeCanvas>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
For a raw GLSL shader background, render a full-screen quad with a `shaderMaterial` whose `uTime` uniform is set to `frame / fps` each render (driven by `useFrame` in react-three-fiber). Because the canvas re-renders per Remotion frame, the shader is deterministic.
|
||||
|
||||
Heavy WebGL benefits from `--gl=angle` (or `swangle` for headless servers without a GPU) on the render command.
|
||||
|
||||
## Determinism checklist
|
||||
|
||||
- Replace `Math.random()` with `random('seed' + frame)` from `remotion`.
|
||||
- Wrap async asset loads with `delayRender()` / `continueRender(handle)`.
|
||||
- Preload fonts with `@remotion/google-fonts` or `delayRender` around `document.fonts.load`.
|
||||
- Avoid `Date.now()`, `performance.now()`, and real timers entirely.
|
||||
|
||||
---
|
||||
Drive every frame from props and Remotion renders video as code. Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=motion-design-skills&utm_content=skill_footer&utm_term=remotion-video)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
Whitespace-only changes.
@@ -1,185 +0,0 @@
|
||||
---
|
||||
name: shot-composition
|
||||
description: This skill should be used when the user asks to "compose a frame", "set up a grid", "plan shot composition", "place the focal point", "decide where elements enter and exit", "add a parallax/camera move", "adapt a layout for 16:9 / 9:16 / 1:1", or "keep content inside safe areas". Covers grids, safe areas, focal hierarchy, and 2D/3D camera framing.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Shot Composition
|
||||
|
||||
Stage the frame and move the camera: grid systems, safe areas, focal hierarchy, negative space, parallax layering, and how layout guides the eye over time. Produces composition notes, layer structure (bg/mid/fg), and concrete camera-move plans with numbers.
|
||||
|
||||
## When to use
|
||||
|
||||
- Defining a frame's layout, focal point, and negative space.
|
||||
- Planning element enter/exit directions and motion paths.
|
||||
- Designing camera push/pull, pan, follow, and parallax moves.
|
||||
- Adapting one composition across 16:9, 9:16, and 1:1 with safe areas.
|
||||
|
||||
## Decision tree
|
||||
|
||||
```
|
||||
What are you staging?
|
||||
├─ A static frame's layout
|
||||
│ └─ set grid → place ONE focal point on a thirds power point
|
||||
│ → rank hierarchy (size>contrast>color>position) → reserve negative space
|
||||
├─ Where things enter / leave
|
||||
│ └─ follow reading direction; nearest edge; ease-out in, ease-in out
|
||||
│ (new content → from right/below; back/undo → reverse it)
|
||||
├─ A sense of depth / a camera move
|
||||
│ └─ split into bg/mid/fg layers (the three motion layers)
|
||||
│ → ONE camera move per beat (push | pan | follow | parallax), ease-in-out
|
||||
└─ One comp across aspects (16:9 / 9:16 / 1:1)
|
||||
└─ master in 16:9, focal in the center band → RESTACK (don't crop) for 9:16
|
||||
→ keep critical content inside title-safe margins
|
||||
```
|
||||
|
||||
**Cross-reference — apply the shared frameworks.** This skill is the spatial half of `animation-principles`. Apply them explicitly:
|
||||
|
||||
- **1/3 Rule** (`animation-principles`) has two forms and this skill uses both *spatially*: (a) *Composition thirds* — place the focal point on a rule-of-thirds power point (33%/67%); (b) *Distance* — no element travels more than ~1/3 of the frame without an intermediate keyframe or a scale/opacity change, so entrances stay readable.
|
||||
- **Three motion layers** (`animation-principles`: Primary / Secondary / Ambient) map directly onto depth: **foreground = Primary** (the move the eye follows), **midground = Secondary** (the subject + supporting reactions), **background = Ambient** (slow, low-contrast life). Parallax speed scales with that role.
|
||||
- Settle the **Three Pillars** (Emotional intent / Visual narrative / Motion craft) and the chosen **Motion Personality** (`motion-art-direction`) *before* numbering a camera move — premium wants slow long pushes, energetic wants whip-pans.
|
||||
|
||||
## Core workflow
|
||||
|
||||
### 1. Establish a grid
|
||||
|
||||
A grid gives alignment and rhythm. Default to a **12-column grid** for landscape web/video (it divides into halves, thirds, quarters, sixths) with gutters and outer margins. For a 1920px-wide frame: outer margin 80-120px, 12 columns, gutter 24-32px.
|
||||
|
||||
```
|
||||
content_width = frame_width - 2 * margin
|
||||
column_width = (content_width - (cols - 1) * gutter) / cols
|
||||
```
|
||||
|
||||
Example (1920 wide, margin 100, gutter 24, 12 cols): content 1720, column = (1720 - 11*24)/12 = 121.3px.
|
||||
|
||||
A **baseline grid** (vertical rhythm) of 8px is the standard spacing unit; size and space everything in multiples of 8 (8/16/24/32/48/64) for visual cohesion.
|
||||
|
||||
### 2. Place the focal point
|
||||
|
||||
Use the **rule of thirds**: divide the frame into a 3x3 grid; place the primary subject on a third-line or at an intersection (the "power points"), not dead center — unless the piece deliberately uses symmetry for stillness/formality. The intersections roughly fall at 33%/67% horizontally and vertically.
|
||||
|
||||
Build **visual hierarchy** in this priority order to direct the eye: **size > contrast > color > position**. The biggest, highest-contrast element wins attention first. Establish exactly one primary focal point per frame; secondary elements support, never compete.
|
||||
|
||||
### 3. Use negative space as rhythm
|
||||
|
||||
Do not fill the frame. Generous negative space around the focal point makes it read as premium and confident, and gives motion room to breathe. Whitespace is an active compositional element, not leftover.
|
||||
|
||||
### 4. Layer the scene (bg / mid / fg) for depth and parallax
|
||||
|
||||
Split the composition into depth layers: **background** (slowest, lowest contrast, often desaturated/blurred), **midground** (the subject), **foreground** (fastest, may blur as it crosses). Parallax = moving layers at different speeds to simulate depth.
|
||||
|
||||
These depth layers ARE the `animation-principles` **three motion layers** projected onto Z — each layer's role dictates its speed, contrast, and how much motion it earns:
|
||||
|
||||
| Depth | Motion layer | Parallax speed | Contrast / focus | What lives here |
|
||||
|---|---|---|---|---|
|
||||
| Background | Ambient | 0.1–0.3x | low, often desaturated/blurred | sky, gradient, texture, set dressing |
|
||||
| Midground | Secondary | 0.5–0.7x | mid; supports the subject | subject's environment, supporting elements |
|
||||
| Foreground | Primary | 1.0–1.5x | sharp on subject; FG props blur as they cross | the hero subject + closest props |
|
||||
|
||||
Foreground may exceed 1.0 to read as very close. The Primary move is the one the eye follows; never give two layers competing primary motion.
|
||||
|
||||
### 5. Camera language (works in 2D and 3D)
|
||||
|
||||
- **Push / pull** — scale into or away from the subject (dolly). Builds intimacy or reveals context. 800-2000ms, ease-in-out.
|
||||
- **Pan / tilt / move** — translate the frame across the scene. Reveals space, follows action.
|
||||
- **Follow** — keep a moving subject framed (often slightly off-center, leading the motion).
|
||||
- **Parallax** — layered move that sells depth.
|
||||
|
||||
**One shot, one intent.** Don't push + pan + rotate simultaneously — combined moves read as chaotic. Pick the single move that serves the beat. Inline timing essentials: camera moves use ease-in-out (`cubic-bezier(0.65, 0, 0.35, 1)`); never `linear` except continuous loops; slower is more cinematic.
|
||||
|
||||
### 6. Enter/exit direction carries meaning
|
||||
|
||||
Motion that follows reading direction (left-to-right, top-to-bottom in LTR locales) feels natural and smooth; reversing it creates tension or signals "back/undo." Match the direction to the intent:
|
||||
- New content arriving -> enter from the right / from below.
|
||||
- Dismissing / going back -> exit right or down, or reverse the entrance.
|
||||
- Drilling deeper -> scale up / push in.
|
||||
|
||||
Elements should usually enter and exit through the nearest frame edge with a slight overshoot on enter (ease-out) and acceleration on exit (ease-in).
|
||||
|
||||
### 7. Safe areas and multi-aspect adaptation
|
||||
|
||||
Keep all critical content (text, logos, faces, CTAs) inside a **title-safe margin** so nothing gets clipped or hidden behind platform UI.
|
||||
|
||||
| Aspect | Use | Title-safe margin | Notes |
|
||||
|---|---|---|---|
|
||||
| 16:9 | YouTube, web hero | ~5% all sides | broadcast title-safe historically 10% |
|
||||
| 9:16 | Reels, TikTok, Stories | top ~12-14%, bottom ~18-20%, sides ~6% | platform UI eats top and bottom |
|
||||
| 1:1 | feed posts | ~6-8% all sides | symmetric |
|
||||
| 4:5 | feed (taller) | ~6% sides, ~8% top/bottom | maximizes feed real estate |
|
||||
|
||||
Design the **focal point centered enough** that it survives all crops, then let secondary elements reflow per aspect. For 16:9 -> 9:16, restack horizontal arrangements vertically rather than just cropping the sides off.
|
||||
|
||||
## Worked examples
|
||||
|
||||
### GOOD — product hero, 16:9 → 9:16
|
||||
|
||||
> **Brief**: app launch hero, Premium personality, must work as a web hero and a Reel.
|
||||
>
|
||||
> - **Grid**: 1920×1080, margin 100, gutter 24, 12 cols. Phone mockup spans cols 7–11 (right power-column).
|
||||
> - **Focal**: phone screen on the right thirds intersection (1280, 360) — not centered. One focal point; the headline (cols 1–5) is secondary, lower contrast.
|
||||
> - **Depth (three motion layers)**: bg = soft gradient drift, 0.2x, Ambient. mid = phone mockup, 0.6x, Secondary stage. fg = one out-of-focus UI chip drifting past, 1.2x + 3px blur, reads as Primary depth cue.
|
||||
> - **Camera**: single push-in, scale 1.0→1.10 over 1600ms ease-in-out, origin on the phone screen — matches Premium (slow, no overshoot). No pan added.
|
||||
> - **9:16 adapt**: restack — headline moves *above* the phone, phone pulled up to the upper-middle to clear the bottom 19% CTA/nav zone, type +20%. Focal survives the crop because it started in the center band.
|
||||
>
|
||||
> Why it works: one focal point, depth roles assigned, one camera move, safe-area-aware reflow instead of a blind crop.
|
||||
|
||||
### ANTI-PATTERN — the "everything centered, everything moving" frame
|
||||
|
||||
> - Phone mockup dead-center, headline centered above, logo centered below → three elements fighting on the same axis, no hierarchy, no power point.
|
||||
> - All three layers slide left at the same 1.0x speed → no parallax, no depth, reads as "the whole template moved" (violates the **1/3 Rule / distance**: a full-width slide with no intermediate keyframe).
|
||||
> - Camera does push + pan + slight rotate at once → chaotic, no single intent.
|
||||
> - 9:16 made by cropping the 16:9 sides → headline and logo clipped, phone too low behind the nav bar.
|
||||
>
|
||||
> Fix: pick one focal power point, assign each layer a depth role + distinct parallax speed, one camera move, and restack for vertical instead of cropping.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
| Symptom | Why | Fix |
|
||||
|---|---|---|
|
||||
| Frame feels flat / "the template moved" | all layers move at one speed; no depth roles | assign bg/mid/fg the Ambient/Secondary/Primary roles + 0.2/0.6/1.2x parallax |
|
||||
| Eye doesn't know where to look | 2+ elements at equal size/contrast, all centered | one focal point on a thirds power point; rank size>contrast>color>position |
|
||||
| Camera move reads as chaotic | push + pan + rotate combined | one move per beat; pick the one that serves the intent |
|
||||
| Content clipped on phones | designed for 16:9, cropped to 9:16 | restack vertically; keep critical content in title-safe margins (top 13% / bottom 19% on 9:16) |
|
||||
| Entrance reads as "slide", not "event" | element travels >1/3 of frame in one unbroken move | break with an intermediate keyframe or pair travel with scale/opacity (1/3 Rule, distance) |
|
||||
| Looks cheap / cramped | frame filled edge to edge | reserve negative space around the focal point; it's an active element |
|
||||
| Motion fights the story | numbers chosen before intent | settle Three Pillars + Motion Personality first, then number the move |
|
||||
|
||||
## Deliverable — a composition plan
|
||||
|
||||
A good composition plan is handed to an implementation skill (`gsap-web`, `remotion-video`, `after-effects`) and contains:
|
||||
|
||||
- **Frame spec**: aspect(s), resolution, fps, grid (cols / margin / gutter), baseline unit.
|
||||
- **Focal point**: coordinates (px or %) on a thirds power point + the hierarchy ranking of every element.
|
||||
- **Layer map**: each element assigned bg/mid/fg → Ambient/Secondary/Primary, with parallax speed.
|
||||
- **Enter/exit**: per element — edge, direction, easing family, and whether travel exceeds 1/3 (and how it's broken).
|
||||
- **Camera move**: the single move, its property + from→to values, duration, easing, transform-origin.
|
||||
- **Safe areas**: title-safe margins per target aspect + the restack plan for vertical.
|
||||
- **Cross-refs**: which Motion Personality and which Pillars decisions drive the timing.
|
||||
|
||||
### Before you finish — checklist
|
||||
|
||||
- [ ] Exactly one primary focal point, on a thirds power point (or deliberate symmetry).
|
||||
- [ ] Hierarchy ranked (size > contrast > color > position); secondaries don't compete.
|
||||
- [ ] Negative space reserved around the focal point.
|
||||
- [ ] Every element assigned a depth layer (bg/mid/fg = Ambient/Secondary/Primary) with a parallax speed.
|
||||
- [ ] One camera move, not stacked; eased (not linear unless a loop); 800–2000ms for hero moves.
|
||||
- [ ] Entrances/exits use nearest edge, follow reading direction, ease-out in / ease-in out.
|
||||
- [ ] No element travels >1/3 of frame without an intermediate keyframe or scale/opacity change.
|
||||
- [ ] Critical content inside title-safe margins for every target aspect; vertical is restacked, not cropped.
|
||||
- [ ] Motion Personality + Three Pillars settled before any number was chosen.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Need | Default |
|
||||
|---|---|
|
||||
| Grid | 12 columns, 8px baseline, multiples of 8 spacing |
|
||||
| Focal placement | rule-of-thirds power point, not dead center |
|
||||
| Hierarchy order | size > contrast > color > position |
|
||||
| Depth layers | bg / mid / fg, parallax 0.2x / 0.6x / 1.2x |
|
||||
| Camera | one move at a time, ease-in-out, 800-2000ms |
|
||||
| Enter / exit | follow reading direction; ease-out in, ease-in out |
|
||||
| 9:16 safe | top 12%, bottom 18%, sides 6% |
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/camera-and-grids.md` — grid math with worked numbers, full safe-area percentage tables per aspect, parallax layering code (CSS scroll + JS pointer + Three.js), and named camera-move recipes (push-in, whip-pan, orbit, dolly-zoom) with keyframe values.
|
||||
@@ -1,153 +0,0 @@
|
||||
# Camera moves, grids, safe areas, and parallax
|
||||
|
||||
Worked math and runnable code. Camera timings use ease-in-out (`cubic-bezier(0.65, 0, 0.35, 1)`); never `linear` except continuous loops.
|
||||
|
||||
## Grid math
|
||||
|
||||
```
|
||||
content_width = frame_width - 2 * margin
|
||||
column_width = (content_width - (cols - 1) * gutter) / cols
|
||||
column_x(i) = margin + i * (column_width + gutter) // 0-indexed
|
||||
span_width(n) = n * column_width + (n - 1) * gutter // width of n columns
|
||||
```
|
||||
|
||||
Worked examples:
|
||||
|
||||
| Frame | margin | gutter | cols | content | column | 4-col span |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1920x1080 | 100 | 24 | 12 | 1720 | 121.3 | 557.3 |
|
||||
| 1080x1080 | 64 | 16 | 12 | 952 | 64.7 | 306.6 |
|
||||
| 1080x1920 | 48 | 16 | 6 | 984 | 150.7 | 650.6 |
|
||||
|
||||
Baseline / spacing: use an 8px base unit. Allowed spacing values: 4, 8, 16, 24, 32, 48, 64, 96. Type sizes also on the scale (e.g. 16/20/24/32/48/64/96). Everything snapping to multiples of 8 produces automatic visual cohesion.
|
||||
|
||||
Rule-of-thirds lines for any frame: verticals at `W/3` and `2W/3`, horizontals at `H/3` and `2H/3`. Power points = the four intersections. For 1920x1080: verticals 640 / 1280, horizontals 360 / 720.
|
||||
|
||||
## Safe-area percentages
|
||||
|
||||
Margins are fractions of the frame dimension on that side. Keep text, logos, faces, and CTAs inside.
|
||||
|
||||
| Aspect | Top | Bottom | Left | Right | Reason |
|
||||
|---|---|---|---|---|---|
|
||||
| 16:9 (1920x1080) | 5% (54px) | 5% (54px) | 5% (96px) | 5% (96px) | general web/video; 10% for broadcast |
|
||||
| 9:16 (1080x1920) | 13% (250px) | 19% (365px) | 6% (65px) | 6% (65px) | top status/caption, bottom CTA bar + nav |
|
||||
| 1:1 (1080x1080) | 7% (76px) | 7% (76px) | 7% (76px) | 7% (76px) | feed crop symmetric |
|
||||
| 4:5 (1080x1350) | 8% (108px) | 8% (108px) | 6% (65px) | 6% (65px) | taller feed crop |
|
||||
|
||||
9:16 platform-specific danger zones (approximate, design around these): TikTok right-rail icons occupy the right ~12% mid-to-lower; caption/handle sit in the bottom ~15-20%; Instagram Reels similar. Center the focal subject so it survives all crops, then reflow secondary content per aspect.
|
||||
|
||||
Multi-aspect strategy: design the master in 16:9 with the focal point near the vertical center band; for 9:16, restack horizontally-arranged elements into a vertical stack (don't merely crop the sides), enlarge type ~15-25%, and pull the focal point up toward the upper-middle to clear the bottom UI.
|
||||
|
||||
## Parallax layering
|
||||
|
||||
### CSS scroll-driven (pure CSS, modern browsers)
|
||||
|
||||
```css
|
||||
@keyframes parallax { to { translate: 0 calc(var(--speed) * 200px); } }
|
||||
.layer { animation: parallax linear; animation-timeline: scroll(root block); }
|
||||
.bg { --speed: 0.2; }
|
||||
.mid { --speed: 0.6; }
|
||||
.fg { --speed: 1.2; }
|
||||
```
|
||||
|
||||
### Pointer / mouse parallax (JS)
|
||||
|
||||
```js
|
||||
const layers = [
|
||||
{ el: document.querySelector('.bg'), depth: 0.2 },
|
||||
{ el: document.querySelector('.mid'), depth: 0.6 },
|
||||
{ el: document.querySelector('.fg'), depth: 1.2 },
|
||||
];
|
||||
addEventListener('pointermove', (e) => {
|
||||
const x = (e.clientX / innerWidth - 0.5) * 2; // -1..1
|
||||
const y = (e.clientY / innerHeight - 0.5) * 2;
|
||||
for (const { el, depth } of layers) {
|
||||
el.style.transform = `translate3d(${-x * depth * 30}px, ${-y * depth * 30}px, 0)`;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Background depth 0.1-0.3, midground 0.5-0.7, foreground 1.0-1.5. Foreground may exceed 1.0 to read as very close; apply a slight blur to foreground layers as they cross the subject.
|
||||
|
||||
### Three.js depth parallax
|
||||
|
||||
```js
|
||||
// Place planes at different z; perspective camera makes nearer planes move more
|
||||
camera.position.z = 5;
|
||||
bg.position.z = -8; // far -> moves least
|
||||
mid.position.z = -2;
|
||||
fg.position.z = 1.5; // near -> moves most
|
||||
function onMove(nx, ny) { // nx, ny in -1..1
|
||||
camera.position.x = nx * 0.6;
|
||||
camera.position.y = ny * 0.4;
|
||||
camera.lookAt(0, 0, 0);
|
||||
}
|
||||
```
|
||||
|
||||
## Named camera-move recipes
|
||||
|
||||
### Push-in (dolly in) — build focus/intimacy
|
||||
|
||||
```
|
||||
scale 1.0 -> 1.12 over 1600ms, ease-in-out
|
||||
origin: the subject (transform-origin at focal point)
|
||||
optional: slight bg blur ramp 0 -> 3px to add depth cue
|
||||
```
|
||||
|
||||
### Pull-out (reveal context)
|
||||
|
||||
```
|
||||
scale 1.15 -> 1.0 over 1800ms, ease-in-out, origin centered
|
||||
```
|
||||
|
||||
### Whip-pan (energetic transition between scenes)
|
||||
|
||||
```
|
||||
translateX 0 -> -120% over 280ms, ease-in (accelerate out)
|
||||
add motion blur (CSS: filter blur on X, or directional blur in compositor)
|
||||
incoming scene: translateX 120% -> 0 over 280ms ease-out, overlapped
|
||||
```
|
||||
|
||||
### Pan / move (reveal space)
|
||||
|
||||
```
|
||||
translateX 0 -> -300px over 1500ms ease-in-out
|
||||
keep the subject leading: frame it ~1/3 from the trailing edge so it "leads" into open space
|
||||
```
|
||||
|
||||
### Orbit (3D)
|
||||
|
||||
```js
|
||||
// angle sweep around subject at fixed radius
|
||||
const r = 6, a0 = 0, a1 = Math.PI / 4, dur = 2000; // 45deg over 2s
|
||||
// ease t in 0..1, then:
|
||||
const a = a0 + (a1 - a0) * easeInOut(t);
|
||||
camera.position.set(Math.sin(a) * r, camera.position.y, Math.cos(a) * r);
|
||||
camera.lookAt(target);
|
||||
```
|
||||
|
||||
### Dolly-zoom (vertigo) — unsettling, dramatic
|
||||
|
||||
Move the camera toward the subject while zooming out (or vice versa) so the subject stays the same size but the background warps.
|
||||
|
||||
```js
|
||||
// keep subject screen-size constant: as camera moves closer (z down), widen FOV
|
||||
const baseDist = 8, baseFov = 35;
|
||||
function dollyZoom(dist) {
|
||||
camera.position.z = dist;
|
||||
// maintain framing: fov such that subjectHeight subtends same angle
|
||||
camera.fov = 2 * Math.atan((Math.tan((baseFov * Math.PI/180)/2) * baseDist) / dist) * 180/Math.PI;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
// animate dist from 8 -> 4 over ~2000ms ease-in-out
|
||||
```
|
||||
|
||||
### General rules
|
||||
|
||||
- One move per beat. Combining push + pan + rotate reads as chaos; pick the single move that serves the moment.
|
||||
- Slower = more cinematic and expensive-looking; 800-2000ms for hero camera moves.
|
||||
- Lead moving subjects: frame them off-center toward the trailing edge so there's open space in the direction of travel.
|
||||
- Enter/exit through the nearest edge: ease-out (slight overshoot) on enter, ease-in (accelerate) on exit, following reading direction for natural feel.
|
||||
|
||||
---
|
||||
Respect the grid and safe areas and the camera move guides the eye. Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=motion-design-skills&utm_content=skill_footer&utm_term=shot-composition)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/after-effects
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/animation-principles
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/beat-sync-editing
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/color-motion
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/logo-animation
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/motion-art-direction
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/motion-background
|
||||
@@ -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/shot-composition
|
||||
@@ -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": {
|
||||
"after-effects": {
|
||||
"source": "iart-ai/motion-design-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/after-effects/SKILL.md",
|
||||
"computedHash": "9db077d9664f5c45157c53b754d3c72bb346999a260c63d3d4892745a3eefd02"
|
||||
},
|
||||
"animation-principles": {
|
||||
"source": "iart-ai/motion-design-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/animation-principles/SKILL.md",
|
||||
"computedHash": "140b00a44e08aa7649fb1cce1c48670ff2e76f3889fbac0b5311cc5bb818d7b7"
|
||||
},
|
||||
"beat-sync-editing": {
|
||||
"source": "iart-ai/motion-design-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/beat-sync-editing/SKILL.md",
|
||||
"computedHash": "4089a0b65782c529ebd4fe9a576588fb51cf716573b4d683b0494e6554e61e03"
|
||||
},
|
||||
"color-motion": {
|
||||
"source": "iart-ai/motion-design-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/color-motion/SKILL.md",
|
||||
"computedHash": "7c05f7d35427d5fbe2fd1b5b6c0622252314a049797085fce085085cbf4c3e6e"
|
||||
},
|
||||
"logo-animation": {
|
||||
"source": "iart-ai/motion-design-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/logo-animation/SKILL.md",
|
||||
"computedHash": "3bba34771ff84fa7e68dc8026e195aab411a82b01091c9427fc8f3c8e48e7d99"
|
||||
},
|
||||
"motion-art-direction": {
|
||||
"source": "iart-ai/motion-design-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/motion-art-direction/SKILL.md",
|
||||
"computedHash": "bcba09b729fd3d99b3b1a388c3aaac655a3771b26dc3e8d077a110dfdd9e1631"
|
||||
},
|
||||
"motion-background": {
|
||||
"source": "iart-ai/motion-design-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/motion-background/SKILL.md",
|
||||
"computedHash": "99a576ac6549791b410abbc48a18074fed9e7925cf3ee5392108c59ad9e25fa4"
|
||||
},
|
||||
"remotion-video": {
|
||||
"source": "iart-ai/motion-design-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/remotion-video/SKILL.md",
|
||||
"computedHash": "940885087443f4aa5d3baf6edb4ed25edafea2435b52922e120200b4c1af99ba"
|
||||
},
|
||||
"shot-composition": {
|
||||
"source": "iart-ai/motion-design-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/shot-composition/SKILL.md",
|
||||
"computedHash": "9ff4551d175f951fb443a0680020871eb93c5ae07a0f36a2008ca98e5d0fa324"
|
||||
}
|
||||
}
|
||||
}
|
||||
+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