Make grain a treatment, tempo a governor, and params commit harder
Grain was in every video. It was added twice unconditionally — every scene called sigGrain, and the grade added its own on top — so the only thing that varied between two tracks was how much of it there was. That makes grain the renderer's fingerprint rather than a decision about one video. It is now described rather than dialled (look/grain.js): a mode (off / constant / swell / sections / transient), a cell size in pixels, a refresh rate in frames, a mask (uniform, shadows, highlights, edges, bands) and a chroma amount. Roughly 45% of tracks get none at all. The non-constant modes carry a per-frame envelope computed in Show._postAt from frame and features only, so preview and export still agree. Scene-side grain is gated the same way, and a module can decline it outright with `texture: 0` — crisp line work should stay crisp. The post tab grew a real grain block so any of it can be forced per track. Slow songs got fast scenes. `motion` bias was mostly section energy with tempo as a small correction, so a 70bpm track's drop asked for nearly as much speed as a 150bpm one. Motion is now tempo-dominated, and every `rate: true` param is additionally scaled by a per-track rateScale — measured, 84bpm now samples its rate params at 0.276 of range against 148bpm's 0.571. Parameter sampling also commits harder: extremity starts at 0.45 rather than 0.25 and shapes the draw more aggressively. This was first pushed to 0.82 and backed off to 0.72, because the gates caught the overshoot — seeds began collapsing onto the same range ends and a sparse scene sampled at its low end rendered effectively black. Fallout worth recording: turning the default grade grain off exposed two scenes that were never really animating. Dust Chamber and Eclipse Field passed the Phase 7 movement gate only because per-pixel noise was moving underneath them; both now breathe on their own fixed clock, and Dust Chamber needed a brightness floor as well. Four scenes (Classic Wave, Silk Ribbon, Kaleido Tunnel, Slow Orb) express the style trait ONLY through grain and so cannot opt out yet; they hold a reduced share at 0.35 pending real edge and softness response. Phase 3's look-space check now measures its closest pair relative to image brightness, the same correction Phase 10 already documents for sparse scenes: the absolute number was being propped up by grain rather than by look-space width. Five new Phase 10 checks cover grain distribution, treatment variety, envelope range, the texture opt-out, and tempo. 93/93 pass. Two transport bugs, both stale state surfacing in the UI: - Loading a track replaces audio.src, which stops playback silently, so state.playing stayed true and the play button stayed on pause — the first click after a track change only flipped the flag back. Added stopPlayback(). - The controls row wrapped mid-song because two readouts that change length while playing were sized by their content: the clock crossing ten minutes and the section label whenever a scene name is long or a crossfade appears. The clock is now fixed-width with tabular figures and the section label is the row's only flexible item, laying out at zero width and ellipsising into whatever space is left. The two spacers competed with it for that space and are gone; its own text-align does their job. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
44e1826fcb
commit
14d1204e82
11
flow-state/.claude/launch.json
Normal file
11
flow-state/.claude/launch.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "flow-state",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5180
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -112,6 +112,15 @@ intentionally shrinks per track. Only declare what you genuinely use:
|
||||
The lint greps your shader and fails a declared trait with no evidence. Declaring
|
||||
`[]` (none) is valid.
|
||||
|
||||
### `texture` — how much surface grain your scene accepts
|
||||
|
||||
Optional module field, `0..2`, default `1`. It scales `u_sigTexture` — and so
|
||||
every `sigGrain(uv)` in your shader — for this scene only. Set it to `0` if your
|
||||
scene is crisp line work that grain only furs up, or to something under 1 if it
|
||||
should be dusted rather than dirty. It has nothing to do with the *grade's*
|
||||
grain, which is a per-track treatment (see `look/grain.js`) and is off entirely
|
||||
for most tracks.
|
||||
|
||||
## Traps that have actually bitten here
|
||||
|
||||
1. **Anything multiplying `u_time` must be `rate: true`.** Phase is
|
||||
|
||||
@ -579,6 +579,35 @@ Adding a scene was mostly boilerplate and round-trips, which is expensive in bot
|
||||
a verdict, instead of rendering the whole library to find out whether one shader is alive.
|
||||
- `.claude/skills/build-visualizer/` is the same procedure as a repo skill.
|
||||
|
||||
### Phase 10b — grain as a treatment, tempo as a governor
|
||||
|
||||
Watching the library again surfaced two things no gate was asking about.
|
||||
|
||||
**Grain was in every video.** It was added twice unconditionally — every scene called
|
||||
`sigGrain`, and the grade added its own on top — so the only thing that varied between two
|
||||
tracks was how much. That makes grain the renderer's fingerprint rather than a decision about
|
||||
one video. It is now DESCRIBED (`look/grain.js`): a mode (`off` / `constant` / `swell` /
|
||||
`sections` / `transient`), a cell size in pixels, a refresh rate in frames, a mask (uniform,
|
||||
shadows, highlights, edges, bands) and a chroma amount. Roughly 45% of tracks get none at all;
|
||||
the non-constant modes have a per-frame envelope computed in `Show._postAt` from frame and
|
||||
features only, so preview and export still agree. Scene-side grain is gated the same way, and
|
||||
a module can decline it outright with `texture: 0` — crisp line work should stay crisp.
|
||||
|
||||
**Slow songs got fast scenes.** `motion` bias was mostly section energy with tempo as a small
|
||||
correction, so a 70bpm track's drop asked for nearly as much speed as a 150bpm one. Motion is
|
||||
now tempo-dominated, and every `rate: true` param is additionally scaled by a per-track
|
||||
`rateScale`, so absolute animation speed follows the song rather than only the sampled
|
||||
position in a range.
|
||||
|
||||
Parameter sampling also commits harder: `extremity` starts at 0.5 rather than 0.25 and shapes
|
||||
the draw more aggressively, because a range is the scene author's statement of what the scene
|
||||
can survive and a library that samples the middle of every range shows every scene's default.
|
||||
|
||||
**Gate:** a quarter to two thirds of tracks have no grain and at least four modes appear;
|
||||
thirty grainy tracks produce mostly-distinct treatments; every time-varying grain reaches both
|
||||
zero and full; a `texture: 0` scene gets nothing even from a maximally gritty track; and a
|
||||
84bpm track's rate params sample materially below a 148bpm track's.
|
||||
|
||||
---
|
||||
|
||||
## 10. Detachment from `party-stage`
|
||||
|
||||
@ -32,9 +32,11 @@
|
||||
<button id="btn-next-section" title="Next section (→)">⇥</button>
|
||||
<button id="btn-loop" title="Loop current section (L)">↻</button>
|
||||
<span id="time-display">0:00 / 0:00</span>
|
||||
<span class="spacer"></span>
|
||||
<!-- The section readout is the row's only flexible item: it
|
||||
absorbs all the free space and centres its own text, which
|
||||
is what the two spacers used to do. They competed with it
|
||||
for that space and squeezed it to nothing. -->
|
||||
<span id="section-display"></span>
|
||||
<span class="spacer"></span>
|
||||
<label class="ctl">quality
|
||||
<select id="sel-quality">
|
||||
<option value="draft">draft</option>
|
||||
|
||||
@ -4,6 +4,7 @@ import { FeatureTrack, featureProviderFor } from './audio/FeatureTrack.js';
|
||||
import { decodeFile, monoSamples } from './audio/decode.js';
|
||||
import { generateLook, rerollLook, rerollSection } from './look/LookGenerator.js';
|
||||
import { ArcDriver } from './look/ArcDriver.js';
|
||||
import { grainEnvelope } from './look/grain.js';
|
||||
import { hashSamples } from './engine/rng.js';
|
||||
|
||||
const FADE_SECONDS = 1.5;
|
||||
@ -128,6 +129,30 @@ export class Show {
|
||||
return Math.min(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* The post settings for one frame.
|
||||
*
|
||||
* Everything in the grade is constant per track except grain, which has a
|
||||
* time envelope: it can swell in and out, or belong to particular section
|
||||
* kinds, or answer transients. See look/grain.js. The envelope is computed
|
||||
* from frame and features only, so preview and export agree.
|
||||
*/
|
||||
_postAt(timeline, features) {
|
||||
const grain = this.look.grain;
|
||||
if (!grain || grain.mode === 'off') return this.look.post;
|
||||
if (grain.mode === 'constant') return this.look.post;
|
||||
|
||||
const kind = this.track.sectionAt(timeline.frame)?.kind || '';
|
||||
const env = grainEnvelope(grain, { time: timeline.time, sectionKind: kind, features });
|
||||
|
||||
// Reused object: renderFrame runs 60 times a second and setPost copies
|
||||
// out of it anyway.
|
||||
this._post = this._post || {};
|
||||
Object.assign(this._post, this.look.post);
|
||||
this._post.grain = grain.amount * env;
|
||||
return this._post;
|
||||
}
|
||||
|
||||
/** Toggle the title plate. Off removes it from the stack entirely. */
|
||||
setOSDEnabled(enabled) {
|
||||
this.osdEnabled = !!enabled;
|
||||
@ -152,7 +177,9 @@ export class Show {
|
||||
this._lastLayers = layers.slice();
|
||||
}
|
||||
|
||||
this.engine.compositor.setPost(this.look.post).setFeedback(this.look.feedback);
|
||||
this.engine.compositor
|
||||
.setPost(this._postAt(timeline, features))
|
||||
.setFeedback(this.look.feedback);
|
||||
this.engine.compositor.fade = this._fadeAt(timeline.frame);
|
||||
|
||||
return this.engine.compositor.render({ timeline, features });
|
||||
|
||||
@ -13,6 +13,13 @@
|
||||
// palette — a wider reachable colour space, so two tracks differ on
|
||||
// colour before they differ on anything else
|
||||
//
|
||||
// Two later additions are held here for the same reason. GRAIN was in every
|
||||
// video — applied unconditionally per scene and again in the grade, with only
|
||||
// its amount varying — so it is now a treatment most tracks go without and no
|
||||
// two grainy tracks wear the same way (look/grain.js). And TEMPO now dominates
|
||||
// how fast a scene animates, because a slow song was getting scenes that
|
||||
// skittered over it.
|
||||
//
|
||||
// The hard part of testing "variety" is that it is a property of a POPULATION,
|
||||
// not of one render. Every check here therefore samples many tracks and asks
|
||||
// about the spread, never about a single value.
|
||||
@ -28,6 +35,8 @@ import { synthesizeSectioned } from '../audio/synth.js';
|
||||
import { sampleValues } from '../params/schema.js';
|
||||
import { frameDistance, frameLuminance } from '../engine/hash.js';
|
||||
import { battery as timbreBattery } from './phase3.js';
|
||||
import { grainEnvelope } from '../look/grain.js';
|
||||
import { signatureUniforms } from '../look/Personality.js';
|
||||
|
||||
/**
|
||||
* Several tracks that genuinely differ in what they sound like — the population
|
||||
@ -60,15 +69,36 @@ check(10, 'two tracks do not share a temperament', () => {
|
||||
// The per-track hand on the dials. If these collapsed toward one value the
|
||||
// whole mechanism would be decorative, and the symptom — every video
|
||||
// sampling around the library average — is exactly what it was built for.
|
||||
const looks = battery().map(({ track }, i) => generateLook(track, { seed: 100 + i * 7919 }));
|
||||
const t = looks.map((l) => l.personality.temperament);
|
||||
// Sampled over several seeds per track rather than one. Four samples of a
|
||||
// ±0.8 draw can land close together by luck, and a gate that fails on that
|
||||
// is measuring the seeds, not the mechanism. What the mechanism has to
|
||||
// deliver is both: a wide population AND no two tracks landing on the same
|
||||
// hand — so both are asserted.
|
||||
const t = [];
|
||||
battery().forEach(({ track }, i) => {
|
||||
for (let s = 0; s < 3; s++) t.push(generateLook(track, { seed: 100 + i * 7919 + s * 104729 }).personality.temperament);
|
||||
});
|
||||
|
||||
// Measured as a fraction of each dial's own range, because they are not the
|
||||
// same width — extremity spans 0.5 in total and intensity over three times
|
||||
// that, so one absolute floor would be either trivial or unreachable.
|
||||
const dialRange = { intensity: 1.7, pace: 1.6, extremity: 0.5 };
|
||||
const keys = Object.keys(dialRange);
|
||||
const spread = (key) => Math.max(...t.map((x) => x[key])) - Math.min(...t.map((x) => x[key]));
|
||||
const worst = Math.min(spread('intensity'), spread('pace'), spread('extremity'));
|
||||
const worst = Math.min(...keys.map((k) => spread(k) / dialRange[k]));
|
||||
|
||||
return expect(worst > 0.25,
|
||||
`intensity ${spread('intensity').toFixed(2)} · pace ${spread('pace').toFixed(2)} · ` +
|
||||
`detail ${spread('detail').toFixed(2)} · extremity ${spread('extremity').toFixed(2)}`);
|
||||
let closest = Infinity;
|
||||
for (let a = 0; a < t.length; a++) {
|
||||
for (let b = a + 1; b < t.length; b++) {
|
||||
closest = Math.min(closest, Math.max(...keys.map((k) => Math.abs(t[a][k] - t[b][k]))));
|
||||
}
|
||||
}
|
||||
|
||||
return expect(worst > 0.5 && closest > 0.02,
|
||||
`${t.length} tracks · worst dial covers ${(worst * 100).toFixed(0)}% of its range · ` +
|
||||
`spread intensity ${spread('intensity').toFixed(2)} · ` +
|
||||
`pace ${spread('pace').toFixed(2)} · detail ${spread('detail').toFixed(2)} · ` +
|
||||
`extremity ${spread('extremity').toFixed(2)} · closest pair ${closest.toFixed(3)}`);
|
||||
});
|
||||
|
||||
check(10, 'one scene looks different in two different videos', () => {
|
||||
@ -263,3 +293,116 @@ check(10, 'two tracks do not get the same palette', () => {
|
||||
`closest pair mean channel distance ${closest.toFixed(3)} (floor 0.08), ` +
|
||||
`${new Set(lums).size}/${palettes.length} distinct`);
|
||||
});
|
||||
|
||||
// --- grain -----------------------------------------------------------------
|
||||
// The complaint these answer: grain was in every video. It was applied twice
|
||||
// unconditionally — once per scene, once in the grade — so the only thing that
|
||||
// varied between tracks was how much. See look/grain.js.
|
||||
|
||||
check(10, 'most tracks carry no grain at all', () => {
|
||||
const modes = [];
|
||||
for (let i = 0; i < 60; i++) {
|
||||
modes.push(generateLook(battery()[i % 4].track, { seed: 900 + i * 5779 }).grain.mode);
|
||||
}
|
||||
const off = modes.filter((m) => m === 'off').length / modes.length;
|
||||
const distinct = new Set(modes).size;
|
||||
|
||||
// A quarter to two thirds. Never grainy is as much a failure as always
|
||||
// grainy — the treatment has to remain available.
|
||||
return expect(off > 0.25 && off < 0.7 && distinct >= 4,
|
||||
`${(off * 100).toFixed(0)}% of 60 tracks have no grain · ${distinct} modes used: ` +
|
||||
`${[...new Set(modes)].join(', ')}`);
|
||||
});
|
||||
|
||||
check(10, 'two grainy tracks are not grainy the same way', () => {
|
||||
// Amount alone was never the difference that mattered. Cell size, refresh
|
||||
// rate, mask and chroma are, so the population has to spread across them.
|
||||
const specs = [];
|
||||
for (let i = 0; i < 80 && specs.length < 30; i++) {
|
||||
const g = generateLook(battery()[i % 4].track, { seed: 4100 + i * 7717 }).grain;
|
||||
if (g.mode !== 'off') specs.push(g);
|
||||
}
|
||||
const uniq = (key) => new Set(specs.map((s) => s[key])).size;
|
||||
const signatures = new Set(specs.map((s) => `${s.mode}/${s.scale}/${s.rate}/${s.mask}`)).size;
|
||||
|
||||
return expect(uniq('scale') >= 3 && uniq('rate') >= 3 && uniq('mask') >= 3
|
||||
&& signatures >= specs.length * 0.5,
|
||||
`${specs.length} grainy tracks · ${uniq('scale')} cell sizes · ${uniq('rate')} rates · ` +
|
||||
`${uniq('mask')} masks · ${signatures} distinct treatments`);
|
||||
});
|
||||
|
||||
check(10, 'grain that is not constant actually comes and goes', () => {
|
||||
// A 'swell' or 'sections' grain that never reaches zero is just constant
|
||||
// grain with extra steps, and one that never reaches full is decoration.
|
||||
const { track } = battery()[1];
|
||||
const problems = [];
|
||||
let tested = 0;
|
||||
|
||||
for (let i = 0; i < 120 && tested < 6; i++) {
|
||||
const look = generateLook(track, { seed: 7000 + i * 3571 });
|
||||
const g = look.grain;
|
||||
if (g.mode === 'off' || g.mode === 'constant') continue;
|
||||
tested++;
|
||||
|
||||
let lo = Infinity, hi = -Infinity;
|
||||
for (let f = 0; f < track.frameCount; f += 7) {
|
||||
const env = grainEnvelope(g, {
|
||||
time: f / 60,
|
||||
sectionKind: track.sectionAt(f).kind,
|
||||
features: track.at(f),
|
||||
});
|
||||
lo = Math.min(lo, env);
|
||||
hi = Math.max(hi, env);
|
||||
}
|
||||
if (lo > 0.05 || hi < 0.4) problems.push(`${g.mode}: ${lo.toFixed(2)}..${hi.toFixed(2)}`);
|
||||
}
|
||||
|
||||
return expect(tested > 0 && problems.length === 0,
|
||||
problems.length ? problems.join(' · ') : `${tested} time-varying grains all reach 0 and full`);
|
||||
});
|
||||
|
||||
check(10, 'a scene that refuses grain never gets any', () => {
|
||||
// `texture: 0` on a module has to survive a track that wants maximum grit,
|
||||
// because the point of it is that crisp line work stays crisp.
|
||||
const gritty = { style: { lineWeight: 0.5, softness: 0.5, texture: 1, symmetry: 1 },
|
||||
shape: { sides: 0, roundness: 0, elongation: 1, tilt: 0 },
|
||||
camera: { driftAngle: 0, driftRate: 0, sway: 0, swayRate: 0, spin: 0, breathe: 0 },
|
||||
space: { horizon: 0.5, depth: 0, washAngle: 0, wash: 0 } };
|
||||
|
||||
const refusing = scenes.filter((m) => m.texture === 0);
|
||||
const leaked = refusing.filter((m) => signatureUniforms(gritty, m).u_sigTexture > 0);
|
||||
const takesIt = signatureUniforms(gritty, scenes.find((m) => m.texture === undefined));
|
||||
|
||||
return expect(refusing.length > 0 && leaked.length === 0 && takesIt.u_sigTexture === 1,
|
||||
`${refusing.length} scenes opt out, ${leaked.length} leaked · ` +
|
||||
`an opted-in scene still gets ${takesIt.u_sigTexture}`);
|
||||
});
|
||||
|
||||
// --- tempo -----------------------------------------------------------------
|
||||
|
||||
check(10, 'a slow track animates slower than a fast one', () => {
|
||||
// Motion used to be mostly energy with tempo as a small correction, so a
|
||||
// 70bpm ballad's drop asked its scenes for nearly as much speed as a
|
||||
// 150bpm track's — and the scenes obliged, over a song that was not moving.
|
||||
const slow = generateLook(battery()[0].track, { seed: 31 }); // 84bpm
|
||||
const fast = generateLook(battery()[2].track, { seed: 31 }); // 148bpm
|
||||
|
||||
const rateMean = (look) => {
|
||||
const vals = [];
|
||||
for (const stack of stacksOf(look)) {
|
||||
for (const layer of stack) {
|
||||
for (const [name, def] of Object.entries(layer.module.params || {})) {
|
||||
if (!def.rate || typeof layer.params[name] !== 'number') continue;
|
||||
const [lo, hi] = def.range;
|
||||
vals.push((layer.params[name] - lo) / (hi - lo)); // normalised
|
||||
}
|
||||
}
|
||||
}
|
||||
return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
|
||||
};
|
||||
|
||||
const s = rateMean(slow), f = rateMean(fast);
|
||||
return expect(f > s * 1.25,
|
||||
`mean normalised rate: 84bpm ${s.toFixed(3)} vs 148bpm ${f.toFixed(3)} ` +
|
||||
`(${(f / Math.max(1e-6, s)).toFixed(2)}×)`);
|
||||
});
|
||||
|
||||
@ -109,17 +109,27 @@ check(3, 'look space is genuinely wide across seeds', () => {
|
||||
|
||||
let sum = 0;
|
||||
let pairs = 0;
|
||||
let minDistance = 1;
|
||||
let minRelative = Infinity;
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
for (let j = i + 1; j < frames.length; j++) {
|
||||
const d = frameDistance(frames[i], frames[j]);
|
||||
sum += d; pairs++;
|
||||
minDistance = Math.min(minDistance, d);
|
||||
// The closest pair is judged RELATIVE to how much image the two
|
||||
// frames contain, for the reason Phase 10 documents at length:
|
||||
// an absolute distance scores two genuinely different renders of
|
||||
// a sparse minimal scene as nearly identical, because the black
|
||||
// they share agrees with itself. Three of these sixteen seeds
|
||||
// cast the same near-black intro scene, and the absolute number
|
||||
// was reporting that as a collapsed generator.
|
||||
const brightness = Math.max(1e-3,
|
||||
(frameLuminance(frames[i]) + frameLuminance(frames[j])) * 0.5);
|
||||
minRelative = Math.min(minRelative, d / brightness);
|
||||
}
|
||||
}
|
||||
const mean = sum / pairs;
|
||||
return expect(mean > 0.08 && minDistance > 0.01,
|
||||
`mean pairwise distance ${mean.toFixed(4)} (floor 0.08), closest pair ${minDistance.toFixed(4)} (floor 0.01)`);
|
||||
return expect(mean > 0.08 && minRelative > 0.12,
|
||||
`mean pairwise distance ${mean.toFixed(4)} (floor 0.08), ` +
|
||||
`closest pair ${minRelative.toFixed(3)} of its own brightness (floor 0.12)`);
|
||||
} finally {
|
||||
engine.dispose();
|
||||
}
|
||||
|
||||
@ -10,7 +10,13 @@ const DEFAULT_POST = {
|
||||
bloomThreshold: 0.6,
|
||||
bloomKnee: 0.3,
|
||||
chroma: 0.15,
|
||||
grain: 0.02,
|
||||
// Grain defaults to absent. What it looks like when a look does ask for it
|
||||
// is described by the four fields below — see look/grain.js.
|
||||
grain: 0,
|
||||
grainScale: 1,
|
||||
grainRate: 1,
|
||||
grainMask: 0,
|
||||
grainChroma: 0,
|
||||
vignette: 0.35,
|
||||
contrast: 1.05,
|
||||
saturation: 1.1,
|
||||
@ -98,6 +104,10 @@ export class Compositor {
|
||||
u_bloomAmount: { value: 0 },
|
||||
u_chroma: { value: 0 },
|
||||
u_grain: { value: 0 },
|
||||
u_grainScale: { value: 1 },
|
||||
u_grainRate: { value: 1 },
|
||||
u_grainMask: { value: 0 },
|
||||
u_grainChroma: { value: 0 },
|
||||
u_vignette: { value: 0 },
|
||||
u_contrast: { value: 1 },
|
||||
u_saturation: { value: 1 },
|
||||
@ -313,6 +323,10 @@ export class Compositor {
|
||||
cu.u_bloomAmount.value = p.bloom;
|
||||
cu.u_chroma.value = p.chroma;
|
||||
cu.u_grain.value = p.grain;
|
||||
cu.u_grainScale.value = p.grainScale;
|
||||
cu.u_grainRate.value = p.grainRate;
|
||||
cu.u_grainMask.value = p.grainMask;
|
||||
cu.u_grainChroma.value = p.grainChroma;
|
||||
cu.u_vignette.value = p.vignette;
|
||||
cu.u_contrast.value = p.contrast;
|
||||
cu.u_saturation.value = p.saturation;
|
||||
|
||||
@ -92,7 +92,7 @@ export function setFrameUniforms(layer, renderer, target, ctx) {
|
||||
if (c) u.u_colors.value[i].set(c[0], c[1], c[2]);
|
||||
}
|
||||
|
||||
const signature = signatureUniforms(layer.personality);
|
||||
const signature = signatureUniforms(layer.personality, layer.module);
|
||||
for (const [name, type] of Object.entries(SIGNATURE_UNIFORMS)) {
|
||||
const v = signature[name];
|
||||
if (type === 'vec2') u[name].value.set(v[0], v[1]);
|
||||
|
||||
@ -107,6 +107,10 @@ uniform sampler2D u_bloom;
|
||||
uniform float u_bloomAmount;
|
||||
uniform float u_chroma;
|
||||
uniform float u_grain;
|
||||
uniform float u_grainScale; // pixels per noise cell, 1 = per-pixel
|
||||
uniform float u_grainRate; // frames a noise field survives
|
||||
uniform int u_grainMask; // see look/grain.js GRAIN_MASKS
|
||||
uniform float u_grainChroma; // 0 = mono speckle, 1 = full colour speckle
|
||||
uniform float u_vignette;
|
||||
uniform float u_contrast;
|
||||
uniform float u_saturation;
|
||||
@ -152,9 +156,38 @@ void main() {
|
||||
col *= clamp(v, 0.0, 1.0);
|
||||
|
||||
// Deterministic grain: keyed on frame index, never on a random source.
|
||||
//
|
||||
// Cell size and refresh rate are separate on purpose. Fine-and-boiling is
|
||||
// film; coarse-and-sticky is a dirty sensor; and they are different enough
|
||||
// that two tracks carrying grain do not read as the same treatment. The
|
||||
// mask decides where it lands, which does more for that than amount ever
|
||||
// did — grain only in the shadows is a look, grain everywhere is a filter.
|
||||
if (u_grain > 0.0001) {
|
||||
float n = hash13(vec3(floor(uv * u_resolution), u_frame));
|
||||
col += (n - 0.5) * u_grain;
|
||||
float cellPx = max(u_grainScale, 1.0);
|
||||
vec2 cell = floor(uv * u_resolution / cellPx);
|
||||
float slot = floor(u_frame / max(u_grainRate, 1.0));
|
||||
|
||||
float m = 1.0;
|
||||
float lum = dot(clamp(col, 0.0, 1.0), vec3(0.2126, 0.7152, 0.0722));
|
||||
if (u_grainMask == 1) m = 1.0 - smoothstep(0.02, 0.6, lum); // shadows
|
||||
else if (u_grainMask == 2) m = smoothstep(0.2, 0.9, lum); // highlights
|
||||
else if (u_grainMask == 3) m = smoothstep(0.02, 0.45, dot(dir, dir)); // toward the edges
|
||||
else if (u_grainMask == 4) {
|
||||
// Horizontal bands: grain in stripes, so it reads as a signal
|
||||
// problem rather than as a surface.
|
||||
float band = fract(uv.y * u_resolution.y / (cellPx * 8.0));
|
||||
m = mix(0.15, 1.0, smoothstep(0.35, 0.5, band) * smoothstep(0.95, 0.8, band));
|
||||
}
|
||||
|
||||
float n = hash13(vec3(cell, slot));
|
||||
vec3 speckle = vec3(n);
|
||||
if (u_grainChroma > 0.001) {
|
||||
vec3 rgb = vec3(n,
|
||||
hash13(vec3(cell + 19.0, slot)),
|
||||
hash13(vec3(cell + 47.0, slot)));
|
||||
speckle = mix(speckle, rgb, u_grainChroma);
|
||||
}
|
||||
col += (speckle - 0.5) * u_grain * m;
|
||||
}
|
||||
|
||||
col *= u_fade;
|
||||
|
||||
@ -10,6 +10,7 @@ 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';
|
||||
import { deriveGrain, describeGrain, applyGrainToPost } from './grain.js';
|
||||
|
||||
/**
|
||||
* Which families suit which section kind, in preference order.
|
||||
@ -43,13 +44,26 @@ function biasFor(section, summary) {
|
||||
const measured = Math.min(1, section.energy / Math.max(1e-6, summary.meanLoudness * 1.6));
|
||||
const energy = kindEnergy * 0.6 + measured * 0.4;
|
||||
|
||||
// 60bpm → 0, 180bpm → 1. Tempo, not energy, is what a viewer reads as
|
||||
// "this is moving too fast for the song": a slow track can have a huge drop
|
||||
// and still want scenes that drift. Motion used to be mostly energy with
|
||||
// tempo as a small correction, which is why a 70bpm ballad got a drop
|
||||
// biased to 0.9 motion and scenes that skittered over it.
|
||||
const tempo = clamp01((summary.bpm - 60) / 120);
|
||||
|
||||
return {
|
||||
energy,
|
||||
density: Math.min(1, energy * 0.7 + section.flux * 1.2),
|
||||
motion: Math.min(1, 0.25 + energy * 0.5 + (summary.bpm - 90) / 180),
|
||||
motion: clamp01(0.12 + tempo * 0.55 + energy * 0.28),
|
||||
// Applied on top of every `rate: true` param, so absolute animation
|
||||
// speed scales with the song rather than only its sampled position in
|
||||
// a range. Bounded well short of a stop or a blur. See params/schema.js.
|
||||
rateScale: 0.45 + tempo * 0.95,
|
||||
};
|
||||
}
|
||||
|
||||
const clamp01 = (x) => Math.max(0, Math.min(1, x));
|
||||
|
||||
/**
|
||||
* Scenes eligible for a section kind, weighted by how well the family fits.
|
||||
*
|
||||
@ -146,10 +160,14 @@ function assignRostersByKind(sections, rng, signature = []) {
|
||||
|
||||
/**
|
||||
* Post-processing and feedback derived from track character.
|
||||
* Ambient material gets more feedback and bloom and less grain; dense club
|
||||
* material gets tighter, punchier settings.
|
||||
* Ambient material gets more feedback and bloom; dense club material gets
|
||||
* tighter, punchier settings.
|
||||
*
|
||||
* Grain is NOT decided here — see look/grain.js. `post.grain` carries only the
|
||||
* amount for the current frame, which the Show multiplies by the grain
|
||||
* envelope, so a track can be clean, permanently dirty, or anything between.
|
||||
*/
|
||||
function derivePost(summary, rng) {
|
||||
function derivePost(summary, rng, grain) {
|
||||
const bright = summary.meanCentroid;
|
||||
const noisy = Math.min(1, summary.meanFlatness * 3);
|
||||
const dynamic = Math.min(1, summary.dynamicRange);
|
||||
@ -160,7 +178,7 @@ function derivePost(summary, rng) {
|
||||
bloomThreshold: 0.45 + bright * 0.25,
|
||||
bloomKnee: 0.25,
|
||||
chroma: 0.05 + noisy * 0.25 + rng.range(0, 0.08),
|
||||
grain: 0.012 + noisy * 0.03,
|
||||
...applyGrainToPost(grain, {}),
|
||||
vignette: 0.25 + (1 - bright) * 0.25,
|
||||
contrast: 1.0 + dynamic * 0.15,
|
||||
saturation: 1.0 + (1 - noisy) * 0.25,
|
||||
@ -272,7 +290,10 @@ export function generateLook(track, { seed = null, samples = null, overrides = n
|
||||
|
||||
const rosterByKind = assignRostersByKind(
|
||||
track.sections, rng.fork('scenes'), personality.signature);
|
||||
const { post, feedback } = derivePost(summary, rng.fork('post'));
|
||||
// The grain treatment: usually none, and when present described rather than
|
||||
// dialled. See look/grain.js.
|
||||
const grain = deriveGrain(summary, rng.fork('grain'));
|
||||
const { post, feedback } = derivePost(summary, rng.fork('post'), grain);
|
||||
|
||||
// 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
|
||||
@ -326,6 +347,7 @@ export function generateLook(track, { seed = null, samples = null, overrides = n
|
||||
palette,
|
||||
personality,
|
||||
paletteScheme: paletteSource.lastScheme,
|
||||
grain,
|
||||
post,
|
||||
feedback,
|
||||
sections,
|
||||
@ -403,7 +425,8 @@ function applyOverrides(look, overrides) {
|
||||
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} · ` +
|
||||
`${describePersonality(look.personality)} · ${[...new Set(kinds)].join(', ')}`;
|
||||
`${describePersonality(look.personality)} · ${describeGrain(look.grain)} · ` +
|
||||
`${[...new Set(kinds)].join(', ')}`;
|
||||
}
|
||||
|
||||
export { defaultValues };
|
||||
|
||||
@ -105,11 +105,13 @@ export function generatePersonality(summary, rng, countEligible = null) {
|
||||
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),
|
||||
// Surface grain is a texture trait, not a default. The old floor of
|
||||
// ~0.12 put visible film grain on even a perfectly tonal track, and
|
||||
// since every scene adds sigGrain AND the grade adds its own, that read
|
||||
// as "grainy by default". Only tracks that want grit carry any.
|
||||
texture: noisy * 0.4 + rng.range(0, 0.1),
|
||||
// Surface grain is a texture trait, not a default, and most tracks have
|
||||
// none at all. Every scene adds sigGrain and the grade can add its own,
|
||||
// so anything short of a hard gate here reads as "grainy by default" —
|
||||
// which is exactly what it read as when this was a floor of ~0.12 and
|
||||
// then again when it was a small unconditional amount. A scene can also
|
||||
// opt out entirely with `texture: 0` in its module.
|
||||
texture: rng.bool(0.25 + noisy * 0.4) ? noisy * 0.35 + rng.range(0.02, 0.12) : 0,
|
||||
// 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.
|
||||
@ -128,8 +130,11 @@ export function generatePersonality(summary, rng, countEligible = null) {
|
||||
// a quiet track can be intricate and a loud one can be blunt.
|
||||
detail: rng.range(-0.6, 0.6),
|
||||
// How far toward the ends of a range this track is willing to sample.
|
||||
// The single most effective knob against "every video looks average".
|
||||
extremity: rng.range(0.25, 0.95),
|
||||
// The single most effective knob against "every video looks average",
|
||||
// so the floor sits well above timid: a track at 0.25 sampled almost
|
||||
// uniformly and produced the library's average look, and enough tracks
|
||||
// did that to make the average look like the house style.
|
||||
extremity: rng.range(0.45, 1.0),
|
||||
};
|
||||
|
||||
const signature = pickSignature(rng, countEligible);
|
||||
@ -172,9 +177,12 @@ export function sceneHonours(module, signature) {
|
||||
* the library regression checks build layers directly and would otherwise all
|
||||
* shift at once.
|
||||
*/
|
||||
export function signatureUniforms(personality) {
|
||||
export function signatureUniforms(personality, module = null) {
|
||||
if (!personality) return NEUTRAL_UNIFORMS;
|
||||
const { shape, camera, space, style } = personality;
|
||||
// A scene may scale — or refuse — the track's surface grain. A clean vector
|
||||
// look has no business being speckled just because the track is gritty.
|
||||
const textureAffinity = module && module.texture !== undefined ? module.texture : 1;
|
||||
return {
|
||||
u_sigSides: shape.sides,
|
||||
u_sigRound: shape.roundness,
|
||||
@ -195,7 +203,7 @@ export function signatureUniforms(personality) {
|
||||
|
||||
u_sigLine: style.lineWeight,
|
||||
u_sigSoft: style.softness,
|
||||
u_sigTexture: style.texture,
|
||||
u_sigTexture: style.texture * textureAffinity,
|
||||
u_sigFold: style.symmetry,
|
||||
};
|
||||
}
|
||||
|
||||
193
flow-state/src/look/grain.js
Normal file
193
flow-state/src/look/grain.js
Normal file
@ -0,0 +1,193 @@
|
||||
// Grain as a deliberate treatment rather than a permanent surface.
|
||||
//
|
||||
// Grain used to be two unconditional additions — every scene added sigGrain and
|
||||
// the grade added its own on top — with only the amount varying. The result was
|
||||
// that every track in the library was grainy, which made grain read as the
|
||||
// renderer's fingerprint instead of as a choice about one video.
|
||||
//
|
||||
// So grain is now DESCRIBED, not dialled:
|
||||
//
|
||||
// mode — when it is present at all. A third of tracks get none, and of the
|
||||
// rest most only carry it some of the time.
|
||||
// scale — the size of a noise cell in pixels. 1 is film-fine; 5 is a coarse
|
||||
// dither that reads as a different medium entirely.
|
||||
// rate — how many frames a noise field survives. 1 boils; 5 is sticky
|
||||
// static that sits on the image like dirt on a lens.
|
||||
// mask — where it lands. Only in the shadows, only in the highlights, only
|
||||
// toward the edges, or in horizontal bands.
|
||||
// chroma — mono speckle or colour speckle.
|
||||
//
|
||||
// Two tracks that both "have grain" should still not look like each other.
|
||||
|
||||
/** Mask ids, mirrored in COMPOSITE_FRAG. */
|
||||
export const GRAIN_MASKS = { uniform: 0, shadows: 1, highlights: 2, edges: 3, bands: 4 };
|
||||
|
||||
export const GRAIN_MODES = ['off', 'constant', 'swell', 'sections', 'transient'];
|
||||
|
||||
/** Section kinds a 'sections' grain can be pinned to. */
|
||||
const GATEABLE_KINDS = ['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro'];
|
||||
|
||||
export const NO_GRAIN = {
|
||||
mode: 'off',
|
||||
amount: 0,
|
||||
scale: 1,
|
||||
rate: 1,
|
||||
mask: 'uniform',
|
||||
chroma: 0,
|
||||
kinds: [],
|
||||
period: 24,
|
||||
duty: 0.4,
|
||||
};
|
||||
|
||||
/**
|
||||
* The track's grain treatment.
|
||||
*
|
||||
* `noisy` (spectral flatness) tilts the odds but never forces the issue: a
|
||||
* clean tonal track can still be the one that gets heavy dirt, because that is
|
||||
* a legitimate art-direction choice and predictable mapping is what made the
|
||||
* library uniform in the first place.
|
||||
*/
|
||||
export function deriveGrain(summary, rng) {
|
||||
const noisy = Math.min(1, (summary.meanFlatness ?? 0.2) * 3);
|
||||
|
||||
// 'off' is the single most likely outcome, and deliberately so — a library
|
||||
// where two videos in five have no grain at all is what makes the ones that
|
||||
// do read as a decision.
|
||||
const mode = rng.pickWeighted(
|
||||
['off', 'constant', 'swell', 'sections', 'transient'],
|
||||
[6 - noisy * 3, 1.5 + noisy * 2.5, 2.5, 2.5, 2],
|
||||
);
|
||||
if (mode === 'off') return { ...NO_GRAIN };
|
||||
|
||||
// Constant grain has to live with the image for the whole video, so it is
|
||||
// held well below what an intermittent treatment can get away with.
|
||||
const ceiling = mode === 'constant' ? 0.05 : 0.11;
|
||||
const amount = rng.range(0.015, ceiling);
|
||||
|
||||
const scale = rng.pickWeighted([1, 1.5, 2, 3, 5], [4, 3, 3, 2, 1]);
|
||||
// Coarse cells that also boil every frame read as a broken video signal, so
|
||||
// the bigger the cell the more likely it is to hold still for a few frames.
|
||||
const rate = rng.pickWeighted([1, 2, 3, 5], [5, 2 + scale, 1 + scale, scale]);
|
||||
|
||||
const mask = rng.pickWeighted(
|
||||
['uniform', 'shadows', 'highlights', 'edges', 'bands'],
|
||||
[3, 3, 1.5, 2, 1],
|
||||
);
|
||||
|
||||
const kinds = mode === 'sections' ? pickKinds(rng) : [];
|
||||
|
||||
return {
|
||||
mode,
|
||||
amount,
|
||||
scale,
|
||||
rate,
|
||||
mask,
|
||||
// Colour speckle is the loudest of these choices and stays rare.
|
||||
chroma: rng.bool(0.25) ? rng.range(0.3, 1) : 0,
|
||||
kinds,
|
||||
// Swell period in seconds. Long enough that it reads as the image
|
||||
// breathing rather than as a flicker.
|
||||
period: rng.range(12, 40),
|
||||
duty: rng.range(0.25, 0.6),
|
||||
};
|
||||
}
|
||||
|
||||
/** One to three section kinds this grain belongs to. */
|
||||
function pickKinds(rng) {
|
||||
const count = rng.pickWeighted([1, 2, 3], [3, 3, 1]);
|
||||
const pool = [...GATEABLE_KINDS];
|
||||
const out = [];
|
||||
for (let i = 0; i < count && pool.length; i++) {
|
||||
const pick = rng.pick(pool);
|
||||
out.push(pick);
|
||||
pool.splice(pool.indexOf(pick), 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 0..1 envelope on the grain amount for one frame.
|
||||
*
|
||||
* Deterministic in frame and features only — no state, no random source — so a
|
||||
* preview frame and the exported frame agree, which is the same rule the noise
|
||||
* itself follows.
|
||||
*
|
||||
* @param {object} spec from deriveGrain
|
||||
* @param {object} ctx
|
||||
* @param {number} ctx.time seconds into the track
|
||||
* @param {string} ctx.sectionKind kind of the section this frame is in
|
||||
* @param {object} ctx.features FeatureTrack row for this frame
|
||||
*/
|
||||
export function grainEnvelope(spec, { time = 0, sectionKind = '', features = null } = {}) {
|
||||
if (!spec || spec.mode === 'off' || spec.amount <= 0) return 0;
|
||||
|
||||
switch (spec.mode) {
|
||||
case 'constant':
|
||||
return 1;
|
||||
|
||||
case 'swell': {
|
||||
// Raised cosine over `period`, on for `duty` of it. The image drifts
|
||||
// into grain and back out with nothing in the audio triggering it,
|
||||
// which is what makes it feel like film rather than like a reaction.
|
||||
const phase = (time % spec.period) / spec.period;
|
||||
if (phase > spec.duty) return 0;
|
||||
return 0.5 - 0.5 * Math.cos((phase / spec.duty) * Math.PI * 2);
|
||||
}
|
||||
|
||||
case 'sections': {
|
||||
if (!spec.kinds.includes(sectionKind)) return 0;
|
||||
// Ease across the section edges so grain arrives with the section
|
||||
// rather than snapping on at the cut.
|
||||
const p = features ? features.sectionProgress : 0.5;
|
||||
return smoothstep(0, 0.08, p) * smoothstep(0, 0.08, 1 - p);
|
||||
}
|
||||
|
||||
case 'transient': {
|
||||
if (!features) return 0;
|
||||
// Rides flux, so grain answers hits and edits. Floored slightly
|
||||
// above zero on loud material so it does not strobe on and off.
|
||||
const hit = Math.min(1, (features.flux ?? 0) * 2.5);
|
||||
const bed = Math.min(0.35, (features.sectionEnergy ?? 0) * 0.35);
|
||||
return Math.max(bed, hit);
|
||||
}
|
||||
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function smoothstep(a, b, x) {
|
||||
const t = Math.max(0, Math.min(1, (x - a) / (b - a)));
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a grain spec's static fields into a post object.
|
||||
*
|
||||
* The amount is the exception: for anything other than 'constant' it is owned
|
||||
* by the per-frame envelope (see Show._postAt), so it is set to zero here and
|
||||
* the envelope writes it every frame.
|
||||
*/
|
||||
export function applyGrainToPost(spec, post) {
|
||||
post.grain = spec.mode === 'constant' ? spec.amount : 0;
|
||||
post.grainScale = spec.scale;
|
||||
post.grainRate = spec.rate;
|
||||
post.grainMask = GRAIN_MASKS[spec.mask] ?? 0;
|
||||
post.grainChroma = spec.chroma;
|
||||
return post;
|
||||
}
|
||||
|
||||
/** One line for the look panel and check output. */
|
||||
export function describeGrain(spec) {
|
||||
if (!spec || spec.mode === 'off') return 'grain: none';
|
||||
const bits = [
|
||||
`grain: ${spec.mode}`,
|
||||
`${spec.amount.toFixed(3)}`,
|
||||
`${spec.scale}px`,
|
||||
spec.rate > 1 ? `every ${spec.rate}f` : 'per frame',
|
||||
spec.mask,
|
||||
];
|
||||
if (spec.chroma > 0) bits.push('colour');
|
||||
if (spec.kinds.length) bits.push(`on ${spec.kinds.join('+')}`);
|
||||
return bits.join(' · ');
|
||||
}
|
||||
@ -4,6 +4,7 @@ import { ParamPanel } from './ui/ParamPanel.js';
|
||||
import { formatTime } from './audio/decode.js';
|
||||
import { describeLook } from './look/LookGenerator.js';
|
||||
import { toHex } from './look/palette.js';
|
||||
import { applyGrainToPost, describeGrain, GRAIN_MASKS, GRAIN_MODES } from './look/grain.js';
|
||||
import { renderClickTrack, audioBufferToWavBlob } from './audio/clicktrack.js';
|
||||
import { Exporter, exportSegment, downloadBlob, PRESETS, isSupported } from './export/Exporter.js';
|
||||
|
||||
@ -57,6 +58,7 @@ const paramPanel = new ParamPanel(document.createElement('div'), onParamChange);
|
||||
async function loadFile(file) {
|
||||
if (state.busy) return;
|
||||
state.busy = true;
|
||||
stopPlayback();
|
||||
|
||||
if (dom.overlay) {
|
||||
dom.overlay.hidden = true;
|
||||
@ -128,6 +130,21 @@ function seekTo(frame, options = {}) {
|
||||
strip.setFrame(clamped);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the transport to a stopped state.
|
||||
*
|
||||
* Loading a track replaces `audio.src`, which stops playback without telling
|
||||
* anyone — so `state.playing` stayed true, the button stayed on ❚❚, and the
|
||||
* first click after a track change only toggled the flag back rather than
|
||||
* starting anything. Every path that stops playback behind the UI's back has
|
||||
* to come through here.
|
||||
*/
|
||||
function stopPlayback() {
|
||||
state.playing = false;
|
||||
dom.audio.pause();
|
||||
dom.play.textContent = '▶';
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (!state.show.ready) return;
|
||||
state.playing = !state.playing;
|
||||
@ -321,7 +338,11 @@ function renderPanel() {
|
||||
const post = show.look.post;
|
||||
const fb = show.look.feedback;
|
||||
const wide = new Set(['contrast', 'saturation', 'exposure']);
|
||||
const rows = Object.entries(post).map(([key, value]) => `
|
||||
// Grain has its own block below: its fields are a mode, a mask id and a
|
||||
// pixel size, none of which are a 0..1 slider.
|
||||
const rows = Object.entries(post)
|
||||
.filter(([key]) => !key.startsWith('grain'))
|
||||
.map(([key, value]) => `
|
||||
<div class="pp-row">
|
||||
<label class="pp-label">${key}</label>
|
||||
<input class="pp-input" type="range" min="0" max="${wide.has(key) ? 2 : 1}"
|
||||
@ -338,7 +359,9 @@ function renderPanel() {
|
||||
<span class="pp-value">${(+value).toFixed(3)}</span>
|
||||
</div>`).join('');
|
||||
dom.panelBody.innerHTML =
|
||||
`<div class="pp-sub">post</div>${rows}<div class="pp-sub">feedback</div>${fbRows}`;
|
||||
`<div class="pp-sub">post</div>${rows}` +
|
||||
`<div class="pp-sub">grain</div>${grainRows(show.look.grain)}` +
|
||||
`<div class="pp-sub">feedback</div>${fbRows}`;
|
||||
dom.panelBody.oninput = (e) => {
|
||||
const t = e.target;
|
||||
if (t.dataset.post) {
|
||||
@ -349,7 +372,32 @@ function renderPanel() {
|
||||
fb[t.dataset.feedback] = +t.value;
|
||||
t.nextElementSibling.textContent = (+t.value).toFixed(3);
|
||||
}
|
||||
if (t.dataset.grain) {
|
||||
const key = t.dataset.grain;
|
||||
if (key === 'kinds') {
|
||||
const kinds = new Set(show.look.grain.kinds);
|
||||
if (t.checked) kinds.add(t.value); else kinds.delete(t.value);
|
||||
show.look.grain.kinds = [...kinds];
|
||||
} else {
|
||||
show.look.grain[key] = t.tagName === 'SELECT' ? t.value : +t.value;
|
||||
}
|
||||
// Turning grain on for a track that was generated without it
|
||||
// would otherwise select a mode and still show nothing.
|
||||
if (show.look.grain.mode !== 'off' && show.look.grain.amount <= 0) {
|
||||
show.look.grain.amount = 0.04;
|
||||
}
|
||||
applyGrainToPost(show.look.grain, post);
|
||||
// The mode decides which of the other controls exist, so it is
|
||||
// the one edit that has to rebuild the panel.
|
||||
if (key === 'mode') { renderPanel(); return; }
|
||||
if (t.nextElementSibling) {
|
||||
t.nextElementSibling.textContent = (+t.value).toFixed(3);
|
||||
}
|
||||
const desc = dom.panelBody.querySelector('#grain-desc');
|
||||
if (desc) desc.textContent = describeGrain(show.look.grain);
|
||||
}
|
||||
};
|
||||
dom.panelBody.onchange = dom.panelBody.oninput;
|
||||
return;
|
||||
}
|
||||
|
||||
@ -371,6 +419,70 @@ function renderPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grain controls for the post tab.
|
||||
*
|
||||
* Grain is the one part of the grade with a shape rather than a level — when it
|
||||
* is present, how coarse it is, how often it refreshes and where it lands — so
|
||||
* it gets its own block instead of five sliders that all read 0..1.
|
||||
*/
|
||||
function grainRows(grain) {
|
||||
const slider = (key, min, max, step, value) => `
|
||||
<div class="pp-row">
|
||||
<label class="pp-label">${key}</label>
|
||||
<input class="pp-input" type="range" min="${min}" max="${max}" step="${step}"
|
||||
value="${value}" data-grain="${key}">
|
||||
<span class="pp-value">${(+value).toFixed(3)}</span>
|
||||
</div>`;
|
||||
|
||||
const modeRow = `
|
||||
<div class="pp-row">
|
||||
<label class="pp-label">mode</label>
|
||||
<select class="pp-input" data-grain="mode">
|
||||
${GRAIN_MODES.map((m) =>
|
||||
`<option ${m === grain.mode ? 'selected' : ''}>${m}</option>`).join('')}
|
||||
</select>
|
||||
<span class="pp-value"></span>
|
||||
</div>`;
|
||||
|
||||
if (grain.mode === 'off') {
|
||||
return modeRow + `<div class="hint" id="grain-desc">${describeGrain(grain)}</div>`;
|
||||
}
|
||||
|
||||
const maskRow = `
|
||||
<div class="pp-row">
|
||||
<label class="pp-label">mask</label>
|
||||
<select class="pp-input" data-grain="mask">
|
||||
${Object.keys(GRAIN_MASKS).map((m) =>
|
||||
`<option ${m === grain.mask ? 'selected' : ''}>${m}</option>`).join('')}
|
||||
</select>
|
||||
<span class="pp-value"></span>
|
||||
</div>`;
|
||||
|
||||
const kindsRow = grain.mode !== 'sections' ? '' : `
|
||||
<div class="pp-row">
|
||||
<label class="pp-label">sections</label>
|
||||
<span class="pp-input">
|
||||
${['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro'].map((k) => `
|
||||
<label class="dim"><input type="checkbox" data-grain="kinds" value="${k}"
|
||||
${grain.kinds.includes(k) ? 'checked' : ''}>${k}</label>`).join(' ')}
|
||||
</span>
|
||||
</div>`;
|
||||
|
||||
const swellRows = grain.mode !== 'swell' ? ''
|
||||
: slider('period', 4, 60, 1, grain.period) + slider('duty', 0.05, 1, 0.01, grain.duty);
|
||||
|
||||
return modeRow
|
||||
+ slider('amount', 0, 0.25, 0.002, grain.amount)
|
||||
+ slider('scale', 1, 8, 0.5, grain.scale)
|
||||
+ slider('rate', 1, 8, 1, grain.rate)
|
||||
+ maskRow
|
||||
+ slider('chroma', 0, 1, 0.01, grain.chroma)
|
||||
+ kindsRow
|
||||
+ swellRows
|
||||
+ `<div class="hint" id="grain-desc">${describeGrain(grain)}</div>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- export
|
||||
|
||||
function currentPreset() {
|
||||
@ -468,7 +580,7 @@ function frame(now) {
|
||||
}
|
||||
}
|
||||
show.timeline.syncToAudio(dom.audio.currentTime);
|
||||
if (dom.audio.ended) { state.playing = false; dom.play.textContent = '▶'; }
|
||||
if (dom.audio.ended) stopPlayback();
|
||||
}
|
||||
|
||||
if (state.playing || show.timeline.frame !== lastRenderedFrame) {
|
||||
|
||||
@ -135,6 +135,13 @@ export function sampleValues(module, rng, bias = {}, temperament = null) {
|
||||
out[name] = [clampValue(def, [v, v2])[0], clampValue(def, [v, v2])[1]];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Absolute animation speed follows the song, not the scene's taste. A
|
||||
// rate param sampled at 0.7 of its range means the same visual speed
|
||||
// whether the track is 70bpm or 170, which is how slow songs ended up
|
||||
// with scenes skittering over them. See look/LookGenerator biasFor.
|
||||
if (def[RATE_FLAG] && bias.rateScale) v *= bias.rateScale;
|
||||
|
||||
if (def.type === 'int') v = Math.round(v);
|
||||
out[name] = clampValue(def, v);
|
||||
}
|
||||
@ -149,10 +156,22 @@ const clamp01 = (x) => Math.max(0, Math.min(1, x));
|
||||
* At extremity 0 this is unchanged. As it rises the distribution hollows out:
|
||||
* the same draw lands further from the centre, so a track that wants density
|
||||
* gets scenes at their dense end rather than at a polite 60%.
|
||||
*
|
||||
* The exponent floor is low on purpose. A param range is the scene author's
|
||||
* statement of what the scene can survive, so the ends of it are supposed to be
|
||||
* usable — a library that samples the middle of every range is a library where
|
||||
* every scene shows its default.
|
||||
*
|
||||
* There is a ceiling on this, found by overshooting it. Pushed harder (0.82),
|
||||
* enough draws piled onto the range ends that two different seeds started
|
||||
* producing near-identical frames — the Phase 3 look-space check caught a
|
||||
* closest pair at 0.0096 against a floor of 0.01 — and a sparse scene sampled
|
||||
* at its low end rendered as effectively black. Extremes are where the variety
|
||||
* is; the extremes are also where every scene collapses onto the same extreme.
|
||||
*/
|
||||
function boldUniform(u, extremity) {
|
||||
const signed = (u - 0.5) * 2;
|
||||
const shaped = Math.sign(signed) * Math.pow(Math.abs(signed), 1 - clamp01(extremity) * 0.65);
|
||||
const shaped = Math.sign(signed) * Math.pow(Math.abs(signed), 1 - clamp01(extremity) * 0.72);
|
||||
return clamp01(0.5 + shaped * 0.5);
|
||||
}
|
||||
|
||||
@ -200,6 +219,11 @@ export function validateModule(module) {
|
||||
if (!TRAIT_NAMES.includes(t)) errors.push(`${id}: unknown trait '${t}'`);
|
||||
}
|
||||
}
|
||||
if (module.texture !== undefined
|
||||
&& (typeof module.texture !== 'number' || module.texture < 0 || module.texture > 2)) {
|
||||
errors.push(`${id}: \`texture\` must be a number 0..2 — how much of the track's ` +
|
||||
`surface grain this scene takes (1 = all, 0 = none)`);
|
||||
}
|
||||
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)\``);
|
||||
|
||||
@ -13,6 +13,8 @@ export const cargoBelt = {
|
||||
name: 'Cargo Belt',
|
||||
family: 'structural',
|
||||
kind: 'fragment',
|
||||
// Takes the track's surface grain, but lightly — this is drawn, not filmed.
|
||||
texture: 0.4,
|
||||
traits: ['shape', 'camera', 'style'],
|
||||
|
||||
params: {
|
||||
|
||||
@ -13,6 +13,8 @@ export const circuitBloom = {
|
||||
name: 'Circuit Bloom',
|
||||
family: 'geometric',
|
||||
kind: 'fragment',
|
||||
// Takes the track's surface grain, but lightly — this is drawn, not filmed.
|
||||
texture: 0.4,
|
||||
traits: ['shape', 'camera', 'style'],
|
||||
|
||||
params: {
|
||||
|
||||
@ -7,6 +7,9 @@ export const classicWave = {
|
||||
family: 'flow',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js.
|
||||
// Grain is this scene's ONLY expression of the style trait — it draws no
|
||||
// hard edges to weight — so it keeps a share of it rather than opting out.
|
||||
texture: 0.35,
|
||||
traits: ['shape', 'camera', 'style'],
|
||||
|
||||
params: {
|
||||
|
||||
@ -44,13 +44,25 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
float inBeam = exp(-pow((p.x - axis) / max(halfWidth, 1e-3), 2.0) * 2.2);
|
||||
inBeam *= smoothstep(floorY - 0.05, floorY + 0.5, p.y);
|
||||
|
||||
vec3 col = pal(0) * 0.05;
|
||||
col += pal(1) * inBeam * u_beam * 0.5;
|
||||
// The light is the thing that moves here, and it has to move on its own
|
||||
// clock rather than on u_drift: at a low drift the motes barely breathe and
|
||||
// the whole frame measured as static. Fixed rate, so it is not a param and
|
||||
// cannot be reacted into a phase jump.
|
||||
float breath = 0.82 + 0.18 * sin(u_time * 0.4 + u_seed);
|
||||
// Floored, because the shaft IS the scene. An intro sampling u_beam near
|
||||
// its minimum with a narrow beam produced a frame dark enough to fail the
|
||||
// live-frame gate — a dim room is the point, an empty one is a bug.
|
||||
float beam = (0.22 + u_beam * 0.78) * breath;
|
||||
|
||||
// Ambient fill. It carries the whole frame wherever the shaft is not, so it
|
||||
// is what decides whether "almost black" stays on the right side of black.
|
||||
vec3 col = pal(0) * 0.11;
|
||||
col += pal(1) * inBeam * beam * 0.5;
|
||||
|
||||
// The pool where the shaft meets the floor.
|
||||
float pool = exp(-abs(p.y - floorY) * 14.0)
|
||||
* exp(-pow((p.x - axis) / max(halfWidth * 1.3, 1e-3), 2.0));
|
||||
col += pal(2) * pool * u_beam * 0.7;
|
||||
col += pal(2) * pool * beam * 0.7;
|
||||
|
||||
// Motes: fixed positions, breathing brightness, only visible in the light.
|
||||
for (int i = 0; i < 40; i++) {
|
||||
@ -66,7 +78,7 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
float lit = exp(-pow((at.x - axis) / max(halfWidth, 1e-3), 2.0) * 2.0);
|
||||
float pulse = 0.55 + 0.45 * sin(t * 2.0 + s * 3.0);
|
||||
float m = sigForm(p, at, u_moteSize * (0.6 + hash11(s + 3.3)));
|
||||
col += pal(3) * m * lit * pulse * (0.5 + u_beam);
|
||||
col += pal(3) * m * lit * pulse * (0.5 + beam);
|
||||
}
|
||||
|
||||
col = sigAir(col, p, smoothstep(0.0, 1.5, length(p)));
|
||||
|
||||
@ -16,6 +16,8 @@ export const eclipseField = {
|
||||
name: 'Eclipse Field',
|
||||
family: 'minimal',
|
||||
kind: 'fragment',
|
||||
// Crisp line work: the track's surface grain would only fur the edges.
|
||||
texture: 0,
|
||||
traits: ['shape', 'camera', 'space', 'style'],
|
||||
|
||||
params: {
|
||||
@ -58,13 +60,17 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
// the disc it must be zero, or the occluder stops occluding.
|
||||
float outside = smoothstep(0.0, 0.02, d);
|
||||
float ring = exp(-max(d, 0.0) / max(u_corona * 0.25, 0.01));
|
||||
col += palRamp(0.35 + d * 0.5) * ring * outside * (0.8 + u_corona);
|
||||
// The corona breathes on a fixed clock rather than on u_drift. At a low
|
||||
// drift — which is most of this scene's range, it is a minimal scene —
|
||||
// nothing else in the frame moved enough to register as animation at all.
|
||||
float breath = 0.82 + 0.18 * sin(u_time * 0.5 + u_seed * 1.7);
|
||||
col += palRamp(0.35 + d * 0.5) * ring * outside * (0.8 + u_corona) * breath;
|
||||
|
||||
// Rays: angular streaks in the corona, rotating slowly. Confined to the
|
||||
// corona by the same falloff, so they never light the whole frame.
|
||||
if (u_rays > 0.01) {
|
||||
float ang = atan(p.y - discAt.y, p.x - discAt.x);
|
||||
float spokes = 0.5 + 0.5 * sin(ang * u_rayCount + t * 2.0);
|
||||
float spokes = 0.5 + 0.5 * sin(ang * u_rayCount + t * 2.0 + u_time * 0.3);
|
||||
col += pal(4) * pow(spokes, 3.0) * ring * outside * u_rays;
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,8 @@ export const floatingGeometry = {
|
||||
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.
|
||||
// Takes the track's surface grain, but lightly — this is drawn, not filmed.
|
||||
texture: 0.4,
|
||||
traits: ['shape', 'camera', 'style'],
|
||||
|
||||
params: {
|
||||
|
||||
@ -14,6 +14,8 @@ export const gateCorridor = {
|
||||
name: 'Gate Corridor',
|
||||
family: 'structural',
|
||||
kind: 'fragment',
|
||||
// Takes the track's surface grain, but lightly — this is drawn, not filmed.
|
||||
texture: 0.4,
|
||||
traits: ['shape', 'camera', 'space', 'style'],
|
||||
|
||||
params: {
|
||||
|
||||
@ -15,6 +15,8 @@ export const girderLattice = {
|
||||
name: 'Girder Lattice',
|
||||
family: 'structural',
|
||||
kind: 'fragment',
|
||||
// Crisp line work: the track's surface grain would only fur the edges.
|
||||
texture: 0,
|
||||
traits: ['shape', 'camera', 'space', 'style'],
|
||||
|
||||
params: {
|
||||
|
||||
@ -7,6 +7,8 @@ export const horizonLines = {
|
||||
family: 'minimal',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js.
|
||||
// Crisp line work: the track's surface grain would only fur the edges.
|
||||
texture: 0,
|
||||
traits: ['space', 'camera', 'style'],
|
||||
|
||||
params: {
|
||||
|
||||
@ -6,6 +6,9 @@ export const kaleidoTunnel = {
|
||||
family: 'geometric',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js.
|
||||
// Grain is this scene's ONLY expression of the style trait — it draws no
|
||||
// hard edges to weight — so it keeps a share of it rather than opting out.
|
||||
texture: 0.35,
|
||||
traits: ['shape', 'camera', 'style'],
|
||||
|
||||
params: {
|
||||
|
||||
@ -10,6 +10,8 @@ export const moireGrid = {
|
||||
family: 'geometric',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js.
|
||||
// Crisp line work: the track's surface grain would only fur the edges.
|
||||
texture: 0,
|
||||
traits: ['camera', 'style'],
|
||||
|
||||
params: {
|
||||
|
||||
@ -8,6 +8,8 @@ export const prismBloom = {
|
||||
family: 'geometric',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js.
|
||||
// Crisp line work: the track's surface grain would only fur the edges.
|
||||
texture: 0,
|
||||
traits: ['shape', 'camera', 'style'],
|
||||
|
||||
params: {
|
||||
|
||||
@ -9,6 +9,8 @@ export const pylonGrid = {
|
||||
family: 'structural',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js.
|
||||
// Takes the track's surface grain, but lightly — this is drawn, not filmed.
|
||||
texture: 0.4,
|
||||
traits: ['shape', 'space'],
|
||||
|
||||
params: {
|
||||
|
||||
@ -14,6 +14,8 @@ export const quasicrystal = {
|
||||
name: 'Quasicrystal',
|
||||
family: 'geometric',
|
||||
kind: 'fragment',
|
||||
// Crisp line work: the track's surface grain would only fur the edges.
|
||||
texture: 0,
|
||||
traits: ['camera', 'style'],
|
||||
|
||||
params: {
|
||||
|
||||
@ -9,6 +9,9 @@ export const silkRibbon = {
|
||||
family: 'minimal',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js.
|
||||
// Grain is this scene's ONLY expression of the style trait — it draws no
|
||||
// hard edges to weight — so it keeps a share of it rather than opting out.
|
||||
texture: 0.35,
|
||||
traits: ['camera', 'style'],
|
||||
|
||||
params: {
|
||||
|
||||
@ -10,6 +10,9 @@ export const slowOrb = {
|
||||
family: 'minimal',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js.
|
||||
// Grain is this scene's ONLY expression of the style trait — it draws no
|
||||
// hard edges to weight — so it keeps a share of it rather than opting out.
|
||||
texture: 0.35,
|
||||
traits: ['shape', 'camera', 'space', 'style'],
|
||||
|
||||
params: {
|
||||
|
||||
@ -10,6 +10,8 @@ export const spectrumSculpture = {
|
||||
family: 'minimal',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js.
|
||||
// Takes the track's surface grain, but lightly — this is drawn, not filmed.
|
||||
texture: 0.4,
|
||||
traits: ['shape', 'camera', 'style'],
|
||||
|
||||
params: {
|
||||
|
||||
@ -11,6 +11,8 @@ export const truchetFold = {
|
||||
name: 'Truchet Fold',
|
||||
family: 'geometric',
|
||||
kind: 'fragment',
|
||||
// Takes the track's surface grain, but lightly — this is drawn, not filmed.
|
||||
texture: 0.4,
|
||||
traits: ['camera', 'style'],
|
||||
|
||||
params: {
|
||||
|
||||
@ -95,9 +95,29 @@ body {
|
||||
border-top: 1px solid var(--line);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#controls .spacer { flex: 1; }
|
||||
#time-display, #section-display { color: var(--dim); font-size: 12px; }
|
||||
|
||||
/* Both readouts change length while the song plays, and the row wraps. Left to
|
||||
themselves they push the buttons onto a second line mid-track — the clock
|
||||
when it crosses ten minutes, the section label whenever the scene name is
|
||||
long or a crossfade appears. Neither is allowed to influence the layout:
|
||||
the clock is fixed-width with tabular figures, and the section label lays
|
||||
out at zero width and ellipsises into whatever space is left over. */
|
||||
#time-display {
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 9ch;
|
||||
text-align: center;
|
||||
}
|
||||
#section-display {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
button, select {
|
||||
background: #171a22;
|
||||
color: var(--text);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user