Clickable previews: exact app deep-links everywhere

Every rendered still links to the app frame that produced it (song + seed + frame + venue/viz pins + probe warm-up, new window), via shared src/ui/preview.js. Supporting work, each measured: app honors ?frame/?venueSel/?viz/?warm= with pre-build pins (Show.applyPins) and primes deep-link loads; shared pinViz/pinVenueShell paths for gallery, filmstrip and app; probes prime and carry their warm-up; bank probes analyse the bank PCM and file decode is fixed-rate with bit-exact WAV parsing so app and probes see identical samples; materials built mid-update compile synchronously (needsCompile). Round trip verified bit-identical gallery-versus-app.
This commit is contained in:
Dejvino
2026-09-20 18:42:53 +02:00
parent cc8888077f
commit 4d05250294
18 changed files with 377 additions and 96 deletions
+37 -39
View File
@@ -59,17 +59,19 @@
<script type="module">
import { Show } from '/src/Show.js';
import { SONGS, song } from '/src/audio/songbank.js';
import { Rng, hashString } from '/src/engine/rng.js';
import { 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 { pinViz } from '/src/look/pinViz.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 };
// Frames of warm-up behind every probe. Carried in preview links (?warm=)
// so the app lands on identically converged state. See src/ui/preview.js.
const WARMUP = 24;
const DEFAULT_ROWS = 3;
const DEFAULT_COLS = 3;
const ROW_OPTIONS = [2, 3, 4, 5, 6, 8, 10];
@@ -177,36 +179,6 @@ async function ensureSongs(names) {
return out;
}
// Pin one viz as the subject of every section's stacks, keeping the venue
// ground (and overlays) untouched. Mutates the look before Show.useTrack, so
// the ArcDriver builds its layer cache from the pinned stacks directly.
function pinViz(look, viz, seedBase) {
const temper = look.personality && look.personality.temperament;
for (const sec of look.sections) {
const rng = new Rng(hashString(`${viz.name}:sec:${sec.index}:${seedBase >>> 0}`));
for (let v = 0; v < (sec.variants || []).length; v++) {
const stack = sec.variants[v];
if (!stack) continue;
const at = subjectIndexOf(stack);
const cur = stack[at];
if (!cur) continue;
const params = sampleValues(viz, rng.fork(`variant:${v}`), sec.bias, temper);
stack[at] = {
module: viz,
params,
seed: rng.int(0, 0x7fffffff),
// A model viz composites by alpha; a fragment viz needs the
// key/screen path over the venue ground. Reuse the old blend
// when it already fits, else fall back to the compositor-safe one.
blend: viz.kind === 'model' ? 'normal'
: (cur.blend === 'normal' ? 'lumakey' : cur.blend),
opacity: cur.opacity ?? 1,
blaze: cur.blaze ?? false,
};
}
}
}
function errorCard(title, detail) {
const card = document.createElement('div');
card.className = 'card error';
@@ -239,7 +211,17 @@ async function renderCell(show, entry, venueName, vizMod, colIdx) {
return;
}
}
if (vizMod) pinViz(look, vizMod, seedBase);
// Pin the viz through the shared path with the app's ?viz=
// (src/look/pinViz.js): the card and its deep-link pin the same subject
// with the same seeds.
if (vizMod) {
try {
pinViz(look, vizMod.name);
} catch (err) {
errorCard(`${entry.name} · col ${colIdx + 1}`, err.message || String(err));
return;
}
}
const fps = entry.track.fps || 60;
const frame = Math.min(
@@ -247,12 +229,25 @@ async function renderCell(show, entry, venueName, vizMod, colIdx) {
Math.floor(entry.track.duration * 0.35 * fps),
);
try {
// The file name the app shows in its title plate (OSD): without it
// the card renders a blank plate and its deep-link can't match.
// See src/ui/preview.js.
show.fileName = entry.name;
show.useTrack(entry.track, look);
} catch (err) {
errorCard(`${entry.name} · col ${colIdx + 1}`, `useTrack failed: ${err.message || err}`);
return;
}
show.warmUp(frame, 24);
// Prime so the probe never renders against unlinked programs — the app
// primes deep-link loads for the same reason; both sides must be linked
// for a card and its link to agree. See src/ui/preview.js.
try {
show.prime();
} catch (err) {
errorCard(`${entry.name} · col ${colIdx + 1}`, `prime failed: ${err.message || err}`);
return;
}
show.warmUp(frame, WARMUP);
let target;
try {
target = show.renderFrame(frame);
@@ -277,16 +272,19 @@ 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) {
// Clicking opens the exact frame in the app (song + seed + frame +
// venue pin + viz pin + the probe's warm-up length — the app rebuilds
// the same look through the same shared pin paths). Every card links:
// the gallery holds nothing back.
if (look.venue) {
previewLink(cv, {
song: entry.name,
seed: seedBase,
frame,
venue: true,
venueSel: venueName || look.venue.name,
viz: vizMod ? vizMod.name : null,
warm: WARMUP,
});
}
blit(cv, pix, THUMB.width, THUMB.height);