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).
This commit is contained in:
@@ -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 = [];
|
||||
|
||||
+27
-3
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user