Files
341641d71a [OPEND-3962] fix(motion): 修复预览声音与模板配乐约束 (#8777)
* fix(motion): avoid audio seek thrashing during preview stalls

* fix(motion): keep in-progress video previews silent

* fix(motion): let user briefs override template demo soundtracks

* fix(motion): keep project grid covers muted

All Projects grid and recent-project hover covers loaded motion sources
without odMotionThumbnail, so the embedded player autoplayed the
soundtrack. Covers now always request thumbnail mode: animation keeps
playing, audio stays muted until the project preview is opened.

---------

Co-authored-by: bone3deep1962-collab <bone3deep1962@gmail.com>
Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com>
2026-10-22 04:53:43 +00:00

882 lines
67 KiB
HTML

<!DOCTYPE html>
<html lang="en" data-od-preview-ready="false">
<head>
<meta name="od-motion-output" content="video">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bring Ideas to Life — Open Design Paper Story</title>
<script src="assets/gsap.min.js"></script>
<style>
@font-face{font-family:Stack;src:url('assets/short-stack-regular.ttf') format('truetype');font-display:block}
:root{--bg:#252C6E;--ink:#FBF6EC;--accent:#7CE15A}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;background:#000;overflow:hidden}
#viewport{position:fixed;inset:0;overflow:hidden;background:var(--bg)}
#main{position:absolute;left:50%;top:50%;width:1920px;height:1080px;transform-origin:center;background:var(--bg);overflow:hidden}
#main canvas{display:block;width:1920px;height:1080px}
#controls{position:fixed;left:50%;bottom:14px;transform:translateX(-50%);z-index:100;display:flex;align-items:center;gap:14px;width:min(900px,92vw);padding:12px 18px;border-radius:14px;background:#101214dd;color:#fff;font:600 13px ui-monospace,Menlo,monospace;opacity:0;transition:opacity .2s}
#controls:hover,#controls:focus-within{opacity:1}
#controls button{font:inherit;letter-spacing:.06em;background:var(--accent);color:#0B0B0C;border:0;border-radius:999px;padding:9px 16px;cursor:pointer;min-width:88px}
#controls button:focus-visible,#scrub:focus-visible{outline:3px solid #fff;outline-offset:2px}
#scrub{flex:1;accent-color:var(--accent)}
#tc{min-width:110px;text-align:right;font-variant-numeric:tabular-nums}
/* The OD export bridge marks frame capture; keep preview controls out of the film. */
html.od-export #controls{display:none!important}
html.od-export #main{left:0!important;top:0!important;transform:none!important}
</style>
</head>
<body>
<div id="viewport">
<div id="main" data-composition-id="main" data-start="0" data-duration="20" data-width="1920" data-height="1080">
<canvas id="stage" width="1920" height="1080"></canvas>
<audio id="soundtrack" src="assets/soundtrack.mp3" data-start="0" data-duration="20" data-track-index="2" data-volume="1" preload="auto"></audio>
</div>
</div>
<div id="controls">
<button id="playBtn" type="button" aria-label="Play or pause">PAUSE</button>
<input id="scrub" type="range" min="0" max="20" step="0.01" value="0" aria-label="Timeline position">
<span id="tc">0.00 / 20.00</span>
</div>
<script>
/* ============================================================
What Can You Design? — a 20-second paper-collage short.
A girl asks Open Design what it can design. Five working paper
miniatures fold into a book of possibilities. One canvas, one clock: render(t) draws
every frame purely from timeline time. Every texture (dry brush,
crayon grain, newsprint, handwriting, knit) and every cut-paper
illustration is painted once into an offscreen canvas at load;
frames only composite them.
============================================================ */
const CFG = {
duration: 20, w: 1920, h: 1080, bpm: 120, reducedAt: 6.8,
night: '#262D70', night2: '#30398A', night3: '#1E2462', star: '#26251E', tile: '#26251E', accent: '#7CE15A',
girlSweater: '#2E9C82', skin: '#EDB98F', hair: '#3A2723', clip: '#F2C53D', cheek: '#F09AA0',
ink: '#2B2550', pen: '#3B62B5', red: '#D8443A', paper: '#FFFCF3', wall: '#4A3A8A', yellow: '#F4C443', curtain: '#EE8E96', wood: '#C8914F',
question: ['OpenDesign,', 'what can you', 'design?'],
answer: ['It starts with', 'your idea.'],
invitation: ['What will', 'you', 'design?']
};
const W = CFG.w, H = CFG.h, TAU = Math.PI * 2;
const HAND = 'Stack, "Comic Sans MS", "Chalkboard SE", sans-serif';
const cv = document.getElementById('stage');
let ctx = cv.getContext('2d');
const clamp = (x, a = 0, b = 1) => Math.max(a, Math.min(b, x));
const seg = (t, a, b) => clamp((t - a) / (b - a));
const lerp = (a, b, p) => a + (b - a) * p;
const EZ = { o2: p => 1 - (1 - p) ** 2, o3: p => 1 - (1 - p) ** 3, i2: p => p * p, io2: p => p < .5 ? 2 * p * p : 1 - (-2 * p + 2) ** 2 / 2,
back: p => { const c = 1.9, c3 = c + 1; return 1 + c3 * (p - 1) ** 3 + c * (p - 1) ** 2; } };
function rnd(i) { const s = Math.sin(i * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); }
const hop = (t, a, d = 0.35) => Math.sin(Math.PI * seg(t, a, a + d));
/* ---------- textures, painted once ---------- */
function layer(w, h, draw) { const c = document.createElement('canvas'); c.width = w; c.height = h; draw(c.getContext('2d')); return c; }
// Wrap a stroke so the tile repeats without seams.
function tileStroke(g, size, draw) { for (const dx of [-size, 0, size]) for (const dy of [-size, 0, size]) { g.save(); g.translate(dx, dy); draw(); g.restore(); } }
const TEX = {
// Broad, directional gouache strokes. Bristles follow each stroke; the
// uneven ends and per-sheet rotations avoid the old repeated dash pattern.
brush: layer(1024, 1024, g => {
g.lineCap = 'round';
for (let i = 0; i < 155; i++) {
const x = rnd(i * 1.3) * 1024, y = rnd(i * 2.9) * 1024;
const len = 90 + rnd(i * 4.1) * 380, width = 9 + rnd(i * 5.3) * 34;
const slope = -0.1 + rnd(i * 6.7) * 0.2;
tileStroke(g, 1024, () => {
g.strokeStyle = i % 3 ? 'rgba(255,255,255,.055)' : 'rgba(0,0,0,.035)';
g.lineWidth = width; g.beginPath(); g.moveTo(x, y);
g.quadraticCurveTo(x + len * .45, y + slope * len * .4 - 4, x + len, y + slope * len); g.stroke();
for (let j = 0; j < 9; j++) {
const off = (rnd(i * 13 + j) - .5) * width;
const inset = rnd(i * 21 + j) * 35;
g.strokeStyle = j % 3 ? 'rgba(255,255,255,.045)' : 'rgba(0,0,0,.025)';
g.lineWidth = .4 + rnd(i + j * 7) * 1.2;
g.beginPath(); g.moveTo(x + inset, y + off);
g.quadraticCurveTo(x + len * .5, y + off + slope * len * .45 - 3, x + len - inset, y + off + slope * len); g.stroke();
}
});
}
}),
soft: layer(1024, 1024, g => {
for (let i = 0; i < 110; i++) {
const x = rnd(i * 1.7) * 1024, y = rnd(i * 3.3) * 1024;
g.strokeStyle = i % 2 ? 'rgba(255,255,255,.024)' : 'rgba(0,0,0,.019)';
g.lineWidth = 30 + rnd(i * 5.9) * 70; g.lineCap = 'round';
tileStroke(g, 1024, () => { g.beginPath(); g.moveTo(x, y); g.quadraticCurveTo(x + 160, y - 25, x + 340, y - 34); g.stroke(); });
}
}),
// Paper tooth: fine specks and short fibres.
fiber: layer(256, 256, g => { for (let i = 0; i < 900; i++) { const x = rnd(i * 5.1) * 256, y = rnd(i * 8.7) * 256; g.fillStyle = i % 3 ? `rgba(255,255,255,${0.05 + rnd(i * 1.9) * 0.06})` : `rgba(0,0,0,${0.035 + rnd(i * 2.7) * 0.04})`; g.fillRect(x, y, 1, 1); }
g.lineWidth = 0.8; for (let i = 0; i < 70; i++) { const x = rnd(i * 6.3) * 256, y = rnd(i * 4.4) * 256, a = rnd(i * 9.9) * TAU, l = 3 + rnd(i) * 7; g.strokeStyle = `rgba(0,0,0,${0.04 + rnd(i * 3) * 0.04})`; tileStroke(g, 256, () => { g.beginPath(); g.moveTo(x, y); g.lineTo(x + Math.cos(a) * l, y + Math.sin(a) * l); g.stroke(); }); } }),
// Crayon tooth: sparse specks.
grain: layer(256, 256, g => { for (let i = 0; i < 700; i++) { const x = rnd(i * 3.7) * 256, y = rnd(i * 9.1) * 256, r = 0.6 + rnd(i) * 1.4; g.fillStyle = i % 2 ? `rgba(255,255,255,${0.08 + rnd(i * 2) * 0.1})` : `rgba(0,0,0,${0.04 + rnd(i * 5) * 0.06})`; g.fillRect(x, y, r, r); } }),
// Old newsprint: headline bars, columns of grey word dashes.
news: layer(360, 360, g => { g.fillStyle = 'rgba(40,36,30,.5)';
for (let col = 0; col < 3; col++) { const x0 = 8 + col * 120; let y = 10;
while (y < 350) { if (rnd(col * 31 + y) > 0.86) { g.fillRect(x0, y, 100, 9); y += 18; continue; }
let x = x0; while (x < x0 + 104) { const w = 4 + rnd(x * 1.7 + y * 3.1) * 14; g.fillRect(x, y, Math.min(w, x0 + 106 - x), 2.6); x += w + 3; } y += 7; } } }),
// Hand lettering texture: rows of loopy script.
scrib: layer(420, 300, g => { g.strokeStyle = 'rgba(60,40,25,.42)'; g.lineWidth = 1.6; g.lineCap = 'round';
for (let y = 14; y < 300; y += 20) { let x = 6; while (x < 410) { const w = 18 + rnd(x * 0.9 + y * 2.3) * 46; g.beginPath(); for (let k = 0; k <= w; k += 2) g.lineTo(x + k, y + Math.sin(k * 0.9 + x) * 3.2 - Math.abs(Math.sin(k * 0.45 + y)) * 3); g.stroke(); x += w + 9; } } }),
knit: layer(256, 256, g => {
g.fillStyle = CFG.girlSweater; g.fillRect(0, 0, 256, 256);
for (let i = 0; i < 130; i++) {
const y = rnd(i * 2.3) * 256, x = rnd(i * 5.7) * 256;
g.strokeStyle = i % 3 ? 'rgba(255,255,255,.065)' : 'rgba(0,0,0,.04)'; g.lineWidth = 1 + rnd(i) * 4;
tileStroke(g, 256, () => { g.beginPath(); g.moveTo(x, y); g.lineTo(x + 75 + rnd(i * 8) * 70, y + 30); g.stroke(); });
}
}),
hair: layer(120, 120, g => { g.fillStyle = CFG.hair; g.fillRect(0, 0, 120, 120); g.strokeStyle = 'rgba(255,255,255,.07)'; g.lineWidth = 3; for (let i = 0; i < 26; i++) { const x = rnd(i * 2.2) * 120; tileStroke(g, 120, () => { g.beginPath(); g.moveTo(x, 0); g.quadraticCurveTo(x + 10, 60, x - 4, 120); g.stroke(); }); } }),
};
const PATS = new Map();
function pat(g, name) { let m = PATS.get(g); if (!m) PATS.set(g, m = {}); return m[name] || (m[name] = g.createPattern(TEX[name], 'repeat')); }
// Wash a texture over whatever the current clip allows.
function wash(g, name, alpha, box) { g.save(); g.globalAlpha = alpha; g.fillStyle = pat(g, name); g.fillRect(box[0], box[1], box[2], box[3]); g.restore(); }
/* ---------- cut paper ---------- */
function poly(g, pts) { g.beginPath(); pts.forEach(([x, y], i) => i ? g.lineTo(x, y) : g.moveTo(x, y)); g.closePath(); }
function jag(g, pts, close = true) { g.beginPath(); pts.forEach(([x, y], i) => i ? g.lineTo(x, y) : g.moveTo(x, y)); if (close) g.closePath(); }
function resample(pts, step = 7) { const out = []; for (let i = 0; i < pts.length; i++) { const a = pts[i], b = pts[(i + 1) % pts.length], n = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1]) / step)); for (let k = 0; k < n; k++) out.push([a[0] + (b[0] - a[0]) * k / n, a[1] + (b[1] - a[1]) * k / n]); } return out; }
// Push each outline point outward by a ragged amount: the white fibre rim of torn paper.
function ragged(pts, amp, seed) {
const n = pts.length, cx = pts.reduce((s, p) => s + p[0], 0) / n, cy = pts.reduce((s, p) => s + p[1], 0) / n;
return pts.map((p, i) => { const a = pts[(i - 1 + n) % n], b = pts[(i + 1) % n]; let nx = b[1] - a[1], ny = a[0] - b[0]; const l = Math.hypot(nx, ny) || 1; nx /= l; ny /= l;
if ((p[0] - cx) * nx + (p[1] - cy) * ny < 0) { nx = -nx; ny = -ny; } const r = amp * (0.25 + rnd(seed + i * 1.37) * 1.1 + (rnd(seed + Math.floor(i / 5)) > 0.8 ? 0.8 : 0)); return [p[0] + nx * r, p[1] + ny * r]; });
}
function bbox(pts) { let a = 1e9, b = 1e9, c = -1e9, d = -1e9; pts.forEach(([x, y]) => { a = Math.min(a, x); b = Math.min(b, y); c = Math.max(c, x); d = Math.max(d, y); }); return [a - 4, b - 4, c - a + 8, d - b + 8]; }
// A cut-paper shape: ragged white rim, flat paint, dry brush, grain, optional print.
function cut(g, pts, color, o = {}) {
const P = resample(pts, o.step || 5), seed = o.seed || 1, box = bbox(P);
const edge = ragged(P, o.ground ? 1.5 : 0.8, seed + 97);
if (o.rim !== 0) {
g.fillStyle = o.rimColor || CFG.paper;
poly(g, ragged(P, o.rim || 3.2, seed)); g.fill();
}
g.save(); poly(g, edge); g.fillStyle = color; g.fill(); g.clip();
g.save(); g.translate(box[0], box[1]);
g.rotate(o.angle ?? (rnd(seed * 2.3) - .5) * .7);
const extent = Math.hypot(box[2], box[3]) + 80;
wash(g, 'brush', o.brush ?? (o.ground ? .8 : 1), [-extent, -extent, extent * 2, extent * 2]);
if (o.ground) wash(g, 'soft', .6, [-extent, -extent, extent * 2, extent * 2]);
g.restore();
wash(g, 'fiber', .9, box); wash(g, 'grain', o.grainA ?? .45, box);
if (o.print) wash(g, o.print, o.printA ?? .55, box);
g.restore();
// A few loose fibres, attached to the paper, never animated noise.
if (o.rim !== 0) {
g.save(); g.strokeStyle = 'rgba(255,252,243,.65)'; g.lineWidth = .65;
const fibres = ragged(P, (o.rim || 3.2) + 1.7, seed);
for (let i = 0; i < P.length; i += 9) { g.beginPath(); g.moveTo(...P[i]); g.lineTo(...fibres[i]); g.stroke(); }
g.restore();
}
}
const ell = (cx, cy, rx, ry, seed = 1, jit = 0.025, n = 56) => Array.from({ length: n }, (_, i) => { const a = i / n * TAU, r = 1 + (rnd(seed + i) - 0.5) * jit * 2; return [cx + Math.cos(a) * rx * r, cy + Math.sin(a) * ry * r]; });
const box4 = (x, y, w, h, seed = 1, j = 2) => [[x + (rnd(seed) - .5) * j, y + (rnd(seed + 1) - .5) * j], [x + w + (rnd(seed + 2) - .5) * j, y + (rnd(seed + 3) - .5) * j], [x + w + (rnd(seed + 4) - .5) * j, y + h + (rnd(seed + 5) - .5) * j], [x + (rnd(seed + 6) - .5) * j, y + h + (rnd(seed + 7) - .5) * j]];
function arcPts(cx, cy, r, a0, a1, n = 30) { return Array.from({ length: n + 1 }, (_, i) => { const a = a0 + (a1 - a0) * i / n; return [cx + Math.cos(a) * r, cy + Math.sin(a) * r]; }); }
// Every ground has its own brush direction, with no per-frame texture changes.
function sheet(color, seed, o = {}) { return layer(W, H, g => {
cut(g, [[-40,-40],[W+40,-40],[W+40,H+40],[-40,H+40]], color,
{ ...o, seed, rim: 0, ground: true, brush: o.brush ?? .8 });
}); }
/* ---------- lettering ---------- */
function hand(s, x, y, size, color, o = {}) {
const g = o.g || ctx; g.save(); g.font = `${size}px ${HAND}`; g.textAlign = o.align || 'center'; g.textBaseline = 'alphabetic'; g.fillStyle = color; g.globalAlpha *= o.a ?? 1;
g.translate(x, y); if (o.rot) g.rotate(o.rot); g.lineJoin = 'round';
if (o.rim) { g.strokeStyle = CFG.paper; g.lineWidth = size * 0.16; g.strokeText(s, 0, 0); }
g.lineWidth = size * 0.035; g.strokeStyle = color; g.strokeText(s, 0, 0); g.fillText(s, 0, 0); g.restore();
}
function writeText(s, x, y, size, color, p, g = ctx) {
g.save(); g.font = `${size}px ${HAND}`; g.textBaseline = 'alphabetic'; g.textAlign = 'left';
const total = g.measureText(s).width, cut2 = total * clamp(p);
g.beginPath(); g.rect(x - 10, y - size * 1.2, cut2 + 10, size * 1.6); g.clip();
g.fillStyle = color; g.strokeStyle = color; g.lineWidth = size * 0.035; g.lineJoin = 'round'; g.strokeText(s, x, y); g.fillText(s, x, y); g.restore();
return [x + cut2, y - size * 0.3];
}
function sparkle(x, y, r, col, rot = 0, g = ctx) { g.save(); g.translate(x, y); g.rotate(rot); g.fillStyle = col; g.beginPath();
for (let i = 0; i < 8; i++) { const a = i / 8 * TAU, rr = i % 2 ? r * 0.28 : r; g.lineTo(Math.cos(a) * rr, Math.sin(a) * rr); } g.closePath(); g.fill(); g.restore(); }
function star5(g, x, y, r, col, rot = 0) { g.save(); g.translate(x, y); g.rotate(rot); g.fillStyle = col; g.beginPath(); for (let k = 0; k < 10; k++) { const a = k / 10 * TAU - Math.PI / 2, rr = k % 2 ? r * 0.45 : r; g.lineTo(Math.cos(a) * rr, Math.sin(a) * rr); } g.closePath(); g.fill(); g.restore(); }
function plus(x, y, r, col, g = ctx) { g.strokeStyle = col; g.lineWidth = r * 0.35; g.lineCap = 'round'; g.beginPath(); g.moveTo(x - r, y); g.lineTo(x + r, y); g.moveTo(x, y - r); g.lineTo(x, y + r); g.stroke(); }
// Sprites that carry lettering wait for the hand font; until then they are drawn fresh each frame.
const fontReady = () => document.fonts.check('10px Stack');
function textSprite(w, h, draw) { let c = null; return () => c || (fontReady() ? (c = layer(w, h, draw)) : layer(w, h, draw)); }
const rrPts = (x, y, w, h, r, n = 8) => { const p = []; [[x + w - r, y + r, -Math.PI / 2], [x + w - r, y + h - r, 0], [x + r, y + h - r, Math.PI / 2], [x + r, y + r, Math.PI]].forEach(([cx, cy, a0]) => { for (let i = 0; i <= n; i++) { const a = a0 + i / n * Math.PI / 2; p.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]); } }); return p; };
// A limb: paper rim, then paint, then a round paw.
function limb(g, pts, w, color, pawR, pawColor, o = {}) {
for (let i = 1; i < pts.length; i++) {
const [x0,y0]=pts[i-1],[x1,y1]=pts[i],a=Math.atan2(y1-y0,x1-x0),r=w/2;
const shape=[...arcPts(x0,y0,r,a+Math.PI/2,a+Math.PI*1.5,12),...arcPts(x1,y1,r,a-Math.PI/2,a+Math.PI/2,12)];
cut(g,shape,color,{rim:1.8,step:3,seed:950+i,brush:.8,angle:a,print:o.pat,printA:o.pat?.45:0});
}
const [hx,hy]=pts[pts.length-1];
if(pawR)cut(g,ell(hx,hy,pawR,pawR*1.08,955,.035,26),pawColor,{rim:1.4,step:3,seed:955,brush:.4});
}
/* ---------- the Open Design buddy (from the logo mark) ---------- */
// Body painted once at R = 320: a dark cut-paper tile with the logo's white ring and green cursor badge.
const BUD_R = 320, BUD_S = BUD_R * 0.92, BUD_r = BUD_S * 0.62;
const ringPts = (r) => { const p = arcPts(0, 0, r, -Math.PI / 2, Math.PI / 2, 24); p.push([-r * 0.88, r]); p.push(...arcPts(-r * 0.88, r * 0.88, r * 0.12, Math.PI / 2, Math.PI, 4)); p.push(...arcPts(0, 0, r, Math.PI, Math.PI * 1.5, 16)); return p; };
const BUD = layer(700, 700, g => { g.translate(350, 350); const S = BUD_S, r = BUD_r;
cut(g, rrPts(-S, -S, S * 2, S * 2, S * 0.62, 10), CFG.tile, { rim: 6, seed: 7, brush: 0.55, grainA: 0.5, shade: false });
cut(g, ringPts(r), '#F7F2E6', { rim: 0, seed: 8, brush: 0.6, grainA: 0.5 });
cut(g, ell(0, 0, r * 0.8, r * 0.8, 9, 0.01), CFG.tile, { rim: 0, seed: 9, brush: 0.55, grainA: 0.5, shade: false });
g.save(); g.translate(S * 0.62, S * 0.6); g.scale(2.6, 2.6); cut(g, [[-14, -16], [18, -3], [3, 2], [-3, 18]], CFG.accent, { rim: 1.6, step: 3, seed: 10, brush: 0.6 }); g.restore();
g.fillStyle = CFG.cheek; g.globalAlpha = 0.8; [-1, 1].forEach(s => { g.beginPath(); g.ellipse(s * r * 0.47, r * 0.16, r * 0.11, r * 0.07, 0, 0, TAU); g.fill(); }); });
// expr: smile | o | closed | content | wow | squint
function buddy(cx, cy, R, t, expr = 'smile', o = {}) {
const g = o.g || ctx; g.save(); g.translate(cx, cy); g.rotate((o.rot || 0) + Math.sin(t * 3 + (o.ph || 0)) * 0.03 * (o.wiggle ?? 1));
const k = R / BUD_R; g.scale(k * (o.sx || 1), k * (o.sy || 1)); g.drawImage(BUD, -350, -350);
const r = BUD_r, ex = r * 0.3, ey = -r * 0.12, er = r * 0.075, fc = '#FFFCF3', lx = (o.look || 0) * r * 0.06;
g.strokeStyle = fc; g.fillStyle = fc; g.lineWidth = r * 0.06; g.lineCap = 'round';
if (expr === 'closed' || expr === 'content') [-1, 1].forEach(s => { g.beginPath(); g.arc(s * ex, ey + er, er * 1.35, Math.PI * 1.15, Math.PI * 1.85); g.stroke(); });
else if (expr === 'squint') [-1, 1].forEach(s => { g.beginPath(); g.moveTo(s * ex - er * s, ey - er * 0.8); g.lineTo(s * ex + er * s, ey); g.lineTo(s * ex - er * s, ey + er * 0.8); g.stroke(); });
else [-1, 1].forEach(s => { const rr = expr === 'wow' ? er * 1.45 : er; g.beginPath(); g.arc(s * ex + lx, ey, rr, 0, TAU); g.fill(); g.fillStyle = CFG.tile; g.beginPath(); g.arc(s * ex + lx + rr * 0.3, ey - rr * 0.3, rr * 0.3, 0, TAU); g.fill(); g.fillStyle = fc; });
if (expr === 'o' || expr === 'wow') { g.beginPath(); g.ellipse(0, r * 0.26, r * 0.08, r * 0.11, 0, 0, TAU); g.fill(); }
else if (expr === 'content' || expr === 'grin') { g.beginPath(); g.arc(0, r * 0.12, r * 0.19, 0.05 * Math.PI, 0.95 * Math.PI); g.closePath(); g.fill(); }
else { g.beginPath(); g.arc(0, r * 0.14, r * 0.17, 0.15 * Math.PI, 0.85 * Math.PI); g.stroke(); }
g.restore();
}
// One soft paper mitten per side. Move the buddy toward a prop instead of stretching its arms.
function buddyArms(cx, cy, R, hands, g = ctx) {
hands.forEach(([tx, ty], i) => {
const s = i ? 1 : -1, sx = cx + s * R * .78;
const sy = cy + Math.max(-R * .48, Math.min(R * .7, (ty - cy) * .8));
const dx = tx - sx, dy = ty - sy, reach = Math.min(1, R * .6 / Math.max(1, Math.hypot(dx, dy)));
const hx = sx + dx * reach, hy = sy + dy * reach, a = Math.atan2(hy - sy, hx - sx);
const shape = [...arcPts(sx, sy, R * .15, a + Math.PI / 2, a + Math.PI * 1.5, 12),
...arcPts(hx, hy, R * .19, a - Math.PI / 2, a + Math.PI / 2, 14)];
cut(g, shape, CFG.tile, { rim: Math.max(1, R * .006), step: 3, seed: 950 + i, brush: .45, grainA: .5, shade: false, angle: a });
});
}
/* ---------- the girl ---------- */
// Body and head painted once at 2.3x; arms and the face are drawn live.
const GS = 2.3;
const GIRL_BODY = layer(500, 340, g => { g.translate(250, -18 * GS + 6); g.scale(GS, GS);
const sw = [[-95, 150], ...curve([-95, 150], [-92, 40], [-42, 30], 10), ...curve([42, 30], [92, 40], [95, 150], 10)];
cut(g, sw, CFG.girlSweater, { rim: 2.6, seed: 9, print: 'knit', printA: 0.85, brush: 0.5 });
g.strokeStyle = 'rgba(255,255,255,.18)'; g.lineWidth = 2; [[-1], [1]].forEach(([s]) => { g.beginPath(); g.moveTo(s * 60, 40); g.quadraticCurveTo(s * 70, 90, s * 66, 150); g.stroke(); });
// pointed white collar
[-1, 1].forEach(s => cut(g, [[0, 30], [s * 46, 24], [s * 44, 34], [s * 24, 60], [s * 4, 44]].map(([x, y]) => [x, y]), '#FBF7EC', { rim: 1.6, rimColor: '#D8D2C4', step: 4, seed: 12 + s, brush: 0.3 })); });
const GIRL_HEAD = layer(440, 380, g => { g.translate(220, 275); g.scale(GS, GS);
const back = [...arcPts(0, -40, 76, Math.PI, TAU, 24), [78, 20], ...curve([78, 20], [60, 32], [44, 18], 6), [-44, 18], ...curve([-44, 18], [-60, 32], [-78, 20], 6)];
cut(g, back, CFG.hair, { rim: 2.4, seed: 20, print: 'hair', printA: 0.8, brush: 0.4 });
cut(g, ell(0, -10, 54, 58, 21, 0.01), CFG.skin, { rim: 1.8, seed: 21, brush: 0.65, grainA: 0.5 });
g.strokeStyle = 'rgba(160,90,60,.25)'; g.lineWidth = 3; g.beginPath(); g.arc(-54, -6, 8, -1.2, 1.2); g.stroke();
const bangs = [[-62, -16], ...curve([-62, -16], [-60, -100], [0, -104], 12), ...curve([0, -104], [60, -100], [64, -16], 12), [44, -38], [10, -44], [-28, -46]];
cut(g, bangs, CFG.hair, { rim: 1.4, seed: 22, print: 'hair', printA: 0.8, brush: 0.4 });
g.save(); g.translate(30, -66); g.rotate(-0.4); cut(g, box4(-22, -7, 44, 14, 23, 1), CFG.clip, { rim: 1.6, step: 4, seed: 23, brush: 0.5 }); g.restore();
g.fillStyle = CFG.cheek; g.globalAlpha = 0.85; [-1, 1].forEach(sx => { g.beginPath(); g.ellipse(sx * 31, 7, 12, 9, 0, 0, TAU); g.fill(); }); });
// pose: desk | idea | throw | hold | hug | post | wave | write ; expr: smile | o | grin | closed
function girl(x, y, s, t, pose = 'desk', expr = 'smile', o = {}) {
const g = ctx; g.save(); g.translate(x, y); g.scale(s / GS, s / GS); g.drawImage(GIRL_BODY, -250, 18 * GS - 6); g.scale(GS, GS);
const sk = CFG.skin, arm = (x1, y1, x2, y2, handR = 16) => limb(g, [[x1, y1], [x1 + (x2-x1)*.55, y1 + (y2-y1)*.56 + 12], [x2, y2]], 30, CFG.girlSweater, handR, sk, { pat: 'knit' });
const tt = o.armT ?? 0;
if (o.book || pose === 'hug') booklet(0, 89, .245, 0, t);
if (o.parcel) { const p = o.parcel; at(p.x,p.y,1,p.rot,()=>{
if(p.wrap>0)at(0,0,p.scale/.54,0,()=>wrappedReply(0,0,p.wrap,t,Math.PI));
else booklet(0,0,p.scale,p.open,t);
}); }
if (pose === 'desk') { arm(-70, 70, -20, 128); arm(70, 70, 30, 132); }
else if (pose === 'write') { arm(-70, 70, -20, 128); arm(70, 70, 40 + Math.sin(t * 22) * 6, 120); g.strokeStyle = CFG.pen; g.lineWidth = 8; g.lineCap = 'round'; g.beginPath(); g.moveTo(40 + Math.sin(t * 22) * 6, 120); g.lineTo(18 + Math.sin(t * 22) * 6, 100); g.stroke(); }
else if (pose === 'idea') { arm(-70, 70, -20, 128); g.strokeStyle = CFG.pen; g.lineWidth = 8; g.lineCap = 'round'; g.beginPath(); g.moveTo(108, -2 - 20 * tt); g.lineTo(122, -40 - 20 * tt); g.stroke(); arm(68, 66, 108, 0 - 20 * tt); }
else if (pose === 'throw') { arm(-70, 70, -20, 128); arm(68, 66, lerp(80, 150, tt), lerp(20, -70, tt)); }
else if (pose === 'wave') { arm(-70, 70, -20, 128); arm(68, 66, 120 + Math.sin(t * 10) * 14, -50); }
else if (pose === 'hold') {
if (o.parcel) { const p = o.parcel; for (const side of [-1,1]) {
const hx=side*lerp(75,274,1-(p.wrap||0))*p.scale,hy=66*p.scale;
arm(side*70,70,p.x+hx*Math.cos(p.rot)-hy*Math.sin(p.rot),p.y+hx*Math.sin(p.rot)+hy*Math.cos(p.rot),13);
} } else { arm(-70,70,-50,112); arm(70,70,50,112); }
}
else if (pose === 'hug') { arm(-70, 70, lerp(-49, 24, tt), lerp(119, 100, tt)); arm(70, 70, lerp(49, -19, tt), lerp(119, 91, tt)); }
else if (pose === 'post') { arm(-70, 70, -6, 88); arm(70, 70, o.handX, o.handY); }
g.save(); g.translate(0, 16); g.rotate((o.headTilt ?? (pose === 'write' ? -.045 : pose === 'throw' ? -.06 : .015)) + Math.sin(Math.floor(t * 12) / 12 * 2.4) * .018); g.translate(0, -16);
g.scale(1 / GS, 1 / GS); g.drawImage(GIRL_HEAD, -220, -275); g.scale(GS, GS);
g.fillStyle = '#2A1A18'; g.strokeStyle = '#2A1A18'; g.lineWidth = 4; g.lineCap = 'round';
if (expr === 'closed' || o.blink) [-1, 1].forEach(sx => { g.beginPath(); g.arc(sx * 18, -10, 7, Math.PI * 1.1, Math.PI * 1.9); g.stroke(); });
else [-1, 1].forEach(sx => { const ey = -13, look = o.look || (pose === 'write' ? -.5 : 0);
g.fillStyle = CFG.paper; g.beginPath(); g.ellipse(sx * 18, ey, 9.5, 11, 0, 0, TAU); g.fill();
g.fillStyle = '#2A1A18'; g.beginPath(); g.arc(sx * 18 + look * 2, ey - 3 - look * 3, 5.5, 0, TAU); g.fill(); });
g.strokeStyle = '#2A1A18'; g.lineWidth = 2.3;
[-1, 1].forEach(sx => { g.beginPath(); g.moveTo(sx * 18 - 6, -34); g.lineTo(sx * 18 + 6, -35 - (expr === 'o' ? sx * 3 : 0)); g.stroke(); });
g.strokeStyle = '#C68D69'; g.lineWidth = 2; g.beginPath(); g.arc(0, 1, 3, .1, Math.PI - .1); g.stroke();
g.strokeStyle = '#2A1A18'; g.lineWidth = 3;
if (expr === 'o') { g.beginPath(); g.ellipse(0, 18, 6, 7, 0, 0, TAU); g.fill(); }
else if (expr === 'grin') { g.fillStyle = '#7A2A2A'; g.beginPath(); g.arc(0, 12, 14, 0, Math.PI); g.fill(); g.fillStyle = '#E8737A'; g.beginPath(); g.ellipse(0, 21, 7, 4, 0, 0, TAU); g.fill(); }
else { g.beginPath(); g.arc(0, 10, 10, 0.2 * Math.PI, 0.8 * Math.PI); g.stroke(); }
g.restore(); g.restore();
}
// Big foreground arms (desk close-ups).
function bigArm(x1, y1, x2, y2, handR = 64) { ctx.save(); ctx.translate(x1, y1); ctx.scale(3.6, 3.6); limb(ctx, [[0, 0], [(x2 - x1) / 3.6, (y2 - y1) / 3.6]], 30, CFG.girlSweater, handR / 3.6, CFG.skin, { pat: 'knit' }); ctx.restore(); }
/* ---------- paper plane, folded from newsprint ---------- */
const PLANE = layer(150, 90, g => { g.translate(75, 45); g.scale(2.4, 2.4);
cut(g, [[30, 0], [-22, -14], [-12, 0]], '#F4F1EA', { rim: 0, step: 3, seed: 30, print: 'news', printA: 0.35, brush: 0.2 });
cut(g, [[30, 0], [-22, 12], [-12, 0]], '#D9D4C8', { rim: 0, step: 3, seed: 31, print: 'news', printA: 0.3, brush: 0.2 });
g.fillStyle = '#FFFFFF'; jag(g, [[30, 0], [-16, -2], [-8, 4]]); g.fill(); });
function plane(x, y, s, rot, g = ctx) { g.save(); g.translate(x, y); g.rotate(rot); g.scale(s / 2.4, s / 2.4); g.drawImage(PLANE, -75, -45); g.restore(); }
function dashed(pts, p, col = 'rgba(255,255,255,.7)', w = 3) {
ctx.save(); ctx.strokeStyle = col; ctx.lineWidth = w; ctx.setLineDash([12, 12]); ctx.lineCap = 'round'; ctx.beginPath();
const n = Math.max(2, Math.floor(pts.length * clamp(p))); pts.slice(0, n).forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); ctx.stroke(); ctx.restore();
}
function curve(a, c, b, n = 40) { return Array.from({ length: n }, (_, i) => { const k = i / (n - 1); return [(1 - k) ** 2 * a[0] + 2 * (1 - k) * k * c[0] + k * k * b[0], (1 - k) ** 2 * a[1] + 2 * (1 - k) * k * c[1] + k * k * b[1]]; }); }
function along(pts, p) { const i = clamp(p) * (pts.length - 1), k = Math.min(pts.length - 2, Math.floor(i)), f = i - k, a = pts[k], b = pts[k + 1]; return [lerp(a[0], b[0], f), lerp(a[1], b[1], f), Math.atan2(b[1] - a[1], b[0] - a[0])]; }
/* ---------- more prints for houses ---------- */
TEX.staff = layer(300, 120, g => { g.strokeStyle = 'rgba(255,255,255,.22)'; g.lineWidth = 1.5; for (let k = 0; k < 5; k++) { g.beginPath(); g.moveTo(0, 20 + k * 9); g.lineTo(300, 20 + k * 9); g.stroke(); }
g.fillStyle = 'rgba(255,255,255,.25)'; for (let i = 0; i < 9; i++) { const x = 16 + i * 32, y = 22 + rnd(i * 4) * 36; g.beginPath(); g.ellipse(x, y, 5, 3.6, -0.4, 0, TAU); g.fill(); g.fillRect(x + 4, y - 26, 1.6, 26); } });
TEX.contour = layer(400, 400, g => { g.strokeStyle = 'rgba(255,255,255,.16)'; g.lineWidth = 2; for (let k = 0; k < 9; k++) tileStroke(g, 400, () => { g.beginPath(); for (let x = -10; x <= 410; x += 10) g.lineTo(x, k * 46 + Math.sin(x * 0.02 + k) * 14 + Math.sin(x * 0.05 + k * 2) * 5); g.stroke(); }); });
// Light versions of the dark prints, for dark paper.
function lightPrint(name, a) { const s = TEX[name]; return layer(s.width, s.height, g => { g.drawImage(s, 0, 0); g.globalCompositeOperation = 'source-in'; g.fillStyle = `rgba(255,255,255,${a})`; g.fillRect(0, 0, s.width, s.height); }); }
TEX.scribLight = lightPrint('scrib', 0.55); TEX.newsLight = lightPrint('news', 0.35);
/* ---------- baked backdrops ---------- */
const NIGHT = layer(W, H, g => {
g.drawImage(sheet(CFG.night, 1), 0, 0, W, H);
[[330, CFG.night2], [560, '#2A3380'], [780, CFG.night2]].forEach(([y0, c], k) => {
const pts = [[-20, H + 20]]; for (let x = -20; x <= W + 20; x += 30) pts.push([x, y0 + Math.sin(x * 0.004 + k * 2) * 24 + (rnd(x + k * 99) - 0.5) * 12]); pts.push([W + 20, H + 20]);
cut(g, pts, c, { rim: 3.4, step: 6, seed: 30 + k, ground: true, shade: false }); });
for (let i = 0; i < 70; i++) { const x = rnd(i * 3.3) * W, y = rnd(i * 7.1) * 760; g.fillStyle = '#F6DF7E'; g.globalAlpha = 0.9; if (i % 3) star5(g, x, y, 6 + rnd(i) * 5, '#F3D86E', rnd(i * 2)); else plus(x, y, 6, 'rgba(255,255,255,.55)', g); }
g.globalAlpha = 1;
const crescent = []; for (let i = 0; i <= 24; i++) { const th = -Math.PI / 2 + i / 24 * Math.PI; crescent.push([250 - 74 * Math.cos(th), 160 + 74 * Math.sin(th)]); }
for (let i = 24; i >= 0; i--) { const th = -Math.PI / 2 + i / 24 * Math.PI; crescent.push([250 - 30 * Math.cos(th), 160 + 74 * Math.sin(th)]); }
cut(g, crescent, '#F4EBCF', { rim: 2.6, step: 5, seed: 33, brush: 0.4, print: 'scrib', printA: 0.25 });
});
// Torn house shapes: square body plus a gable or a tower spire.
function house(g, x, y, w, h, roof, c, seed, print) {
const rh = roof === 'tower' ? w * 0.9 : w * 0.5;
cut(g, box4(x, y, w, h, seed, 3), c, { rim: 2.8, seed, print, printA: 0.9, brush: 0.9 });
cut(g, [[x - 14, y + 4], [x + w / 2, y - rh], [x + w + 14, y + 4]], '#151B4A', { rim: 2.8, seed: seed + 1, brush: 0.8 });
const cols = Math.max(1, Math.floor((w - 30) / 56)), rows = Math.floor((h - 40) / 70);
for (let r = 0; r < rows; r++) for (let q = 0; q < cols; q++) { if (rnd(seed * 3 + r * 7 + q) < 0.3) continue;
const wx = x + (w - cols * 56) / 2 + q * 56 + 12, wy = y + 34 + r * 70; g.fillStyle = '#F5CE55'; g.fillRect(wx, wy, 30, 36); g.fillStyle = 'rgba(255,255,255,.25)'; g.fillRect(wx, wy, 30, 5);
g.fillStyle = '#2A2C5E'; g.fillRect(wx + 13.5, wy, 3, 36); g.fillRect(wx, wy + 16, 30, 3); }
}
const HOUSE_PRINTS = ['newsLight', 'staff', 'contour', 'scribLight'], HOUSE_COLORS = ['#1E2660', '#262F70', '#2C2F6A'];
function town_houses(g, list) { list.forEach(([x, y, w, h, roof, k]) => house(g, x, y, w, h, roof, HOUSE_COLORS[k % 3], 50 + k * 7, HOUSE_PRINTS[k % 4])); }
const SKYLINE = layer(W, H, g => {
town_houses(g, [[40, 840, 180, 300, 'gable', 0], [230, 760, 120, 380, 'tower', 1], [360, 860, 200, 260, 'gable', 2], [570, 800, 150, 300, 'gable', 3], [730, 720, 110, 400, 'tower', 4], [850, 850, 210, 260, 'gable', 5], [1070, 790, 160, 320, 'gable', 6], [1240, 740, 120, 380, 'tower', 7], [1370, 860, 210, 250, 'gable', 8], [1590, 800, 160, 300, 'gable', 9], [1760, 860, 200, 250, 'tower', 10]]);
cat(g, 790, 600, 0.9);
});
function cat(g, x, y, s) { g.save(); g.translate(x, y); g.scale(s, s); g.fillStyle = '#10143A';
g.beginPath(); g.ellipse(0, 26, 24, 30, 0, 0, TAU); g.fill(); g.beginPath(); g.arc(0, -10, 18, 0, TAU); g.fill();
jag(g, [[-16, -16], [-12, -36], [-3, -22]]); g.fill(); jag(g, [[16, -16], [12, -36], [3, -22]]); g.fill();
g.strokeStyle = '#10143A'; g.lineWidth = 7; g.lineCap = 'round'; g.beginPath(); g.moveTo(18, 48); g.quadraticCurveTo(48, 50, 44, 18); g.stroke();
g.fillStyle = '#F5CE55'; g.beginPath(); g.ellipse(-7, -11, 3, 2, 0, 0, TAU); g.ellipse(7, -11, 3, 2, 0, 0, TAU); g.fill(); g.restore(); }
// The girl's house wall: purple card with a handwriting print.
function wallSheet(g, pts, seed) { cut(g, pts, CFG.wall, { rim: 3.2, seed, print: 'scribLight', printA: 0.4, ground: true }); }
function vine(g, x0, y0, y1, seed) { g.strokeStyle = '#2F6B3A'; g.lineWidth = 5; g.lineCap = 'round'; g.beginPath(); for (let y = y0; y <= y1; y += 10) g.lineTo(x0 + Math.sin(y * 0.02 + seed) * 12, y); g.stroke();
for (let y = y0 + 14, i = 0; y < y1; y += 34, i++) { const x = x0 + Math.sin(y * 0.02 + seed) * 12, s = i % 2 ? 1 : -1; g.save(); g.translate(x + s * 16, y); g.rotate(s * 0.6); cut(g, ell(0, 0, 15, 9, seed + i, 0.04, 16), i % 3 ? '#62B064' : '#4E9B5A', { rim: 1.6, step: 4, seed: seed + i, brush: 0.4, shade: false }); g.restore(); } }
function windowFrame(g, x, y, w, h, b, seed) {
cut(g, box4(x - b, y - b, w + b * 2, h + b * 2, seed, 3), '#EDE2C8', { rim: 3, seed, print: 'scrib', printA: 0.35, brush: 0.5 });
cut(g, box4(x, y, w, h, seed + 2, 2), CFG.yellow, { rim: 0, seed: seed + 2, ground: true });
const cw = w * 0.17; cut(g, [[x, y], [x + cw, y], [x + cw * 0.5, y + h * 0.48], [x + cw * 0.8, y + h], [x, y + h]], CFG.curtain, { rim: 2.4, seed: seed + 3, brush: 1 });
cut(g, [[x + w, y], [x + w - cw, y], [x + w - cw * 0.5, y + h * 0.48], [x + w - cw * 0.8, y + h], [x + w, y + h]], CFG.curtain, { rim: 2.4, seed: seed + 4, brush: 1 });
}
function sill(g, x, y, w, seed) { cut(g, box4(x, y, w, 34, seed, 2), '#C79A5E', { rim: 2.6, seed, brush: 1 }); }
function pot(g, x, y, s, seed) { g.save(); g.translate(x, y); g.scale(s, s);
[[-1], [1]].forEach(([d]) => { g.save(); g.translate(d * 12, -52); g.rotate(d * 0.45); cut(g, ell(0, 0, 9, 22, seed + d, 0.03, 16), '#4C9E52', { rim: 1.4, step: 4, seed: seed + d, brush: 0.4, shade: false }); g.restore(); });
g.strokeStyle = '#3C7A46'; g.lineWidth = 3; g.beginPath(); g.moveTo(0, -40); g.lineTo(0, -78); g.stroke();
for (let i = 0; i < 5; i++) { const a = i / 5 * TAU; g.fillStyle = CFG.red; g.beginPath(); g.arc(Math.cos(a) * 8, -84 + Math.sin(a) * 8, 6, 0, TAU); g.fill(); } g.fillStyle = CFG.clip; g.beginPath(); g.arc(0, -84, 5, 0, TAU); g.fill();
cut(g, [[-26, -40], [26, -40], [20, 0], [-20, 0]], '#C2643E', { rim: 1.8, step: 4, seed: seed + 5, brush: 0.6 }); g.restore(); }
const TOWN = layer(W, H, g => {
town_houses(g, [[1110, 680, 120, 420, 'tower', 0], [1240, 760, 190, 320, 'gable', 1], [1440, 650, 110, 450, 'tower', 2], [1560, 780, 200, 300, 'gable', 3], [1770, 700, 170, 380, 'gable', 4]]);
cat(g, 1495, 448, 1);
town_houses(g, [[1020, 880, 200, 220, 'gable', 5], [1350, 900, 210, 200, 'gable', 6], [1680, 920, 260, 180, 'gable', 7]]);
// the girl's house
wallSheet(g, [[-30, 330], [1060, 278], [1060, H + 30], [-30, H + 30]], 61);
cut(g, [[-30, 286], [1080, 232], [1080, 284], [-30, 340]], '#1A1E4E', { rim: 3, seed: 62, ground: true });
vine(g, 400, 330, H, 1);
windowFrame(g, 505, 505, 490, 420, 36, 70);
sill(g, 450, 925, 600, 72);
pot(g, 935, 925, 1.05, 73);
vine(g, 60, 360, H, 4);
});
const ROOM = layer(W, H, g => {
wallSheet(g, [[-30, -30], [W + 30, -30], [W + 30, H + 30], [-30, H + 30]], 80);
// a neighbour's window, lamp on
g.save(); g.translate(1690, 230); g.rotate(-0.01); cut(g, box4(0, 0, 280, 360, 82, 3), '#EDE2C8', { rim: 3, seed: 82, print: 'scrib', printA: 0.35, brush: 0.5 });
cut(g, box4(22, 22, 236, 316, 83), '#F2B85A', { rim: 0, seed: 83, brush: 1 }); g.fillStyle = '#2A2C5E'; g.fillRect(136, 22, 8, 316); g.fillRect(22, 170, 236, 8);
cut(g, [[22, 22], [100, 22], [60, 338], [22, 338]], '#6CA8D8', { rim: 2, seed: 84, brush: 0.8 }); g.restore();
vine(g, 250, -30, H, 6); vine(g, 1600, 600, H, 9);
windowFrame(g, 440, -40, 1040, 970, 40, 86);
// the buddy drawing taped to the window
g.save(); g.translate(1290, 170); g.rotate(0.06); cut(g, box4(-80, -90, 160, 180, 88, 3), CFG.paper, { rim: 0, seed: 88, brush: 0.2, grainA: 0.4 }); cut(g, box4(-30, -104, 60, 22, 89, 2), 'rgba(244,197,67,.85)', { rim: 0, seed: 89, brush: 0.2 });
buddy(0, -10, 52, 0, 'smile', { g, wiggle: 0 }); g.strokeStyle = '#4C9E52'; g.lineWidth = 4; g.beginPath(); g.moveTo(-40, 62); g.lineTo(40, 58); g.stroke(); g.restore();
sill(g, 380, 930, 1160, 90); cut(g, [[-30, 964], [W + 30, 964], [W + 30, H + 30], [-30, H + 30]], '#3D2E6E', { rim: 3, seed: 91, print: 'scribLight', printA: 0.3, ground: true });
pot(g, 1400, 930, 1.5, 92);
});
const DESK = layer(W, H, g => {
g.drawImage(sheet(CFG.wood, 93), 0, 0, W, H);
g.strokeStyle = 'rgba(90,50,20,.18)'; g.lineWidth = 3; for (let k = 0; k < 14; k++) { g.beginPath(); for (let x = 0; x <= W; x += 20) g.lineTo(x, k * 80 + 20 + Math.sin(x * 0.006 + k) * 16); g.stroke(); }
cut(g, ell(960, 520, 660, 560, 91, 0.03, 90), '#DDB070', { rim: 0, seed: 92, ground: true });
[[1580, 960, -0.5, CFG.red], [1640, 1000, -0.35, '#4C9E52'], [1700, 940, -0.6, CFG.clip]].forEach(([x, y, r, c], i) => { g.save(); g.translate(x, y); g.rotate(r); cut(g, [[-80, -9], [70, -9], [92, 0], [70, 9], [-80, 9]], c, { rim: 2, step: 5, seed: 94 + i, brush: 0.6 }); g.restore(); });
});
const NOTE_PAGE = layer(1180, 790, g => {
cut(g, box4(10, 30, 1160, 740, 96, 3), '#4E3484', { rim: 3, seed: 96, brush: 1 });
cut(g, box4(20, 30, 1140, 710, 95, 2), '#F8F4EA', { rim: 0, seed: 95, brush: 0.35, grainA: 0.5 });
g.strokeStyle = 'rgba(70,120,190,.4)'; g.lineWidth = 2; for (let y = 130; y < 730; y += 70) { g.beginPath(); g.moveTo(24, y); g.lineTo(1156, y); g.stroke(); }
g.strokeStyle = 'rgba(216,68,58,.5)'; g.beginPath(); g.moveTo(130, 34); g.lineTo(130, 736); g.stroke();
for (let i = 0; i < 22; i++) { g.strokeStyle = '#8F8FA0'; g.lineWidth = 7; g.beginPath(); g.ellipse(70 + i * 50, 30, 12, 22, 0, Math.PI * 0.9, Math.PI * 2.1); g.stroke(); }
});
const PALETTE = { prototypes: '#E9B85D', websites: '#9BC6B6', slides: '#B4C8E0', posters: '#E8A1B5', motion: '#292F70' };
const BG = {}; Object.entries(PALETTE).forEach(([k, c], i) => { BG[k] = sheet(c, 200 + i); });
const SCRIBBLE = sheet('#F2E9D8', 300, { print: 'scrib', printA: 0.17 });
// The note, front and back, painted at its largest size.
const NOTE_W = 1120;
function notePaper(g, w) { const top = [[-w / 2, -w * 0.35]]; for (let k = 1; k <= 20; k++) top.push([-w / 2 + k * w / 20, -w * 0.35 + (k % 2 ? 7 : 0)]);
cut(g, [...top, [w / 2, w * 0.35], [-w / 2, w * 0.35]], '#F8F4EA', { rim: 3, seed: 500, brush: 0.35, grainA: 0.5 });
g.strokeStyle = 'rgba(70,120,190,.35)'; g.lineWidth = 3; for (let y = -w * 0.18; y < w * 0.33; y += w * 0.12) { g.beginPath(); g.moveTo(-w / 2 + 6, y); g.lineTo(w / 2 - 6, y); g.stroke(); } }
function questionText(g, w, opacity = 1) {
CFG.question.forEach((line, i) => hand(line, 0, w * (-.15 + i * .19), w * (i === 0 ? .098 : i === 1 ? .105 : .125), CFG.pen, { g, a: opacity, rot: i === 1 ? -.016 : .008 }));
}
const NOTE_FRONT = textSprite(NOTE_W + 40, NOTE_W * .7 + 40, g => {
g.translate(NOTE_W / 2 + 20, NOTE_W * .35 + 20); notePaper(g, NOTE_W); questionText(g, NOTE_W);
});
const NOTE_BACK = textSprite(NOTE_W + 40, NOTE_W * .7 + 40, g => {
g.translate(NOTE_W / 2 + 20, NOTE_W * .35 + 20); notePaper(g, NOTE_W); g.scale(-1, 1); questionText(g, NOTE_W, .13);
});
// The last note turns the story's question toward the viewer.
const INVITATION = textSprite(NOTE_W + 40, NOTE_W * .7 + 40, g => {
g.translate(NOTE_W / 2 + 20, NOTE_W * .35 + 20); notePaper(g, NOTE_W);
CFG.invitation.forEach((line, i) => hand(line, 0, NOTE_W * (-.15 + i * .19), NOTE_W * .123, CFG.pen, { g, rot: i === 1 ? -.016 : .008 }));
g.strokeStyle = CFG.red; g.lineWidth = 9; g.beginPath();
g.ellipse(0, -10, 168, 82, -.055, .1, TAU + .1); g.stroke();
});
function noteCard(x, y, w, scale, rot, o = {}) {
ctx.save(); ctx.translate(x, y); ctx.rotate(rot); ctx.scale(scale * w / NOTE_W, scale * w / NOTE_W);
ctx.drawImage(o.invitation ? INVITATION() : o.back ? NOTE_BACK() : NOTE_FRONT(), -NOTE_W / 2 - 20, -NOTE_W * .35 - 20); ctx.restore();
}
/* ---------- five capabilities, built as little paper works ---------- */
const stroke = (g, pts, w, col, rim = 3) => { g.save(); g.lineCap = 'round'; g.lineJoin = 'round'; g.beginPath(); pts.forEach(([x, y], i) => i ? g.lineTo(x, y) : g.moveTo(x, y)); if (rim) { g.strokeStyle = CFG.paper; g.lineWidth = w + rim * 2; g.stroke(); } g.strokeStyle = col; g.lineWidth = w; g.stroke(); g.restore(); };
const LIST = [['prototypes', 'interactive prototypes'], ['websites', 'websites'], ['slides', 'PPT & presentations'], ['posters', 'posters'], ['motion', 'motion films']];
// Static paper pieces are cached. Their hinges, contents and the buddy move live.
const PIECES = new Map();
function piece(id, x, y, w, h, draw) {
let p = PIECES.get(id);
if (!p) { p = layer(w + 32, h + 32, g => { g.translate(-x + 16, -y + 16); draw(g); }); PIECES.set(id, p); }
ctx.drawImage(p, x - 16, y - 16);
}
function paperBox(x, y, w, h, color, seed, r = 3) {
piece([x,y,w,h,color,seed,r].join(':'), x, y, w, h, g => cut(g, r > 3 ? rrPts(x,y,w,h,r) : box4(x,y,w,h,seed,5), color, { seed, rim: 3, brush: .85 }));
}
function at(x, y, scale, rot, draw) { ctx.save(); ctx.translate(x,y); ctx.rotate(rot); ctx.scale(scale,scale); draw(); ctx.restore(); }
function cursor(x, y, click = 0) {
at(x,y,1,0,() => {
cut(ctx, [[0,0],[5,55],[18,40],[34,62],[47,52],[29,33],[51,29]], CFG.accent, { rim: 3, seed: 710 });
if (click > 0) { ctx.strokeStyle = CFG.ink; ctx.lineWidth = 3; for (let i=0;i<6;i++) { const a=i/6*TAU; stroke(ctx, [[Math.cos(a)*(20+click*20),Math.sin(a)*(20+click*20)],[Math.cos(a)*(28+click*27),Math.sin(a)*(28+click*27)]],3,CFG.ink,0); } }
});
}
const BLOOMS=new Map();
function bloom(x,y,r,t,g=ctx) {
let sprite=BLOOMS.get(r);
if(!sprite){const size=Math.ceil(r*2.2+24);sprite={size,canvas:layer(size*2,size*2,p=>{
p.scale(2,2);p.translate(size/2,size/2);
for(let i=0;i<8;i++){p.save();p.rotate(i/8*TAU);cut(p,ell(r*.6,0,r*.5,r*.23,720+i,.035,20),'#E87E62',{rim:1.8,seed:720+i});p.restore();}
cut(p,ell(0,0,r*.29,r*.29,730,.04,20),CFG.yellow,{rim:1.5,seed:730});
})};BLOOMS.set(r,sprite);}
g.save();g.translate(x,y);g.rotate(t);g.drawImage(sprite.canvas,-sprite.size/2,-sprite.size/2,sprite.size,sprite.size);g.restore();
}
function prototypeCard(p, t, actor) {
const tap = EZ.io2(seg(p,.28,.6));
paperBox(-155,-265,310,530,CFG.ink,740,35);
paperBox(-137,-245,274,480,'#FFF6DE',741,23);
ctx.save();ctx.beginPath();ctx.rect(-132,-209,264,419);ctx.clip();ctx.translate(-tap*264,0);
hand('a little day',0,-164,29,CFG.ink);
paperBox(-112,-125,224,195,'#9BC6B6',742);bloom(0,-30,69,-.08);
paperBox(-106,101,212,62,'#E98262',743,14);hand('make a plan',0,143,23,CFG.ink);
ctx.translate(264,0);hand('your next idea',0,-164,25,CFG.ink);
[0,1,2].forEach(i=>{paperBox(-111,-104+i*86,222,64,['#EACB7D','#B8CAE3','#EAA9B5'][i],744+i);hand(['sketch it','shape it','share it'][i],6,-63+i*86,25,CFG.ink);});
ctx.restore();stroke(ctx,[[-36,-224],[36,-224]],6,CFG.ink,0);stroke(ctx,[[-36,231],[36,231]],5,CFG.paper,0);
if(actor){const press=EZ.io2(seg(p,.08,.3))*(1-EZ.io2(seg(p,.62,.88))),bx=-275+48*press,by=113-6*press;
buddy(bx,by,105,t,tap>.5?'content':'smile',{rot:-.08+.05*press,look:1});
buddyArms(bx,by,105,[[bx-103,by+60],[lerp(bx+114,-102,press),by+22]]);
cursor(lerp(220,52,EZ.o3(seg(p,0,.3))),lerp(245,128,EZ.o3(seg(p,0,.3))),hop(p,.25,.22));}
}
function websiteCard(p,t,actor){
paperBox(-355,-220,710,449,'#F9F4E7',760,12);paperBox(-355,-220,710,50,'#DFCFB1',761,8);
[0,1,2].forEach(i=>{ctx.fillStyle=['#DC735C','#E9B74E','#76A393'][i];ctx.beginPath();ctx.arc(-326+i*22,-195,5,0,TAU);ctx.fill();});
hand('little studio',-306,-117,24,CFG.ink,{align:'left'});
hand('a little',-304,-31,58,CFG.ink,{align:'left'});hand('wonder.',-304,38,58,CFG.ink,{align:'left'});
paperBox(-303,76,175,37,'#D67760',762,8);hand('come on in',-216,102,19,CFG.paper);
const unfold=EZ.o3(seg(p,.03,.55));at(178,-20,1,-.08*(1-unfold),()=>{ctx.scale(Math.max(.03,unfold),1);paperBox(-104,-110,212,235,'#A1BEAB',763);bloom(0,0,88,.1);});
[0,1,2].forEach(i=>{const q=EZ.o3(seg(p,.16+i*.1,.45+i*.1));at(-220+i*220,161+(1-q)*80,1,(1-q)*.15,()=>paperBox(-93,-19,186,41,['#E6B764','#B2C9DE','#E6A3B5'][i],764+i));});
if(actor){buddy(360,185,100,t,'smile',{rot:.08});buddyArms(360,185,100,[[252,185],[474,148-12*Math.sin(p*TAU)]]);}
}
function slideCard(p,t,actor){
at(60,-25,1,.1,()=>paperBox(-300,-210,600,390,'#D58368',780));
at(-12,0,1,-.065,()=>paperBox(-300,-210,600,390,'#E2C571',781));
const turn=EZ.io2(seg(p,.14,.62));
paperBox(-320,-195,640,390,'#F9F2DF',782);hand('GROW YOUR IDEAS',-264,-130,30,CFG.ink,{align:'left'});
[0,1,2].forEach(i=>{const rise=EZ.o3(seg(p,.23+i*.09,.53+i*.09));const h=[72,125,190][i]*rise;paperBox(-237+i*128,125-h,84,Math.max(1,h),['#DB795F','#87BCA6','#7B9FC3'][i],783+i);});
stroke(ctx,[[-257,129],[172,129]],3,CFG.ink,0);hand('03 / 08',236,165,18,CFG.ink);
if(turn<1){ctx.save();ctx.translate(-320,-195);ctx.transform(Math.max(.01,1-turn),-.12*Math.sin(turn*Math.PI),0,1,0,0);paperBox(0,0,640,390,'#EBBA59',787);hand('BIG',320,157,90,CFG.ink);hand('IDEAS',320,266,90,CFG.ink);ctx.restore();}
if(actor){const bx=-366+10*turn,by=195-8*turn;buddy(bx,by,94,t,'content',{rot:-.08});buddyArms(bx,by,94,[[bx-99,by-44],[bx+108,by-17]]);}
}
function posterCard(p,t,actor){
const stick=EZ.o3(seg(p,.15,.65)),release=EZ.io2(seg(p,.68,.94));
const tx=175+(1-stick)*58,ty=-235+(1-stick)*82;
at(-115,0,.8,-.16,()=>{paperBox(-220,-280,440,560,'#91AECC',800);hand('PLAY',0,-158,58,CFG.ink);bloom(0,50,155,.1);});
at(55,0,1,.045,()=>{paperBox(-220,-280,440,560,'#F7E4B8',801);hand('MAKE',0,-178,64,CFG.ink);hand('SOMETHING',0,-114,32,CFG.ink);bloom(0,38,130,-.12);hand('wonderful',0,226,37,CFG.ink,{rot:-.04});
at(tx,ty,1,lerp(-.5,.12,stick),()=>{cut(ctx,box4(-43,-12,86,24,802,3),'#EBC870',{rim:1,seed:802});});});
if(actor){const hx=55+Math.cos(.045)*tx-Math.sin(.045)*ty,hy=Math.sin(.045)*tx+Math.cos(.045)*ty;
const bx=hx+123+12*release,by=hy+33+20*release;
buddy(bx,by,102,t,'content',{rot:-.08+.13*release});
buddyArms(bx,by,102,[[lerp(hx,bx-113,release),lerp(hy,by+25,release)],[bx+104,by+55]]);}
}
function motionCard(p,t,actor){
paperBox(-345,-236,690,407,CFG.paper,820,6);paperBox(-324,-214,648,350,'#313D82',821);
const flight=EZ.io2(seg(p,.08,.9));
for(let i=0;i<12;i++){paperBox(-326+i*57,-257,32,17,CFG.ink,822+i);paperBox(-326+i*57,176,32,17,CFG.ink,834+i);}
ctx.save();ctx.beginPath();ctx.rect(-324,-214,648,350);ctx.clip();
[0,1,2].forEach(i=>{const x=-210+i*210;star5(ctx,x,-143+(i%2)*40,12,CFG.yellow,.2);stroke(ctx,[[x-70,61],[x+65,61]],2,'#B3BEDF',0);});
const pts=curve([-245,40],[-100,-185],[230,14],45);dashed(pts,flight,'#F8E2A6',3);const[x,y,a]=along(pts,flight);plane(x,y,3,a);ctx.restore();
paperBox(-286,234,572,32,'#C8CEDD',849,5);paperBox(-286,234,Math.max(1,572*flight),32,'#DFB555',850,5);
if(actor){buddy(-320,205,88,t,'smile',{rot:-.1});buddyArms(-320,205,88,[[-410,164-8*Math.sin(p*TAU)],[-215,249]]);}
}
const S={prototypes:prototypeCard,websites:websiteCard,slides:slideCard,posters:posterCard,motion:motionCard};
function workCard(key,p,t,actor=true){S[key](clamp(p),t,actor);}
// Completed works stay still inside the book. Paint their paper and lettering once.
const WORK_THUMBNAILS=new Map();
function workThumbnail(key){
if(!fontReady()){workCard(key,.86,0,false);return;}
let sprite=WORK_THUMBNAILS.get(key);
if(!sprite){
const target=ctx;
try{sprite=layer(1680,1360,g=>{ctx=g;g.scale(2,2);g.translate(420,340);workCard(key,.86,0,false);});}
finally{ctx=target;}
WORK_THUMBNAILS.set(key,sprite);
}
ctx.drawImage(sprite,-420,-340,840,680);
}
const BOOK_SPOTS=[[-184,-55,.25],[-5,-61,.22],[164,-55,.24],[-112,119,.20],[126,119,.22]];
function booklet(x,y,scale,open,t,reveals){
at(x,y,scale,0,()=>{
paperBox(-294,-219,588,438,'#CF745F',870,5);
paperBox(-284,-209,568,416,'#F6EDDA',871,5);
stroke(ctx,[[0,-203],[0,200]],3,'#C5B99E',0);
if(open>0)LIST.forEach(([key],i)=>{if(!reveals||reveals[i])at(...BOOK_SPOTS[i],0,()=>workThumbnail(key));});
if(open<1){ctx.save();ctx.translate(-294,-219);ctx.transform(Math.max(.005,1-open),-.04*Math.sin(open*Math.PI),0,1,0,0);
paperBox(0,0,588,438,'#D98065',872,5);hand('OpenDesign',294,124,51,CFG.paper);hand('a book of',294,217,39,CFG.ink);hand('possibilities',294,279,46,CFG.ink);bloom(294,353,42,0);ctx.restore();}
});
}
// The reply stays visible as a parcel while its paper wrapper folds into wings.
function wrappedReply(x,y,p,t,rot=0){
at(x,y,1,0,()=>{
if(p<.38){
booklet(0,0,.54,0,t);
const wrap=EZ.o3(seg(p,0,.38));
for(const side of [-1,1])cut(ctx,[[side*165,-121],[side*165,121],[side*165*(1-wrap),121],[side*165*(1-wrap),-121]],side<0?'#F8F4EA':'#E0D9CC',{rim:1,seed:910+side,brush:.3});
}else{
const q=EZ.io2(seg(p,.38,1)),rotate=([x,y])=>[x*Math.cos(rot)-y*Math.sin(rot),x*Math.sin(rot)+y*Math.cos(rot)];
const from=[[[165,-121],[-165,-121],[-165,0],[165,0]],[[165,0],[-165,0],[-165,121],[165,121]]];
const to=[[[81,0],[-59.4,-37.8],[-32.4,0],[81,0]],[[81,0],[-32.4,0],[-59.4,32.4],[81,0]]];
from.forEach((pts,i)=>cut(ctx,pts.map(([x,y],j)=>{const [tx,ty]=rotate(to[i][j]);return [lerp(x,tx,q),lerp(y,ty,q)];}),i?'#DDD6C8':'#F8F4EA',{rim:1,seed:914+i,brush:.3}));
}
});
}
/* ---------- scenes ---------- */
const PLANES = Array.from({ length: 7 }, (_, i) => ({ from: [1100 + rnd(i) * 800, 760 + rnd(i * 3) * 160], ctrl: [1400 + rnd(i * 5) * 300, 300 + rnd(i * 7) * 200], at: rnd(i * 9) * 1.2, d: 1.1 + rnd(i * 2) * 0.5 }));
function rays(x, y, r0, t, n = 16) { ctx.strokeStyle = 'rgba(246,223,126,.85)'; ctx.lineWidth = 6; ctx.lineCap = 'round'; for (let i = 0; i < n; i++) { const a = i / n * TAU + 0.1, r = r0 + Math.sin(t * 6 + i) * 8, l = i % 2 ? 22 : 40; ctx.beginPath(); ctx.moveTo(x + Math.cos(a) * r, y + Math.sin(a) * r); ctx.lineTo(x + Math.cos(a) * (r + l), y + Math.sin(a) * (r + l)); ctx.stroke(); } }
function town(t, lt, o = {}) {
ctx.drawImage(NIGHT, 0, 0);
const sx = 1270, sy = 210, R = 150;
if (o.rays) rays(sx, sy, R * 1.12, t);
buddy(sx, sy + Math.sin(t * 2) * 6, R, t, o.starExpr || 'smile');
(o.planes ?? true) && PLANES.forEach(p => { const k = ((lt + p.at) % p.d) / p.d; const pts = curve(p.from, p.ctrl, [sx + 40, sy + 80], 30); dashed(pts.slice(0, Math.max(2, Math.floor(30 * k))), 1, 'rgba(255,255,255,.35)', 2); const [x, y, a] = along(pts, k); plane(x, y, 1.1, a); });
ctx.drawImage(TOWN, 0, 0);
if (o.lights) for (let i = 0; i < 16; i++) { const p = EZ.back(seg(lt, 0.3 + i * 0.05, 0.6 + i * 0.05)); if (p <= 0) continue; const x = 1050 + rnd(i * 4) * 860, y = 640 + rnd(i * 6) * 400; ctx.fillStyle = 'rgba(246,218,106,.35)'; ctx.beginPath(); ctx.arc(x, y, 34 * p, 0, TAU); ctx.fill(); ctx.fillStyle = '#F6DA6A'; ctx.beginPath(); ctx.arc(x, y, 18 * p, 0, TAU); ctx.fill(); plus(x, y, 8 * p, '#C9932F'); }
ctx.save(); ctx.beginPath(); ctx.rect(505, 505, 490, 420); ctx.clip(); girl(750, 722, 1.6, t, o.pose || 'write', o.expr || 'smile', o); ctx.restore();
ctx.drawImage(TOWN, 440, 915, 620, 50, 440, 915, 620, 50);
}
function room(t, pose, expr, o = {}) { ctx.drawImage(ROOM, 0, 0); girl(960, 650, 2.3, t, pose, expr, o); ctx.drawImage(ROOM, 360, 920, 1200, 160, 360, 920, 1200, 160); }
const ROOM_ZOOM = layer(W, H, g => g.drawImage(ROOM, 0, 0, W, H, -200, -150, W * 1.2, H * 1.2));
const SC = [
// 0 — town at night: everyone sends the buddy a request
[0.0, 1.0, (t, lt) => town(t, lt + 0.6, { pose: 'write', rays: true })],
// 1 — an idea
[1.0, 1.5, (t, lt) => { room(t, 'idea', 'o', { armT: EZ.back(seg(lt, 0, 0.3)) });
ctx.strokeStyle = '#FFF3C4'; ctx.lineWidth = 8; ctx.lineCap = 'round'; const k = EZ.o3(seg(lt, 0.05, 0.3));
for (let i = 0; i < 7; i++) { const a = -Math.PI * (0.15 + i * 0.12), r0 = 300, r1 = 300 + 60 * k; ctx.beginPath(); ctx.moveTo(960 + Math.cos(a) * r0, 560 + Math.sin(a) * r0); ctx.lineTo(960 + Math.cos(a) * r1, 560 + Math.sin(a) * r1); ctx.stroke(); } }],
// 2 — she writes the question
[1.5, 3.5, (t, lt) => { ctx.drawImage(DESK, 0, 0);
const lift = EZ.o3(seg(lt, 1.75, 2.0));
ctx.save(); ctx.translate(960, 560 - lift * 40); ctx.rotate(-0.02 - lift * 0.05); ctx.scale(0.88, 0.88); ctx.translate(-590, -380);
ctx.drawImage(NOTE_PAGE, 0, 0);
const progress = [seg(lt,.06,.52),seg(lt,.53,1.08),seg(lt,1.09,1.6)];
const tips = CFG.question.map((line,i)=>writeText(line,165,230+i*192,i===0?112:i===1?116:139,CFG.pen,progress[i]));
ctx.restore();
const tip=tips[lt<.53?0:lt<1.09?1:2],dx=(tip[0]-590)*.88,dy=(tip[1]-380)*.88,angle=-.02-lift*.05;
const px=960+dx*Math.cos(angle)-dy*Math.sin(angle),py=560-lift*40+dx*Math.sin(angle)+dy*Math.cos(angle);
if (lt < 1.75) { stroke(ctx, [[px, py], [px + 60, py + 50]], 16, CFG.pen, 3); bigArm(px + 300, py + 700, px + 50, py + 70, 62); }
else { [[-1, 380], [1, 1540]].forEach(([s2, x]) => bigArm(x + s2 * 200, 1200, x, 200 - lift * 40, 66));
ctx.strokeStyle = '#FFF3C4'; ctx.lineWidth = 7; ctx.lineCap = 'round'; for (let i = 0; i < 7; i++) { const x = 560 + i * 130; ctx.beginPath(); ctx.moveTo(x, 110 - lift * 40); ctx.lineTo(x + (i - 3) * 8, 60 - lift * 40); ctx.stroke(); } } }],
// 3 — corner folds, then the two wings; each fold has a contact crease.
[3.5,4.25,(t,lt)=>{ctx.drawImage(DESK,0,0);
if(lt<.18){const turn=EZ.io2(seg(lt,0,.18)),scale=lerp(.88,.55,turn),rot=lerp(-.07,Math.PI/2-.03,turn);
at(960,lerp(520,540,turn),scale,rot,()=>{
ctx.scale(Math.max(.06,Math.abs(Math.cos(turn*Math.PI))),1);ctx.translate(-590,-380);
ctx.drawImage(NOTE_PAGE,0,0);ctx.globalAlpha=turn<.5?1:.13;
CFG.question.forEach((line,i)=>writeText(line,165,230+i*192,i===0?112:i===1?116:139,CFG.pen,1));
});
const grip=lerp(545,185,turn);bigArm(180,1250,960-grip,lerp(170,320,turn),54);bigArm(1740,1250,960+grip,lerp(170,320,turn),54);return;
}
const a=EZ.io2(seg(lt,.18,.43)),b=EZ.io2(seg(lt,.43,.73));
at(960,540,1,-.03,()=>{
const w=390*(1-b*.68),h=640;
cut(ctx,[[-w/2,-h/2+150*a],[0,-h/2],[w/2,-h/2+150*a],[w/2,h/2],[-w/2,h/2]],'#F8F4EA',{seed:901,rim:3});
for(let i=0;i<6;i++)stroke(ctx,[[-w/2+14+i*(w-28)/5,-h/2+160*a],[-w/2+14+i*(w-28)/5,h/2-6]],1,'rgba(70,120,190,.25)',0);
for(const side of [-1,1]){cut(ctx,[[0,-h/2],[side*w/2,-h/2+150*a],[side*w/2*(1-a),-80],[0,70]],side<0?'#E2D9C7':'#FFF9EB',{seed:902+side,rim:1,brush:.3});}
stroke(ctx,[[0,-h/2],[0,h/2]],2,'#BAAF99',0);
if(b>.2)cut(ctx,[[0,-h/2],[w/2,h/2],[-w*.2,h/2-38]],'#DFD8C9',{seed:904,rim:1});
const gripY=lerp(-220,65,a)+85*b,gripX=lerp(w/2-12,28,a)*(1-.3*b);
bigArm(-570,710,-gripX,gripY,54);bigArm(570,710,gripX,gripY,54);
});}],
// 4 — throw it
[4.25, 4.75, (t, lt) => { room(t, 'throw', 'o', { armT: EZ.o3(seg(lt, 0, 0.25)), look: 1 }); const k = seg(lt, 0.15, 0.5); if (k > 0) { const pts = curve([1300, 380], [1500, 200], [1900, 60], 30); dashed(pts, k, 'rgba(43,37,80,.6)', 5); const [x, y, a] = along(pts, k); plane(x, y, 2.4, a); } }],
// 5 — across the night sky
[4.75, 5.5, (t, lt) => { ctx.drawImage(NIGHT, 0, 0); rays(1500, 170, 125, t, 12); buddy(1500, 170, 110, t, 'smile'); ctx.drawImage(SKYLINE, 0, 0);
PLANES.slice(0, 4).forEach(p => { const k = ((lt * 0.8 + p.at) % p.d) / p.d; const pts = curve([p.from[0] - 400, p.from[1] + 100], p.ctrl, [1500, 250], 30); const [x, y, a] = along(pts, k); plane(x, y, 1.6, a); });
const pts = curve([380, 1000], [800, 400], [1460, 220], 40), k = EZ.io2(seg(lt, 0, 0.75)); dashed(pts, k, 'rgba(255,255,255,.75)', 4); const [x, y, a] = along(pts, k); plane(x, y, 2.6, a); }],
// 6 — the buddy reads it: curious, thinking, a spark, delight
[5.5, 7.0, (t, lt) => { ctx.drawImage(NIGHT, 0, 0);
const expr = lt < 0.45 ? 'o' : lt < 0.95 ? 'closed' : lt < 1.25 ? 'wow' : 'content', bounce = 1 + 0.08 * hop(lt, 0.95, 0.3);
if (lt > 0.95 && lt < 1.4) { const p = EZ.back(seg(lt, 0.95, 1.1));
ctx.strokeStyle = CFG.clip; ctx.lineWidth = 8; ctx.lineCap = 'round'; for (let i = 0; i < 8; i++) { const a = i / 8 * TAU, r = 440 + 40 * p; ctx.beginPath(); ctx.moveTo(960 + Math.cos(a) * r, 400 + Math.sin(a) * r * 0.8); ctx.lineTo(960 + Math.cos(a) * (r + 40), 400 + Math.sin(a) * (r + 40) * 0.8); ctx.stroke(); } }
buddy(960, 400, 300 * bounce, t, expr);
noteCard(960, 760, 400, 1, -0.02);
buddyArms(960, 400, 300 * bounce, [[748, 686], [1172, 686]]);
if (lt > 0.45 && lt < 0.95) [0, 1, 2].forEach(i => { const p = seg(lt, 0.5 + i * 0.12, 0.6 + i * 0.12); if (p > 0) { ctx.fillStyle = CFG.paper; ctx.beginPath(); ctx.arc(1290 + i * 50, 300 - i * 50, (10 + i * 6) * EZ.back(p), 0, TAU); ctx.fill(); } });
if (lt > 0.95 && lt < 1.4) { const p = EZ.back(seg(lt, 0.95, 1.1)); ctx.save(); ctx.translate(1420, 230); ctx.scale(p, p); hand('!', 0, 50, 200, CFG.yellow, { rim: true }); ctx.restore(); }
if (lt >= 1.25) { sparkle(700, 220, 22, '#F3D86E', t); sparkle(1240, 560, 16, '#F3D86E', -t); plus(660, 520, 12, 'rgba(255,255,255,.8)'); } }],
// 7 — each capability has an action: tap, unfold, turn, stick, animate.
[7.0,13.0,(t,lt)=>{const starts=[0,1.5,2.5,4,5,6],k=starts.slice(0,-1).reduce((n,s,i)=>lt>=s?i:n,0),p=seg(lt,starts[k],starts[k+1]),[key,label]=LIST[k];
ctx.drawImage(BG[key],0,0,W,H);
const enter=EZ.o3(seg(p,0,.16));at(960,450+(1-enter)*12,1.32,(1-enter)*-.025,()=>workCard(key,p,t));
hand(label,960,967,key==='prototypes'?82:94,key==='motion'?CFG.paper:CFG.ink);
}],
// 8 — the same works collect into a little book, with the buddy holding it.
[13.0,14.1,(t,lt)=>{ctx.drawImage(SCRIBBLE,0,0,W,H);const close=EZ.io2(seg(lt,.76,1.06));
const arrivals=LIST.map((_,i)=>EZ.io2(seg(lt,.05+i*.06,.45+i*.06)));
buddy(960,394,182,t,'content');
booklet(960,656,.83,1-close,t,arrivals.map(p=>p>=1));
const from=[[440,560,.36],[1470,680,.32],[1490,795,.32],[430,820,.32],[965,1000,.38]];
LIST.forEach(([key],i)=>{const p=arrivals[i];if(p>=1)return;
const [sx,sy,scale]=from[i],[tx,ty,size]=BOOK_SPOTS[i],end=[960+tx*.83,656+ty*.83];
const [x,y]=along(curve([sx,sy],[sx,Math.max(sy,end[1])+90],end,30),p);
at(x,y,lerp(scale,size*.83,p),0,()=>workThumbnail(key));
});
buddyArms(960,394,182,[[728,520],[1192,520]]);
}],
// 9 — the book is wrapped in the reply; its flight leads the next cut.
[14.1,14.8,(t,lt)=>{ctx.drawImage(NIGHT,0,0);ctx.drawImage(SKYLINE,0,120);const fly=EZ.io2(seg(lt,.22,.7));
buddy(960,355,224,t,'smile',{look:-1});buddyArms(960,355,224,[[794,557],[1126,557]]);
const pts=curve([960,670],[420,650],[350,1150],40),[x,y,a]=along(pts,fly);
if(lt<.22)wrappedReply(x,y,seg(lt,0,.22),t,a);
else{dashed(pts,fly,'rgba(255,255,255,.7)',4);plane(x,y,2.7,a);}
}],
// 10 — the girl catches the parcel and opens it with both hands.
[14.8,15.6,(t,lt)=>{const land=EZ.o3(seg(lt,0,.36)),settle=hop(lt,.3,.2);
room(t,'hold',lt<.3?'o':'smile',{look:1-land,parcel:{
x:lerp(132,0,land),y:lerp(46,72,land)+3*settle,scale:.47/2.3,
rot:lerp(-.16,0,land),wrap:1-EZ.io2(seg(lt,0,.14)),open:EZ.io2(seg(lt,.4,.75))
}});
}],
// 11 — the answer is a book of real possibilities, plus her own idea.
[15.6,17.0,(t,lt)=>{ctx.drawImage(DESK,0,0);booklet(960,627,1.65,1,t);
bigArm(290,1280,477,873,65);bigArm(1630,1280,1440,874,65);
hand(CFG.answer[0],960,134,63,CFG.ink);hand(CFG.answer[1],960,234,92,CFG.pen);
const p=EZ.io2(seg(lt,.25,.85));ctx.strokeStyle=CFG.red;ctx.lineWidth=5;ctx.lineCap='round';ctx.beginPath();
ctx.ellipse(960,210,267,67,-.025,Math.PI*.1,Math.PI*.1+TAU*p);ctx.stroke();
}],
// 12 — she hugs the reply before the story turns toward the viewer.
[17.0,17.75,(t,lt)=>{const hug=EZ.io2(seg(lt,.04,.5));
room(t,'hug','grin',{armT:hug,blink:lt>.24,headTilt:.08*hug});
}],
// 13 — a brief thank-you across the town; she keeps the book in her other hand.
[17.75,18.5,(t,lt)=>{town(t,lt+.5,{pose:'wave',expr:'grin',blink:true,book:true,rays:true,lights:true,starExpr:'content',planes:false});
}],
// 14 — like the reference's pinned note, a physical gesture introduces the audience question.
[18.5,20,(t,lt)=>{ctx.drawImage(ROOM,0,0);
const lift=EZ.io2(seg(lt,0,.34)),release=EZ.io2(seg(lt,.56,.8));
const x=lerp(1080,1270,lift),y=lerp(766,545,lift),w=lerp(330,450,lift),rot=lerp(-.14,-.025,lift);
noteCard(x,y,w,1,rot,{invitation:true});
const gripX=x-w*.46,gripY=y+w*.25;
girl(800,650,2.3,t,'post',lt>.8?'grin':'smile',{
handX:lerp((gripX-800)/2.3,6,release),handY:lerp((gripY-650)/2.3,83,release),blink:lt>.83,headTilt:-.025
});
for(const side of [-1,1]){const stick=EZ.o3(seg(lt,side<0?.34:.42,side<0?.46:.54));
if(stick>0)at(x+side*w*.39,y-w*.33-12*(1-stick),stick,side*.28,()=>paperBox(-26,-9,52,18,'#EBD69E',940+side));
}
ctx.drawImage(ROOM,360,920,1200,160,360,920,1200,160);
}],
];
function render(t) {
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = 1; ctx.imageSmoothingQuality = 'high';
const k = SC.reduce((n, s, i) => t >= s[0] ? i : n, 0), [t0, , draw] = SC[k];
draw(t, t - t0);
}
/* ---------- master timeline: the only clock ---------- */
gsap.ticker.lagSmoothing(0);
const tl = gsap.timeline({ paused: true });
const clock = { t: 0 };
tl.to(clock, { t: CFG.duration, duration: CFG.duration, ease: 'none' }, 0);
tl.eventCallback('onUpdate', () => render(tl.time()));
window.__timelines = { main: tl };
render(0);
Promise.all(['10px Stack'].map(f => document.fonts.load(f))).then(() => render(tl.time()), () => render(tl.time()));
/* ---------- preview shell (outside the composition) ---------- */
const main = document.getElementById('main');
const playBtn = document.getElementById('playBtn'), scrub = document.getElementById('scrub'), tcEl = document.getElementById('tc');
function fit() {
const s = Math.min(window.innerWidth / CFG.w, window.innerHeight / CFG.h);
main.style.transform = 'translate(-50%,-50%) scale(' + s + ')';
}
// Inside an Open Design project the motion source player wraps the stage,
// fits it and adds its own controls; leave both to it there.
const hostPlayer = () => window.__odMotionSourcePlayer || main.parentElement.id !== 'viewport';
const ownFit = fit;
fit = function () { if (hostPlayer()) { main.style.transform = 'translate(-50%,-50%)'; return; } ownFit(); };
window.addEventListener('resize', fit); fit();
document.addEventListener('DOMContentLoaded', () => setTimeout(() => {
fit(); if (hostPlayer()) document.getElementById('controls').style.display = 'none';
}, 0));
let syncedAt=null,syncedActive=null;
function sync() {
if(window.__odFrameRenderer)return;
const t = tl.time();
const active=tl.isActive();
if(t===syncedAt&&active===syncedActive)return;
syncedAt=t;syncedActive=active;
scrub.value = t; tcEl.textContent = t.toFixed(2) + ' / ' + CFG.duration.toFixed(2);
playBtn.textContent = active ? 'PAUSE' : (t >= CFG.duration - 0.01 ? 'REPLAY' : 'PLAY');
}
gsap.ticker.add(sync);
playBtn.addEventListener('click', () => {
if (tl.time() >= CFG.duration - 0.01) tl.restart(); else if (tl.paused()) tl.play(); else tl.pause();
sync();
});
scrub.addEventListener('input', () => { tl.pause(); tl.time(parseFloat(scrub.value)); sync(); });
window.addEventListener('keydown', e => { if (e.code === 'Space' && e.target === document.body) { e.preventDefault(); playBtn.click(); } });
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { tl.pause(); tl.time(CFG.reducedAt); }
else document.fonts.ready.then(() => tl.play(0));
sync();
</script>
<script>
// Video export seeks with timeline callbacks suppressed, so the onUpdate
// drawing also runs from a per-frame loop whenever the timeline time moves.
(function install() {
var tls = window.__timelines;
var tl = tls && tls[Object.keys(tls)[0]];
if (!tl) { setTimeout(install, 50); return; }
var draw = tl.eventCallback('onUpdate');
if (!draw) return;
var drawnAt = null;
tl.eventCallback('onUpdate', function () { drawnAt = tl.time(); return draw.apply(this, arguments); });
(function loop() {
if (window.__odFrameRenderer && !document.documentElement.classList.contains('od-export')) {
document.documentElement.classList.add('od-export');
}
requestAnimationFrame(loop);
var t = tl.time();
if (t !== drawnAt) { drawnAt = t; draw(); }
})();
})();
</script>
<script>
// Preview bake: mark ready once the timeline reaches the poster frame.
(function () {
var POSTER_AT = 6.8;
function check() {
var tls = window.__timelines;
var tl = tls && tls[Object.keys(tls)[0]];
if (tl && tl.time() >= POSTER_AT) {
document.documentElement.setAttribute('data-od-preview-ready', 'true');
return;
}
requestAnimationFrame(check);
}
requestAnimationFrame(check);
})();
</script>
<script>
// Sound starts with the film. If the browser blocks unmuted autoplay, the first
// gesture on the page turns it on; the master timeline owns play, pause and seek.
(function() {
// BEGIN shared motion audio sync
// A render stall is not a request to seek the soundtrack. Only correct drift
// after fresh, stable samples; explicit transport actions bypass that filter.
function createMotionAudioSync(audio) {
var sampledAt = null, driftSince = null, previousDrift = 0, correctedAt = -Infinity, align = false;
return function (target, moving, force) {
var now = performance.now(), gap = sampledAt === null ? Infinity : now - sampledAt;
sampledAt = now;
if (force) { align = true; driftSince = null; }
if (align) {
if (audio.readyState > 0) {
if (Math.abs(audio.currentTime - target) > .01) audio.currentTime = target;
align = false; correctedAt = now;
}
return;
}
var drift = target - audio.currentTime;
if (!moving || audio.paused || audio.seeking || audio.readyState < 3 || gap > 200 || Math.abs(drift) <= .3 || now - correctedAt < 1500) {
driftSince = null; previousDrift = drift; return;
}
if (driftSince === null || drift * previousDrift <= 0 || Math.abs(drift - previousDrift) > .1) driftSince = now;
previousDrift = drift;
if (now - driftSince >= 750) {
audio.currentTime = target; correctedAt = now; driftSince = null;
}
};
}
// END shared motion audio sync
const audio=document.getElementById('soundtrack');
if (!audio || typeof audio.play !== 'function' || window.__odFrameRenderer) return;
const controls=document.getElementById('controls');
const button=document.createElement('button');button.type='button';controls.appendChild(button);
// Gallery thumbnails load the page in an opaque sandbox; those stay silent until asked.
let enabled=window.origin!=='null',pending=false,chosen=false;
const timeline=()=>window.__timelines&&window.__timelines.main;
const syncAudio=createMotionAudioSync(audio);
let wasPlaying=false,lastTime=0;
function label() { button.textContent=enabled?'Sound off':'Sound on';button.setAttribute('aria-label',enabled?'Mute sound':'Enable sound');button.setAttribute('aria-pressed',String(enabled)); }
function play() { if(pending||!audio.paused)return;pending=true;audio.play().catch(()=>{enabled=false;label();if(!chosen)waitForGesture();}).finally(()=>{pending=false;}); }
function waitForGesture() {
const start=event=>{
if(event.target===button)return;
document.removeEventListener('pointerdown',start,true);document.removeEventListener('keydown',start,true);
if(chosen||enabled)return;
const tl=timeline();enabled=true;label();if(tl)syncAudio(tl.time(),true,true);play();
};
document.addEventListener('pointerdown',start,true);document.addEventListener('keydown',start,true);
}
label();
button.onclick=()=>{chosen=true;enabled=!enabled;label();const tl=timeline();if(enabled&&tl){if(tl.progress()>=1)tl.restart();else tl.play();syncAudio(tl.time(),true,true);play();}else audio.pause();};
document.getElementById('scrub').addEventListener('input',event=>{if(enabled)syncAudio(Number(event.target.value),false,true);});
function sync() {
if (window.__odMotionSourcePlayer) return;
const tl=timeline();
if(tl&&enabled&&!window.__odFrameRenderer){
const time=tl.time(),moving=!tl.paused()&&tl.progress()<1;
syncAudio(time,moving,(moving&&!wasPlaying)||time<lastTime-.1);
wasPlaying=moving;lastTime=time;
if(moving)play();else audio.pause();
}else { audio.pause();wasPlaying=false; }
requestAnimationFrame(sync);
}sync();
})();
</script>
</body>
</html>