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:
@@ -29,6 +29,14 @@ references, `MIGRATION.md` is the shader→stage migration recipe.
|
|||||||
is on. Section boundaries are NOT exact for free — the compositor's
|
is on. Section boundaries are NOT exact for free — the compositor's
|
||||||
feedback buffer is global and carries across them. Clearing it per
|
feedback buffer is global and carries across them. Clearing it per
|
||||||
boundary would flash every transition; warm-up is the better trade.
|
boundary would flash every transition; warm-up is the better trade.
|
||||||
|
- Previews link to their exact app frame (`src/ui/preview.js`: song + seed
|
||||||
|
+ frame + venue/viz pins + the probe's warm-up length, both sides primed).
|
||||||
|
Exactness takes four agreements: the bank probes analyse the bank PCM
|
||||||
|
(peak-normalised 16-bit mono — `audio/songbank.js bankPcm`), file decode
|
||||||
|
is fixed at 44.1 kHz with bit-exact WAV parsing (never the context rate),
|
||||||
|
pins run before any layer builds (rebuilding mid-context renders
|
||||||
|
differently), and materials built mid-update compile synchronously
|
||||||
|
(`needsCompile`) so no warm-up frame draws against an unlinked program.
|
||||||
|
|
||||||
## 2. Lookahead
|
## 2. Lookahead
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@
|
|||||||
<div id="out"></div>
|
<div id="out"></div>
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { buildFilmstrip, songNames, STRIP, DEFAULT_EVERY, DEFAULT_DURATION } from '/src/checks/filmstrip.js';
|
import { buildFilmstrip, songNames, STRIP, WARMUP, DEFAULT_EVERY, DEFAULT_DURATION } from '/src/checks/filmstrip.js';
|
||||||
import { venues } from '/src/venues/registry.js';
|
import { venues } from '/src/venues/registry.js';
|
||||||
import { blit } from '/src/checks/blit.js';
|
import { blit } from '/src/checks/blit.js';
|
||||||
import { hashString } from '/src/engine/rng.js';
|
import { hashString } from '/src/engine/rng.js';
|
||||||
@@ -178,6 +178,7 @@ const shotSpec = (rowName, shot) => ({
|
|||||||
frame: shot.frame,
|
frame: shot.frame,
|
||||||
venue: venueEnabled || !!venueSel.value,
|
venue: venueEnabled || !!venueSel.value,
|
||||||
venueSel: venueSel.value || null,
|
venueSel: venueSel.value || null,
|
||||||
|
warm: WARMUP,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+49
-3
@@ -7,6 +7,8 @@ import { ArcDriver } from './look/ArcDriver.js';
|
|||||||
import { grainEnvelope } from './look/grain.js';
|
import { grainEnvelope } from './look/grain.js';
|
||||||
import { hashSamples } from './engine/rng.js';
|
import { hashSamples } from './engine/rng.js';
|
||||||
import { subjectOf } from './look/stack.js';
|
import { subjectOf } from './look/stack.js';
|
||||||
|
import { pinVenueShell } from './venues/pin.js';
|
||||||
|
import { pinViz } from './look/pinViz.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The opening and closing fade to black. Exported because anything SAMPLING a
|
* The opening and closing fade to black. Exported because anything SAMPLING a
|
||||||
@@ -51,7 +53,7 @@ export class Show {
|
|||||||
* only so that watching a song reproduces the look they measured — the
|
* only so that watching a song reproduces the look they measured — the
|
||||||
* filmstrip seeds off the song's name, not off its samples.
|
* filmstrip seeds off the song's name, not off its samples.
|
||||||
*/
|
*/
|
||||||
async load(file, onProgress = null, { seed = null } = {}) {
|
async load(file, onProgress = null, { seed = null, pins = null } = {}) {
|
||||||
const report = (stage, p) => onProgress && onProgress(stage, p);
|
const report = (stage, p) => onProgress && onProgress(stage, p);
|
||||||
|
|
||||||
report('decoding', 0);
|
report('decoding', 0);
|
||||||
@@ -69,10 +71,18 @@ export class Show {
|
|||||||
|
|
||||||
report('look', 0.97);
|
report('look', 0.97);
|
||||||
const samples = monoSamples(audioBuffer);
|
const samples = monoSamples(audioBuffer);
|
||||||
const wantsVenue = this._wantsVenue();
|
// A pinned venue shell implies the venue: ?venueSel= without ?venue=1
|
||||||
|
// still wants the room, not a silent skip.
|
||||||
|
const wantsVenue = this._wantsVenue() || !!(pins && pins.venueSel);
|
||||||
this.setLook(seed === null
|
this.setLook(seed === null
|
||||||
? generateLook(this.track, { samples, venueEnabled: wantsVenue })
|
? generateLook(this.track, { samples, venueEnabled: wantsVenue })
|
||||||
: generateLook(this.track, { seed, venueEnabled: wantsVenue }));
|
: generateLook(this.track, { seed, venueEnabled: wantsVenue }));
|
||||||
|
// Preview deep-link pins (?venueSel=, ?viz=), applied here — after
|
||||||
|
// the look exists, before anything builds layers from it. Pinning
|
||||||
|
// later would need cache invalidation, and rebuilding mid-context
|
||||||
|
// renders differently than building once (measured). Gallery and
|
||||||
|
// filmstrip pin before useTrack for the same reason.
|
||||||
|
this.applyPins(pins);
|
||||||
|
|
||||||
this.engine.timeline.setDuration(this.track.duration);
|
this.engine.timeline.setDuration(this.track.duration);
|
||||||
this.engine.setFeatureProvider(featureProviderFor(this.track));
|
this.engine.setFeatureProvider(featureProviderFor(this.track));
|
||||||
@@ -112,6 +122,33 @@ export class Show {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview deep-link pins: force the venue shell and/or the viz subject
|
||||||
|
* through the same shared paths the probe pages use, so a card and its
|
||||||
|
* link render the same look. Must run before the arc builds any layer
|
||||||
|
* (load() calls it between setLook and prewarm for exactly this reason).
|
||||||
|
* Unknown names warn and fall back to the census cast — never break a load.
|
||||||
|
*
|
||||||
|
* @returns {boolean} true when anything was pinned
|
||||||
|
*/
|
||||||
|
applyPins({ venueSel = null, viz = null } = {}) {
|
||||||
|
if (!this.look || (!venueSel && !viz)) return false;
|
||||||
|
let pinned = false;
|
||||||
|
if (venueSel && this.look.venue) {
|
||||||
|
try {
|
||||||
|
pinned = pinVenueShell(this.look, venueSel,
|
||||||
|
{ seed: this.look.seed, buildIfMissing: true }) || pinned;
|
||||||
|
} catch (err) { console.warn('[flow-state]', err.message); }
|
||||||
|
}
|
||||||
|
if (viz) {
|
||||||
|
try {
|
||||||
|
pinViz(this.look, viz);
|
||||||
|
pinned = true;
|
||||||
|
} catch (err) { console.warn('[flow-state]', err.message); }
|
||||||
|
}
|
||||||
|
return pinned;
|
||||||
|
}
|
||||||
|
|
||||||
reroll(seed) {
|
reroll(seed) {
|
||||||
this.setLook(rerollLook(this.look, this.track, seed));
|
this.setLook(rerollLook(this.look, this.track, seed));
|
||||||
this.prewarm();
|
this.prewarm();
|
||||||
@@ -341,8 +378,17 @@ export class Show {
|
|||||||
* boundaries would make seeks exact at the cost of a visible flash at every
|
* boundaries would make seeks exact at the cost of a visible flash at every
|
||||||
* transition, which is a much worse trade. Warm-up is cheap; the flash is not.
|
* transition, which is a much worse trade. Warm-up is cheap; the flash is not.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Seek for review. `warmup` accepts true (converge per the feedback
|
||||||
|
* decay), false (reset, exact only without feedback), or an exact frame
|
||||||
|
* count — the form preview deep-links use to replay the probe's own
|
||||||
|
* warm-up length (?warm=), so both sides land on identically converged
|
||||||
|
* state. See src/ui/preview.js.
|
||||||
|
*/
|
||||||
seek(frame, { warmup = true } = {}) {
|
seek(frame, { warmup = true } = {}) {
|
||||||
const frames = warmup ? this.warmupFrames() : 0;
|
const frames = typeof warmup === 'number'
|
||||||
|
? Math.max(0, Math.min(400, Math.round(warmup)))
|
||||||
|
: warmup ? this.warmupFrames() : 0;
|
||||||
if (frames > 0) {
|
if (frames > 0) {
|
||||||
this.warmUp(frame, frames);
|
this.warmUp(frame, frames);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -16,13 +16,102 @@ export async function decodeFile(file) {
|
|||||||
return decodeArrayBuffer(arrayBuffer);
|
return decodeArrayBuffer(arrayBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rate everything is decoded at: the synth's rate (see audio/synth.js),
|
||||||
|
* never the hardware context rate.
|
||||||
|
*
|
||||||
|
* `AudioContext.decodeAudioData` resamples to the CONTEXT rate, so the same
|
||||||
|
* file came out 44.1k in one browser and 48k in another — and, worse, the
|
||||||
|
* probe pages (filmstrip/gallery/venue-gallery) all render from 44.1k synth
|
||||||
|
* while the app decoded the bank wav at the context rate. Different spectra,
|
||||||
|
* different features, different videos: no preview deep-link could ever
|
||||||
|
* reproduce its image. Decoding in a fixed-rate OfflineAudioContext makes
|
||||||
|
* the app see the same audio the probes do. See src/ui/preview.js.
|
||||||
|
*/
|
||||||
|
export const DECODE_RATE = 44100;
|
||||||
|
|
||||||
export async function decodeArrayBuffer(arrayBuffer) {
|
export async function decodeArrayBuffer(arrayBuffer) {
|
||||||
const ctx = context();
|
// Our own files first, sample-exact: even at a 1.0 ratio Chrome's
|
||||||
|
// decodeAudioData perturbs int16→float conversion (measured 2.6e-5),
|
||||||
|
// which is enough to flip section classification on quiet tracks and
|
||||||
|
// break every preview deep-link. A manual parse keeps every bit.
|
||||||
|
const wav = decodeWav(arrayBuffer);
|
||||||
|
if (wav) return wav;
|
||||||
|
const ctx = new OfflineAudioContext(2, DECODE_RATE, DECODE_RATE);
|
||||||
// decodeAudioData detaches the buffer, so hand it a copy if the caller may
|
// decodeAudioData detaches the buffer, so hand it a copy if the caller may
|
||||||
// still need the original bytes.
|
// still need the original bytes.
|
||||||
return await ctx.decodeAudioData(arrayBuffer.slice(0));
|
return await ctx.decodeAudioData(arrayBuffer.slice(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exact WAV decode: 8/16/24-bit PCM and 32-bit float, any rate, any
|
||||||
|
* channels. No resampling, no conversion noise — int16 comes back as
|
||||||
|
* V / 32768, the same floats the bank PCM holds. Returns null for
|
||||||
|
* anything else (compressed formats still go through decodeAudioData).
|
||||||
|
*/
|
||||||
|
export function decodeWav(arrayBuffer) {
|
||||||
|
const bytes = new Uint8Array(arrayBuffer);
|
||||||
|
const dv = new DataView(arrayBuffer);
|
||||||
|
const str = (o, n) => {
|
||||||
|
let s = '';
|
||||||
|
for (let i = 0; i < n; i++) s += String.fromCharCode(bytes[o + i]);
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
if (bytes.length < 44 || str(0, 4) !== 'RIFF' || str(8, 4) !== 'WAVE') return null;
|
||||||
|
let fmt = null, dataOff = 0, dataLen = 0;
|
||||||
|
let o = 12;
|
||||||
|
while (o + 8 <= bytes.length) {
|
||||||
|
const id = str(o, 4);
|
||||||
|
const size = dv.getUint32(o + 4, true);
|
||||||
|
if (id === 'fmt ') {
|
||||||
|
fmt = {
|
||||||
|
audioFormat: dv.getUint16(o + 8, true),
|
||||||
|
channels: dv.getUint16(o + 10, true),
|
||||||
|
sampleRate: dv.getUint32(o + 12, true),
|
||||||
|
bits: dv.getUint16(o + 22, true),
|
||||||
|
};
|
||||||
|
} else if (id === 'data') {
|
||||||
|
dataOff = o + 8;
|
||||||
|
dataLen = Math.min(size, bytes.length - dataOff);
|
||||||
|
}
|
||||||
|
o += 8 + size + (size % 2);
|
||||||
|
}
|
||||||
|
if (!fmt || !dataLen || (fmt.audioFormat !== 1 && fmt.audioFormat !== 3)) return null;
|
||||||
|
if (fmt.channels < 1 || fmt.channels > 32 || !fmt.sampleRate) return null;
|
||||||
|
const bytesPerSample = fmt.audioFormat === 3 ? 4 : fmt.bits / 8;
|
||||||
|
if (![1, 2, 3, 4].includes(bytesPerSample) || (fmt.audioFormat === 3 && fmt.bits !== 32)) return null;
|
||||||
|
const frames = Math.floor(dataLen / (bytesPerSample * fmt.channels));
|
||||||
|
if (frames < 1) return null;
|
||||||
|
let out;
|
||||||
|
try {
|
||||||
|
out = new AudioBuffer({
|
||||||
|
length: frames, sampleRate: fmt.sampleRate, numberOfChannels: fmt.channels,
|
||||||
|
});
|
||||||
|
} catch { return null; }
|
||||||
|
for (let c = 0; c < fmt.channels; c++) {
|
||||||
|
const ch = out.getChannelData(c);
|
||||||
|
for (let i = 0; i < frames; i++) {
|
||||||
|
const at = dataOff + (i * fmt.channels + c) * bytesPerSample;
|
||||||
|
let v;
|
||||||
|
if (fmt.audioFormat === 3) {
|
||||||
|
v = dv.getFloat32(at, true);
|
||||||
|
} else if (bytesPerSample === 1) {
|
||||||
|
v = (bytes[at] - 128) / 128;
|
||||||
|
} else if (bytesPerSample === 2) {
|
||||||
|
v = dv.getInt16(at, true) / 32768;
|
||||||
|
} else if (bytesPerSample === 3) {
|
||||||
|
let s = bytes[at] | (bytes[at + 1] << 8) | (bytes[at + 2] << 16);
|
||||||
|
if (s & 0x800000) s -= 0x1000000;
|
||||||
|
v = s / 8388608;
|
||||||
|
} else {
|
||||||
|
v = dv.getInt32(at, true) / 2147483648;
|
||||||
|
}
|
||||||
|
ch[i] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Interleaved mono mixdown. Used for the content hash that seeds the look. */
|
/** Interleaved mono mixdown. Used for the content hash that seeds the look. */
|
||||||
export function monoSamples(audioBuffer) {
|
export function monoSamples(audioBuffer) {
|
||||||
const channels = audioBuffer.numberOfChannels;
|
const channels = audioBuffer.numberOfChannels;
|
||||||
|
|||||||
@@ -26,7 +26,30 @@
|
|||||||
// These are test signals, not music. They exist to be measured.
|
// These are test signals, not music. They exist to be measured.
|
||||||
|
|
||||||
import { FeatureTrack } from './FeatureTrack.js';
|
import { FeatureTrack } from './FeatureTrack.js';
|
||||||
import { synthesizeSong } from './synth.js';
|
import { synthesizeSong, MockAudioBuffer } from './synth.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bank's PCM contract: peak-normalise to -1 dBFS, then round to the
|
||||||
|
* 16-bit grid — bit-exactly what tools/build-song-bank.js writes into
|
||||||
|
* test/songs/*.wav (int16 LE of round(v * 32767), read back as V / 32768).
|
||||||
|
*
|
||||||
|
* Why this exists: the app decodes the wav while every probe page analyses
|
||||||
|
* the synth, and unless both see these exact samples their features differ
|
||||||
|
* (measured: 3x loudness and a whole extra drop section on drone) and no
|
||||||
|
* preview deep-link can reproduce its image. song() applies it so the bank
|
||||||
|
* IS the files; the writer shares it so the files stay the bank.
|
||||||
|
*/
|
||||||
|
export function bankPcm(samples) {
|
||||||
|
let peak = 0;
|
||||||
|
for (let i = 0; i < samples.length; i++) peak = Math.max(peak, Math.abs(samples[i]));
|
||||||
|
const gain = peak > 1e-9 ? 0.89 / peak : 1;
|
||||||
|
const out = new Float32Array(samples.length);
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
const v = Math.max(-1, Math.min(1, samples[i] * gain));
|
||||||
|
out[i] = Math.round(v * 32767) / 32768;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {object} SongSpec
|
* @typedef {object} SongSpec
|
||||||
@@ -88,20 +111,24 @@ export function song(name, { fps = 60, duration = DURATION } = {}) {
|
|||||||
const spec = SONGS.find((s) => s.name === name);
|
const spec = SONGS.find((s) => s.name === name);
|
||||||
if (!spec) throw new Error(`no song named "${name}" — have: ${SONGS.map((s) => s.name).join(', ')}`);
|
if (!spec) throw new Error(`no song named "${name}" — have: ${SONGS.map((s) => s.name).join(', ')}`);
|
||||||
|
|
||||||
const entry = {
|
// Analysed from the bank PCM, not the raw synth: peak gain, 16-bit
|
||||||
...spec,
|
// grid, mono — the exact samples test/songs/*.wav holds, so this track
|
||||||
track: FeatureTrack.fromAudioBuffer(synthesizeSong({
|
// is the file the app decodes rather than a near-twin of it. See bankPcm.
|
||||||
bpm: spec.bpm,
|
const synth = synthesizeSong({
|
||||||
duration,
|
bpm: spec.bpm,
|
||||||
arrangement: spec.arrangement,
|
duration,
|
||||||
brightness: spec.brightness,
|
arrangement: spec.arrangement,
|
||||||
noise: spec.noise,
|
brightness: spec.brightness,
|
||||||
noiseColour: spec.noiseColour ?? null,
|
noise: spec.noise,
|
||||||
dynamics: spec.dynamics,
|
noiseColour: spec.noiseColour ?? null,
|
||||||
transition: spec.transition ?? 0.5,
|
dynamics: spec.dynamics,
|
||||||
seed: 1 + SONGS.indexOf(spec),
|
transition: spec.transition ?? 0.5,
|
||||||
}), { fps }),
|
seed: 1 + SONGS.indexOf(spec),
|
||||||
};
|
});
|
||||||
|
const mono = new MockAudioBuffer(
|
||||||
|
1, synth.length, synth.sampleRate);
|
||||||
|
mono.getChannelData(0).set(bankPcm(synth.getChannelData(0)));
|
||||||
|
const entry = { ...spec, track: FeatureTrack.fromAudioBuffer(mono, { fps }) };
|
||||||
cache.set(cacheKey, entry);
|
cache.set(cacheKey, entry);
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ export const DEFAULT_DURATION = 180;
|
|||||||
* the whole strip look more alike than it is. Half of what the variety harness
|
* the whole strip look more alike than it is. Half of what the variety harness
|
||||||
* uses, because this is 17 songs deep and the cost is linear.
|
* uses, because this is 17 songs deep and the cost is linear.
|
||||||
*/
|
*/
|
||||||
const WARMUP = 24;
|
/** 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. */
|
||||||
|
export const WARMUP = 24;
|
||||||
|
|
||||||
export const songNames = () => SONGS.map((s) => s.name);
|
export const songNames = () => SONGS.map((s) => s.name);
|
||||||
|
|
||||||
@@ -80,7 +82,16 @@ export function renderSong(show, name, { duration, every, venueEnabled = null, v
|
|||||||
// VenueSpec population stays the census one, only the shell is forced.
|
// VenueSpec population stays the census one, only the shell is forced.
|
||||||
const sel = venueSel || (typeof location !== 'undefined' && new URLSearchParams(location.search).get('venueSel')) || null;
|
const sel = venueSel || (typeof location !== 'undefined' && new URLSearchParams(location.search).get('venueSel')) || null;
|
||||||
if (sel) pinVenueShell(look, sel);
|
if (sel) pinVenueShell(look, sel);
|
||||||
|
// The file name the app shows in its title plate (OSD): without it the
|
||||||
|
// probe renders a blank plate and no deep-link can match the app's
|
||||||
|
// default view. See src/ui/preview.js.
|
||||||
|
show.fileName = name;
|
||||||
show.useTrack(track, look);
|
show.useTrack(track, look);
|
||||||
|
// Prime once per song: programs link asynchronously and a draw against an
|
||||||
|
// unlinked program is wrong output — invisible in a sweep, wrong in an
|
||||||
|
// export, and fatal to preview deep-links, which replay these frames in
|
||||||
|
// the app (primed there too). See src/ui/preview.js.
|
||||||
|
show.prime();
|
||||||
|
|
||||||
const frames = [];
|
const frames = [];
|
||||||
const descriptors = [];
|
const descriptors = [];
|
||||||
|
|||||||
@@ -473,6 +473,19 @@ export class ModelLayer extends Layer {
|
|||||||
venueArrangement: this.venueArrangement ?? null,
|
venueArrangement: this.venueArrangement ?? null,
|
||||||
THREE,
|
THREE,
|
||||||
});
|
});
|
||||||
|
// Materials built mid-update (first update, satellite/passer rebuilds
|
||||||
|
// on discrete-count cuts, venue re-hangs) draw wrong until their
|
||||||
|
// programs link — up to ten frames on the heaviest scene, carried by
|
||||||
|
// feedback long after. Compiling synchronously here converts the
|
||||||
|
// rebuild frame from garbage to correct. Modules set
|
||||||
|
// instance.needsCompile = true wherever they (re)build geometry in
|
||||||
|
// update(); build()-time geometry is covered by prewarm instead.
|
||||||
|
if (this.instance && this.instance.needsCompile) {
|
||||||
|
this.instance.needsCompile = false;
|
||||||
|
try {
|
||||||
|
renderer.compileScene(this.scene, cam);
|
||||||
|
} catch { /* non-fatal: next warm-up frame converges anyway */ }
|
||||||
|
}
|
||||||
renderer.renderScene(this.scene, cam, target, true);
|
renderer.renderScene(this.scene, cam, target, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Pin one viz as the subject of every section's stacks.
|
||||||
|
//
|
||||||
|
// The same override in two places: the venue gallery's viz picker and the
|
||||||
|
// app's `?viz=`. One code path, so a gallery card and its app deep-link
|
||||||
|
// render the same pixels — the venue ground (and overlays) stay untouched,
|
||||||
|
// only the subject slot is re-cast. Seeds derive from (viz, section, look
|
||||||
|
// seed), so the pin is stable across rebuilds and identical everywhere.
|
||||||
|
|
||||||
|
import { Rng, hashString } from '../engine/rng.js';
|
||||||
|
import { sampleValues } from '../params/schema.js';
|
||||||
|
import { subjectIndexOf } from './stack.js';
|
||||||
|
import { scenes } from '../scenes/registry.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} look LookSpec from generateLook (mutated in place)
|
||||||
|
* @param {string} name scene name, e.g. 'Synthwave Corridor'
|
||||||
|
* @returns {object} the pinned scene module
|
||||||
|
*/
|
||||||
|
export function pinViz(look, name) {
|
||||||
|
const viz = scenes.find((s) => s.name === name);
|
||||||
|
if (!viz) throw new Error(`unknown viz "${name}"`);
|
||||||
|
const seedBase = look.seed >>> 0;
|
||||||
|
const temper = look.personality && look.personality.temperament;
|
||||||
|
for (const sec of look.sections) {
|
||||||
|
const rng = new Rng(hashString(`${viz.name}:sec:${sec.index}:${seedBase}`));
|
||||||
|
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;
|
||||||
|
stack[at] = {
|
||||||
|
module: viz,
|
||||||
|
params: sampleValues(viz, rng.fork(`variant:${v}`), sec.bias, temper),
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return viz;
|
||||||
|
}
|
||||||
+21
-17
@@ -16,7 +16,6 @@ import { synthesizeSong } from './audio/synth.js';
|
|||||||
import { FeatureTrack } from './audio/FeatureTrack.js';
|
import { FeatureTrack } from './audio/FeatureTrack.js';
|
||||||
import { generateLook } from './look/LookGenerator.js';
|
import { generateLook } from './look/LookGenerator.js';
|
||||||
import { SONGS } from './audio/songbank.js';
|
import { SONGS } from './audio/songbank.js';
|
||||||
import { pinVenueShell } from './venues/pin.js';
|
|
||||||
import { captureThumbnails } from './llm/thumbs.js';
|
import { captureThumbnails } from './llm/thumbs.js';
|
||||||
|
|
||||||
const QUALITY = {
|
const QUALITY = {
|
||||||
@@ -101,7 +100,7 @@ const paramPanel = new ParamPanel(document.createElement('div'), onParamChange);
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- loading
|
// ---------------------------------------------------------------- loading
|
||||||
|
|
||||||
async function loadFile(file, { seed = null, venueSel = null, frame = null } = {}) {
|
async function loadFile(file, { seed = null, pins = null, frame = null, warm = null } = {}) {
|
||||||
if (state.busy) return;
|
if (state.busy) return;
|
||||||
state.busy = true;
|
state.busy = true;
|
||||||
stopPlayback();
|
stopPlayback();
|
||||||
@@ -121,7 +120,7 @@ async function loadFile(file, { seed = null, venueSel = null, frame = null } = {
|
|||||||
await state.show.load(file, (stage, fraction) => {
|
await state.show.load(file, (stage, fraction) => {
|
||||||
dom.thStep.textContent = stage;
|
dom.thStep.textContent = stage;
|
||||||
dom.thFill.style.width = `${Math.round((fraction || 0) * 100)}%`;
|
dom.thFill.style.width = `${Math.round((fraction || 0) * 100)}%`;
|
||||||
}, { seed });
|
}, { seed, pins });
|
||||||
|
|
||||||
dom.audio.src = URL.createObjectURL(file);
|
dom.audio.src = URL.createObjectURL(file);
|
||||||
dom.thLabel.textContent = 'loaded track';
|
dom.thLabel.textContent = 'loaded track';
|
||||||
@@ -135,18 +134,18 @@ async function loadFile(file, { seed = null, venueSel = null, frame = null } = {
|
|||||||
if (dom.osd) dom.osd.classList.toggle('on', state.osdVisible);
|
if (dom.osd) dom.osd.classList.toggle('on', state.osdVisible);
|
||||||
resize();
|
resize();
|
||||||
// Deep link from a preview (filmstrip figure, venue-gallery card):
|
// Deep link from a preview (filmstrip figure, venue-gallery card):
|
||||||
// the same shell-swap the probe pages do, then the exact frame with
|
// pins ride into load() so the arc builds pinned layers once, then
|
||||||
// warm-up so stateful layers land converged like the probe render.
|
// the exact frame with the probe's warm-up so stateful layers land
|
||||||
// Dropped files never carry these — only `?song=` loads pass them.
|
// converged like the probe render. Dropped files never carry pins —
|
||||||
if (venueSel && state.show.look.venue) {
|
// only `?song=` loads pass them.
|
||||||
try {
|
const deepLink = !!(pins && (pins.venueSel || pins.viz)) || Number.isFinite(frame);
|
||||||
if (pinVenueShell(state.show.look, venueSel,
|
// Prime before the landing seek on deep-link loads: programs link
|
||||||
{ seed: state.show.look.seed, buildIfMissing: true })) {
|
// asynchronously and a draw against an unlinked program is wrong
|
||||||
state.show.prewarm();
|
// output — the probe pages prime for the same reason, and both sides
|
||||||
}
|
// must be linked for the frames to agree. Normal loads skip it.
|
||||||
} catch (err) { console.warn('[flow-state]', err.message); }
|
if (deepLink) state.show.prime();
|
||||||
}
|
seekTo(Number.isFinite(frame) ? frame : 0,
|
||||||
seekTo(Number.isFinite(frame) ? frame : 0);
|
Number.isFinite(warm) ? { warmup: warm } : {});
|
||||||
renderPanel();
|
renderPanel();
|
||||||
document.title = `flow-state · ${state.show.fileName}`;
|
document.title = `flow-state · ${state.show.fileName}`;
|
||||||
console.info('[flow-state]', describeLook(state.show.look));
|
console.info('[flow-state]', describeLook(state.show.look));
|
||||||
@@ -184,7 +183,10 @@ dom.fileInput.addEventListener('change', (e) => {
|
|||||||
* the strip's seed across so the video you watch is the one it measured.
|
* 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
|
* `&frame=` seeks to the exact frame after load (with warm-up), and
|
||||||
* `&venueSel=` re-applies the pinned venue shell — together they make a
|
* `&venueSel=` re-applies the pinned venue shell — together they make a
|
||||||
* preview deep-link reproduce its image. Built by src/ui/preview.js.
|
* preview deep-link reproduce its image. `&viz=` pins the subject the same
|
||||||
|
* way, and `&warm=` replays the probe's own warm-up length so feedback
|
||||||
|
* lands identically converged; deep-link loads prime first so neither side
|
||||||
|
* renders against unlinked programs. Built by src/ui/preview.js.
|
||||||
*
|
*
|
||||||
* Dev-only in practice: `test/songs/` is served by vite from the project root
|
* 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
|
* and is not part of a build. The name is matched against a bare word rather
|
||||||
@@ -201,10 +203,12 @@ async function loadBankSong(name, seed) {
|
|||||||
// on the exact image the preview showed. See src/ui/preview.js.
|
// on the exact image the preview showed. See src/ui/preview.js.
|
||||||
const q = new URLSearchParams(location.search);
|
const q = new URLSearchParams(location.search);
|
||||||
const frame = q.get('frame');
|
const frame = q.get('frame');
|
||||||
|
const warm = q.get('warm');
|
||||||
await loadFile(new File([blob], `${name}.wav`, { type: 'audio/wav' }), {
|
await loadFile(new File([blob], `${name}.wav`, { type: 'audio/wav' }), {
|
||||||
seed,
|
seed,
|
||||||
venueSel: q.get('venueSel'),
|
pins: { venueSel: q.get('venueSel'), viz: q.get('viz') },
|
||||||
frame: frame === null ? null : Number(frame),
|
frame: frame === null ? null : Number(frame),
|
||||||
|
warm: warm === null ? null : Number(warm),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
dom.thLabel.textContent = 'no such song';
|
dom.thLabel.textContent = 'no such song';
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export const assembly = {
|
|||||||
}
|
}
|
||||||
instance.heroPaletteMap = actorSpec.paletteMap || [];
|
instance.heroPaletteMap = actorSpec.paletteMap || [];
|
||||||
instance.heroBuilt = true;
|
instance.heroBuilt = true;
|
||||||
|
instance.needsCompile = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- satellites: small instances so count/spread visibly matter ---
|
// --- satellites: small instances so count/spread visibly matter ---
|
||||||
@@ -173,6 +174,7 @@ export const assembly = {
|
|||||||
}
|
}
|
||||||
instance.satBuilt = true;
|
instance.satBuilt = true;
|
||||||
instance.satCount = needCount;
|
instance.satCount = needCount;
|
||||||
|
instance.needsCompile = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Palette re-bind so paletteArc is live on mesh — ground + hero + sats.
|
// Palette re-bind so paletteArc is live on mesh — ground + hero + sats.
|
||||||
|
|||||||
@@ -298,6 +298,7 @@ export const pylonField3D = {
|
|||||||
instance.builtRows = rows;
|
instance.builtRows = rows;
|
||||||
instance.builtIdentityKey = identityKey;
|
instance.builtIdentityKey = identityKey;
|
||||||
instance.pylonCount = cols * rows;
|
instance.pylonCount = cols * rows;
|
||||||
|
instance.needsCompile = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Palette + opacity live rebind so paletteArc is visible.
|
// Palette + opacity live rebind so paletteArc is visible.
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ export const synthwaveCorridor = {
|
|||||||
instance.heroGroup.add(m);
|
instance.heroGroup.add(m);
|
||||||
}
|
}
|
||||||
instance.heroBuilt = true;
|
instance.heroBuilt = true;
|
||||||
|
instance.needsCompile = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Passers — rebuilt only when count changes or identity changes materially.
|
// Passers — rebuilt only when count changes or identity changes materially.
|
||||||
@@ -306,6 +307,7 @@ export const synthwaveCorridor = {
|
|||||||
instance.passerGroup.add(mesh);
|
instance.passerGroup.add(mesh);
|
||||||
}
|
}
|
||||||
instance.passerCount = passerCount;
|
instance.passerCount = passerCount;
|
||||||
|
instance.needsCompile = true;
|
||||||
} else if (passerCount === 0) {
|
} else if (passerCount === 0) {
|
||||||
for (const child of [...instance.passerGroup.children]) {
|
for (const child of [...instance.passerGroup.children]) {
|
||||||
instance.passerGroup.remove(child);
|
instance.passerGroup.remove(child);
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
// Clickable previews: every rendered thumbnail links back to the app frame
|
// Clickable previews: every rendered thumbnail links back to the app frame
|
||||||
// that produced it.
|
// that produced it.
|
||||||
//
|
//
|
||||||
// A preview is exact when the app can rebuild the same pixels from the URL:
|
// A preview is exact when the app rebuilds the same pixels from the URL,
|
||||||
// bank song + look seed + frame, plus the venue flag and — when the source
|
// which takes five agreements, all enforced here and in the pages that link:
|
||||||
// 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
|
// song + seed the look (bank PCM, so the file IS the synth — see
|
||||||
// converged, the way every probe page renders them.
|
// audio/songbank.js bankPcm and the fixed-rate, exact WAV
|
||||||
|
// decode in audio/decode.js)
|
||||||
|
// frame the landing frame (?frame=, warm seek)
|
||||||
|
// venue/venueSel the pinned room shell (shared src/venues/pin.js)
|
||||||
|
// viz the pinned subject (shared src/look/pinViz.js)
|
||||||
|
// warm the probe's warm-up length (?warm=): feedback converges
|
||||||
|
// geometrically, so different warm-up lengths land on
|
||||||
|
// differently converged state
|
||||||
|
//
|
||||||
|
// Both sides prime programs first (app deep-link loads; probe harnesses),
|
||||||
|
// because a draw against an unlinked program is wrong output that feedback
|
||||||
|
// then carries for the rest of the shot.
|
||||||
//
|
//
|
||||||
// Gallery cells are the deliberate exception: they hold one scene fixed
|
// Gallery cells are the deliberate exception: they hold one scene fixed
|
||||||
// outside any look, so no app URL reproduces them — don't wrap those.
|
// 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
|
// Pure module: no DOM at import time, no WebGL, no wall-clock. The URL
|
||||||
// builder is node-testable; wrapPreview needs a document.
|
// builder is node-testable; previewLink needs a document.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App URL for the exact frame a preview shows.
|
* App URL for the exact frame a preview shows.
|
||||||
@@ -22,8 +33,10 @@
|
|||||||
* @param {number} spec.frame frame index
|
* @param {number} spec.frame frame index
|
||||||
* @param {boolean} [spec.venue] venue on (app ?venue=1)
|
* @param {boolean} [spec.venue] venue on (app ?venue=1)
|
||||||
* @param {string} [spec.venueSel] pinned venue shell (implies venue)
|
* @param {string} [spec.venueSel] pinned venue shell (implies venue)
|
||||||
|
* @param {string} [spec.viz] pinned viz subject (app ?viz=)
|
||||||
|
* @param {number} [spec.warm] probe warm-up frames (app ?warm=)
|
||||||
*/
|
*/
|
||||||
export function appFrameUrl({ song, seed, frame, venue = false, venueSel = null }) {
|
export function appFrameUrl({ song, seed, frame, venue = false, venueSel = null, viz = null, warm = null }) {
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
p.set('song', song);
|
p.set('song', song);
|
||||||
p.set('seed', String(seed >>> 0));
|
p.set('seed', String(seed >>> 0));
|
||||||
@@ -34,16 +47,19 @@ export function appFrameUrl({ song, seed, frame, venue = false, venueSel = null
|
|||||||
} else if (venue) {
|
} else if (venue) {
|
||||||
p.set('venue', '1');
|
p.set('venue', '1');
|
||||||
}
|
}
|
||||||
|
if (viz) p.set('viz', viz);
|
||||||
|
if (Number.isFinite(warm)) p.set('warm', String(Math.max(0, Math.round(warm))));
|
||||||
return `/?${p}`;
|
return `/?${p}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Human caption for the link title: song · seed · m:ss · venue. */
|
/** Human caption for the link title: song · seed · m:ss · venue · viz. */
|
||||||
export function previewTitle({ song, seed, frame, venue = false, venueSel = null, fps = 60 }) {
|
export function previewTitle({ song, seed, frame, venue = false, venueSel = null, viz = null, fps = 60 }) {
|
||||||
const t = Math.max(0, Math.round(frame)) / fps;
|
const t = Math.max(0, Math.round(frame)) / fps;
|
||||||
const mm = Math.floor(t / 60);
|
const mm = Math.floor(t / 60);
|
||||||
const ss = String(Math.floor(t % 60)).padStart(2, '0');
|
const ss = String(Math.floor(t % 60)).padStart(2, '0');
|
||||||
const where = venueSel ? `venue ${venueSel}` : (venue ? 'venue' : 'no venue');
|
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`;
|
const what = viz ? ` · viz ${viz}` : '';
|
||||||
|
return `${song} · seed ${seed >>> 0} · frame ${Math.round(frame)} (${mm}:${ss}) · ${where}${what} — open this exact frame in the app`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -687,6 +687,7 @@ export const chamber = {
|
|||||||
instance.actorCfg = actorCfg;
|
instance.actorCfg = actorCfg;
|
||||||
instance.builtKey = popKey;
|
instance.builtKey = popKey;
|
||||||
instance.popCount = count;
|
instance.popCount = count;
|
||||||
|
instance.needsCompile = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actors walk a closed loop — stand at furniture A facing it, meet at
|
// Actors walk a closed loop — stand at furniture A facing it, meet at
|
||||||
|
|||||||
@@ -593,6 +593,7 @@ export const ride = {
|
|||||||
|
|
||||||
instance.builtKey = popKey;
|
instance.builtKey = popKey;
|
||||||
instance.popCount = count;
|
instance.popCount = count;
|
||||||
|
instance.needsCompile = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- motion: analytic, no integration ---
|
// --- motion: analytic, no integration ---
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ export const stage = {
|
|||||||
instance.builtCols = cols;
|
instance.builtCols = cols;
|
||||||
instance.builtKey = identityKey;
|
instance.builtKey = identityKey;
|
||||||
instance.pylonCount = cols*rows;
|
instance.pylonCount = cols*rows;
|
||||||
|
instance.needsCompile = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Analytic pose
|
// Analytic pose
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { resolve, dirname } from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { synthesizeSong } from '../src/audio/synth.js';
|
import { synthesizeSong } from '../src/audio/synth.js';
|
||||||
import { FeatureTrack } from '../src/audio/FeatureTrack.js';
|
import { FeatureTrack } from '../src/audio/FeatureTrack.js';
|
||||||
import { SONGS, AXES } from '../src/audio/songbank.js';
|
import { SONGS, AXES, bankPcm } from '../src/audio/songbank.js';
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url));
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@@ -41,7 +41,9 @@ const CHECK_ONLY = has('check');
|
|||||||
* 16-bit PCM WAV, mono.
|
* 16-bit PCM WAV, mono.
|
||||||
*
|
*
|
||||||
* Mono halves the size and loses nothing: the analyser sums to mono before it
|
* Mono halves the size and loses nothing: the analyser sums to mono before it
|
||||||
* does anything, and the synth writes the same signal to both channels anyway.
|
* does anything, the synth writes the same signal to both channels anyway —
|
||||||
|
* and the probes analyse the mono bank PCM, so the files must be mono for
|
||||||
|
* app and probe features to match bit-exactly. See songbank.js bankPcm.
|
||||||
*/
|
*/
|
||||||
function wav(samples, sampleRate) {
|
function wav(samples, sampleRate) {
|
||||||
const bytes = samples.length * 2;
|
const bytes = samples.length * 2;
|
||||||
@@ -63,13 +65,12 @@ function wav(samples, sampleRate) {
|
|||||||
// Peak-normalise to -1 dBFS. Without it the quiet ambient entries clip down
|
// Peak-normalise to -1 dBFS. Without it the quiet ambient entries clip down
|
||||||
// into 16-bit noise and their measured flatness stops being what the spec
|
// into 16-bit noise and their measured flatness stops being what the spec
|
||||||
// asked for — the bank would then be testing the encoder, not the generator.
|
// asked for — the bank would then be testing the encoder, not the generator.
|
||||||
let peak = 0;
|
// Shared with songbank.js bankPcm: the files must hold exactly what the
|
||||||
for (let i = 0; i < samples.length; i++) peak = Math.max(peak, Math.abs(samples[i]));
|
// probes analyse, or app-vs-probe previews can never match.
|
||||||
const gain = peak > 1e-9 ? 0.89 / peak : 1;
|
const pcm = bankPcm(samples);
|
||||||
|
|
||||||
for (let i = 0; i < samples.length; i++) {
|
for (let i = 0; i < pcm.length; i++) {
|
||||||
const v = Math.max(-1, Math.min(1, samples[i] * gain));
|
buffer.writeInt16LE(Math.round(pcm[i] * 32768), 44 + i * 2);
|
||||||
buffer.writeInt16LE(Math.round(v * 32767), 44 + i * 2);
|
|
||||||
}
|
}
|
||||||
return buffer;
|
return buffer;
|
||||||
}
|
}
|
||||||
@@ -92,7 +93,19 @@ for (const spec of SONGS) {
|
|||||||
transition: spec.transition ?? 0.5,
|
transition: spec.transition ?? 0.5,
|
||||||
seed: 1 + SONGS.indexOf(spec),
|
seed: 1 + SONGS.indexOf(spec),
|
||||||
});
|
});
|
||||||
const track = FeatureTrack.fromAudioBuffer(buffer, { fps: 60 });
|
// Measured from the bank PCM — the same samples the files hold and the
|
||||||
|
// probes analyse (see songbank.js bankPcm) — not the raw synth.
|
||||||
|
const pcm = bankPcm(buffer.getChannelData(0));
|
||||||
|
const track = FeatureTrack.fromAudioBuffer({
|
||||||
|
sampleRate: buffer.sampleRate,
|
||||||
|
length: pcm.length,
|
||||||
|
duration: buffer.duration,
|
||||||
|
numberOfChannels: 1,
|
||||||
|
getChannelData: (i) => {
|
||||||
|
if (i !== 0) throw new Error('bank is mono');
|
||||||
|
return pcm;
|
||||||
|
},
|
||||||
|
}, { fps: 60 });
|
||||||
|
|
||||||
if (!CHECK_ONLY) {
|
if (!CHECK_ONLY) {
|
||||||
writeFileSync(resolve(OUT, `${spec.name}.wav`),
|
writeFileSync(resolve(OUT, `${spec.name}.wav`),
|
||||||
|
|||||||
@@ -59,17 +59,19 @@
|
|||||||
<script type="module">
|
<script type="module">
|
||||||
import { Show } from '/src/Show.js';
|
import { Show } from '/src/Show.js';
|
||||||
import { SONGS, song } from '/src/audio/songbank.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 { generateLook } from '/src/look/LookGenerator.js';
|
||||||
import { pinVenueShell } from '/src/venues/pin.js';
|
import { pinVenueShell } from '/src/venues/pin.js';
|
||||||
import { sampleValues } from '/src/params/schema.js';
|
import { pinViz } from '/src/look/pinViz.js';
|
||||||
import { subjectIndexOf } from '/src/look/stack.js';
|
|
||||||
import { venues } from '/src/venues/registry.js';
|
import { venues } from '/src/venues/registry.js';
|
||||||
import { scenes } from '/src/scenes/registry.js';
|
import { scenes } from '/src/scenes/registry.js';
|
||||||
import { blit } from '/src/checks/blit.js';
|
import { blit } from '/src/checks/blit.js';
|
||||||
import { previewLink } from '/src/ui/preview.js';
|
import { previewLink } from '/src/ui/preview.js';
|
||||||
|
|
||||||
const THUMB = { width: 320, height: 180 };
|
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_ROWS = 3;
|
||||||
const DEFAULT_COLS = 3;
|
const DEFAULT_COLS = 3;
|
||||||
const ROW_OPTIONS = [2, 3, 4, 5, 6, 8, 10];
|
const ROW_OPTIONS = [2, 3, 4, 5, 6, 8, 10];
|
||||||
@@ -177,36 +179,6 @@ async function ensureSongs(names) {
|
|||||||
return out;
|
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) {
|
function errorCard(title, detail) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'card error';
|
card.className = 'card error';
|
||||||
@@ -239,7 +211,17 @@ async function renderCell(show, entry, venueName, vizMod, colIdx) {
|
|||||||
return;
|
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 fps = entry.track.fps || 60;
|
||||||
const frame = Math.min(
|
const frame = Math.min(
|
||||||
@@ -247,12 +229,25 @@ async function renderCell(show, entry, venueName, vizMod, colIdx) {
|
|||||||
Math.floor(entry.track.duration * 0.35 * fps),
|
Math.floor(entry.track.duration * 0.35 * fps),
|
||||||
);
|
);
|
||||||
try {
|
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);
|
show.useTrack(entry.track, look);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorCard(`${entry.name} · col ${colIdx + 1}`, `useTrack failed: ${err.message || err}`);
|
errorCard(`${entry.name} · col ${colIdx + 1}`, `useTrack failed: ${err.message || err}`);
|
||||||
return;
|
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;
|
let target;
|
||||||
try {
|
try {
|
||||||
target = show.renderFrame(frame);
|
target = show.renderFrame(frame);
|
||||||
@@ -277,16 +272,19 @@ async function renderCell(show, entry, venueName, vizMod, colIdx) {
|
|||||||
card.appendChild(cv);
|
card.appendChild(cv);
|
||||||
card.appendChild(meta);
|
card.appendChild(meta);
|
||||||
out.appendChild(card);
|
out.appendChild(card);
|
||||||
// Clicking opens the exact frame in the app — but only when the cell is
|
// Clicking opens the exact frame in the app (song + seed + frame +
|
||||||
// reproducible there: a pinned viz re-samples the stacks outside any
|
// venue pin + viz pin + the probe's warm-up length — the app rebuilds
|
||||||
// look, so those cards stay unlinked (same reason gallery cells aren't).
|
// the same look through the same shared pin paths). Every card links:
|
||||||
if (!vizMod && look.venue) {
|
// the gallery holds nothing back.
|
||||||
|
if (look.venue) {
|
||||||
previewLink(cv, {
|
previewLink(cv, {
|
||||||
song: entry.name,
|
song: entry.name,
|
||||||
seed: seedBase,
|
seed: seedBase,
|
||||||
frame,
|
frame,
|
||||||
venue: true,
|
venue: true,
|
||||||
venueSel: venueName || look.venue.name,
|
venueSel: venueName || look.venue.name,
|
||||||
|
viz: vizMod ? vizMod.name : null,
|
||||||
|
warm: WARMUP,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
blit(cv, pix, THUMB.width, THUMB.height);
|
blit(cv, pix, THUMB.width, THUMB.height);
|
||||||
|
|||||||
Reference in New Issue
Block a user