Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53388fe977 | ||
|
|
0b42fdeae1 |
No files matched your search
@@ -0,0 +1,24 @@
|
||||
# Caption Animation
|
||||
|
||||
Turn a transcript or voiceover into word-timed, scroll-stopping captions: each word pops in on the syllable, the active word highlights, and the text stays glued to the narration. Build the kind of captions that drive watch-time on TikTok, Reels, and Shorts — readable, on-beat, and inside the safe area. For short-form creators burning open captions onto vertical video.
|
||||
|
||||
## When it activates
|
||||
|
||||
- "Add animated captions," "make TikTok captions," or "karaoke captions."
|
||||
- "Word-by-word subtitles," "auto subtitles," or "highlight the active word."
|
||||
- "Burn in captions to a video" or "sync captions to a voiceover/narration."
|
||||
- "Turn an SRT/transcript into animated text."
|
||||
|
||||
## Example prompts
|
||||
|
||||
- "Turn this Whisper transcript into karaoke captions with a per-word pop."
|
||||
- "Restyle these auto subtitles into a branded, Hormozi-style reveal."
|
||||
- "Burn word-level captions onto my voiceover and export a sidecar SRT."
|
||||
|
||||
## What's inside
|
||||
|
||||
- `SKILL.md` — the transcribe → normalize → page → animate → place → export pipeline.
|
||||
- `references/word-timed-captions.md` — deeper guidance on word-level timing and animation.
|
||||
|
||||
---
|
||||
Part of **[Short-Form Video Skills](../../)** · Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=tiktok-video-skills&utm_content=skill_footer&utm_term=caption-animation)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
@@ -0,0 +1,177 @@
|
||||
---
|
||||
name: caption-animation
|
||||
description: This skill should be used when the user asks to "add animated captions", "make TikTok captions", "karaoke captions", "word-by-word subtitles", "auto subtitles", "highlight the active word", "burn in captions to a video", "sync captions to a voiceover/narration", or "turn an SRT/transcript into animated text". Covers word-level timing from Whisper, per-word pop/scale-in, active-word highlight, 9:16 safe-area placement, readable type, and burn-in vs sidecar SRT/VTT.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Caption Animation
|
||||
|
||||
Turn a transcript or voiceover into word-timed, scroll-stopping captions: each word pops in on the syllable, the active word highlights, and the text stays glued to the narration. Build the kind of captions that drive watch-time on TikTok, Reels, and Shorts — readable, on-beat, and inside the safe area.
|
||||
|
||||
## When to use
|
||||
|
||||
- Word-by-word or karaoke captions on short-form vertical video (9:16).
|
||||
- Burning open captions onto a voiceover/narration track from a transcript.
|
||||
- Turning a Whisper/SRT transcript into animated text in Remotion or on the web.
|
||||
- Restyling auto-generated subtitles into a branded, "Hormozi-style" reveal.
|
||||
|
||||
## The pipeline
|
||||
|
||||
| Stage | Job | Tool |
|
||||
|---|---|---|
|
||||
| Transcribe | Audio → word-level timestamps | Whisper (`@remotion/install-whisper-cpp`), AssemblyAI |
|
||||
| Normalize | → `{ text, startMs, endMs }[]` tokens | `@remotion/captions` `Caption` type |
|
||||
| Page | Group words into 1–4 word "pages" | `createTikTokStyleCaptions()` |
|
||||
| Animate | Per-word pop + active-word highlight | Remotion `spring()` / CSS |
|
||||
| Place | Safe-area, readable type | layout rules below |
|
||||
| Export | Burn-in (MP4) and/or sidecar SRT/VTT | Remotion render / file emit |
|
||||
|
||||
## Two non-negotiable rules
|
||||
|
||||
1. **Word-level timing, not line-level.** Karaoke reads as magic only when each word lands on the syllable. Always transcribe to word timestamps; never fake them by splitting a line evenly over its duration — drift is instantly visible.
|
||||
2. **Readability beats style.** Captions are read on a phone, in sunlight, muted. Bold sans-serif, heavy stroke or shadow, high contrast first; decoration second. A caption nobody can read is decoration, not a caption.
|
||||
|
||||
## Word timing from a transcript
|
||||
|
||||
Whisper emits word-level timestamps directly. Normalize every source into one flat token shape so the renderer never cares where the words came from:
|
||||
|
||||
```ts
|
||||
// Caption token — the one shape everything downstream consumes
|
||||
type Token = { text: string; startMs: number; endMs: number };
|
||||
|
||||
// Parse an SRT cue block "00:00:01,200 --> 00:00:01,640" into ms
|
||||
const toMs = (t: string) => {
|
||||
const [h, m, rest] = t.split(":");
|
||||
const [s, ms] = rest.split(",");
|
||||
return ((+h * 60 + +m) * 60 + +s) * 1000 + +ms;
|
||||
};
|
||||
```
|
||||
|
||||
If only sentence-level cues exist, re-transcribe for word timing — do not interpolate. Whisper's `medium.en` is ~2x faster than `large` and accurate enough for clean voice audio.
|
||||
|
||||
## Per-word pop-in (Remotion)
|
||||
|
||||
Drive each word's scale and opacity off a spring anchored to its own `startMs`. The micro-overshoot is what makes it feel "alive".
|
||||
|
||||
```tsx
|
||||
import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
|
||||
|
||||
const Word: React.FC<{ token: Token; active: boolean }> = ({ token, active }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const enter = (token.startMs / 1000) * fps; // word's own entrance frame
|
||||
const p = spring({ frame: frame - enter, fps, config: { damping: 12, mass: 0.6 } });
|
||||
const scale = interpolate(p, [0, 1], [0.6, 1]); // pop from 60% → 100% (overshoots)
|
||||
return (
|
||||
<span style={{
|
||||
display: "inline-block",
|
||||
transform: `scale(${scale})`,
|
||||
opacity: interpolate(p, [0, 1], [0, 1]),
|
||||
color: active ? "#FFE45E" : "#FFFFFF", // active-word highlight
|
||||
transition: "color 80ms linear",
|
||||
}}>{token.text} </span>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Compute `active` by testing `frame` against each token's `[startMs, endMs]`. Keep the highlight a single accent color or a filled "box" behind the live word — never animate every word's color at once.
|
||||
|
||||
## Pages, not walls of text
|
||||
|
||||
Show 1–4 words at a time. Use Remotion's `createTikTokStyleCaptions()` (or group manually) and tune `combineTokensWithinMilliseconds`: ~200–500ms for true word-by-word, ~1000–1500ms for short readable phrases.
|
||||
|
||||
```ts
|
||||
import { createTikTokStyleCaptions } from "@remotion/captions";
|
||||
const { pages } = createTikTokStyleCaptions({
|
||||
captions, combineTokensWithinMilliseconds: 1200, // ~2–3 words per page
|
||||
});
|
||||
```
|
||||
|
||||
## Web/CSS pop (no Remotion)
|
||||
|
||||
For a DOM/live player, give each word its own `animation-delay` equal to `startMs`:
|
||||
|
||||
```css
|
||||
.word { display:inline-block; opacity:0; animation: pop .26s cubic-bezier(.34,1.56,.64,1) forwards; }
|
||||
@keyframes pop { from { opacity:0; transform:translateY(.18em) scale(.7) } to { opacity:1; transform:none } }
|
||||
.word.active { color:#FFE45E; }
|
||||
```
|
||||
```js
|
||||
words.forEach(w => { const el = mk(w.text); el.style.animationDelay = `${w.startMs}ms`; track.append(el); });
|
||||
```
|
||||
|
||||
## Readable type and safe-area placement (9:16, 1080×1920)
|
||||
|
||||
| Property | Value | Why |
|
||||
|---|---|---|
|
||||
| Font | Bold/ExtraBold sans (Montserrat, Inter, Helvetica) | Reads on small, busy screens |
|
||||
| Size | 56–80px (≈8% of frame height), min 45px | Legible muted on a phone |
|
||||
| Stroke | 2–6px solid black outline | Survives any background |
|
||||
| Shadow | Soft drop shadow as backup to stroke | Separation on bright frames |
|
||||
| Case | Uppercase or sentence; high contrast fill | Punch + scannability |
|
||||
| Vertical pos | Center band, ~62–70% down | Above the UI, below the action |
|
||||
| Bottom safe | Keep clear of bottom ~280px / 15% | Avoids caption/CTA/audio UI |
|
||||
| Side safe | Keep within center 80% width | Avoids right-rail icons |
|
||||
|
||||
Place captions in the center band — not pinned to the very bottom, where the platform UI (username, audio, buttons) lives. Pad text to a max of ~2 lines.
|
||||
|
||||
## Sync to a voiceover / narration
|
||||
|
||||
The transcript already carries the timing of the exact audio. Lay that same audio under the composition and keep timestamps in the audio's timebase — captions and voice stay locked with zero manual nudging. If the VO is re-recorded, re-transcribe; never hand-shift offsets.
|
||||
|
||||
## Burn-in vs sidecar SRT/VTT
|
||||
|
||||
| | Burn-in (open) | Sidecar SRT/VTT (closed) |
|
||||
|---|---|---|
|
||||
| Where | Pixels in the MP4 | Separate `.srt`/`.vtt` file |
|
||||
| Social (TikTok/Reels) | Required — guaranteed, styleable | Often ignored by the platform |
|
||||
| Accessibility (ADA/WCAG) | Does not satisfy alone | Required (toggleable) |
|
||||
| Best practice | Burn animated captions for social | Also ship a sidecar for the web/SEO |
|
||||
|
||||
Emit both: burn the animated captions for social, and write a plain SRT/VTT from the same tokens for accessible/SEO playback.
|
||||
|
||||
## Output checklist
|
||||
|
||||
- Word-level timestamps (real, from transcription) — no even-split fakery.
|
||||
- Per-word pop with micro-overshoot; one accent color for the active word.
|
||||
- 1–4 words per page; never a wall of text.
|
||||
- Bold sans, 56–80px, 2–6px stroke + shadow, high contrast.
|
||||
- Center band, clear of bottom ~280px and side rails.
|
||||
- Audio laid under composition; timestamps in the audio timebase.
|
||||
- Ship burn-in for social and a sidecar SRT/VTT for accessibility.
|
||||
|
||||
## 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`.
|
||||
|
||||
Captions ship as a Remotion composition (`<Composition>` + zod `schema` + `defaultProps`) — all word motion frame-driven off `useCurrentFrame()`, never `Date.now()` / `Math.random()` / timers. Deliverable = `out/*.mp4` (burned-in) + the project + the sidecar SRT/VTT. 9:16 vertical (1080×1920) is the default.
|
||||
|
||||
**Verify loop — render stills → inspect → encode.** Word timing is the thing that breaks; check it at exact frames before you spend an encode.
|
||||
|
||||
```bash
|
||||
# Stills at start / a sampled active-word frame / end — WITH SHIPPED PROPS (real tokens + audio)
|
||||
npx remotion still Captions out/f-start.png --frame=0 --props='{"captionsSrc":"vo.json"}'
|
||||
npx remotion still Captions out/f-mid.png --frame=90 --props='{"captionsSrc":"vo.json"}'
|
||||
npx remotion still Captions out/f-end.png --frame=N --props='{"captionsSrc":"vo.json"}' # N = durationInFrames-1
|
||||
|
||||
# Inspect each PNG:
|
||||
# - the word highlighted at frame 90 is the word whose [startMs,endMs] contains 90/fps (no drift)
|
||||
# - burn-in legible: bold sans, stroke+shadow holds, no clipping
|
||||
# - 9:16 safe area: caption sits in the center band, clear of top ~12% and bottom ~20-35% (captions/CTA/audio UI) and the right action rail
|
||||
|
||||
npx remotion render Captions out/captions.mp4 --props='{"captionsSrc":"vo.json"}' # encode once stills are right
|
||||
npx remotion render Captions out/demo.gif --codec=gif # README proof clip
|
||||
```
|
||||
|
||||
Use `npx remotion compositions` to read `durationInFrames`/`fps` and pick the active-word + end frames.
|
||||
|
||||
**Before you finish:**
|
||||
1. Stills render cleanly at frame 0, a mid active-word frame, and last — no missing font/audio.
|
||||
2. The correct word is highlighted at the sampled frame (frame/fps lands inside its token); no even-split fakery.
|
||||
3. Burn-in is legible (stroke+shadow) and the caption is fully inside the 9:16 safe area at every checked frame.
|
||||
4. Frame-driven only — no `Date.now()` / `Math.random()` / timers.
|
||||
5. Shipped props (real tokens, not just `defaultProps`) render correctly; MP4 + sidecar SRT/VTT emitted, GIF optional.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/word-timed-captions.md` — end-to-end build: Whisper transcription and the `Caption` type, a full SRT parser, Remotion word-timed component with active-highlight, manual paging, an SRT/VTT emitter, per-platform safe-area maps, and a readable-type spec sheet.
|
||||
@@ -0,0 +1,254 @@
|
||||
# Word-Timed Captions — End-to-End Build
|
||||
|
||||
A complete, runnable path from audio/transcript to animated burn-in captions plus a sidecar SRT/VTT. Code targets Remotion for burn-in and plain JS for the web; the data shapes are framework-agnostic.
|
||||
|
||||
## 1. The one data shape
|
||||
|
||||
Everything normalizes to a flat array of word tokens. Get to this shape and the renderer never cares about the source.
|
||||
|
||||
```ts
|
||||
export type Token = { text: string; startMs: number; endMs: number };
|
||||
export type Page = { startMs: number; endMs: number; tokens: Token[] };
|
||||
```
|
||||
|
||||
Remotion's `@remotion/captions` uses a compatible `Caption` type:
|
||||
|
||||
```ts
|
||||
type Caption = { text: string; startMs: number; endMs: number; timestampMs: number | null; confidence: number | null };
|
||||
```
|
||||
|
||||
## 2. Transcribe to word-level timing (Whisper)
|
||||
|
||||
Word timestamps are non-negotiable for karaoke. Whisper produces them natively.
|
||||
|
||||
```bash
|
||||
npx @remotion/install-whisper-cpp # one-time: installs whisper.cpp + a model
|
||||
```
|
||||
|
||||
```ts
|
||||
import { transcribe, toCaptions } from "@remotion/install-whisper-cpp";
|
||||
|
||||
const { transcription } = await transcribe({
|
||||
inputPath: "voiceover.wav", // 16kHz mono WAV is ideal for whisper.cpp
|
||||
model: "medium.en", // medium.en ≈ 2x faster than large, accurate on clean voice
|
||||
tokenLevelTimestamps: true, // <-- the key flag: per-word, not per-line
|
||||
whisperPath: "./whisper.cpp",
|
||||
});
|
||||
const { captions } = toCaptions({ transcription }); // → Caption[]
|
||||
```
|
||||
|
||||
Tips:
|
||||
- Feed clean voice audio (denoise/normalize first). Background music wrecks word boundaries.
|
||||
- `large` is most accurate but 2–3x real-time on a laptop. Use `medium.en` unless accuracy is critical.
|
||||
- Cloud alternatives (AssemblyAI, Deepgram, OpenAI Whisper API) also return word timing — map their `words[]` into `Token` the same way.
|
||||
|
||||
Map any token shape into `Token`:
|
||||
|
||||
```ts
|
||||
const toTokens = (caps: Caption[]): Token[] =>
|
||||
caps.map(c => ({ text: c.text.trim(), startMs: c.startMs, endMs: c.endMs }));
|
||||
```
|
||||
|
||||
## 3. Parse an existing SRT into tokens
|
||||
|
||||
When handed an SRT, parse cues; if cues are sentence-level, re-transcribe for word timing rather than interpolating.
|
||||
|
||||
```ts
|
||||
const toMs = (t: string) => {
|
||||
const [h, m, rest] = t.trim().split(":");
|
||||
const [s, ms] = rest.split(",");
|
||||
return ((+h * 60 + +m) * 60 + +s) * 1000 + +ms;
|
||||
};
|
||||
|
||||
export function parseSrt(src: string): Token[] {
|
||||
return src.trim().split(/\n\s*\n/).map(block => {
|
||||
const lines = block.split("\n");
|
||||
const time = lines.find(l => l.includes("-->"))!;
|
||||
const [a, b] = time.split("-->");
|
||||
const text = lines.slice(lines.indexOf(time) + 1).join(" ").trim();
|
||||
return { text, startMs: toMs(a), endMs: toMs(b) };
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Page the tokens (1–4 words at a time)
|
||||
|
||||
Either use Remotion's helper or group manually. Manual grouping by gap + max-words keeps full control:
|
||||
|
||||
```ts
|
||||
export function paginate(tokens: Token[], maxWords = 3, maxGapMs = 1200): Page[] {
|
||||
const pages: Page[] = [];
|
||||
let cur: Token[] = [];
|
||||
for (const t of tokens) {
|
||||
const span = cur.length ? t.endMs - cur[0].startMs : 0;
|
||||
if (cur.length >= maxWords || span > maxGapMs) {
|
||||
if (cur.length) pages.push(pageOf(cur)); cur = [];
|
||||
}
|
||||
cur.push(t);
|
||||
}
|
||||
if (cur.length) pages.push(pageOf(cur));
|
||||
return pages;
|
||||
}
|
||||
const pageOf = (ts: Token[]): Page => ({ startMs: ts[0].startMs, endMs: ts.at(-1)!.endMs, tokens: ts });
|
||||
```
|
||||
|
||||
Remotion equivalent:
|
||||
|
||||
```ts
|
||||
import { createTikTokStyleCaptions } from "@remotion/captions";
|
||||
const { pages } = createTikTokStyleCaptions({ captions, combineTokensWithinMilliseconds: 1200 });
|
||||
// 200–500ms → word-by-word; 1000–1500ms → short readable phrases
|
||||
```
|
||||
|
||||
## 5. Remotion component: word-timed with active highlight
|
||||
|
||||
```tsx
|
||||
import { AbsoluteFill, Audio, Sequence, useCurrentFrame, useVideoConfig,
|
||||
spring, interpolate, staticFile } from "remotion";
|
||||
|
||||
const CAPTION_STYLE: React.CSSProperties = {
|
||||
fontFamily: "Montserrat, Inter, sans-serif",
|
||||
fontWeight: 800,
|
||||
fontSize: 72, // ~8% of 1920px height
|
||||
color: "#fff",
|
||||
textAlign: "center",
|
||||
textTransform: "uppercase",
|
||||
WebkitTextStroke: "5px #000", // heavy outline survives any background
|
||||
paintOrder: "stroke fill", // stroke behind fill, not clipping glyphs
|
||||
textShadow: "0 4px 16px rgba(0,0,0,.55)",
|
||||
lineHeight: 1.1,
|
||||
padding: "0 80px", // keep inside center 80% width
|
||||
};
|
||||
|
||||
const Word: React.FC<{ token: Token; active: boolean }> = ({ token, active }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const enter = (token.startMs / 1000) * fps;
|
||||
const p = spring({ frame: frame - enter, fps, config: { damping: 12, mass: 0.6 } });
|
||||
return (
|
||||
<span style={{
|
||||
display: "inline-block",
|
||||
transform: `scale(${interpolate(p, [0, 1], [0.6, 1])})`,
|
||||
opacity: interpolate(p, [0, 1], [0, 1]),
|
||||
color: active ? "#FFE45E" : undefined,
|
||||
transition: "color 80ms linear",
|
||||
}}>{token.text} </span>
|
||||
);
|
||||
};
|
||||
|
||||
export const Captions: React.FC<{ pages: Page[]; audio: string }> = ({ pages, audio }) => {
|
||||
const { fps } = useVideoConfig();
|
||||
const f = (ms: number) => Math.round((ms / 1000) * fps);
|
||||
const frame = useCurrentFrame();
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<Audio src={staticFile(audio)} /> {/* same audio the transcript came from */}
|
||||
{pages.map((pg, i) => (
|
||||
<Sequence key={i} from={f(pg.startMs)} durationInFrames={Math.max(1, f(pg.endMs - pg.startMs))}>
|
||||
<AbsoluteFill style={{ justifyContent: "flex-start", alignItems: "center", paddingTop: "62%" }}>
|
||||
<div style={CAPTION_STYLE}>
|
||||
{pg.tokens.map((t, j) => {
|
||||
const now = (frame / fps) * 1000;
|
||||
return <Word key={j} token={t} active={now >= t.startMs && now < t.endMs} />;
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</Sequence>
|
||||
))}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Because the `<Audio>` and the timestamps share one timebase, voice and captions stay locked with no manual nudging. Re-record the VO → re-transcribe → done.
|
||||
|
||||
## 6. Web/CSS path (live DOM player)
|
||||
|
||||
```js
|
||||
function renderTrack(tokens, mountEl) {
|
||||
tokens.forEach(t => {
|
||||
const el = document.createElement("span");
|
||||
el.className = "word";
|
||||
el.textContent = t.text + " ";
|
||||
el.style.animationDelay = `${t.startMs}ms`; // each word pops on its own start
|
||||
mountEl.append(el);
|
||||
});
|
||||
}
|
||||
// drive .active off the media element's currentTime
|
||||
audio.addEventListener("timeupdate", () => {
|
||||
const ms = audio.currentTime * 1000;
|
||||
[...track.children].forEach((el, i) =>
|
||||
el.classList.toggle("active", ms >= tokens[i].startMs && ms < tokens[i].endMs));
|
||||
});
|
||||
```
|
||||
|
||||
```css
|
||||
.cap { font: 800 72px/1.1 Montserrat, sans-serif; text-transform: uppercase;
|
||||
color:#fff; -webkit-text-stroke:5px #000; paint-order:stroke fill;
|
||||
text-shadow:0 4px 16px rgba(0,0,0,.55); text-align:center; }
|
||||
.word { display:inline-block; opacity:0; animation: pop .26s cubic-bezier(.34,1.56,.64,1) forwards; }
|
||||
.word.active { color:#FFE45E; }
|
||||
@keyframes pop { from { opacity:0; transform:translateY(.18em) scale(.7) } to { opacity:1; transform:none } }
|
||||
```
|
||||
|
||||
## 7. Emit a sidecar SRT / VTT from the same tokens
|
||||
|
||||
Burn-in alone does not satisfy ADA/WCAG and many platforms ignore uploaded tracks — so ship both. Group tokens into readable cues and serialize:
|
||||
|
||||
```ts
|
||||
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
|
||||
const stamp = (ms: number, sep: string) => {
|
||||
const h = Math.floor(ms / 3.6e6), m = Math.floor(ms % 3.6e6 / 6e4),
|
||||
s = Math.floor(ms % 6e4 / 1000), x = ms % 1000;
|
||||
return `${pad(h)}:${pad(m)}:${pad(s)}${sep}${pad(x, 3)}`;
|
||||
};
|
||||
|
||||
export const toSrt = (pages: Page[]) => pages.map((p, i) =>
|
||||
`${i + 1}\n${stamp(p.startMs, ",")} --> ${stamp(p.endMs, ",")}\n${p.tokens.map(t => t.text).join(" ")}`
|
||||
).join("\n\n") + "\n";
|
||||
|
||||
export const toVtt = (pages: Page[]) => "WEBVTT\n\n" + pages.map(p =>
|
||||
`${stamp(p.startMs, ".")} --> ${stamp(p.endMs, ".")}\n${p.tokens.map(t => t.text).join(" ")}`
|
||||
).join("\n\n") + "\n";
|
||||
```
|
||||
|
||||
Use SRT for the broadest platform support; VTT when styling/positioning cues or serving on the web.
|
||||
|
||||
## 8. Per-platform safe areas (1080×1920, 9:16)
|
||||
|
||||
| Platform | Bottom keep-clear | Top keep-clear | Side keep-clear | Notes |
|
||||
|---|---|---|---|---|
|
||||
| TikTok | ~340px | ~120px | ~110px right rail | Captions, audio title, action buttons crowd the bottom-right |
|
||||
| Instagram Reels | ~280px | ~130px | ~60px | Username/CTA bar at bottom |
|
||||
| YouTube Shorts | ~240px | ~100px | ~120px right rail | Title + channel at bottom-left |
|
||||
| Facebook Reels | ~300px | ~130px | ~80px | Similar to Reels |
|
||||
|
||||
Safe rule across all: place caption baseline at ~62–70% of frame height, keep text within the center 80% width, and never let it enter the bottom ~15%.
|
||||
|
||||
## 9. Readable-type spec sheet
|
||||
|
||||
| Property | Recommended | Floor |
|
||||
|---|---|---|
|
||||
| Weight | 800 (ExtraBold) | 700 |
|
||||
| Size @1080p | 56–80px | 45px |
|
||||
| Stroke | 4–6px solid #000 | 2px |
|
||||
| Shadow | `0 4px 16px rgba(0,0,0,.55)` | soft 1px |
|
||||
| Active accent | one color (e.g. #FFE45E) or filled box | — |
|
||||
| Lines on screen | 1–2 | 2 max |
|
||||
| Words per page | 1–4 | 5 max |
|
||||
|
||||
Set `paint-order: stroke fill` so a thick stroke sits behind the glyph instead of eating it. Test on a bright frame and a busy frame; if it survives both, it ships.
|
||||
|
||||
## 10. Render
|
||||
|
||||
```bash
|
||||
npx remotion render src/index.ts Captions out/captions.mp4 \
|
||||
--props='{"pages": ..., "audio": "voiceover.wav"}'
|
||||
```
|
||||
|
||||
A 10-minute video with animated captions can take 30–60 min to render locally; render long-form in segments, or render on Remotion Lambda for parallel speed.
|
||||
|
||||
---
|
||||
## 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 auto-time captions to a narration track and one-click burn them in from a template — change the text/audio and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=tiktok-video-skills&utm_content=ref_footer&utm_term=caption-animation)
|
||||
@@ -0,0 +1,25 @@
|
||||
# Countdown Video
|
||||
|
||||
Make a countdown that ends exactly when it should. The whole craft is one idea: derive the remaining time from the frame (or a monotonic timestamp), then format and animate that number — never count down with a `setInterval`. Covers livestream "starting soon" screens, launch and flash-sale countdowns, and looping backgrounds. For creators who need a timer that lands on zero on the exact frame.
|
||||
|
||||
## When it activates
|
||||
|
||||
- "Make a countdown timer video" or "create a countdown animation."
|
||||
- "Build a stream starting soon countdown" or "do an event/launch/sale countdown."
|
||||
- "Animate a mm:ss or dd:hh:mm:ss timer."
|
||||
- "Add digit roll/flip transitions to a counter" or "loop a countdown background."
|
||||
|
||||
## Example prompts
|
||||
|
||||
- "Build a 10-minute 'be right back' livestream countdown."
|
||||
- "Make a dd:hh:mm:ss launch countdown to a fixed target date."
|
||||
- "Add flip transitions to an mm:ss timer that hits zero on the exact frame."
|
||||
|
||||
## What's inside
|
||||
|
||||
- `SKILL.md` — frame-accurate (drift-free) timers, time formatting, and a configurable duration prop.
|
||||
- `references/countdown-cookbook.md` — recipes for common countdown scenarios.
|
||||
- `references/digit-flip.md` — digit flip/roll transition techniques.
|
||||
|
||||
---
|
||||
Part of **[Short-Form Video Skills](../../)** · Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=tiktok-video-skills&utm_content=skill_footer&utm_term=countdown-video)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
name: countdown-video
|
||||
description: This skill should be used when the user asks to "make a countdown timer video", "create a countdown animation", "build a stream starting soon countdown", "do an event/launch/sale countdown", "animate a mm:ss or dd:hh:mm:ss timer", "add digit roll/flip transitions to a counter", or "loop a countdown background". Covers frame-accurate (drift-free) timers, digit flip/roll motion, time formatting, and a configurable duration prop.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Countdown Video
|
||||
|
||||
Make a countdown that ends exactly when it should. The whole craft is one idea: derive the remaining time from the frame (or a monotonic timestamp), then format and animate that number — never count down with a `setInterval`. Covers livestream "starting soon" screens, launch/sale countdowns, and looping backgrounds.
|
||||
|
||||
## When to use
|
||||
|
||||
- "Starting soon" / "be right back" livestream screens with a 5–10 min timer.
|
||||
- Launch and flash-sale countdowns (dd:hh:mm:ss down to a target date; mm:ss for urgency).
|
||||
- Any animated counter where the number must land on zero on the exact frame.
|
||||
|
||||
## The one rule: drive the number from the clock, never a tick
|
||||
|
||||
A `setInterval(fn, 1000)` countdown **drifts** — the callback fires *at least* 1000 ms later, never exactly, and stacks up when the tab is throttled or a render frame is slow. Reports of ~1 second lost per minute are common. The fix is the same for rendered video and live web: compute remaining time as a pure function of an authoritative clock, so a late frame self-corrects on the next frame instead of accumulating error.
|
||||
|
||||
| Context | Authoritative clock | Remaining time |
|
||||
|---|---|---|
|
||||
| Remotion render | `useCurrentFrame()` | `total − frame/fps` |
|
||||
| Web (live page) | `performance.now()` or `Date.now()` | `targetMs − now` |
|
||||
| To a fixed date | `Date.now()` | `targetTimestamp − Date.now()` |
|
||||
|
||||
Never store remaining time in state and decrement it. Recompute it every frame.
|
||||
|
||||
## Frame-accurate countdown (Remotion → MP4)
|
||||
|
||||
`useCurrentFrame()` is deterministic: frame N is always rendered at exactly `N/fps` seconds. Subtract from the total and the timer is drift-free by construction.
|
||||
|
||||
```tsx
|
||||
import { useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
export const Countdown: React.FC<{ durationInSeconds: number }> = ({
|
||||
durationInSeconds,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// remaining seconds, clamped at 0 — ceil so "1" shows for its full second
|
||||
const elapsed = frame / fps;
|
||||
const remaining = Math.max(0, Math.ceil(durationInSeconds - elapsed));
|
||||
|
||||
return <div style={{ fontVariantNumeric: "tabular-nums" }}>{formatMMSS(remaining)}</div>;
|
||||
};
|
||||
```
|
||||
|
||||
`durationInSeconds` is a **prop**, not a constant — the same composition renders a 5-minute stream timer or a 30-second sale timer by changing one input. Set the composition's `durationInFrames` to `durationInSeconds * fps` so the video is exactly as long as the countdown.
|
||||
|
||||
## Web timer (live page, requestAnimationFrame)
|
||||
|
||||
For a live page, drive an rAF loop from `performance.now()` and only repaint when the displayed second changes (no work 60×/sec for a 1 Hz number):
|
||||
|
||||
```js
|
||||
const start = performance.now();
|
||||
let lastShown = -1;
|
||||
function tick(now) {
|
||||
const remaining = Math.max(0, Math.ceil(duration - (now - start) / 1000));
|
||||
if (remaining !== lastShown) { render(remaining); lastShown = remaining; }
|
||||
if (remaining > 0) requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
```
|
||||
|
||||
Counting down to a fixed date is the same idea with `Date.now()`: `Math.max(0, target - Date.now())`. See `references/countdown-cookbook.md` for the full date-target version with timezone handling.
|
||||
|
||||
## Time formatting
|
||||
|
||||
Break a remaining-seconds integer into fields with division + modulo, then zero-pad. Pick the format to fit the duration — never show `00d 00h 05m` when minutes is all that matters.
|
||||
|
||||
```js
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
const formatMMSS = (s) => `${pad(Math.floor(s / 60))}:${pad(s % 60)}`;
|
||||
const formatDHMS = (s) => {
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60), sec = s % 60;
|
||||
return `${pad(d)}:${pad(h)}:${pad(m)}:${pad(sec)}`;
|
||||
};
|
||||
```
|
||||
|
||||
| Format | Use | Drop fields when |
|
||||
|---|---|---|
|
||||
| `mm:ss` | stream "starting soon", short timers (<1h) | — |
|
||||
| `hh:mm:ss` | flash sale, same-day event | hide `dd` while 0 |
|
||||
| `dd:hh:mm:ss` | launch / multi-day countdown | hide leading 0 units for cleaner read |
|
||||
|
||||
Always set `font-variant-numeric: tabular-nums` (or a monospace digit font) so digits keep equal width and the layout does not jitter as numbers change.
|
||||
|
||||
## Digit roll / flip transitions
|
||||
|
||||
A flat number swap reads as cheap; the rolling "odometer" look sells the countdown. Render each digit as a vertical strip 0–9 and translate it by `-digit × digitHeight`. Animate the *position*, not the text content, so the change is a glide, not a pop.
|
||||
|
||||
```css
|
||||
.reel { height: 1em; overflow: hidden; }
|
||||
.reel .col { transition: transform .45s cubic-bezier(.22,1,.36,1); }
|
||||
/* col holds stacked 0..9; shift by -value em */
|
||||
```
|
||||
|
||||
In Remotion, drive the strip offset off the frame instead of a CSS transition (so it is deterministic). The flip-card style (top half folds down over the bottom) and the per-digit reel are both detailed, with runnable components, in `references/digit-flip.md`.
|
||||
|
||||
## Looping background
|
||||
|
||||
Stream screens hold for minutes, so the background must loop seamlessly. Build motion on a cycle that divides evenly into the timeline (e.g. a gradient or particle drift whose period is `loopFrames`), and read it as `frame % loopFrames` so there is no visible seam at the wrap. Keep it low-contrast and slow — the number is the subject; the background is ambience. Recipes in `references/countdown-cookbook.md`.
|
||||
|
||||
## Output checklist
|
||||
|
||||
- Remaining time recomputed from `useCurrentFrame()` / `performance.now()` every frame — no `setInterval` decrement.
|
||||
- Timer hits `00:00` on the exact intended frame; `durationInFrames === durationInSeconds × fps`.
|
||||
- Duration is an input prop, not a hardcoded constant.
|
||||
- Digits use `tabular-nums`; layout does not shift as numbers change.
|
||||
- Format matches the span (mm:ss vs dd:hh:mm:ss); leading zero units dropped when distracting.
|
||||
- Background loop period divides the timeline evenly; no seam at the wrap.
|
||||
|
||||
## 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`.
|
||||
|
||||
The countdown ships as a Remotion composition (`<Composition>` + zod `schema` + `defaultProps`, `durationInSeconds` a prop) — the timer is a pure function of `useCurrentFrame()`, never `setInterval` / `Date.now()` / `Math.random()`. Set `durationInFrames = durationInSeconds * fps`. Deliverable = `out/*.mp4` + the project. 9:16 vertical (1080×1920) is the default.
|
||||
|
||||
**Verify loop — render stills → inspect → encode.** The whole point is frame-accuracy, so check the *digit value* at exact frames before encoding.
|
||||
|
||||
```bash
|
||||
# Stills at start / mid / end — WITH SHIPPED PROPS (the real duration you'll render)
|
||||
npx remotion still Countdown out/f-start.png --frame=0 --props='{"durationInSeconds":300}'
|
||||
npx remotion still Countdown out/f-mid.png --frame=N --props='{"durationInSeconds":300}'
|
||||
npx remotion still Countdown out/f-end.png --frame=L --props='{"durationInSeconds":300}' # L = durationInFrames-1
|
||||
|
||||
# Inspect each PNG — the digits must read EXACTLY the frame/fps math:
|
||||
# - at frame N, displayed = ceil(durationInSeconds - N/fps) (e.g. 300s @ 30fps, frame 4500 → 150s → "02:30")
|
||||
# - last frame reads 00:00 (or the end-state hold); no drift, no off-by-one second
|
||||
# - tabular-nums: layout does not jitter between frames
|
||||
# - 9:16 safe area: the timer + brand block sit clear of top ~12% and bottom ~20-35% UI and the right action rail
|
||||
|
||||
npx remotion render Countdown out/countdown.mp4 --props='{"durationInSeconds":300}' # encode once digits verify
|
||||
npx remotion render Countdown out/demo.gif --codec=gif # README proof clip
|
||||
```
|
||||
|
||||
Use `npx remotion compositions` to confirm `durationInFrames`/`fps`, then compute the frame for any second you want to check.
|
||||
|
||||
**Before you finish:**
|
||||
1. Stills render cleanly at frame 0, mid, and last — no errors.
|
||||
2. The digit value at frame N equals `ceil(durationInSeconds - N/fps)` exactly; last frame hits 00:00 on the intended frame (no drift / off-by-one).
|
||||
3. Timer + brand stay inside the 9:16 safe area at every checked frame.
|
||||
4. Frame-driven only — no `setInterval` / `Date.now()` / timers; `tabular-nums` so layout holds.
|
||||
5. Shipped `durationInSeconds` (not just `defaultProps`) renders correctly; MP4 encoded, GIF optional.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/countdown-cookbook.md` — complete runnable Remotion `<Countdown>` (configurable duration, mm:ss / dd:hh:mm:ss, end-state hold), the count-down-to-a-fixed-date web component with timezone handling, a "starting soon" stream-screen layout (brand block + social handles + looping gradient background), and the seamless-loop background pattern.
|
||||
- `references/digit-flip.md` — two deterministic digit-animation components: the odometer reel (vertical 0–9 strip driven by frame) and the flip-card (top-half fold) effect, plus when each suits launch vs sale vs stream.
|
||||
@@ -0,0 +1,205 @@
|
||||
# Countdown Cookbook
|
||||
|
||||
Complete, runnable recipes. Every value is derived from an authoritative clock (frame or timestamp) so nothing drifts.
|
||||
|
||||
## 1. Configurable Remotion countdown (mm:ss + dd:hh:mm:ss)
|
||||
|
||||
A single composition that handles any duration and either format. Drop into `src/Countdown.tsx`.
|
||||
|
||||
```tsx
|
||||
import React from "react";
|
||||
import { useCurrentFrame, useVideoConfig, AbsoluteFill } from "remotion";
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
const formatTime = (totalSeconds: number, mode: "mm:ss" | "dd:hh:mm:ss") => {
|
||||
const s = Math.max(0, totalSeconds);
|
||||
if (mode === "mm:ss") {
|
||||
return `${pad(Math.floor(s / 60))}:${pad(s % 60)}`;
|
||||
}
|
||||
const d = Math.floor(s / 86400);
|
||||
const h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
// drop leading day field once it is zero, for a cleaner read
|
||||
return d > 0
|
||||
? `${pad(d)}d ${pad(h)}:${pad(m)}:${pad(sec)}`
|
||||
: `${pad(h)}:${pad(m)}:${pad(sec)}`;
|
||||
};
|
||||
|
||||
export type CountdownProps = {
|
||||
durationInSeconds: number;
|
||||
format?: "mm:ss" | "dd:hh:mm:ss";
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const Countdown: React.FC<CountdownProps> = ({
|
||||
durationInSeconds,
|
||||
format = "mm:ss",
|
||||
label = "",
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// ceil so the last whole second ("1") is shown for its full duration,
|
||||
// and the display reaches "0" exactly on the final frame.
|
||||
const remaining = Math.max(0, Math.ceil(durationInSeconds - frame / fps));
|
||||
const ended = remaining === 0;
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
background: "#0b0d17",
|
||||
color: "#fff",
|
||||
fontFamily: "Inter, system-ui, sans-serif",
|
||||
}}
|
||||
>
|
||||
{label && (
|
||||
<div style={{ fontSize: 36, opacity: 0.7, marginBottom: 16, letterSpacing: 4 }}>
|
||||
{label.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 180,
|
||||
fontWeight: 800,
|
||||
fontVariantNumeric: "tabular-nums", // equal-width digits → no jitter
|
||||
lineHeight: 1,
|
||||
color: ended ? "#ff4d4d" : "#fff",
|
||||
}}
|
||||
>
|
||||
{ended ? formatTime(0, format) : formatTime(remaining, format)}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Register it so `durationInFrames` matches the countdown exactly — the video ends when the timer does:
|
||||
|
||||
```tsx
|
||||
import { Composition } from "remotion";
|
||||
import { Countdown } from "./Countdown";
|
||||
|
||||
const FPS = 30;
|
||||
const DURATION_SECONDS = 300; // 5-minute "starting soon" timer
|
||||
|
||||
export const RemotionRoot = () => (
|
||||
<Composition
|
||||
id="Countdown"
|
||||
component={Countdown}
|
||||
durationInFrames={DURATION_SECONDS * FPS}
|
||||
fps={FPS}
|
||||
width={1920}
|
||||
height={1080}
|
||||
defaultProps={{ durationInSeconds: DURATION_SECONDS, format: "mm:ss", label: "Starting soon" }}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
Render any duration/format without touching code — duration is a prop:
|
||||
|
||||
```bash
|
||||
npx remotion render Countdown out/sale.mp4 \
|
||||
--props='{"durationInSeconds":86400,"format":"dd:hh:mm:ss","label":"Sale ends in"}'
|
||||
```
|
||||
|
||||
To hold a final state (e.g. "WE'RE LIVE") instead of ending on `00:00`, extend `durationInFrames` past the countdown and branch on `ended`.
|
||||
|
||||
## 2. Count down to a fixed date (live web component)
|
||||
|
||||
For a real-time page that targets a calendar moment. Drift-free because remaining time is `target − Date.now()` recomputed each frame, never a decrement.
|
||||
|
||||
```js
|
||||
// targetISO must include an offset, e.g. "2026-07-01T09:00:00-07:00",
|
||||
// so the deadline is unambiguous regardless of the viewer's timezone.
|
||||
function startCountdown(targetISO, el) {
|
||||
const target = new Date(targetISO).getTime();
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
let lastShown = -1;
|
||||
|
||||
function tick() {
|
||||
const remainingMs = Math.max(0, target - Date.now());
|
||||
const s = Math.floor(remainingMs / 1000);
|
||||
if (s !== lastShown) {
|
||||
const d = Math.floor(s / 86400);
|
||||
const h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
el.textContent = `${pad(d)}:${pad(h)}:${pad(m)}:${pad(s % 60)}`;
|
||||
lastShown = s;
|
||||
}
|
||||
if (remainingMs > 0) requestAnimationFrame(tick);
|
||||
else el.textContent = "00:00:00:00"; // or swap to an "It's live" state
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
```
|
||||
|
||||
Timezone notes:
|
||||
- Bake the offset into the target string (`-07:00`, `Z`). A bare `"2026-07-01T09:00:00"` is parsed as the viewer's *local* time and every viewer sees a different deadline.
|
||||
- To show "ends 9am in *your* timezone everywhere," instead pick the wall-clock time per locale and let each viewer's `Date` resolve it — but that is a different deadline per region. Decide which you want before building.
|
||||
|
||||
## 3. "Starting soon" stream screen
|
||||
|
||||
Livestream lead-in screens hold 5–10 minutes: a clean brand block, social handles, and a calm looping background, with the timer as the focal point. Keep it low-clutter — chat and music carry the wait; the screen just signals "we're about to start."
|
||||
|
||||
```tsx
|
||||
import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate } from "remotion";
|
||||
|
||||
export const StartingSoon: React.FC<{ durationInSeconds: number; handle: string; brand: string }> = ({
|
||||
durationInSeconds,
|
||||
handle,
|
||||
brand,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
const remaining = Math.max(0, Math.ceil(durationInSeconds - frame / fps));
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
// seamless looping gradient: angle cycles over loopFrames with no seam
|
||||
const loopFrames = fps * 8;
|
||||
const angle = interpolate(frame % loopFrames, [0, loopFrames], [0, 360]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `linear-gradient(${angle}deg, #141a3a, #0b0d17 60%)`,
|
||||
color: "#fff",
|
||||
fontFamily: "Inter, system-ui, sans-serif",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 64, fontWeight: 800, marginBottom: 8 }}>{brand}</div>
|
||||
<div style={{ fontSize: 28, opacity: 0.6, letterSpacing: 6, marginBottom: 40 }}>
|
||||
STARTING SOON
|
||||
</div>
|
||||
<div style={{ fontSize: 200, fontWeight: 800, fontVariantNumeric: "tabular-nums", lineHeight: 1 }}>
|
||||
{pad(Math.floor(remaining / 60))}:{pad(remaining % 60)}
|
||||
</div>
|
||||
<div style={{ position: "absolute", bottom: 60, fontSize: 30, opacity: 0.7 }}>{handle}</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Export as MP4 and add it as a Media/Video source in OBS, or use a looping playout for a browser source. Common durations: 300s (5 min) or 600s (10 min) at 1920×1080.
|
||||
|
||||
## 4. Seamless looping background
|
||||
|
||||
Any motion whose period divides the timeline evenly wraps without a seam. Read animation phase as `frame % loopFrames` so the value at the wrap equals the value at frame 0.
|
||||
|
||||
```tsx
|
||||
const loopFrames = fps * 6; // one full cycle every 6 seconds
|
||||
const phase = (frame % loopFrames) / loopFrames; // 0 → 1, repeats
|
||||
const driftY = Math.sin(phase * Math.PI * 2) * 40; // returns to start each cycle
|
||||
```
|
||||
|
||||
Use sine/cosine (inherently periodic) or `interpolate(frame % loopFrames, [0, loopFrames], [a, a])` with matching start/end so there is no jump. Keep contrast low and motion slow — the number is the subject.
|
||||
|
||||
---
|
||||
## 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 render countdown videos from a template with a set time and brand — change the text/data and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=tiktok-video-skills&utm_content=ref_footer&utm_term=countdown-video)
|
||||
@@ -0,0 +1,146 @@
|
||||
# Digit Flip & Roll Transitions
|
||||
|
||||
Two ways to animate a digit changing. Both are driven by the frame (deterministic) so they render identically every time — no CSS transition clock that the renderer can disagree with.
|
||||
|
||||
Pick by tone:
|
||||
|
||||
| Effect | Feel | Best for |
|
||||
|---|---|---|
|
||||
| Odometer reel | smooth, continuous glide | launch countdowns, premium/minimal timers |
|
||||
| Flip card | mechanical "snap", split-flap board | sale urgency, retro/airport-board style |
|
||||
|
||||
## Shared idea
|
||||
|
||||
A single displayed digit is a stack of glyphs `0..9` laid out vertically. Showing digit `d` means translating the stack so glyph `d` sits in the window: `translateY(-d × digitHeight)`. Animate the *offset*, not the text, and you get a roll. The trick for a countdown is that the offset is continuous: between whole seconds the digit eases from its old value toward its new one.
|
||||
|
||||
## Odometer reel (Remotion, frame-driven)
|
||||
|
||||
```tsx
|
||||
import React from "react";
|
||||
import { useCurrentFrame, useVideoConfig, interpolate, Easing } from "remotion";
|
||||
|
||||
const DIGIT_H = 160; // px per glyph
|
||||
const ROLL_FRAMES = 12; // how long a single digit roll takes
|
||||
|
||||
// Animated single digit: rolls from `prev` to `value` over the last ROLL_FRAMES
|
||||
// of the current second.
|
||||
const Reel: React.FC<{ value: number; prev: number; secondProgress: number }> = ({
|
||||
value,
|
||||
prev,
|
||||
secondProgress,
|
||||
}) => {
|
||||
// secondProgress: 0→1 across the current whole second.
|
||||
// Start the roll near the end of the second so the number "settles" on change.
|
||||
const t = interpolate(secondProgress, [0.7, 1], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.bezier(0.22, 1, 0.36, 1),
|
||||
});
|
||||
// continuous position between prev and value
|
||||
const shown = prev + (value - prev) * t;
|
||||
const offset = -shown * DIGIT_H;
|
||||
|
||||
return (
|
||||
<div style={{ height: DIGIT_H, width: 110, overflow: "hidden", position: "relative" }}>
|
||||
<div style={{ transform: `translateY(${offset}px)` }}>
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
|
||||
<div
|
||||
key={n}
|
||||
style={{
|
||||
height: DIGIT_H,
|
||||
lineHeight: `${DIGIT_H}px`,
|
||||
textAlign: "center",
|
||||
fontSize: 140,
|
||||
fontWeight: 800,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Renders an mm:ss reel where each digit rolls.
|
||||
export const ReelClock: React.FC<{ durationInSeconds: number }> = ({ durationInSeconds }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const elapsed = frame / fps;
|
||||
const remaining = Math.max(0, Math.ceil(durationInSeconds - elapsed));
|
||||
const prevRemaining = Math.max(0, remaining + 1); // value one second ago
|
||||
const secondProgress = elapsed % 1; // 0→1 within the current second
|
||||
|
||||
const digits = (s: number) =>
|
||||
[Math.floor(s / 600) % 10, Math.floor(s / 60) % 10, Math.floor(s / 10) % 10, s % 10];
|
||||
|
||||
const cur = digits(remaining);
|
||||
const prev = digits(prevRemaining);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 8, color: "#fff", background: "#0b0d17", padding: 40 }}>
|
||||
{cur.map((d, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i === 2 && <div style={{ fontSize: 140, fontWeight: 800 }}>:</div>}
|
||||
<Reel value={d} prev={prev[i]} secondProgress={secondProgress} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Each digit only rolls when it actually changes; a digit whose `prev === value` sits still because `shown` stays constant.
|
||||
- A `9 → 0` rollover rolls *backward* through the strip (the cheap-looking part). To make tens/units roll the natural way on a carry, render an extra `0` glyph below the `9` and clamp — covered as an exercise; for most timers the backward roll at the tens boundary is unnoticeable at speed.
|
||||
- `ROLL_FRAMES` is unused above because the roll is tied to `secondProgress`; expose it instead if a fixed-length roll is preferred.
|
||||
|
||||
## Flip card (split-flap)
|
||||
|
||||
The top half of the old digit folds down over a static bottom half, revealing the new digit. Pure CSS keyframe version — fine for a live web page; for Remotion, drive the `rotateX` off `secondProgress` the same way as the reel so it stays deterministic.
|
||||
|
||||
```css
|
||||
.flip { position: relative; width: 110px; height: 160px; perspective: 600px; }
|
||||
.flip .card {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 140px; font-weight: 800; font-variant-numeric: tabular-nums;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
.flip .top { clip-path: inset(0 0 50% 0); transform-origin: bottom; }
|
||||
.flip .bottom { clip-path: inset(50% 0 0 0); }
|
||||
/* on change, animate the leaf folding down */
|
||||
.flip .leaf {
|
||||
position: absolute; inset: 0; transform-origin: bottom;
|
||||
animation: fold .45s cubic-bezier(.36,0,.66,-0.2) forwards;
|
||||
}
|
||||
@keyframes fold {
|
||||
0% { transform: rotateX(0deg); }
|
||||
100% { transform: rotateX(-90deg); }
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// swap the digit and replay the fold whenever the value changes
|
||||
function setFlip(el, newDigit) {
|
||||
if (el.dataset.v === String(newDigit)) return; // no change → no flip
|
||||
el.dataset.v = String(newDigit);
|
||||
el.querySelector(".top").textContent = newDigit;
|
||||
el.querySelector(".bottom").textContent = newDigit;
|
||||
const leaf = el.querySelector(".leaf");
|
||||
leaf.textContent = newDigit;
|
||||
leaf.style.animation = "none";
|
||||
void leaf.offsetWidth; // reflow → restart the animation
|
||||
leaf.style.animation = "";
|
||||
}
|
||||
```
|
||||
|
||||
Drive `setFlip` from the same `requestAnimationFrame` + `performance.now()` loop in the cookbook (only call it when the displayed second changes), so the flips fire on real time and never drift.
|
||||
|
||||
## Choosing
|
||||
|
||||
- **Launch / premium:** odometer reel, slow ease, large tabular digits, muted palette.
|
||||
- **Flash sale:** flip card for the mechanical urgency, or reel with a faster roll and a hot accent color on `mm:ss`.
|
||||
- **Stream "starting soon":** either works, but keep it calm — a single reel on `mm:ss` reads better than a busy split-flap for a 5–10 minute hold.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Lower Thirds
|
||||
|
||||
Build a clean, branded lower third — the name/role caption that lives in the bottom band of the frame — that animates in, holds long enough to read, and exits without stealing the shot. The craft is restraint: a crisp reveal, a strong type hierarchy, correct safe-area placement, and one template that drives a whole roster of speakers. For creators producing interviews, panels, podcasts, and broadcast-style titles.
|
||||
|
||||
## When it activates
|
||||
|
||||
- "Make an animated lower third" or "add lower thirds."
|
||||
- "Create a name tag/name lower third" or "do a speaker name overlay."
|
||||
- "Build broadcast titles" or "animate a title/name/role caption."
|
||||
- "Template lower thirds across a list of names."
|
||||
|
||||
## Example prompts
|
||||
|
||||
- "Add a lower third with the speaker's name and role, branded to my colors."
|
||||
- "Build a staggered slide-in name plate for a podcast guest."
|
||||
- "Template one lower-third design across a roster of 12 panelists."
|
||||
|
||||
## What's inside
|
||||
|
||||
- `SKILL.md` — enter/exit animation, in/out timing and dwell, safe-area conventions, and brand styling.
|
||||
- `references/lower-third-component.md` — a reusable lower-third component.
|
||||
|
||||
---
|
||||
Part of **[Short-Form Video Skills](../../)** · Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=tiktok-video-skills&utm_content=skill_footer&utm_term=lower-thirds)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
name: lower-thirds
|
||||
description: This skill should be used when the user asks to "make an animated lower third", "add lower thirds", "create a name tag/name lower third", "build broadcast titles", "animate a title/name/role caption", "do a speaker name overlay", or "template lower thirds across a list of names". Covers enter/exit animation (slide/wipe/mask reveal), in/out timing & dwell, safe-area & broadcast conventions, name-vs-role type hierarchy, brand styling, and rendering one design across many names.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Lower Thirds
|
||||
|
||||
Build a clean, branded lower third — the name/role caption that lives in the bottom band of the frame — that animates in, holds long enough to read, and exits without stealing the shot. The craft is restraint: a crisp reveal, a strong type hierarchy, correct safe-area placement, and one template that drives a whole roster of speakers.
|
||||
|
||||
## When to use
|
||||
|
||||
- Speaker/interview name tags, panel rosters, talking-head captions.
|
||||
- Broadcast-style titles, news straps, podcast/webinar name plates.
|
||||
- Templating one design across a list of names/titles → many clips (the payoff).
|
||||
|
||||
## Anatomy
|
||||
|
||||
A lower third is two text lines plus a graphic bed, placed in the lower band:
|
||||
|
||||
| Element | Role | Type treatment |
|
||||
|---|---|---|
|
||||
| **Name** (primary) | Who | Largest, heaviest, tightest tracking |
|
||||
| **Role/title** (secondary) | What/where | ~45–60% of name size, lighter weight, often a brand accent color |
|
||||
| **Bar / panel / accent** | Reads against any background | Solid or 70–85% opacity; an accent rule ties to brand |
|
||||
|
||||
Keep it to two lines. A name line + role line is the whole job — extra lines erode the hierarchy and crowd the safe area.
|
||||
|
||||
## Three rules that prevent cheap-looking titles
|
||||
|
||||
1. **Never hard-cut a title on or off.** A bare appear/disappear reads as a mistake. Even a 12-frame fade or slide makes it feel produced.
|
||||
2. **Stagger, don't slam.** Animate the bar first, then the name, then the role, each offset by 3–6 frames. Everything arriving at once feels flat; a short cascade feels designed.
|
||||
3. **Hold long enough to read twice.** Roughly 3–5s on screen (read it aloud twice). Too short and it's missed; too long and it nags. Animate in/out, never mid-segment.
|
||||
|
||||
## Enter & exit animations
|
||||
|
||||
Pick one motion language and reuse it. The most reliable, premium-reading reveals:
|
||||
|
||||
| Reveal | Feel | How |
|
||||
|---|---|---|
|
||||
| **Slide + fade** | Clean, default | Translate in ~24–40px while fading; ease-out on enter |
|
||||
| **Wipe bar** | Broadcast | A bar scales/sweeps in (`scaleX`/clip), text fades behind it |
|
||||
| **Mask reveal** | Premium | Clip text to a moving rectangle so letters "wipe on" |
|
||||
|
||||
Use asymmetric easing: a snappy `ease-out` (`cubic-bezier(.22,1,.36,1)`) on enter, a quicker `ease-in` on exit. Exit faster than you entered (~⅔ the duration) so the title gets out of the way.
|
||||
|
||||
```css
|
||||
/* Mask reveal: text wipes on left→right, no plugin needed */
|
||||
.lt-name { clip-path: inset(0 100% 0 0); animation: wipe .5s cubic-bezier(.22,1,.36,1) forwards; }
|
||||
@keyframes wipe { to { clip-path: inset(0 0 0 0); } }
|
||||
```
|
||||
|
||||
## In/out timing & dwell
|
||||
|
||||
Think in three phases. Budget frames, not vibes (at 30fps):
|
||||
|
||||
| Phase | Budget | Note |
|
||||
|---|---|---|
|
||||
| Enter | 12–18f (0.4–0.6s) | Staggered: bar → name → role |
|
||||
| Hold | 90–150f (3–5s) | The readable dwell; scale with line length |
|
||||
| Exit | 8–12f (0.27–0.4s) | Faster than enter |
|
||||
|
||||
In code, drive every state from the current frame (or a paused timeline), not wall-clock time — a video renderer demands deterministic frames. Express enter/hold/exit as a single progress curve so the same component works live in a browser and frame-by-frame in a renderer.
|
||||
|
||||
## Safe area & broadcast conventions
|
||||
|
||||
The lower third must survive every screen and platform UI. Compose inside the **title-safe area** (inner ~90% / margin 5%) and respect platform chrome:
|
||||
|
||||
| Frame | Anchor | Avoid |
|
||||
|---|---|---|
|
||||
| 16:9 (1920×1080) | Left edge at ~5% margin; baseline ~80–85% down | Outer 5% (title-safe), action-safe outer 10% |
|
||||
| 9:16 (1080×1920) | Same left margin; **lift the band up** | Bottom ~18% (captions/UI), top ~12% |
|
||||
| 1:1 (1080×1080) | Lower band, center-left | Outer 5% |
|
||||
|
||||
Anchor to the **left** and grow rightward so names of different lengths stay pinned and never re-center awkwardly. Keep the role line within the same left margin as the name. For 9:16, do not park the title at the very bottom — platform captions and buttons sit there.
|
||||
|
||||
## Type hierarchy & brand styling
|
||||
|
||||
Hierarchy is the whole design. Make the name unmistakably dominant:
|
||||
|
||||
- **Size contrast** ≥ 1.6× (name vs role). If unsure, push the name bigger, not the role.
|
||||
- **Weight + color**: name in bold/near-white; role in a lighter weight and a **brand accent** color (one accent, used consistently).
|
||||
- **Contrast for legibility**: text rides over unknown footage. Use a solid/semi-opaque bar, a subtle scrim, or a soft text shadow so it reads over both bright and dark plates.
|
||||
- **Brand lock**: pin font, accent color, bar style, and corner radius in one theme object so every title across a series matches.
|
||||
|
||||
## Templating one design across a list
|
||||
|
||||
The reason to build this in code: change the text/data, re-export — no re-animating. Make name/role/theme **props**, never hardcoded, then map a roster to instances.
|
||||
|
||||
```jsx
|
||||
const ROSTER = [
|
||||
{ name: "Ada Lovelace", role: "Founder, Analytical Engines" },
|
||||
{ name: "Grace Hopper", role: "Rear Admiral, US Navy" },
|
||||
{ name: "Alan Turing", role: "Mathematician" },
|
||||
];
|
||||
// One <LowerThird> design; map the list → many instances (sequence them in a renderer)
|
||||
{ROSTER.map((p, i) => <LowerThird key={p.name} {...p} theme={BRAND} startFrame={i * 150} />)}
|
||||
```
|
||||
|
||||
In a renderer, make the roster an input prop and render once per row (`--props`) to emit one branded clip per person — same animation, same brand, only the text changes. See `references/lower-third-component.md` for the full runnable component and batch script.
|
||||
|
||||
## Output checklist
|
||||
|
||||
- Two lines only: dominant name, secondary role (≥1.6× size contrast).
|
||||
- Animated in and out (no hard cut); enter staggered bar→name→role; exit faster.
|
||||
- Hold 3–5s (readable twice); motion only at the ends, never mid-segment.
|
||||
- Left-anchored inside title-safe; 9:16 band lifted above the bottom-18% UI zone.
|
||||
- Text legible over any plate (bar/scrim/shadow); one brand accent throughout.
|
||||
- Name/role/theme are props — one template renders the whole roster.
|
||||
|
||||
## 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`.
|
||||
|
||||
The lower third ships as a Remotion composition (`<Composition>` + zod `schema` + `defaultProps`, name/role/theme as props) — enter/dwell/exit all driven by `useCurrentFrame()`, never `Date.now()` / `Math.random()` / timers. Deliverable = `out/*.mp4` + the project (re-render per roster row). 9:16 vertical (1080×1920) is the default.
|
||||
|
||||
**Verify loop — render stills → inspect → encode.** Sample the three phases so you catch a stagger or safe-area bug before encoding (or batching the whole roster).
|
||||
|
||||
```bash
|
||||
# Stills at enter / dwell / exit — WITH SHIPPED PROPS (a real roster row, not defaults)
|
||||
npx remotion still LowerThird out/f-enter.png --frame=10 --props='{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}'
|
||||
npx remotion still LowerThird out/f-dwell.png --frame=75 --props='{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}'
|
||||
npx remotion still LowerThird out/f-exit.png --frame=N --props='{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}'
|
||||
|
||||
# Inspect each PNG — focus the DWELL frame (fully on screen):
|
||||
# - hierarchy holds: name dominant (>=1.6x role), role in brand accent; both fully revealed, not mid-wipe
|
||||
# - brand lock: font, accent color, bar/scrim correct; legible over the plate
|
||||
# - 9:16 safe area: band LIFTED above the bottom ~20-35% UI zone, clear of top ~12% and the right action rail; left-anchored inside title-safe
|
||||
# - enter/exit frames show partial reveal (animation actually runs), not a hard cut
|
||||
|
||||
npx remotion render LowerThird out/lower-third.mp4 --props='{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}'
|
||||
npx remotion render LowerThird out/demo.gif --codec=gif # README proof clip
|
||||
```
|
||||
|
||||
**Batch roster**: verify ONE row via stills *before* rendering all names — catch a layout/overflow bug once, not N times. Use `npx remotion compositions` for `durationInFrames`/`fps` to pick the dwell + exit frames.
|
||||
|
||||
**Before you finish:**
|
||||
1. Stills render cleanly at enter, dwell, and exit — no missing font/brand assets.
|
||||
2. At the dwell frame: name dominant over role, brand accent + bar correct, fully (not partly) revealed.
|
||||
3. The band is inside the 9:16 safe area — lifted above the bottom UI zone, clear of top and right rail — at every checked frame.
|
||||
4. Frame-driven only — no `Date.now()` / `Math.random()` / timers; enter/exit show motion, never a hard cut.
|
||||
5. A real roster row (not `defaultProps`) renders correctly; MP4 encoded, GIF optional.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/lower-third-component.md` — a complete runnable lower-third: a Remotion component with staggered spring enter, readable hold, and faster exit driven purely by frame; a typed theme object for brand lock; the list→many-instances sequencing pattern; a per-row batch render script; plus a dependency-free CSS/HTML version of the same design with mask-reveal and wipe-bar variants.
|
||||
@@ -0,0 +1,268 @@
|
||||
# Lower-Third Component — runnable code
|
||||
|
||||
A complete, production-grade lower third you can drop in. Two paths:
|
||||
|
||||
1. **Remotion** (frame-deterministic, renders to MP4, batches over a roster).
|
||||
2. **CSS/HTML** (no dependencies, for the web or a quick preview) with mask-reveal and wipe-bar variants.
|
||||
|
||||
Both share the same design language: a left-anchored bar, a dominant name, a brand-accent role line, a staggered enter, a readable hold, and a faster exit.
|
||||
|
||||
---
|
||||
|
||||
## 1. Remotion component (props-driven, frame-deterministic)
|
||||
|
||||
The animation is a pure function of `frame`, so it renders identically every time and stays in sync with the renderer. Enter staggers the bar, name, then role; exit is computed from the end of the clip so the title leaves faster than it arrived.
|
||||
|
||||
```tsx
|
||||
// LowerThird.tsx
|
||||
import {
|
||||
AbsoluteFill, Sequence, interpolate, spring,
|
||||
useCurrentFrame, useVideoConfig,
|
||||
} from "remotion";
|
||||
|
||||
// --- Brand lock: change once, every title matches -------------------------
|
||||
export type Theme = {
|
||||
font: string;
|
||||
bar: string; // panel/bar color
|
||||
name: string; // primary text color
|
||||
role: string; // accent color for the role line
|
||||
radius: number;
|
||||
marginPct: number; // left/safe margin as % of width
|
||||
};
|
||||
export const BRAND: Theme = {
|
||||
font: "Inter, system-ui, sans-serif",
|
||||
bar: "rgba(12,14,22,0.86)",
|
||||
name: "#FFFFFF",
|
||||
role: "#6EE7F0", // single brand accent
|
||||
radius: 10,
|
||||
marginPct: 5,
|
||||
};
|
||||
|
||||
export type LowerThirdProps = {
|
||||
name: string;
|
||||
role: string;
|
||||
theme?: Theme;
|
||||
holdSeconds?: number; // readable dwell, default 4s
|
||||
};
|
||||
|
||||
// Staggered enter via spring; exit via a quick fade/slide near the end.
|
||||
const useEnterExit = (delayFrames: number, holdSeconds: number) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
|
||||
// ENTER: spring 0→1, offset by delayFrames (the cascade)
|
||||
const enter = spring({
|
||||
frame: frame - delayFrames,
|
||||
fps,
|
||||
config: { damping: 200, stiffness: 180, mass: 0.6 },
|
||||
});
|
||||
|
||||
// EXIT: faster than enter — last 10 frames of the clip
|
||||
const exitStart = durationInFrames - 10;
|
||||
const exit = interpolate(frame, [exitStart, durationInFrames], [1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
const opacity = enter * exit;
|
||||
const x = interpolate(enter, [0, 1], [-32, 0]); // slide in from left
|
||||
return { opacity, x };
|
||||
};
|
||||
|
||||
const Line: React.FC<{
|
||||
delay: number; holdSeconds: number; children: React.ReactNode;
|
||||
size: number; weight: number; color: string; tracking?: number;
|
||||
}> = ({ delay, holdSeconds, children, size, weight, color, tracking = 0 }) => {
|
||||
const { opacity, x } = useEnterExit(delay, holdSeconds);
|
||||
return (
|
||||
<div style={{
|
||||
opacity,
|
||||
transform: `translateX(${x}px)`,
|
||||
fontSize: size, fontWeight: weight, color,
|
||||
letterSpacing: tracking, lineHeight: 1.15, whiteSpace: "nowrap",
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const LowerThird: React.FC<LowerThirdProps> = ({
|
||||
name, role, theme = BRAND, holdSeconds = 4,
|
||||
}) => {
|
||||
const { width, height } = useVideoConfig();
|
||||
const bar = useEnterExit(0, holdSeconds); // bar leads the cascade
|
||||
|
||||
const left = (width * theme.marginPct) / 100;
|
||||
const baseline = height * 0.80; // sit ~80% down, inside title-safe
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ fontFamily: theme.font }}>
|
||||
<div style={{
|
||||
position: "absolute", left, top: baseline,
|
||||
opacity: bar.opacity,
|
||||
transform: `translateX(${bar.x}px)`,
|
||||
}}>
|
||||
{/* panel behind text guarantees legibility over any plate */}
|
||||
<div style={{
|
||||
background: theme.bar, borderRadius: theme.radius,
|
||||
padding: "14px 22px 16px",
|
||||
borderLeft: `4px solid ${theme.role}`, // accent rule ties to brand
|
||||
backdropFilter: "blur(6px)",
|
||||
}}>
|
||||
<Line delay={4} holdSeconds={holdSeconds} size={46} weight={800} color={theme.name} tracking={-0.5}>
|
||||
{name}
|
||||
</Line>
|
||||
<Line delay={8} holdSeconds={holdSeconds} size={26} weight={500} color={theme.role}>
|
||||
{role}
|
||||
</Line>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Why each choice matters:
|
||||
- **`spring` per line, offset by `delay`** gives the bar→name→role cascade. Same offsets everywhere keep titles consistent.
|
||||
- **Exit derived from `durationInFrames`** means any clip length exits cleanly without per-instance tuning, and it's quicker than the enter (10f vs the spring's settle).
|
||||
- **`whiteSpace: nowrap` + left anchor** keeps long and short names pinned to the same edge — no awkward re-centering across a roster.
|
||||
- **`borderLeft` accent + semi-opaque panel** is the brand tie plus the legibility guarantee over unknown footage.
|
||||
|
||||
### Roster → many instances (one design, a whole panel)
|
||||
|
||||
Sequence the same component down a list. Each person gets a slot; only the text changes.
|
||||
|
||||
```tsx
|
||||
// Roster.tsx
|
||||
import { Series } from "remotion";
|
||||
import { LowerThird, BRAND } from "./LowerThird";
|
||||
|
||||
const ROSTER = [
|
||||
{ name: "Ada Lovelace", role: "Founder, Analytical Engines" },
|
||||
{ name: "Grace Hopper", role: "Rear Admiral, US Navy" },
|
||||
{ name: "Alan Turing", role: "Mathematician" },
|
||||
];
|
||||
|
||||
export const Roster: React.FC = () => (
|
||||
<Series>
|
||||
{ROSTER.map((p) => (
|
||||
<Series.Sequence key={p.name} durationInFrames={150 /* ~5s @30fps */}>
|
||||
<LowerThird name={p.name} role={p.role} theme={BRAND} />
|
||||
</Series.Sequence>
|
||||
))}
|
||||
</Series>
|
||||
);
|
||||
```
|
||||
|
||||
### Batch: one branded clip per person
|
||||
|
||||
Make the roster an input prop and render once per row, so each speaker becomes a standalone overlay clip — identical animation and brand, only the text differs.
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# render one transparent-friendly lower-third clip per person in roster.json
|
||||
# roster.json: [{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}, ...]
|
||||
set -euo pipefail
|
||||
mkdir -p out
|
||||
jq -c '.[]' roster.json | while read -r row; do
|
||||
slug=$(echo "$row" | jq -r '.name' | tr '[:upper:] ' '[:lower:]-')
|
||||
npx remotion render LowerThird "out/${slug}.mp4" --props="$row"
|
||||
done
|
||||
```
|
||||
|
||||
For overlay-ready output with transparency, render the `LowerThird` composition (transparent background) to a codec that keeps alpha, e.g. `--codec=prores --prores-profile=4444`, or `--image-format=png` for a PNG sequence.
|
||||
|
||||
---
|
||||
|
||||
## 2. CSS / HTML version (no dependencies)
|
||||
|
||||
The same design without a framework — for the web, OBS browser sources, or a fast look-test. Includes both a **slide+fade** default and a **mask-reveal** name, plus a **wipe-bar** variant.
|
||||
|
||||
```html
|
||||
<div class="lt" style="--accent:#6EE7F0">
|
||||
<div class="lt-bar">
|
||||
<div class="lt-name">Ada Lovelace</div>
|
||||
<div class="lt-role">Founder, Analytical Engines</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:root { font-family: Inter, system-ui, sans-serif; }
|
||||
|
||||
.lt {
|
||||
position: absolute; left: 5%; top: 80%; /* title-safe, left-anchored */
|
||||
}
|
||||
.lt-bar {
|
||||
display: inline-block;
|
||||
background: rgba(12,14,22,0.86);
|
||||
backdrop-filter: blur(6px);
|
||||
border-left: 4px solid var(--accent);
|
||||
border-radius: 10px;
|
||||
padding: 14px 22px 16px;
|
||||
/* bar leads the cascade: slide + fade in, then a faster fade out */
|
||||
animation: lt-in .5s cubic-bezier(.22,1,.36,1) both,
|
||||
lt-out .3s ease-in 4.5s forwards; /* hold ~4s, exit faster */
|
||||
}
|
||||
.lt-name {
|
||||
font-size: 46px; font-weight: 800; color: #fff; letter-spacing: -.5px;
|
||||
line-height: 1.15; white-space: nowrap;
|
||||
/* MASK REVEAL: letters wipe on left→right, staggered after the bar */
|
||||
clip-path: inset(0 100% 0 0);
|
||||
animation: lt-wipe .5s cubic-bezier(.22,1,.36,1) .12s forwards;
|
||||
}
|
||||
.lt-role {
|
||||
font-size: 26px; font-weight: 500; color: var(--accent);
|
||||
line-height: 1.15; white-space: nowrap;
|
||||
opacity: 0;
|
||||
animation: lt-fade .4s ease-out .22s forwards; /* role arrives last */
|
||||
}
|
||||
|
||||
@keyframes lt-in { from { opacity: 0; transform: translateX(-32px); }
|
||||
to { opacity: 1; transform: none; } }
|
||||
@keyframes lt-out { to { opacity: 0; transform: translateX(-16px); } }
|
||||
@keyframes lt-wipe { to { clip-path: inset(0 0 0 0); } }
|
||||
@keyframes lt-fade { to { opacity: 1; } }
|
||||
|
||||
/* Mobile/9:16: lift the band above the bottom UI/caption zone */
|
||||
@media (max-aspect-ratio: 1/1) { .lt { top: 70%; } }
|
||||
</style>
|
||||
```
|
||||
|
||||
### Wipe-bar variant (broadcast feel)
|
||||
|
||||
Swap the bar animation so a colored bar sweeps in and the text rides behind it:
|
||||
|
||||
```css
|
||||
.lt-bar.wipe {
|
||||
position: relative; overflow: hidden;
|
||||
animation: lt-in .45s cubic-bezier(.22,1,.36,1) both,
|
||||
lt-out .3s ease-in 4.5s forwards;
|
||||
}
|
||||
.lt-bar.wipe::before {
|
||||
content: ""; position: absolute; inset: 0;
|
||||
background: var(--accent);
|
||||
transform-origin: left;
|
||||
animation: lt-sweep .55s cubic-bezier(.76,0,.24,1) both; /* sweep across, then reveal */
|
||||
}
|
||||
@keyframes lt-sweep { 0% { transform: scaleX(0); } 60% { transform: scaleX(1); }
|
||||
100% { transform: scaleX(0); transform-origin: right; } }
|
||||
```
|
||||
|
||||
The CSS clock (`animation`) is fine for live/web playback. For a deterministic MP4, prefer the Remotion path above — a frame renderer and CSS timers disagree, which causes flicker or desync at export.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Need | Knob |
|
||||
|---|---|
|
||||
| Slower/faster reveal | enter duration 12–18f / `.5s` |
|
||||
| Longer dwell | `holdSeconds` / the `4.5s` exit delay |
|
||||
| Bigger name dominance | name `fontSize` (keep ≥1.6× the role) |
|
||||
| Rebrand everything | the `BRAND` theme object / `--accent` |
|
||||
| 9:16 placement | `baseline 0.80→0.70` / the media query |
|
||||
|
||||
---
|
||||
## 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 generate branded lower-thirds from a list of names/titles, one-click — change the text/data and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=tiktok-video-skills&utm_content=ref_footer&utm_term=lower-thirds)
|
||||
@@ -0,0 +1,24 @@
|
||||
# Short-Form Video
|
||||
|
||||
Engineer a 9:16 video that survives the swipe. Short-form is a retention machine, not a small launch film: a hook that earns the first second, pattern interrupts that reset attention every few seconds, and often a loop that buys watch-time past 100%. The craft is timing, not polish. For creators making Reels, TikToks, and YouTube Shorts (7–60s, vertical).
|
||||
|
||||
## When it activates
|
||||
|
||||
- "Make a Reels/TikTok/YouTube Shorts video" or "build a short-form video template."
|
||||
- "Write a hook for a short" or "fix the first 3 seconds."
|
||||
- "Edit for retention," "add pattern interrupts," or "pace cuts for a vertical video."
|
||||
- "Make a video loop" or "set up 9:16 safe areas."
|
||||
|
||||
## Example prompts
|
||||
|
||||
- "Build a reusable 9:16 short-form template — one design, many topics."
|
||||
- "Fix the drop-off in this Reel: tighten pacing and add pattern interrupts."
|
||||
- "Write three hook options for a 30-second educational short."
|
||||
|
||||
## What's inside
|
||||
|
||||
- `SKILL.md` — the retention arc, hook grammar, and pacing rules.
|
||||
- `references/retention-pacing.md` — deeper guidance on retention editing and pacing.
|
||||
|
||||
---
|
||||
Part of **[Short-Form Video Skills](../../)** · Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=tiktok-video-skills&utm_content=skill_footer&utm_term=short-form-video)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
@@ -0,0 +1,155 @@
|
||||
---
|
||||
name: short-form-video
|
||||
description: This skill should be used when the user asks to "make a Reels/TikTok/YouTube Shorts video", "build a short-form video template", "write a hook for a short", "edit for retention", "add pattern interrupts", "make a video loop", "fix the first 3 seconds", "pace cuts for a vertical video", or "set up 9:16 safe areas". Covers the hook→retention→loop grammar of vertical short-form (distinct from a launch hype film).
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Short-Form Video
|
||||
|
||||
Engineer a 9:16 video that survives the swipe. Short-form is not a small launch film — it is a retention machine: a hook that earns the first second, pattern interrupts that reset attention every few seconds, and often a loop that buys watch-time past 100%. The craft is timing, not polish.
|
||||
|
||||
## When to use
|
||||
|
||||
- Reels / TikTok / YouTube Shorts (7–60s, vertical 9:16).
|
||||
- Retention editing: fixing drop-off, adding pattern interrupts, tightening pacing.
|
||||
- Hooks, seamless loops, repeatable short-form templates (one design, many topics).
|
||||
|
||||
## Story structure (the retention contract)
|
||||
|
||||
Short-form storytelling is a contract with the viewer's thumb: every second must re-earn the next one. The narrative is shaped *backwards from the retention curve*, not forwards from an intro. Lock these before touching the timeline.
|
||||
|
||||
- **One idea per video.** A short carries exactly one payoff. A second idea splits attention and the curve sags in the middle — make it a second video instead. If you can't name the single takeaway in a sentence, you don't have a short yet.
|
||||
- **Open a loop in the first ~3s, close it last.** The hook poses a question or gap (the open loop); the payoff answers it. The viewer stays because the loop is open — so never answer it early, and never bury the payoff under setup.
|
||||
- **No dead air.** Every beat either advances the idea or resets attention. Cut anything that does neither. Silence, slow ramps, throat-clearing intros ("hey guys, in this video…") are where viewers swipe.
|
||||
- **Why the curve, not the cut, is the unit of work.** The algorithm distributes whatever holds the per-second retention curve flat; a "good" edit that flattens the curve loses to an ugly one that keeps it up. You are editing the *curve*, and the hook gates everything after it — fix it first.
|
||||
|
||||
The narrative spine (the `Hook → Setup → Body → Payoff/loop` arc is budgeted in the table below):
|
||||
|
||||
| Move | Story job | Failure if missed |
|
||||
|---|---|---|
|
||||
| Hook | Open the loop; promise the payoff | Swipe in first 3s |
|
||||
| Tension hold | Keep the gap open through the body | Flat middle, mid-drop |
|
||||
| Payoff | Close the loop — the one idea, delivered | "Wasted my time," no share |
|
||||
| Loop / CTA | Feed back to frame 0, or one clear ask | No re-watch, no action |
|
||||
|
||||
(`The retention arc` table below assigns seconds to each move; this section is the narrative logic and ordering behind it. `Hook grammar` lists the hook archetypes.)
|
||||
|
||||
## The one metric that drives everything
|
||||
|
||||
**Retention, not production value.** The algorithm distributes whatever holds viewers; ~87% decide to stay or swipe inside 3 seconds, and the threshold for distribution sits near **70%+ average view-through** (Shorts ~73%, TikTok ~78%, Reels ~65%). Every decision below trades against that curve. A flawless shot that bores at 0:04 loses to an ugly one that holds.
|
||||
|
||||
## The retention arc
|
||||
|
||||
| Beat | Job | Budget (of a 20s short) |
|
||||
|---|---|---|
|
||||
| Hook (0–1s) | One frame + one line that creates an open loop | 0–1s |
|
||||
| Setup | Pay off *why watch* — promise, stakes, or the gap | 1–4s |
|
||||
| Body | Deliver value in beats; one idea per beat | 4–17s |
|
||||
| Payoff / loop | Close the loop — or feed it back to frame 0 | 17–20s |
|
||||
|
||||
Scale the *body*, never the hook. A 60s short has a longer body and more beats — the same 1-second hook.
|
||||
|
||||
## Hook grammar (first ~1s)
|
||||
|
||||
The hook is a sentence + a visual that opens a curiosity gap the brain needs closed. Pick one pattern and commit:
|
||||
|
||||
| Hook type | Shape | Best for |
|
||||
|---|---|---|
|
||||
| Open loop | "The reason your X keeps Y…" (answer delayed) | most content |
|
||||
| Direct promise | "3 ways to ___ in 30 seconds" | educational / listicle |
|
||||
| Pattern interrupt | snap-zoom / whip-pan / visual mismatch in frame 1 | entertainment |
|
||||
| Negation | "Stop doing ___" / "You're ___ wrong" | strong opinions |
|
||||
|
||||
Two rules: the hook line is **on-screen as text by frame 1** (85% watch muted), and the most striking *visual* is also the first frame — never a slow ramp-in. Hold a strong opening frame; do not fade up from black.
|
||||
|
||||
## Pattern interrupts & cut rhythm
|
||||
|
||||
A pattern interrupt is any change that resets attention: a hard cut, zoom punch, b-roll insert, sound effect, caption pop, or angle change. In short-form, density is high — a *visual change every ~2–4 seconds*; videos with an interrupt roughly every 4s hold ~58% vs ~41% for a static talking head. The killer is a flat middle: TikTok throttles distribution when the watch-through curve flattens, so never stitch the body from low-energy takes.
|
||||
|
||||
```js
|
||||
// Drive every interrupt off the FRAME, not wall-clock — renders deterministically.
|
||||
import { useCurrentFrame } from "remotion";
|
||||
const fps = 30;
|
||||
const cuts = [0, 1.2, 3.0, 5.4, 8.0, 11.2, 14.0]; // seconds — uneven on purpose
|
||||
const frame = useCurrentFrame();
|
||||
const shot = cuts.findIndex((t, i) => frame < (cuts[i + 1] ?? Infinity) * fps);
|
||||
```
|
||||
|
||||
Vary the interval — a metronome reads as boredom; uneven cuts read as momentum. A punch-in zoom is the cheapest interrupt that needs no new footage:
|
||||
|
||||
```jsx
|
||||
import { useCurrentFrame, interpolate, spring, useVideoConfig } from "remotion";
|
||||
const PunchIn = ({ at, children }) => {
|
||||
const frame = useCurrentFrame(); const { fps } = useVideoConfig();
|
||||
const s = spring({ frame: frame - at * fps, fps, config: { damping: 12, stiffness: 200 } });
|
||||
const scale = interpolate(s, [0, 1], [1, 1.12]); // snap to 112% on the beat
|
||||
return <div style={{ transform: `scale(${scale})`, transformOrigin: "50% 45%" }}>{children}</div>;
|
||||
};
|
||||
```
|
||||
|
||||
## Captions
|
||||
|
||||
Most viewers watch muted, so on-screen captions are not optional — they carry the content and double as a pattern interrupt when each phrase pops in. Keep 4–7 words per line, high contrast, inside the safe area. **Do not re-implement caption animation here** — use the `caption-animation` skill for word-by-word timing, karaoke highlight, and styling; this skill only places captions on the retention timeline and inside the 9:16 safe zone.
|
||||
|
||||
## Loops & re-watch
|
||||
|
||||
A seamless loop counts each re-watch as fresh watch-time — a clean 8s loop watched 3× is 300% view-through, the strongest possible signal. Two techniques: match the **last frame to the first** (same composition, position, color) so the cut is invisible, and write a **sentence loop** — phrase the hook so the payoff feeds back into frame 0 ("…and that's why I never — " → loops to the start). Best length for re-watch is ~7–15s.
|
||||
|
||||
## 9:16 safe areas
|
||||
|
||||
Compose inside the universal safe zone so platform UI never covers text or faces.
|
||||
|
||||
| Zone | Pixels (1080×1920) | Avoid |
|
||||
|---|---|---|
|
||||
| Universal safe (all platforms) | center **900×1400** | anything critical outside it |
|
||||
| Top | keep clear ~120px | profile / sound UI |
|
||||
| Bottom | keep clear ~320px | captions, CTA, hashtags, audio bar |
|
||||
| Right | keep clear ~120px | like / comment / share rail |
|
||||
|
||||
Put the hook text and key subject in the center 900×1400 box. Reserve the bottom ~320px even if it looks empty in the editor — that is where the platform paints its own captions and buttons.
|
||||
|
||||
## Output checklist
|
||||
|
||||
- Hook line is on-screen text by frame 1; strongest visual is the first frame (no fade-up).
|
||||
- One open loop / promise set in the first ~4s.
|
||||
- A visual pattern interrupt every ~2–4s; intervals uneven; no flat middle.
|
||||
- Captions present (via `caption-animation`), 4–7 words, inside safe area.
|
||||
- Loop closed — matched first/last frame or a sentence that feeds frame 0.
|
||||
- Composed inside the 900×1400 center box; bottom 320px / top 120px / right 120px kept clear.
|
||||
- Every animated value derived from `useCurrentFrame()` — no CSS/library timers.
|
||||
|
||||
## 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`.
|
||||
|
||||
The short ships as a Remotion composition (`<Composition>` + zod `schema` + `defaultProps`) — every cut, punch-in, and interrupt driven by `useCurrentFrame()`, never `Date.now()` / `Math.random()` / timers. Deliverable = `out/*.mp4` + the project (re-render per topic). 9:16 vertical (1080×1920) is the default.
|
||||
|
||||
**Verify loop — render stills → inspect → encode.** Three frames test the three things that make or break retention: the hook, the pacing, and the loop seam.
|
||||
|
||||
```bash
|
||||
# Stills at hook / mid-body / last frame — WITH SHIPPED PROPS (the real topic, not defaults)
|
||||
npx remotion still Short out/f-hook.png --frame=10 --props='{"topic":"...","hook":"..."}' # ~first 3s reads instantly
|
||||
npx remotion still Short out/f-mid.png --frame=N --props='{"topic":"...","hook":"..."}'
|
||||
npx remotion still Short out/f-end.png --frame=L --props='{"topic":"...","hook":"..."}' # L = durationInFrames-1
|
||||
|
||||
# Inspect each PNG:
|
||||
# - HOOK frame (~frame 0-ish / first 3s): hook line is on-screen text and reads instantly; strongest visual already up (no fade-up)
|
||||
# - LOOP seam: compare f-hook(frame 0) vs f-end(last frame) — same composition/position/color for an invisible loop cut
|
||||
# - 9:16 safe area: hook + key subject inside the center 900x1400 box; clear of top ~12%, bottom ~20-35% (captions/CTA/audio), right action rail
|
||||
|
||||
npx remotion render Short out/short.mp4 --props='{"topic":"...","hook":"..."}' # encode once stills verify
|
||||
npx remotion render Short out/demo.gif --codec=gif # README proof clip
|
||||
```
|
||||
|
||||
**Per topic / batch**: verify ONE topic via stills *before* rendering all of them. Use `npx remotion compositions` for `durationInFrames`/`fps` to pick the mid + last frames.
|
||||
|
||||
**Before you finish:**
|
||||
1. Stills render cleanly at the hook, mid, and last frame — no errors.
|
||||
2. The hook frame reads instantly (on-screen text + strongest visual up front, no fade-up); first vs last frame match for a clean loop seam.
|
||||
3. Hook + subject + captions sit inside the 9:16 center 900×1400 safe box at every checked frame.
|
||||
4. Frame-driven only — no `Date.now()` / `Math.random()` / timers; cuts uneven, no flat middle.
|
||||
5. A real topic's shipped props (not `defaultProps`) render correctly; MP4 encoded, GIF optional.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/retention-pacing.md` — a complete runnable Remotion `<Short>` composition: a frame-driven shot scheduler with uneven cuts, the PunchIn pattern interrupt, an on-screen hook, a 9:16 safe-area overlay, and a seamless first/last-frame loop. Plus a hook-pattern library, a retention-curve debugging method, per-platform safe-zone maps, and the template×topic batch-render pattern.
|
||||
@@ -0,0 +1,209 @@
|
||||
# Retention Pacing — Short-Form Video Reference
|
||||
|
||||
Deep reference for the `short-form-video` skill: a full runnable Remotion short, a hook library, a retention-debugging method, per-platform safe zones, and batch output. All timing is frame-driven so renders are deterministic.
|
||||
|
||||
## 1. A complete `<Short>` composition
|
||||
|
||||
One self-contained vertical short: frame-driven shot scheduler with uneven cuts, a punch-in pattern interrupt, an on-screen hook, a 9:16 safe-area overlay (dev only), and a seamless loop. Topic content is a prop, so the same file renders many shorts.
|
||||
|
||||
```tsx
|
||||
// Short.tsx — 1080×1920, 30fps. Register at 600 frames (20s) for a clean loop.
|
||||
import {
|
||||
AbsoluteFill, useCurrentFrame, useVideoConfig,
|
||||
interpolate, spring, Sequence, Img,
|
||||
} from "remotion";
|
||||
|
||||
type Beat = { at: number; text: string; src?: string }; // at = seconds
|
||||
export type ShortProps = {
|
||||
hook: string; // on-screen by frame 1
|
||||
beats: Beat[]; // body, one idea each
|
||||
loopText: string; // sentence that feeds back to frame 0
|
||||
};
|
||||
|
||||
// --- frame-driven shot scheduler: uneven cuts read as momentum ---
|
||||
const useShot = (cuts: number[]) => {
|
||||
const { fps } = useVideoConfig();
|
||||
const frame = useCurrentFrame();
|
||||
let i = 0;
|
||||
for (let k = 0; k < cuts.length; k++) if (frame >= cuts[k] * fps) i = k;
|
||||
const startF = cuts[i] * fps;
|
||||
const endF = (cuts[i + 1] ?? cuts[i] + 2) * fps;
|
||||
const local = (frame - startF) / (endF - startF); // 0→1 within shot
|
||||
return { index: i, local, startF };
|
||||
};
|
||||
|
||||
// --- punch-in: the cheapest pattern interrupt, snaps on the cut ---
|
||||
const PunchIn: React.FC<{ startF: number; to?: number; children: React.ReactNode }> = ({
|
||||
startF, to = 1.1, children,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const s = spring({ frame: frame - startF, fps, config: { damping: 13, stiffness: 220 } });
|
||||
const scale = interpolate(s, [0, 1], [1, to]);
|
||||
return <AbsoluteFill style={{ transform: `scale(${scale})`, transformOrigin: "50% 45%" }}>{children}</AbsoluteFill>;
|
||||
};
|
||||
|
||||
// --- hook: text present from frame 0, micro-overshoot, no fade-up ---
|
||||
const Hook: React.FC<{ text: string }> = ({ text }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const pop = spring({ frame, fps, config: { damping: 9, stiffness: 260 } });
|
||||
const scale = interpolate(pop, [0, 1], [0.9, 1]);
|
||||
return (
|
||||
<div style={{
|
||||
position: "absolute", top: 360, left: 90, right: 90, // inside 900-wide safe box
|
||||
transform: `scale(${scale})`, transformOrigin: "0 0",
|
||||
fontFamily: "Inter, sans-serif", fontWeight: 800, fontSize: 76, lineHeight: 1.05,
|
||||
color: "#fff", textShadow: "0 4px 24px rgba(0,0,0,.55)",
|
||||
}}>{text}</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Short: React.FC<ShortProps> = ({ hook, beats, loopText }) => {
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
const frame = useCurrentFrame();
|
||||
const cuts = [0, ...beats.map((b) => b.at)]; // hook shot + one per beat
|
||||
const { index, startF } = useShot(cuts);
|
||||
const current = index === 0 ? null : beats[index - 1];
|
||||
|
||||
// seamless loop: match the LAST 0.5s back toward the opening frame
|
||||
const tailF = durationInFrames - frame;
|
||||
const loopFade = interpolate(tailF, [0, fps * 0.5], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "#0b0b0f" }}>
|
||||
<PunchIn startF={startF}>
|
||||
{current?.src && <Img src={current.src} style={{ width: "100%", height: "100%", objectFit: "cover" }} />}
|
||||
</PunchIn>
|
||||
|
||||
{index === 0 && <Hook text={hook} />}
|
||||
|
||||
{/* body caption — placement only; styling/word-timing belongs to caption-animation */}
|
||||
{current && (
|
||||
<div style={{
|
||||
position: "absolute", left: 90, right: 90, bottom: 380, // above the 320px bottom UI band
|
||||
fontFamily: "Inter, sans-serif", fontWeight: 700, fontSize: 60, color: "#fff",
|
||||
textAlign: "center", textShadow: "0 3px 18px rgba(0,0,0,.6)",
|
||||
}}>{current.text}</div>
|
||||
)}
|
||||
|
||||
{/* loop sentence rises as the clip ends, easing the eye back to frame 0 */}
|
||||
<div style={{
|
||||
position: "absolute", left: 90, right: 90, bottom: 380, opacity: 1 - loopFade,
|
||||
fontFamily: "Inter, sans-serif", fontWeight: 800, fontSize: 64, color: "#fff", textAlign: "center",
|
||||
}}>{loopText}</div>
|
||||
|
||||
<SafeZone />{/* remove for final render */}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Register it with a duration that *is* the loop length:
|
||||
|
||||
```ts
|
||||
// Root.tsx
|
||||
import { Composition } from "remotion";
|
||||
import { Short } from "./Short";
|
||||
export const Root = () => (
|
||||
<Composition
|
||||
id="Short" component={Short}
|
||||
durationInFrames={600} fps={30} width={1080} height={1920}
|
||||
defaultProps={{
|
||||
hook: "You're editing Shorts wrong —",
|
||||
beats: [
|
||||
{ at: 1.2, text: "Cut every 2–4 seconds", src: "b1.jpg" },
|
||||
{ at: 4.0, text: "Never on a fixed grid", src: "b2.jpg" },
|
||||
{ at: 7.5, text: "Kill the flat middle", src: "b3.jpg" },
|
||||
{ at: 11.0, text: "Match last frame to first", src: "b4.jpg" },
|
||||
],
|
||||
loopText: "…and that's why mine loop forever",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
## 2. The 9:16 safe-area overlay (dev-only)
|
||||
|
||||
Render this on top while editing; delete it before the final export.
|
||||
|
||||
```tsx
|
||||
import { AbsoluteFill } from "remotion";
|
||||
export const SafeZone: React.FC = () => (
|
||||
<AbsoluteFill style={{ pointerEvents: "none" }}>
|
||||
{/* universal safe box: center 900×1400 in 1080×1920 */}
|
||||
<div style={{ position: "absolute", left: 90, top: 260, width: 900, height: 1400, outline: "2px dashed #39d", opacity: .6 }} />
|
||||
{/* platform UI bands to keep clear */}
|
||||
<div style={{ position: "absolute", top: 0, left: 0, right: 0, height: 120, background: "rgba(255,0,0,.12)" }} />
|
||||
<div style={{ position: "absolute", bottom: 0, left: 0, right: 0, height: 320, background: "rgba(255,0,0,.12)" }} />
|
||||
<div style={{ position: "absolute", bottom: 320, top: 120, right: 0, width: 120, background: "rgba(255,128,0,.10)" }} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
```
|
||||
|
||||
## 3. Hook pattern library
|
||||
|
||||
The hook is a sentence that opens a gap the brain must close, plus the most striking frame as frame 0. Copyable shapes:
|
||||
|
||||
| Pattern | Template | Note |
|
||||
|---|---|---|
|
||||
| Open loop | "The real reason your ___ keeps ___ …" | answer withheld until payoff |
|
||||
| Direct promise | "___ in 30 seconds, no ___ needed" | name the payoff + the timebox |
|
||||
| Negation | "Stop ___. You're doing it backwards." | strong-opinion content |
|
||||
| Pattern interrupt | snap-zoom / whip / hard visual mismatch | entertainment, no text needed |
|
||||
| Stakes | "This ___ cost me $___ — don't repeat it" | story / case-study |
|
||||
| Listicle | "3 ___ that ___ (the 3rd one ___ )" | tees up a forward reference |
|
||||
|
||||
Rules that matter more than the wording: (1) the hook text is on-screen by frame 1 — most viewers are muted; (2) frame 0 is the best frame, never a fade-up from black; (3) one gap only — stacking two hooks closes neither.
|
||||
|
||||
## 4. Retention-curve debugging
|
||||
|
||||
Treat the per-second retention graph (TikTok Analytics, YT Studio "Audience retention", Reels) as a profiler.
|
||||
|
||||
| Symptom on the curve | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| Cliff at 0:00–0:02 | weak hook / slow first frame | new hook line; cut the ramp-in; lead with the best frame |
|
||||
| Steady gentle decline | normal — leave it | — |
|
||||
| Flat-then-drop in the middle | low-energy body, cuts too sparse | add interrupts; tighten cut spacing to 2–4s |
|
||||
| Bump back up near the end | viewers are looping | lean in — tighten the loop, shorten total length |
|
||||
| Spike at one timestamp | a re-watched moment | front-load that beat or make it the hook |
|
||||
|
||||
Target ≥70% average view-through. Iterate the hook first — it moves the curve more than any other edit because it gates everything after it.
|
||||
|
||||
## 5. Per-platform safe zones
|
||||
|
||||
All on a 1080×1920 canvas. The universal box (center 900×1400) is the intersection; design inside it and one master fits every platform.
|
||||
|
||||
| Platform | Top clear | Bottom clear | Right clear | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Universal (use this) | ~120px | ~320px | ~120px | center 900×1400 holds on all |
|
||||
| TikTok | ~108px | ~320px | ~120px | caption + button rail at bottom-right |
|
||||
| Instagram Reels | ~120px | ~310px | ~84px | audio bar + caption bottom |
|
||||
| YouTube Shorts | ~120px | ~300px | ~60px | channel + subscribe bottom band |
|
||||
| TikTok Ads | ~108px | ~370px | ~120px | extra room for the CTA button |
|
||||
|
||||
When unsure, design to the *largest* margin (TikTok Ads bottom 370px) and nothing ever clips.
|
||||
|
||||
## 6. Template × topic — batch output
|
||||
|
||||
The payoff of code-driven shorts: lock the design once, swap only the props, render N videos. Make topic content a prop (already done above), keep one theme object, and loop over JSON files.
|
||||
|
||||
```jsx
|
||||
// theme.ts — one source of truth so 50 shorts stay on-brand
|
||||
export const theme = { font: "Inter", hookSize: 76, bodySize: 60, bg: "#0b0b0f", fg: "#fff", accent: "#39d" };
|
||||
```
|
||||
|
||||
```bash
|
||||
# render the same Short template for every topic file in /topics
|
||||
for f in topics/*.json; do
|
||||
name=$(basename "$f" .json)
|
||||
npx remotion render Short "out/$name.mp4" --props="$f"
|
||||
done
|
||||
```
|
||||
|
||||
Each `topics/*.json` is one `ShortProps` object (`hook`, `beats`, `loopText`). Keep the hook line, cut points, and loop sentence per-topic; everything else stays fixed so the series reads as one brand.
|
||||
|
||||
---
|
||||
## 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 batch-produce short-form videos from one template × data — change the text/data and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=tiktok-video-skills&utm_content=ref_footer&utm_term=short-form-video)
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/caption-animation
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/countdown-video
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/lower-thirds
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,62 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,38 @@
|
||||
# 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>`.
|
||||
@@ -0,0 +1,37 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,34 @@
|
||||
# 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>
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
# 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).
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/short-form-video
|
||||
@@ -1,2 +0,0 @@
|
||||
node_modules
|
||||
out/
|
||||
@@ -1,461 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,21 +0,0 @@
|
||||
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.
|
||||
@@ -1,41 +0,0 @@
|
||||
# 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.
|
Before Width: | Height: | Size: 545 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.4 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 480 KiB |
@@ -1,30 +0,0 @@
|
||||
// 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
@@ -1,550 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"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
@@ -1,157 +0,0 @@
|
||||
// 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();
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"caption-animation": {
|
||||
"source": "iart-ai/tiktok-video-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/caption-animation/SKILL.md",
|
||||
"computedHash": "e42c4828d3c6115dfb4d8fd3c3fd485b8e6a89c35a8835eee6dae39fb83210a4"
|
||||
},
|
||||
"countdown-video": {
|
||||
"source": "iart-ai/tiktok-video-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/countdown-video/SKILL.md",
|
||||
"computedHash": "dafb9127ededa76d0df08be73adc46e18d6ec3955153f6ecfd0f928984ce95b2"
|
||||
},
|
||||
"lower-thirds": {
|
||||
"source": "iart-ai/tiktok-video-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/lower-thirds/SKILL.md",
|
||||
"computedHash": "502c1a8a491e477dd855eb1a45469c09f6ff46a72ea841838d358d0a6a9fd790"
|
||||
},
|
||||
"short-form-video": {
|
||||
"source": "iart-ai/tiktok-video-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/short-form-video/SKILL.md",
|
||||
"computedHash": "893ea19615f3199159b9fe94f05e37a5e538e7e0c3af7f77f9f9bed7ff933423"
|
||||
}
|
||||
}
|
||||
}
|
||||
-644
@@ -1,644 +0,0 @@
|
||||
// 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 }); }
|
||||
@@ -1,5 +0,0 @@
|
||||
// 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
@@ -1,326 +0,0 @@
|
||||
// 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();
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
// 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]]);
|
||||
})();
|
||||
@@ -1,43 +0,0 @@
|
||||
// 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;
|
||||
})();
|
||||
@@ -1,54 +0,0 @@
|
||||
// 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
@@ -1,38 +0,0 @@
|
||||
<!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