draft
diff --git a/flow-state/src/Show.js b/flow-state/src/Show.js
index 5c93710..0027802 100644
--- a/flow-state/src/Show.js
+++ b/flow-state/src/Show.js
@@ -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 });
diff --git a/flow-state/src/checks/phase10.js b/flow-state/src/checks/phase10.js
index 5074147..ceec818 100644
--- a/flow-state/src/checks/phase10.js
+++ b/flow-state/src/checks/phase10.js
@@ -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)}×)`);
+});
diff --git a/flow-state/src/checks/phase3.js b/flow-state/src/checks/phase3.js
index c793f85..fa8015f 100644
--- a/flow-state/src/checks/phase3.js
+++ b/flow-state/src/checks/phase3.js
@@ -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();
}
diff --git a/flow-state/src/engine/Compositor.js b/flow-state/src/engine/Compositor.js
index 58731ce..ac01816 100644
--- a/flow-state/src/engine/Compositor.js
+++ b/flow-state/src/engine/Compositor.js
@@ -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;
diff --git a/flow-state/src/engine/Layer.js b/flow-state/src/engine/Layer.js
index c3182e9..d6a4f35 100644
--- a/flow-state/src/engine/Layer.js
+++ b/flow-state/src/engine/Layer.js
@@ -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]);
diff --git a/flow-state/src/engine/passes.js b/flow-state/src/engine/passes.js
index ed14423..cba178f 100644
--- a/flow-state/src/engine/passes.js
+++ b/flow-state/src/engine/passes.js
@@ -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;
diff --git a/flow-state/src/look/LookGenerator.js b/flow-state/src/look/LookGenerator.js
index d8b77e0..e10417e 100644
--- a/flow-state/src/look/LookGenerator.js
+++ b/flow-state/src/look/LookGenerator.js
@@ -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 };
diff --git a/flow-state/src/look/Personality.js b/flow-state/src/look/Personality.js
index 14fbe57..d1e42c9 100644
--- a/flow-state/src/look/Personality.js
+++ b/flow-state/src/look/Personality.js
@@ -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,
};
}
diff --git a/flow-state/src/look/grain.js b/flow-state/src/look/grain.js
new file mode 100644
index 0000000..6c80526
--- /dev/null
+++ b/flow-state/src/look/grain.js
@@ -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(' · ');
+}
diff --git a/flow-state/src/main.js b/flow-state/src/main.js
index ea5b9d3..66f3e24 100644
--- a/flow-state/src/main.js
+++ b/flow-state/src/main.js
@@ -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]) => `
${key}
${(+value).toFixed(3)}
`).join('');
dom.panelBody.innerHTML =
- `post
${rows}feedback
${fbRows}`;
+ `post
${rows}` +
+ `grain
${grainRows(show.look.grain)}` +
+ `feedback
${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) => `
+
+ ${key}
+
+ ${(+value).toFixed(3)}
+
`;
+
+ const modeRow = `
+
+ mode
+
+ ${GRAIN_MODES.map((m) =>
+ `${m} `).join('')}
+
+
+
`;
+
+ if (grain.mode === 'off') {
+ return modeRow + `${describeGrain(grain)}
`;
+ }
+
+ const maskRow = `
+
+ mask
+
+ ${Object.keys(GRAIN_MASKS).map((m) =>
+ `${m} `).join('')}
+
+
+
`;
+
+ const kindsRow = grain.mode !== 'sections' ? '' : `
+
+ sections
+
+ ${['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro'].map((k) => `
+ ${k} `).join(' ')}
+
+
`;
+
+ 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
+ + `${describeGrain(grain)}
`;
+}
+
// ---------------------------------------------------------------- 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) {
diff --git a/flow-state/src/params/schema.js b/flow-state/src/params/schema.js
index 37437fc..bf56956 100644
--- a/flow-state/src/params/schema.js
+++ b/flow-state/src/params/schema.js
@@ -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)\``);
diff --git a/flow-state/src/scenes/shader/cargo-belt.js b/flow-state/src/scenes/shader/cargo-belt.js
index 246a83e..a97c5f2 100644
--- a/flow-state/src/scenes/shader/cargo-belt.js
+++ b/flow-state/src/scenes/shader/cargo-belt.js
@@ -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: {
diff --git a/flow-state/src/scenes/shader/circuit-bloom.js b/flow-state/src/scenes/shader/circuit-bloom.js
index 1b56138..fdeae07 100644
--- a/flow-state/src/scenes/shader/circuit-bloom.js
+++ b/flow-state/src/scenes/shader/circuit-bloom.js
@@ -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: {
diff --git a/flow-state/src/scenes/shader/classic-wave.js b/flow-state/src/scenes/shader/classic-wave.js
index 9d7e85a..a9b2767 100644
--- a/flow-state/src/scenes/shader/classic-wave.js
+++ b/flow-state/src/scenes/shader/classic-wave.js
@@ -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: {
diff --git a/flow-state/src/scenes/shader/dust-chamber.js b/flow-state/src/scenes/shader/dust-chamber.js
index c6fef63..d6e09d3 100644
--- a/flow-state/src/scenes/shader/dust-chamber.js
+++ b/flow-state/src/scenes/shader/dust-chamber.js
@@ -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)));
diff --git a/flow-state/src/scenes/shader/eclipse-field.js b/flow-state/src/scenes/shader/eclipse-field.js
index 7fe616e..f093bff 100644
--- a/flow-state/src/scenes/shader/eclipse-field.js
+++ b/flow-state/src/scenes/shader/eclipse-field.js
@@ -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;
}
diff --git a/flow-state/src/scenes/shader/floating-geometry.js b/flow-state/src/scenes/shader/floating-geometry.js
index ba93955..5968e2c 100644
--- a/flow-state/src/scenes/shader/floating-geometry.js
+++ b/flow-state/src/scenes/shader/floating-geometry.js
@@ -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: {
diff --git a/flow-state/src/scenes/shader/gate-corridor.js b/flow-state/src/scenes/shader/gate-corridor.js
index b4eeace..d8aef9c 100644
--- a/flow-state/src/scenes/shader/gate-corridor.js
+++ b/flow-state/src/scenes/shader/gate-corridor.js
@@ -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: {
diff --git a/flow-state/src/scenes/shader/girder-lattice.js b/flow-state/src/scenes/shader/girder-lattice.js
index aab1adf..adbebb3 100644
--- a/flow-state/src/scenes/shader/girder-lattice.js
+++ b/flow-state/src/scenes/shader/girder-lattice.js
@@ -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: {
diff --git a/flow-state/src/scenes/shader/horizon-lines.js b/flow-state/src/scenes/shader/horizon-lines.js
index bbab702..209c22a 100644
--- a/flow-state/src/scenes/shader/horizon-lines.js
+++ b/flow-state/src/scenes/shader/horizon-lines.js
@@ -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: {
diff --git a/flow-state/src/scenes/shader/kaleido-tunnel.js b/flow-state/src/scenes/shader/kaleido-tunnel.js
index 5358a87..fa0f4eb 100644
--- a/flow-state/src/scenes/shader/kaleido-tunnel.js
+++ b/flow-state/src/scenes/shader/kaleido-tunnel.js
@@ -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: {
diff --git a/flow-state/src/scenes/shader/moire-grid.js b/flow-state/src/scenes/shader/moire-grid.js
index 62b71ae..d36b630 100644
--- a/flow-state/src/scenes/shader/moire-grid.js
+++ b/flow-state/src/scenes/shader/moire-grid.js
@@ -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: {
diff --git a/flow-state/src/scenes/shader/prism-bloom.js b/flow-state/src/scenes/shader/prism-bloom.js
index afe3fe3..8cb3527 100644
--- a/flow-state/src/scenes/shader/prism-bloom.js
+++ b/flow-state/src/scenes/shader/prism-bloom.js
@@ -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: {
diff --git a/flow-state/src/scenes/shader/pylon-grid.js b/flow-state/src/scenes/shader/pylon-grid.js
index a37041b..4f10b3a 100644
--- a/flow-state/src/scenes/shader/pylon-grid.js
+++ b/flow-state/src/scenes/shader/pylon-grid.js
@@ -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: {
diff --git a/flow-state/src/scenes/shader/quasicrystal.js b/flow-state/src/scenes/shader/quasicrystal.js
index 72d910c..47db882 100644
--- a/flow-state/src/scenes/shader/quasicrystal.js
+++ b/flow-state/src/scenes/shader/quasicrystal.js
@@ -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: {
diff --git a/flow-state/src/scenes/shader/silk-ribbon.js b/flow-state/src/scenes/shader/silk-ribbon.js
index 85a59b0..bcff392 100644
--- a/flow-state/src/scenes/shader/silk-ribbon.js
+++ b/flow-state/src/scenes/shader/silk-ribbon.js
@@ -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: {
diff --git a/flow-state/src/scenes/shader/slow-orb.js b/flow-state/src/scenes/shader/slow-orb.js
index 038fbf0..a410ff6 100644
--- a/flow-state/src/scenes/shader/slow-orb.js
+++ b/flow-state/src/scenes/shader/slow-orb.js
@@ -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: {
diff --git a/flow-state/src/scenes/shader/spectrum-sculpture.js b/flow-state/src/scenes/shader/spectrum-sculpture.js
index 10da335..58e1206 100644
--- a/flow-state/src/scenes/shader/spectrum-sculpture.js
+++ b/flow-state/src/scenes/shader/spectrum-sculpture.js
@@ -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: {
diff --git a/flow-state/src/scenes/shader/truchet-fold.js b/flow-state/src/scenes/shader/truchet-fold.js
index 0f402ab..bb375d9 100644
--- a/flow-state/src/scenes/shader/truchet-fold.js
+++ b/flow-state/src/scenes/shader/truchet-fold.js
@@ -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: {
diff --git a/flow-state/src/ui/style.css b/flow-state/src/ui/style.css
index a3df012..ea577c6 100644
--- a/flow-state/src/ui/style.css
+++ b/flow-state/src/ui/style.css
@@ -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);