diff --git a/flow-state/README.md b/flow-state/README.md
index 96da7bb..83d88d5 100644
--- a/flow-state/README.md
+++ b/flow-state/README.md
@@ -34,6 +34,7 @@ tension, instead of reacting once the drop has landed.
| `←` `→` | previous / next section boundary |
| `L` | loop the current section |
| `D` | debug HUD |
+| `O` | toggle the corner title plate |
| `,` `.` | step one frame |
**test render** exports 20 seconds around the playhead at full export quality. Use
diff --git a/flow-state/index.html b/flow-state/index.html
index 23a5f3c..d54ac03 100644
--- a/flow-state/index.html
+++ b/flow-state/index.html
@@ -45,6 +45,7 @@
+
diff --git a/flow-state/src/Show.js b/flow-state/src/Show.js
index 2fc6963..5c93710 100644
--- a/flow-state/src/Show.js
+++ b/flow-state/src/Show.js
@@ -1,4 +1,5 @@
import { Engine } from './engine/Engine.js';
+import { OSDLayer } from './engine/OSD.js';
import { FeatureTrack, featureProviderFor } from './audio/FeatureTrack.js';
import { decodeFile, monoSamples } from './audio/decode.js';
import { generateLook, rerollLook, rerollSection } from './look/LookGenerator.js';
@@ -24,6 +25,8 @@ export class Show {
this.arc = null;
this.audioBuffer = null;
this.fileName = '';
+ this.osd = new OSDLayer(this.engine.renderer);
+ this.osdEnabled = true;
this._lastLayers = null;
}
@@ -80,6 +83,7 @@ export class Show {
if (this.arc) this.arc.dispose();
this.look = look;
this.arc = new ArcDriver(look, this.track);
+ this.osd.setText(this.fileName, look.personality, look.palette);
this._lastLayers = null;
return this;
}
@@ -99,6 +103,7 @@ export class Show {
setPalette(palette) {
this.look.palette = palette;
this.arc.setPalette(palette);
+ this.osd.setPalette(palette);
}
/** Live param edit on a section's primary layer. */
@@ -123,6 +128,13 @@ export class Show {
return Math.min(a, b);
}
+ /** Toggle the title plate. Off removes it from the stack entirely. */
+ setOSDEnabled(enabled) {
+ this.osdEnabled = !!enabled;
+ this._lastLayers = null;
+ return this;
+ }
+
/**
* Render one frame. Identical in preview and export — the only difference is
* the size of the target and whether the result is presented or encoded.
@@ -132,7 +144,8 @@ export class Show {
timeline.seek(frame);
const features = this.track.at(timeline.frame);
- const layers = this.arc.update(timeline.frame, features);
+ const sceneLayers = this.arc.update(timeline.frame, features);
+ const layers = this.osdEnabled ? [...sceneLayers, this.osd] : sceneLayers;
if (this._lastLayers === null || this.arc.layersChanged(this._lastLayers)) {
this.engine.compositor.setLayers(layers);
@@ -155,7 +168,7 @@ export class Show {
prewarm() {
if (!this.arc) return this;
this.arc.prewarm();
- this.engine.compositor.primeLayers([...this.arc.layerCache.values()]);
+ this.engine.compositor.primeLayers([this.osd, ...this.arc.layerCache.values()]);
return this;
}
@@ -220,6 +233,7 @@ export class Show {
dispose() {
if (this.arc) this.arc.dispose();
+ this.osd.dispose();
this.engine.dispose();
}
}
diff --git a/flow-state/src/engine/Layer.js b/flow-state/src/engine/Layer.js
index 8b10765..c3182e9 100644
--- a/flow-state/src/engine/Layer.js
+++ b/flow-state/src/engine/Layer.js
@@ -19,6 +19,90 @@ function applyResponse(value, response) {
}
}
+/**
+ * The standard uniform dictionary every fragment layer is compiled against.
+ * Shared by ShaderLayer and the OSD plate so a text layer honours exactly the
+ * same contract — palette, audio features and personality — as a scene.
+ */
+export function buildShaderUniforms(module, baseParams, seed) {
+ const uniforms = {
+ u_resolution: { value: new THREE.Vector2(1, 1) },
+ u_aspect: { value: 1 },
+ u_pixelScale: { value: 1 },
+ u_time: { value: 0 },
+ u_frame: { value: 0 },
+ u_progress: { value: 0 },
+ u_seed: { value: (seed >>> 0) % 100000 / 1000 },
+ u_opacity: { value: 1 },
+ u_colors: { value: Array.from({ length: 8 }, () => new THREE.Vector3(1, 1, 1)) },
+ u_colorCount: { value: 1 },
+ u_prev: { value: null },
+ u_hasPrev: { value: 0 },
+ };
+ for (const name of AUDIO_UNIFORMS) uniforms[name] = { value: 0 };
+ for (const [name, type] of Object.entries(SIGNATURE_UNIFORMS)) {
+ const v = NEUTRAL_UNIFORMS[name];
+ uniforms[name] = { value: type === 'vec2' ? new THREE.Vector2(v[0], v[1]) : v };
+ }
+
+ for (const [name, def] of Object.entries(module.params || {})) {
+ if (!def.uniform || def.type === 'palette') continue;
+ const v = baseParams[name];
+ uniforms[def.uniform] = {
+ value: def.type === 'vec2'
+ ? new THREE.Vector2(v ? v[0] : 0, v ? v[1] : 0)
+ : def.type === 'bool' ? (v ? 1 : 0) : (v || 0),
+ };
+ }
+ return uniforms;
+}
+
+/**
+ * Push the per-frame values into a fragment layer's uniforms: the frame, the
+ * audio features, the palette and the personality. Shared by scene layers and
+ * the OSD plate so they all see the same inputs — the same render path, so
+ * what a scene honours a plate honours too.
+ */
+export function setFrameUniforms(layer, renderer, target, ctx) {
+ const u = layer.uniforms;
+ const { timeline, features, prevTexture } = ctx;
+ const w = target ? target.width : renderer.width;
+ const h = target ? target.height : renderer.height;
+
+ u.u_resolution.value.set(w, h);
+ u.u_aspect.value = w / h;
+ u.u_pixelScale.value = h / 1080; // reference height; keeps 720p ≡ 4K
+ u.u_time.value = timeline.time;
+ u.u_frame.value = timeline.frame;
+ u.u_progress.value = timeline.progress;
+ u.u_opacity.value = layer.opacity;
+
+ if (features) {
+ for (const name of AUDIO_UNIFORMS) {
+ const key = name.slice(2); // u_bandLow -> bandLow
+ const v = features[key];
+ u[name].value = v === undefined ? 0 : v;
+ }
+ }
+
+ const colors = layer.palette || [];
+ u.u_colorCount.value = Math.max(1, Math.min(8, colors.length));
+ for (let i = 0; i < 8; i++) {
+ const c = colors[i % Math.max(1, colors.length)];
+ if (c) u.u_colors.value[i].set(c[0], c[1], c[2]);
+ }
+
+ const signature = signatureUniforms(layer.personality);
+ for (const [name, type] of Object.entries(SIGNATURE_UNIFORMS)) {
+ const v = signature[name];
+ if (type === 'vec2') u[name].value.set(v[0], v[1]);
+ else u[name].value = v;
+ }
+
+ u.u_prev.value = prevTexture || null;
+ u.u_hasPrev.value = prevTexture ? 1 : 0;
+}
+
/** Common surface for shader layers and 3D layers, so Compositor holds one type. */
export class Layer {
constructor({ module, params = {}, seed = 1, opacity = 1, blend = 'normal' }) {
@@ -99,89 +183,24 @@ export class ShaderLayer extends Layer {
constructor(options) {
super(options);
- const uniforms = {
- u_resolution: { value: new THREE.Vector2(1, 1) },
- u_aspect: { value: 1 },
- u_pixelScale: { value: 1 },
- u_time: { value: 0 },
- u_frame: { value: 0 },
- u_progress: { value: 0 },
- u_seed: { value: (this.seed % 100000) / 1000 },
- u_opacity: { value: 1 },
- u_colors: { value: Array.from({ length: 8 }, () => new THREE.Vector3(1, 1, 1)) },
- u_colorCount: { value: 1 },
- u_prev: { value: null },
- u_hasPrev: { value: 0 },
- };
- for (const name of AUDIO_UNIFORMS) uniforms[name] = { value: 0 };
- for (const [name, type] of Object.entries(SIGNATURE_UNIFORMS)) {
- const v = NEUTRAL_UNIFORMS[name];
- uniforms[name] = { value: type === 'vec2' ? new THREE.Vector2(v[0], v[1]) : v };
- }
-
- for (const [name, def] of Object.entries(this.module.params || {})) {
- if (!def.uniform || def.type === 'palette') continue;
- const v = this.baseParams[name];
- uniforms[def.uniform] = {
- value: def.type === 'vec2'
- ? new THREE.Vector2(v ? v[0] : 0, v ? v[1] : 0)
- : def.type === 'bool' ? (v ? 1 : 0) : (v || 0),
- };
- }
-
- this.uniforms = uniforms;
+ this.uniforms = buildShaderUniforms(this.module, this.baseParams, this.seed);
this.material = new THREE.ShaderMaterial({
vertexShader: VERTEX_SHADER,
fragmentShader: buildFragmentShader(this.module),
- uniforms,
+ uniforms: this.uniforms,
depthTest: false,
depthWrite: false,
});
}
render(renderer, target, ctx) {
- const { timeline, features, prevTexture } = ctx;
- const u = this.uniforms;
- const w = target ? target.width : renderer.width;
- const h = target ? target.height : renderer.height;
-
- u.u_resolution.value.set(w, h);
- u.u_aspect.value = w / h;
- u.u_pixelScale.value = h / 1080; // reference height; keeps 720p ≡ 4K
- u.u_time.value = timeline.time;
- u.u_frame.value = timeline.frame;
- u.u_progress.value = timeline.progress;
- u.u_opacity.value = this.opacity;
-
- if (features) {
- for (const name of AUDIO_UNIFORMS) {
- const key = name.slice(2); // u_bandLow -> bandLow
- const v = features[key];
- u[name].value = v === undefined ? 0 : v;
- }
- }
-
- const colors = this.palette || [];
- u.u_colorCount.value = Math.max(1, Math.min(8, colors.length));
- for (let i = 0; i < 8; i++) {
- const c = colors[i % Math.max(1, colors.length)];
- if (c) u.u_colors.value[i].set(c[0], c[1], c[2]);
- }
-
- const signature = signatureUniforms(this.personality);
- for (const [name, type] of Object.entries(SIGNATURE_UNIFORMS)) {
- const v = signature[name];
- if (type === 'vec2') u[name].value.set(v[0], v[1]);
- else u[name].value = v;
- }
-
- u.u_prev.value = prevTexture || null;
- u.u_hasPrev.value = prevTexture ? 1 : 0;
+ const { features } = ctx;
+ setFrameUniforms(this, renderer, target, ctx);
const resolved = this.resolveParams(features);
for (const [name, def] of Object.entries(this.module.params || {})) {
if (!def.uniform || def.type === 'palette') continue;
- const target_u = u[def.uniform];
+ const target_u = this.uniforms[def.uniform];
const v = resolved[name];
if (v === undefined) continue;
if (def.type === 'vec2') target_u.value.set(v[0], v[1]);
diff --git a/flow-state/src/engine/OSD.js b/flow-state/src/engine/OSD.js
new file mode 100644
index 0000000..0c150e7
--- /dev/null
+++ b/flow-state/src/engine/OSD.js
@@ -0,0 +1,213 @@
+import * as THREE from 'three';
+import { VERTEX_SHADER, PREAMBLE } from './shader-contract.js';
+import { Layer, buildShaderUniforms, setFrameUniforms } from './Layer.js';
+
+//
+// OSD — the on-screen title plate.
+//
+// A small music-video plate that sits in the corner and names the track. It is
+// NOT a DOM overlay: it is rendered into the same layer stack an export runs,
+// so the title appears in the exported video exactly where it shows in preview.
+//
+// Following the personality is not optional fat, it is the point. The plate is
+// built out of the same four traits every scene is:
+//
+// shape — the track's signature form is stamped beside the title as a
+// monogram, so the plate carries the same subject the video does.
+// camera — the same operator holds the plate: it drifts and sways and takes
+// the bar-locked breath every scene takes.
+// space — a soft wash of the location's air sits behind it so it stays
+// readable over a busy scene.
+// style — the title is edged in the track's line weight and softness, folds
+// nothing, and carries the same surface grain.
+//
+// And it is reactive rather than inert: the plate's glow rides the loudness and
+// it breathes on the bar, so even a static corner moves with the music.
+//
+// DETERMINISM: the plate's glyph mask is rasterised once off-frame and held as
+// a texture. It uses no clock, no Math.random, no wall time — the same seed
+// always produces the same title plate, and preview and export share the one
+// texture, so they cannot disagree.
+
+/**
+ * The corner plate, a fullscreen pass like any other layer so it slots into the
+ * compositor's blend loop. It masks to a block in the bottom-left corner.
+ *
+ * "OSD" follows the personality because it shares the Layer contract: the same
+ * palette, audio features and signature uniforms as every scene, driven by the
+ * same per-frame pass.
+ */
+export class OSDLayer extends Layer {
+ constructor(renderer, { text = '', personality = null, palette = [] } = {}) {
+ // A layer with no rendered params; it is its own document, not a scene.
+ // opacity is 0 until a title exists: the compositor skips zero-opacity
+ // layers before they draw, so a nameless plate never writes over a frame.
+ super({ module: { name: 'OSD title' }, blend: 'normal', opacity: 0 });
+ this.renderer = renderer;
+ this.personality = personality;
+ this.palette = palette;
+ this.text = '';
+ this._titleTex = null;
+
+ this.uniforms = buildShaderUniforms({ params: {} }, {}, 0);
+ this.uniforms.u_title = { value: null };
+ this.uniforms.u_titleAspect = { value: 1 };
+
+ this.material = new THREE.ShaderMaterial({
+ vertexShader: VERTEX_SHADER,
+ fragmentShader: PREAMBLE + `\nuniform sampler2D u_title;\nuniform float u_titleAspect;\n${OSD_FRAGMENT}`,
+ uniforms: this.uniforms,
+ depthTest: false,
+ depthWrite: false,
+ });
+
+ this.setText(text, personality, palette);
+ }
+
+ /** Set the track name (and refresh personality/palette it renders with). */
+ setText(text, personality = null, palette = null) {
+ this.text = text || '';
+ if (personality) this.personality = personality;
+ if (palette) this.palette = palette;
+ this._rebuildTitleTex();
+ return this;
+ }
+
+ setPersonality(personality) { this.personality = personality; this._rebuildTitleTex(); return this; }
+ setPalette(palette) {
+ // Palette lives in uniforms (setFrameUniforms), not the texture, so a
+ // recolour needs no re-raster. Keep the reference for parity anyway.
+ this.palette = palette;
+ return this;
+ }
+
+ /** Rasterise the song name once, off-frame. See module notes on determinism. */
+ _rebuildTitleTex() {
+ if (this._titleTex) { this._titleTex.dispose(); this._titleTex = null; }
+ const canvas = renderTitlePlate(this.text, this.personality);
+ this.uniforms.u_title.value = null;
+ this.opacity = canvas ? 1 : 0;
+ if (!canvas) return;
+ const tex = new THREE.CanvasTexture(canvas);
+ tex.colorSpace = THREE.SRGBColorSpace;
+ this.uniforms.u_title.value = tex;
+ this.uniforms.u_titleAspect.value = canvas.width / canvas.height;
+ this._titleTex = tex;
+ }
+
+ render(renderer, target, ctx) {
+ if (!this.uniforms.u_title.value) return;
+ setFrameUniforms(this, renderer, target, ctx);
+ renderer.blit(this.material, target);
+ }
+
+ dispose() {
+ if (this.material) this.material.dispose();
+ if (this._titleTex) this._titleTex.dispose();
+ }
+}
+
+/**
+ * Rasterise the title as a white-on-transparent mask the shader recolours.
+ * Sized to the glyph run, so the texture's aspect is the plate's aspect.
+ * Returns a canvas, or null if there is nothing to draw.
+ */
+function renderTitlePlate(text, personality) {
+ const label = (text || '').trim().toUpperCase();
+ if (!label) return null;
+
+ // Letterspacing reflects the track's line weight; a heavier art direction
+ // spreads the type further apart.
+ const spacing = (personality && personality.style ? personality.style.lineWeight : 0.6) * 14;
+ const font = (px) => `700 ${px}px "Arial Black", "Avenir Next", "Helvetica Neue", "Segoe UI", sans-serif`;
+ const widthCap = 2048;
+
+ let canvas = document.createElement('canvas');
+ let ctx = canvas.getContext('2d');
+ const measure = (px) => {
+ ctx.font = font(px);
+ try { ctx.letterSpacing = `${spacing}px`; } catch { /* older engines ignore it */ }
+ return ctx.measureText(label).width;
+ };
+
+ let px = 1500;
+ let width = measure(px);
+ while (width > widthCap && px > 260) { px *= 0.8; width = measure(px); }
+
+ const pad = Math.round(px * 0.06);
+ canvas.width = Math.ceil(width + pad * 2);
+ canvas.height = Math.ceil(px + pad * 2);
+
+ ctx = canvas.getContext('2d');
+ ctx.fillStyle = '#fff';
+ ctx.font = font(px);
+ try { ctx.letterSpacing = `${spacing}px`; } catch { /* older engines */}
+ ctx.textBaseline = 'middle';
+ ctx.textAlign = 'center';
+ ctx.fillText(label, canvas.width / 2, canvas.height / 2);
+ return canvas;
+}
+
+// The plate pass. Works in centred aspect-corrected coordinates same as every
+// scene, and reads the personality and audio uniforms the contract provides.
+const OSD_FRAGMENT = `
+void main() {
+ vec2 uv = vUv;
+ vec2 p = (uv - 0.5) * 2.0;
+ p.x *= u_aspect;
+
+ // --- the operator films the plate too: sway, and the bar-locked breath ---
+ vec2 cam = vec2(
+ sin(u_time * u_sigSwayRate) * u_sigSway,
+ cos(u_time * u_sigSwayRate * 0.83) * u_sigSway
+ ) * 0.08;
+ float breath = 1.0 + u_sigBreathe * sin(u_barPhase * 6.28318530718);
+
+ vec2 corner = vec2(-u_aspect + 0.2, -1.0 + 0.1) + cam;
+ float blockH = 0.09 * breath;
+ float blockW = blockH * u_titleAspect;
+ vec2 blockBL = corner;
+ vec2 blockTR = corner + vec2(blockW, blockH);
+ vec2 blockC = (blockBL + blockTR) * 0.5;
+
+ vec3 acc = vec3(0.0);
+ float alpha = 0.0;
+
+ // --- space: the location's backing wash, soft, so the plate reads ---
+ vec2 rel = (p - blockC) / vec2(blockW + blockH, blockH * 2.2);
+ float back = exp(-dot(rel, rel) * 4.0) * 0.32;
+ acc += pal(0) * back * 0.4;
+ alpha += back;
+
+ // --- shape: the signature form stamped as a monogram left of the title ---
+ float emD = blockH * 0.95;
+ vec2 emC = vec2(blockBL.x - blockH * 0.35, blockC.y);
+ float d = sigShape((p - emC) / max(emD * 0.5, 1e-3)) * (emD * 0.5);
+ float emFill = smoothstep(u_sigSoft * emD * 0.15, -u_sigSoft * emD * 0.15, d);
+ float emEdge = sigEdge(d);
+ vec3 emCol = mix(pal(2), pal(1), 0.35);
+ acc += emCol * emFill * 0.2 + emCol * emEdge;
+ alpha += emFill * 0.2 + emEdge;
+
+ // --- the title itself, recoloured by the track ---
+ vec2 tuv = (p - blockBL) / vec2(blockW, blockH);
+ if (tuv.x >= 0.0 && tuv.x <= 1.0 && tuv.y >= 0.0 && tuv.y <= 1.0) {
+ float mask = texture2D(u_title, tuv).a;
+ float core = smoothstep(0.5, 0.62, mask);
+ // A soft glow instead of a hard outline: offset-sampled outlines break
+ // into a dotted line around the glyphs once the plate is downsampled.
+ float fringe = smoothstep(0.06, 0.5, mask) * (1.0 - core);
+ float live = 0.55 + u_loudness * 0.6;
+
+ vec3 fill = pal(1);
+ vec3 glow = pal(0);
+ acc += fill * core + glow * fringe * 0.55 * live;
+ alpha += core + fringe * 0.55 * live;
+ }
+
+ // --- the same surface grain every scene carries ---
+ acc += sigGrain(uv) * 0.3;
+
+ gl_FragColor = vec4(acc, min(alpha, 1.0) * u_opacity);
+}
+`;
\ No newline at end of file
diff --git a/flow-state/src/engine/shader-contract.js b/flow-state/src/engine/shader-contract.js
index 3223b71..04c71bd 100644
--- a/flow-state/src/engine/shader-contract.js
+++ b/flow-state/src/engine/shader-contract.js
@@ -80,7 +80,7 @@ export const FRAME_UNIFORMS = [
'u_resolution', 'u_aspect', 'u_pixelScale', 'u_opacity',
];
-const PREAMBLE = `
+export const PREAMBLE = `
precision highp float;
uniform vec2 u_resolution;
diff --git a/flow-state/src/main.js b/flow-state/src/main.js
index 5e3bbea..ea5b9d3 100644
--- a/flow-state/src/main.js
+++ b/flow-state/src/main.js
@@ -25,6 +25,7 @@ const dom = {
panelBody: document.getElementById('panel-body'),
panelTabs: document.getElementById('panel-tabs'),
hud: document.getElementById('hud'),
+ osd: document.getElementById('btn-osd'),
play: document.getElementById('btn-play'),
thLabel: document.getElementById('th-label'),
thName: document.getElementById('th-name'),
@@ -41,6 +42,7 @@ const state = {
loopSection: -1,
tab: 'look',
hudVisible: false,
+ osdVisible: true,
busy: false,
rerollSalt: 0,
lastFrameTime: 0,
@@ -82,6 +84,7 @@ async function loadFile(file) {
dom.changeTrack.textContent = 'change track';
strip.setShow(state.show);
+ if (dom.osd) dom.osd.classList.toggle('on', state.osdVisible);
resize();
seekTo(0);
renderPanel();
@@ -160,11 +163,18 @@ document.getElementById('btn-hud').addEventListener('click', () => {
state.hudVisible = !state.hudVisible;
dom.hud.hidden = !state.hudVisible;
});
+if (dom.osd) dom.osd.addEventListener('click', () => setOSDVisible(!state.osdVisible));
document.getElementById('sel-quality').addEventListener('change', (e) => {
state.quality = e.target.value;
resize();
});
+function setOSDVisible(visible) {
+ state.osdVisible = !!visible;
+ state.show.setOSDEnabled(state.osdVisible);
+ if (dom.osd) dom.osd.classList.toggle('on', state.osdVisible);
+}
+
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
switch (e.key) {
@@ -176,6 +186,7 @@ document.addEventListener('keydown', (e) => {
state.hudVisible = !state.hudVisible;
dom.hud.hidden = !state.hudVisible;
break;
+ case 'o': case 'O': setOSDVisible(!state.osdVisible); break;
case ',': seekTo(state.show.timeline.frame - 1); break;
case '.': seekTo(state.show.timeline.frame + 1); break;
default: break;
diff --git a/flow-state/src/ui/style.css b/flow-state/src/ui/style.css
index 9ca821b..7a0f69f 100644
--- a/flow-state/src/ui/style.css
+++ b/flow-state/src/ui/style.css
@@ -110,6 +110,7 @@ button, select {
button:hover, select:hover { background: #1f232d; }
button.primary { border-color: #2f6f4a; color: var(--accent); }
button.wide { width: 100%; margin-top: 12px; }
+#controls button.on { border-color: rgba(74,222,128,0.55); color: var(--accent); background: rgba(74,222,128,0.08); }
.ctl { color: var(--dim); display: flex; align-items: center; gap: 5px; }
#panel {