[object Object]

← back to Afterlight Exhibition

Wire Seedance motion clips per object (video primary, Canvas study as fallback)

590b749a0e8ae1e616054da6f5ad6ccf4db1f369 · 2026-09-01 07:26:32 -0700 · Steve Abrams

Generated 5 Seedance-1-lite 720p/5s clips (~$0.90), recompressed to web weight
(~7.8MB total), extracted poster frames. Each object's motion plate now plays its
Seedance clip (muted/loop, IntersectionObserver play-pause, paused-on-poster under
reduced motion); the in-page Canvas study remains the graceful fallback if media/
is absent. Cost logged to ledger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit 590b749a0e8ae1e616054da6f5ad6ccf4db1f369
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 1 07:26:32 2026 -0700

    Wire Seedance motion clips per object (video primary, Canvas study as fallback)
    
    Generated 5 Seedance-1-lite 720p/5s clips (~$0.90), recompressed to web weight
    (~7.8MB total), extracted poster frames. Each object's motion plate now plays its
    Seedance clip (muted/loop, IntersectionObserver play-pause, paused-on-poster under
    reduced motion); the in-page Canvas study remains the graceful fallback if media/
    is absent. Cost logged to ledger.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 .gitignore               |   2 +
 index.html               |  71 ++++++++++++++++++++++++++-------
 media/obj-1.jpg          | Bin 0 -> 201458 bytes
 media/obj-1.mp4          | Bin 0 -> 1995505 bytes
 media/obj-2.jpg          | Bin 0 -> 242745 bytes
 media/obj-2.mp4          | Bin 0 -> 3265094 bytes
 media/obj-3.jpg          | Bin 0 -> 124549 bytes
 media/obj-3.mp4          | Bin 0 -> 779503 bytes
 media/obj-4.jpg          | Bin 0 -> 181666 bytes
 media/obj-4.mp4          | Bin 0 -> 766463 bytes
 media/obj-5.jpg          | Bin 0 -> 77107 bytes
 media/obj-5.mp4          | Bin 0 -> 1339467 bytes
 scripts/gen-seedance.mjs |  99 +++++++++++++++++++++++++++++++++++++++++++++++
 13 files changed, 159 insertions(+), 13 deletions(-)

diff --git a/.gitignore b/.gitignore
index 3ed9365..3e2a726 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,5 @@ build/
 .next/
 .playwright-mcp/
 shot-*.png
+# keep generated Seedance media tracked (they are the deliverable)
+!media/
diff --git a/index.html b/index.html
index a97a3ed..ce21393 100644
--- a/index.html
+++ b/index.html
@@ -281,7 +281,9 @@
     border-top: 2px solid var(--accent);
     overflow: hidden;
   }
-  .motion canvas { display: block; width: 100%; aspect-ratio: 16 / 7; }
+  .motion video, .motion canvas { display: block; width: 100%; aspect-ratio: 16 / 7; object-fit: cover; }
+  .motion canvas[hidden] { display: none; }
+  html[data-motion="reduce"] .motion video { filter: saturate(.9); }
   .motion figcaption {
     position: absolute; left: .8rem; bottom: .6rem;
     font-size: .64rem; letter-spacing: .2em; text-transform: uppercase;
@@ -652,7 +654,12 @@
       <h2 id="obj-${o.id}-title" class="reveal">${o.title}<span class="vh"> (${o.year})</span></h2>
 
       <figure class="motion reveal" data-obj="${o.id}">
-        <canvas class="motion-canvas" role="img"
+        <video class="motion-video" muted loop playsinline preload="metadata"
+               poster="media/obj-${o.id}.jpg"
+               aria-label="Motion study evoking ${o.title}, ${o.year}.">
+          <source src="media/obj-${o.id}.mp4" type="video/mp4" />
+        </video>
+        <canvas class="motion-canvas" role="img" hidden
                 aria-label="Motion study evoking ${o.title}, ${o.year} — restrained, era-toned."></canvas>
         <figcaption>Motion Study · ${o.year}</figcaption>
         <span class="paused-note">Motion paused</span>
@@ -1040,34 +1047,72 @@
     }
     function draw(s, t) { if (s.w) s.render(s.ctx, s.w, s.h, t, s); }
 
