// AFTERLIGHT · film 4 · The Same Water // Keyframe module. Plain browser JS, Canvas 2D only, deterministic (seeded). (function () { 'use strict'; // ---------- seeded helpers ---------- function rng(seed) { let a = seed >>> 0; return function () { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // ---------- palette ---------- const P = { floor: '#2b3236', floorGap: '#1d2326', rim: '#b9b1a3', rimDark: '#8f887c', rimLight: '#d6cfc2', water: [38, 74, 82], waterDeep: [24, 52, 60], light: [226, 240, 255], // pale, short-wavelength-leaning white (the paper: shorter wavelengths felt warmer) wall: '#cfc6b6', wallDark: '#b8ae9c', metal: '#3a3e42', metalLight: '#5d6368', A: { skin: '#875736', skinDark: '#5e3a22', sleeve: '#c48a3a', sleeveDark: '#94661f', hair: '#1c1714', trousers: '#4a3f33' }, B: { skin: '#d9b391', skinDark: '#a9825f', sleeve: '#8ea385', sleeveDark: '#64785c', hair: '#5a4532', trousers: '#3c4440' }, wood: '#9a6b42', woodDark: '#6e4a2c' }; const cache = {}; // paper grain overlay, cached function grain(W, H) { const key = 'grain' + W + 'x' + H; if (cache[key]) return cache[key]; const c = document.createElement('canvas'); c.width = 320; c.height = 180; const g = c.getContext('2d'); const id = g.createImageData(320, 180); const r = rng(77); for (let i = 0; i < id.data.length; i += 4) { const v = 128 + (r() - 0.5) * 70; id.data[i] = id.data[i + 1] = id.data[i + 2] = v; id.data[i + 3] = 26; } g.putImageData(id, 0, 0); // fibres for (let i = 0; i < 260; i++) { g.strokeStyle = r() < 0.5 ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.10)'; g.lineWidth = 0.5; const x = r() * 320, y = r() * 180, a = r() * Math.PI; g.beginPath(); g.moveTo(x, y); g.lineTo(x + Math.cos(a) * 6, y + Math.sin(a) * 6); g.stroke(); } cache[key] = c; return c; } // caustic field: spots = [{x,y,r,k}] in 0..1 coords of the image function caustic(seed, spots, cw, ch) { cw = cw || 256; ch = ch || 144; const key = 'c' + seed + JSON.stringify(spots) + cw + 'x' + ch; if (cache[key]) return cache[key]; const c = document.createElement('canvas'); c.width = cw; c.height = ch; const g = c.getContext('2d'); const id = g.createImageData(cw, ch); const r = rng(seed); const s1 = r() * 6, s2 = r() * 6, s3 = r() * 6; for (let y = 0; y < ch; y++) { for (let x = 0; x < cw; x++) { const u = x * 0.11, v = y * 0.11 * (cw / ch) * (144 / 256) * 1.0; let lit = 0; for (const s of spots) { const dx = x / cw - s.x, dy = (y / ch - s.y) * (ch / cw) * (s.sq || 1); const d = Math.sqrt(dx * dx + dy * dy) / s.r; lit += s.k * Math.max(0, 1 - d * d); } const f = 1 + lit * 0.9; const a = Math.sin(u * 1.3 * f + Math.sin(v * 0.9 + s1) * 1.7) + Math.sin(v * 1.1 * f + Math.sin(u * 0.7 + s2) * 1.9) + Math.sin((u + v) * 0.6 + s3); let ridge = 1 - Math.min(1, Math.abs(a) * 1.5); ridge = ridge * ridge * ridge; const amb = 0.18 + 0.55 * lit; const L = Math.min(1, ridge * amb * 1.6 + lit * 0.32); const deep = 0.5 + 0.5 * Math.sin(u * 0.2 + v * 0.15 + s1); const i = (y * cw + x) * 4; const br = [P.water[0] * (1 - deep * 0.3), P.water[1] * (1 - deep * 0.25), P.water[2] * (1 - deep * 0.2)]; id.data[i] = br[0] + (P.light[0] - br[0]) * L; id.data[i + 1] = br[1] + (P.light[1] - br[1]) * L; id.data[i + 2] = br[2] + (P.light[2] - br[2]) * L; id.data[i + 3] = 255; } } g.putImageData(id, 0, 0); cache[key] = c; return c; } // jittered ("cut") path primitives function cutRRect(ctx, x, y, w, h, rad, seed, j) { const r = rng(seed); j = j == null ? 1.6 : j; const pts = []; const corner = (cx, cy, a0) => { for (let i = 0; i <= 6; i++) { const a = a0 + i / 6 * Math.PI / 2; pts.push([cx + Math.cos(a) * rad, cy + Math.sin(a) * rad]); } }; const edge = (x0, y0, x1, y1) => { const n = Math.max(2, Math.floor(Math.hypot(x1 - x0, y1 - y0) / 40)); for (let i = 1; i < n; i++) pts.push([x0 + (x1 - x0) * i / n, y0 + (y1 - y0) * i / n]); }; corner(x + w - rad, y + rad, -Math.PI / 2); edge(x + w, y + rad, x + w, y + h - rad); corner(x + w - rad, y + h - rad, 0); edge(x + w - rad, y + h, x + rad, y + h); corner(x + rad, y + h - rad, Math.PI / 2); edge(x, y + h - rad, x, y + rad); corner(x + rad, y + rad, Math.PI); edge(x + rad, y, x + w - rad, y); ctx.beginPath(); pts.forEach((p, i) => { const q = [p[0] + (r() - 0.5) * j * 2, p[1] + (r() - 0.5) * j * 2]; i ? ctx.lineTo(q[0], q[1]) : ctx.moveTo(q[0], q[1]); }); ctx.closePath(); } function cutEllipse(ctx, cx, cy, rx, ry, rot, seed, j) { const r = rng(seed); j = j == null ? 1.4 : j; const n = 44; ctx.beginPath(); for (let i = 0; i < n; i++) { const a = i / n * Math.PI * 2; const jj = (r() - 0.5) * j * 2; const ex = Math.cos(a) * (rx + jj), ey = Math.sin(a) * (ry + jj); const x = cx + ex * Math.cos(rot) - ey * Math.sin(rot), y = cy + ex * Math.sin(rot) + ey * Math.cos(rot); i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); } ctx.closePath(); } function paper(ctx, fill, sh) { ctx.save(); if (sh !== false) { ctx.shadowColor = 'rgba(0,0,0,0.38)'; ctx.shadowBlur = sh || 7; ctx.shadowOffsetX = 3; ctx.shadowOffsetY = 5; } ctx.fillStyle = fill; ctx.fill(); ctx.restore(); ctx.save(); ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 1; ctx.stroke(); ctx.restore(); } function poly(ctx, pts) { ctx.beginPath(); pts.forEach((p, i) => i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])); ctx.closePath(); } // ---------- body parts ---------- // Hand in local frame: +x from wrist toward fingertips; handed = +1/-1 puts thumb on -y or +y side. function hand(ctx, wx, wy, ang, s, who, pose, handed, opt) { opt = opt || {}; const c = P[who]; ctx.save(); ctx.translate(wx, wy); ctx.rotate(ang); ctx.scale(1, handed); const L = { flat: [0.44, 0.50, 0.47, 0.36], curl: [0.17, 0.19, 0.18, 0.15], point: [0.50, 0.17, 0.16, 0.14], open: [0.46, 0.52, 0.49, 0.38] }[pose]; const spread = { flat: [-0.10, -0.03, 0.04, 0.12], curl: [0.0, 0.0, 0.0, 0.05], point: [-0.06, 0.05, 0.06, 0.1], open: [-0.24, -0.08, 0.08, 0.26] }[pose]; const ys = [-0.22, -0.075, 0.075, 0.21]; const fw = 0.125 * s; const fingers = ys.map((y, i) => { const bx = 0.5 * s, by = y * s * (pose === 'curl' ? 0.95 : 1); return [bx, by, bx + Math.cos(spread[i]) * L[i] * s, by + Math.sin(spread[i]) * L[i] * s]; }); const thumbA = pose === 'curl' ? -0.35 : pose === 'open' ? -1.05 : -0.75; const thumbL = pose === 'curl' ? 0.26 : 0.4; const th = [0.13 * s, -0.24 * s, 0.13 * s + Math.cos(thumbA) * thumbL * s, -0.24 * s + Math.sin(thumbA) * thumbL * s]; const palm = [[-0.02 * s, -0.19 * s], [0.18 * s, -0.27 * s], [0.52 * s, -0.27 * s], [0.56 * s, 0], [0.52 * s, 0.26 * s], [0.2 * s, 0.25 * s], [-0.02 * s, 0.18 * s]]; // shadow + outline pass ctx.save(); if (opt.shadow !== false) { ctx.shadowColor = 'rgba(0,0,0,' + (opt.shadowA || 0.35) + ')'; ctx.shadowBlur = opt.shadowBlur || 6; ctx.shadowOffsetX = opt.sx || 3; ctx.shadowOffsetY = (opt.sy || 5) * handed; } ctx.strokeStyle = c.skinDark; ctx.fillStyle = c.skinDark; ctx.lineCap = 'round'; ctx.lineWidth = fw + 4; fingers.forEach(f => { ctx.beginPath(); ctx.moveTo(f[0], f[1]); ctx.lineTo(f[2], f[3]); ctx.stroke(); }); ctx.lineWidth = fw * 1.15 + 4; ctx.beginPath(); ctx.moveTo(th[0], th[1]); ctx.lineTo(th[2], th[3]); ctx.stroke(); poly(ctx, palm); ctx.lineWidth = 4; ctx.lineJoin = 'round'; ctx.stroke(); ctx.fill(); ctx.restore(); // fill pass ctx.strokeStyle = c.skin; ctx.fillStyle = c.skin; ctx.lineCap = 'round'; ctx.lineWidth = fw; fingers.forEach(f => { ctx.beginPath(); ctx.moveTo(f[0], f[1]); ctx.lineTo(f[2], f[3]); ctx.stroke(); }); ctx.lineWidth = fw * 1.15; ctx.beginPath(); ctx.moveTo(th[0], th[1]); ctx.lineTo(th[2], th[3]); ctx.stroke(); poly(ctx, palm); ctx.fill(); // knuckle / curl creases ctx.strokeStyle = 'rgba(0,0,0,0.22)'; ctx.lineWidth = 1.4; if (pose === 'curl') { ctx.beginPath(); ctx.moveTo(0.58 * s, -0.25 * s); ctx.quadraticCurveTo(0.66 * s, 0, 0.58 * s, 0.24 * s); ctx.stroke(); } fingers.forEach(f => { const mx = f[0] + (f[2] - f[0]) * 0.55, my = f[1] + (f[3] - f[1]) * 0.55; ctx.beginPath(); ctx.moveTo(mx, my - fw * 0.3); ctx.lineTo(mx, my + fw * 0.3); ctx.stroke(); }); // nails as tiny light marks ctx.fillStyle = 'rgba(255,240,230,0.35)'; fingers.forEach(f => { if (Math.hypot(f[2] - f[0], f[3] - f[1]) > 0.3 * s) { ctx.beginPath(); ctx.arc(f[2] - Math.cos(0) * fw * 0.15, f[3], fw * 0.22, 0, 7); ctx.fill(); } }); ctx.restore(); } // Arm from shoulder S to wrist Wr, sleeve covering the upper part. function arm(ctx, S, Wr, who, w0, w1, sleeveFrac, shadow) { const c = P[who]; const dx = Wr[0] - S[0], dy = Wr[1] - S[1]; const len = Math.hypot(dx, dy); const nx = -dy / len, ny = dx / len; const at = (t, w) => [S[0] + dx * t + nx * w, S[1] + dy * t + ny * w]; const wAt = t => w0 + (w1 - w0) * t; const sf = sleeveFrac == null ? 0.58 : sleeveFrac; // skin forearm poly(ctx, [at(sf - 0.05, wAt(sf) / 2), at(1, w1 / 2), at(1.02, 0), at(1, -w1 / 2), at(sf - 0.05, -wAt(sf) / 2)]); ctx.save(); if (shadow !== false) { ctx.shadowColor = 'rgba(0,0,0,0.35)'; ctx.shadowBlur = 6; ctx.shadowOffsetX = 3; ctx.shadowOffsetY = 5; } ctx.fillStyle = c.skin; ctx.fill(); ctx.restore(); ctx.strokeStyle = c.skinDark; ctx.lineWidth = 2; ctx.stroke(); // sleeve poly(ctx, [at(-0.02, w0 / 2), at(sf, wAt(sf) / 2 + 3), at(sf + 0.015, 0), at(sf, -wAt(sf) / 2 - 3), at(-0.02, -w0 / 2)]); ctx.save(); if (shadow !== false) { ctx.shadowColor = 'rgba(0,0,0,0.35)'; ctx.shadowBlur = 7; ctx.shadowOffsetX = 3; ctx.shadowOffsetY = 5; } ctx.fillStyle = c.sleeve; ctx.fill(); ctx.restore(); ctx.strokeStyle = c.sleeveDark; ctx.lineWidth = 2; ctx.stroke(); // fold lines ctx.strokeStyle = 'rgba(0,0,0,0.18)'; ctx.lineWidth = 1.5; [0.25, 0.42].forEach(t => { const a = at(t, wAt(t) * 0.35), b = at(t + 0.04, -wAt(t) * 0.2); ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke(); }); return Math.atan2(dy, dx); } // overhead person: head + shoulders, facing direction 'face' (radians, toward basin) function topPerson(ctx, x, y, face, who, tilt, seed) { const c = P[who]; ctx.save(); ctx.translate(x, y); ctx.rotate(face - Math.PI / 2); // shoulders (back toward camera edge) cutEllipse(ctx, 0, -18, 128, 50, 0, seed, 2); paper(ctx, c.sleeve, 10); ctx.strokeStyle = c.sleeveDark; ctx.lineWidth = 2; ctx.stroke(); // collar cutEllipse(ctx, 0, 2, 38, 20, 0, seed + 1, 1); ctx.fillStyle = c.skinDark; ctx.fill(); // head (seen from above: hair dominates), tilted ctx.rotate(tilt || 0); cutEllipse(ctx, 0, 26, 40, 46, 0, seed + 2, 1.2); paper(ctx, c.hair, 8); // ears ctx.fillStyle = c.skin; cutEllipse(ctx, -40, 30, 7, 11, 0, seed + 3, 0.6); ctx.fill(); cutEllipse(ctx, 40, 30, 7, 11, 0, seed + 4, 0.6); ctx.fill(); // brow/nose tip visible at the forward edge (looking down at the water) cutEllipse(ctx, 0, 66, 24, 10, 0, seed + 5, 0.6); ctx.fillStyle = c.skin; ctx.fill(); // part line in hair ctx.strokeStyle = 'rgba(255,255,255,0.10)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(-6, -12); ctx.quadraticCurveTo(-2, 20, 4, 54); ctx.stroke(); ctx.restore(); } function ripples(ctx, x, y, n, r0, alpha, sq) { ctx.save(); ctx.strokeStyle = 'rgba(226,240,255,' + alpha + ')'; for (let i = 0; i < n; i++) { ctx.lineWidth = 1.6 - i * 0.3; ctx.beginPath(); ctx.ellipse(x, y, r0 + i * 16, (r0 + i * 16) * (sq || 0.8), 0, 0, 7); ctx.globalAlpha = 1 - i / (n + 1); ctx.stroke(); } ctx.restore(); } function finish(ctx, W, H) { ctx.drawImage(grain(W, H), 0, 0, 1280, 720); const g = ctx.createRadialGradient(640, 360, 260, 640, 360, 820); g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(0,0,0,0.55)'); ctx.fillStyle = g; ctx.fillRect(0, 0, 1280, 720); } // ---------- OVERHEAD SET (K1, K2, K5) ---------- const BASIN = { x: 210, y: 150, w: 860, h: 420, rim: 38 }; function overhead(ctx, W, H, o) { ctx.save(); ctx.scale(W / 1280, H / 720); if (o.zoom) { ctx.translate(640, 360); ctx.scale(o.zoom, o.zoom); ctx.translate(-o.cx, -o.cy); } const B = BASIN; // floor tiles ctx.fillStyle = P.floorGap; ctx.fillRect(-400, -400, 2100, 1500); const r = rng(11); for (let ty = -360; ty < 1100; ty += 96) for (let tx = -380; tx < 1700; tx += 96) { cutRRect(ctx, tx + 3, ty + 3, 90, 90, 4, (tx * 7 + ty * 13) & 0xffff, 1); const v = 40 + Math.floor(r() * 10); ctx.fillStyle = 'rgb(' + v + ',' + (v + 7) + ',' + (v + 11) + ')'; ctx.fill(); } // basin rim (stone, cut paper) cutRRect(ctx, B.x - B.rim, B.y - B.rim, B.w + B.rim * 2, B.h + B.rim * 2, 46, 21, 2); paper(ctx, P.rim, 14); cutRRect(ctx, B.x - B.rim + 8, B.y - B.rim + 8, B.w + B.rim * 2 - 16, B.h + B.rim * 2 - 16, 40, 22, 1.5); ctx.strokeStyle = P.rimLight; ctx.lineWidth = 2; ctx.stroke(); // people bodies (o.people || []).forEach((p, i) => topPerson(ctx, p.x, p.y, p.face, p.who, p.tilt, 300 + i * 10)); // water const inner = () => cutRRect(ctx, B.x, B.y, B.w, B.h, 22, 23, 1.2); const spotsN = (o.spots || []).map(s => ({ x: (s.x - B.x) / B.w, y: (s.y - B.y) / B.h, r: s.r / B.w, k: s.k, sq: B.w / B.h * (B.h / B.w) })); const cimg = caustic(o.seed || 5, spotsN, 300, 147); ctx.save(); inner(); ctx.clip(); ctx.imageSmoothingEnabled = true; ctx.drawImage(cimg, B.x, B.y, B.w, B.h); // inner wall shadow (rim casts onto water) const gs = ctx.createLinearGradient(0, B.y, 0, B.y + 50); gs.addColorStop(0, 'rgba(0,0,0,0.45)'); gs.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = gs; ctx.fillRect(B.x, B.y, B.w, 50); const gl = ctx.createLinearGradient(B.x, 0, B.x + 40, 0); gl.addColorStop(0, 'rgba(0,0,0,0.35)'); gl.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = gl; ctx.fillRect(B.x, B.y, 40, B.h); // submerged limbs (o.limbs || []).filter(l => l.state === 'flat').forEach(l => { const a = arm(ctx, l.S, l.W, l.who, 64, 40, 0.5, false); hand(ctx, l.W[0], l.W[1], a + (l.twist || 0), l.s || 92, l.who, l.pose || 'flat', l.handed, { shadowA: 0.25, sx: 2, sy: 3 }); }); // water veil over submerged skin ctx.fillStyle = 'rgba(' + P.waterDeep.join(',') + ',0.40)'; ctx.fillRect(B.x, B.y, B.w, B.h); ctx.globalCompositeOperation = 'screen'; ctx.globalAlpha = 0.55; ctx.drawImage(cimg, B.x, B.y, B.w, B.h); ctx.globalAlpha = 0.5; (o.limbs || []).filter(l => l.state === 'flat').forEach(l => { const a = arm(ctx, l.S, l.W, l.who, 64, 40, 0.5, false); hand(ctx, l.W[0], l.W[1], a + (l.twist || 0), l.s || 92, l.who, l.pose || 'flat', l.handed, { shadow: false }); }); ctx.globalAlpha = 1; // beam pools: pale, soft-edged, on the water (o.spots || []).forEach(s => { const g = ctx.createRadialGradient(s.x, s.y, 0, s.x, s.y, s.r * 1.3); g.addColorStop(0, 'rgba(226,240,255,' + (0.30 * s.k) + ')'); g.addColorStop(0.6, 'rgba(226,240,255,' + (0.14 * s.k) + ')'); g.addColorStop(1, 'rgba(226,240,255,0)'); ctx.fillStyle = g; ctx.beginPath(); ctx.ellipse(s.x, s.y, s.r * 1.3, s.r * 1.15, 0, 0, 7); ctx.fill(); }); ctx.globalCompositeOperation = 'source-over'; // ripples where arms enter / near resting hands (o.limbs || []).filter(l => l.state === 'flat').forEach(l => ripples(ctx, l.entry[0], l.entry[1], 3, 26, 0.32, 0.6)); (o.rings || []).forEach(q => ripples(ctx, q[0], q[1], q[2], q[3], q[4] || 0.3, 0.8)); ctx.restore(); // arm portions outside the water, re-drawn over rim (clip to outside of inner) ctx.save(); ctx.beginPath(); ctx.rect(-400, -400, 2100, 1500); cutRRect(ctx, B.x, B.y, B.w, B.h, 22, 23, 1.2); ctx.clip('evenodd'); (o.limbs || []).filter(l => l.state === 'flat').forEach(l => arm(ctx, l.S, l.W, l.who, 64, 40, 0.5)); ctx.restore(); // lifted / gesturing limbs (above water: larger, with cast shadow on the water) (o.limbs || []).filter(l => l.state !== 'flat').forEach(l => { // cast shadow on water ctx.save(); inner(); ctx.clip(); ctx.fillStyle = 'rgba(8,20,26,0.40)'; ctx.filter = 'blur(10px)'; ctx.beginPath(); ctx.ellipse(l.W[0] + 34, l.W[1] + 40, (l.s || 92) * 0.6, (l.s || 92) * 0.38, Math.atan2(l.W[1] - l.S[1], l.W[0] - l.S[0]), 0, 7); ctx.fill(); ctx.filter = 'none'; ctx.restore(); const a = arm(ctx, l.S, l.W, l.who, 70, 46, 0.5); hand(ctx, l.W[0], l.W[1], a + (l.twist || 0), (l.s || 92) * 1.08, l.who, l.pose || 'curl', l.handed, { shadowA: 0.45, sx: 10, sy: 14, shadowBlur: 14 }); // drips if (l.drips) { const rr = rng(l.drips); ctx.fillStyle = 'rgba(226,240,255,0.75)'; for (let i = 0; i < 6; i++) { const t = rr(); ctx.beginPath(); ctx.ellipse(l.W[0] + (rr() - 0.5) * 60 + Math.cos(a) * 40, l.W[1] + (rr() - 0.5) * 50 + Math.sin(a) * 40, 2.2 + t * 2, 3 + t * 3, a, 0, 7); ctx.fill(); } ctx.save(); inner(); ctx.clip(); ripples(ctx, l.W[0] + 30, l.W[1] + 44, 3, 12, 0.4, 0.75); ctx.restore(); } }); // props if (o.cup) { const [cx, cy] = o.cup; cutEllipse(ctx, cx, cy, 30, 30, 0, 61, 1); paper(ctx, P.wood, 8); cutEllipse(ctx, cx, cy, 22, 22, 0, 62, 0.8); ctx.fillStyle = P.woodDark; ctx.fill(); cutEllipse(ctx, cx - 3, cy - 3, 14, 13, 0, 63, 0.6); ctx.fillStyle = 'rgb(' + P.water.join(',') + ')'; ctx.fill(); } if (o.towel) { const [tx, ty] = o.towel; cutRRect(ctx, tx, ty, 120, 70, 8, 64, 2); paper(ctx, '#e6e0d3', 8); ctx.strokeStyle = 'rgba(0,0,0,0.12)'; ctx.lineWidth = 1.5; for (let i = 1; i < 4; i++) { ctx.beginPath(); ctx.moveTo(tx + 6, ty + i * 17); ctx.lineTo(tx + 114, ty + i * 17); ctx.stroke(); } } // fixture shadow (the lamp bar overhead, out of frame, casts a soft band) — keeps the mechanism present if (o.fixture) { ctx.save(); ctx.filter = 'blur(14px)'; ctx.fillStyle = 'rgba(0,0,0,0.28)'; ctx.fillRect(o.fixture[0], -60, 26, o.fixture[1] + 60); ctx.beginPath(); ctx.ellipse(o.fixture[0] - 90, o.fixture[1], 70, 28, 0, 0, 7); ctx.fill(); ctx.beginPath(); ctx.ellipse(o.fixture[0] + 110, o.fixture[1], 70, 28, 0, 0, 7); ctx.fill(); ctx.fillRect(o.fixture[0] - 120, o.fixture[1] - 10, 260, 18); ctx.restore(); } ctx.restore(); ctx.save(); ctx.scale(W / 1280, H / 720); finish(ctx, W, H); ctx.restore(); } // ---------- THREE-QUARTER SET (K3, K4) ---------- function kneel(ctx, x, baseY, facing, who, look, armTo, seed) { const c = P[who]; ctx.save(); ctx.translate(x, baseY); ctx.scale(facing, 1); // folded legs cutRRect(ctx, -70, -70, 150, 70, 30, seed, 1.5); paper(ctx, c.trousers, 8); cutEllipse(ctx, -78, -12, 22, 14, 0, seed + 1, 1); ctx.fillStyle = c.skinDark; ctx.fill(); // heel // torso leaning toward basin poly(ctx, [[-50, -60], [-30, -230], [40, -238], [62, -64]]); paper(ctx, c.sleeve, 10); ctx.strokeStyle = c.sleeveDark; ctx.lineWidth = 2; ctx.stroke(); // neck + head, tilted up by 'look' ctx.save(); ctx.translate(14, -240); ctx.rotate(-look); cutRRect(ctx, -12, -36, 26, 40, 8, seed + 2, 0.8); ctx.fillStyle = c.skin; ctx.fill(); cutEllipse(ctx, 6, -74, 40, 46, 0, seed + 3, 1.2); paper(ctx, c.skin, 8); // hair cap on the back of the head ctx.save(); cutEllipse(ctx, 6, -74, 41, 47, 0, seed + 3, 1.2); ctx.clip(); ctx.fillStyle = c.hair; ctx.beginPath(); ctx.ellipse(-16, -92, 44, 42, -0.4, 0, 7); ctx.fill(); ctx.restore(); // face features in profile: brow, nose, eye ctx.fillStyle = c.skin; poly(ctx, [[44, -82], [56, -66], [44, -62]]); ctx.fill(); ctx.fillStyle = 'rgba(20,14,10,0.85)'; ctx.beginPath(); ctx.ellipse(30, -80, 3.2, 4, 0, 0, 7); ctx.fill(); ctx.strokeStyle = 'rgba(0,0,0,0.35)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(22, -88); ctx.lineTo(38, -90); ctx.stroke(); ctx.restore(); ctx.restore(); // arm in world coords if (armTo) { const S = [x + 20 * facing, baseY - 205]; const a = arm(ctx, S, armTo.W, who, 46, 30, 0.55); hand(ctx, armTo.W[0], armTo.W[1], a + (armTo.twist || 0), armTo.s || 64, who, armTo.pose || 'flat', armTo.handed || 1, { sx: 3, sy: 4 }); } } function lampHead(ctx, x, y, aim, on, seed, scale) { scale = scale || 1; ctx.save(); ctx.translate(x, y); ctx.rotate(aim); ctx.scale(scale, scale); // yoke ctx.strokeStyle = P.metal; ctx.lineWidth = 7; ctx.beginPath(); ctx.moveTo(-34, -6); ctx.lineTo(-34, -40); ctx.lineTo(34, -40); ctx.lineTo(34, -6); ctx.stroke(); // shade: truncated cone pointing +y poly(ctx, [[-22, -24], [22, -24], [42, 40], [-42, 40]]); paper(ctx, P.metal, 9); ctx.fillStyle = P.metalLight; poly(ctx, [[-22, -24], [-8, -24], [-24, 40], [-42, 40]]); ctx.fill(); // lens cutEllipse(ctx, 0, 40, 42, 9, 0, seed, 0.6); ctx.fillStyle = on ? 'rgb(236,245,255)' : '#25292c'; ctx.fill(); if (on) { ctx.save(); ctx.globalCompositeOperation = 'screen'; const g = ctx.createRadialGradient(0, 44, 0, 0, 44, 70); g.addColorStop(0, 'rgba(226,240,255,0.7)'); g.addColorStop(1, 'rgba(226,240,255,0)'); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(0, 44, 70, 0, 7); ctx.fill(); ctx.restore(); } ctx.restore(); } function beam(ctx, x0, y0, w0, x1, y1, rx, ry, alpha) { ctx.save(); ctx.globalCompositeOperation = 'screen'; const g = ctx.createLinearGradient(x0, y0, x1, y1); g.addColorStop(0, 'rgba(226,240,255,' + alpha + ')'); g.addColorStop(1, 'rgba(226,240,255,' + alpha * 0.35 + ')'); ctx.fillStyle = g; ctx.filter = 'blur(3px)'; poly(ctx, [[x0 - w0, y0], [x0 + w0, y0], [x1 + rx, y1], [x1 - rx, y1]]); ctx.fill(); ctx.filter = 'none'; ctx.restore(); } function threeQuarter(ctx, W, H, o) { ctx.save(); ctx.scale(W / 1280, H / 720); if (o.zoom) { ctx.translate(640, 360); ctx.scale(o.zoom, o.zoom); ctx.translate(-o.cx, -o.cy); } // wall ctx.fillStyle = P.wall; ctx.fillRect(-300, -300, 1900, 660); ctx.strokeStyle = 'rgba(0,0,0,0.07)'; ctx.lineWidth = 3; for (let y = 40; y < 360; y += 64) { ctx.beginPath(); ctx.moveTo(-300, y); ctx.lineTo(1600, y); ctx.stroke(); } const rr = rng(31); for (let y = 40; y < 360; y += 64) for (let x = -300 + (y % 128 ? 50 : 0); x < 1600; x += 100) { ctx.beginPath(); ctx.moveTo(x + rr() * 4, y); ctx.lineTo(x, y + 64); ctx.stroke(); } // skirting + floor ctx.fillStyle = P.wallDark; ctx.fillRect(-300, 340, 1900, 22); ctx.fillStyle = P.floor; ctx.fillRect(-300, 362, 1900, 800); ctx.strokeStyle = P.floorGap; ctx.lineWidth = 3; for (let i = -14; i < 22; i++) { ctx.beginPath(); ctx.moveTo(640 + i * 70, 362); ctx.lineTo(640 + i * 210, 1000); ctx.stroke(); } [410, 480, 580, 720, 900].forEach(y => { ctx.beginPath(); ctx.moveTo(-300, y); ctx.lineTo(1600, y); ctx.stroke(); }); const Bc = o.basin; // {cx,cy,rx,ry,face} // basin front face + rim ctx.save(); poly(ctx, [[Bc.cx - Bc.rx, Bc.cy], [Bc.cx + Bc.rx, Bc.cy], [Bc.cx + Bc.rx, Bc.cy + Bc.face], [Bc.cx - Bc.rx, Bc.cy + Bc.face]]); ctx.fillStyle = P.rimDark; ctx.fill(); ctx.restore(); cutEllipse(ctx, Bc.cx, Bc.cy + Bc.face, Bc.rx, Bc.ry, 0, 41, 1.5); paper(ctx, P.rimDark, 16); ctx.fillStyle = P.rimDark; ctx.fillRect(Bc.cx - Bc.rx, Bc.cy, Bc.rx * 2, Bc.face); cutEllipse(ctx, Bc.cx, Bc.cy, Bc.rx, Bc.ry, 0, 42, 1.5); paper(ctx, P.rim, 6); // figures behind the basin's back rim are drawn first if flagged (o.figures || []).filter(f => f.behind).forEach(f => kneel(ctx, f.x, f.y, f.facing, f.who, f.look, f.arm, f.seed)); // water const wrx = Bc.rx - 34, wry = Bc.ry - 16; const spotsN = (o.spots || []).map(s => ({ x: (s.x - (Bc.cx - wrx)) / (wrx * 2), y: (s.y - (Bc.cy - wry)) / (wry * 2), r: s.rx / (wrx * 2), k: s.k, sq: (wry * 2) / (wrx * 2) * ((wrx * 2) / (wry * 2)) * (s.rx / s.ry) * (wry / wrx) })); const cimg = caustic(o.seed || 9, spotsN, 300, 90); ctx.save(); cutEllipse(ctx, Bc.cx, Bc.cy + 4, wrx, wry, 0, 43, 1); ctx.clip(); ctx.drawImage(cimg, Bc.cx - wrx, Bc.cy + 4 - wry, wrx * 2, wry * 2); const gs = ctx.createLinearGradient(0, Bc.cy - wry, 0, Bc.cy - wry + 40); gs.addColorStop(0, 'rgba(0,0,0,0.45)'); gs.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = gs; ctx.fillRect(Bc.cx - wrx, Bc.cy - wry, wrx * 2, 40); (o.submerged || []).forEach(l => { const a = arm(ctx, l.S, l.W, l.who, 44, 30, 0.5, false); hand(ctx, l.W[0], l.W[1], a + (l.twist || 0), l.s || 62, l.who, l.pose || 'flat', l.handed || 1, { shadowA: 0.2, sx: 1, sy: 2 }); }); ctx.fillStyle = 'rgba(' + P.waterDeep.join(',') + ',0.40)'; ctx.fillRect(Bc.cx - wrx, Bc.cy - wry, wrx * 2, wry * 2 + 10); ctx.globalCompositeOperation = 'screen'; ctx.globalAlpha = 0.5; ctx.drawImage(cimg, Bc.cx - wrx, Bc.cy + 4 - wry, wrx * 2, wry * 2); ctx.globalCompositeOperation = 'source-over'; (o.submerged || []).forEach(l => { const a = arm(ctx, l.S, l.W, l.who, 44, 30, 0.5, false); hand(ctx, l.W[0], l.W[1], a + (l.twist || 0), l.s || 62, l.who, l.pose || 'flat', l.handed || 1, { shadow: false }); }); ctx.globalCompositeOperation = 'screen'; ctx.globalAlpha = 1; (o.spots || []).forEach(s => { const g = ctx.createRadialGradient(s.x, s.y, 0, s.x, s.y, s.rx); g.addColorStop(0, 'rgba(226,240,255,' + 0.45 * s.k + ')'); g.addColorStop(1, 'rgba(226,240,255,0)'); ctx.fillStyle = g; ctx.beginPath(); ctx.ellipse(s.x, s.y, s.rx, s.ry, 0, 0, 7); ctx.fill(); }); ctx.globalCompositeOperation = 'source-over'; (o.submerged || []).forEach(l => ripples(ctx, l.entry[0], l.entry[1], 3, 18, 0.35, 0.3)); ctx.restore(); // re-draw the out-of-water part of submerged arms over the rim ctx.save(); ctx.beginPath(); ctx.rect(-300, -300, 1900, 1400); cutEllipse(ctx, Bc.cx, Bc.cy + 4, wrx, wry, 0, 43, 1); ctx.clip('evenodd'); (o.submerged || []).forEach(l => arm(ctx, l.S, l.W, l.who, 44, 30, 0.5)); ctx.restore(); // fixture if (o.fixture) { const F = o.fixture; ctx.strokeStyle = P.metal; ctx.lineWidth = 10; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(F.mx, -40); ctx.lineTo(F.mx, F.by); ctx.stroke(); ctx.lineWidth = 12; ctx.beginPath(); ctx.moveTo(F.bx0, F.by); ctx.lineTo(F.bx1, F.by); ctx.stroke(); ctx.lineWidth = 2; ctx.strokeStyle = P.metalLight; ctx.beginPath(); ctx.moveTo(F.bx0, F.by - 4); ctx.lineTo(F.bx1, F.by - 4); ctx.stroke(); // cable ctx.strokeStyle = '#1e2124'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(F.mx + 10, -40); ctx.bezierCurveTo(F.mx + 40, 60, F.mx + 70, F.by - 30, F.bx1 - 10, F.by); ctx.stroke(); F.heads.forEach((h, i) => { if (h.on) beam(ctx, h.x + Math.sin(-h.aim) * 44 * (h.scale || 1), h.y + Math.cos(h.aim) * 44 * (h.scale || 1), 30 * (h.scale || 1), h.tx, h.ty, h.trx, h.trx * 0.3, h.alpha || 0.30); }); F.heads.forEach((h, i) => lampHead(ctx, h.x, h.y, h.aim, h.on, 50 + i, h.scale)); } // figures in front of the fixture (o.figures || []).filter(f => !f.behind).forEach(f => kneel(ctx, f.x, f.y, f.facing, f.who, f.look, f.arm, f.seed)); // reaching hands (K4) on top (o.reach || []).forEach(l => { const a = arm(ctx, l.S, l.W, l.who, l.w0 || 80, l.w1 || 54, 0.55); hand(ctx, l.W[0], l.W[1], a + (l.twist || 0), l.s || 110, l.who, l.pose, l.handed, { sx: 8, sy: 10, shadowBlur: 12, shadowA: 0.4 }); }); ctx.restore(); ctx.save(); ctx.scale(W / 1280, H / 720); finish(ctx, W, H); ctx.restore(); } // ---------- shared cast placements ---------- // Overhead: seat S (bottom, near the lamp's spot) and seat N (top). const SEAT_S = { x: 500, y: 676, face: -Math.PI / 2 }; const SEAT_N = { x: 830, y: 46, face: Math.PI / 2 }; const SPOT1 = { x: 560, y: 405, r: 92, k: 1.0 }; // ---------- references (all opened during research) ---------- const REF = { hydro: { kind: 'research paper', title: 'Hydroptical Thermal Feedback: Spatial Thermal Feedback Using Visible Lights and Water (UIST ’24), project page', creator: 'Sosuke Ichihashi, Masahiko Inami, Hsin-Ni Ho, Noura Howell', date: '2024', url: 'https://sites.gatech.edu/futurefeelings/2024/11/14/hydroptical-thermal-feedback-uist-24', rights: 'unknown — not granted', verified: true }, hydroVid: { kind: 'archive footage', title: 'Hydroptical Thermal Feedback — UIST ’24 full video (3:51)', creator: 'Ichihashi, Inami, Ho, Howell', date: '2024-09-25', url: 'https://www.youtube.com/watch?v=VG1r-MGIW7Q', rights: 'unknown — not granted', verified: true }, locke: { kind: 'source text', title: 'An Essay Concerning Humane Understanding, Vol. 1 (Books I–II); Book II ch. VIII: “the same water, at the same time, may produce the idea of cold by one hand and of heat by the other”', creator: 'John Locke', date: '1690 (2nd ed. text)', url: 'https://www.gutenberg.org/ebooks/10615', rights: 'public domain', verified: true }, bowls: { kind: 'source text', title: 'When Hot Is Not (three-bowl temperature-adaptation activity)', creator: 'The Franklin Institute', date: 'n.d.', url: 'https://fi.edu/node/3214', rights: 'unknown — not granted', verified: true }, japan: { kind: 'archive footage', title: 'Children of Japan (ERPI Classroom Films) — includes household bath heated by fire and a restful soak', creator: 'ERPI Classroom Films / Prelinger Archives', date: '1941', url: 'https://archive.org/details/Children1941', rights: 'public domain', verified: true }, clean: { kind: 'archive footage', title: 'Health: Your Cleanliness — hand washing and bathing', creator: 'Centron Productions / Prelinger Archives', date: '1953', url: 'https://archive.org/details/health_your_cleanliness', rights: 'public domain', verified: true }, springs: { kind: 'archive footage', title: 'Home Movie: 97276: Hot Springs 1943 (Hot Springs, Arkansas, colour)', creator: 'Prelinger Archives (home movie, maker unrecorded)', date: '1943', url: 'https://archive.org/details/97276_hm_hot_springs_1943', rights: 'unknown — not granted', verified: true }, hands: { kind: 'archive footage', title: 'Master Hands (shot 10) — hands working metal fittings', creator: 'Library of Congress National Film Registry set, via movingimagearchive.com', date: 'unknown', url: 'https://www.movingimagearchive.com/sources/master-hands?clip=e8e1c40a-9716-5709-94b3-2e16c2fcc616', rights: 'public domain', verified: true }, toyokuni: { kind: 'still image', title: 'Women and an Infant Boy in a Public Bath House (triptych)', creator: 'Utagawa Toyokuni I', date: 'ca. 1799', url: 'https://www.metmuseum.org/art/collection/search/56995', rights: 'public domain', verified: true }, kiyonaga: { kind: 'still image', title: 'Onna yu (Bathhouse women), colour woodcut diptych', creator: 'Torii Kiyonaga', date: '1780s', url: 'https://www.loc.gov/item/2008660821/', rights: 'public domain', verified: true }, roman: { kind: 'still image', title: '[Roman Baths and Abbey, Circular Bath, Bath, England] (photochrom)', creator: 'Detroit Publishing Co. / Photoglob', date: 'c. 1890–1900', url: 'https://www.loc.gov/item/2002696371/', rights: 'unknown — not granted', verified: false } }; const ref = (k, use, extra) => Object.assign({}, REF[k], { use: use }, extra || {}); window.AFTERLIGHT_FILMS = window.AFTERLIGHT_FILMS || []; window.AFTERLIGHT_FILMS.push({ id: 'F4', n: 4, title: 'The Same Water', role: 'Thermal media and situated experience', understand: 'A shared environment need not produce the same bodily experience; an interface can change that relationship.', medium: 'Cut-paper figures and a stone basin over a drawn water-caustic plate, mostly overhead with two three-quarter shots. Seen from above, the two people read as equals across the water. The caustics show where light lands without coding anyone\'s sensation in colour.', archivePlan: 'The opening and closing overheads are INVENTED cut paper. They are intercut with PHOTOGRAPHED public-domain bath footage (Children of Japan, 1941; Health: Your Cleanliness, 1953), and a Toyokuni bathhouse print is used as a still plate, so the basin belongs to a long history of shared bathing. Every scene with the two people is labelled "COMPOSED SCENE · archive fragments re-performed · not a recorded event". The lamp reveal is drawn, not filmed. Its pale, short-wavelength-leaning light follows the paper\'s finding that shorter wavelengths felt warmer, and a paper-slip caption cites Ichihashi et al., UIST 2024. No layer colours anyone\'s experience red or blue. Hands and gestures carry the sensation, and the light pool on the water is the only visible cause.', basis: [ { title: 'Ichihashi, Inami, Ho & Howell, "Hydroptical Thermal Feedback: Spatial Thermal Feedback Using Visible Lights and Water", UIST ’24 (37th ACM Symposium on User Interface Software and Technology), Article 104, pp. 1–19, Oct 2024. DOI 10.1145/3654777.3676453', url: 'https://doi.org/10.1145/3654777.3676453', note: 'Confirmed through the Georgia Tech LMC publication record and the Future Feelings Lab project page. The ACM DOI page itself refused automated fetch. Sosuke Ichihashi is first author, and the lab is Noura Howell\'s at Georgia Tech. Claims: visible light cast on skin under water is perceived as thermal sensation. Shorter wavelengths (cooler colours) gave stronger warmth. Brighter light felt warmer, with a reaction time of about 1.5 s. Switching lights in sequence produced apparent motion of warmth and coolness. Lighting the whole immersed skin raised perceived water temperature by about 13 °C in 25 °C water, an illusion that the water itself is warmer. Different people in one body of water can be given different perceived temperatures; "shared water experiences" is listed as a proposed application. The paper reports perception. It does not claim the water is heated.', verified: true }, { title: 'Hydroptical Thermal Feedback — project page (Future Feelings Lab, Georgia Tech)', url: 'https://sites.gatech.edu/futurefeelings/2024/11/14/hydroptical-thermal-feedback-uist-24', note: 'Source for the study-by-study claims above (Studies 1–4; palm stimulation in 1–3; foot-bath example of two users in one bath).', verified: true }, { title: 'Hydroptical Thermal Feedback — UIST ’24 video', url: 'https://www.youtube.com/watch?v=VG1r-MGIW7Q', note: 'The video description carries the abstract: "Humans perceive thermal sensations when visible lights are cast on the skin under water"; it lists VR, shared water experiences and therapies as applications.', verified: true }, { title: 'John Locke, An Essay Concerning Humane Understanding, Book II ch. VIII', url: 'https://www.gutenberg.org/ebooks/10615', note: 'This is the classic statement of the same-water paradox: "the same water, at the same time, may produce the idea of cold by one hand and of heat by the other." The wording was confirmed through the IntraText concordance and Snyder\'s quotation (arXiv physics/0110086). I could not read the Gutenberg text as far as Book II, so the section number (§21) is not confirmed. The film borrows the situation, not Locke\'s explanation.', verified: true }, { title: 'Three-bowl temperature-adaptation demonstration (Franklin Institute activity)', url: 'https://fi.edu/node/3214', note: 'The demonstration shows that perceived temperature depends on what the skin was in before. The brief calls it "Weber\'s three-bowl experiment", but no source I found attributes it to E. H. Weber, and the Franklin Institute and Scientific American versions give no originator. The documented early source is Locke.', verified: true }, { title: 'D. M. Snyder, "On Thermal Sensation and Entropy"', url: 'https://arxiv.org/pdf/physics/0110086', note: 'Secondary source that quotes Locke\'s same-water passage. Used only to confirm the quotation.', verified: true } ], keyframes: [ { n: 1, t: 0.0, title: 'Same water, two answers', shot: 'Locked overhead, full basin. A at the south rim, B at the north rim. Their heads frame the water like two ends of a table.', action: 'Both rest a hand in the shallow basin. A\'s hand settles flat and stays. B lifts their hand out with curled fingers, drips falling, and looks across at A.', mechanism: 'unseen lamp → water/skin at one spot (A\'s side) ; light on submerged skin → A (felt as warmth) ; plain water → B (felt as cool) ; B\'s withdrawal ⇝ A (“is it me, or the water?”)', layers: [ { name: 'stone basin and slate floor (cut paper)', source: 'INVENTED', ref: null }, { name: 'water caustic plate, faint brighter patch under A\'s hand (planted, not explained)', source: 'INVENTED', ref: 0 }, { name: 'A and B, heads, shoulders and arms (cut paper)', source: 'INVENTED', ref: 3 }, { name: 'soak posture rotoscoped from a 1941 household-bath shot', source: 'PHOTOGRAPHED', ref: 2 } ], prompt: 'Overhead view of a shallow rectangular stone bath in a quiet tiled room, two adults at opposite ends, cut-paper animation style. One rests a dark-skinned hand flat in the water, relaxed; the other, ochre and sage clothing, lifts a pale hand out with curled fingers, water dripping. Soft caustic light webs on the basin floor, one faintly brighter patch under the resting hand. Muted slate, stone grey, teal water, paper grain, no text, no logos', draw: (ctx, w, h) => overhead(ctx, w, h, { seed: 5, spots: [Object.assign({}, SPOT1, { k: 0.55 })], people: [Object.assign({ who: 'A', tilt: 0.05 }, SEAT_S), Object.assign({ who: 'B', tilt: -0.22 }, SEAT_N)], limbs: [ { who: 'A', S: [560, 650], W: [566, 430], entry: [562, 560], state: 'flat', handed: 1, s: 96, twist: 0.05 }, { who: 'B', S: [770, 70], W: [700, 262], state: 'lifted', pose: 'curl', handed: -1, s: 90, drips: 7, twist: 0.2 } ], rings: [[690, 290, 3, 14, 0.3]], towel: [1110, 520] }), refs: [ ref('hydro', 'citation: the planted brighter patch is the lamp the film reveals later; the paper reports light on submerged skin perceived as warmth'), ref('locke', 'citation: the dramatic situation of one water and two contrary reports'), ref('japan', 'rotoscope reference: an unhurried soak posture, and a possible PHOTOGRAPHED cutaway before the title'), ref('kiyonaga', 'costume and pose reference: bathers at a shared basin as ordinary company') ] }, { n: 2, t: 14.0, title: 'Here, try here', shot: 'Same overhead, pushed in about 1.3x on the bright patch. Both seats are still in frame.', action: 'A points to the place where their hand rested ("Here, try here"), and they trade seats. B\'s hand now settles flat where A\'s was. A, in B\'s old place, lifts out with curled fingers.', mechanism: 'gesture ⇝ B (an invitation, not a correction) ; same spot → B\'s skin (now B settles) ; plain water → A (now A withdraws) ; swapped responses ⇝ both (the difference belongs to the place, not the person)', layers: [ { name: 'basin, floor, water caustics (continuity with K1)', source: 'INVENTED', ref: null }, { name: 'B settled, A withdrawn and pointing (cut paper)', source: 'INVENTED', ref: 1 }, { name: 'hands-in-basin timing rotoscoped from 1953 hygiene film', source: 'PHOTOGRAPHED', ref: 2 } ], prompt: 'Overhead close view of a shallow stone bath, cut-paper style. A pale hand in a sage sleeve rests flat and relaxed in a softly brighter patch of water. From the far side a dark-skinned hand in an ochre sleeve points down at that spot while the other dark hand lifts out of the water, fingers curled, drops falling. Teal water with caustic light webs, stone rim, paper texture, no text, no logos', draw: (ctx, w, h) => overhead(ctx, w, h, { seed: 5, zoom: 1.28, cx: 650, cy: 380, spots: [Object.assign({}, SPOT1, { k: 0.6 })], people: [Object.assign({ who: 'B', tilt: 0.0 }, SEAT_S), Object.assign({ who: 'A', tilt: 0.18 }, SEAT_N)], limbs: [ { who: 'B', S: [560, 650], W: [566, 430], entry: [562, 560], state: 'flat', handed: 1, s: 96, twist: -0.03 }, { who: 'A', S: [880, 70], W: [770, 258], state: 'lifted', pose: 'curl', handed: 1, s: 90, drips: 9, twist: -0.1 }, { who: 'A', S: [760, 80], W: [640, 300], state: 'lifted', pose: 'point', handed: -1, s: 92, twist: 0.12 } ], rings: [[760, 286, 3, 14, 0.3]], towel: [1110, 520] }), refs: [ ref('bowls', 'citation: the comparison logic. The same water reads differently depending on the skin\'s situation, so swapping places is the honest test'), ref('toyokuni', 'pose reference: bathers attending to one another across a shared bath, gesture without hierarchy'), ref('clean', 'rotoscope reference: hands entering and leaving a basin, drip timing (PHOTOGRAPHED)'), ref('hydro', 'citation: the warmth is localized to where light meets submerged skin; studies 1–3 stimulated the palm') ] }, { n: 3, t: 28.0, title: 'The lamp', shot: 'Three-quarter, low angle from the basin\'s long side, wide. The ceiling fixture enters frame top and the camera tilts down the beam to the water.', action: 'Both look up together. One of two lamps on a ceiling bar is on, and its pale beam enters the water at one spot. B\'s hand rests in that spot. The second lamp is dark.', mechanism: 'lamp → water → skin at one spot (visible light, absorbed) ; light on skin → B (felt as warm water) ; seeing the lamp ⇝ both (cause found outside either person) ; switch/aim ⊣ where warmth is felt', layers: [ { name: 'tiled wall, floor, basin in three-quarter (cut paper)', source: 'INVENTED', ref: null }, { name: 'basin geometry and stone colour from a Roman circular bath photochrom', source: 'PHOTOGRAPHED', ref: 2 }, { name: 'two-lamp ceiling fixture and beam (drawn, pale short-wavelength-leaning white)', source: 'INVENTED', ref: 0 }, { name: 'B (left) and A (right) kneeling, looking up (cut paper)', source: 'INVENTED', ref: null } ], prompt: 'Three-quarter low-angle view of an oval stone bath in a plain tiled room, cut-paper animation. Two adults kneel on either side looking up at a ceiling bar holding two small spotlights; one lamp is on, sending a pale cool-white beam into the water where one person\'s hand rests in a bright caustic pool. The other lamp is dark. Ochre and sage clothing, slate floor, teal water, no text, no logos', draw: (ctx, w, h) => threeQuarter(ctx, w, h, { seed: 9, basin: { cx: 640, cy: 520, rx: 430, ry: 118, face: 70 }, spots: [{ x: 500, y: 525, rx: 90, ry: 26, k: 1 }], submerged: [{ who: 'B', S: [320, 446], W: [470, 528], entry: [415, 506], s: 58, handed: -1, twist: 0.25 }], fixture: { mx: 660, by: 110, bx0: 470, bx1: 860, heads: [ { x: 520, y: 150, aim: 0.06, on: true, tx: 500, ty: 525, trx: 92, alpha: 0.32 }, { x: 800, y: 150, aim: -0.25, on: false } ] }, figures: [ { who: 'B', x: 170, y: 620, facing: 1, look: 0.55, seed: 410, arm: null }, { who: 'A', x: 1110, y: 620, facing: -1, look: 0.5, seed: 420, arm: { W: [935, 452], pose: 'curl', handed: 1, s: 60, twist: 0 } } ] }), refs: [ ref('hydro', 'lighting reference: visible light cast on submerged skin. Pale short-wavelength-leaning beam because the paper found shorter wavelengths gave stronger warmth (so no orange "heat lamp")'), ref('hydroVid', 'lighting reference: how the study rig places light above water onto a hand. Not usable footage; rights not granted'), ref('roman', 'background plate: stone basin geometry, rim thickness, water colour. Item listed in LOC search results; item page could not be opened, so rights unconfirmed'), ref('springs', 'archive footage: possible PHOTOGRAPHED cutaway of people at hot-spring baths before the reveal. Rights not stated') ] }, { n: 4, t: 40.0, title: 'Adjusting it together', shot: 'Three-quarter MCU under the fixture: lamp heads large in the upper frame, the water and both submerged hands below.', action: 'A reaches up and tilts the dark second lamp on, aiming it toward their own place while B steadies the bar. A new, softer pool lands beside A\'s waiting hand, and B\'s pool stays as it was.', mechanism: 'hands ⊣ lamp aim and brightness ; lamp 2 → water → A\'s skin (a second, dimmer spot) ; brighter light → warmer feel (paper, Study 2) ; joint adjustment ⇝ both (a setting they share, not a verdict on whose report is right)', layers: [ { name: 'fixture close-up, two lamp heads (cut paper)', source: 'INVENTED', ref: null }, { name: 'reaching hands rotoscoped from hands on machine fittings', source: 'PHOTOGRAPHED', ref: 2 }, { name: 'water band with two light pools and submerged hands', source: 'INVENTED', ref: 0 } ], prompt: 'Medium close-up beneath a ceiling bar with two small spotlights, cut-paper style. A dark-skinned hand in an ochre sleeve tilts the right lamp while a pale hand in a sage sleeve steadies the bar. Below, a shallow stone bath shows two pools of pale cool light on the water, one bright and one softer, each beside a resting hand. Slate, stone, teal, paper grain, no text, no logos', draw: (ctx, w, h) => threeQuarter(ctx, w, h, { seed: 13, zoom: 1.45, cx: 640, cy: 370, basin: { cx: 640, cy: 560, rx: 470, ry: 130, face: 80 }, spots: [{ x: 470, y: 566, rx: 92, ry: 28, k: 1 }, { x: 812, y: 560, rx: 110, ry: 32, k: 0.55 }], submerged: [ { who: 'B', S: [300, 500], W: [446, 570], entry: [372, 540], s: 54, handed: -1, twist: 0.2 }, { who: 'A', S: [1000, 500], W: [846, 566], entry: [930, 540], s: 54, handed: 1, twist: -0.2 } ], fixture: { mx: 640, by: 200, bx0: 420, bx1: 880, heads: [ { x: 470, y: 238, aim: 0.0, on: true, tx: 470, ty: 566, trx: 90, alpha: 0.30, scale: 0.9 }, { x: 800, y: 238, aim: 0.06, on: true, tx: 812, ty: 560, trx: 110, alpha: 0.16, scale: 0.9 } ] }, reach: [ { who: 'A', S: [1060, 470], W: [858, 262], pose: 'open', handed: -1, s: 64, w0: 52, w1: 34, twist: -0.6 }, { who: 'B', S: [250, 430], W: [520, 214], pose: 'flat', handed: 1, s: 62, w0: 52, w1: 34, twist: -0.25 } ] }), refs: [ ref('hydro', 'citation: brightness adjustable (brighter = warmer), reaction about 1.5 s, and light switched in sequence moves the warm point. This supports a second, dimmer spot'), ref('hydroVid', 'citation: abstract lists "shared water experiences" as an application, which this beat dramatizes as fiction'), ref('hands', 'rotoscope reference: hands grasping and turning metal fittings (PHOTOGRAPHED, movingimagearchive.com)'), ref('clean', 'archive footage: no footage of anyone adjusting a lamp over water was found; this film\'s basin shots are the nearest PHOTOGRAPHED cutaway') ] }, { n: 5, t: 52.0, title: 'The same water', shot: 'Locked overhead, matching K1 exactly. The fixture\'s soft shadow lies across the top rim.', action: 'Both hands rest flat, each in its own pool. The pools differ in size and brightness. A small wooden cup sits on the rim between them, and their heads tilt toward each other in conversation.', mechanism: 'two lamps → water → two spots (unequal on purpose) ; light on skin → each person (comfortable, not identical) ; shared setting ⇝ both (difference acknowledged, not argued) ; neither ⊣ the other\'s report', layers: [ { name: 'basin, floor, caustics (matches K1)', source: 'INVENTED', ref: null }, { name: 'B at south seat, A at north seat, both settled (cut paper)', source: 'INVENTED', ref: null }, { name: 'communal bath print as a held still before the cut to black', source: 'PHOTOGRAPHED', ref: 0 }, { name: 'fixture shadow on rim, so the mechanism stays visible', source: 'INVENTED', ref: 3 } ], prompt: 'Overhead view of a shallow rectangular stone bath, cut-paper animation. Two adults at opposite ends, both resting a hand flat in the water, relaxed, heads tilted toward each other. Each hand lies in its own pool of pale light: one small and bright, one wider and softer. A small wooden cup on the rim between them, soft shadow of a light fixture across the far edge. Teal water, caustic webs, slate floor, no text, no logos', draw: (ctx, w, h) => overhead(ctx, w, h, { seed: 5, spots: [Object.assign({}, SPOT1, { k: 0.75 }), { x: 790, y: 300, r: 120, k: 0.45 }], people: [Object.assign({ who: 'B', tilt: 0.16 }, SEAT_S), Object.assign({ who: 'A', tilt: 0.14 }, SEAT_N)], limbs: [ { who: 'B', S: [560, 650], W: [566, 430], entry: [562, 560], state: 'flat', handed: 1, s: 96, twist: 0.05 }, { who: 'A', S: [840, 70], W: [792, 278], entry: [818, 160], state: 'flat', handed: -1, s: 96, twist: -0.05 } ], cup: [1088, 360], towel: [1110, 520], fixture: [640, 96] }), refs: [ ref('kiyonaga', 'background plate: held PHOTOGRAPHED still of a shared bathhouse, an ordinary shared activity, before the cut to black'), ref('japan', 'archive footage: closing PHOTOGRAPHED cutaway of an unhurried soak, so the ending is ordinary rather than a device reveal'), ref('toyokuni', 'composition reference: company around a bath, attention to one another rather than to an apparatus'), ref('hydro', 'citation: different perceived temperatures for different people in one body of water. The interpersonal outcome is the film\'s proposal, not a reported finding') ] } ] }); })();