// AFTERLIGHT · Film 2 · The Picture You Could Feel // Keyframe module. Plain browser JS, Canvas 2D only, deterministic. // Medium: embossed cut-paper relief on a tabletop under one lamp. (function () { const TAU = Math.PI * 2; const VW = 1280, VH = 720; // ---------- utilities ---------- function rng(seed) { let a = seed >>> 0; return function () { a = (a + 0x6D2B79F5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function cv(w, h) { const c = document.createElement('canvas'); c.width = w; c.height = h; return c; } function applyM(ctx, m) { ctx.transform(m.a, m.b, m.c, m.d, m.e, m.f); } const hyp = Math.hypot; // consistent cast: A (ochre sleeve, darker skin) and B (slate sleeve, lighter skin) const CAST = { A: { skin: '#8d5b3e', hi: '#b98762', sleeve: '#b07a30', cuff: '#8a5c20', hair: '#1c1512' }, B: { skin: '#dcae8f', hi: '#f1d0b8', sleeve: '#4c6176', cuff: '#3a4b5c', hair: '#5b3f2c' } }; // ---------- the picture (sheet units, u across, v down) ---------- const HILL = [[0.06, 0.80], [0.18, 0.70], [0.30, 0.64], [0.42, 0.66], [0.55, 0.68], [0.68, 0.585], [0.80, 0.60], [0.94, 0.72]]; const TRUNK = [[0.30, 0.645], [0.305, 0.52], [0.31, 0.38]]; const BR = [ [[0.307, 0.50], [0.25, 0.44], [0.19, 0.40]], [[0.309, 0.45], [0.36, 0.39], [0.43, 0.34]], [[0.31, 0.40], [0.28, 0.32], [0.25, 0.26]], [[0.36, 0.39], [0.40, 0.42], [0.44, 0.43]] ]; const SUN = [0.72, 0.28, 0.075]; function ringPts(cx, cy, r, n) { const p = []; for (let i = 0; i <= n; i++) { const a = i / n * TAU; p.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r * 1.3]); } return p; } function PIC(ver) { if (ver === 'v1') { return { lines: [{ w: 0.016, p: HILL }, { w: 0.013, p: TRUNK }].concat(BR.map(p => ({ w: 0.0055, p }))), discs: [SUN] }; } // v2 (corrected) and v2p (corrected, sun stipple half done) const lines = [{ w: 0.018, p: HILL }, { w: 0.021, p: TRUNK }].concat(BR.map(p => ({ w: 0.017, p }))); lines.push({ w: 0.016, p: ringPts(SUN[0], SUN[1], SUN[2], 48) }); const discs = []; for (let du = -0.06; du <= 0.0601; du += 0.024) for (let dv = -0.06; dv <= 0.0601; dv += 0.024) { if (hyp(du, dv) > 0.052) continue; if (ver === 'v2p' && dv > 0.001) continue; discs.push([SUN[0] + du, SUN[1] + dv * 1.3, 0.0068]); } return { lines, discs }; } function tracePts(x, pts, W, H) { x.moveTo(pts[0][0] * W, pts[0][1] * H); for (let i = 1; i < pts.length - 1; i++) { const mx = (pts[i][0] + pts[i + 1][0]) / 2 * W, my = (pts[i][1] + pts[i + 1][1]) / 2 * H; x.quadraticCurveTo(pts[i][0] * W, pts[i][1] * H, mx, my); } const l = pts[pts.length - 1]; x.lineTo(l[0] * W, l[1] * H); } function drawPic(x, ver, W, H, mirror) { x.save(); if (mirror) { x.translate(W, 0); x.scale(-1, 1); } x.lineCap = 'round'; x.lineJoin = 'round'; const P = PIC(ver); for (const s of P.lines) { x.lineWidth = s.w * W; x.beginPath(); tracePts(x, s.p, W, H); x.stroke(); } for (const d of P.discs) { x.beginPath(); x.arc(d[0] * W, d[1] * H, d[2] * W, 0, TAU); x.fill(); } x.restore(); } function maskOf(ver, W, H, mirror, blur) { const c = cv(W, H), x = c.getContext('2d'); x.fillStyle = '#000'; x.fillRect(0, 0, W, H); if (blur) x.filter = 'blur(' + blur + 'px)'; x.fillStyle = x.strokeStyle = '#fff'; drawPic(x, ver, W, H, mirror); const d = x.getImageData(0, 0, W, H).data, m = new Float32Array(W * H); for (let i = 0; i < m.length; i++) m[i] = d[i * 4] / 255; return m; } // ---------- the sheet: heat-diffusion height field + raking-light shading ---------- // Height = narrow-diffusion term (thin lines lose heat and stay shallow) // + wide-diffusion term (large dark areas keep heat and over-swell). function makeSheet(o) { const W = o.W || 520, H = Math.round(W / 1.3), N = W * H; const mirror = o.side === 'back'; const ink = maskOf(o.ver, W, H, mirror, 0); const h = new Float32Array(N); if (o.relief > 0) { const b1 = maskOf(o.ver, W, H, mirror, 4), b2 = maskOf(o.ver, W, H, mirror, 14); for (let i = 0; i < N; i++) { const I1 = b1[i], I2 = b2[i]; h[i] = o.relief * (26 * Math.max(0, I1 - 0.28) + 30 * I2 * I2); } } const r = rng(o.seed || 7); const pasteEdge = o.side === 'back' ? (o.pasteEdge == null ? 0 : o.pasteEdge) : 2; // paper fibre / paste grain, plus knife streaks in fresh paste const streak = new Float32Array(H); for (let y = 0; y < H; y++) streak[y] = Math.sin(y * 0.21 + r() * 0.6) * 0.6 + Math.sin(y * 0.047) * 0.8; for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { const i = y * W + x; const edge = pasteEdge * W + Math.sin(y * 0.09) * 9 + Math.sin(y * 0.31) * 4; const onPaste = x > edge; h[i] += (r() - 0.5) * (onPaste ? 0.9 : 0.25); if (onPaste && o.wetPaste) h[i] += streak[y] + (x - edge < 10 ? (10 - (x - edge)) * 0.25 : 0); } const c = cv(W, H), x = c.getContext('2d'); const img = x.createImageData(W, H), D = img.data; const L = o.light, amb = o.amb, kd = o.kd, tint = o.tint || [1, 1, 1]; const paper = [240, 234, 221], paste = [233, 229, 219], inkC = [26, 24, 27]; for (let y = 0; y < H; y++) for (let xx = 0; xx < W; xx++) { const i = y * W + xx; let v = 1; if (xx > 0 && yy(y) && xx < W - 1) { const hx = (h[i + 1] - h[i - 1]) * 0.5, hy = (h[i + W] - h[i - W]) * 0.5; const nl = hyp(hx, hy, 1); const lx = L.x - xx, ly = L.y - y, lz = L.z - h[i], ll = hyp(lx, ly, lz); const dif = Math.max(0, (-hx * lx - hy * ly + lz) / (nl * ll)); const flat = Math.max(0.02, L.z / hyp(L.x - xx, L.y - y, L.z)); let sh = 1; if (o.relief > 0) { const dxy = hyp(lx, ly) || 1, ux = lx / dxy, uy = ly / dxy, sl = lz / dxy; for (let s = 2; s <= 30; s += 2) { const px = Math.round(xx + ux * s), py = Math.round(y + uy * s); if (px < 0 || py < 0 || px >= W || py >= H) break; if (h[py * W + px] > h[i] + sl * s + 0.4) { sh = 0.42; break; } } } v = (amb + kd * dif * sh) / (amb + kd * flat); } const edge = pasteEdge * W + Math.sin(y * 0.09) * 9 + Math.sin(y * 0.31) * 4; const base = xx > edge ? paste : paper; let cr = base[0], cg = base[1], cb = base[2]; if (!mirror) { const k = ink[i]; cr += (inkC[0] - cr) * k; cg += (inkC[1] - cg) * k; cb += (inkC[2] - cb) * k; } else { const k = ink[i] * (xx > edge ? 0.05 : 0.14); cr *= 1 - k; cg *= 1 - k; cb *= 1 - k * 0.8; } const j = i * 4; D[j] = Math.min(255, cr * v * tint[0]); D[j + 1] = Math.min(255, cg * v * tint[1]); D[j + 2] = Math.min(255, cb * v * tint[2]); D[j + 3] = 255; } function yy(y) { return y > 0 && y < H - 1; } x.putImageData(img, 0, 0); return c; } // sheet placement on the table: centre, width, rotation (deg), foreshortening ys function placeM(pl) { return new DOMMatrix().translate(pl.cx, pl.cy).scale(1, pl.ys).rotate(pl.rot); } function sp(pl, u, v) { const m = placeM(pl), hh = pl.w / 1.3; const p = m.transformPoint(new DOMPoint((u - 0.5) * pl.w, (v - 0.5) * hh)); return [p.x, p.y]; } function picPt(pl, u, v, back) { return sp(pl, back ? 1 - u : u, v); } function lightInSheet(pl, sx, sy, z, W) { const inv = placeM(pl).inverse(), p = inv.transformPoint(new DOMPoint(sx, sy)), hh = pl.w / 1.3, H = Math.round(W / 1.3); return { x: (p.x / pl.w + 0.5) * W, y: (p.y / hh + 0.5) * H, z }; } function drawSheet(ctx, canvas, pl, L, shadowA) { const hh = pl.w / 1.3; const c = [pl.cx, pl.cy], dx = c[0] - L[0], dy = c[1] - L[1], d = hyp(dx, dy) || 1; ctx.save(); ctx.translate(dx / d * 9, dy / d * 9); applyM(ctx, placeM(pl)); ctx.filter = 'blur(6px)'; ctx.fillStyle = 'rgba(12,8,6,' + (shadowA == null ? 0.45 : shadowA) + ')'; ctx.fillRect(-pl.w / 2, -hh / 2, pl.w, hh); ctx.restore(); ctx.save(); applyM(ctx, placeM(pl)); ctx.drawImage(canvas, -pl.w / 2, -hh / 2, pl.w, hh); ctx.strokeStyle = 'rgba(255,250,240,0.35)'; ctx.lineWidth = 1.2; ctx.strokeRect(-pl.w / 2, -hh / 2, pl.w, hh); ctx.restore(); } // ---------- embossed paper shapes ---------- function paperShape(ctx, path, color, c, L, depth, size, opt) { opt = opt || {}; const dx = c[0] - L[0], dy = c[1] - L[1], d = hyp(dx, dy) || 1, ux = dx / d, uy = dy / d; if (!opt.noShadow) { ctx.save(); ctx.translate(ux * depth, uy * depth); ctx.filter = 'blur(' + Math.max(1, depth * 0.7) + 'px)'; ctx.fillStyle = 'rgba(10,6,4,' + (opt.shadowA == null ? 0.5 : opt.shadowA) + ')'; ctx.fill(path); ctx.restore(); } ctx.fillStyle = color; ctx.fill(path); ctx.save(); ctx.clip(path); const R = size; const g = ctx.createLinearGradient(c[0] - ux * R, c[1] - uy * R, c[0] + ux * R, c[1] + uy * R); const hi = opt.hi || 'rgba(255,238,210,0.30)'; g.addColorStop(0, hi); g.addColorStop(0.5, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(0,0,0,0.34)'); ctx.fillStyle = g; ctx.fillRect(c[0] - R * 2, c[1] - R * 2, R * 4, R * 4); const k = Math.max(1, depth * 0.35); ctx.lineWidth = k * 1.6; ctx.save(); ctx.translate(ux * k, uy * k); ctx.strokeStyle = opt.rim || 'rgba(255,240,215,0.45)'; ctx.stroke(path); ctx.restore(); ctx.save(); ctx.translate(-ux * k, -uy * k); ctx.strokeStyle = 'rgba(0,0,0,0.35)'; ctx.stroke(path); ctx.restore(); ctx.restore(); } function capsule(p, x1, y1, x2, y2, r) { const a = Math.atan2(y2 - y1, x2 - x1), q = Math.PI / 2; p.moveTo(x1 + Math.cos(a - q) * r, y1 + Math.sin(a - q) * r); p.lineTo(x2 + Math.cos(a - q) * r, y2 + Math.sin(a - q) * r); p.arc(x2, y2, r, a - q, a + q); p.lineTo(x1 + Math.cos(a + q) * r, y1 + Math.sin(a + q) * r); p.arc(x1, y1, r, a + q, a + 3 * q); p.closePath(); } // ---------- hands ---------- const FING = [[-0.29, 0.74, 0.105], [-0.10, 0.82, 0.11], [0.10, 0.76, 0.105], [0.27, 0.60, 0.092]]; const CURL = { flat: [1, 1, 1, 1], rest: [0.92, 0.96, 0.9, 0.86], point: [1, 0.34, 0.3, 0.3], trace: [1, 0.93, 0.36, 0.32], pen: [0.66, 0.36, 0.32, 0.3] }; function handLocal(pose) { const p = new Path2D(), parts = []; p.ellipse(0.42, 0.0, 0.52, 0.43, 0, 0, TAU); const cm = CURL[pose]; FING.forEach((f, k) => { const spread = (pose === 'flat' || pose === 'rest') ? f[0] * 0.35 : f[0] * 0.12; const len = f[1] * cm[k], bx = 0.82, by = f[0]; const tx = bx + Math.cos(spread) * len, ty = by + Math.sin(spread) * len; const q = new Path2D(); capsule(q, bx - 0.08, by, tx, ty, f[2]); p.addPath(q); parts.push({ q, tx, ty, r: f[2], ext: cm[k] > 0.6, a: spread }); }); const ta = pose === 'pen' ? -0.42 : -0.95, tl = pose === 'pen' ? 0.62 : 0.5; const t = new Path2D(); capsule(t, 0.28, -0.33, 0.28 + Math.cos(ta) * tl, -0.33 + Math.sin(ta) * tl, 0.12); p.addPath(t); parts.push({ q: t, tx: 0.28 + Math.cos(ta) * tl, ty: -0.33 + Math.sin(ta) * tl, r: 0.12, ext: true, a: ta, thumb: true }); return { p, parts, tip: [parts[0].tx, parts[0].ty] }; } // o: {tip:[x,y], from:[x,y], s, ys, pose, who, flip, L, armW, tool:'pen'|'knife', dim} function drawHand(ctx, o) { const C = CAST[o.who], hl = handLocal(o.pose); let anchor = hl.tip; if (o.tool) anchor = [hl.tip[0] + 0.3, hl.tip[1] + 0.05]; const vx = o.tip[0] - o.from[0], vy = o.tip[1] - o.from[1]; const ang = Math.atan2(vy / o.ys, vx) * 180 / Math.PI + (o.twist || 0); const M = new DOMMatrix().translate(o.tip[0], o.tip[1]).scale(1, o.ys).rotate(ang) .scale(o.s, o.s * (o.flip ? -1 : 1)).translate(-anchor[0], -anchor[1]); const wr = M.transformPoint(new DOMPoint(-0.02, 0)), wr2 = M.transformPoint(new DOMPoint(-0.02, 0.42)), wr1 = M.transformPoint(new DOMPoint(-0.02, -0.42)); const L = o.L, depth = o.s * 0.09; // arm (sleeve) const ax = wr.x - o.from[0], ay = wr.y - o.from[1], al = hyp(ax, ay) || 1, nx = -ay / al, ny = ax / al; const aw = (o.armW || o.s * 1.1) / 2, ww = hyp(wr2.x - wr1.x, wr2.y - wr1.y) / 2 * 1.08; const arm = new Path2D(); arm.moveTo(o.from[0] + nx * aw, o.from[1] + ny * aw); arm.lineTo(wr.x + nx * ww, wr.y + ny * ww); arm.quadraticCurveTo(wr.x + ax / al * ww * 0.5, wr.y + ay / al * ww * 0.5, wr.x - nx * ww, wr.y - ny * ww); arm.lineTo(o.from[0] - nx * aw, o.from[1] - ny * aw); arm.closePath(); const mid = [(o.from[0] + wr.x) / 2, (o.from[1] + wr.y) / 2]; paperShape(ctx, arm, C.sleeve, mid, L, depth * 1.3, al / 2, { shadowA: o.dim ? 0.3 : 0.5, hi: o.hiCol, rim: o.rimCol }); // cuff const cuff = new Path2D(); const cb = [wr.x - ax / al * o.s * 0.22, wr.y - ay / al * o.s * 0.22]; cuff.moveTo(cb[0] + nx * ww * 1.04, cb[1] + ny * ww * 1.04); cuff.lineTo(wr.x + nx * ww, wr.y + ny * ww); cuff.lineTo(wr.x - nx * ww, wr.y - ny * ww); cuff.lineTo(cb[0] - nx * ww * 1.04, cb[1] - ny * ww * 1.04); cuff.closePath(); ctx.fillStyle = C.cuff; ctx.fill(cuff); // hand const P = new Path2D(); P.addPath(hl.p, M); const hc = M.transformPoint(new DOMPoint(0.6, 0)); paperShape(ctx, P, C.skin, [hc.x, hc.y], L, depth, o.s, { shadowA: o.dim ? 0.35 : 0.55, hi: o.hiCol, rim: o.rimCol }); ctx.save(); ctx.clip(P); ctx.strokeStyle = 'rgba(50,25,15,0.32)'; ctx.lineWidth = Math.max(1, o.s * 0.018); hl.parts.forEach(f => { const q = new Path2D(); q.addPath(f.q, M); ctx.stroke(q); }); // nails and knuckle creases hl.parts.forEach(f => { if (!f.ext) { const k = M.transformPoint(new DOMPoint(f.tx, f.ty)); ctx.fillStyle = 'rgba(40,20,10,0.22)'; ctx.beginPath(); ctx.arc(k.x, k.y, o.s * 0.05, 0, TAU); ctx.fill(); return; } const nxp = f.tx - Math.cos(f.a) * f.r * 0.9, nyp = f.ty - Math.sin(f.a) * f.r * 0.9; const nm = new DOMMatrix().multiply(M).translate(nxp, nyp).rotate(f.a * 180 / Math.PI); const nail = new Path2D(); nail.ellipse(0, 0, f.r * 0.85, f.r * 0.62, 0, 0, TAU); const q = new Path2D(); q.addPath(nail, nm); ctx.fillStyle = o.dim ? 'rgba(220,210,220,0.18)' : 'rgba(255,225,205,0.42)'; ctx.fill(q); }); ctx.restore(); // tools if (o.tool === 'pen') { const a = M.transformPoint(new DOMPoint(anchor[0], anchor[1])); const b = M.transformPoint(new DOMPoint(-0.25, -0.75)); const pen = new Path2D(); capsuleScreen(pen, a, b, o.s * 0.07); paperShape(ctx, pen, '#1d1f24', [(a.x + b.x) / 2, (a.y + b.y) / 2], L, depth * 0.8, o.s, { shadowA: 0.4 }); ctx.fillStyle = '#0b0b0c'; ctx.beginPath(); ctx.arc(a.x, a.y, o.s * 0.045, 0, TAU); ctx.fill(); // re-draw thumb and index over the pen const fp = new Path2D(); fp.addPath(hl.parts[0].q, M); fp.addPath(hl.parts[4].q, M); ctx.fillStyle = C.skin; ctx.fill(fp); ctx.save(); ctx.clip(fp); ctx.strokeStyle = 'rgba(50,25,15,0.35)'; ctx.lineWidth = Math.max(1, o.s * 0.02); ctx.stroke(fp); ctx.restore(); } if (o.tool === 'knife') { const a = M.transformPoint(new DOMPoint(anchor[0] + 0.1, anchor[1])); const b = M.transformPoint(new DOMPoint(hl.tip[0] - 0.1, hl.tip[1] + 0.1)); const blade = new Path2D(); capsuleScreen(blade, a, b, o.s * 0.16); paperShape(ctx, blade, '#b9bcc0', [(a.x + b.x) / 2, (a.y + b.y) / 2], L, depth * 0.4, o.s, { shadowA: 0.3 }); ctx.save(); ctx.clip(blade); ctx.fillStyle = 'rgba(245,242,232,0.95)'; ctx.beginPath(); ctx.arc(a.x, a.y, o.s * 0.2, 0, TAU); ctx.fill(); ctx.restore(); const hnd = M.transformPoint(new DOMPoint(-0.3, -0.6)); const handle = new Path2D(); capsuleScreen(handle, b, hnd, o.s * 0.08); paperShape(ctx, handle, '#5a3b26', [(b.x + hnd.x) / 2, (b.y + hnd.y) / 2], L, depth * 0.6, o.s, { shadowA: 0.3 }); const fp = new Path2D(); fp.addPath(hl.parts[0].q, M); fp.addPath(hl.parts[4].q, M); ctx.fillStyle = C.skin; ctx.fill(fp); ctx.save(); ctx.clip(fp); ctx.strokeStyle = 'rgba(50,25,15,0.35)'; ctx.lineWidth = 1.5; ctx.stroke(fp); ctx.restore(); } } function capsuleScreen(p, a, b, r) { capsule(p, a.x, a.y, b.x, b.y, r); } // ---------- busts (KF1 only) ---------- function drawBust(ctx, x, y, s, who, eyes, tilt, L) { const C = CAST[who]; const torso = new Path2D(); torso.moveTo(x - 2.8 * s, y + 4.6 * s); torso.lineTo(x - 2.55 * s, y + 2.4 * s); torso.quadraticCurveTo(x - 2.25 * s, y + 1.55 * s, x - 0.9 * s, y + 1.45 * s); torso.lineTo(x + 0.9 * s, y + 1.45 * s); torso.quadraticCurveTo(x + 2.25 * s, y + 1.55 * s, x + 2.55 * s, y + 2.4 * s); torso.lineTo(x + 2.8 * s, y + 4.6 * s); torso.closePath(); paperShape(ctx, torso, C.sleeve, [x, y + 3 * s], L, s * 0.12, 2.5 * s); // collar ctx.fillStyle = C.cuff; ctx.beginPath(); ctx.ellipse(x, y + 1.5 * s, 0.75 * s, 0.25 * s, 0, 0, TAU); ctx.fill(); const neck = new Path2D(); neck.rect(x - 0.36 * s, y + 0.55 * s, 0.72 * s, 0.95 * s); paperShape(ctx, neck, C.skin, [x, y + s], L, s * 0.05, s, { noShadow: true }); ctx.save(); ctx.translate(x, y); ctx.rotate(tilt * Math.PI / 180); ctx.translate(-x, -y); const M = ctx.getTransform(); const head = new Path2D(); head.ellipse(x, y, 0.8 * s, s, 0, 0, TAU); // ears const ears = new Path2D(); ears.ellipse(x - 0.8 * s, y + 0.05 * s, 0.14 * s, 0.24 * s, 0, 0, TAU); ears.ellipse(x + 0.8 * s, y + 0.05 * s, 0.14 * s, 0.24 * s, 0, 0, TAU); ctx.fillStyle = C.skin; ctx.fill(ears); paperShape(ctx, head, C.skin, [x, y], L, s * 0.1, s, { hi: 'rgba(255,230,200,0.35)' }); // hair const hair = new Path2D(); if (who === 'A') { hair.moveTo(x - 0.84 * s, y + 0.05 * s); hair.bezierCurveTo(x - 0.95 * s, y - 1.25 * s, x + 0.95 * s, y - 1.25 * s, x + 0.84 * s, y + 0.05 * s); hair.bezierCurveTo(x + 0.7 * s, y - 0.45 * s, x - 0.7 * s, y - 0.45 * s, x - 0.84 * s, y + 0.05 * s); } else { hair.moveTo(x - 0.9 * s, y + 1.0 * s); hair.bezierCurveTo(x - 1.15 * s, y - 1.4 * s, x + 1.15 * s, y - 1.4 * s, x + 0.9 * s, y + 1.0 * s); hair.lineTo(x + 0.7 * s, y + 0.2 * s); hair.bezierCurveTo(x + 0.6 * s, y - 0.55 * s, x - 0.4 * s, y - 0.7 * s, x - 0.7 * s, y + 0.2 * s); hair.closePath(); hair.ellipse(x + 0.2 * s, y - 1.05 * s, 0.38 * s, 0.3 * s, 0, 0, TAU); } paperShape(ctx, hair, C.hair, [x, y - 0.5 * s], L, s * 0.08, s, { hi: 'rgba(255,230,200,0.18)' }); // face ctx.lineCap = 'round'; ctx.strokeStyle = 'rgba(40,22,14,0.85)'; ctx.lineWidth = Math.max(1.5, s * 0.05); for (const sx of [-1, 1]) { const ex = x + sx * 0.32 * s, ey = y + 0.08 * s; // brow ctx.beginPath(); ctx.moveTo(ex - 0.17 * s, ey - 0.24 * s); ctx.quadraticCurveTo(ex, ey - 0.32 * s, ex + 0.17 * s, ey - 0.25 * s); ctx.stroke(); if (eyes === 'closed') { ctx.beginPath(); ctx.moveTo(ex - 0.15 * s, ey); ctx.quadraticCurveTo(ex, ey + 0.1 * s, ex + 0.15 * s, ey); ctx.stroke(); for (let k = -1; k <= 1; k++) { ctx.beginPath(); ctx.moveTo(ex + k * 0.08 * s, ey + 0.05 * s); ctx.lineTo(ex + k * 0.1 * s, ey + 0.11 * s); ctx.stroke(); } } else { ctx.fillStyle = '#efe6dc'; ctx.beginPath(); ctx.ellipse(ex, ey + 0.02 * s, 0.15 * s, 0.08 * s, 0, 0, TAU); ctx.fill(); ctx.fillStyle = '#20140e'; ctx.beginPath(); ctx.arc(ex + 0.04 * s, ey + 0.05 * s, 0.06 * s, 0, TAU); ctx.fill(); ctx.beginPath(); ctx.moveTo(ex - 0.17 * s, ey - 0.01 * s); ctx.quadraticCurveTo(ex, ey - 0.06 * s, ex + 0.17 * s, ey); ctx.stroke(); } } ctx.beginPath(); ctx.moveTo(x + 0.02 * s, y + 0.15 * s); ctx.quadraticCurveTo(x + 0.14 * s, y + 0.38 * s, x - 0.02 * s, y + 0.42 * s); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x - 0.2 * s, y + 0.64 * s); ctx.quadraticCurveTo(x, y + (who === 'A' ? 0.6 : 0.7) * s, x + 0.2 * s, y + 0.64 * s); ctx.stroke(); ctx.restore(); void M; } // ---------- set: table, wall, lamp, light ---------- function drawWood(ctx, x0, y0, w, h, seed, persp) { const r = rng(seed); ctx.fillStyle = '#7d5638'; ctx.fillRect(x0, y0, w, h); // planks const n = 7; for (let k = 0; k <= n; k++) { const t = k / n, yy = persp ? y0 + h * Math.pow(t, 1.6) : y0 + h * t; ctx.fillStyle = 'rgba(40,22,12,0.55)'; ctx.fillRect(x0, yy - 1.5, w, 3); ctx.fillStyle = 'rgba(255,220,180,0.10)'; ctx.fillRect(x0, yy + 1.5, w, 1.5); } for (let i = 0; i < 140; i++) { const t = r(), yy = persp ? y0 + h * Math.pow(t, 1.6) : y0 + h * t; const amp = 2 + r() * 6, fr = 0.004 + r() * 0.01, ph = r() * TAU; ctx.strokeStyle = r() < 0.6 ? 'rgba(55,32,18,' + (0.12 + r() * 0.2) + ')' : 'rgba(200,150,105,' + (0.08 + r() * 0.12) + ')'; ctx.lineWidth = 0.6 + r() * 1.8; ctx.beginPath(); for (let xx = x0; xx <= x0 + w; xx += 16) { const yv = yy + Math.sin(xx * fr + ph) * amp * (persp ? (0.4 + t) : 1); xx === x0 ? ctx.moveTo(xx, yv) : ctx.lineTo(xx, yv); } ctx.stroke(); } } function lightPool(ctx, cx, cy, r, warm) { ctx.save(); ctx.globalCompositeOperation = 'multiply'; const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r); if (warm) { g.addColorStop(0, 'rgb(255,244,222)'); g.addColorStop(0.35, 'rgb(225,196,160)'); g.addColorStop(0.7, 'rgb(105,82,66)'); g.addColorStop(1, 'rgb(34,27,25)'); } ctx.fillStyle = g; ctx.fillRect(0, 0, VW, VH); ctx.restore(); } function darkRoom(ctx) { ctx.save(); ctx.globalCompositeOperation = 'multiply'; const g = ctx.createLinearGradient(VW, 0, 200, VH); g.addColorStop(0, 'rgb(118,128,150)'); g.addColorStop(0.45, 'rgb(70,77,96)'); g.addColorStop(1, 'rgb(24,26,34)'); ctx.fillStyle = g; ctx.fillRect(0, 0, VW, VH); ctx.restore(); } function vignette(ctx, a) { const g = ctx.createRadialGradient(VW / 2, VH / 2, VH * 0.4, VW / 2, VH / 2, VW * 0.75); g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(0,0,0,' + a + ')'); ctx.fillStyle = g; ctx.fillRect(0, 0, VW, VH); } // lamp: base point, joint, head point, aim (deg), size, on function drawLamp(ctx, base, joint, head, aim, size, on) { const metal = on ? '#3b3734' : '#22252c'; ctx.lineCap = 'round'; if (base) { ctx.fillStyle = metal; ctx.beginPath(); ctx.ellipse(base[0], base[1], size * 0.75, size * 0.28, 0, 0, TAU); ctx.fill(); ctx.strokeStyle = metal; ctx.lineWidth = size * 0.12; ctx.beginPath(); ctx.moveTo(base[0], base[1]); ctx.quadraticCurveTo(joint[0], joint[1], head[0], head[1]); ctx.stroke(); ctx.strokeStyle = on ? 'rgba(255,225,180,0.35)' : 'rgba(150,170,200,0.18)'; ctx.lineWidth = size * 0.03; ctx.beginPath(); ctx.moveTo(base[0] - size * 0.03, base[1]); ctx.quadraticCurveTo(joint[0] - size * 0.04, joint[1], head[0] - size * 0.03, head[1]); ctx.stroke(); } ctx.save(); ctx.translate(head[0], head[1]); ctx.rotate(aim * Math.PI / 180); const sh = new Path2D(); sh.moveTo(-0.5 * size, -0.2 * size); sh.lineTo(0.55 * size, -0.55 * size); sh.lineTo(0.55 * size, 0.55 * size); sh.lineTo(-0.5 * size, 0.2 * size); sh.closePath(); ctx.fillStyle = metal; ctx.fill(sh); const g = ctx.createLinearGradient(0, -0.55 * size, 0, 0.55 * size); g.addColorStop(0, on ? 'rgba(255,220,170,0.35)' : 'rgba(150,170,205,0.22)'); g.addColorStop(0.5, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(0,0,0,0.4)'); ctx.fillStyle = g; ctx.fill(sh); ctx.fillStyle = on ? '#fff3d2' : '#0e1015'; ctx.beginPath(); ctx.ellipse(0.55 * size, 0, 0.13 * size, 0.55 * size, 0, 0, TAU); ctx.fill(); if (!on) { ctx.strokeStyle = 'rgba(140,160,190,0.35)'; ctx.lineWidth = 1.5; ctx.stroke(); } ctx.restore(); if (on) { const mx = head[0] + Math.cos(aim * Math.PI / 180) * 0.6 * size, my = head[1] + Math.sin(aim * Math.PI / 180) * 0.6 * size; ctx.save(); ctx.globalCompositeOperation = 'screen'; const gl = ctx.createRadialGradient(mx, my, 0, mx, my, size * 2.4); gl.addColorStop(0, 'rgba(255,240,200,0.85)'); gl.addColorStop(0.3, 'rgba(255,214,150,0.25)'); gl.addColorStop(1, 'rgba(255,200,140,0)'); ctx.fillStyle = gl; ctx.fillRect(mx - size * 3, my - size * 3, size * 6, size * 6); ctx.restore(); } } function lightCone(ctx, from, to, r, a) { ctx.save(); ctx.globalCompositeOperation = 'screen'; const g = ctx.createLinearGradient(from[0], from[1], to[0], to[1]); g.addColorStop(0, 'rgba(255,225,170,' + a + ')'); g.addColorStop(1, 'rgba(255,225,170,0)'); const dx = to[0] - from[0], dy = to[1] - from[1], d = hyp(dx, dy), nx = -dy / d, ny = dx / d; ctx.fillStyle = g; ctx.beginPath(); ctx.moveTo(from[0] + nx * 20, from[1] + ny * 20); ctx.lineTo(to[0] + nx * r, to[1] + ny * r); ctx.lineTo(to[0] - nx * r, to[1] - ny * r); ctx.lineTo(from[0] - nx * 20, from[1] - ny * 20); ctx.closePath(); ctx.fill(); ctx.restore(); } function jar(ctx, x, y, r, L) { const body = new Path2D(); body.ellipse(x, y, r, r * 0.82, 0, 0, TAU); paperShape(ctx, body, '#8e9a96', [x, y], L, r * 0.15, r); ctx.fillStyle = '#efece3'; ctx.beginPath(); ctx.ellipse(x, y, r * 0.8, r * 0.64, 0, 0, TAU); ctx.fill(); ctx.strokeStyle = 'rgba(150,140,125,0.6)'; ctx.lineWidth = 2; for (let k = 0; k < 3; k++) { ctx.beginPath(); ctx.ellipse(x + 4, y + 2, r * (0.2 + k * 0.17), r * (0.15 + k * 0.13), 0.3, 0.4, 4.6); ctx.stroke(); } } function marker(ctx, x, y, len, rot, L) { const a = rot * Math.PI / 180, b = [x + Math.cos(a) * len, y + Math.sin(a) * len]; const p = new Path2D(); capsule(p, x, y, b[0], b[1], len * 0.07); paperShape(ctx, p, '#1d1f24', [(x + b[0]) / 2, (y + b[1]) / 2], L, 6, len / 2); const c = new Path2D(); capsule(c, x, y, x + Math.cos(a) * len * 0.3, y + Math.sin(a) * len * 0.3, len * 0.078); ctx.fillStyle = '#2c2f36'; ctx.fill(c); } function begin(ctx, w, h) { ctx.save(); ctx.scale(w / VW, h / VH); ctx.beginPath(); ctx.rect(0, 0, VW, VH); ctx.clip(); } function end(ctx) { ctx.restore(); } // ================= KEYFRAMES ================= const keyframes = []; // ---- KF1: nothing there ---- keyframes.push({ n: 1, t: 0.0, title: 'Nothing there', shot: 'Medium two-shot across the table, slightly high angle; lamp in left foreground', action: 'Lamp on. Maker A draws the hill and tree in black marker, watching the lines. Maker B, eyes closed, runs a fingertip along the same hill and finds only flat paper.', mechanism: 'lamp → ink (visible contrast) ; ink ⇝ A (a picture) ; flat ink ⇝ B (nothing) ; seeing ⊣ who can read the picture', layers: [ { name: 'tabletop, lamp, sheet, two makers (cut-paper relief)', source: 'INVENTED', ref: null }, { name: 'pinned print on the studio wall: blind readers at an embossed map', source: 'PHOTOGRAPHED', ref: 1 }, { name: 'reading-hand pose (B’s fingertip on paper)', source: 'PHOTOGRAPHED', ref: 2 } ], prompt: 'Medium two-shot across a wooden worktable at night, one gooseneck desk lamp in the left foreground throwing a warm pool onto a sheet of paper. Left, a maker in an ochre sweater draws a hill and a bare tree in thick black marker. Right, a maker in a slate-blue sweater sits with eyes closed, one fingertip on the drawn hill. Embossed cut-paper relief style, every layer raised and shadowed by the single lamp, warm tungsten against a dark wall. no text, no logos', draw: function (ctx, w, h) { begin(ctx, w, h); const LAMP = [330, 300]; ctx.fillStyle = '#5d5048'; ctx.fillRect(0, 0, VW, 340); // wall texture const r = rng(11); for (let i = 0; i < 90; i++) { ctx.fillStyle = 'rgba(30,20,15,' + r() * 0.08 + ')'; ctx.fillRect(r() * VW, 0, 2 + r() * 10, 340); } // pinned archive print on the wall ctx.save(); ctx.translate(1060, 120); ctx.rotate(0.05); ctx.fillStyle = 'rgba(0,0,0,0.4)'; ctx.fillRect(-86, -60, 180, 132); ctx.fillStyle = '#e9e1cf'; ctx.fillRect(-92, -66, 180, 132); const pg = ctx.createLinearGradient(-80, -55, 80, 55); pg.addColorStop(0, '#8b7a62'); pg.addColorStop(1, '#4f4335'); ctx.fillStyle = pg; ctx.fillRect(-82, -56, 160, 112); ctx.fillStyle = '#b3a183'; ctx.beginPath(); ctx.moveTo(-60, 10); ctx.bezierCurveTo(-40, -40, 10, -30, 30, -10); ctx.bezierCurveTo(60, 10, 30, 40, -10, 35); ctx.closePath(); ctx.fill(); ctx.fillStyle = '#3b3027'; ctx.beginPath(); ctx.ellipse(-30, -38, 14, 16, 0, 0, TAU); ctx.fill(); ctx.fillRect(-46, -24, 34, 50); ctx.fillStyle = '#c9b48f'; ctx.beginPath(); ctx.ellipse(-4, 8, 12, 7, 0.4, 0, TAU); ctx.fill(); ctx.fillStyle = '#9c2b22'; ctx.beginPath(); ctx.arc(-2, -60, 4, 0, TAU); ctx.fill(); ctx.restore(); drawBust(ctx, 515, 172, 56, 'A', 'open', 9, LAMP); drawBust(ctx, 815, 166, 54, 'B', 'closed', -5, LAMP); // tabletop ctx.save(); ctx.beginPath(); ctx.rect(0, 335, VW, VH); ctx.clip(); drawWood(ctx, 0, 335, VW, 385, 3, true); ctx.restore(); ctx.fillStyle = 'rgba(25,15,10,0.6)'; ctx.fillRect(0, 333, VW, 6); const pl = { cx: 655, cy: 505, w: 450, rot: -3, ys: 0.56 }; const sheet = makeSheet({ ver: 'v1', side: 'front', relief: 0, light: { x: 0, y: 0, z: 400 }, amb: 1, kd: 0, seed: 21 }); drawSheet(ctx, sheet, pl, LAMP); const L = LAMP; // A: left hand steadies sheet, right hand draws drawHand(ctx, { who: 'A', pose: 'rest', tip: sp(pl, 0.1, 0.86), from: [430, 300], s: 62, ys: 0.72, L, armW: 78 }); drawHand(ctx, { who: 'A', pose: 'pen', tool: 'pen', tip: picPt(pl, 0.43, 0.34, false), from: [600, 290], s: 60, ys: 0.72, L, armW: 76, flip: true }); // B: tracing fingertip on hill, other hand resting drawHand(ctx, { who: 'B', pose: 'trace', tip: picPt(pl, 0.66, 0.6, false), from: [760, 290], s: 58, ys: 0.72, L, armW: 72 }); drawHand(ctx, { who: 'B', pose: 'rest', tip: [1010, 600], from: [900, 300], s: 58, ys: 0.72, L, armW: 72, flip: true }); lightPool(ctx, 640, 500, 760, true); lightCone(ctx, [380, 335], [640, 500], 230, 0.10); drawLamp(ctx, [150, 760], [140, 330], [320, 300], 38, 78, true); vignette(ctx, 0.45); end(ctx); }, refs: [ { kind: 'archive footage', title: 'Work Pays America (Part I)', creator: 'U.S. Works Progress Administration', date: '1937', url: 'https://archive.org/details/WorkPays1937', rights: 'public domain', use: 'cutaway plate before the title: the shot list includes braille maps and books being prepared and read; matted as a PHOTOGRAPHED insert', verified: true }, { kind: 'still image', title: 'Map of Europe, for blind', creator: 'Bain News Service (Library of Congress, LC-B2-3461-6)', date: 'c.1910–1915', url: 'https://www.flickr.com/photos/library_of_congress/7753226878/', rights: 'public domain (LOC: no known restrictions on publication)', use: 'background plate: the pinned print on the studio wall', verified: true }, { kind: 'still image', title: 'Learning to read and write by touch (soldier reading Braille, Baltimore)', creator: 'American National Red Cross, photographer Grier (Library of Congress)', date: '1918', url: 'https://www.flickr.com/photos/library_of_congress/51990449903/', rights: 'public domain (LOC: no known restrictions on publication)', use: 'rotoscope reference for B’s reading hand and posture', verified: true } ] }); // ---- KF2: revise and coat ---- keyframes.push({ n: 2, t: 13.0, title: 'Thicker, then the paste', shot: 'High-angle close shot on the tabletop, two sheets side by side', action: 'B taps the thin branches on the first print: there is nothing to feel. A has reprinted the picture with heavier lines; the sheet lies face down and A spreads heat-expanding paste across its back while B holds the corner.', mechanism: 'thin ink ⇝ B (absent) ; B’s objection ⊣ the next print ; dark ink will absorb light → heat → paste swells (on the back)', layers: [ { name: 'tabletop, both sheets, paste jar, hands (cut-paper relief)', source: 'INVENTED', ref: null }, { name: 'process: dark print on the front, heat-expanding paste on the back', source: 'INVENTED', ref: 0 }, { name: 'hand rhythm of two people working one task by touch', source: 'PHOTOGRAPHED', ref: 2 } ], prompt: 'High-angle close shot of a wooden table under warm lamplight. Left, a printed sheet with a thin black line drawing of a hill and tree; a light-skinned hand in a slate sleeve presses a fingertip on a faint branch. Right, a second sheet lies face down, the drawing faintly showing through, and a darker hand in an ochre sleeve spreads white paste with a palette knife. A jar of paste in the corner. Embossed cut-paper relief, single raking lamp. no text, no logos', draw: function (ctx, w, h) { begin(ctx, w, h); const L = [-60, -120]; drawWood(ctx, 0, 0, VW, VH, 5, false); const p1 = { cx: 330, cy: 385, w: 480, rot: -9, ys: 0.84 }; const p2 = { cx: 905, cy: 410, w: 540, rot: 6, ys: 0.84 }; const s1 = makeSheet({ ver: 'v1', side: 'front', relief: 0, light: { x: 0, y: 0, z: 400 }, amb: 1, kd: 0, seed: 22 }); const s2 = makeSheet({ ver: 'v2', side: 'back', relief: 0, pasteEdge: 0.47, wetPaste: true, light: lightInSheet(p2, L[0], L[1], 160, 520), amb: 0.45, kd: 0.8, seed: 23 }); drawSheet(ctx, s1, p1, L); drawSheet(ctx, s2, p2, L); jar(ctx, 1185, 95, 72, L); marker(ctx, 60, 70, 170, 18, L); drawHand(ctx, { who: 'B', pose: 'point', tip: picPt(p1, 0.24, 0.43, false), from: [110, 780], s: 78, ys: 0.84, L, armW: 110 }); drawHand(ctx, { who: 'B', pose: 'rest', tip: sp(p2, 0.12, 0.84), from: [560, 790], s: 74, ys: 0.84, L, armW: 104, flip: true }); drawHand(ctx, { who: 'A', pose: 'pen', tool: 'knife', tip: sp(p2, 0.5, 0.5), from: [1330, 700], s: 80, ys: 0.84, L, armW: 112, flip: true }); lightPool(ctx, 560, 300, 980, true); vignette(ctx, 0.4); end(ctx); }, refs: [ { kind: 'research paper', title: 'Swell by Light: An Approachable Technique for Freeform Raised Textures', creator: 'Sosuke Ichihashi, Noura Howell, HyunJoo Oh (Georgia Tech), TEI ’25', date: '2025', url: 'https://sites.gatech.edu/futurefeelings/?p=474', rights: 'unknown — not granted', use: 'citation for the process: dark printed pattern on the front, heat-expanding paste on the back, strong light', verified: true }, { kind: 'source text', title: 'Make Puffy Patterns with Light! (tutorial)', creator: 'Sosuke Ichihashi, Future Feelings lab, Georgia Tech', date: '2025-07-23', url: 'https://sites.gatech.edu/futurefeelings/2025/07/23/make-puffy-patterns-with-light', rights: 'unknown — not granted', use: 'props and procedure reference: puffy additive mixed 1:1 with fabric glue, spread on the blank back; “thin lines don’t puff well”', verified: true }, { kind: 'archive footage', title: 'Instructing the Blind Worker on the Job', creator: 'U.S. Office of Education', date: '1944', url: 'https://archive.org/details/InstructingTheBlindWorker', rights: 'public domain (Public Domain Mark 1.0)', use: 'performance reference for two people coordinating one task by hand; possible PHOTOGRAPHED insert', verified: true }, { kind: 'still image', title: 'Making Matrix for magazine for blind', creator: 'Bain News Service (Library of Congress, LC-B2-3458-15)', date: 'c.1910–1915', url: 'https://www.flickr.com/photos/library_of_congress/7656196904/', rights: 'public domain (LOC: no known restrictions on publication)', use: 'texture and gesture reference: hands preparing a raised-print matrix', verified: true } ] }); // ---- KF3: too far, too shallow ---- keyframes.push({ n: 3, t: 27.0, title: 'Too far, too shallow', shot: 'Close shot over the turned sheet; lamp head low in frame top-left, raking', action: 'After the lamp has heated the printed side, they turn the sheet over. On the back the picture has risen: the solid sun has ballooned into a blister, the hill stands clear, the branches barely lift. Both pairs of hands wait at the edge for it to cool.', mechanism: 'lamp → dark ink (absorbs) → heat → paste (swells) ; heat spreads ⊣ thin lines (stay shallow) ; heat pools → solid sun (over-swells)', layers: [ { name: 'swollen sheet under raking light, lamp, waiting hands (relief)', source: 'INVENTED', ref: null }, { name: 'heat-diffusion behaviour: thin lines under-swell, large dark areas over-swell', source: 'INVENTED', ref: 0 }, { name: 'braille map preparation, cut in as a PHOTOGRAPHED plate', source: 'PHOTOGRAPHED', ref: 3 } ], prompt: 'Close shot over a sheet of paper whose back has swollen into a raised white relief, lit by a low desk lamp at the top-left edge of frame so every ridge throws a long shadow. A round sun shape has ballooned into a large blister, a hill line stands crisp, thin tree branches barely rise. Two pairs of hands, ochre and slate sleeves, rest on the wood just beyond the sheet. Embossed paper relief, warm tungsten, deep shadows. no text, no logos', draw: function (ctx, w, h) { begin(ctx, w, h); const LS = [70, 60]; drawWood(ctx, 0, 0, VW, VH, 9, false); const pl = { cx: 660, cy: 345, w: 760, rot: -3, ys: 0.8 }; const s = makeSheet({ ver: 'v1', side: 'back', relief: 1, pasteEdge: 0, light: lightInSheet(pl, LS[0], LS[1], 70, 520), amb: 0.22, kd: 1.0, seed: 31 }); drawSheet(ctx, s, pl, LS); drawHand(ctx, { who: 'A', pose: 'rest', tip: [300, 650], from: [120, 790], s: 82, ys: 0.8, L: LS, armW: 112 }); drawHand(ctx, { who: 'B', pose: 'rest', tip: [1010, 655], from: [1200, 790], s: 78, ys: 0.8, L: LS, armW: 106, flip: true }); lightPool(ctx, 430, 330, 1100, true); drawLamp(ctx, null, null, [40, 40], 28, 110, true); vignette(ctx, 0.35); end(ctx); }, refs: [ { kind: 'research paper', title: 'Swell by Light: An Approachable Technique for Freeform Raised Textures (LMC publication record)', creator: 'Sosuke Ichihashi, Noura Howell, HyunJoo Oh', date: '2025', url: 'https://lmc.gatech.edu/publications/pub/7751', rights: 'unknown — not granted', use: 'citation: the paper’s print-pattern optimisation, based on thermal simulation, darkens thin lines and lightens large solid areas to compensate for heat dissipation; the film shows the uncompensated print', verified: true }, { kind: 'source text', title: 'Make Puffy Patterns with Light! (Advanced)', creator: 'Sosuke Ichihashi, Future Feelings lab', date: '2025-08-06', url: 'https://sites.gatech.edu/futurefeelings/2025/08/06/make-puffy-patterns-with-light-advanced', rights: 'unknown — not granted', use: 'procedure reference: lamp of 100 W or more or focused sunlight; let bumps cool about 30 s before touching (why the hands wait)', verified: true }, { kind: 'source text', title: 'Picture in a Flash: a tactile image worth a thousand words', creator: 'Perkins School for the Blind', date: 'n.d.', url: 'https://www.perkins.org/resource/picture-flash-tactile-image-worth-thousand-words/', rights: 'unknown — not granted', use: 'context: commercial swell (capsule) paper rises where carbon ink takes heat; longer exposure raises lines more, overheating distorts them', verified: true }, { kind: 'archive footage', title: 'Work Pays America (Part I)', creator: 'U.S. Works Progress Administration', date: '1937', url: 'https://archive.org/details/WorkPays1937', rights: 'public domain', use: 'PHOTOGRAPHED plate: the braille map shop, cut against the swelling sheet. No footage of light-induced swelling exists in the archives checked', verified: true } ] }); // ---- KF4: fingertips adjust ---- keyframes.push({ n: 4, t: 41.0, title: 'Read it, mark it', shot: 'Close insert, high angle: the swollen sheet left, a fresh print right', action: 'Roles cross. A, the one who drew by eye, now reads by touch, fingertip on the over-swollen sun. B reads the shallow branches with one hand and with the other stipples the new sun into dots and a ring so it will rise less.', mechanism: 'fingertips → relief (height felt) ⇝ both makers (what is wrong) ; their reading ⊣ the next print (thicker lines, stippled sun)', layers: [ { name: 'swollen first sheet, fresh corrected print, three hands (relief)', source: 'INVENTED', ref: null }, { name: 'two readers sharing one page by touch (blocking reference)', source: 'PHOTOGRAPHED', ref: 1 }, { name: 'touch communication between people, as reference only', source: 'PHOTOGRAPHED', ref: 0 } ], prompt: 'Close insert from above on a wooden table under warm raking lamplight. Left, a white raised relief of a hill, tree and a large swollen sun; a darker hand in an ochre sleeve rests a fingertip on the sun, a lighter hand in a slate sleeve feels the faint branches. Right, a fresh black print where the same lighter hand stipples the sun into dots with a marker. Embossed cut-paper relief, one lamp, soft long shadows. no text, no logos', draw: function (ctx, w, h) { begin(ctx, w, h); const LS = [-80, -40]; drawWood(ctx, 0, 0, VW, VH, 13, false); const pR = { cx: 470, cy: 410, w: 720, rot: -5, ys: 0.8 }; const pN = { cx: 1070, cy: 290, w: 470, rot: 8, ys: 0.8 }; const sR = makeSheet({ ver: 'v1', side: 'back', relief: 1, pasteEdge: 0, light: lightInSheet(pR, LS[0], LS[1], 120, 520), amb: 0.3, kd: 1.0, seed: 41 }); const sN = makeSheet({ ver: 'v2p', side: 'front', relief: 0, light: { x: 0, y: 0, z: 400 }, amb: 1, kd: 0, seed: 42 }); drawSheet(ctx, sN, pN, LS); drawSheet(ctx, sR, pR, LS); drawHand(ctx, { who: 'A', pose: 'trace', tip: picPt(pR, 0.70, 0.31, true), from: [90, 790], s: 84, ys: 0.8, L: LS, armW: 116 }); drawHand(ctx, { who: 'B', pose: 'trace', tip: picPt(pR, 0.24, 0.39, true), from: [800, 800], s: 80, ys: 0.8, L: LS, armW: 110, flip: true }); drawHand(ctx, { who: 'B', pose: 'pen', tool: 'pen', tip: picPt(pN, 0.735, 0.315, false), from: [1330, 720], s: 74, ys: 0.8, L: LS, armW: 104, flip: true }); lightPool(ctx, 520, 300, 1050, true); vignette(ctx, 0.35); end(ctx); }, refs: [ { kind: 'archive footage', title: 'Communicating with Deaf-Blind People', creator: 'American Foundation for the Blind (digitised by A/V Geeks)', date: '1964', url: 'https://archive.org/details/CommunicatingWithDeafBlindPeople', rights: 'unknown — not granted', use: 'performance reference only (palm printing, hand-on-hand methods); not for use as a plate without clearance from A/V Geeks', verified: true }, { kind: 'still image', title: 'A blind Woman Teaching A Blind Man to Read Braille', creator: 'Armed Forces Institute of Pathology (National Library of Medicine A014999)', date: '19--', url: 'https://www.flickr.com/photos/nlmhmd/49609883318/', rights: 'no known copyright restrictions (Flickr Commons, NLM)', use: 'blocking reference: two people at one table, both reaching to the same page', verified: true }, { kind: 'still image', title: 'FSU student learning the campus with Braille map', creator: 'State Library and Archives of Florida (RC01466)', date: 'c.1960', url: 'https://www.flickr.com/photos/floridamemory/13269625434/', rights: 'no known copyright restrictions (Flickr Commons)', use: 'hand-on-raised-map reference', verified: true }, { kind: 'museum object', title: 'Relief map of the east coast of North America (Printing for the Blind)', creator: 'L. L. Poates, New York (Smithsonian NMAH 600011)', date: '1919', url: 'https://www.si.edu/object/nmah_600011', rights: 'metadata CC0; image reuse restricted — not granted', use: 'texture reference for an embossed line under raking light', verified: true } ] }); // ---- KF5: lamp dark ---- keyframes.push({ n: 5, t: 55.0, title: 'Lamp off', shot: 'Extreme close-up on the corrected sheet; lamp dark at top-left; cool ambient from frame right', action: 'They switch off the lamp. The camera can barely make out the raised lines, but two index fingers keep following the hill from opposite ends toward each other.', mechanism: 'lamp ⊣ off (no picture for the eye) ; relief → fingertips (picture remains) ; the swelling is permanent ⇝ the picture no longer needs the light that made it', layers: [ { name: 'corrected relief sheet, two tracing hands, dark lamp (relief)', source: 'INVENTED', ref: null }, { name: 'close-up of fingers reading raised dots, for hand timing', source: 'PHOTOGRAPHED', ref: 0 }, { name: 'raised-line map from the Perkins/Howe tradition, for the end-card plate', source: 'PHOTOGRAPHED', ref: 2 } ], prompt: 'Extreme close-up in near darkness: a desk lamp, switched off, a cold silhouette at the top-left edge. Faint blue ambient light from the right grazes a sheet of white raised relief, a hill, tree and a stippled sun barely visible. Two index fingers, one darker with an ochre cuff and one lighter with a slate cuff, trace the hill line from opposite ends toward each other. Embossed paper relief, low-key, cool moonlike tones, hands softly rim-lit. no text, no logos', draw: function (ctx, w, h) { begin(ctx, w, h); const LA = [1500, -300]; drawWood(ctx, 0, 0, VW, VH, 17, false); const pl = { cx: 640, cy: 380, w: 880, rot: -2, ys: 0.8 }; const s = makeSheet({ ver: 'v2', side: 'back', relief: 1, pasteEdge: 0, light: lightInSheet(pl, LA[0], LA[1], 260, 520), amb: 0.5, kd: 0.42, seed: 51, tint: [0.78, 0.84, 0.95] }); drawSheet(ctx, s, pl, LA, 0.3); const rim = 'rgba(170,195,235,0.55)', hi = 'rgba(160,185,230,0.28)'; drawHand(ctx, { who: 'A', pose: 'trace', tip: picPt(pl, 0.73, 0.598, true), from: [80, 800], s: 92, ys: 0.8, L: LA, armW: 124, dim: true, rimCol: rim, hiCol: hi }); drawHand(ctx, { who: 'B', pose: 'trace', tip: picPt(pl, 0.39, 0.655, true), from: [1220, 800], s: 88, ys: 0.8, L: LA, armW: 118, dim: true, flip: true, rimCol: rim, hiCol: hi }); darkRoom(ctx); drawLamp(ctx, null, null, [40, 30], 30, 110, false); vignette(ctx, 0.5); end(ctx); }, refs: [ { kind: 'archive footage', title: 'Stock shot 3493: hands reading Braille, ECU (NFB archives)', creator: 'National Film Board of Canada', date: 'n.d.', url: 'https://archives.nfb.ca/stockshot/3493/', rights: 'unknown — not granted', use: 'timing reference for fingertips moving along a raised line; licence through NFB before any use as a plate', verified: true }, { kind: 'archive footage', title: 'Braille Lesson', creator: 'The Box (South West Film & Television Archive), via BFI Player', date: '1962', url: 'https://player.bfi.org.uk/free/film/watch-braille-lesson-1962-online', rights: 'unknown — not granted', use: 'performance reference: an adult learning to read by touch, patience of the hand', verified: true }, { kind: 'museum object', title: 'Atlas of the United States Printed for the Use of the Blind', creator: 'Samuel Gridley Howe, New England Institution for the Education of the Blind (David Rumsey Map Collection)', date: '1837', url: 'https://www.davidrumsey.com/blog/2012/5/21/atlas-for-the-blind-1837', rights: 'CC BY-NC-SA 3.0 (David Rumsey site) — confirm for commercial use', use: 'end-card plate: an inkless embossed map, a picture made for the hand', verified: true }, { kind: 'source text', title: 'Tactile graphic', creator: 'Wikipedia contributors', date: 'accessed 2026-10-28', url: 'https://en.wikipedia.org/wiki/Tactile_graphic', rights: 'CC BY-SA 4.0', use: 'citation for swell paper: alcohol microcapsules burst under heat where black ink is printed', verified: true } ] }); window.AFTERLIGHT_FILMS.push({ id: 'F2', n: 2, title: 'The Picture You Could Feel', role: 'The bridge to HCI and making', understand: 'Light can change a material so that an image becomes available to another sense.', medium: 'Embossed cut-paper relief on a tabletop under one lamp: the subject is a raised surface, and relief shading makes raking light versus no light the visible drama.', archivePlan: 'All tabletop action (lamp, sheets, paste, hands) is INVENTED cut-paper relief, shot under a single practical lamp so its light direction can be matched to the archive plates. PHOTOGRAPHED plates (LOC/Bain “Map of Europe, for blind”, Prelinger “Work Pays America” braille-map shop, the 1837 Howe embossed atlas) enter as pinned prints on the studio wall and as cut-in inserts, each carrying a paper-slip caption reading PHOTOGRAPHED or INVENTED. The two makers’ collaboration carries the slip “COMPOSED SCENE · archive fragments re-performed · not a recorded event”, and the process slip credits Swell by Light (Ichihashi, Howell, Oh, TEI ’25) for the technique only, not the story.', basis: [ { title: 'Swell by Light: An Approachable Technique for Freeform Raised Textures — Ichihashi, Howell, Oh, TEI ’25 (project page)', url: 'https://sites.gatech.edu/futurefeelings/?p=474', note: 'Dark pattern printed on the front, heat-expanding paste on the back; strong visible light heats the dark areas and the back swells. Includes a print-pattern optimisation algorithm based on thermal simulation (darkens thin lines, lightens large blocks to offset heat dissipation). Applications: tactile picture books, elevation maps, sensors, scented braille, textured origami.', verified: true }, { title: 'Swell by Light — ACM DL, Proc. TEI ’25, Article 45, pp. 1–16', url: 'https://doi.org/10.1145/3689050.3704420', note: 'DOI given on the lab and LMC pages; metadata and abstract confirmed through the Semantic Scholar record. dl.acm.org refused the fetcher, so the DOI page itself was not opened.', verified: false }, { title: 'LMC publication record: Swell by Light (March 2025)', url: 'https://lmc.gatech.edu/publications/pub/7751', note: 'Confirms authors (Sosuke Ichihashi, Noura Howell, HyunJoo Oh) and venue.', verified: true }, { title: 'Make Puffy Patterns with Light! (tutorial)', url: 'https://sites.gatech.edu/futurefeelings/2025/07/23/make-puffy-patterns-with-light', note: '“Thin lines don’t puff well”; light of 100 W or more or a magnifier with sunlight; cool about 30 s before touching. Grounds the shallow-branch beat and the waiting hands.', verified: true }, { title: 'Tactile graphic (swell / microcapsule paper)', url: 'https://en.wikipedia.org/wiki/Tactile_graphic', note: 'Commercial swell paper: alcohol microcapsules burst under heat where black ink is printed. Distinct from Swell by Light, which uses a paste on the reverse and a lamp or sunlight in place of a fuser.', verified: true }, { title: 'Perkins: Picture in a Flash (PIAF) tactile graphics', url: 'https://www.perkins.org/resource/picture-flash-tactile-image-worth-thousand-words/', note: 'Capsule paper rises where carbon ink absorbs heat from an even heat source; longer exposure raises lines more; overheating distorts.', verified: true } ], keyframes }); })();