+    const motionOnNow = () => root.getAttribute("data-motion") === "animate";
+
+    // Canvas fallback loop — only drives studies whose video failed to load.
     function frame(now) {
       const t = (now - startT)/1000;
-      for (const s of studies) if (s.inView) draw(s, t);
+      for (const s of studies) if (s.videoFailed && s.inView) draw(s, t);
       raf = requestAnimationFrame(frame);
     }
     function start() { if (!raf) { startT = performance.now(); raf = requestAnimationFrame(frame); } }
     function stop() { if (raf) { cancelAnimationFrame(raf); raf = null; } }
+    const anyFallback = () => studies.some(s => s.videoFailed);
+
+    // Reveal the canvas fallback when a video can't be fetched/decoded.
+    function failToCanvas(s) {
+      if (s.videoFailed) return;
+      s.videoFailed = true;
+      if (s.video) s.video.style.display = "none";
+      s.canvas.hidden = false;
+      size(s); draw(s, 2.4);
+      sync();
+    }
+
+    function videoState(s) {
+      if (s.videoFailed || !s.video) return;
+      if (motionOnNow() && s.inView) s.video.play().catch(() => {});
+      else { s.video.pause(); if (!motionOnNow()) { try { s.video.currentTime = 0; } catch (e) {} } }
+    }
 
     function sync() {
-      const on = root.getAttribute("data-motion") === "animate";
-      if (on) start();
-      else { stop(); studies.forEach(s => draw(s, 2.4)); } // static representative frame
+      const on = motionOnNow();
+      studies.forEach(videoState);                       // videos: play/pause per state
+      if (on && anyFallback()) start(); else stop();      // canvas loop only if a video failed
+      if (!on) studies.forEach(s => { if (s.videoFailed) draw(s, 2.4); }); // frozen frame
     }
 
     function init() {
       $$(".motion").forEach(fig => {
         const id = +fig.dataset.obj;
         const canvas = $(".motion-canvas", fig);
-        const s = { canvas, ctx: canvas.getContext("2d"), render: RENDER[id],
-                    rgb: ACCENT[id], w: 0, h: 0, inView: false };
-        studies.push(s); size(s); draw(s, 2.4);
+        const video = $(".motion-video", fig);
+        const s = { id, fig, video, canvas, ctx: canvas.getContext("2d"), render: RENDER[id],
+                    rgb: ACCENT[id], w: 0, h: 0, inView: false, videoFailed: false };
+        studies.push(s);
+        size(s);
+        if (video) {
+          video.addEventListener("error", () => failToCanvas(s));
+          // <source> failure surfaces on the media element after it exhausts sources
+          const src = $("source", video);
+          if (src) src.addEventListener("error", () => failToCanvas(s));
+        } else {
+          failToCanvas(s);
+        }
       });
       const io = new IntersectionObserver(ents => {
-        ents.forEach(e => { const s = studies.find(x => x.canvas.closest(".motion") === e.target);
-          if (s) s.inView = e.isIntersecting; });
+        ents.forEach(e => {
+          const s = studies.find(x => x.fig === e.target);
+          if (!s) return;
+          s.inView = e.isIntersecting;
+          videoState(s);
+        });
       }, { threshold: 0.01 });
-      $$(".motion").forEach(f => io.observe(f));
-      window.addEventListener("resize", () => { studies.forEach(size); if (root.getAttribute("data-motion")!=="animate") studies.forEach(s=>draw(s,2.4)); });
+      studies.forEach(s => io.observe(s.fig));
+      window.addEventListener("resize", () => {
+        studies.forEach(size);
+        if (!motionOnNow()) studies.forEach(s => { if (s.videoFailed) draw(s, 2.4); });
+      });
       sync();
     }
     return { init, sync };
