diff --git a/flow-state/filmstrip.html b/flow-state/filmstrip.html
index 3cad26f..d783f44 100644
--- a/flow-state/filmstrip.html
+++ b/flow-state/filmstrip.html
@@ -63,6 +63,8 @@
.strip { display: flex; gap: 4px; overflow-x: auto; padding-bottom: 6px; }
.strip figure { margin: 0; flex: 0 0 auto; width: 168px; }
.strip canvas { width: 100%; display: block; background: #05070a; border-radius: 2px; aspect-ratio: 16 / 9; }
+ a.preview-link canvas { cursor: zoom-in; }
+ a.preview-link:hover canvas { outline: 1px solid #7dd3fc; outline-offset: -1px; }
.strip figcaption { font-size: 10px; color: #565f70; margin-top: 3px; line-height: 1.35; }
.strip .t { color: #8b94a7; }
.strip .sc { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -107,6 +109,7 @@ import { buildFilmstrip, songNames, STRIP, DEFAULT_EVERY, DEFAULT_DURATION } fro
import { venues } from '/src/venues/registry.js';
import { blit } from '/src/checks/blit.js';
import { hashString } from '/src/engine/rng.js';
+import { previewLink } from '/src/ui/preview.js';
import {
sourceFingerprint, loadGallery, saveGallery,
pixelsToBlob, blobToCanvas,
@@ -156,8 +159,26 @@ const grade = (row) => {
// video. The app takes the bank's wav straight from `?song=`, and the seed is
// the one renderSong() uses — hashed off the name — so what plays is the take
// the strip measured rather than a fresh draw that happens to share a title.
-const watchLink = (name) =>
- `/?song=${encodeURIComponent(name)}&seed=${hashString(name)}`;
+// The venue flag and pin ride along too, so a venue strip replays its room.
+const watchLink = (name) => {
+ const p = new URLSearchParams();
+ p.set('song', name);
+ p.set('seed', String(hashString(name)));
+ if (venueSel.value || venueEnabled) p.set('venue', '1');
+ if (venueSel.value) p.set('venueSel', venueSel.value);
+ return `/?${p}`;
+};
+
+// Deep link for one figure: song + seed + the frame it shows, so clicking a
+// still opens the exact image in the app. Built by the shared preview
+// component (src/ui/preview.js) — every preview page links the same way.
+const shotSpec = (rowName, shot) => ({
+ song: rowName,
+ seed: hashString(rowName),
+ frame: shot.frame,
+ venue: venueEnabled || !!venueSel.value,
+ venueSel: venueSel.value || null,
+});
/**
* One song's row.
@@ -192,6 +213,8 @@ function renderRow(row) {
if (shot.moment === 'climax') fig.className = 'climax';
const canvas = document.createElement('canvas');
fig.appendChild(canvas);
+ // Clicking a still opens the exact frame in the app.
+ if (Number.isFinite(shot.frame)) previewLink(canvas, shotSpec(row.name, shot));
const cap = document.createElement('figcaption');
const mm = Math.floor(shot.time / 60);
const ss = String(Math.round(shot.time % 60)).padStart(2, '0');
@@ -282,7 +305,7 @@ async function build() {
...row,
frames: undefined,
shots: await Promise.all((row.frames || []).map(async (f) => ({
- time: f.time, kind: f.kind, scene: f.scene, act: f.act,
+ time: f.time, frame: f.frame, kind: f.kind, scene: f.scene, act: f.act,
tension: f.tension, journey: f.journey, moment: f.moment,
blob: await pixelsToBlob(f.pixels, STRIP.width, STRIP.height),
}))),
diff --git a/flow-state/src/checks/filmstrip.js b/flow-state/src/checks/filmstrip.js
index eaa1827..12968e1 100644
--- a/flow-state/src/checks/filmstrip.js
+++ b/flow-state/src/checks/filmstrip.js
@@ -22,9 +22,9 @@
import { Show, FADE_SECONDS } from '../Show.js';
import { generateLook } from '../look/LookGenerator.js';
-import { venues } from '../venues/registry.js';
import { song, SONGS } from '../audio/songbank.js';
import { hashString } from '../engine/rng.js';
+import { pinVenueShell } from '../venues/pin.js';
import { frameDescriptor, motionDescriptor } from './variety/descriptors.js';
import { descriptorDistance, directionOf, STRUCTURAL } from './variety/signature.js';
import { describeStory } from '../look/Story.js';
@@ -79,13 +79,7 @@ export function renderSong(show, name, { duration, every, venueEnabled = null, v
// Pin one venue in every row (same shell-swap as the venue gallery): the
// VenueSpec population stays the census one, only the shell is forced.
const sel = venueSel || (typeof location !== 'undefined' && new URLSearchParams(location.search).get('venueSel')) || null;
- if (sel && look.venue) {
- const picked = venues.find((v) => v.name === sel);
- if (!picked) throw new Error(`unknown venue "${sel}"`);
- look.venue.module = picked;
- look.venue.name = picked.name;
- look.venue.archetype = picked.archetype;
- }
+ if (sel) pinVenueShell(look, sel);
show.useTrack(track, look);
const frames = [];
diff --git a/flow-state/src/main.js b/flow-state/src/main.js
index da0bbb3..a88e35a 100644
--- a/flow-state/src/main.js
+++ b/flow-state/src/main.js
@@ -16,6 +16,7 @@ import { synthesizeSong } from './audio/synth.js';
import { FeatureTrack } from './audio/FeatureTrack.js';
import { generateLook } from './look/LookGenerator.js';
import { SONGS } from './audio/songbank.js';
+import { pinVenueShell } from './venues/pin.js';
import { captureThumbnails } from './llm/thumbs.js';
const QUALITY = {
@@ -100,7 +101,7 @@ const paramPanel = new ParamPanel(document.createElement('div'), onParamChange);
// ---------------------------------------------------------------- loading
-async function loadFile(file, { seed = null } = {}) {
+async function loadFile(file, { seed = null, venueSel = null, frame = null } = {}) {
if (state.busy) return;
state.busy = true;
stopPlayback();
@@ -133,7 +134,19 @@ async function loadFile(file, { seed = null } = {}) {
strip.setShow(state.show);
if (dom.osd) dom.osd.classList.toggle('on', state.osdVisible);
resize();
- seekTo(0);
+ // Deep link from a preview (filmstrip figure, venue-gallery card):
+ // the same shell-swap the probe pages do, then the exact frame with
+ // warm-up so stateful layers land converged like the probe render.
+ // Dropped files never carry these — only `?song=` loads pass them.
+ if (venueSel && state.show.look.venue) {
+ try {
+ if (pinVenueShell(state.show.look, venueSel,
+ { seed: state.show.look.seed, buildIfMissing: true })) {
+ state.show.prewarm();
+ }
+ } catch (err) { console.warn('[flow-state]', err.message); }
+ }
+ seekTo(Number.isFinite(frame) ? frame : 0);
renderPanel();
document.title = `flow-state · ${state.show.fileName}`;
console.info('[flow-state]', describeLook(state.show.look));
@@ -169,6 +182,9 @@ dom.fileInput.addEventListener('change', (e) => {
* reading is to watch the thing move, and until now that meant remembering
* which of seventeen near-identically-named wavs to drag in. `&seed=` carries
* the strip's seed across so the video you watch is the one it measured.
+ * `&frame=` seeks to the exact frame after load (with warm-up), and
+ * `&venueSel=` re-applies the pinned venue shell — together they make a
+ * preview deep-link reproduce its image. Built by src/ui/preview.js.
*
* Dev-only in practice: `test/songs/` is served by vite from the project root
* and is not part of a build. The name is matched against a bare word rather
@@ -181,7 +197,15 @@ async function loadBankSong(name, seed) {
const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const blob = await res.blob();
- await loadFile(new File([blob], `${name}.wav`, { type: 'audio/wav' }), { seed });
+ // Preview deep-links (?frame=, ?venueSel=) ride along: the app lands
+ // on the exact image the preview showed. See src/ui/preview.js.
+ const q = new URLSearchParams(location.search);
+ const frame = q.get('frame');
+ await loadFile(new File([blob], `${name}.wav`, { type: 'audio/wav' }), {
+ seed,
+ venueSel: q.get('venueSel'),
+ frame: frame === null ? null : Number(frame),
+ });
} catch (err) {
dom.thLabel.textContent = 'no such song';
dom.thName.hidden = false;
diff --git a/flow-state/src/ui/preview.js b/flow-state/src/ui/preview.js
new file mode 100644
index 0000000..607f8d4
--- /dev/null
+++ b/flow-state/src/ui/preview.js
@@ -0,0 +1,63 @@
+// Clickable previews: every rendered thumbnail links back to the app frame
+// that produced it.
+//
+// A preview is exact when the app can rebuild the same pixels from the URL:
+// bank song + look seed + frame, plus the venue flag and — when the source
+// pinned one — the venue shell. The app honors all five on `?song=` loads
+// (see main.js query handling), seeking with warm-up so stateful layers land
+// converged, the way every probe page renders them.
+//
+// Gallery cells are the deliberate exception: they hold one scene fixed
+// outside any look, so no app URL reproduces them — don't wrap those.
+//
+// Pure module: no DOM at import time, no WebGL, no wall-clock. The URL
+// builder is node-testable; wrapPreview needs a document.
+
+/**
+ * App URL for the exact frame a preview shows.
+ *
+ * @param {object} spec
+ * @param {string} spec.song bank song name
+ * @param {number} spec.seed look seed (unsigned int)
+ * @param {number} spec.frame frame index
+ * @param {boolean} [spec.venue] venue on (app ?venue=1)
+ * @param {string} [spec.venueSel] pinned venue shell (implies venue)
+ */
+export function appFrameUrl({ song, seed, frame, venue = false, venueSel = null }) {
+ const p = new URLSearchParams();
+ p.set('song', song);
+ p.set('seed', String(seed >>> 0));
+ p.set('frame', String(Math.max(0, Math.round(frame))));
+ if (venueSel) {
+ p.set('venue', '1');
+ p.set('venueSel', venueSel);
+ } else if (venue) {
+ p.set('venue', '1');
+ }
+ return `/?${p}`;
+}
+
+/** Human caption for the link title: song · seed · m:ss · venue. */
+export function previewTitle({ song, seed, frame, venue = false, venueSel = null, fps = 60 }) {
+ const t = Math.max(0, Math.round(frame)) / fps;
+ const mm = Math.floor(t / 60);
+ const ss = String(Math.floor(t % 60)).padStart(2, '0');
+ const where = venueSel ? `venue ${venueSel}` : (venue ? 'venue' : 'no venue');
+ return `${song} · seed ${seed >>> 0} · frame ${Math.round(frame)} (${mm}:${ss}) · ${where} — open this exact frame in the app`;
+}
+
+/**
+ * Wrap a rendered canvas so clicking it opens the exact app frame.
+ * Returns the anchor (wrapping the canvas); the canvas keeps its size.
+ */
+export function previewLink(canvas, spec) {
+ const a = document.createElement('a');
+ a.href = appFrameUrl(spec);
+ a.target = '_blank';
+ a.rel = 'noopener';
+ a.title = previewTitle(spec);
+ a.className = 'preview-link';
+ canvas.replaceWith(a);
+ a.appendChild(canvas);
+ return a;
+}
diff --git a/flow-state/src/venues/pin.js b/flow-state/src/venues/pin.js
new file mode 100644
index 0000000..a8d6362
--- /dev/null
+++ b/flow-state/src/venues/pin.js
@@ -0,0 +1,44 @@
+// Pin one venue shell onto a look.
+//
+// The same shell-swap in three places: the filmstrip's `venueSel`, the venue
+// gallery's venue picker, and the app's `?venueSel=`. Only the shell is
+// forced — module/name/archetype. The VenueSpec population stays the census
+// one (counts overlap between archetypes), so this is what makes a pinned
+// preview the exact image the app reproduces, rather than a nearby one.
+
+import { venues } from './registry.js';
+
+/**
+ * Force `look.venue` to render as the named venue.
+ *
+ * @param {object} look LookSpec from generateLook (mutated in place)
+ * @param {string} name venue name, e.g. 'Ride'
+ * @param {object} [opts]
+ * @param {number} [opts.seed] seed for a from-scratch spec (default 1)
+ * @param {boolean} [opts.buildIfMissing] build a neutral spec when the
+ * census produced no venue at all (the venue gallery does; the filmstrip
+ * keeps the no-venue video instead)
+ * @returns {boolean} true when a venue is pinned afterwards
+ */
+export function pinVenueShell(look, name, { seed = 1, buildIfMissing = false } = {}) {
+ if (!name || !look) return false;
+ const picked = venues.find((v) => v.name === name);
+ if (!picked) throw new Error(`unknown venue "${name}"`);
+ if (!look.venue) {
+ if (!buildIfMissing) return false;
+ look.venue = {
+ archetype: picked.archetype, name: picked.name, seed,
+ shell: { kind: picked.archetype }, projection: { kind: 'screen' },
+ population: { kind: 'scatter', count: 5, spread: 0.7, jitter: 0.25 },
+ paletteMap: [0, 1, 2, 3],
+ perSection: (look.sections || []).map(
+ () => ({ light: 0.7, fog: 0.35, popMul: 0.65, reachMul: 0.85 })),
+ venue: true, readsDepth: false, module: picked,
+ };
+ return true;
+ }
+ look.venue.module = picked;
+ look.venue.name = picked.name;
+ look.venue.archetype = picked.archetype;
+ return true;
+}
diff --git a/flow-state/venue-gallery.html b/flow-state/venue-gallery.html
index 7edb3ee..e4e5cfc 100644
--- a/flow-state/venue-gallery.html
+++ b/flow-state/venue-gallery.html
@@ -23,6 +23,8 @@
.meta { color:#6b7280; font-size:11px; }
.meta.err { color:#f87171; }
canvas { width:100%; display:block; background:#05070a; border-radius:2px; aspect-ratio:16/9; }
+ a.preview-link canvas { cursor: zoom-in; }
+ a.preview-link:hover canvas { outline: 1px solid #7dd3fc; outline-offset: -1px; }
.hint { font-size:10px; color:#565f70; margin-top:4px; }
#controls { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-top:8px; }
.progress { height:2px; background:#1a1e28; margin-top:8px; border-radius:2px; overflow:hidden; display:none; }
@@ -59,11 +61,13 @@ import { Show } from '/src/Show.js';
import { SONGS, song } from '/src/audio/songbank.js';
import { Rng, hashString } from '/src/engine/rng.js';
import { generateLook } from '/src/look/LookGenerator.js';
+import { pinVenueShell } from '/src/venues/pin.js';
import { sampleValues } from '/src/params/schema.js';
import { subjectIndexOf } from '/src/look/stack.js';
import { venues } from '/src/venues/registry.js';
import { scenes } from '/src/scenes/registry.js';
import { blit } from '/src/checks/blit.js';
+import { previewLink } from '/src/ui/preview.js';
const THUMB = { width: 320, height: 180 };
const DEFAULT_ROWS = 3;
@@ -228,25 +232,12 @@ async function renderCell(show, entry, venueName, vizMod, colIdx) {
// Force the selected venue. The VenueSpec population stays the census one
// (counts overlap between archetypes), so this is a shell swap for the lab.
if (venueName) {
- const picked = venues.find(v => v.name === venueName);
- if (!picked) {
- errorCard(`${entry.name} · col ${colIdx + 1}`, `unknown venue "${venueName}"`);
+ try {
+ pinVenueShell(look, venueName, { seed: seedBase, buildIfMissing: true });
+ } catch (err) {
+ errorCard(`${entry.name} · col ${colIdx + 1}`, err.message || String(err));
return;
}
- if (!look.venue) {
- look.venue = {
- archetype: picked.archetype, name: picked.name, seed: seedBase,
- shell: { kind: picked.archetype }, projection: { kind: 'screen' },
- population: { kind: 'scatter', count: 5, spread: 0.7, jitter: 0.25 },
- paletteMap: [0, 1, 2, 3],
- perSection: look.sections.map(() => ({ light: 0.7, fog: 0.35, popMul: 0.65, reachMul: 0.85 })),
- venue: true, readsDepth: false, module: picked,
- };
- } else {
- look.venue.module = picked;
- look.venue.name = picked.name;
- look.venue.archetype = picked.archetype;
- }
}
if (vizMod) pinViz(look, vizMod, seedBase);
@@ -286,6 +277,18 @@ async function renderCell(show, entry, venueName, vizMod, colIdx) {
card.appendChild(cv);
card.appendChild(meta);
out.appendChild(card);
+ // Clicking opens the exact frame in the app — but only when the cell is
+ // reproducible there: a pinned viz re-samples the stacks outside any
+ // look, so those cards stay unlinked (same reason gallery cells aren't).
+ if (!vizMod && look.venue) {
+ previewLink(cv, {
+ song: entry.name,
+ seed: seedBase,
+ frame,
+ venue: true,
+ venueSel: venueName || look.venue.name,
+ });
+ }
blit(cv, pix, THUMB.width, THUMB.height);
}