Phases 8 and 9: shots, and a production design per track
Both phases come out of the manual gate — watching whole tracks — and both
fix something no automated check was looking for.
Phase 8: shots. A section is a STAGE of the song and can run ninety
seconds; one scene held that long reads as a still image with a wobble on
it. Each section kind now gets a roster of three or four stage visuals
instead of one scene, and each section is cut into shots that rotate
between them on phrase lines, never holding past 22s. The roster stays per
kind, so a track's drops still cut between the same images and the video
keeps its identity; the anchor opens each section and the rotation returns
to it, and when a companion is due it is the least recently shown one.
The arc driver stopped working in sections and started working in cues, one
per shot, so a shot cut and a section change take the same code path and
differ only in transition length. The default transition is a slow
dissolve — two bars calm, one loud; a straight cut is reserved for
sections above the energy threshold, because on calm material a cut reads
as a glitch rather than as an edit.
Phase 9: production design. With cuts every fifteen seconds the next
problem was that the images being cut between shared nothing but the
palette. What a music video actually shares across shots is a location, a
cast, a camera operator and an art direction, so each track now generates a
personality in four traits (shape, camera, space, style) off the look seed.
The traits reach shaders as uniforms plus four helpers in the contract, and
each scene expresses them its own way: Classic Wave's rings take the
signature polygon, Metaballs merge as one, Floating Geometry no longer
picks between a box and a circle because the production already decided.
The part that makes it a design rather than a filter: scenes DECLARE which
traits they honour, a track is built on one or two, and a scene that does
not honour all of them is not cast in that track. The library shrinks per
track on purpose.
Two gates keep the declaration honest — lint greps each shader for evidence
of every trait it claims, and a render check measures that each declared
trait actually moves the image (41 scene/trait pairs, weakest response 64
of 255). A layer with no personality renders bit-identically to before,
which is what keeps every earlier sweep and regression valid.
Checks changed rather than added:
- P4 scene-change and drift checks now measure per shot, not per section;
the crossfade check reads its length off the cue.
- P5 flash sweep runs per shot, so the visuals that only appear
mid-section are measured too.
- P6 preview/export parity primes first (as both real paths do) and
compares at the one-LSB tolerance Phase 7 already uses. Measured over
four consecutive shows: 3 frames at delta 1, then bit-exact — GPU
variance on first render, not a divergence.
- P2's contract-uniform list is derived from the contract instead of
retyped, so the signature uniforms cannot fall out of sync.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
9d15c3cf49
commit
ca5a68eb84
@ -452,6 +452,82 @@ Toward six families. Each new scene is a shader plus a schema block.
|
||||
- Library-wide regression contact sheet at a fixed seed, diffed against last known good, so
|
||||
shared-code changes can't silently break existing scenes.
|
||||
|
||||
### Phase 8 — shots
|
||||
Not in the original plan. It exists because the manual gate in Phase 4 caught exactly what it
|
||||
was written to catch: watching whole tracks, long stretches went by without the image
|
||||
changing. Sections are the song's *stages*, and a stage can run ninety seconds; one scene held
|
||||
for ninety seconds reads as a still image with a wobble on it, however reactive the wobble is.
|
||||
|
||||
So a third level sits between section and frame. Each section KIND gets a roster of three or
|
||||
four **stage visuals** instead of one scene, and each section is cut into **shots** that
|
||||
rotate between them on phrase lines — four to eight bars in a drop, eight to sixteen in an
|
||||
intro, never longer than twenty-two seconds. The roster stays per kind, so all of a track's
|
||||
drops still cut between the same visuals and the video keeps its identity; the first entry is
|
||||
the anchor, opens every section of that kind, and the rotation keeps returning to it. When a
|
||||
companion is due it is the least recently shown one, so a long section reaches its whole
|
||||
roster instead of ping-ponging between two images.
|
||||
|
||||
Mechanically this is one change: the arc driver stopped working in sections and started
|
||||
working in **cues**, one per shot, so a shot cut and a section change take the same code path
|
||||
and differ only in transition length. The default transition is a slow dissolve — two bars on
|
||||
calm material, one on loud, capped at 40% of the shot. A straight cut is reserved for genuinely
|
||||
high-energy sections: below the energy threshold nothing ever cuts, because on calm material a
|
||||
cut reads as a glitch rather than as an edit.
|
||||
|
||||
**Gate:**
|
||||
- No image held longer than the ceiling, and none shorter than the floor. Both measured across
|
||||
several seeds; the first is the complaint that started the phase, expressed as a number.
|
||||
- Intra-section cuts land within a quarter-bar of a downbeat.
|
||||
- Identity holds: a kind's roster is stable across its sections, the anchor opens each section
|
||||
and stays within one showing of the most-shown visual, and no variant repeats back to back.
|
||||
- A section with four or more shots reaches at least three distinct visuals — otherwise the
|
||||
rotation has collapsed back to A/B and the roster is decoration.
|
||||
- Dissolves outnumber cuts, and no section below the energy threshold cuts at all.
|
||||
- No black frame or discontinuity at a cut; the flash-rate sweep runs per shot rather than per
|
||||
section, so the visuals that only appear mid-section are measured too.
|
||||
- **Watch the battery again.** The phase exists because of a manual finding and the fix is a
|
||||
pacing judgement, which no delta threshold can make.
|
||||
|
||||
### Phase 9 — production design
|
||||
Also not in the original plan, and for the same reason as Phase 8: watching real tracks. With
|
||||
cuts every fifteen seconds the next problem became obvious — the images being cut between had
|
||||
nothing in common but the palette. That is a slideshow, not a music video.
|
||||
|
||||
What a music video actually shares across its shots is a location, a cast, a camera operator
|
||||
and an art direction. So each track now generates a **personality** in four traits, once, off
|
||||
the look seed:
|
||||
|
||||
| trait | what it fixes | how a scene expresses it |
|
||||
|---|---|---|
|
||||
| `shape` | the cast | a signature form — round, or an n-gon at a tilt — stamped wherever a scene draws elements |
|
||||
| `camera` | the operator | one slow returning pan, sway, roll and bar-locked breath, applied to the coordinate a scene works in |
|
||||
| `space` | the location | a shared horizon height, depth falloff and background wash |
|
||||
| `style` | the art direction | line weight, edge softness, surface grain, fold count |
|
||||
|
||||
The traits reach shaders as ordinary uniforms plus four helper functions in the contract
|
||||
(`sigShape`, `sigCamera`, `sigAir`, `sigEdge`/`sigGrain`), so a scene honours a trait in its own
|
||||
way — the rings of Classic Wave become hexagonal, Metaballs merge as hexagons, Floating
|
||||
Geometry stops choosing between a box and a circle because the production already decided.
|
||||
|
||||
The part that makes it a design rather than a filter: each scene DECLARES which traits it
|
||||
honours, each track is built on one or two of them, and **a scene that does not honour all of
|
||||
them is not cast in that track**. The library shrinks per track on purpose. A scene with no way
|
||||
to draw a hexagon should not appear in the hexagon video.
|
||||
|
||||
**Gate:**
|
||||
- Personality reproduces exactly from the seed, and differs between seeds.
|
||||
- Every trait has at least six scenes, or a track built on it could not fill its rosters.
|
||||
- No scene is ever cast in a track it does not honour, and no signature starves a track below
|
||||
three distinct scenes (the fallback to a one-trait signature exists for this).
|
||||
- **Every declared trait visibly changes the scene that declares it.** The lint proves a scene
|
||||
mentions a trait; only rendering proves it matters. Measured as a frame delta against a
|
||||
one-LSB floor — the tolerance the determinism checks already call noise.
|
||||
- Changing the personality moves every scene in a track's cast, not just one.
|
||||
- A layer with no personality renders exactly what it rendered before the phase existed, which
|
||||
is what keeps every earlier sweep and regression valid.
|
||||
- **Watch the battery.** The target is that a viewer could name the through-line on a second
|
||||
viewing — not the first. No metric expresses that.
|
||||
|
||||
---
|
||||
|
||||
## 10. Detachment from `party-stage`
|
||||
|
||||
@ -60,6 +60,9 @@ export class Show {
|
||||
this.engine.timeline.setDuration(this.track.duration);
|
||||
this.engine.setFeatureProvider(featureProviderFor(this.track));
|
||||
|
||||
report('scenes', 0.99);
|
||||
this.prewarm();
|
||||
|
||||
report('ready', 1);
|
||||
return this;
|
||||
}
|
||||
@ -83,12 +86,14 @@ export class Show {
|
||||
|
||||
reroll(seed) {
|
||||
this.setLook(rerollLook(this.look, this.track, seed));
|
||||
this.prewarm();
|
||||
}
|
||||
|
||||
rerollSection(index, salt) {
|
||||
rerollSection(this.look, this.track, index, salt);
|
||||
this.arc.invalidateSection(index);
|
||||
this._lastLayers = null;
|
||||
this.prewarm();
|
||||
}
|
||||
|
||||
setPalette(palette) {
|
||||
@ -140,8 +145,23 @@ export class Show {
|
||||
return this.engine.compositor.render({ timeline, features });
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and compile every layer in the look up front.
|
||||
*
|
||||
* Without this the first frame of every shot pays for a shader link, which
|
||||
* shows as a hitch exactly on the cut. One pass at load costs a few hundred
|
||||
* milliseconds and removes all of them.
|
||||
*/
|
||||
prewarm() {
|
||||
if (!this.arc) return this;
|
||||
this.arc.prewarm();
|
||||
this.engine.compositor.primeLayers([...this.arc.layerCache.values()]);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** See Engine.prime — required before frame-exact rendering. */
|
||||
prime(frame = 0) {
|
||||
this.prewarm();
|
||||
this.renderFrame(frame); // ensures the arc has built its layers
|
||||
this.engine.prime(frame);
|
||||
return this;
|
||||
|
||||
@ -9,6 +9,8 @@ import './phase4.js';
|
||||
import './phase5.js';
|
||||
import './phase6.js';
|
||||
import './phase7.js';
|
||||
import './phase8.js';
|
||||
import './phase9.js';
|
||||
|
||||
const out = document.getElementById('results');
|
||||
const summaryEl = document.getElementById('summary');
|
||||
|
||||
@ -13,6 +13,7 @@ import { defaultValues, sweepValues, validateModule } from '../params/schema.js'
|
||||
import { serializeParams, deserializeParams } from '../params/serialize.js';
|
||||
import { ParamPanel } from '../ui/ParamPanel.js';
|
||||
import { frameLuminance, frameVariance } from '../engine/hash.js';
|
||||
import { AUDIO_UNIFORMS, SIGNATURE_UNIFORMS } from '../engine/shader-contract.js';
|
||||
import { featureProviderFor } from '../audio/FeatureTrack.js';
|
||||
import { testTrack } from './phase1.js';
|
||||
|
||||
@ -43,13 +44,14 @@ check(2, 'every scene schema validates', () => {
|
||||
});
|
||||
|
||||
check(2, 'declared uniforms and shader sources agree both ways', () => {
|
||||
// Derived from the contract rather than retyped: the signature uniforms
|
||||
// arrived in Phase 9 and a hand-maintained copy of this list would have
|
||||
// reported every scene that reads one as reading an undeclared uniform.
|
||||
const CONTRACT = new Set([
|
||||
'u_resolution', 'u_aspect', 'u_pixelScale', 'u_time', 'u_frame', 'u_progress',
|
||||
'u_seed', 'u_opacity', 'u_colors', 'u_colorCount', 'u_prev', 'u_hasPrev',
|
||||
'u_loudness', 'u_rms', 'u_bandSub', 'u_bandLow', 'u_bandMid', 'u_bandHigh',
|
||||
'u_bandAir', 'u_flux', 'u_centroid', 'u_flatness', 'u_width', 'u_beat',
|
||||
'u_beatPhase', 'u_barPhase', 'u_phrasePhase', 'u_sectionProgress',
|
||||
'u_sectionEnergy', 'u_buildSlope',
|
||||
...AUDIO_UNIFORMS,
|
||||
...Object.keys(SIGNATURE_UNIFORMS),
|
||||
]);
|
||||
const problems = [];
|
||||
for (const module of scenes) {
|
||||
|
||||
@ -31,11 +31,14 @@ function makeShow(seed = 2024, width = 160, height = 90) {
|
||||
return show;
|
||||
}
|
||||
|
||||
check(4, 'the scene changes only at section boundaries', () => {
|
||||
check(4, 'the scene changes only on a planned cut', () => {
|
||||
// Since Phase 8 a cut is a SHOT boundary, not only a section boundary — a
|
||||
// section rotates between its stage visuals. What must still hold is that no
|
||||
// change happens anywhere the look did not plan one.
|
||||
const show = makeShow();
|
||||
try {
|
||||
const track = show.track;
|
||||
const boundaries = track.sections.map((s) => s.startFrame);
|
||||
const boundaries = show.arc.cues.map((c) => c.startFrame);
|
||||
const changes = [];
|
||||
let previous = null;
|
||||
|
||||
@ -48,8 +51,9 @@ check(4, 'the scene changes only at section boundaries', () => {
|
||||
|
||||
const stray = changes.filter((f) => !boundaries.some((b) => Math.abs(f - b) <= 10));
|
||||
return expect(stray.length === 0,
|
||||
`${changes.length} scene change(s), ${stray.length} away from a boundary · ` +
|
||||
`${track.sections.length} sections: ${track.sections.map((s) => s.kind).join(', ')}`);
|
||||
`${changes.length} scene change(s), ${stray.length} away from a cut · ` +
|
||||
`${track.sections.length} sections, ${boundaries.length} shots: ` +
|
||||
track.sections.map((s) => s.kind).join(', '));
|
||||
} finally {
|
||||
show.dispose();
|
||||
}
|
||||
@ -72,48 +76,70 @@ check(4, 'transitions produce no pops or black frames', () => {
|
||||
|
||||
let previous = null;
|
||||
let peak = 0;
|
||||
let peakAt = start;
|
||||
let darkest = 1;
|
||||
for (let f = start; f < Math.min(end, track.frameCount); f++) {
|
||||
const pixels = Uint8Array.from(show.readPixels(show.renderFrame(f)));
|
||||
darkest = Math.min(darkest, frameLuminance(pixels));
|
||||
if (previous) peak = Math.max(peak, frameDistance(previous, pixels));
|
||||
if (previous) {
|
||||
const d = frameDistance(previous, pixels);
|
||||
if (d > peak) { peak = d; peakAt = f; }
|
||||
}
|
||||
previous = pixels;
|
||||
}
|
||||
return { peak, darkest };
|
||||
return { peak, peakAt, darkest };
|
||||
};
|
||||
|
||||
// The control must sit in the SAME scenes the boundary window contains.
|
||||
// Scenes differ enormously in inherent frame-to-frame motion — one busy
|
||||
// scene next to a calm one reads as an 8x "spike" against a control taken
|
||||
// from the calm one, with no cut anywhere near it.
|
||||
const interior = (section) => {
|
||||
const mid = section.startFrame + Math.floor((section.endFrame - section.startFrame) / 2);
|
||||
return scan(mid, Math.min(mid + 200, section.endFrame));
|
||||
//
|
||||
// Since Phase 8 that means the same SHOT, not merely the same section: a
|
||||
// section rotates between two or three visuals, and the middle of the
|
||||
// section is often not the visual that is on screen at the boundary.
|
||||
const interior = (cue) => {
|
||||
const mid = cue.startFrame + Math.floor((cue.endFrame - cue.startFrame) / 2);
|
||||
return scan(mid, Math.min(mid + 200, cue.endFrame));
|
||||
};
|
||||
|
||||
const cueAt = (frame) => show.arc.cueAt(frame);
|
||||
|
||||
let worstRatio = 0;
|
||||
let worstBoundary = -1;
|
||||
let darkest = 1;
|
||||
let controlUsed = 0;
|
||||
let worstPeakAt = -1;
|
||||
|
||||
for (let i = 1; i < track.sections.length; i++) {
|
||||
const s = track.sections[i];
|
||||
const before = interior(track.sections[i - 1]);
|
||||
const after = interior(s);
|
||||
const before = interior(cueAt(s.startFrame - 1));
|
||||
const after = interior(cueAt(s.startFrame));
|
||||
const control = Math.max(before.peak, after.peak);
|
||||
|
||||
const w = scan(s.startFrame - 60, s.startFrame + show.arc.crossfadeFrames + 60);
|
||||
// The window starts just before the boundary rather than a second
|
||||
// before it. The transition runs FORWARD from the boundary, so a
|
||||
// handful of pre-frames is all it takes to catch a pop on the
|
||||
// boundary frame itself — while a longer pre-roll would drag in the
|
||||
// lookahead build ramp, where a scene is deliberately driven to the
|
||||
// top of its range and flashes accordingly. That is the intended
|
||||
// climax of a build, not a transition fault, and the control window
|
||||
// (mid-shot, no ramp) has no equivalent to cancel it against.
|
||||
const w = scan(s.startFrame - 8, s.startFrame + cueAt(s.startFrame).fadeFrames + 60);
|
||||
darkest = Math.min(darkest, w.darkest, before.darkest, after.darkest);
|
||||
|
||||
const ratio = w.peak / Math.max(control, 1e-6);
|
||||
if (ratio > worstRatio) { worstRatio = ratio; worstBoundary = s.startFrame; controlUsed = control; }
|
||||
if (ratio > worstRatio) {
|
||||
worstRatio = ratio; worstBoundary = s.startFrame; controlUsed = control;
|
||||
worstPeakAt = w.peakAt;
|
||||
}
|
||||
}
|
||||
|
||||
if (worstBoundary < 0) return expect(true, 'single-section track');
|
||||
|
||||
return expect(worstRatio < 1.6 && darkest > 0.002,
|
||||
`worst boundary peak ${worstRatio.toFixed(2)}x the adjacent scenes' own peak ` +
|
||||
`(control ${controlUsed.toFixed(4)}) at frame ${worstBoundary}, darkest ${darkest.toFixed(4)}`);
|
||||
`(control ${controlUsed.toFixed(4)}) at boundary ${worstBoundary}, peak frame ${worstPeakAt}, darkest ${darkest.toFixed(4)}`);
|
||||
} finally {
|
||||
show.dispose();
|
||||
}
|
||||
@ -126,8 +152,11 @@ check(4, 'crossfade ramps rather than cuts', () => {
|
||||
const boundary = track.sections[1] && track.sections[1].startFrame;
|
||||
if (!boundary) return expect(true, 'single-section track, nothing to cross-fade');
|
||||
|
||||
// Read the length off the cue rather than assuming the nominal one: since
|
||||
// Phase 8 a dissolve is two bars on calm material and one on loud.
|
||||
const fadeFrames = show.arc.cueAt(boundary).fadeFrames;
|
||||
const samples = [];
|
||||
for (let f = boundary; f < boundary + show.arc.crossfadeFrames; f += 2) {
|
||||
for (let f = boundary; f < boundary + fadeFrames; f += 2) {
|
||||
show.arc.update(f, track.at(f));
|
||||
samples.push(show.arc.state.crossfade);
|
||||
}
|
||||
@ -135,7 +164,7 @@ check(4, 'crossfade ramps rather than cuts', () => {
|
||||
const spans = samples[0] < 0.15 && samples[samples.length - 1] > 0.85;
|
||||
|
||||
return expect(monotonic && spans,
|
||||
`${show.arc.crossfadeFrames}-frame fade, monotonic ${monotonic}, ` +
|
||||
`${fadeFrames}-frame fade, monotonic ${monotonic}, ` +
|
||||
`${samples[0].toFixed(2)}→${samples[samples.length - 1].toFixed(2)}`);
|
||||
} finally {
|
||||
show.dispose();
|
||||
@ -177,13 +206,15 @@ check(4, 'lookahead ramps params into a higher-energy section', () => {
|
||||
}
|
||||
});
|
||||
|
||||
check(4, 'params drift within a long section', () => {
|
||||
// Guards the failure mode automated checks are worst at: a section that is
|
||||
// technically correct and completely static.
|
||||
check(4, 'params drift within a held shot', () => {
|
||||
// Guards the failure mode automated checks are worst at: an image that is
|
||||
// technically correct and completely static. Measured over the longest SHOT
|
||||
// rather than the longest section — across a shot cut the scene itself
|
||||
// changes, which would pass this trivially and prove nothing about drift.
|
||||
const show = makeShow();
|
||||
try {
|
||||
const track = show.track;
|
||||
const longest = track.sections.reduce((a, b) =>
|
||||
const longest = show.arc.cues.reduce((a, b) =>
|
||||
(b.endFrame - b.startFrame > a.endFrame - a.startFrame ? b : a));
|
||||
|
||||
const sample = (frame) => {
|
||||
@ -200,7 +231,7 @@ check(4, 'params drift within a long section', () => {
|
||||
|
||||
return expect(moved.length >= Math.ceil(numeric.length * 0.5),
|
||||
`${moved.length}/${numeric.length} params moved across a ` +
|
||||
`${((longest.endFrame - longest.startFrame) / 60).toFixed(0)}s section`);
|
||||
`${((longest.endFrame - longest.startFrame) / 60).toFixed(0)}s shot`);
|
||||
} finally {
|
||||
show.dispose();
|
||||
}
|
||||
|
||||
@ -174,9 +174,13 @@ check(5, 'generated looks stay within the flash-rate ceiling', () => {
|
||||
const show = new Show({ width: 96, height: 54 });
|
||||
try {
|
||||
show.useTrack(track, generateLook(track, { seed: 5000 + s * 7919 }));
|
||||
for (const section of track.sections) {
|
||||
const start = section.startFrame + 60;
|
||||
const end = Math.min(section.endFrame, start + 300); // 5 seconds
|
||||
// Per SHOT, not per section: a section rotates between two or three
|
||||
// stage visuals and only the first of them sits at the section start,
|
||||
// so sweeping sections would leave most of what ships unmeasured. The
|
||||
// window opens before the cut so the cut itself is inside it.
|
||||
for (const cue of show.arc.cues.slice(0, 8)) {
|
||||
const start = Math.max(0, cue.startFrame - 20);
|
||||
const end = Math.min(cue.endFrame, start + 300); // 5 seconds
|
||||
if (end - start < 120) continue;
|
||||
|
||||
show.engine.compositor.reset();
|
||||
@ -187,7 +191,8 @@ check(5, 'generated looks stay within the flash-rate ceiling', () => {
|
||||
luminance.push(frameLuminance(show.readPixels(show.renderFrame(f))));
|
||||
}
|
||||
const rate = peakFlashRate(luminance, 60);
|
||||
const scene = show.look.sections[section.index].layers[0].module.name;
|
||||
const section = show.look.sections[cue.sectionIndex];
|
||||
const scene = (section.variants[cue.variant] || section.layers)[0].module.name;
|
||||
const label = `seed ${s} ${section.kind}/${scene}`;
|
||||
if (rate > worst) { worst = rate; worstLabel = label; }
|
||||
if (rate > 3) problems.push(`${label}: ${rate}/s`);
|
||||
|
||||
@ -12,7 +12,7 @@ import { generateLook } from '../look/LookGenerator.js';
|
||||
import { FeatureTrack } from '../audio/FeatureTrack.js';
|
||||
import { synthesizeSectioned } from '../audio/synth.js';
|
||||
import { Exporter, isSupported, PRESETS } from '../export/Exporter.js';
|
||||
import { frameDistance } from '../engine/hash.js';
|
||||
import { frameDistance, frameMaxDelta } from '../engine/hash.js';
|
||||
|
||||
let cached = null;
|
||||
function track6() {
|
||||
@ -70,21 +70,42 @@ check(6, 'audio-clock preview and frame-counted export agree', () => {
|
||||
const start = 600;
|
||||
const count = 40;
|
||||
|
||||
// Prime first, as BOTH real paths do — the exporter calls prime() and the
|
||||
// preview prewarms on load. Without it the first pass here renders through
|
||||
// programs that are still linking and the first few frames come back
|
||||
// different, which is the hazard Compositor.prime() documents rather than
|
||||
// anything about preview versus export. Measured: unprimed, frames 0/2/3
|
||||
// of the first pass differed and every later pass was identical.
|
||||
show.prime(start);
|
||||
|
||||
show.engine.compositor.reset();
|
||||
const preview = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
show.timeline.syncToAudio((start + i) / show.fps);
|
||||
preview.push(show.engine.hashCurrent(show.renderFrame(show.timeline.frame)));
|
||||
preview.push(Uint8Array.from(show.readPixels(show.renderFrame(show.timeline.frame))));
|
||||
}
|
||||
|
||||
show.engine.compositor.reset();
|
||||
const exported = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
exported.push(show.engine.hashCurrent(show.renderFrame(start + i)));
|
||||
exported.push(Uint8Array.from(show.readPixels(show.renderFrame(start + i))));
|
||||
}
|
||||
|
||||
const mismatches = preview.filter((h, i) => h !== exported[i]).length;
|
||||
return expect(mismatches === 0, `${mismatches}/${count} frames differed`);
|
||||
// Judged on a one-LSB tolerance rather than bit-exact hashes, for the same
|
||||
// reason Phase 7's determinism check is (see PLAN.md §1): the first Show
|
||||
// built in a fresh context comes back with a handful of pixels differing
|
||||
// by 1/255 from the second render of the same frames, and every later
|
||||
// Show in the same context is bit-exact. Measured over four consecutive
|
||||
// shows: 3 frames at delta 1, then 0, 0, 0. That is GPU float variance
|
||||
// under differing load, and demanding bit-exactness of the very first
|
||||
// render is demanding something the hardware does not offer. A real
|
||||
// preview/export divergence — a different scene, a different param, a
|
||||
// frame off by one — scores in the tens or hundreds here, not 1.
|
||||
const deltas = preview.map((f, i) => frameMaxDelta(f, exported[i]));
|
||||
const worst = Math.max(...deltas);
|
||||
return expect(worst <= 1,
|
||||
`${deltas.filter((d) => d > 1).length}/${count} frames differed visibly ` +
|
||||
`· worst channel delta ${worst}`);
|
||||
} finally {
|
||||
show.dispose();
|
||||
}
|
||||
|
||||
225
flow-state/src/checks/phase8.js
Normal file
225
flow-state/src/checks/phase8.js
Normal file
@ -0,0 +1,225 @@
|
||||
// Phase 8 gate — shots.
|
||||
//
|
||||
// The problem this phase exists to fix is measurable, so the gate measures it:
|
||||
// how long does the same image stay on screen? Everything else here guards the
|
||||
// ways more cuts could go wrong — cuts off the bar grid, cuts so fast they
|
||||
// become a strobe, or a rotation so busy the section loses its identity.
|
||||
|
||||
import { check, expect, expectBelow } from './framework.js';
|
||||
import { Show } from '../Show.js';
|
||||
import { FeatureTrack } from '../audio/FeatureTrack.js';
|
||||
import { synthesizeSectioned } from '../audio/synth.js';
|
||||
import { generateLook } from '../look/LookGenerator.js';
|
||||
import { MIN_SHOT_SECONDS, MAX_SHOT_SECONDS, HARD_CUT_ENERGY } from '../look/shots.js';
|
||||
import { frameDistance } from '../engine/hash.js';
|
||||
|
||||
let cached = null;
|
||||
function track8() {
|
||||
if (!cached) {
|
||||
cached = FeatureTrack.fromAudioBuffer(
|
||||
synthesizeSectioned({ bpm: 128, duration: 240, changeAt: 90 }), { fps: 60 });
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
function looks(count = 8) {
|
||||
const track = track8();
|
||||
return Array.from({ length: count }, (_, i) => generateLook(track, { seed: 1000 + i * 7919 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* A rendered frame as an independent buffer. Renderer.readPixels hands back a
|
||||
* reused array, so comparing two of its results compares one frame with itself.
|
||||
*/
|
||||
function copyPixels(show, frame) {
|
||||
return Uint8Array.from(show.readPixels(show.renderFrame(frame)));
|
||||
}
|
||||
|
||||
/** Every shot in a look, flattened, in playback order. */
|
||||
function allShots(look) {
|
||||
return look.sections.flatMap((s) => (s.shots || []).map((shot) => ({ ...shot, section: s })));
|
||||
}
|
||||
|
||||
check(8, 'no image is held past the ceiling', () => {
|
||||
// The complaint that started the phase. A shot is the longest a single
|
||||
// image can stay up, so this is the whole fix expressed as a number.
|
||||
const fps = track8().fps;
|
||||
let worst = 0;
|
||||
let worstWhere = '';
|
||||
|
||||
for (const look of looks()) {
|
||||
for (const shot of allShots(look)) {
|
||||
const seconds = (shot.endFrame - shot.startFrame) / fps;
|
||||
if (seconds > worst) {
|
||||
worst = seconds;
|
||||
worstWhere = `${shot.section.kind} shot ${shot.index}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return expectBelow(worst, MAX_SHOT_SECONDS + 0.05, `longest held image ${worstWhere}`);
|
||||
});
|
||||
|
||||
check(8, 'shots are not shorter than the floor', () => {
|
||||
// The other end of the same axis: a cut every two seconds is not editing,
|
||||
// it is a strobe, and the flash meter would be the next thing to complain.
|
||||
const fps = track8().fps;
|
||||
let shortest = Infinity;
|
||||
for (const look of looks()) {
|
||||
for (const shot of allShots(look)) {
|
||||
shortest = Math.min(shortest, (shot.endFrame - shot.startFrame) / fps);
|
||||
}
|
||||
}
|
||||
return expect(shortest >= MIN_SHOT_SECONDS - 0.05,
|
||||
`shortest shot ${shortest.toFixed(2)}s (floor ${MIN_SHOT_SECONDS}s)`);
|
||||
});
|
||||
|
||||
check(8, 'cuts land on the bar grid', () => {
|
||||
// A cut that lands between phrases reads as a mistake even when the image
|
||||
// is good, so this is a quality gate rather than a correctness one.
|
||||
const track = track8();
|
||||
const barSeconds = (track.tempo.period * track.tempo.beatsPerBar) / track.fps;
|
||||
let total = 0;
|
||||
let onGrid = 0;
|
||||
|
||||
for (const look of looks()) {
|
||||
for (const shot of allShots(look)) {
|
||||
if (shot.index === 0) continue; // section boundary, snapped upstream
|
||||
total++;
|
||||
const time = shot.startFrame / track.fps;
|
||||
const nearest = track.tempo.downbeats.reduce(
|
||||
(best, d) => Math.min(best, Math.abs(d - time)), Infinity);
|
||||
if (nearest <= barSeconds * 0.26) onGrid++;
|
||||
}
|
||||
}
|
||||
const ratio = total ? onGrid / total : 1;
|
||||
return expect(ratio >= 0.9, `${onGrid}/${total} intra-section cuts within a quarter-bar`);
|
||||
});
|
||||
|
||||
check(8, 'a section keeps its identity across its shots', () => {
|
||||
// Shots must not become a shuffle. A section rotates between three or four
|
||||
// visuals, the anchor opens it, and the anchor keeps coming back.
|
||||
//
|
||||
// "Keeps coming back" is deliberately not "is strictly the most shown". The
|
||||
// rotation is random with a bias, and on a nine-shot section a companion can
|
||||
// legitimately edge the anchor by one without the section losing its centre.
|
||||
// Demanding a strict maximum would either fail on ordinary seeds or force a
|
||||
// rigid A-B-A-C pattern, which is audible as a pattern within three cycles.
|
||||
const problems = [];
|
||||
for (const look of looks()) {
|
||||
for (const section of look.sections) {
|
||||
const shots = section.shots || [];
|
||||
if (!shots.length) continue;
|
||||
const counts = section.variants.map((_, v) => shots.filter((s) => s.variant === v).length);
|
||||
if (section.variants.length > 4) problems.push(`${section.kind}: ${section.variants.length} variants`);
|
||||
if (shots[0].variant !== 0) problems.push(`${section.kind}: opens on variant ${shots[0].variant}`);
|
||||
if (counts[0] < Math.max(...counts) - 1) {
|
||||
problems.push(`${section.kind}: anchor shown ${counts[0]}x against ${Math.max(...counts)}x`);
|
||||
}
|
||||
for (let i = 1; i < shots.length; i++) {
|
||||
if (shots[i].variant === shots[i - 1].variant) {
|
||||
problems.push(`${section.kind}: repeats variant ${shots[i].variant} back to back`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return expect(problems.length === 0, problems.slice(0, 4).join(' · ') || 'rosters well formed');
|
||||
});
|
||||
|
||||
check(8, 'the same section kind reuses the same roster', () => {
|
||||
// The identity rule one level up: two drops cut between the same visuals.
|
||||
const problems = [];
|
||||
for (const look of looks()) {
|
||||
const byKind = new Map();
|
||||
for (const section of look.sections) {
|
||||
const roster = (section.variants || [section.layers]).map((v) => v[0].module.name).join('+');
|
||||
const seen = byKind.get(section.kind);
|
||||
if (seen && seen !== roster) problems.push(`${section.kind}: ${seen} vs ${roster}`);
|
||||
byKind.set(section.kind, roster);
|
||||
}
|
||||
}
|
||||
return expect(problems.length === 0, problems.slice(0, 3).join(' · ') || 'rosters stable per kind');
|
||||
});
|
||||
|
||||
check(8, 'dissolves are the default and straight cuts are reserved for energy', () => {
|
||||
// A cut on calm material reads as a glitch rather than as an edit, so the
|
||||
// policy is: nothing below the threshold ever cuts. This also guards the
|
||||
// pacing from the other side — if every transition became a hard cut the
|
||||
// video would feel like a slideshow, and no other check would notice.
|
||||
const problems = [];
|
||||
let cuts = 0;
|
||||
let dissolves = 0;
|
||||
|
||||
for (const look of looks()) {
|
||||
for (const section of look.sections) {
|
||||
for (const shot of section.shots || []) {
|
||||
if (shot.index === 0) continue;
|
||||
if (shot.hardCut) {
|
||||
cuts++;
|
||||
if (section.bias.energy <= HARD_CUT_ENERGY) {
|
||||
problems.push(`${section.kind} cuts at energy ${section.bias.energy.toFixed(2)}`);
|
||||
}
|
||||
} else {
|
||||
dissolves++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return expect(problems.length === 0 && dissolves > cuts,
|
||||
problems.slice(0, 3).join(' · ') || `${dissolves} dissolves, ${cuts} cuts`);
|
||||
});
|
||||
|
||||
check(8, 'a section rotates through more than two visuals when it is long enough', () => {
|
||||
// The roster is only worth having if the shots reach it. Sections with four
|
||||
// or more shots must show at least three distinct visuals — otherwise the
|
||||
// rotation has collapsed back to A/B, which is the complaint this sizing
|
||||
// was meant to answer.
|
||||
const problems = [];
|
||||
let checked = 0;
|
||||
|
||||
for (const look of looks()) {
|
||||
for (const section of look.sections) {
|
||||
const shots = section.shots || [];
|
||||
if (shots.length < 4 || section.variants.length < 3) continue;
|
||||
checked++;
|
||||
const distinct = new Set(shots.map((s) => s.variant)).size;
|
||||
if (distinct < 3) {
|
||||
problems.push(`${section.kind}: ${shots.length} shots, only ${distinct} visuals`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!checked) return expect(true, 'no section long enough to rotate');
|
||||
return expect(problems.length === 0,
|
||||
problems.slice(0, 3).join(' · ') || `${checked} long section(s) all reached 3+ visuals`);
|
||||
});
|
||||
|
||||
check(8, 'shot cuts do not produce a black frame or a jump cut to nothing', () => {
|
||||
// A cut is a short crossfade, not a swap. Rendered either side of every
|
||||
// intra-section cut, consecutive frames must still be continuous enough that
|
||||
// nothing goes black — the failure mode of getting the layer bookkeeping
|
||||
// wrong is one empty frame, which is invisible in review and obvious in an
|
||||
// export.
|
||||
const show = new Show({ width: 160, height: 90 });
|
||||
try {
|
||||
const track = track8();
|
||||
show.useTrack(track, generateLook(track, { seed: 4242 }));
|
||||
|
||||
const cuts = show.arc.cues.filter((c) => !c.atSectionStart).slice(0, 6);
|
||||
if (!cuts.length) return expect(false, 'no intra-section cuts were planned');
|
||||
|
||||
let worst = 0;
|
||||
let worstAt = 0;
|
||||
for (const cue of cuts) {
|
||||
show.seek(Math.max(0, cue.startFrame - 4));
|
||||
let previous = copyPixels(show, cue.startFrame - 3);
|
||||
for (let f = cue.startFrame - 2; f <= cue.startFrame + cue.fadeFrames + 2; f++) {
|
||||
const pixels = copyPixels(show, f);
|
||||
const d = frameDistance(previous, pixels);
|
||||
if (d > worst) { worst = d; worstAt = f; }
|
||||
previous = pixels;
|
||||
}
|
||||
}
|
||||
return expectBelow(worst, 0.35, `largest frame-to-frame delta across ${cuts.length} cuts @${worstAt}`);
|
||||
} finally {
|
||||
show.dispose();
|
||||
}
|
||||
}, { slow: true });
|
||||
224
flow-state/src/checks/phase9.js
Normal file
224
flow-state/src/checks/phase9.js
Normal file
@ -0,0 +1,224 @@
|
||||
// Phase 9 gate — the production design.
|
||||
//
|
||||
// Phase 8 gave a track more cuts. Watching the result made the next problem
|
||||
// obvious: the cuts were between images that had nothing in common but their
|
||||
// palette, which is a slideshow, not a video. Phase 9 gives every track a
|
||||
// personality — a signature form, a camera, a location and an art direction —
|
||||
// and requires scenes to express it or sit the track out. See look/Personality.js.
|
||||
//
|
||||
// The gate has to answer three questions. Is the personality reproducible? Is
|
||||
// the casting rule actually enforced? And — the one that matters — does any of
|
||||
// it reach the screen, or are sixteen scenes quietly ignoring the uniforms?
|
||||
|
||||
import { check, expect } from './framework.js';
|
||||
import { Engine } from '../engine/Engine.js';
|
||||
import { scenes } from '../scenes/registry.js';
|
||||
import { defaultValues } from '../params/schema.js';
|
||||
import { TRAITS, generatePersonality, sceneHonours, MIN_ELIGIBLE_SCENES } from '../look/Personality.js';
|
||||
import { generateLook } from '../look/LookGenerator.js';
|
||||
import { Rng } from '../engine/rng.js';
|
||||
import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js';
|
||||
import { synthesizeSectioned } from '../audio/synth.js';
|
||||
import { frameMaxDelta } from '../engine/hash.js';
|
||||
|
||||
const PALETTE = [
|
||||
[0.06, 0.03, 0.16], [0.85, 0.15, 0.55], [0.15, 0.75, 0.95],
|
||||
[0.98, 0.85, 0.35], [0.55, 0.25, 0.85], [0.2, 0.95, 0.6],
|
||||
];
|
||||
|
||||
let cached = null;
|
||||
function track9() {
|
||||
if (!cached) {
|
||||
cached = FeatureTrack.fromAudioBuffer(
|
||||
synthesizeSectioned({ bpm: 124, duration: 150, changeAt: 70 }), { fps: 60 });
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
function looks(count = 8) {
|
||||
const track = track9();
|
||||
return Array.from({ length: count }, (_, i) => generateLook(track, { seed: 3000 + i * 6841 }));
|
||||
}
|
||||
|
||||
const castOf = (look) => look.sections.flatMap((s) => (s.variants || [s.layers]).map((v) => v[0].module));
|
||||
|
||||
/** Two personalities differing in exactly one trait, for the "does it show" checks. */
|
||||
function pairDifferingIn(trait) {
|
||||
const a = generatePersonality(summaryStub(), new Rng(11));
|
||||
const b = generatePersonality(summaryStub(), new Rng(11));
|
||||
switch (trait) {
|
||||
case 'shape':
|
||||
b.shape = { sides: 6, roundness: 0.05, elongation: 1.3, tilt: 0.7 };
|
||||
a.shape = { sides: 0, roundness: 0.5, elongation: 1.0, tilt: 0.0 };
|
||||
break;
|
||||
case 'camera':
|
||||
b.camera = { ...a.camera, driftAngle: 1.1, driftRate: 0.06, sway: 0.06, swayRate: 0.2, spin: 0.05, breathe: 0.05 };
|
||||
a.camera = { ...a.camera, driftAngle: 0, driftRate: 0, sway: 0, swayRate: 0.1, spin: 0, breathe: 0 };
|
||||
break;
|
||||
case 'space':
|
||||
a.space = { horizon: 0.34, depth: 0.1, washAngle: 0, wash: 0.1 };
|
||||
b.space = { horizon: 0.66, depth: 0.9, washAngle: 2.4, wash: 0.5 };
|
||||
break;
|
||||
case 'style':
|
||||
a.style = { lineWeight: 0.15, softness: 0.2, texture: 0.0, symmetry: 1 };
|
||||
b.style = { lineWeight: 0.95, softness: 0.9, texture: 0.5, symmetry: 4 };
|
||||
break;
|
||||
}
|
||||
return [a, b];
|
||||
}
|
||||
|
||||
function summaryStub() {
|
||||
return { meanCentroid: 0.5, meanFlatness: 0.2, bpm: 124 };
|
||||
}
|
||||
|
||||
function makeEngine(width = 160, height = 90) {
|
||||
const engine = new Engine({ width, height });
|
||||
const track = track9();
|
||||
engine.timeline.setDuration(track.duration);
|
||||
engine.setFeatureProvider(featureProviderFor(track));
|
||||
return engine;
|
||||
}
|
||||
|
||||
/** Render one scene twice under two personalities and report the largest difference. */
|
||||
function deltaUnder(module, a, b, engine) {
|
||||
const spec = {
|
||||
module, params: defaultValues(module), seed: 4242,
|
||||
opacity: 1, blend: 'normal', palette: PALETTE,
|
||||
};
|
||||
const capture = (personality) => {
|
||||
engine.setLayerSpecs([{ ...spec, personality }]);
|
||||
engine.prime(420);
|
||||
engine.compositor.reset();
|
||||
return Uint8Array.from(engine.readPixels(engine.renderFrame(420)));
|
||||
};
|
||||
return frameMaxDelta(capture(a), capture(b));
|
||||
}
|
||||
|
||||
check(9, 'the personality is reproducible from the seed', () => {
|
||||
const track = track9();
|
||||
const a = generateLook(track, { seed: 77 }).personality;
|
||||
const b = generateLook(track, { seed: 77 }).personality;
|
||||
const c = generateLook(track, { seed: 78 }).personality;
|
||||
|
||||
const same = JSON.stringify(a) === JSON.stringify(b);
|
||||
const different = JSON.stringify(a) !== JSON.stringify(c);
|
||||
return expect(same && different,
|
||||
`same seed identical: ${same} · different seed differs: ${different} · ` +
|
||||
`signature ${a.signature.join('+')}`);
|
||||
});
|
||||
|
||||
check(9, 'every trait has enough scenes to build a track from', () => {
|
||||
// The casting rule only works if the library can staff it. A trait declared
|
||||
// by three scenes cannot carry a track — the rosters would collapse and every
|
||||
// section would show the same two images, which is Phase 8 undone.
|
||||
const counts = TRAITS.map((t) => [t, scenes.filter((m) => m.role !== 'accent'
|
||||
&& sceneHonours(m, [t])).length]);
|
||||
const thin = counts.filter(([, n]) => n < MIN_ELIGIBLE_SCENES);
|
||||
return expect(thin.length === 0,
|
||||
counts.map(([t, n]) => `${t}:${n}`).join(' ') +
|
||||
(thin.length ? ` — too thin: ${thin.map(([t]) => t).join(', ')}` : ''));
|
||||
});
|
||||
|
||||
check(9, 'no scene is cast in a track it cannot express', () => {
|
||||
// The whole point. A scene that ignores the trait a track is built on is the
|
||||
// shot that was obviously filmed somewhere else.
|
||||
const problems = [];
|
||||
for (const look of looks()) {
|
||||
const signature = look.personality.signature;
|
||||
for (const module of castOf(look)) {
|
||||
if (!sceneHonours(module, signature)) {
|
||||
problems.push(`${module.name} cast in a ${signature.join('+')} track`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return expect(problems.length === 0,
|
||||
[...new Set(problems)].slice(0, 4).join(' · ') || 'every scene honours its track');
|
||||
});
|
||||
|
||||
check(9, 'the signature still leaves a track enough scenes to cut between', () => {
|
||||
// The failure mode of a hard filter: a personality so specific that the
|
||||
// whole video is two scenes. The fallback in pickSignature exists for this,
|
||||
// and this is what proves it fires.
|
||||
const problems = [];
|
||||
for (const look of looks()) {
|
||||
const distinct = new Set(castOf(look).map((m) => m.name));
|
||||
if (distinct.size < 3) {
|
||||
problems.push(`${look.personality.signature.join('+')}: only ${distinct.size} scenes`);
|
||||
}
|
||||
}
|
||||
return expect(problems.length === 0, problems.slice(0, 3).join(' · ') || 'all casts 3+ scenes');
|
||||
});
|
||||
|
||||
check(9, 'every declared trait visibly changes the scene that declares it', () => {
|
||||
// The check that would have caught the whole thing being decorative. The
|
||||
// lint proves a scene MENTIONS the trait; only rendering proves it matters.
|
||||
// One LSB is the tolerance the determinism checks already treat as noise, so
|
||||
// anything at or under it counts as ignored.
|
||||
const engine = makeEngine();
|
||||
const problems = [];
|
||||
const measured = [];
|
||||
try {
|
||||
for (const trait of TRAITS) {
|
||||
const [a, b] = pairDifferingIn(trait);
|
||||
for (const module of scenes) {
|
||||
if (!(module.traits || []).includes(trait)) continue;
|
||||
if (module.kind !== 'fragment') continue; // 3D layers move the camera, not the frame
|
||||
const delta = deltaUnder(module, a, b, engine);
|
||||
measured.push(delta);
|
||||
if (delta <= 1) problems.push(`${module.name}/${trait}: delta ${delta}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
engine.dispose();
|
||||
}
|
||||
const worst = Math.min(...measured);
|
||||
return expect(problems.length === 0,
|
||||
problems.slice(0, 4).join(' · ') ||
|
||||
`${measured.length} scene/trait pairs, weakest response delta ${worst}`);
|
||||
}, { slow: true });
|
||||
|
||||
check(9, 'a personality changes the whole cast, not one scene', () => {
|
||||
// Coherence, measured the only way it can be: if the track's design reaches
|
||||
// every scene it cast, then changing the design changes every one of them.
|
||||
const engine = makeEngine();
|
||||
try {
|
||||
const track = track9();
|
||||
const look = generateLook(track, { seed: 4242 });
|
||||
const [a, b] = pairDifferingIn(look.personality.signature[0] || 'camera');
|
||||
|
||||
const cast = [...new Map(castOf(look).map((m) => [m.name, m])).values()]
|
||||
.filter((m) => m.kind === 'fragment');
|
||||
const deltas = cast.map((m) => [m.name, deltaUnder(m, a, b, engine)]);
|
||||
const unmoved = deltas.filter(([, d]) => d <= 1);
|
||||
|
||||
return expect(unmoved.length === 0,
|
||||
unmoved.length
|
||||
? `unmoved: ${unmoved.map(([n]) => n).join(', ')}`
|
||||
: `${deltas.length} cast scenes all respond · ` +
|
||||
deltas.map(([n, d]) => `${n} ${d}`).join(', '));
|
||||
} finally {
|
||||
engine.dispose();
|
||||
}
|
||||
}, { slow: true });
|
||||
|
||||
check(9, 'a layer with no personality renders what it always rendered', () => {
|
||||
// The neutral-default promise. Every range sweep, flash sweep and library
|
||||
// regression builds layers directly with no personality attached, and they
|
||||
// all compare against numbers recorded before this phase existed.
|
||||
const engine = makeEngine();
|
||||
try {
|
||||
const module = scenes.find((m) => m.kind === 'fragment' && (m.traits || []).length);
|
||||
const neutral = {
|
||||
shape: { sides: 0, roundness: 0.25, elongation: 1, tilt: 0 },
|
||||
camera: { driftAngle: 0, driftRate: 0, sway: 0, swayRate: 0.1, spin: 0, breathe: 0 },
|
||||
space: { horizon: 0.5, depth: 0, washAngle: 0, wash: 0 },
|
||||
style: { lineWeight: 0.5, softness: 0.5, texture: 0, symmetry: 1 },
|
||||
signature: [],
|
||||
};
|
||||
const delta = deltaUnder(module, null, neutral, engine);
|
||||
return expect(delta <= 1,
|
||||
`${module.name}: no-personality vs explicit-neutral delta ${delta}`);
|
||||
} finally {
|
||||
engine.dispose();
|
||||
}
|
||||
}, { slow: true });
|
||||
@ -135,6 +135,19 @@ export class Compositor {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile layers that are not on screen yet, so the frame they first appear
|
||||
* on does not pay for the link. Used by the arc driver's prewarm pass.
|
||||
*/
|
||||
primeLayers(layers) {
|
||||
for (const layer of layers) {
|
||||
if (this._primed.has(layer)) continue;
|
||||
this._primeLayer(layer);
|
||||
this._primed.add(layer);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a layer's shader program to finish linking before it is used for real.
|
||||
*
|
||||
|
||||
@ -57,6 +57,7 @@ export class Engine {
|
||||
const layers = specs.map((s) => {
|
||||
const layer = createLayer(s.module, s);
|
||||
if (s.palette) layer.setPalette(s.palette);
|
||||
if (s.personality) layer.setPersonality(s.personality);
|
||||
return layer;
|
||||
});
|
||||
this.ownedLayers = layers;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import * as THREE from 'three';
|
||||
import { VERTEX_SHADER, buildFragmentShader, AUDIO_UNIFORMS } from './shader-contract.js';
|
||||
import { VERTEX_SHADER, buildFragmentShader, AUDIO_UNIFORMS, SIGNATURE_UNIFORMS } from './shader-contract.js';
|
||||
import { signatureUniforms, NEUTRAL_UNIFORMS } from '../look/Personality.js';
|
||||
import { clampValue } from '../params/schema.js';
|
||||
|
||||
export const BLEND_MODES = ['normal', 'add', 'screen', 'multiply', 'overlay', 'softlight'];
|
||||
@ -28,6 +29,17 @@ export class Layer {
|
||||
this.opacity = opacity;
|
||||
this.blend = blend;
|
||||
this.palette = [];
|
||||
this.personality = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The track's production design. Constant for the whole video — see
|
||||
* look/Personality.js — and pushed in the same way the palette is, so a
|
||||
* layer never reaches for global state.
|
||||
*/
|
||||
setPersonality(personality) {
|
||||
this.personality = personality;
|
||||
return this;
|
||||
}
|
||||
|
||||
setParams(params) {
|
||||
@ -102,6 +114,10 @@ export class ShaderLayer extends Layer {
|
||||
u_hasPrev: { value: 0 },
|
||||
};
|
||||
for (const name of AUDIO_UNIFORMS) uniforms[name] = { value: 0 };
|
||||
for (const [name, type] of Object.entries(SIGNATURE_UNIFORMS)) {
|
||||
const v = NEUTRAL_UNIFORMS[name];
|
||||
uniforms[name] = { value: type === 'vec2' ? new THREE.Vector2(v[0], v[1]) : v };
|
||||
}
|
||||
|
||||
for (const [name, def] of Object.entries(this.module.params || {})) {
|
||||
if (!def.uniform || def.type === 'palette') continue;
|
||||
@ -152,6 +168,13 @@ export class ShaderLayer extends Layer {
|
||||
if (c) u.u_colors.value[i].set(c[0], c[1], c[2]);
|
||||
}
|
||||
|
||||
const signature = signatureUniforms(this.personality);
|
||||
for (const [name, type] of Object.entries(SIGNATURE_UNIFORMS)) {
|
||||
const v = signature[name];
|
||||
if (type === 'vec2') u[name].value.set(v[0], v[1]);
|
||||
else u[name].value = v;
|
||||
}
|
||||
|
||||
u.u_prev.value = prevTexture || null;
|
||||
u.u_hasPrev.value = prevTexture ? 1 : 0;
|
||||
|
||||
@ -212,6 +235,7 @@ export class SceneLayer extends Layer {
|
||||
features: features || {},
|
||||
params: resolved,
|
||||
palette: this.palette,
|
||||
personality: this.personality,
|
||||
opacity: this.opacity,
|
||||
THREE,
|
||||
});
|
||||
|
||||
@ -43,6 +43,38 @@ export const AUDIO_UNIFORMS = [
|
||||
'u_buildSlope', // >0 while energy is ramping toward the next section
|
||||
];
|
||||
|
||||
/**
|
||||
* The track's personality, constant for the whole video. See look/Personality.js.
|
||||
*
|
||||
* These are what make sixteen unrelated shaders read as one production. A scene
|
||||
* declares in `traits` which of them it honours, and the look generator will not
|
||||
* cast a scene that cannot express what the track is built on.
|
||||
*
|
||||
* All of them are neutral by default, so a layer built without a personality
|
||||
* renders exactly what it always rendered.
|
||||
*/
|
||||
export const SIGNATURE_UNIFORMS = {
|
||||
u_sigSides: 'float', // signature form: 0 = round, else polygon sides
|
||||
u_sigRound: 'float', // corner rounding of that form
|
||||
u_sigElong: 'float', // how far from square the form is
|
||||
u_sigTilt: 'float', // its resting angle
|
||||
|
||||
u_sigDrift: 'vec2', // camera translation per second
|
||||
u_sigSway: 'float', // camera sway amplitude
|
||||
u_sigSwayRate: 'float',
|
||||
u_sigSpin: 'float', // slow camera roll, radians per second
|
||||
u_sigBreathe: 'float', // bar-locked zoom
|
||||
|
||||
u_sigHorizon: 'float', // where the ground meets the sky, 0..1 up the frame
|
||||
u_sigDepth: 'float', // distance falloff
|
||||
u_sigWash: 'vec2', // background gradient direction and strength
|
||||
|
||||
u_sigLine: 'float', // line weight
|
||||
u_sigSoft: 'float', // edge softness
|
||||
u_sigTexture: 'float', // surface grain
|
||||
u_sigFold: 'float', // kaleidoscopic folds, 1 = none
|
||||
};
|
||||
|
||||
export const FRAME_UNIFORMS = [
|
||||
'u_time', 'u_frame', 'u_progress', 'u_seed',
|
||||
'u_resolution', 'u_aspect', 'u_pixelScale', 'u_opacity',
|
||||
@ -65,6 +97,8 @@ uniform int u_colorCount;
|
||||
|
||||
${AUDIO_UNIFORMS.map((u) => `uniform float ${u};`).join('\n')}
|
||||
|
||||
${Object.entries(SIGNATURE_UNIFORMS).map(([u, t]) => `uniform ${t} ${u};`).join('\n')}
|
||||
|
||||
uniform sampler2D u_prev;
|
||||
uniform int u_hasPrev;
|
||||
|
||||
@ -140,6 +174,86 @@ vec2 kaleido(vec2 p, float sides) {
|
||||
return vec2(cos(a), sin(a)) * r;
|
||||
}
|
||||
|
||||
// --- personality -----------------------------------------------------------
|
||||
// The four traits, as functions a scene applies in its own way. A scene that
|
||||
// calls none of these must not declare the matching trait, or it will be cast
|
||||
// in a track it cannot express. See look/Personality.js.
|
||||
|
||||
/**
|
||||
* TRAIT: shape. Signed distance to the track's signature form, radius ~1.
|
||||
* Round tracks return a circle, so a scene can call this unconditionally.
|
||||
*/
|
||||
float sigShape(vec2 q) {
|
||||
q = rot(u_sigTilt) * q;
|
||||
q.x /= max(u_sigElong, 0.05);
|
||||
|
||||
if (u_sigSides < 2.5) return length(q) - 1.0;
|
||||
|
||||
// Regular polygon by angular folding, then rounded back toward the circle.
|
||||
float seg = 6.28318530718 / u_sigSides;
|
||||
float a = atan(q.y, q.x);
|
||||
float r = length(q);
|
||||
float folded = cos(mod(a + seg * 0.5, seg) - seg * 0.5);
|
||||
float poly = r * folded - cos(seg * 0.5);
|
||||
return mix(poly, r - 1.0, clamp(u_sigRound, 0.0, 1.0));
|
||||
}
|
||||
|
||||
/** TRAIT: shape, as a filled mask of the given radius, centred on a point. */
|
||||
float sigForm(vec2 p, vec2 centre, float size) {
|
||||
float d = sigShape((p - centre) / max(size, 1e-3)) * max(size, 1e-3);
|
||||
return smoothstep(u_sigSoft * 0.25 + 0.004, -u_sigSoft * 0.25, d);
|
||||
}
|
||||
|
||||
/**
|
||||
* TRAIT: camera. The same operator filming every scene — a slow drift, a sway,
|
||||
* a roll, and a bar-locked breath. Apply to a centred coordinate before using it.
|
||||
*/
|
||||
vec2 sigCamera(vec2 p) {
|
||||
float t = u_time;
|
||||
p = rot(u_sigSpin * t) * p;
|
||||
p *= 1.0 - u_sigBreathe * sin(u_barPhase * 6.28318530718);
|
||||
p += vec2(sin(t * u_sigSwayRate), cos(t * u_sigSwayRate * 0.83)) * u_sigSway;
|
||||
// The pan RETURNS. A constant translation would be a camera on rails: two
|
||||
// minutes in, every scene has left the frame entirely. This is a slow track
|
||||
// across the subject and back, roughly a two-minute cycle.
|
||||
p -= u_sigDrift * 20.0 * sin(t * 0.05);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** TRAIT: style. Fold the frame the track's way. 1 fold means no fold. */
|
||||
vec2 sigFolded(vec2 p) {
|
||||
return kaleido(p, u_sigFold);
|
||||
}
|
||||
|
||||
/** TRAIT: style. Turn a signed distance into an edge drawn in the track's hand. */
|
||||
float sigEdge(float d) {
|
||||
float w = 0.004 + u_sigLine * 0.03;
|
||||
float soft = w * (0.25 + u_sigSoft * 1.5);
|
||||
return smoothstep(w + soft, w - soft, abs(d));
|
||||
}
|
||||
|
||||
/** TRAIT: style. The track's surface grain, for a scene to add at its own weight. */
|
||||
float sigGrain(vec2 uv) {
|
||||
if (u_sigTexture <= 0.001) return 0.0;
|
||||
return (hash12(uv * 512.0 + floor(u_frame)) - 0.5) * u_sigTexture;
|
||||
}
|
||||
|
||||
/**
|
||||
* TRAIT: space. Height of the shared horizon in the same units as 'p'.
|
||||
* Positive is up; a scene with any sense of ground should sit on it.
|
||||
*/
|
||||
float sigHorizonY() {
|
||||
return (u_sigHorizon - 0.5) * 2.0;
|
||||
}
|
||||
|
||||
/** TRAIT: space. The location's air: distance haze plus the background wash. */
|
||||
vec3 sigAir(vec3 col, vec2 p, float distance01) {
|
||||
vec3 far = pal(0) * (0.25 + 0.35 * u_sigDepth);
|
||||
col = mix(col, far, clamp(distance01, 0.0, 1.0) * u_sigDepth);
|
||||
col += pal(1) * dot(p, u_sigWash) * 0.35;
|
||||
return col;
|
||||
}
|
||||
|
||||
vec3 prev(vec2 uv) {
|
||||
if (u_hasPrev == 0) return vec3(0.0);
|
||||
return texture2D(u_prev, uv).rgb;
|
||||
|
||||
@ -5,16 +5,23 @@ import { clampValue } from '../params/schema.js';
|
||||
/**
|
||||
* Drives the look across the song.
|
||||
*
|
||||
* Three timescales are stacked here, and it takes all three to keep six minutes
|
||||
* Four timescales are stacked here, and it takes all four to keep six minutes
|
||||
* from reading as a loop:
|
||||
*
|
||||
* per frame — reactive mappings (handled in Layer, from the feature row)
|
||||
* per shot — cuts between the section's stage visuals, on phrase lines
|
||||
* per section — seeded LFO drift, so nothing sits still during a long sustain
|
||||
* whole song — scene changes at real boundaries, plus lookahead ramps that
|
||||
* build INTO a drop rather than reacting after it lands
|
||||
*
|
||||
* Layer instances are created once per section and reused. Rebuilding them per
|
||||
* frame would recompile shaders and is the obvious way to make this unusably slow.
|
||||
* The shot level is what stops a ninety-second sustain from being one held
|
||||
* image. Everything below works in CUES — a flat list of (section, shot) spans
|
||||
* built from the look, so a shot cut and a section change take exactly the same
|
||||
* code path and differ only in how long the transition is. See look/shots.js.
|
||||
*
|
||||
* Layer instances are created once per (section, variant) and reused across
|
||||
* every shot that shows that variant. Rebuilding them per shot would recompile
|
||||
* shaders at every cut, which is the obvious way to make this unusably slow.
|
||||
*/
|
||||
export class ArcDriver {
|
||||
constructor(look, track, { crossfadeBars = 1, driftAmount = 0.09 } = {}) {
|
||||
@ -24,11 +31,13 @@ export class ArcDriver {
|
||||
|
||||
const barSeconds = (track.tempo.period * track.tempo.beatsPerBar) / track.fps;
|
||||
this.crossfadeFrames = Math.max(12, Math.round(barSeconds * crossfadeBars * track.fps));
|
||||
this.barFrames = Math.max(1, Math.round(barSeconds * track.fps));
|
||||
|
||||
this.layerCache = new Map();
|
||||
this.driftPlans = new Map();
|
||||
this.activeLayers = [];
|
||||
this.state = { sectionIndex: 0, crossfade: 0, incoming: null };
|
||||
this.cues = this._buildCues();
|
||||
this.state = { sectionIndex: 0, shotIndex: 0, crossfade: 0, incoming: null };
|
||||
}
|
||||
|
||||
dispose() {
|
||||
@ -36,12 +45,85 @@ export class ArcDriver {
|
||||
this.layerCache.clear();
|
||||
}
|
||||
|
||||
/** One Layer per (section, layer) slot, built lazily and kept. */
|
||||
_layerFor(sectionIndex, slot = 0) {
|
||||
const key = `${sectionIndex}:${slot}`;
|
||||
/**
|
||||
* Flatten the look into cues: one per shot, in playback order.
|
||||
*
|
||||
* A look generated before shots existed (or hand-built by a check) has no
|
||||
* `shots` array; it degrades to exactly one cue per section, which is the
|
||||
* old behaviour.
|
||||
*/
|
||||
_buildCues() {
|
||||
const cues = [];
|
||||
this.look.sections.forEach((section, sectionIndex) => {
|
||||
const shots = (section.shots && section.shots.length) ? section.shots : [{
|
||||
startFrame: section.startFrame, endFrame: section.endFrame,
|
||||
variant: 0, hardCut: false,
|
||||
}];
|
||||
const energy = (section.bias && section.bias.energy) || 0;
|
||||
shots.forEach((shot, shotIndex) => {
|
||||
const atSectionStart = shotIndex === 0;
|
||||
const span = shot.endFrame - shot.startFrame;
|
||||
cues.push({
|
||||
index: cues.length,
|
||||
sectionIndex,
|
||||
shotIndex,
|
||||
variant: shot.variant || 0,
|
||||
startFrame: shot.startFrame,
|
||||
endFrame: shot.endFrame,
|
||||
atSectionStart,
|
||||
fadeFrames: shot.hardCut && !atSectionStart
|
||||
? Math.max(2, Math.round(this.track.fps * 0.06))
|
||||
: this._dissolveFrames(energy, span),
|
||||
});
|
||||
});
|
||||
});
|
||||
return cues;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a dissolve takes: the default transition, and deliberately slow.
|
||||
*
|
||||
* Two bars on calm material, one on loud — a long dissolve between two
|
||||
* quiet scenes reads as the image evolving, while the same length under a
|
||||
* drop reads as mush, because both images are moving too fast to overlay.
|
||||
* Capped at 40% of the incoming shot so a transition never occupies most of
|
||||
* the shot it is transitioning into.
|
||||
*/
|
||||
_dissolveFrames(energy, spanFrames) {
|
||||
const bars = energy > 0.6 ? 1 : 2;
|
||||
const wanted = Math.max(this.crossfadeFrames, this.barFrames * bars);
|
||||
return Math.max(12, Math.round(Math.min(wanted, spanFrames * 0.4)));
|
||||
}
|
||||
|
||||
/** Cue covering a frame. Binary search — a seek can land anywhere. */
|
||||
_cueIndexAt(frame) {
|
||||
const cues = this.cues;
|
||||
let lo = 0;
|
||||
let hi = cues.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (cues[mid].startFrame <= frame) lo = mid; else hi = mid - 1;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
/** The cue on screen at a frame. For the UI and the checks. */
|
||||
cueAt(frame) {
|
||||
return this.cues[this._cueIndexAt(frame)];
|
||||
}
|
||||
|
||||
_specFor(sectionIndex, variant, slot) {
|
||||
const section = this.look.sections[sectionIndex];
|
||||
const stack = (section.variants && section.variants[variant]) || section.layers;
|
||||
return stack[slot] || null;
|
||||
}
|
||||
|
||||
/** One Layer per (section, variant, layer slot), built lazily and kept. */
|
||||
_layerFor(sectionIndex, variant, slot = 0) {
|
||||
const key = `${sectionIndex}:${variant}:${slot}`;
|
||||
let layer = this.layerCache.get(key);
|
||||
if (!layer) {
|
||||
const spec = this.look.sections[sectionIndex].layers[slot];
|
||||
const spec = this._specFor(sectionIndex, variant, slot);
|
||||
layer = createLayer(spec.module, {
|
||||
params: spec.params,
|
||||
seed: spec.seed,
|
||||
@ -49,6 +131,7 @@ export class ArcDriver {
|
||||
blend: spec.blend,
|
||||
});
|
||||
layer.setPalette(this.look.palette);
|
||||
layer.setPersonality(this.look.personality);
|
||||
this.layerCache.set(key, layer);
|
||||
}
|
||||
return layer;
|
||||
@ -58,12 +141,12 @@ export class ArcDriver {
|
||||
* Per-param LFO plan for a section: amplitude, period and phase, all seeded.
|
||||
* Slow enough to read as evolution rather than wobble — 20 to 70 seconds.
|
||||
*/
|
||||
_driftPlan(sectionIndex, slot = 0) {
|
||||
const key = `${sectionIndex}:${slot}`;
|
||||
_driftPlan(sectionIndex, variant, slot = 0) {
|
||||
const key = `${sectionIndex}:${variant}:${slot}`;
|
||||
let plan = this.driftPlans.get(key);
|
||||
if (plan) return plan;
|
||||
|
||||
const spec = this.look.sections[sectionIndex].layers[slot];
|
||||
const spec = this._specFor(sectionIndex, variant, slot);
|
||||
const rng = new Rng(spec.seed ^ 0x5bf03635);
|
||||
plan = [];
|
||||
for (const [name, def] of Object.entries(spec.module.params || {})) {
|
||||
@ -86,11 +169,11 @@ export class ArcDriver {
|
||||
* Base params for a section at a given time: the look's sampled values, plus
|
||||
* drift, plus the lookahead ramp toward whatever comes next.
|
||||
*/
|
||||
_paramsAt(sectionIndex, slot, time, features) {
|
||||
const spec = this.look.sections[sectionIndex].layers[slot];
|
||||
_paramsAt(cue, slot, time, features) {
|
||||
const spec = this._specFor(cue.sectionIndex, cue.variant, slot);
|
||||
const out = { ...spec.params };
|
||||
|
||||
for (const item of this._driftPlan(sectionIndex, slot)) {
|
||||
for (const item of this._driftPlan(cue.sectionIndex, cue.variant, slot)) {
|
||||
const base = out[item.name];
|
||||
if (typeof base !== 'number') continue;
|
||||
const wave = Math.sin(2 * Math.PI * (time / item.period + item.phase));
|
||||
@ -103,10 +186,13 @@ export class ArcDriver {
|
||||
// already at tension instead of catching up afterwards.
|
||||
const slope = features ? features.buildSlope || 0 : 0;
|
||||
if (slope > 0.001) {
|
||||
const next = this.look.sections[sectionIndex + 1];
|
||||
if (next && next.layers[slot] && next.layers[slot].module === spec.module) {
|
||||
const nextCue = this.cues[cue.index + 1];
|
||||
const next = nextCue
|
||||
? this._specFor(nextCue.sectionIndex, nextCue.variant, slot)
|
||||
: null;
|
||||
if (next && next.module === spec.module) {
|
||||
// Same scene either side: ramp the actual target values.
|
||||
const target = next.layers[slot].params;
|
||||
const target = next.params;
|
||||
for (const [name, def] of Object.entries(spec.module.params || {})) {
|
||||
if (def.type === 'palette' || typeof out[name] !== 'number') continue;
|
||||
if (typeof target[name] !== 'number') continue;
|
||||
@ -138,83 +224,85 @@ export class ArcDriver {
|
||||
* that was correct on every repeat but wrong the first time through, which is
|
||||
* exactly the kind of fault the determinism checks exist to surface.
|
||||
*/
|
||||
_boundarySlope(sectionIndex) {
|
||||
_boundarySlope(cue) {
|
||||
if (!this._slopeCache) this._slopeCache = new Map();
|
||||
if (this._slopeCache.has(sectionIndex)) return this._slopeCache.get(sectionIndex);
|
||||
if (this._slopeCache.has(cue.index)) return this._slopeCache.get(cue.index);
|
||||
|
||||
const section = this.look.sections[sectionIndex];
|
||||
const frame = Math.max(0, section.startFrame - 1);
|
||||
const frame = Math.max(0, cue.startFrame - 1);
|
||||
const value = this.track.tracks.buildSlope[frame] || 0;
|
||||
this._slopeCache.set(sectionIndex, value);
|
||||
this._slopeCache.set(cue.index, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the active layer stack for a frame.
|
||||
*
|
||||
* The crossfade runs FORWARD from a boundary: the outgoing scene holds at
|
||||
* full opacity while the incoming one fades in over it. That keeps the
|
||||
* boundary frame itself a clean state, which is what makes a boundary seek
|
||||
* exact without warm-up.
|
||||
* The transition runs FORWARD from a cue: the outgoing image holds at full
|
||||
* opacity while the incoming one fades in over it. That keeps the cue frame
|
||||
* itself a clean state, which is what makes a boundary seek exact without
|
||||
* warm-up. A hard cut is the same path with a two-frame fade — it is still
|
||||
* a ramp rather than a jump, because a single-frame swap of two bright
|
||||
* scenes is a flash, and the flash meter is not decorative.
|
||||
*/
|
||||
update(frame, features) {
|
||||
const track = this.track;
|
||||
const time = frame / track.fps;
|
||||
const sectionIndex = track.sectionIndexAt(frame);
|
||||
const section = this.look.sections[sectionIndex];
|
||||
if (!section) return this.activeLayers;
|
||||
const time = frame / this.track.fps;
|
||||
const cueIndex = this._cueIndexAt(frame);
|
||||
const cue = this.cues[cueIndex];
|
||||
if (!cue) return this.activeLayers;
|
||||
const section = this.look.sections[cue.sectionIndex];
|
||||
|
||||
const framesIntoSection = frame - section.startFrame;
|
||||
const fading = sectionIndex > 0 && framesIntoSection < this.crossfadeFrames;
|
||||
const t = fading ? framesIntoSection / this.crossfadeFrames : 1;
|
||||
const framesIntoCue = frame - cue.startFrame;
|
||||
const previous = cueIndex > 0 ? this.cues[cueIndex - 1] : null;
|
||||
const fading = !!previous && framesIntoCue < cue.fadeFrames;
|
||||
const t = fading ? framesIntoCue / cue.fadeFrames : 1;
|
||||
const eased = t * t * (3 - 2 * t);
|
||||
|
||||
const layers = [];
|
||||
|
||||
if (fading) {
|
||||
const previousIndex = sectionIndex - 1;
|
||||
const outgoing = this._layerFor(previousIndex);
|
||||
// buildSlope is discontinuous at a section boundary by construction:
|
||||
// it ramps to ~1 through the bars before the change and is 0
|
||||
// immediately after. The outgoing layer is still on screen when that
|
||||
// happens, so feeding it the new section's features collapses its
|
||||
// lookahead ramp in a single frame — a visible pop precisely at the
|
||||
// transition. Hold the slope it had going into the boundary; it
|
||||
// finished its build, and it stays there while it fades out. Within a
|
||||
// section the slope is continuous, so the live value is correct there.
|
||||
const outgoingFeatures = cue.atSectionStart
|
||||
? { ...features, buildSlope: this._boundarySlope(cue) }
|
||||
: features;
|
||||
|
||||
// buildSlope is discontinuous at a boundary by construction: it ramps
|
||||
// to ~1 through the bars before the change and is 0 immediately after.
|
||||
// The outgoing layer is still on screen when that happens, so feeding
|
||||
// it the new section's features collapses its lookahead ramp in a
|
||||
// single frame — a visible pop precisely at the transition. Hold the
|
||||
// slope it had going into the boundary; it finished its build, and it
|
||||
// stays there while it fades out.
|
||||
outgoing.setParams(this._paramsAt(previousIndex, 0, time, {
|
||||
...features,
|
||||
buildSlope: this._boundarySlope(sectionIndex),
|
||||
}));
|
||||
outgoing.opacity = 1;
|
||||
outgoing.blend = 'normal';
|
||||
outgoing.setPalette(this.look.palette);
|
||||
layers.push(outgoing);
|
||||
for (let slot = 0; slot < this._stackSize(previous); slot++) {
|
||||
const spec = this._specFor(previous.sectionIndex, previous.variant, slot);
|
||||
const layer = this._layerFor(previous.sectionIndex, previous.variant, slot);
|
||||
layer.setParams(this._paramsAt(previous, slot, time, outgoingFeatures));
|
||||
layer.opacity = slot === 0 ? 1 : spec.opacity;
|
||||
layer.blend = slot === 0 ? 'normal' : spec.blend;
|
||||
layer.setPalette(this.look.palette);
|
||||
layer.setPersonality(this.look.personality);
|
||||
layers.push(layer);
|
||||
}
|
||||
}
|
||||
|
||||
const current = this._layerFor(sectionIndex);
|
||||
current.setParams(this._paramsAt(sectionIndex, 0, time, features));
|
||||
current.opacity = fading ? eased : 1;
|
||||
current.blend = 'normal';
|
||||
current.setPalette(this.look.palette);
|
||||
layers.push(current);
|
||||
|
||||
// Extra composited layers declared on the section (Phase 5 stacks).
|
||||
for (let slot = 1; slot < section.layers.length; slot++) {
|
||||
const spec = section.layers[slot];
|
||||
const layer = this._layerFor(sectionIndex, slot);
|
||||
layer.setParams(this._paramsAt(sectionIndex, slot, time, features));
|
||||
layer.opacity = spec.opacity * (fading ? eased : 1);
|
||||
layer.blend = spec.blend;
|
||||
for (let slot = 0; slot < this._stackSize(cue); slot++) {
|
||||
const spec = this._specFor(cue.sectionIndex, cue.variant, slot);
|
||||
const layer = this._layerFor(cue.sectionIndex, cue.variant, slot);
|
||||
layer.setParams(this._paramsAt(cue, slot, time, features));
|
||||
layer.opacity = (slot === 0 ? 1 : spec.opacity) * (fading ? eased : 1);
|
||||
layer.blend = slot === 0 ? 'normal' : spec.blend;
|
||||
layer.setPalette(this.look.palette);
|
||||
layer.setPersonality(this.look.personality);
|
||||
layers.push(layer);
|
||||
}
|
||||
|
||||
this.state = {
|
||||
sectionIndex,
|
||||
sectionIndex: cue.sectionIndex,
|
||||
shotIndex: cue.shotIndex,
|
||||
shotCount: section.shots ? section.shots.length : 1,
|
||||
variant: cue.variant,
|
||||
kind: section.kind,
|
||||
crossfade: fading ? eased : 0,
|
||||
sceneName: section.layers[0].module.name,
|
||||
sceneName: this._specFor(cue.sectionIndex, cue.variant, 0).module.name,
|
||||
buildSlope: features ? features.buildSlope || 0 : 0,
|
||||
};
|
||||
|
||||
@ -222,6 +310,12 @@ export class ArcDriver {
|
||||
return layers;
|
||||
}
|
||||
|
||||
_stackSize(cue) {
|
||||
const section = this.look.sections[cue.sectionIndex];
|
||||
const stack = (section.variants && section.variants[cue.variant]) || section.layers;
|
||||
return stack.length;
|
||||
}
|
||||
|
||||
/** Layers changed identity — the compositor needs the new list. */
|
||||
layersChanged(previous) {
|
||||
if (!previous || previous.length !== this.activeLayers.length) return true;
|
||||
@ -237,11 +331,34 @@ export class ArcDriver {
|
||||
this.driftPlans.delete(key);
|
||||
}
|
||||
}
|
||||
// A reroll re-plans the section's shots, so the cue list is stale too.
|
||||
this.cues = this._buildCues();
|
||||
this._slopeCache = null;
|
||||
}
|
||||
|
||||
invalidateAll() {
|
||||
this.dispose();
|
||||
this.driftPlans.clear();
|
||||
this.cues = this._buildCues();
|
||||
this._slopeCache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and compile every layer the look will ever show.
|
||||
*
|
||||
* Layers are otherwise built on first use, which means a shader compile on
|
||||
* the frame of a cut — a visible hitch, and there are now many more cuts
|
||||
* than there were sections. Paying for all of them once at load is cheaper
|
||||
* than paying for one at every transition.
|
||||
*/
|
||||
prewarm(onLayer = null) {
|
||||
for (const cue of this.cues) {
|
||||
for (let slot = 0; slot < this._stackSize(cue); slot++) {
|
||||
const layer = this._layerFor(cue.sectionIndex, cue.variant, slot);
|
||||
if (onLayer) onLayer(layer);
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Push a palette change through without rebuilding layers. */
|
||||
|
||||
@ -8,6 +8,8 @@ import { Rng, hashSamples } from '../engine/rng.js';
|
||||
import { AudioPalette, generateUsablePalette } from './palette.js';
|
||||
import { scenes, scenesInFamily, FAMILIES } from '../scenes/registry.js';
|
||||
import { sampleValues, defaultValues } from '../params/schema.js';
|
||||
import { planShots } from './shots.js';
|
||||
import { generatePersonality, sceneHonours, describePersonality } from './Personality.js';
|
||||
|
||||
/**
|
||||
* Which families suit which section kind, in preference order.
|
||||
@ -49,15 +51,64 @@ function biasFor(section, summary) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Scenes are chosen per section KIND, not per section.
|
||||
* Scenes eligible for a section kind, weighted by how well the family fits.
|
||||
*
|
||||
* All of a track's drops therefore share a scene, all its breakdowns share
|
||||
* another, and the video acquires an identity instead of reading as a shuffle.
|
||||
* Variation between two sections of the same kind comes from their parameter
|
||||
* sets and from the arc driver's drift, which is enough to keep them distinct
|
||||
* without losing the through-line.
|
||||
* `signature` is the track's personality signature, and it is a hard filter
|
||||
* rather than a weight: a scene with no way to express what the track is built
|
||||
* on is not a worse choice, it is the shot that was clearly filmed somewhere
|
||||
* else. See look/Personality.js.
|
||||
*/
|
||||
function assignScenesByKind(sections, rng) {
|
||||
function candidatesForKind(kind, used, signature = []) {
|
||||
const families = FAMILY_BY_KIND[kind] || Object.keys(FAMILIES);
|
||||
const candidates = [];
|
||||
for (const family of families) {
|
||||
const inFamily = scenesInFamily(family)
|
||||
.filter((m) => m.role !== 'accent' && sceneHonours(m, signature));
|
||||
// Weight by family preference order, and push down anything already
|
||||
// used so a five-section track doesn't show one scene five times.
|
||||
const weight = families.length - families.indexOf(family);
|
||||
for (const scene of inFamily) {
|
||||
candidates.push({ scene, weight: weight * (used.has(scene.name) ? 0.15 : 1) });
|
||||
}
|
||||
}
|
||||
if (!candidates.length) {
|
||||
// Every family for this kind was emptied by the signature filter. Widen
|
||||
// to the whole library, still honouring the signature; only if that is
|
||||
// empty too does the personality lose and the video keep its scenes.
|
||||
const anywhere = scenes.filter((m) => m.role !== 'accent' && sceneHonours(m, signature));
|
||||
const pool = anywhere.length ? anywhere : scenes.filter((m) => m.role !== 'accent');
|
||||
return pool.map((scene) => ({ scene, weight: 1 }));
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many stage visuals a kind rotates between. Busy material takes more.
|
||||
*
|
||||
* Sized against the library rather than picked out of the air: a kind draws
|
||||
* from three families, which is seven to nine non-accent scenes, so a roster of
|
||||
* four still leaves the weighting room to avoid what other kinds already took.
|
||||
* Variants a section never reaches cost nothing — layers are built per cue, so
|
||||
* only the ones its shots actually show are ever compiled.
|
||||
*/
|
||||
function rosterSizeFor(kind) {
|
||||
return (KIND_ENERGY[kind] ?? 0.5) > 0.5 ? 4 : 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scenes are chosen per section KIND, not per section — and a kind gets a
|
||||
* ROSTER of two or three, not one.
|
||||
*
|
||||
* All of a track's drops therefore cut between the same small set of visuals,
|
||||
* all its breakdowns between another, and the video acquires an identity
|
||||
* instead of reading as a shuffle. The first entry is the anchor: it opens
|
||||
* every section of that kind and comes back most often, so the rotation reads
|
||||
* as one idea with variations rather than as three unrelated scenes.
|
||||
*
|
||||
* Variation between two sections of the same kind comes from their parameter
|
||||
* sets, from where their shots fall, and from the arc driver's drift.
|
||||
*/
|
||||
function assignRostersByKind(sections, rng, signature = []) {
|
||||
const byKind = new Map();
|
||||
const used = new Set();
|
||||
|
||||
@ -68,27 +119,27 @@ function assignScenesByKind(sections, rng) {
|
||||
kinds.sort((a, b) => priority.indexOf(a) - priority.indexOf(b));
|
||||
|
||||
for (const kind of kinds) {
|
||||
const families = FAMILY_BY_KIND[kind] || Object.keys(FAMILIES);
|
||||
let candidates = [];
|
||||
for (const family of families) {
|
||||
const inFamily = scenesInFamily(family).filter((m) => m.role !== 'accent');
|
||||
// Weight by family preference order, and push down anything already
|
||||
// used so a five-section track doesn't show one scene five times.
|
||||
const weight = families.length - families.indexOf(family);
|
||||
for (const scene of inFamily) {
|
||||
candidates.push({ scene, weight: weight * (used.has(scene.name) ? 0.15 : 1) });
|
||||
}
|
||||
}
|
||||
if (!candidates.length) {
|
||||
candidates = scenes.filter((m) => m.role !== 'accent').map((scene) => ({ scene, weight: 1 }));
|
||||
const roster = [];
|
||||
const size = rosterSizeFor(kind);
|
||||
|
||||
for (let slot = 0; slot < size; slot++) {
|
||||
const pool = candidatesForKind(kind, used, signature)
|
||||
.filter((c) => !roster.includes(c.scene))
|
||||
.map((c) => ({
|
||||
scene: c.scene,
|
||||
// Companions stay in the anchor's family where possible: a
|
||||
// cut inside a section should change the image, not the
|
||||
// whole visual language.
|
||||
weight: c.weight * (roster.length && c.scene.family === roster[0].family ? 3 : 1),
|
||||
}));
|
||||
if (!pool.length) break;
|
||||
|
||||
const chosen = rng.pickWeighted(pool.map((c) => c.scene), pool.map((c) => c.weight));
|
||||
roster.push(chosen);
|
||||
used.add(chosen.name);
|
||||
}
|
||||
|
||||
const chosen = rng.pickWeighted(
|
||||
candidates.map((c) => c.scene),
|
||||
candidates.map((c) => c.weight),
|
||||
);
|
||||
byKind.set(kind, chosen);
|
||||
used.add(chosen.name);
|
||||
byKind.set(kind, roster.length ? roster : [scenes[0]]);
|
||||
}
|
||||
return byKind;
|
||||
}
|
||||
@ -127,6 +178,36 @@ function derivePost(summary, rng) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One layer stack: a background scene plus an optional accent over it.
|
||||
*
|
||||
* The accent is composited additively at low opacity and drawn from a DIFFERENT
|
||||
* family, so it reads as depth rather than as a second competing scene. Quiet
|
||||
* material mostly goes without — an intro is supposed to be sparse.
|
||||
*/
|
||||
function buildStack(module, accentRoster, bias, rng) {
|
||||
const layers = [{
|
||||
module,
|
||||
params: sampleValues(module, rng, bias),
|
||||
seed: rng.int(0, 0x7fffffff),
|
||||
blend: 'normal',
|
||||
opacity: 1,
|
||||
}];
|
||||
|
||||
if (accentRoster.length && rng.bool(bias.energy * 0.8)) {
|
||||
const eligible = accentRoster.filter((m) => m.family !== module.family);
|
||||
const accent = rng.pick(eligible.length ? eligible : accentRoster);
|
||||
layers.push({
|
||||
module: accent,
|
||||
params: sampleValues(accent, rng.fork('accent'), bias),
|
||||
seed: rng.int(0, 0x7fffffff),
|
||||
blend: rng.pickWeighted(['add', 'screen'], [2, 1]),
|
||||
opacity: rng.range(0.18, 0.5),
|
||||
});
|
||||
}
|
||||
return layers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FeatureTrack} track
|
||||
* @param {object} options
|
||||
@ -143,43 +224,36 @@ export function generateLook(track, { seed = null, samples = null, overrides = n
|
||||
const paletteSource = new AudioPalette(summary, rng.fork('palette'));
|
||||
const palette = generateUsablePalette(paletteSource, 6);
|
||||
|
||||
const sceneByKind = assignScenesByKind(track.sections, rng.fork('scenes'));
|
||||
// The production design, decided before a single scene is cast — casting
|
||||
// depends on it. See look/Personality.js.
|
||||
const personality = generatePersonality(summary, rng.fork('personality'), (signature) =>
|
||||
scenes.filter((m) => m.role !== 'accent' && sceneHonours(m, signature)).length);
|
||||
|
||||
const rosterByKind = assignRostersByKind(
|
||||
track.sections, rng.fork('scenes'), personality.signature);
|
||||
const { post, feedback } = derivePost(summary, rng.fork('post'));
|
||||
|
||||
// Scenes that declare role 'accent' composite over a background rather than
|
||||
// being one — most of their frame is empty by design. They are never chosen
|
||||
// as a section's primary scene.
|
||||
const accentRoster = scenes.filter((m) => m.role === 'accent');
|
||||
// Accents honour the signature too where they can. If none can, the track
|
||||
// goes without depth layers rather than putting an off-design element into
|
||||
// every stack.
|
||||
const accentRoster = scenes.filter((m) => m.role === 'accent'
|
||||
&& sceneHonours(m, personality.signature));
|
||||
|
||||
const sections = track.sections.map((section) => {
|
||||
const module = sceneByKind.get(section.kind) || scenes[0];
|
||||
const sectionRng = rng.fork(`section:${section.index}:${module.name}`);
|
||||
const roster = rosterByKind.get(section.kind) || [scenes[0]];
|
||||
const sectionRng = rng.fork(`section:${section.index}:${roster[0].name}`);
|
||||
const bias = biasFor(section, summary);
|
||||
|
||||
const layers = [{
|
||||
module,
|
||||
params: sampleValues(module, sectionRng, bias),
|
||||
seed: sectionRng.int(0, 0x7fffffff),
|
||||
blend: 'normal',
|
||||
opacity: 1,
|
||||
}];
|
||||
const variants = roster.map((module, v) => buildStack(
|
||||
module, accentRoster, bias, sectionRng.fork(`variant:${section.index}:${v}`),
|
||||
));
|
||||
|
||||
// Accent layer. Composited additively over the background at low opacity,
|
||||
// and drawn from a DIFFERENT family so it reads as depth rather than as a
|
||||
// second competing scene. Quiet sections mostly go without — an intro is
|
||||
// supposed to be sparse.
|
||||
const accentChance = bias.energy * 0.8;
|
||||
if (accentRoster.length && sectionRng.bool(accentChance)) {
|
||||
const accent = sectionRng.pick(accentRoster.filter((m) => m.family !== module.family) || accentRoster)
|
||||
|| accentRoster[0];
|
||||
layers.push({
|
||||
module: accent,
|
||||
params: sampleValues(accent, sectionRng.fork(`accent:${section.index}`), bias),
|
||||
seed: sectionRng.int(0, 0x7fffffff),
|
||||
blend: sectionRng.pickWeighted(['add', 'screen'], [2, 1]),
|
||||
opacity: sectionRng.range(0.18, 0.5),
|
||||
});
|
||||
}
|
||||
const shots = planShots(
|
||||
section, track, bias, variants.length, sectionRng.fork(`shots:${section.index}`),
|
||||
);
|
||||
|
||||
return {
|
||||
index: section.index,
|
||||
@ -190,13 +264,19 @@ export function generateLook(track, { seed = null, samples = null, overrides = n
|
||||
end: section.end,
|
||||
locked: false,
|
||||
bias,
|
||||
layers,
|
||||
variants,
|
||||
shots,
|
||||
// The anchor stack, aliased. Everything that predates shots — the
|
||||
// param panel, presets, the checks — edits a section through this,
|
||||
// and it is the same object the first variant holds.
|
||||
layers: variants[0],
|
||||
};
|
||||
});
|
||||
|
||||
const look = {
|
||||
seed: resolvedSeed,
|
||||
palette,
|
||||
personality,
|
||||
paletteScheme: paletteSource.lastScheme,
|
||||
post,
|
||||
feedback,
|
||||
@ -213,17 +293,33 @@ export function rerollSection(look, track, sectionIndex, salt = 0) {
|
||||
if (!section || section.locked) return look;
|
||||
|
||||
const rng = new Rng((look.seed ^ (sectionIndex * 0x9e3779b9) ^ (salt * 0x85ebca6b)) >>> 0);
|
||||
const signature = (look.personality && look.personality.signature) || [];
|
||||
const families = FAMILY_BY_KIND[section.kind] || Object.keys(FAMILIES);
|
||||
const candidates = families.flatMap((f) => scenesInFamily(f)).filter((m) => m.role !== 'accent');
|
||||
const module = candidates.length ? rng.pick(candidates) : scenes[0];
|
||||
let candidates = families.flatMap((f) => scenesInFamily(f))
|
||||
.filter((m) => m.role !== 'accent' && sceneHonours(m, signature));
|
||||
if (!candidates.length) {
|
||||
candidates = scenes.filter((m) => m.role !== 'accent' && sceneHonours(m, signature));
|
||||
}
|
||||
|
||||
section.layers = [{
|
||||
module,
|
||||
params: sampleValues(module, rng, section.bias),
|
||||
seed: rng.int(0, 0x7fffffff),
|
||||
blend: 'normal',
|
||||
opacity: 1,
|
||||
}];
|
||||
// Re-roll the whole roster, not just the anchor: the section's shots cut
|
||||
// between all of them, so replacing one would leave the section half old.
|
||||
const size = Math.min(rosterSizeFor(section.kind), Math.max(1, candidates.length));
|
||||
const roster = [];
|
||||
while (roster.length < size) {
|
||||
const pool = candidates.filter((m) => !roster.includes(m));
|
||||
if (!pool.length) break;
|
||||
roster.push(rng.pick(pool));
|
||||
}
|
||||
if (!roster.length) roster.push(scenes[0]);
|
||||
|
||||
const accentRoster = scenes.filter((m) => m.role === 'accent');
|
||||
section.variants = roster.map((module, v) => buildStack(
|
||||
module, accentRoster, section.bias, rng.fork(`variant:${v}`),
|
||||
));
|
||||
section.shots = planShots(
|
||||
section, track, section.bias, section.variants.length, rng.fork('shots'),
|
||||
);
|
||||
section.layers = section.variants[0];
|
||||
return look;
|
||||
}
|
||||
|
||||
@ -256,7 +352,8 @@ function applyOverrides(look, overrides) {
|
||||
/** Compact description, used by the HUD and by check output. */
|
||||
export function describeLook(look) {
|
||||
const kinds = look.sections.map((s) => `${s.kind}:${s.layers[0].module.name}`);
|
||||
return `seed ${look.seed.toString(16)} · ${look.paletteScheme} · ${[...new Set(kinds)].join(', ')}`;
|
||||
return `seed ${look.seed.toString(16)} · ${look.paletteScheme} · ` +
|
||||
`${describePersonality(look.personality)} · ${[...new Set(kinds)].join(', ')}`;
|
||||
}
|
||||
|
||||
export { defaultValues };
|
||||
|
||||
205
flow-state/src/look/Personality.js
Normal file
205
flow-state/src/look/Personality.js
Normal file
@ -0,0 +1,205 @@
|
||||
// The track's production design.
|
||||
//
|
||||
// A music video is not held together by its cuts. It is held together by the
|
||||
// fact that every shot was filmed in the same location, with the same actors,
|
||||
// the same camera operator and the same art direction. Cut between two shots of
|
||||
// that and it reads as one piece even when the framing changes completely.
|
||||
//
|
||||
// Nothing in this project had an equivalent. Sections shared a palette and a
|
||||
// post grade, and past that every scene was a separate short film. This module
|
||||
// is the missing layer: one procedurally generated PERSONALITY per track, in
|
||||
// four traits that map onto the four things a production shares.
|
||||
//
|
||||
// shape — the actors. A signature form: how many sides, how round, how
|
||||
// elongated, at what tilt. Scenes that draw discrete elements stamp
|
||||
// this form instead of whatever primitive they would have used.
|
||||
// camera — the operator. A drift direction, a sway, a slow spin, a breathing
|
||||
// zoom. Applied to the coordinate a scene works in, so every scene
|
||||
// is filmed by the same hand.
|
||||
// space — the location. A horizon height, a depth falloff, a background
|
||||
// wash direction. Scenes that have a sense of place share one.
|
||||
// style — the art direction. Line weight, edge softness, texture, and how
|
||||
// many times the frame is folded.
|
||||
//
|
||||
// A scene declares which traits it can honour. Each track picks a SIGNATURE of
|
||||
// one or two traits, and a scene that does not honour all of them is
|
||||
// disqualified from that track — the library shrinks per track, on purpose. A
|
||||
// scene with no way to express a hexagon should not appear in the hexagon
|
||||
// video; it would be the shot that was clearly filmed somewhere else.
|
||||
//
|
||||
// Everything here is seeded off the look seed, so a track's personality is as
|
||||
// reproducible as everything else.
|
||||
|
||||
export const TRAITS = ['shape', 'camera', 'space', 'style'];
|
||||
|
||||
/**
|
||||
* Traits eligible to be a track's signature, and how often.
|
||||
*
|
||||
* `shape` and `space` carry the most identity — they are the ones a viewer can
|
||||
* actually name on a second watch — so they are the ones a signature is built
|
||||
* around. `camera` and `style` are near-universally supported and read as
|
||||
* treatment rather than as subject, so they join a signature but rarely define
|
||||
* one alone.
|
||||
*/
|
||||
const SIGNATURE_WEIGHTS = { shape: 4, space: 3, camera: 2, style: 2 };
|
||||
|
||||
/** Minimum scenes that must survive the signature filter for it to be usable. */
|
||||
export const MIN_ELIGIBLE_SCENES = 6;
|
||||
|
||||
/**
|
||||
* Generate the personality.
|
||||
*
|
||||
* Trait VALUES lean on what was measured in the audio — a bright, noisy track
|
||||
* gets sharper lines and more texture; a slow one gets a lazier camera — but
|
||||
* the seed dominates, so two tracks with similar statistics still look like
|
||||
* different productions.
|
||||
*
|
||||
* @param {object} summary FeatureTrack summary
|
||||
* @param {Rng} rng
|
||||
* @param {(traits: string[]) => number} countEligible
|
||||
* How many scenes would survive a given signature. Injected rather than
|
||||
* imported so this module never has to know the registry exists.
|
||||
*/
|
||||
export function generatePersonality(summary, rng, countEligible = null) {
|
||||
const bright = summary.meanCentroid;
|
||||
const noisy = Math.min(1, summary.meanFlatness * 3);
|
||||
const fast = Math.min(1, Math.max(0, (summary.bpm - 80) / 80));
|
||||
|
||||
const shape = {
|
||||
// 0 sides means round. Everything else is a polygon the whole track
|
||||
// shares — the single most recognisable thing here.
|
||||
sides: rng.pickWeighted([0, 3, 4, 5, 6, 8], [3, 2, 3, 2, 3, 1]),
|
||||
roundness: rng.range(0.05, 0.5),
|
||||
elongation: rng.range(0.85, 1.45),
|
||||
tilt: rng.range(0, Math.PI),
|
||||
};
|
||||
|
||||
const camera = {
|
||||
driftAngle: rng.range(0, Math.PI * 2),
|
||||
// A slow track should not be filmed from a moving car.
|
||||
driftRate: rng.range(0.01, 0.06) * (0.6 + fast * 0.8),
|
||||
sway: rng.range(0.0, 0.06),
|
||||
swayRate: rng.range(0.05, 0.22),
|
||||
spin: rng.range(-0.05, 0.05),
|
||||
// Breathing is locked to the bar, so it is the one camera move that
|
||||
// reads as musical rather than as drifting.
|
||||
breathe: rng.range(0.0, 0.05),
|
||||
};
|
||||
|
||||
const space = {
|
||||
horizon: rng.range(0.32, 0.62),
|
||||
depth: rng.range(0.2, 0.9),
|
||||
washAngle: rng.range(0, Math.PI * 2),
|
||||
wash: rng.range(0.1, 0.5),
|
||||
};
|
||||
|
||||
const style = {
|
||||
lineWeight: 0.4 + bright * 0.4 + rng.range(-0.15, 0.25),
|
||||
softness: 0.25 + (1 - bright) * 0.4 + rng.range(-0.1, 0.2),
|
||||
texture: noisy * 0.5 + rng.range(0, 0.25),
|
||||
// Fold counts stay low and are usually off. Symmetry is the fastest way
|
||||
// to make a library look like one series and also the fastest way to
|
||||
// make every track look like a screensaver.
|
||||
symmetry: rng.pickWeighted([1, 1, 2, 3, 4, 6], [6, 4, 2, 2, 2, 1]),
|
||||
};
|
||||
|
||||
const signature = pickSignature(rng, countEligible);
|
||||
|
||||
return { signature, shape, camera, space, style };
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the one or two traits this track is BUILT on.
|
||||
*
|
||||
* Two is the target: one trait alone is not enough to recognise, and three
|
||||
* disqualifies most of the library. If the pair leaves too few scenes to build
|
||||
* rosters from, fall back to the stronger of the two rather than shipping a
|
||||
* track whose every section is forced onto the same two scenes.
|
||||
*/
|
||||
function pickSignature(rng, countEligible) {
|
||||
const primary = rng.pickWeighted(TRAITS, TRAITS.map((t) => SIGNATURE_WEIGHTS[t]));
|
||||
const rest = TRAITS.filter((t) => t !== primary);
|
||||
const secondary = rng.pickWeighted(rest, rest.map((t) => SIGNATURE_WEIGHTS[t]));
|
||||
|
||||
const pair = [primary, secondary];
|
||||
if (!countEligible || countEligible(pair) >= MIN_ELIGIBLE_SCENES) return pair;
|
||||
|
||||
const single = [primary];
|
||||
if (countEligible(single) >= MIN_ELIGIBLE_SCENES) return single;
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Does a scene honour everything this track is built on? */
|
||||
export function sceneHonours(module, signature) {
|
||||
const traits = module.traits || [];
|
||||
return signature.every((t) => traits.includes(t));
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten to the uniform values the shader contract expects.
|
||||
*
|
||||
* Neutral defaults matter: a layer with no personality attached must render
|
||||
* exactly what it rendered before this existed, because the range sweeps and
|
||||
* the library regression checks build layers directly and would otherwise all
|
||||
* shift at once.
|
||||
*/
|
||||
export function signatureUniforms(personality) {
|
||||
if (!personality) return NEUTRAL_UNIFORMS;
|
||||
const { shape, camera, space, style } = personality;
|
||||
return {
|
||||
u_sigSides: shape.sides,
|
||||
u_sigRound: shape.roundness,
|
||||
u_sigElong: shape.elongation,
|
||||
u_sigTilt: shape.tilt,
|
||||
|
||||
u_sigDrift: [Math.cos(camera.driftAngle) * camera.driftRate,
|
||||
Math.sin(camera.driftAngle) * camera.driftRate],
|
||||
u_sigSway: camera.sway,
|
||||
u_sigSwayRate: camera.swayRate,
|
||||
u_sigSpin: camera.spin,
|
||||
u_sigBreathe: camera.breathe,
|
||||
|
||||
u_sigHorizon: space.horizon,
|
||||
u_sigDepth: space.depth,
|
||||
u_sigWash: [Math.cos(space.washAngle) * space.wash,
|
||||
Math.sin(space.washAngle) * space.wash],
|
||||
|
||||
u_sigLine: style.lineWeight,
|
||||
u_sigSoft: style.softness,
|
||||
u_sigTexture: style.texture,
|
||||
u_sigFold: style.symmetry,
|
||||
};
|
||||
}
|
||||
|
||||
export const NEUTRAL_UNIFORMS = {
|
||||
u_sigSides: 0,
|
||||
u_sigRound: 0.25,
|
||||
u_sigElong: 1,
|
||||
u_sigTilt: 0,
|
||||
u_sigDrift: [0, 0],
|
||||
u_sigSway: 0,
|
||||
u_sigSwayRate: 0.1,
|
||||
u_sigSpin: 0,
|
||||
u_sigBreathe: 0,
|
||||
u_sigHorizon: 0.5,
|
||||
u_sigDepth: 0,
|
||||
u_sigWash: [0, 0],
|
||||
u_sigLine: 0.5,
|
||||
u_sigSoft: 0.5,
|
||||
u_sigTexture: 0,
|
||||
u_sigFold: 1,
|
||||
};
|
||||
|
||||
const SHAPE_NAMES = { 0: 'round', 3: 'triangular', 4: 'square', 5: 'pentagonal', 6: 'hexagonal', 8: 'octagonal' };
|
||||
|
||||
/** One line for the HUD, the look panel and check output. */
|
||||
export function describePersonality(personality) {
|
||||
if (!personality) return 'no personality';
|
||||
const { signature, shape, style } = personality;
|
||||
const parts = [
|
||||
`on ${signature.length ? signature.join('+') : 'nothing'}`,
|
||||
SHAPE_NAMES[shape.sides] || `${shape.sides}-sided`,
|
||||
];
|
||||
if (style.symmetry > 1) parts.push(`${style.symmetry}-fold`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
161
flow-state/src/look/shots.js
Normal file
161
flow-state/src/look/shots.js
Normal file
@ -0,0 +1,161 @@
|
||||
// Shot planning: the level of hierarchy between a song section and a frame.
|
||||
//
|
||||
// A section is a STAGE of the song (intro, build, drop, …) and can easily run
|
||||
// ninety seconds. One scene held for ninety seconds reads as a still image with
|
||||
// a wobble on it, no matter how much per-frame reactivity is underneath. So a
|
||||
// section is cut into SHOTS, each showing one of the section's few "stage
|
||||
// visuals" — the roster the look generator picked for that kind of section.
|
||||
//
|
||||
// Two rules keep this from turning into a shuffle:
|
||||
//
|
||||
// * the roster is per section KIND, not per section, so all of a track's drops
|
||||
// still cut between the same two or three visuals and the video keeps an
|
||||
// identity;
|
||||
// * cuts land on phrase lines, so a change of image lands with the music
|
||||
// instead of across it.
|
||||
//
|
||||
// Shot length follows energy: a drop cuts every four to eight bars, an intro
|
||||
// holds for eight to sixteen, and nothing holds past the ceiling below.
|
||||
// Everything here is seeded, so a track always cuts in the same places.
|
||||
|
||||
/** Never cut faster than this, whatever the tempo or the energy says. */
|
||||
export const MIN_SHOT_SECONDS = 5;
|
||||
|
||||
/**
|
||||
* And never hold longer than this either. Half a minute of one image is the
|
||||
* complaint this whole level of hierarchy exists to answer, so it is a hard
|
||||
* ceiling rather than something the bar maths is trusted to stay under: at a
|
||||
* slow tempo sixteen bars is already past it.
|
||||
*/
|
||||
export const MAX_SHOT_SECONDS = 22;
|
||||
|
||||
/** Below this section energy, a shot change is always a dissolve, never a cut. */
|
||||
export const HARD_CUT_ENERGY = 0.66;
|
||||
|
||||
/** Phrase length for one shot, in bars, from the section's energy. */
|
||||
function shotBarsFor(energy, rng) {
|
||||
if (energy > 0.72) return rng.pick([4, 8, 8]);
|
||||
if (energy > 0.45) return rng.pick([8, 8, 16]);
|
||||
return rng.pick([8, 16, 16]);
|
||||
}
|
||||
|
||||
/** Nearest downbeat to `time`, or null if none is close enough to be the same line. */
|
||||
function nearestDownbeat(time, downbeats, tolerance) {
|
||||
let best = null;
|
||||
let bestDist = Infinity;
|
||||
for (const d of downbeats) {
|
||||
const dist = Math.abs(d - time);
|
||||
if (dist < bestDist) { bestDist = dist; best = d; }
|
||||
else if (d > time && dist > bestDist) break; // sorted: past the minimum
|
||||
}
|
||||
return best !== null && bestDist <= tolerance ? best : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Divide a section into shots.
|
||||
*
|
||||
* @param {object} section a track section (start/end/startFrame/endFrame)
|
||||
* @param {object} track FeatureTrack, for fps and the bar grid
|
||||
* @param {object} bias the section's bias, for energy
|
||||
* @param {number} variantCount how many stage visuals the section has
|
||||
* @param {Rng} rng
|
||||
* @returns {Array<{index,startFrame,endFrame,variant,hardCut}>}
|
||||
*/
|
||||
export function planShots(section, track, bias, variantCount, rng) {
|
||||
const fps = track.fps;
|
||||
const duration = Math.max(0, section.end - section.start);
|
||||
const barSeconds = (track.tempo.period * track.tempo.beatsPerBar) / fps;
|
||||
|
||||
const bars = shotBarsFor(bias.energy, rng);
|
||||
const target = Math.min(MAX_SHOT_SECONDS,
|
||||
Math.max(MIN_SHOT_SECONDS, barSeconds > 0.2 ? bars * barSeconds : 12));
|
||||
|
||||
// Round to the nearest whole number of shots — a 40s section at a 12s target
|
||||
// gets three of 13s, not three of 12 and a stub — then force enough shots to
|
||||
// stay under the ceiling, and finally refuse any count that would push a
|
||||
// shot below the floor. The floor wins if they ever disagree.
|
||||
//
|
||||
// The ceiling gets headroom because snapping moves a cut by up to the
|
||||
// tolerance below, and a cut that snaps LATE would otherwise land just past
|
||||
// the limit the count was chosen to respect.
|
||||
let count = Math.max(
|
||||
Math.round(duration / target),
|
||||
Math.ceil(duration / (MAX_SHOT_SECONDS * 0.88)),
|
||||
1,
|
||||
);
|
||||
count = Math.min(count, Math.max(1, Math.floor(duration / MIN_SHOT_SECONDS)));
|
||||
if (variantCount < 2) count = 1;
|
||||
|
||||
// Cut times: evenly spaced, then pulled onto the nearest downbeat. The
|
||||
// tolerance is deliberately under half a shot, so a snap can never reorder
|
||||
// two cuts or collapse one onto another.
|
||||
const tolerance = Math.min(barSeconds * 1.5, target * 0.35);
|
||||
const downbeats = track.tempo.downbeats || [];
|
||||
const cuts = [];
|
||||
for (let k = 1; k < count; k++) {
|
||||
const raw = section.start + (duration * k) / count;
|
||||
const snapped = nearestDownbeat(raw, downbeats, tolerance);
|
||||
const previous = cuts.length ? cuts[cuts.length - 1] : section.start;
|
||||
const fits = (t) => t - previous >= MIN_SHOT_SECONDS && section.end - t >= MIN_SHOT_SECONDS;
|
||||
|
||||
// Prefer the downbeat, but a snap that pushes the cut inside the floor
|
||||
// is worse than an unsnapped cut: dropping it would leave the hold this
|
||||
// whole mechanism exists to break up.
|
||||
const time = snapped !== null && fits(snapped) ? snapped : raw;
|
||||
if (!fits(time)) continue;
|
||||
cuts.push(time);
|
||||
}
|
||||
|
||||
const bounds = [section.start, ...cuts, section.end];
|
||||
const shots = [];
|
||||
const lastSeen = new Array(variantCount).fill(-1);
|
||||
let previousVariant = -1;
|
||||
for (let i = 0; i < bounds.length - 1; i++) {
|
||||
const variant = i === 0 ? 0 : pickVariant(variantCount, previousVariant, lastSeen, i, rng);
|
||||
lastSeen[variant] = i;
|
||||
previousVariant = variant;
|
||||
shots.push({
|
||||
index: i,
|
||||
startFrame: i === 0 ? section.startFrame : Math.round(bounds[i] * fps),
|
||||
endFrame: i === bounds.length - 2 ? section.endFrame : Math.round(bounds[i + 1] * fps),
|
||||
start: bounds[i],
|
||||
end: bounds[i + 1],
|
||||
variant,
|
||||
// A dissolve is the default. A straight cut is what makes a drop feel
|
||||
// edited, but on anything calmer it reads as a glitch, so cuts are
|
||||
// gated on real energy rather than sprinkled everywhere: nothing below
|
||||
// the threshold ever cuts, and only the loudest material cuts often.
|
||||
hardCut: bias.energy > HARD_CUT_ENERGY
|
||||
&& rng.bool(Math.min(0.85, (bias.energy - HARD_CUT_ENERGY) * 2.5)),
|
||||
});
|
||||
}
|
||||
return shots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Next visual in the rotation.
|
||||
*
|
||||
* The shape is A B A C A D: the anchor comes back between companions, so the
|
||||
* section reads as one idea with departures from it rather than as a playlist.
|
||||
* It is a strong tendency and not a rule — strict alternation is audible as a
|
||||
* pattern within about three cycles.
|
||||
*
|
||||
* When a companion is due, the LEAST RECENTLY SHOWN one wins. With a roster of
|
||||
* four that is the difference between a section showing B, C, D and a section
|
||||
* showing B twice and never reaching D.
|
||||
*/
|
||||
function pickVariant(variantCount, previous, lastSeen, shotIndex, rng) {
|
||||
if (previous !== 0 && rng.bool(0.75)) return 0;
|
||||
|
||||
const options = [];
|
||||
const weights = [];
|
||||
for (let v = 0; v < variantCount; v++) {
|
||||
if (v === previous) continue;
|
||||
options.push(v);
|
||||
// Unseen variants sort first, then by how long ago they were last up.
|
||||
// The anchor stays in the draw so the rotation cannot become rigid.
|
||||
weights.push(v === 0 ? 1 : 2 + (lastSeen[v] < 0 ? variantCount : shotIndex - lastSeen[v]));
|
||||
}
|
||||
if (!options.length) return 0;
|
||||
return rng.pickWeighted(options, weights);
|
||||
}
|
||||
@ -237,6 +237,24 @@ function renderPanel() {
|
||||
if (state.tab === 'scene') {
|
||||
paramPanel.container = dom.panelBody;
|
||||
paramPanel.build(section.layers[0].module, section.layers[0].params);
|
||||
|
||||
// The section's stage visuals, with the one currently on screen marked.
|
||||
// Params above edit the anchor (variant 0) — the image the section opens
|
||||
// and returns to.
|
||||
if (section.variants && section.variants.length > 1) {
|
||||
const active = show.arc.state.variant || 0;
|
||||
const shots = section.shots || [];
|
||||
const list = document.createElement('div');
|
||||
list.className = 'pp-reactive';
|
||||
list.innerHTML = `<div class="pp-sub">stage visuals · ${shots.length} shots</div>` +
|
||||
section.variants.map((stack, v) =>
|
||||
`<div class="pp-react-row${v === active ? ' current' : ''}">` +
|
||||
`<span>${v === 0 ? '●' : '○'} ${stack[0].module.name}</span>` +
|
||||
`<span class="pp-feature">${shots.filter((s) => s.variant === v).length}×</span>` +
|
||||
`</div>`).join('');
|
||||
dom.panelBody.appendChild(list);
|
||||
}
|
||||
|
||||
if (section.layers.length > 1) {
|
||||
const note = document.createElement('div');
|
||||
note.className = 'pp-reactive';
|
||||
@ -252,6 +270,9 @@ function renderPanel() {
|
||||
|
||||
if (state.tab === 'look') {
|
||||
const summary = show.track.summary;
|
||||
// The track's production design. Scenes that cannot express what it is
|
||||
// built on were never cast — see look/Personality.js.
|
||||
const personality = show.look.personality;
|
||||
dom.panelBody.innerHTML = `
|
||||
<div class="pp-heading"><span class="pp-name">${show.fileName || 'track'}</span></div>
|
||||
<div class="kv"><span>seed</span><b>${show.look.seed.toString(16)}</b></div>
|
||||
@ -260,6 +281,13 @@ function renderPanel() {
|
||||
<div class="kv"><span>duration</span><b>${formatTime(show.duration)}</b></div>
|
||||
<div class="kv"><span>sections</span><b>${show.track.sections.length}</b></div>
|
||||
<div class="kv"><span>scheme</span><b>${show.look.paletteScheme}</b></div>
|
||||
<div class="kv"><span>built on</span><b>${personality.signature.join(' + ') || 'nothing'}</b></div>
|
||||
<div class="kv"><span>form</span><b>${personality.shape.sides || 'round'}${
|
||||
personality.shape.sides ? '-sided' : ''}</b></div>
|
||||
<div class="kv"><span>camera</span><b>${(personality.camera.driftRate * 100).toFixed(1)} drift · ${
|
||||
personality.camera.spin >= 0 ? '+' : ''}${personality.camera.spin.toFixed(3)} spin</b></div>
|
||||
<div class="kv"><span>art</span><b>${personality.style.symmetry > 1
|
||||
? personality.style.symmetry + '-fold · ' : ''}line ${personality.style.lineWeight.toFixed(2)}</b></div>
|
||||
<div class="kv"><span>brightness</span><b>${summary.meanCentroid.toFixed(3)}</b></div>
|
||||
<div class="kv"><span>dynamics</span><b>${summary.dynamicRange.toFixed(3)}</b></div>
|
||||
<div class="swatches">${show.look.palette.map((c) =>
|
||||
@ -267,8 +295,9 @@ function renderPanel() {
|
||||
<div class="pp-sub">sections</div>
|
||||
${show.look.sections.map((s, i) => `
|
||||
<div class="kv ${i === index ? 'current' : ''}">
|
||||
<span>${s.kind}${s.locked ? ' 🔒' : ''}</span>
|
||||
<b>${s.layers.map((l) => l.module.name).join(' + ')}</b>
|
||||
<span>${s.kind}${s.locked ? ' 🔒' : ''}
|
||||
${s.shots ? `<i class="dim">${s.shots.length} shots</i>` : ''}</span>
|
||||
<b>${(s.variants || [s.layers]).map((v) => v[0].module.name).join(' / ')}</b>
|
||||
</div>`).join('')}
|
||||
<button id="btn-clicktrack" class="wide">download click track</button>
|
||||
<div class="hint">Mixes clicks onto the detected beat grid. If they don't sit on
|
||||
|
||||
@ -41,6 +41,17 @@ export const REACTIVE_FEATURES = [
|
||||
|
||||
export const REACTIVE_RESPONSES = ['linear', 'spike', 'smooth', 'inverse'];
|
||||
|
||||
/**
|
||||
* Personality traits a scene can honour. See look/Personality.js.
|
||||
*
|
||||
* This is a CONTRACT, not a hint: a track built on `shape` will only cast scenes
|
||||
* that declare `shape`, and it will cast them believing they actually stamp the
|
||||
* signature form. Declaring a trait a scene ignores is worse than declaring
|
||||
* none, because the disqualification rule is the only thing keeping off-design
|
||||
* scenes out of a track.
|
||||
*/
|
||||
export const TRAIT_NAMES = ['shape', 'camera', 'space', 'style'];
|
||||
|
||||
export function defaultValue(def) {
|
||||
if (def.default !== undefined) return def.default;
|
||||
switch (def.type) {
|
||||
@ -140,6 +151,14 @@ export function validateModule(module) {
|
||||
if (!module.name) errors.push('missing `name`');
|
||||
if (!module.family) errors.push(`${id}: missing \`family\``);
|
||||
if (!module.kind) errors.push(`${id}: missing \`kind\``);
|
||||
if (!Array.isArray(module.traits)) {
|
||||
errors.push(`${id}: missing \`traits\` — declare which personality traits it honours ` +
|
||||
`(any of ${TRAIT_NAMES.join(', ')}, or [] for none)`);
|
||||
} else {
|
||||
for (const t of module.traits) {
|
||||
if (!TRAIT_NAMES.includes(t)) errors.push(`${id}: unknown trait '${t}'`);
|
||||
}
|
||||
}
|
||||
if (module.kind === 'fragment' && !module.shader) errors.push(`${id}: kind 'fragment' but no \`shader\``);
|
||||
if (module.kind === 'fragment' && module.shader && !/vec4\s+scene\s*\(/.test(module.shader)) {
|
||||
errors.push(`${id}: shader must define \`vec4 scene(vec2 uv, vec2 p)\``);
|
||||
|
||||
@ -33,23 +33,39 @@ export function deserializeParams(module, stored) {
|
||||
return values;
|
||||
}
|
||||
|
||||
function serializeLayer(layer) {
|
||||
return {
|
||||
scene: layer.module.name,
|
||||
blend: layer.blend,
|
||||
opacity: layer.opacity,
|
||||
seed: layer.seed,
|
||||
params: layer.params,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeLook(look) {
|
||||
return {
|
||||
version: PRESET_VERSION,
|
||||
seed: look.seed,
|
||||
palette: look.palette,
|
||||
// The production design decides which scenes were even eligible, so a
|
||||
// preset without it cannot be reproduced.
|
||||
personality: look.personality,
|
||||
post: look.post,
|
||||
feedback: look.feedback,
|
||||
sections: look.sections.map((s) => ({
|
||||
index: s.index,
|
||||
kind: s.kind,
|
||||
locked: !!s.locked,
|
||||
layers: s.layers.map((l) => ({
|
||||
scene: l.module.name,
|
||||
blend: l.blend,
|
||||
opacity: l.opacity,
|
||||
seed: l.seed,
|
||||
params: l.params,
|
||||
// `layers` is the anchor stack and stays first for compatibility;
|
||||
// `variants` is the full roster the section's shots cut between.
|
||||
layers: s.layers.map(serializeLayer),
|
||||
variants: (s.variants || [s.layers]).map((stack) => stack.map(serializeLayer)),
|
||||
shots: (s.shots || []).map((shot) => ({
|
||||
startFrame: shot.startFrame,
|
||||
endFrame: shot.endFrame,
|
||||
variant: shot.variant,
|
||||
hardCut: !!shot.hardCut,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
@ -15,6 +15,10 @@ export const particleField = {
|
||||
// legitimately black, so it is judged on variance rather than luminance and
|
||||
// the look generator only picks it as an accent layer.
|
||||
role: 'accent',
|
||||
// Personality: see look/Personality.js. A point cloud cannot draw the
|
||||
// signature form and has no horizon, so it claims only the camera — which
|
||||
// it can honour exactly, being the one scene with a real one.
|
||||
traits: ['camera'],
|
||||
|
||||
params: {
|
||||
count: { type: 'int', range: [200, 4000], default: 1200, bias: 'density', noDrift: true },
|
||||
@ -76,7 +80,7 @@ export const particleField = {
|
||||
return { points, geometry, material, positions, colors, phases, max };
|
||||
},
|
||||
|
||||
update({ instance, camera, timeline, features, params, palette }) {
|
||||
update({ instance, camera, timeline, features, params, palette, personality }) {
|
||||
const { geometry, material, positions, colors, phases, max } = instance;
|
||||
const count = Math.min(max, Math.round(params.count));
|
||||
const t = timeline.time;
|
||||
@ -125,8 +129,26 @@ export const particleField = {
|
||||
material.size = params.size;
|
||||
material.opacity = 1;
|
||||
|
||||
camera.position.set(0, 0, 4);
|
||||
camera.lookAt(0, 0, -depth * 0.4);
|
||||
// The track's camera, applied to the only literal camera in the library:
|
||||
// the same slow returning pan, sway and roll every shader scene fakes in
|
||||
// its coordinate space. Bounded and periodic, so a seek still lands on
|
||||
// the same frame as sequential playback.
|
||||
const cam = personality ? personality.camera : null;
|
||||
if (cam) {
|
||||
const pan = 20 * Math.sin(t * 0.05);
|
||||
camera.position.set(
|
||||
Math.cos(cam.driftAngle) * cam.driftRate * pan
|
||||
+ Math.sin(t * cam.swayRate) * cam.sway,
|
||||
Math.sin(cam.driftAngle) * cam.driftRate * pan
|
||||
+ Math.cos(t * cam.swayRate * 0.83) * cam.sway,
|
||||
4,
|
||||
);
|
||||
camera.rotation.z = cam.spin * t;
|
||||
} else {
|
||||
camera.position.set(0, 0, 4);
|
||||
camera.rotation.z = 0;
|
||||
}
|
||||
camera.lookAt(camera.position.x, camera.position.y, -depth * 0.4);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -6,6 +6,8 @@ export const classicWave = {
|
||||
name: 'Classic Wave',
|
||||
family: 'flow',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js.
|
||||
traits: ['shape', 'camera', 'style'],
|
||||
|
||||
params: {
|
||||
rings: { type: 'float', range: [4, 40], default: 18, uniform: 'u_rings', bias: 'density' },
|
||||
@ -31,7 +33,10 @@ export const classicWave = {
|
||||
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float d = length(p);
|
||||
p = sigCamera(p);
|
||||
// The rings take the track's signature form: round tracks get circles,
|
||||
// hexagonal tracks get hexagonal rings, and it costs one call.
|
||||
float d = sigShape(p) + 1.0;
|
||||
float angle = atan(p.y, p.x);
|
||||
float t = u_time * u_speed + u_seed;
|
||||
|
||||
@ -48,6 +53,7 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
|
||||
// Keep the corners from clipping to flat colour.
|
||||
col *= 0.6 + 0.4 * (1.0 - smoothstep(0.8, 1.8, d));
|
||||
col += sigGrain(uv);
|
||||
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
|
||||
@ -8,6 +8,8 @@ export const curlFlow = {
|
||||
name: 'Curl Flow',
|
||||
family: 'flow',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js.
|
||||
traits: ['camera', 'space', 'style'],
|
||||
|
||||
params: {
|
||||
scale: { type: 'float', range: [0.5, 6], default: 2.0, uniform: 'u_scale', bias: 'density' },
|
||||
@ -28,6 +30,7 @@ export const curlFlow = {
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_speed + u_seed;
|
||||
p = sigCamera(p);
|
||||
|
||||
vec2 flow = curl(p * u_scale + vec2(t, -t * 0.7), t * 0.5);
|
||||
vec2 q = p + flow * 0.35;
|
||||
@ -46,6 +49,8 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
col = max(col, trail * u_streak);
|
||||
|
||||
col *= 0.6 + 0.4 * exp(-dot(p, p) * 0.35);
|
||||
col = sigAir(col, p, smoothstep(0.0, 1.7, length(p)));
|
||||
col += sigGrain(uv);
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
`,
|
||||
|
||||
@ -6,13 +6,16 @@ export const floatingGeometry = {
|
||||
name: 'Floating Geometry',
|
||||
family: 'geometric',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js. This scene is nothing but a handful
|
||||
// of shapes, so `shape` is the trait it exists to express.
|
||||
traits: ['shape', 'camera', 'style'],
|
||||
|
||||
params: {
|
||||
count: { type: 'int', range: [2, 14], default: 6, uniform: 'u_count', bias: 'density' },
|
||||
size: { type: 'float', range: [0.04, 0.3],default: 0.15,uniform: 'u_size' },
|
||||
drift: { type: 'float', range: [0.1, 1.5], default: 0.4, uniform: 'u_drift', bias: 'motion', rate: true },
|
||||
spin: { type: 'float', range: [0, 2], default: 0.6, uniform: 'u_spin', rate: true },
|
||||
boxRatio: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_boxRatio' },
|
||||
variety: { type: 'float', range: [0, 0.6], default: 0.25, uniform: 'u_variety' },
|
||||
aura: { type: 'float', range: [0, 1], default: 0.25,uniform: 'u_aura', bias: 'energy' },
|
||||
spread: { type: 'float', range: [0.3, 1.2], default: 0.8, uniform: 'u_spread' },
|
||||
palette: { type: 'palette', count: 5 },
|
||||
@ -24,14 +27,9 @@ export const floatingGeometry = {
|
||||
},
|
||||
|
||||
shader: `
|
||||
float sdCircle(vec2 p, float r) { return length(p) - r; }
|
||||
float sdBox(vec2 p, vec2 b) {
|
||||
vec2 d = abs(p) - b;
|
||||
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
|
||||
}
|
||||
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_drift + u_seed;
|
||||
p = sigCamera(p);
|
||||
|
||||
// Background wash from the two darkest palette entries.
|
||||
vec3 col = mix(pal(0) * 0.18, pal(1) * 0.24, sin(t) * 0.5 + 0.5);
|
||||
@ -49,14 +47,21 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
vec2 sp = rot(t * (0.2 + fract(s) * u_spin)) * (p - pos);
|
||||
|
||||
float size = u_size * (0.6 + fract(s * 0.7) * 0.8);
|
||||
float d = fract(s * 0.37) < u_boxRatio ? sdBox(sp, vec2(size * 0.8)) : sdCircle(sp, size);
|
||||
|
||||
// Every element is the track's signature form. This scene used to pick
|
||||
// between a box and a circle per element, which is precisely the choice
|
||||
// the production design should be making — one video, one cast.
|
||||
// The variety param only scales them apart; it never changes what they are.
|
||||
float scale = size * (1.0 + (fract(s * 0.37) - 0.5) * u_variety);
|
||||
float d = sigShape(sp / max(scale, 1e-3)) * scale;
|
||||
|
||||
vec3 shapeColor = pal(i + int(floor(t * 0.3)));
|
||||
float intensity = smoothstep(0.012, 0.0, d);
|
||||
float intensity = smoothstep(0.012, 0.0, d) + sigEdge(d) * 0.35;
|
||||
col = mix(col, shapeColor, intensity * 0.85);
|
||||
col += shapeColor * (1.0 - smoothstep(0.0, size * 2.2, abs(d))) * u_aura;
|
||||
}
|
||||
|
||||
col += sigGrain(uv);
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
`,
|
||||
|
||||
@ -6,6 +6,8 @@ export const horizonLines = {
|
||||
name: 'Horizon Lines',
|
||||
family: 'minimal',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js.
|
||||
traits: ['space', 'camera', 'style'],
|
||||
|
||||
params: {
|
||||
count: { type: 'float', range: [3, 40], default: 14, uniform: 'u_count', bias: 'density' },
|
||||
@ -25,6 +27,10 @@ export const horizonLines = {
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_speed + u_seed;
|
||||
p = sigCamera(p);
|
||||
// The bundle sits on the track's horizon rather than in the middle of the
|
||||
// frame — this scene is nothing but a horizon, so it should be the shared one.
|
||||
p.y -= sigHorizonY() * 0.6;
|
||||
|
||||
// Displace vertically by a slow wave, strongest at the centre of the frame.
|
||||
float envelope = exp(-p.x * p.x * 1.2);
|
||||
@ -41,7 +47,7 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
float y = slot + offset * (0.4 + fract(fi * 0.37));
|
||||
float d = abs(p.y - y);
|
||||
|
||||
float line = smoothstep(u_thickness, 0.0, d);
|
||||
float line = smoothstep(u_thickness * (0.5 + u_sigLine), 0.0, d);
|
||||
float halo = exp(-d * 26.0) * u_glow;
|
||||
|
||||
vec3 c = pal(i);
|
||||
@ -51,6 +57,8 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
|
||||
// Keep the far edges dark so the lines read as a subject, not wallpaper.
|
||||
col *= 0.55 + 0.45 * exp(-dot(p, p) * 0.5);
|
||||
col = sigAir(col, p, smoothstep(0.0, 1.6, abs(p.y)));
|
||||
col += sigGrain(uv);
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
`,
|
||||
|
||||
@ -5,6 +5,8 @@ export const kaleidoTunnel = {
|
||||
name: 'Kaleido Tunnel',
|
||||
family: 'geometric',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js.
|
||||
traits: ['shape', 'camera', 'style'],
|
||||
|
||||
params: {
|
||||
sides: { type: 'int', range: [2, 12], default: 6, uniform: 'u_sides' },
|
||||
@ -25,9 +27,16 @@ export const kaleidoTunnel = {
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_speed + u_seed;
|
||||
p = sigCamera(p);
|
||||
|
||||
float radius = max(length(p), 1e-4);
|
||||
vec2 folded = kaleido(p, float(u_sides));
|
||||
// A track with a signature polygon dictates the fold count — this is the
|
||||
// scene where that reads most strongly, so its own sides yields to it.
|
||||
float sides = u_sigSides > 2.5 ? u_sigSides : float(u_sides);
|
||||
|
||||
// Cross-section measured in the signature form: the tunnel mouth is the
|
||||
// track's shape rather than a circle.
|
||||
float radius = max(sigShape(p) + 1.0, 1e-4);
|
||||
vec2 folded = kaleido(p, sides);
|
||||
float angle = atan(folded.y, folded.x);
|
||||
|
||||
// Tunnel coordinates: 1/r is depth, angle is the wall.
|
||||
@ -35,7 +44,7 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
float wall = angle / 3.14159265 + sin(z * 0.5 + t) * u_twist * 0.25;
|
||||
|
||||
float ringLines = abs(fract(z * u_rings * 0.1) - 0.5) * 2.0;
|
||||
float wallLines = abs(fract(wall * float(u_sides)) - 0.5) * 2.0;
|
||||
float wallLines = abs(fract(wall * sides) - 0.5) * 2.0;
|
||||
|
||||
float grid = smoothstep(0.42, 0.0, ringLines) + smoothstep(0.42, 0.0, wallLines);
|
||||
|
||||
@ -46,6 +55,7 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
float fade = smoothstep(0.0, 1.1, radius);
|
||||
col *= 0.25 + 0.9 * fade;
|
||||
col += pal(3) * (1.0 - fade) * u_glow * 0.6;
|
||||
col += sigGrain(uv);
|
||||
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
|
||||
@ -7,6 +7,8 @@ export const metaballs = {
|
||||
name: 'Metaballs',
|
||||
family: 'organic',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js.
|
||||
traits: ['shape', 'camera', 'style'],
|
||||
|
||||
params: {
|
||||
count: { type: 'int', range: [2, 10], default: 5, uniform: 'u_count', bias: 'density' },
|
||||
@ -27,6 +29,7 @@ export const metaballs = {
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_speed + u_seed;
|
||||
p = sigCamera(p);
|
||||
|
||||
float field = 0.0;
|
||||
vec3 tint = vec3(0.0);
|
||||
@ -41,7 +44,10 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
cos(t * (0.5 + fract(s * 0.29)) + s * 1.7) * u_spread * 0.62
|
||||
);
|
||||
|
||||
float d = length(p - centre);
|
||||
// Distance measured in the track's form rather than as a circle: for a
|
||||
// round personality this is exactly length(p - centre), and for a
|
||||
// hexagonal one the blobs merge as hexagons.
|
||||
float d = sigShape((p - centre) / max(u_radius, 1e-3)) * u_radius + u_radius;
|
||||
float contribution = (u_radius * u_radius) / max(d * d, 1e-4);
|
||||
field += contribution;
|
||||
tint += pal(i) * contribution;
|
||||
@ -56,6 +62,7 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
vec3 col = pal(0) * 0.06;
|
||||
col = mix(col, tint, surface);
|
||||
col += tint * rim * u_rim;
|
||||
col += sigGrain(uv);
|
||||
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
|
||||
@ -9,6 +9,8 @@ export const moireGrid = {
|
||||
name: 'Moiré Grid',
|
||||
family: 'geometric',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js.
|
||||
traits: ['camera', 'style'],
|
||||
|
||||
params: {
|
||||
density: { type: 'float', range: [6, 60], default: 22, uniform: 'u_density', bias: 'density' },
|
||||
@ -42,12 +44,15 @@ float grid(vec2 q, float density, float width) {
|
||||
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_rotate + u_seed;
|
||||
p = sigFolded(sigCamera(p));
|
||||
|
||||
vec2 warp = vec2(fbm(p * 1.5 + t, 3), fbm(p * 1.5 - t, 3)) - 0.5;
|
||||
vec2 q = p + warp * u_warp;
|
||||
|
||||
float a = grid(rot(t * 6.28318530718) * q, u_density, u_lineWidth);
|
||||
float b = grid(rot(-t * 6.28318530718 + u_offset * 3.14159) * (q + u_offset), u_density, u_lineWidth);
|
||||
// Drawn in the track's hand: its line weight scales this scene's.
|
||||
float weight = u_lineWidth * (0.5 + u_sigLine);
|
||||
float a = grid(rot(t * 6.28318530718) * q, u_density, weight);
|
||||
float b = grid(rot(-t * 6.28318530718 + u_offset * 3.14159) * (q + u_offset), u_density, weight);
|
||||
|
||||
// The interference term is the point: where both grids land, it peaks.
|
||||
float interference = a * b;
|
||||
@ -59,6 +64,7 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
col += pal(3) * pow(interference, 3.0) * u_glow;
|
||||
|
||||
col *= 0.6 + 0.4 * exp(-dot(p, p) * 0.3);
|
||||
col += sigGrain(uv);
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
`,
|
||||
|
||||
@ -8,6 +8,8 @@ export const nebula = {
|
||||
name: 'Deep Nebula',
|
||||
family: 'organic',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js.
|
||||
traits: ['camera', 'space', 'style'],
|
||||
|
||||
params: {
|
||||
scale: { type: 'float', range: [4, 24], default: 12, uniform: 'u_scale', bias: 'density' },
|
||||
@ -27,6 +29,8 @@ export const nebula = {
|
||||
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
// The track's camera films this scene too.
|
||||
p = sigCamera(p);
|
||||
float r = length(p);
|
||||
float a = atan(p.y, p.x);
|
||||
float t = u_time * u_speed + u_seed;
|
||||
@ -51,6 +55,10 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
// Vignette the far field so the frame has a subject.
|
||||
col *= 0.5 + 0.5 * (1.0 - smoothstep(0.6, 1.6, r));
|
||||
|
||||
// The location's air, and its surface.
|
||||
col = sigAir(col, p, smoothstep(0.0, 1.6, r));
|
||||
col += sigGrain(uv);
|
||||
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
`,
|
||||
|
||||
@ -6,6 +6,8 @@ export const plasmaBloom = {
|
||||
name: 'Plasma Bloom',
|
||||
family: 'organic',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js.
|
||||
traits: ['camera', 'space', 'style'],
|
||||
|
||||
params: {
|
||||
scale: { type: 'float', range: [0.8, 6], default: 2.4, uniform: 'u_scale', bias: 'density' },
|
||||
@ -26,6 +28,7 @@ export const plasmaBloom = {
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_speed + u_seed;
|
||||
p = sigCamera(p);
|
||||
|
||||
// Two rounds of domain warping. One looks like noise; two looks organic.
|
||||
vec2 q = vec2(fbm(p * u_scale + t, 4), fbm(p * u_scale + vec2(5.2, 1.3) - t, 4));
|
||||
@ -42,6 +45,9 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
|
||||
// Dark corners so the bloom has somewhere to sit.
|
||||
col *= 0.55 + 0.45 * exp(-dot(p, p) * 0.4);
|
||||
|
||||
col = sigAir(col, p, smoothstep(0.0, 1.8, length(p)));
|
||||
col += sigGrain(uv);
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
`,
|
||||
|
||||
@ -9,6 +9,8 @@ export const psychedelicDrift = {
|
||||
name: 'Psychedelic Drift',
|
||||
family: 'glitch',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js.
|
||||
traits: ['shape', 'camera', 'style'],
|
||||
|
||||
params: {
|
||||
count: { type: 'int', range: [4, 24], default: 12, uniform: 'u_count', bias: 'density' },
|
||||
@ -66,6 +68,7 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
float vs = u_seed + floor(u_variant) * 100.0;
|
||||
|
||||
// Swirling background.
|
||||
p = sigCamera(p);
|
||||
float bg = sin(p.x * 3.0 + t) * cos(p.y * 3.0 - t * 0.7) + sin(length(p) * 4.0 - t);
|
||||
vec3 col = mix(pal(0) * 0.12, pal(1) * 0.18, bg * 0.5 + 0.5);
|
||||
col += pal(0) * 0.05 * beat;
|
||||
@ -101,9 +104,11 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
|
||||
float d;
|
||||
float kind = mod(fi + floor(u_variant), 3.0);
|
||||
// One of the three symbol kinds is the track's own form, so its cast of
|
||||
// characters includes the one every other scene is built from.
|
||||
if (kind == 0.0) d = sdStar(sp, size, 5.0 + floor(hash11(s * 1.2) * 3.0));
|
||||
else if (kind == 1.0) d = sdSmiley(sp, size, u_time, fi);
|
||||
else d = sdCircle(sp, size);
|
||||
else d = sigShape(sp / max(size, 1e-3)) * size;
|
||||
|
||||
float hit = 0.0;
|
||||
for (int j = 0; j < 5; j++) {
|
||||
@ -124,6 +129,7 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
col += pal(i + 2) * intensity * (0.6 + beat);
|
||||
}
|
||||
|
||||
col += sigGrain(uv);
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
`,
|
||||
|
||||
@ -7,6 +7,8 @@ export const ridgeTerrain = {
|
||||
name: 'Ridge Terrain',
|
||||
family: 'structural',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js.
|
||||
traits: ['space', 'camera', 'style'],
|
||||
|
||||
params: {
|
||||
layers: { type: 'int', range: [2, 10], default: 6, uniform: 'u_layers', bias: 'density' },
|
||||
@ -27,16 +29,20 @@ export const ridgeTerrain = {
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_speed + u_seed;
|
||||
p = sigCamera(p);
|
||||
// Its own horizon param decides the framing; the track decides where the
|
||||
// ground actually is, so two scenes with a horizon agree on one.
|
||||
float horizon = clamp(u_horizon + sigHorizonY() * 0.35, -0.85, 0.85);
|
||||
|
||||
// Sky gradient above the horizon.
|
||||
float sky = sat((p.y - u_horizon) * 0.8 + 0.5);
|
||||
float sky = sat((p.y - horizon) * 0.8 + 0.5);
|
||||
vec3 col = mix(pal(0) * 0.35, pal(1) * 0.18, sky);
|
||||
|
||||
// Sparse stars, only in the upper sky, fading as haze rises.
|
||||
if (u_stars > 0.01 && p.y > u_horizon) {
|
||||
if (u_stars > 0.01 && p.y > horizon) {
|
||||
vec2 cell = floor(uv * 220.0);
|
||||
float rnd = hash12(cell);
|
||||
float star = step(0.9975, rnd) * sat((p.y - u_horizon) * 2.0);
|
||||
float star = step(0.9975, rnd) * sat((p.y - horizon) * 2.0);
|
||||
col += vec3(star) * u_stars * (0.6 + 0.4 * sin(t * 8.0 + rnd * 30.0));
|
||||
}
|
||||
|
||||
@ -50,7 +56,7 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
float x = p.x * mix(0.6, 1.8, depth) + t * parallax + fi * 13.7;
|
||||
|
||||
float ridge = fbm(vec2(x, fi * 5.1) * u_rough, 4) - 0.5;
|
||||
float base = u_horizon - depth * 0.28;
|
||||
float base = horizon - depth * 0.28;
|
||||
float top = base + ridge * u_height * mix(0.5, 1.3, depth);
|
||||
|
||||
float mask = smoothstep(0.004, 0.0, p.y - top);
|
||||
@ -64,6 +70,7 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
col += pal(5) * smoothstep(0.02, 0.0, abs(p.y - top)) * (0.12 + depth * 0.25) * u_haze;
|
||||
}
|
||||
|
||||
col += sigGrain(uv);
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
`,
|
||||
|
||||
@ -9,6 +9,8 @@ export const scanTear = {
|
||||
name: 'Scan Tear',
|
||||
family: 'glitch',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js.
|
||||
traits: ['style'],
|
||||
|
||||
params: {
|
||||
slices: { type: 'float', range: [4, 48], default: 16, uniform: 'u_slices', bias: 'density' },
|
||||
@ -39,7 +41,9 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
float rowRandom = hash12(vec2(row, step_));
|
||||
|
||||
// Only some rows tear, and only above the tear threshold.
|
||||
float torn = step(1.0 - u_tear, rowRandom);
|
||||
// Slice weight follows the track's line weight — the same art direction
|
||||
// that thickens a grid line thickens a tear.
|
||||
float torn = step(1.0 - u_tear * (0.6 + u_sigLine), rowRandom);
|
||||
float offset = (rowRandom - 0.5) * 2.0 * u_shift * torn;
|
||||
|
||||
vec2 q = vec2(fract(uv.x + offset), uv.y);
|
||||
@ -65,6 +69,7 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
col = max(col, ghost * u_persist);
|
||||
|
||||
col += pal(4) * torn * u_shift * 0.6;
|
||||
col += sigGrain(uv);
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
`,
|
||||
|
||||
@ -9,6 +9,8 @@ export const slowOrb = {
|
||||
name: 'Slow Orb',
|
||||
family: 'minimal',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js.
|
||||
traits: ['shape', 'camera', 'space', 'style'],
|
||||
|
||||
params: {
|
||||
size: { type: 'float', range: [0.15, 0.8], default: 0.38, uniform: 'u_size', bias: 'energy' },
|
||||
@ -29,12 +31,15 @@ export const slowOrb = {
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_drift + u_seed;
|
||||
|
||||
vec2 centre = vec2(sin(t * 1.7) * 0.28, cos(t * 1.3) * 0.18);
|
||||
vec2 q = p - centre;
|
||||
// The orb drifts about the track's horizon rather than about the middle of
|
||||
// the frame, so it occupies the same space as the scenes that draw ground.
|
||||
vec2 centre = vec2(sin(t * 1.7) * 0.28, cos(t * 1.3) * 0.18 + sigHorizonY() * 0.45);
|
||||
vec2 q = sigCamera(p) - centre;
|
||||
|
||||
// Break the silhouette so it never reads as a hard circle.
|
||||
// The body is the track's signature form — this scene is one shape in an
|
||||
// empty frame, so the shape had better be the track's.
|
||||
float wobble = fbm(q * 2.4 + t, 4) * u_wobble;
|
||||
float d = length(q) * (1.0 + wobble) - u_size;
|
||||
float d = (sigShape(q / max(u_size, 1e-3)) * u_size) * (1.0 + wobble);
|
||||
|
||||
float body = smoothstep(u_softness * 0.5, -u_softness * 0.5, d);
|
||||
float glow = exp(-max(d, 0.0) * (5.0 / max(u_halo, 0.05))) * u_halo;
|
||||
@ -45,7 +50,9 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
col += pal(3) * glow * 0.35;
|
||||
|
||||
// Fine grain keeps large flat areas from banding.
|
||||
col = sigAir(col, p, smoothstep(0.0, 1.6, length(q)));
|
||||
col += (hash12(uv * 640.0 + floor(u_frame)) - 0.5) * u_grain * 0.08;
|
||||
col += sigGrain(uv);
|
||||
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
|
||||
@ -9,6 +9,8 @@ export const spectrumSculpture = {
|
||||
name: 'Spectrum Sculpture',
|
||||
family: 'minimal',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js.
|
||||
traits: ['shape', 'camera', 'style'],
|
||||
|
||||
params: {
|
||||
bars: { type: 'float', range: [8, 96], default: 40, uniform: 'u_bars', bias: 'density' },
|
||||
@ -55,12 +57,15 @@ float bandAt(float x) {
|
||||
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_rotate + u_seed;
|
||||
p = sigCamera(p);
|
||||
|
||||
float bars = u_bars;
|
||||
float seg = 6.28318530718 / bars;
|
||||
|
||||
float angle = atan(p.y, p.x) + t * seg;
|
||||
float radius = length(p);
|
||||
// Radial extent measured in the track's form, so the sculpture is built on
|
||||
// the same outline as everything else in the video.
|
||||
float radius = sigShape(p) + 1.0;
|
||||
float index = floor((angle + 3.14159265) / seg);
|
||||
float cellAngle = mod(angle + 3.14159265, seg) / seg;
|
||||
|
||||
@ -84,7 +89,8 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
col += palRamp(slot * 0.7 + 0.15) * inBar * shape * (0.6 + band);
|
||||
|
||||
// Inner ring outline holds the composition together.
|
||||
col += pal(2) * smoothstep(0.006, 0.0, abs(radius - u_radius)) * 0.35;
|
||||
col += pal(2) * sigEdge(radius - u_radius) * 0.35;
|
||||
col += sigGrain(uv);
|
||||
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
|
||||
@ -10,6 +10,8 @@ export const synthwaveRun = {
|
||||
name: 'Synthwave Run',
|
||||
family: 'structural',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js.
|
||||
traits: ['space', 'camera', 'style'],
|
||||
|
||||
params: {
|
||||
speed: { type: 'float', range: [0.3, 4], default: 1.5, uniform: 'u_speed', bias: 'motion', rate: true },
|
||||
@ -30,7 +32,9 @@ export const synthwaveRun = {
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_speed + u_seed;
|
||||
float beat = u_beat;
|
||||
float horizon = u_horizon;
|
||||
p = sigCamera(p);
|
||||
// Shares the track's ground line with every other scene that has one.
|
||||
float horizon = clamp(u_horizon + sigHorizonY() * 0.35, -0.85, 0.85);
|
||||
|
||||
vec3 colorMain = pal(0);
|
||||
vec3 colorSec = pal(1);
|
||||
@ -97,6 +101,7 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
|
||||
// Horizon haze, scaled by the glow param.
|
||||
col += colorMain * exp(-abs(p.y - horizon) * 8.0) * u_glow * 0.35;
|
||||
col += sigGrain(uv);
|
||||
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
|
||||
@ -118,7 +118,33 @@ export class TimelineStrip {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.72)';
|
||||
ctx.font = '10px ui-monospace, monospace';
|
||||
ctx.fillText(section.kind, x0 + 5, 13);
|
||||
if (look) {
|
||||
|
||||
// Shots: the cuts inside the section. Each is labelled with the
|
||||
// stage visual it shows, so a section that holds one image for a
|
||||
// minute is visible here rather than only on playback.
|
||||
const shots = (look && look.shots && look.shots.length) ? look.shots : null;
|
||||
if (shots) {
|
||||
for (const shot of shots) {
|
||||
const sx = toX(shot.startFrame);
|
||||
const sw = toX(shot.endFrame) - sx;
|
||||
if (shot.index > 0) {
|
||||
ctx.strokeStyle = shot.hardCut
|
||||
? 'rgba(255,255,255,0.34)'
|
||||
: 'rgba(255,255,255,0.16)';
|
||||
ctx.setLineDash(shot.hardCut ? [] : [2, 2]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(Math.round(sx) + 0.5, 0);
|
||||
ctx.lineTo(Math.round(sx) + 0.5, h);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
if (sw > 34) {
|
||||
const stack = look.variants ? look.variants[shot.variant] : look.layers;
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.45)';
|
||||
ctx.fillText(stack[0].module.name, sx + 4, h - 6);
|
||||
}
|
||||
}
|
||||
} else if (look) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.45)';
|
||||
ctx.fillText(look.layers[0].module.name, x0 + 5, h - 6);
|
||||
}
|
||||
|
||||
@ -194,7 +194,9 @@ input[type=range] { accent-color: var(--accent); background: transparent; }
|
||||
|
||||
.pp-reactive { margin-top: 12px; }
|
||||
.pp-react-row { display: grid; grid-template-columns: 1fr auto auto; gap: 8px; font-size: 11px; color: var(--dim); }
|
||||
.pp-react-row.current { color: var(--text); }
|
||||
.pp-feature { color: #6b8afd; }
|
||||
.dim { color: #4b5563; font-style: normal; font-size: 10px; }
|
||||
.pp-amount { color: var(--text); }
|
||||
|
||||
.kv { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; padding: 2px 0; }
|
||||
|
||||
@ -76,8 +76,27 @@ const CONTRACT_UNIFORMS = new Set([
|
||||
'u_bandAir', 'u_flux', 'u_centroid', 'u_flatness', 'u_width', 'u_beat',
|
||||
'u_beatPhase', 'u_barPhase', 'u_phrasePhase', 'u_sectionProgress',
|
||||
'u_sectionEnergy', 'u_buildSlope',
|
||||
'u_sigSides', 'u_sigRound', 'u_sigElong', 'u_sigTilt',
|
||||
'u_sigDrift', 'u_sigSway', 'u_sigSwayRate', 'u_sigSpin', 'u_sigBreathe',
|
||||
'u_sigHorizon', 'u_sigDepth', 'u_sigWash',
|
||||
'u_sigLine', 'u_sigSoft', 'u_sigTexture', 'u_sigFold',
|
||||
]);
|
||||
|
||||
/**
|
||||
* What counts as honouring a personality trait, in shader source.
|
||||
*
|
||||
* The disqualification rule in look/Personality.js is only as good as these
|
||||
* declarations: a scene that claims `shape` and draws circles anyway will be
|
||||
* cast in the hexagon video and be the one shot that looks filmed elsewhere.
|
||||
* So the claim is machine-checked against the source rather than trusted.
|
||||
*/
|
||||
const TRAIT_EVIDENCE = {
|
||||
shape: /\bsig(Shape|Form)\s*\(/,
|
||||
camera: /\bsigCamera\s*\(/,
|
||||
space: /\b(sigHorizonY|sigAir)\s*\(|\bu_sig(Horizon|Depth|Wash)\b/,
|
||||
style: /\b(sigEdge|sigGrain|sigFolded)\s*\(|\bu_sig(Line|Soft|Texture|Fold)\b/,
|
||||
};
|
||||
|
||||
console.log('\nscene schema lint');
|
||||
{
|
||||
const { scenes, FAMILIES } = await import(pathToFileURL(join(SRC, 'scenes/registry.js')).href);
|
||||
@ -98,7 +117,15 @@ console.log('\nscene schema lint');
|
||||
if (module.family && !FAMILIES[module.family]) {
|
||||
fail(`${id}: unknown family '${module.family}'`);
|
||||
}
|
||||
if (module.kind !== 'fragment') continue;
|
||||
if (module.kind !== 'fragment') {
|
||||
// 3D modules honour traits in JS, against the `personality` handed
|
||||
// to update(); there is no shader source to grep, so the evidence
|
||||
// check is just that they read it at all.
|
||||
if ((module.traits || []).length && !/personality/.test(String(module.update))) {
|
||||
fail(`${id}: declares traits but update() never reads \`personality\``);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const src = module.shader || '';
|
||||
const declared = new Map();
|
||||
@ -130,6 +157,16 @@ console.log('\nscene schema lint');
|
||||
fail(`${id}: shader reads ${uniform}, which no param declares (it will silently be 0)`);
|
||||
}
|
||||
|
||||
// Personality traits: declaring one is a promise to express it.
|
||||
for (const trait of module.traits || []) {
|
||||
const evidence = TRAIT_EVIDENCE[trait];
|
||||
if (evidence && !evidence.test(src)) {
|
||||
fail(`${id}: declares trait '${trait}' but the shader never uses it — ` +
|
||||
`either express it or drop the claim, or the scene will be cast ` +
|
||||
`in tracks built on something it ignores`);
|
||||
}
|
||||
}
|
||||
|
||||
// Rate params: anything the shader multiplies absolute time by must be
|
||||
// flagged `rate: true`, which stops reactivity and drift from touching it.
|
||||
// Modulating such a param jumps the phase by elapsed*delta — sixty seconds
|
||||
|
||||
Loading…
Reference in New Issue
Block a user