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:
calesthio
2026-06-30 17:03:20 -07:00
parent 299dd4f5fd
commit 0aa986e61b
6 changed files with 642 additions and 0 deletions

57
ink-theater/README.md Normal file
View 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).

Binary file not shown.

View 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>

View 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
View 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);

View 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 ~4060%. 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).