mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-08-24 17:10:27 +08:00
feat: Ink Theater — deterministic hand-drawn moving-art engine + creative skill
Reusable engine (ink-theater/ink-theater.js, global InkTheater) for hand-drawn ink-on-white animated 'moving art', authored for HyperFrames (atelier). Five seek-safe primitives: variable-width brush strokes (inkPath/inkRibbon), timeline-driven boil, closed-form damped-spring eases, FABRIK 2D IK + riggable mascot, and a parametric contraption grammar (crank/gauge/hopper/slot/lever/box). Adds skills/creative/ink-theater.md (metaphor method + mined archetypes + color grammar) and README with the HyperFrames font gotcha (webfonts apply to HTML divs, not SVG <text>). Two reference builds under ink-theater/examples/. Grounded in deep-research on vector/physics/metaphor foundations (FABRIK, bounded-biharmonic/ARAP, closed-form springs, LLM metaphor+layout planning).
This commit is contained in:
57
ink-theater/README.md
Normal file
57
ink-theater/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Ink Theater
|
||||
|
||||
A deterministic, seek-safe engine for hand-drawn **"moving art"** — a minimalist black-ink-on-white world where a deadpan mascot physically *performs* an abstract idea by operating absurd low-tech contraptions. Built for OpenMontage's **atelier** path and rendered through **HyperFrames** (HTML/SVG/CSS + one paused GSAP timeline → MP4).
|
||||
|
||||
Inspired by Ian's `小黑 / Xiaohei` illustration skill (MIT — credit Ian for the technique); this is an original, generic, English, motion-first engine, not a copy.
|
||||
|
||||
## Why it exists
|
||||
|
||||
The illustration style is simple enough that **the illustration IS the animation** — no diffusion model needed. Vector shapes + math give you the whole thing: free, deterministic, infinitely editable, and the character genuinely acts out the concept. This engine turns the research findings (`memory: project_ink_atelier_animation`, deep-research on vector/physics/metaphor foundations) into reusable primitives.
|
||||
|
||||
## The five capabilities (`ink-theater.js`, global `InkTheater`)
|
||||
|
||||
| Module | What it does | Key API |
|
||||
|---|---|---|
|
||||
| **ink strokes** | Confident hand-drawn lines — variable-width brush ribbons + wobbled centerlines | `inkPath(pts, opt)`, `inkRibbon(pts, {width,taper,seed})` |
|
||||
| **boil** | Seek-safe hand-drawn line "boil" — steps a `feTurbulence` seed off the timeline (~9fps), NOT SMIL | `boil(turbEl, tl, {duration,fps})` |
|
||||
| **spring physics** | Closed-form damped-spring eases (anticipation/overshoot/settle) — pure functions of progress, seek-safe | `springEase({stiffness,damping,mass})`, `ease.{settle,overshoot,bouncy,soft}` |
|
||||
| **rig / IK** | 2D FABRIK inverse kinematics + a riggable mascot whose arms reach a target | `fabrik(lengths,origin,target)`, `mascot({x,y,scale})` → `.reachL/.reachR([x,y])` |
|
||||
| **contraption grammar** | Parametric composable machine parts | `parts.{crank,gauge,hopper,slot,lever,box}` |
|
||||
|
||||
## Determinism (HyperFrames render contract)
|
||||
|
||||
Every frame must be reproducible from time alone. This engine obeys that:
|
||||
|
||||
- **Closed-form springs** — `springEase` evaluates an analytic damped-oscillator step response, so any progress `p` maps deterministically (no numeric integration, no accumulated state).
|
||||
- **Seek-safe boil** — driven by a GSAP stepped-seed tween on the timeline, never SMIL / render-time clocks.
|
||||
- **IK-follow via `onUpdate`** — pose the arm from a target whose position is set by the timeline; GSAP fires `onUpdate` on seek, so it's pure-function-of-time.
|
||||
- Seeded PRNG (`rng`) for all "random-looking" wobble — no runtime `Math.random`.
|
||||
- No `repeat:-1` (finite counts only), animate only transforms/opacity/attrs.
|
||||
|
||||
## ⚠ The font gotcha (learned the hard way)
|
||||
|
||||
**HyperFrames' rasterizer applies `@font-face` webfonts to HTML elements but NOT to SVG `<text>`.** SVG text silently falls back to a system serif — even static, even with the font "loaded".
|
||||
|
||||
**Always render custom-font captions as HTML overlay `<div>`s** positioned over the SVG scene, never as SVG `<text>`. See `projects/ink-theater-momentum/index.html` for the working pattern:
|
||||
|
||||
```html
|
||||
<div class="stage">
|
||||
<svg class="scene">…ink art…</svg> <!-- shapes only -->
|
||||
<div id="cap1" class="cap">handwritten line</div> <!-- HTML text = webfont works -->
|
||||
</div>
|
||||
```
|
||||
|
||||
Bundle the font locally (`@font-face url("assets/x.woff2")`) — a Google Fonts hot-link is a render-time network fetch and violates determinism. Prefer a **static** woff2 (e.g. Patrick Hand) — but note even static SVG `<text>` fails; it's the SVG-vs-HTML boundary that matters, not variable-vs-static.
|
||||
|
||||
## Usage in a HyperFrames project
|
||||
|
||||
1. Copy `ink-theater.js` into the project root; `<script src="ink-theater.js">` after gsap.
|
||||
2. Build the scene programmatically into a mount `<g>`, keep node refs.
|
||||
3. Apply `filter="url(#boil)"` to ink groups; call `InkTheater.boil(...)` once.
|
||||
4. Captions = HTML overlay divs (see gotcha).
|
||||
5. Register one `gsap.timeline({paused:true})` on `window.__timelines["<id>"]`.
|
||||
|
||||
## Demos
|
||||
|
||||
- `projects/ink-theater-reel/` — capabilities reel (each primitive, labeled).
|
||||
- `projects/ink-theater-momentum/` — "Momentum" story (springs + rig + ink + handwriting).
|
||||
BIN
ink-theater/examples/assets/inkhand.woff2
Normal file
BIN
ink-theater/examples/assets/inkhand.woff2
Normal file
Binary file not shown.
119
ink-theater/examples/momentum.html
Normal file
119
ink-theater/examples/momentum.html
Normal file
@@ -0,0 +1,119 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-resolution="landscape">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script src="ink-theater.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
@font-face { font-family: "InkHand"; src: url("assets/inkhand.woff2") format("woff2"); font-display: block; }
|
||||
html, body { width: 1920px; height: 1080px; overflow: hidden; background: #fff; }
|
||||
#root { position: relative; width: 1920px; height: 1080px; overflow: hidden; }
|
||||
.stage { position: absolute; inset: 0; background: #fff; }
|
||||
.stage svg.scene { position: absolute; inset: 0; display: block; width: 1920px; height: 1080px; }
|
||||
.cap { position: absolute; left: 0; width: 1920px; text-align: center; font-family: "InkHand", cursive; color: #1a1a1a; opacity: 0; }
|
||||
#cap1, #cap2 { top: 206px; font-size: 70px; }
|
||||
#cap1b { top: 300px; font-size: 44px; color: #d8402e; }
|
||||
#cap2b { top: 300px; font-size: 44px; color: #2f6fb0; }
|
||||
.endtag { position: absolute; inset: 0; background: #fff; display: flex; flex-direction: column; align-items: center; justify-content: center; opacity: 0; }
|
||||
.ett { font-family: "InkHand", cursive; font-size: 180px; color: #1a1a1a; line-height: 1; }
|
||||
.etu { width: 460px; height: 34px; margin: 4px 0 2px; }
|
||||
.ets { font-family: "InkHand", cursive; font-size: 52px; color: #7a7a7a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" data-composition-id="main" data-start="0" data-duration="14" data-width="1920" data-height="1080">
|
||||
<section id="scene" class="clip" data-start="0" data-duration="14" data-track-index="1">
|
||||
<div class="stage">
|
||||
<svg class="scene" viewBox="0 0 1920 1080" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="boil" x="-15%" y="-15%" width="130%" height="130%">
|
||||
<feTurbulence id="boilTurb" type="fractalNoise" baseFrequency="0.011" numOctaves="1" seed="1" result="n"/>
|
||||
<feDisplacementMap in="SourceGraphic" in2="n" scale="2.4"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<g id="ink"></g>
|
||||
</svg>
|
||||
<div id="cap1" class="cap">starting is the hard part</div>
|
||||
<div id="cap1b" class="cap">all the effort is up front</div>
|
||||
<div id="cap2" class="cap">then it carries itself</div>
|
||||
<div id="cap2b" class="cap">momentum does the rest</div>
|
||||
<div id="endtag" class="endtag">
|
||||
<div class="ett">momentum</div>
|
||||
<svg class="etu" viewBox="0 0 460 34"><path id="endund" d="" fill="none" stroke="#e07b1a" stroke-width="7" stroke-linecap="round"/></svg>
|
||||
<div class="ets">get it moving — then keep it rolling.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
if (document.fonts && document.fonts.load) { document.fonts.load("180px InkHand"); }
|
||||
var IT = window.InkTheater, INK = IT.INK;
|
||||
var mount = document.getElementById("ink");
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
|
||||
function hillY(x) { return 820 - 320 * Math.exp(-Math.pow((x - 1050) / 300, 2)); }
|
||||
|
||||
var gpts = [], gx;
|
||||
for (gx = 300; gx <= 1720; gx += 36) gpts.push([gx, hillY(gx)]);
|
||||
var ground = IT.el("path", { d: IT.inkPath(gpts, { wobble: 1.2, step: 36 }), fill: "none", stroke: INK, "stroke-width": 5, "stroke-linecap": "round" });
|
||||
ground.style.strokeDasharray = 1700; ground.style.strokeDashoffset = 1700;
|
||||
mount.appendChild(IT.el("g", { filter: "url(#boil)" }, [ground]));
|
||||
|
||||
var R = 72;
|
||||
var ballG = IT.el("g", null, [
|
||||
IT.el("circle", { cx: 0, cy: 0, r: R, fill: "#fff", stroke: INK, "stroke-width": 5 }),
|
||||
IT.el("circle", { cx: -34, cy: 0, r: 7, fill: INK }),
|
||||
IT.el("circle", { cx: 30, cy: 14, r: 6, fill: INK }),
|
||||
IT.el("path", { d: "M-20 -34 A 40 40 0 0 1 30 -22", fill: "none", stroke: INK, "stroke-width": 3 })
|
||||
]);
|
||||
var ballWrap = IT.el("g", { filter: "url(#boil)" }, [ballG]);
|
||||
mount.appendChild(ballWrap);
|
||||
|
||||
var SC = 1.3;
|
||||
var m = IT.mascot({ x: 0, y: 0, scale: SC });
|
||||
m.reachR([54, -8]); m.reachL([44, -20]);
|
||||
var mWrap = IT.el("g", { filter: "url(#boil)" }, [m.g]);
|
||||
mount.appendChild(mWrap);
|
||||
var FOOT = 100 * SC;
|
||||
|
||||
var pos = { x: 560 };
|
||||
function placeScene() {
|
||||
var x = pos.x, cy = hillY(x) - R, rot = (x - 560) * 0.5;
|
||||
ballG.setAttribute("transform", "rotate(" + rot + ")");
|
||||
ballWrap.setAttribute("transform", "translate(" + x + "," + cy + ")");
|
||||
var mx = Math.min(x - 100, 952);
|
||||
mWrap.setAttribute("transform", "translate(" + mx + "," + (hillY(mx) - FOOT) + ")");
|
||||
}
|
||||
placeScene();
|
||||
|
||||
tl.to(ground, { strokeDashoffset: 0, duration: 1.6, ease: "power1.inOut" }, 0.3);
|
||||
|
||||
// BEAT 1 — the slow grind up
|
||||
tl.fromTo("#cap1", { opacity: 0, y: 12 }, { opacity: 1, y: 0, duration: 0.6 }, 1.4);
|
||||
tl.fromTo("#cap1b", { opacity: 0 }, { opacity: 1, duration: 0.6 }, 1.9);
|
||||
tl.to(pos, { x: 1052, duration: 3.4, ease: "power1.inOut", onUpdate: placeScene }, 1.4);
|
||||
tl.to(["#cap1", "#cap1b"], { opacity: 0, duration: 0.4 }, 4.9);
|
||||
|
||||
// BEAT 2 — the effortless roll down
|
||||
tl.fromTo("#cap2", { opacity: 0, y: 12 }, { opacity: 1, y: 0, duration: 0.6 }, 5.7);
|
||||
tl.fromTo("#cap2b", { opacity: 0 }, { opacity: 1, duration: 0.6 }, 6.2);
|
||||
tl.to(pos, { x: 1820, duration: 2.0, ease: "power2.in", onUpdate: placeScene }, 5.5);
|
||||
tl.to(mWrap, { y: "+=8", duration: 0.5, yoyo: true, repeat: 1, ease: "sine.inOut" }, 6.0);
|
||||
tl.to(["#cap2", "#cap2b"], { opacity: 0, duration: 0.4 }, 7.8);
|
||||
|
||||
// END TAG
|
||||
var und = document.getElementById("endund");
|
||||
und.setAttribute("d", IT.inkPath([[20, 18], [230, 28], [440, 15]], { wobble: 1.6, step: 60 }));
|
||||
und.style.strokeDasharray = 460; und.style.strokeDashoffset = 460;
|
||||
tl.to("#endtag", { opacity: 1, duration: 0.7, ease: "power2.out" }, 8.4);
|
||||
tl.fromTo(und, { strokeDashoffset: 460 }, { strokeDashoffset: 0, duration: 0.9, ease: "power2.out" }, 9.0);
|
||||
|
||||
IT.boil(document.getElementById("boilTurb"), tl, { duration: 14, fps: 9 });
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
147
ink-theater/examples/reel.html
Normal file
147
ink-theater/examples/reel.html
Normal file
@@ -0,0 +1,147 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-resolution="landscape">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script src="ink-theater.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 1920px; height: 1080px; overflow: hidden; background: #fff; }
|
||||
#root { position: relative; width: 1920px; height: 1080px; overflow: hidden; }
|
||||
.stage { position: absolute; inset: 0; background: #fff; }
|
||||
.stage svg { display: block; width: 1920px; height: 1080px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" data-composition-id="main" data-start="0" data-duration="19" data-width="1920" data-height="1080">
|
||||
<section id="scene" class="clip" data-start="0" data-duration="19" data-track-index="1">
|
||||
<div class="stage">
|
||||
<svg viewBox="0 0 1920 1080" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="boil" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feTurbulence id="boilTurb" type="fractalNoise" baseFrequency="0.012" numOctaves="1" seed="1" result="n"/>
|
||||
<feDisplacementMap in="SourceGraphic" in2="n" scale="2.2"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<g id="ink"></g>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
var IT = window.InkTheater, INK = IT.INK;
|
||||
var SANS = "'Helvetica Neue', Arial, sans-serif";
|
||||
var SVGNS = "http://www.w3.org/2000/svg";
|
||||
var mount = document.getElementById("ink");
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
|
||||
function cap(parent, text, x, y, size, color, weight) {
|
||||
var t = document.createElementNS(SVGNS, "text");
|
||||
t.setAttribute("x", x); t.setAttribute("y", y); t.setAttribute("text-anchor", "middle");
|
||||
t.style.fontFamily = SANS; t.style.fontSize = size + "px";
|
||||
t.style.fontWeight = weight || 400; t.style.fill = color || INK;
|
||||
t.style.letterSpacing = "0.5px";
|
||||
t.textContent = text; parent.appendChild(t); return t;
|
||||
}
|
||||
function beat() {
|
||||
var g = IT.el("g", { opacity: 0 });
|
||||
var art = IT.el("g", { filter: "url(#boil)" });
|
||||
var lab = IT.el("g");
|
||||
g.appendChild(art); g.appendChild(lab); mount.appendChild(g);
|
||||
return { g: g, art: art, lab: lab };
|
||||
}
|
||||
function title(lab, big, small) {
|
||||
cap(lab, big, 960, 296, 80, INK, 500);
|
||||
cap(lab, small, 960, 352, 32, "#7a7a7a", 400);
|
||||
}
|
||||
function fade(g, tIn, tOut) {
|
||||
tl.fromTo(g, { opacity: 0 }, { opacity: 1, duration: 0.5, ease: "power2.out" }, tIn);
|
||||
if (tOut != null) tl.to(g, { opacity: 0, duration: 0.4, ease: "power2.in" }, tOut);
|
||||
}
|
||||
|
||||
// ===== BEAT 1 — living ink (variable-width strokes + boil) =====
|
||||
var b1 = beat();
|
||||
var swoosh = IT.el("path", { d: IT.inkRibbon([[640,520],[820,430],[1040,470],[1240,560],[1330,470]], { width: 30, taper: 0.5, seed: 4 }), fill: INK });
|
||||
var swoosh2 = IT.el("path", { d: IT.inkRibbon([[700,640],[900,600],[1120,640],[1280,600]], { width: 16, taper: 0.7, seed: 9 }), fill: INK });
|
||||
var squiggle = IT.el("path", { d: IT.inkPath([[660,720],[760,690],[860,725],[960,690],[1060,725],[1160,690],[1260,720]], { wobble: 2 }), fill: "none", stroke: INK, "stroke-width": 5, "stroke-linecap": "round" });
|
||||
squiggle.style.strokeDasharray = 900; squiggle.style.strokeDashoffset = 900;
|
||||
b1.art.appendChild(swoosh); b1.art.appendChild(swoosh2); b1.art.appendChild(squiggle);
|
||||
title(b1.lab, "living ink", "variable-width brush strokes · boil driven off the timeline");
|
||||
tl.fromTo([swoosh, swoosh2], { scale: 0.7, transformOrigin: "50% 50%" }, { scale: 1, duration: 0.8, ease: IT.ease.settle }, 0.6);
|
||||
tl.to(squiggle, { strokeDashoffset: 0, duration: 1.4, ease: "power1.inOut" }, 1.0);
|
||||
fade(b1.g, 0.3, 3.9);
|
||||
|
||||
// ===== BEAT 2 — spring physics (eases as ODE solutions) =====
|
||||
var b2 = beat();
|
||||
var BASE = 720, TOP = 470, cols = [
|
||||
{ x: 690, ease: "none", name: "linear" },
|
||||
{ x: 850, ease: IT.ease.soft, name: "soft" },
|
||||
{ x: 1070, ease: IT.ease.settle, name: "settle" },
|
||||
{ x: 1230, ease: IT.ease.overshoot, name: "overshoot" }
|
||||
];
|
||||
title(b2.lab, "spring physics", "closed-form damped springs · anticipation, overshoot, settle");
|
||||
b2.art.appendChild(IT.el("path", { d: "M620 " + BASE + " H1300", stroke: "#cdb8a0", "stroke-width": 2, "stroke-dasharray": "4 10", fill: "none" }));
|
||||
cols.forEach(function (c) {
|
||||
var ball = IT.el("circle", { cx: c.x, cy: TOP, r: 24, fill: INK });
|
||||
b2.art.appendChild(ball);
|
||||
cap(b2.lab, c.name, c.x, 790, 30, "#2f6fb0", 400);
|
||||
tl.fromTo(ball, { attr: { cy: TOP } }, { attr: { cy: BASE }, duration: 1.5, ease: c.ease }, 4.8);
|
||||
});
|
||||
fade(b2.g, 4.2, 7.9);
|
||||
|
||||
// ===== BEAT 3 — IK rig (FABRIK arm tracks a target) =====
|
||||
var b3 = beat();
|
||||
var m = IT.mascot({ x: 800, y: 600, scale: 1.9 });
|
||||
b3.art.appendChild(m.g);
|
||||
var target = IT.el("circle", { cx: 960, cy: 545, r: 14, fill: "#e07b1a" });
|
||||
b3.art.appendChild(target);
|
||||
title(b3.lab, "IK rig", "FABRIK inverse kinematics · the arm bends to reach the target");
|
||||
function follow() { m.reachR([+target.getAttribute("cx"), +target.getAttribute("cy")]); }
|
||||
follow();
|
||||
tl.to(target, { attr: { cx: 985, cy: 530 }, duration: 1.2, ease: IT.ease.settle, onUpdate: follow }, 9.0);
|
||||
tl.to(target, { attr: { cx: 905, cy: 670 }, duration: 1.4, ease: IT.ease.overshoot, onUpdate: follow }, 10.3);
|
||||
tl.to(target, { attr: { cx: 950, cy: 575 }, duration: 1.2, ease: IT.ease.settle, onUpdate: follow }, 11.8);
|
||||
fade(b3.g, 8.2, 12.4);
|
||||
|
||||
// ===== BEAT 4 — contraption grammar (composable parts) =====
|
||||
var b4 = beat();
|
||||
var cx = 940, baseY = 800;
|
||||
var bodyBox = IT.parts.box(cx - 170, baseY - 280, 340, 270, "");
|
||||
var hopper = IT.parts.hopper(cx, baseY - 280, 200, 90);
|
||||
var crank = IT.parts.crank(cx - 200, baseY - 160, 58);
|
||||
var gauge = IT.parts.gauge(cx + 70, baseY - 185, 52);
|
||||
var slot = IT.parts.slot(cx + 168, baseY - 140, 14, 50);
|
||||
[bodyBox.g, hopper.g, crank.g, gauge.g, slot.g].forEach(function (e) { b4.art.appendChild(e); });
|
||||
var outBox = IT.el("g", { opacity: 0 }, [IT.el("rect", { x: cx + 196, y: baseY - 165, width: 86, height: 60, rx: 4, fill: "#fff", stroke: INK, "stroke-width": 4 })]);
|
||||
b4.art.appendChild(outBox);
|
||||
title(b4.lab, "contraption grammar", "parametric parts compose · crank, gauge, hopper, slot");
|
||||
gsap.set(crank.wheel, { svgOrigin: crank.pivot[0] + " " + crank.pivot[1] });
|
||||
gsap.set(gauge.needle, { svgOrigin: gauge.pivot[0] + " " + gauge.pivot[1] });
|
||||
gsap.set(outBox, { transformOrigin: "50% 50%" });
|
||||
tl.to(crank.wheel, { rotation: 360, duration: 1.1, ease: "power2.out" }, 13.3);
|
||||
tl.to(crank.wheel, { rotation: 720, duration: 1.1, ease: "power2.out" }, 14.7);
|
||||
tl.fromTo(gauge.needle, { rotation: 0 }, { rotation: 58, duration: 1.0, ease: IT.ease.overshoot }, 13.5);
|
||||
tl.fromTo(gauge.needle, { rotation: 58 }, { rotation: 64, duration: 1.0, ease: IT.ease.overshoot }, 14.9);
|
||||
tl.fromTo(outBox, { opacity: 1, x: -60, scale: 0.6 }, { x: 0, scale: 1, duration: 0.8, ease: IT.ease.bouncy }, 14.4);
|
||||
fade(b4.g, 12.6, 16.6);
|
||||
|
||||
// ===== END TAG =====
|
||||
var b5 = beat();
|
||||
var und = IT.el("path", { d: IT.inkPath([[690,560],[960,578],[1230,556]], { wobble: 1.5 }), fill: "none", stroke: "#e07b1a", "stroke-width": 8, "stroke-linecap": "round" });
|
||||
und.style.strokeDasharray = 580; und.style.strokeDashoffset = 580;
|
||||
b5.art.appendChild(und);
|
||||
cap(b5.lab, "Ink Theater", 960, 520, 124, INK, 500);
|
||||
cap(b5.lab, "one engine · any moving ink", 960, 632, 40, "#7a7a7a", 400);
|
||||
tl.fromTo(b5.g, { opacity: 0 }, { opacity: 1, duration: 0.7, ease: "power2.out" }, 16.8);
|
||||
tl.to(und, { strokeDashoffset: 0, duration: 0.9, ease: "power2.out" }, 17.4);
|
||||
|
||||
// seek-safe hand-drawn boil across the whole piece
|
||||
IT.boil(document.getElementById("boilTurb"), tl, { duration: 19, fps: 9 });
|
||||
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
269
ink-theater/ink-theater.js
Normal file
269
ink-theater/ink-theater.js
Normal file
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* Ink Theater — a deterministic, seek-safe engine for hand-drawn "moving art".
|
||||
* Browser global `InkTheater`. Designed for HyperFrames: every primitive is a pure
|
||||
* function of geometry or of the GSAP timeline playhead, so frame N is reproducible
|
||||
* from time alone. No render-time clocks, no Math.random at runtime.
|
||||
*
|
||||
* Modules:
|
||||
* rng / noise — seeded determinism
|
||||
* inkPath/inkRibbon — hand-drawn variable-width strokes (the "look")
|
||||
* boil — seek-safe stepped line-boil driven off the timeline
|
||||
* springEase / ease — closed-form damped-spring eases (anticipation/overshoot/settle)
|
||||
* fabrik — 2D inverse kinematics for limb rigs
|
||||
* parts — parametric, composable low-tech contraption pieces (the grammar)
|
||||
* mascot — a riggable deadpan ink character
|
||||
*/
|
||||
(function (root) {
|
||||
"use strict";
|
||||
var SVGNS = "http://www.w3.org/2000/svg";
|
||||
|
||||
// ---- DOM helper ------------------------------------------------------------
|
||||
function el(tag, attrs, kids) {
|
||||
var n = document.createElementNS(SVGNS, tag);
|
||||
if (attrs) for (var k in attrs) if (attrs[k] != null) n.setAttribute(k, attrs[k]);
|
||||
if (kids) (Array.isArray(kids) ? kids : [kids]).forEach(function (c) { if (c) n.appendChild(c); });
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---- Seeded determinism ----------------------------------------------------
|
||||
function rng(seed) { // mulberry32
|
||||
var a = (seed >>> 0) || 1;
|
||||
return function () {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
var t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Path math -------------------------------------------------------------
|
||||
function resample(pts, step) {
|
||||
if (pts.length < 2) return pts.slice();
|
||||
var out = [pts[0]], acc = 0, i, prev = pts[0];
|
||||
for (i = 1; i < pts.length; i++) {
|
||||
var p = pts[i], dx = p[0] - prev[0], dy = p[1] - prev[1], d = Math.hypot(dx, dy);
|
||||
while (acc + d >= step) {
|
||||
var t = (step - acc) / d;
|
||||
prev = [prev[0] + dx * t, prev[1] + dy * t];
|
||||
out.push(prev);
|
||||
dx = p[0] - prev[0]; dy = p[1] - prev[1]; d = Math.hypot(dx, dy); acc = 0;
|
||||
}
|
||||
acc += d; prev = p;
|
||||
}
|
||||
out.push(pts[pts.length - 1]);
|
||||
return out;
|
||||
}
|
||||
function normals(pts) {
|
||||
var ns = [], i;
|
||||
for (i = 0; i < pts.length; i++) {
|
||||
var a = pts[Math.max(0, i - 1)], b = pts[Math.min(pts.length - 1, i + 1)];
|
||||
var tx = b[0] - a[0], ty = b[1] - a[1], L = Math.hypot(tx, ty) || 1;
|
||||
ns.push([-ty / L, tx / L]);
|
||||
}
|
||||
return ns;
|
||||
}
|
||||
// Catmull-Rom -> cubic Bézier 'd' (smooth curve through points)
|
||||
function smoothD(pts, closed) {
|
||||
if (pts.length < 2) return "";
|
||||
var d = "M" + r2(pts[0][0]) + " " + r2(pts[0][1]), i, n = pts.length;
|
||||
for (i = 0; i < n - 1; i++) {
|
||||
var p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;
|
||||
var c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;
|
||||
var c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;
|
||||
d += "C" + r2(c1x) + " " + r2(c1y) + " " + r2(c2x) + " " + r2(c2y) + " " + r2(p2[0]) + " " + r2(p2[1]);
|
||||
}
|
||||
return d + (closed ? "Z" : "");
|
||||
}
|
||||
function r2(x) { return Math.round(x * 100) / 100; }
|
||||
|
||||
// Bake a small deterministic hand-wobble into a polyline (perpendicular jitter).
|
||||
function wobblePts(pts, amp, seed) {
|
||||
var rand = rng(seed || 1), ns = normals(pts);
|
||||
return pts.map(function (p, i) {
|
||||
var j = (rand() - 0.5) * 2 * amp;
|
||||
return [p[0] + ns[i][0] * j, p[1] + ns[i][1] * j];
|
||||
});
|
||||
}
|
||||
|
||||
// A confident hand-drawn centerline (use with stroke + round caps).
|
||||
function inkPath(pts, opt) {
|
||||
opt = opt || {};
|
||||
var P = wobblePts(resample(pts, opt.step || 26), opt.wobble != null ? opt.wobble : 1.6, opt.seed || 7);
|
||||
return smoothD(P, opt.closed);
|
||||
}
|
||||
|
||||
// A variable-width brush ribbon (returns a CLOSED outline, fill it black).
|
||||
// Width tapers at the ends and swells in the middle — the difference between
|
||||
// a drawn line and a traced one.
|
||||
function inkRibbon(pts, opt) {
|
||||
opt = opt || {};
|
||||
var w = opt.width || 12, taper = opt.taper != null ? opt.taper : 0.55, seed = opt.seed || 5;
|
||||
var P = wobblePts(resample(pts, opt.step || 22), opt.wobble != null ? opt.wobble : 1.2, seed);
|
||||
var ns = normals(P), n = P.length, rand = rng(seed + 99);
|
||||
var left = [], right = [], i;
|
||||
for (i = 0; i < n; i++) {
|
||||
var t = n > 1 ? i / (n - 1) : 0;
|
||||
// width profile: 0 at ends, 1 in the belly; taper controls end thinness
|
||||
var prof = Math.pow(Math.sin(Math.PI * t), taper) * (0.85 + 0.3 * rand());
|
||||
var hw = (w / 2) * prof + 0.6;
|
||||
left.push([P[i][0] + ns[i][0] * hw, P[i][1] + ns[i][1] * hw]);
|
||||
right.push([P[i][0] - ns[i][0] * hw, P[i][1] - ns[i][1] * hw]);
|
||||
}
|
||||
return smoothD(left, false) + " " + smoothD(right.reverse(), false).replace(/^M/, "L") + "Z";
|
||||
}
|
||||
|
||||
// ---- Seek-safe line boil ---------------------------------------------------
|
||||
// Steps a feTurbulence seed across the timeline so the inked lines "boil" at a
|
||||
// low frame-rate (hand-drawn feel) while staying 100% deterministic under seek.
|
||||
function boil(turbulenceEl, tl, opt) {
|
||||
opt = opt || {};
|
||||
var dur = opt.duration || tl.duration() || 8;
|
||||
var fps = opt.fps || 9;
|
||||
var steps = Math.max(1, Math.round(dur * fps));
|
||||
tl.to(turbulenceEl, { attr: { seed: steps }, duration: dur, ease: "steps(" + steps + ")" }, 0);
|
||||
return turbulenceEl;
|
||||
}
|
||||
|
||||
// ---- Closed-form damped spring eases --------------------------------------
|
||||
// Step response of a damped harmonic oscillator, normalized to settle at 1.
|
||||
// Underdamped => overshoot + settle (the juicy follow-through). Pure fn of p.
|
||||
function springEase(opt) {
|
||||
opt = opt || {};
|
||||
var stiffness = opt.stiffness || 170, damping = opt.damping || 14, mass = opt.mass || 1;
|
||||
var w = Math.sqrt(stiffness / mass); // natural angular frequency
|
||||
var zeta = damping / (2 * Math.sqrt(stiffness * mass)); // damping ratio
|
||||
var horizon = opt.horizon || 1; // p maps to t in [0, horizon*settle]
|
||||
// choose a time-scale so motion is essentially settled at p=1
|
||||
var settle = 8 / (zeta * w || 1);
|
||||
function y(t) {
|
||||
if (zeta < 1) {
|
||||
var wd = w * Math.sqrt(1 - zeta * zeta);
|
||||
return 1 - Math.exp(-zeta * w * t) * (Math.cos(wd * t) + (zeta * w / wd) * Math.sin(wd * t));
|
||||
} else if (zeta === 1) {
|
||||
return 1 - Math.exp(-w * t) * (1 + w * t);
|
||||
} else {
|
||||
var a = -w * (zeta - Math.sqrt(zeta * zeta - 1)), b = -w * (zeta + Math.sqrt(zeta * zeta - 1));
|
||||
var A = b / (b - a), B = -a / (b - a);
|
||||
return 1 - (A * Math.exp(a * t) + B * Math.exp(b * t));
|
||||
}
|
||||
}
|
||||
var f = function (p) {
|
||||
if (p <= 0) return 0; if (p >= 1) return 1; // pin endpoints
|
||||
return y(p * settle * horizon);
|
||||
};
|
||||
return f;
|
||||
}
|
||||
var ease = {
|
||||
settle: springEase({ stiffness: 180, damping: 18 }), // minimal overshoot
|
||||
overshoot: springEase({ stiffness: 200, damping: 11 }),// clear overshoot + settle
|
||||
bouncy: springEase({ stiffness: 260, damping: 8 }), // springy
|
||||
soft: springEase({ stiffness: 120, damping: 20 }) // gentle, no overshoot
|
||||
};
|
||||
|
||||
// ---- FABRIK 2D inverse kinematics -----------------------------------------
|
||||
// lengths: array of segment lengths (n segments -> n+1 joints). Returns joint pts.
|
||||
function fabrik(lengths, origin, target, opt) {
|
||||
opt = opt || {};
|
||||
var iters = opt.iterations || 12, tol = opt.tol || 0.4;
|
||||
var n = lengths.length, total = lengths.reduce(function (a, b) { return a + b; }, 0);
|
||||
var pts = [origin.slice()], i;
|
||||
for (i = 0; i < n; i++) pts.push([origin[0] + lengths[i] * (i + 1), origin[1]]); // seed straight
|
||||
var dist = Math.hypot(target[0] - origin[0], target[1] - origin[1]);
|
||||
if (dist > total) { // unreachable: stretch straight
|
||||
var ux = (target[0] - origin[0]) / dist, uy = (target[1] - origin[1]) / dist, acc = origin.slice();
|
||||
pts[0] = origin.slice();
|
||||
for (i = 0; i < n; i++) { acc = [acc[0] + ux * lengths[i], acc[1] + uy * lengths[i]]; pts[i + 1] = acc.slice(); }
|
||||
return pts;
|
||||
}
|
||||
for (var it = 0; it < iters; it++) {
|
||||
pts[n] = target.slice(); // backward
|
||||
for (i = n - 1; i >= 0; i--) pts[i] = lerpTo(pts[i + 1], pts[i], lengths[i]);
|
||||
pts[0] = origin.slice(); // forward
|
||||
for (i = 0; i < n; i++) pts[i + 1] = lerpTo(pts[i], pts[i + 1], lengths[i]);
|
||||
if (Math.hypot(pts[n][0] - target[0], pts[n][1] - target[1]) < tol) break;
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
function lerpTo(from, to, len) {
|
||||
var dx = to[0] - from[0], dy = to[1] - from[1], d = Math.hypot(dx, dy) || 1;
|
||||
return [from[0] + dx / d * len, from[1] + dy / d * len];
|
||||
}
|
||||
|
||||
// ---- Contraption grammar — parametric composable parts --------------------
|
||||
// Each returns { g: <g>, ... } with named handles the author wires to motion.
|
||||
var INK = "#1a1a1a";
|
||||
function strokeEl(d, sw) { return el("path", { d: d, fill: "none", stroke: INK, "stroke-width": sw || 4, "stroke-linecap": "round", "stroke-linejoin": "round" }); }
|
||||
var parts = {
|
||||
// crank wheel with a handle on the rim — spin the returned `wheel` group
|
||||
crank: function (cx, cy, r) {
|
||||
var wheel = el("g", { transform: "" });
|
||||
wheel.appendChild(el("circle", { cx: cx, cy: cy, r: r, fill: "#fff", stroke: INK, "stroke-width": 4 }));
|
||||
wheel.appendChild(el("line", { x1: cx, y1: cy, x2: cx, y2: cy - r + 4, stroke: INK, "stroke-width": 2.4 }));
|
||||
wheel.appendChild(el("circle", { cx: cx, cy: cy - r + 8, r: r * 0.16, fill: INK })); // handle
|
||||
var g = el("g", null, [wheel, el("circle", { cx: cx, cy: cy, r: 3, fill: INK })]);
|
||||
return { g: g, wheel: wheel, pivot: [cx, cy] };
|
||||
},
|
||||
// gauge with a needle — swing `needle` (pivot at center)
|
||||
gauge: function (cx, cy, r) {
|
||||
var needle = el("line", { x1: cx, y1: cy, x2: cx, y2: cy - r + 6, stroke: INK, "stroke-width": 4 });
|
||||
var g = el("g", null, [
|
||||
el("circle", { cx: cx, cy: cy, r: r, fill: "#fff", stroke: INK, "stroke-width": 4 }),
|
||||
strokeEl("M" + (cx - r * 0.7) + " " + (cy - r * 0.2) + " A " + (r * 0.7) + " " + (r * 0.7) + " 0 0 1 " + (cx + r * 0.7) + " " + (cy - r * 0.2), 2),
|
||||
needle, el("circle", { cx: cx, cy: cy, r: 3.5, fill: INK })
|
||||
]);
|
||||
return { g: g, needle: needle, pivot: [cx, cy] };
|
||||
},
|
||||
hopper: function (cx, topY, mouthW, depth) {
|
||||
var hw = mouthW / 2;
|
||||
return { g: strokeEl("M" + (cx - hw * 0.5) + " " + (topY + depth) + " L" + (cx - hw) + " " + topY + " L" + (cx + hw) + " " + topY + " L" + (cx + hw * 0.5) + " " + (topY + depth)), mouth: [cx, topY] };
|
||||
},
|
||||
slot: function (x, y, w, h) { return { g: el("rect", { x: x, y: y, width: w, height: h, rx: 2, fill: INK }), out: [x + w, y + h / 2] }; },
|
||||
lever: function (cx, cy, len) {
|
||||
var arm = el("line", { x1: cx, y1: cy, x2: cx + len, y2: cy - len * 0.2, stroke: INK, "stroke-width": 6, "stroke-linecap": "round" });
|
||||
var knob = el("circle", { cx: cx + len, cy: cy - len * 0.2, r: 9, fill: INK });
|
||||
return { g: el("g", null, [el("circle", { cx: cx, cy: cy, r: 5, fill: INK }), arm, knob]), arm: arm, knob: knob, pivot: [cx, cy] };
|
||||
},
|
||||
box: function (x, y, w, h, label) {
|
||||
var g = el("g", null, [el("rect", { x: x, y: y, width: w, height: h, rx: 6, fill: "#fff", stroke: INK, "stroke-width": 4 })]);
|
||||
if (label) { var t = el("text", { x: x + w / 2, y: y + h / 2 + 6, "text-anchor": "middle", fill: INK, "font-size": 22, "font-family": "Caveat, cursive" }); t.textContent = label; g.appendChild(t); }
|
||||
return { g: g };
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Mascot ----------------------------------------------------------------
|
||||
// A deadpan ink blob with two FABRIK-posable arms.
|
||||
function mascot(opt) {
|
||||
opt = opt || {};
|
||||
var x = opt.x || 0, y = opt.y || 0, s = opt.scale || 1;
|
||||
var bw = 40 * s, bh = 50 * s;
|
||||
var body = el("ellipse", { cx: x, cy: y, rx: bw, ry: bh, fill: INK });
|
||||
var legL = el("path", { d: "M" + (x - 12 * s) + " " + (y + bh * 0.92) + " l" + (-4 * s) + " " + (24 * s), stroke: INK, "stroke-width": 7 * s, fill: "none", "stroke-linecap": "round" });
|
||||
var legR = el("path", { d: "M" + (x + 12 * s) + " " + (y + bh * 0.92) + " l" + (4 * s) + " " + (24 * s), stroke: INK, "stroke-width": 7 * s, fill: "none", "stroke-linecap": "round" });
|
||||
var eyeL = el("circle", { cx: x - 12 * s, cy: y - 16 * s, r: 7 * s, fill: "#fff" });
|
||||
var eyeR = el("circle", { cx: x + 12 * s, cy: y - 16 * s, r: 7 * s, fill: "#fff" });
|
||||
var armL = el("path", { d: "", stroke: INK, "stroke-width": 7 * s, fill: "none", "stroke-linecap": "round", "stroke-linejoin": "round" });
|
||||
var armR = el("path", { d: "", stroke: INK, "stroke-width": 7 * s, fill: "none", "stroke-linecap": "round", "stroke-linejoin": "round" });
|
||||
var shoulderL = [x - bw * 0.7, y - 2 * s], shoulderR = [x + bw * 0.7, y - 2 * s];
|
||||
var seg = [32 * s, 30 * s];
|
||||
function poseArm(pathEl, shoulder, target) {
|
||||
var j = fabrik(seg, shoulder, target);
|
||||
pathEl.setAttribute("d", "M" + r2(j[0][0]) + " " + r2(j[0][1]) + " L" + r2(j[1][0]) + " " + r2(j[1][1]) + " L" + r2(j[2][0]) + " " + r2(j[2][1]));
|
||||
}
|
||||
poseArm(armL, shoulderL, [shoulderL[0] - 18 * s, shoulderL[1] + 30 * s]);
|
||||
poseArm(armR, shoulderR, [shoulderR[0] + 18 * s, shoulderR[1] + 30 * s]);
|
||||
var g = el("g", null, [armL, armR, body, legL, legR, eyeL, eyeR]);
|
||||
return {
|
||||
g: g, body: body, eyeL: eyeL, eyeR: eyeR, armL: armL, armR: armR,
|
||||
origin: [x, y], shoulderL: shoulderL, shoulderR: shoulderR,
|
||||
reachL: function (t) { poseArm(armL, shoulderL, t); },
|
||||
reachR: function (t) { poseArm(armR, shoulderR, t); }
|
||||
};
|
||||
}
|
||||
|
||||
root.InkTheater = {
|
||||
el: el, rng: rng, resample: resample, smoothD: smoothD, wobblePts: wobblePts,
|
||||
inkPath: inkPath, inkRibbon: inkRibbon, boil: boil,
|
||||
springEase: springEase, ease: ease, fabrik: fabrik,
|
||||
parts: parts, mascot: mascot, INK: INK
|
||||
};
|
||||
})(window);
|
||||
50
skills/creative/ink-theater.md
Normal file
50
skills/creative/ink-theater.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Ink Theater — hand-drawn "moving art" (creative skill)
|
||||
|
||||
> Style id: `ink-sketch` · Engine: `ink-theater/ink-theater.js` · Runtime: HyperFrames (atelier)
|
||||
> Technique credit: inspired by Ian's `小黑/Xiaohei` MIT skill. Generic, English, motion-first.
|
||||
|
||||
**What it is:** a minimalist **black-ink-on-white** world where a deadpan mascot *physically performs* an abstract idea by operating an absurd **low-tech contraption**. Not a fixed catalog of scenes — a generic method + a parametric engine. Runs on the `animation` pipeline in atelier mode; it is NOT its own pipeline.
|
||||
|
||||
## The generic method (concept → moving scene)
|
||||
|
||||
The "unlock" from research + the Xiaohei composition rules is a 3-step metaphor generator — do this in the agent, not in code:
|
||||
|
||||
1. **Abstract concept → one physical ACTION** — stuck, leaking, compounding, sorting, fermenting, pushing, sinking, unraveling.
|
||||
2. **System → one low-tech OBJECT** — press, funnel, well, jar, conveyor, ladder, bridge, cabinet, boulder, gate, scale.
|
||||
3. **Mascot PERFORMS the action on the object** — it cranks / pushes / pumps / stamps / fishes-out. *If you can delete the mascot and the metaphor survives, it's decoration — redo it.*
|
||||
|
||||
Then stage as beats on one continuous white page with a camera (pan / push).
|
||||
|
||||
## Mined archetypes (pick one, invent fresh specifics)
|
||||
|
||||
| Archetype | Mascot action | Motion recipe |
|
||||
|---|---|---|
|
||||
| **Contraption** | operates a machine (crank/lever/pump) | feed in → crank → machine works (steam/gauge) → output pops |
|
||||
| **Before/after load-shift** | crushed by chaos → relaxed, one key act | left chaos → orange sweep → right structure assembles |
|
||||
| **Journey / pitfalls** | walks a path; falls in pits or hits nodes | path draws on → walk → pit swallows / node pops → blue return loop |
|
||||
| **One → many fan** | splits/cuts one source | source splits → arrows draw to N branches → each branch acts |
|
||||
| **Push / momentum** | shoves a boulder up, it rolls away | slow strained up (power1) → crest → fast roll (power2.in) |
|
||||
| **Well / extract** | lowers a bucket into noise, scoops the gem | bucket descends → scoop → pull up the one good bit |
|
||||
|
||||
## Color grammar (strict)
|
||||
|
||||
- **black** = structure & mascot · **orange** = flow / arrows ONLY · **red** = the problem / warning · **blue** = the good end-state.
|
||||
- Pure white paper, ≥35% negative space, subject ~40–60%. Deadpan mascot (white-dot eyes), never cute.
|
||||
|
||||
## Engine cheat-sheet (`InkTheater`)
|
||||
|
||||
- Lines: `inkPath(pts)`, `inkRibbon(pts,{width,taper})` (brush). Boil: `boil(turbEl, tl, {duration})`.
|
||||
- Motion: `ease.{settle,overshoot,bouncy,soft}` (seek-safe springs) — use overshoot for pops, settle for arrivals, bounce for landings.
|
||||
- Character: `mascot({x,y,scale})` → `.reachL/.reachR([x,y])` (FABRIK). Follow a moving target via GSAP `onUpdate`.
|
||||
- Machines: `parts.{crank,gauge,hopper,slot,lever,box}` — compose them.
|
||||
- Full API + determinism rules: `ink-theater/README.md`.
|
||||
|
||||
## ⚠ Non-negotiables
|
||||
|
||||
- **Captions = HTML overlay `<div>`s, never SVG `<text>`** — HyperFrames doesn't apply webfonts to SVG text (silent serif fallback). Bundle a static handwriting woff2 locally (no hot-link). See `ink-theater/README.md` → "font gotcha".
|
||||
- Determinism: closed-form springs, seed-stepped boil off the timeline, no `repeat:-1`, seeded PRNG only.
|
||||
- One paused `gsap.timeline` on `window.__timelines`. Validate with `lint` + `snapshot` (read the contact-sheet) before render.
|
||||
|
||||
## Reference builds
|
||||
|
||||
- `projects/ink-theater-reel/` — capabilities reel. · `projects/ink-theater-momentum/` — "Momentum" story (handwriting via HTML divs).
|
||||
Reference in New Issue
Block a user