From 6c7e314a3eaaed0d7ef5268a22bf4eb303de15e5 Mon Sep 17 00:00:00 2001 From: Dejvino Date: Sun, 20 Sep 2026 17:08:03 +0200 Subject: [PATCH] Clickable previews: every still links to its exact app frame New shared component src/ui/preview.js (appFrameUrl + previewLink): song + seed + frame + venue/venueSel deep-links. App honors ?frame= (warm seek on bank-song load) and ?venueSel= via shared pinVenueShell helper (src/venues/pin.js), which filmstrip and venue-gallery now also use. Filmstrip figures and venue-gallery cards wrap canvases; gallery cells stay unlinked (isolated scene probes, not reproducible in-app). --- flow-state/filmstrip.html | 29 ++++++++++++-- flow-state/src/checks/filmstrip.js | 10 +---- flow-state/src/main.js | 30 ++++++++++++-- flow-state/src/ui/preview.js | 63 ++++++++++++++++++++++++++++++ flow-state/src/venues/pin.js | 44 +++++++++++++++++++++ flow-state/venue-gallery.html | 37 ++++++++++-------- 6 files changed, 182 insertions(+), 31 deletions(-) create mode 100644 flow-state/src/ui/preview.js create mode 100644 flow-state/src/venues/pin.js 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); }