← 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
M .gitignoreM index.htmlA media/obj-1.jpgA media/obj-1.mp4A media/obj-2.jpgA media/obj-2.mp4A media/obj-3.jpgA media/obj-3.mp4A media/obj-4.jpgA media/obj-4.mp4A media/obj-5.jpgA media/obj-5.mp4A scripts/gen-seedance.mjs
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)