diff --git a/media/obj-1.jpg b/media/obj-1.jpg
new file mode 100644
index 0000000..a8724fb
Binary files /dev/null and b/media/obj-1.jpg differ
diff --git a/media/obj-1.mp4 b/media/obj-1.mp4
new file mode 100644
index 0000000..dcf8ab5
Binary files /dev/null and b/media/obj-1.mp4 differ
diff --git a/media/obj-2.jpg b/media/obj-2.jpg
new file mode 100644
index 0000000..8e99eb9
Binary files /dev/null and b/media/obj-2.jpg differ
diff --git a/media/obj-2.mp4 b/media/obj-2.mp4
new file mode 100644
index 0000000..e2e038b
Binary files /dev/null and b/media/obj-2.mp4 differ
diff --git a/media/obj-3.jpg b/media/obj-3.jpg
new file mode 100644
index 0000000..026350f
Binary files /dev/null and b/media/obj-3.jpg differ
diff --git a/media/obj-3.mp4 b/media/obj-3.mp4
new file mode 100644
index 0000000..20e627e
Binary files /dev/null and b/media/obj-3.mp4 differ
diff --git a/media/obj-4.jpg b/media/obj-4.jpg
new file mode 100644
index 0000000..97824e4
Binary files /dev/null and b/media/obj-4.jpg differ
diff --git a/media/obj-4.mp4 b/media/obj-4.mp4
new file mode 100644
index 0000000..3a63c43
Binary files /dev/null and b/media/obj-4.mp4 differ
diff --git a/media/obj-5.jpg b/media/obj-5.jpg
new file mode 100644
index 0000000..9a92c28
Binary files /dev/null and b/media/obj-5.jpg differ
diff --git a/media/obj-5.mp4 b/media/obj-5.mp4
new file mode 100644
index 0000000..bcfe191
Binary files /dev/null and b/media/obj-5.mp4 differ
diff --git a/scripts/gen-seedance.mjs b/scripts/gen-seedance.mjs
new file mode 100644
index 0000000..4af055c
--- /dev/null
+++ b/scripts/gen-seedance.mjs
@@ -0,0 +1,99 @@
+#!/usr/bin/env node
+/* Generate one Seedance motion clip per object via Replicate.
+   Model: bytedance/seedance-1-lite · 720p · 5s · 16:9  (~$0.036/s => $0.18/clip)
+   Reads REPLICATE_API_TOKEN from ~/Projects/secrets-manager/.env
+   Downloads MP4s to media/obj-N.mp4. Cost is printed before + after. */
+
+import fs from "node:fs";
+import path from "node:path";
+import os from "node:os";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, "..");
+const MEDIA = path.join(ROOT, "media");
+fs.mkdirSync(MEDIA, { recursive: true });
+
+/* ---- token ---- */
+function readToken() {
+  if (process.env.REPLICATE_API_TOKEN) return process.env.REPLICATE_API_TOKEN;
+  const env = path.join(os.homedir(), "Projects/secrets-manager/.env");
+  const line = fs.readFileSync(env, "utf8").split("\n").find(l => l.startsWith("REPLICATE_API_TOKEN="));
+  if (!line) throw new Error("REPLICATE_API_TOKEN not found");
+  return line.slice("REPLICATE_API_TOKEN=".length).trim();
+}
+const TOKEN = readToken();
+const MODEL = "bytedance/seedance-1-lite";
+const PRICE_PER_SEC = 0.036;   // 720p lite
+const DURATION = 5;
+
+const OBJECTS = [
+  { id: 1, camera_fixed: false, prompt:
+    "Cinematic museum shot, slow dolly-in on a charred antique oak schoolroom bench inside a dark gallery vitrine, children's initials carved into scorched wood grain, a warm amber shaft of light, dust motes drifting slowly, shallow depth of field, 35mm film grain, quiet restrained motion, no people, no text" },
+  { id: 2, camera_fixed: false, prompt:
+    "Cinematic close shot in a dark gallery, a translucent hanging textile woven with glowing fibre-optic threads, tiny points of cool signal-blue light slowly migrating across the weave like a data cascade, gentle sway, shallow depth of field, moody low light, no people, no text" },
+  { id: 3, camera_fixed: false, prompt:
+    "Cinematic macro shot, a modified antique brass-and-glass camera lens on a steel stand in a dark room, soft teal rim light, the focus slowly breathing in and out and never resolving, faint reflections, shallow depth of field, contemplative, no people, no text" },
+  { id: 4, camera_fixed: true, prompt:
+    "Cinematic macro shot of an ornate brass clock with motionless hands in a dark room, faint violet rim light, a single dust particle drifting slowly through a thin light beam, utter stillness, shallow depth of field, solemn, no people, no text" },
+  { id: 5, camera_fixed: true, prompt:
+    "Cinematic wide shot of an empty minimalist chamber whose smooth walls slowly shift and breathe pale-green and mint hues, generative shifting light across the surfaces, still locked-off camera, eerie calm, soft ambient glow, no people, no text" },
+];
+
+const projected = OBJECTS.length * DURATION * PRICE_PER_SEC;
+console.log(`\n💵 Projected cost: ${OBJECTS.length} clips × ${DURATION}s × $${PRICE_PER_SEC}/s = $${projected.toFixed(2)}`);
+if (projected > 2) { console.error("Refusing: projected cost over $2 safety guard."); process.exit(1); }
+
+const H = { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" };
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function createPrediction(o) {
+  const body = { input: {
+    prompt: o.prompt, resolution: "720p", aspect_ratio: "16:9",
+    duration: DURATION, fps: 24, camera_fixed: o.camera_fixed, seed: 1000 + o.id
+  }};
+  const r = await fetch(`https://api.replicate.com/v1/models/${MODEL}/predictions`,
+    { method: "POST", headers: H, body: JSON.stringify(body) });
+  const j = await r.json();
+  if (!r.ok) throw new Error(`create obj-${o.id}: ${r.status} ${JSON.stringify(j).slice(0,200)}`);
+  return j;
+}
+async function poll(url) {
+  for (let i = 0; i < 200; i++) {
+    const r = await fetch(url, { headers: H });
+    const j = await r.json();
+    if (j.status === "succeeded") return j;
+    if (j.status === "failed" || j.status === "canceled") throw new Error(`prediction ${j.status}: ${j.error}`);
+    await sleep(3000);
+  }
+  throw new Error("timeout");
+}
+async function download(url, dest) {
+  const r = await fetch(url);
+  if (!r.ok) throw new Error(`download ${r.status}`);
+  const buf = Buffer.from(await r.arrayBuffer());
+  fs.writeFileSync(dest, buf);
+  return buf.length;
+}
+
+const run = async (o) => {
+  const t0 = Date.now();
+  console.log(`  ▸ obj-${o.id}: submitting…`);
+  const p = await createPrediction(o);
+  const done = await poll(p.urls.get);
+  let out = done.output;
+  if (Array.isArray(out)) out = out[0];
+  if (!out) throw new Error(`obj-${o.id}: no output`);
+  const dest = path.join(MEDIA, `obj-${o.id}.mp4`);
+  const bytes = await download(out, dest);
+  const secs = ((Date.now() - t0) / 1000).toFixed(0);
+  console.log(`  ✓ obj-${o.id}: ${(bytes/1e6).toFixed(2)} MB in ${secs}s → media/obj-${o.id}.mp4`);
+  return { id: o.id, bytes };
+};
+
+const results = await Promise.allSettled(OBJECTS.map(run));
+const ok = results.filter(r => r.status === "fulfilled").length;
+const actual = ok * DURATION * PRICE_PER_SEC;
+console.log(`\n✅ ${ok}/${OBJECTS.length} clips generated. 💵 Actual cost ≈ $${actual.toFixed(2)}`);
+results.filter(r => r.status === "rejected").forEach(r => console.error("  ✗", r.reason.message));
+process.exit(ok === OBJECTS.length ? 0 : 1);

← 637567c Add per-object motion studies (in-page Canvas, $0) — era-ton  ·  back to Afterlight Exhibition  ·  (newest)