Add OSD with song name
This commit is contained in:
parent
045ba6a322
commit
2e4e1d7613
@ -34,6 +34,7 @@ tension, instead of reacting once the drop has landed.
|
|||||||
| `←` `→` | previous / next section boundary |
|
| `←` `→` | previous / next section boundary |
|
||||||
| `L` | loop the current section |
|
| `L` | loop the current section |
|
||||||
| `D` | debug HUD |
|
| `D` | debug HUD |
|
||||||
|
| `O` | toggle the corner title plate |
|
||||||
| `,` `.` | step one frame |
|
| `,` `.` | step one frame |
|
||||||
|
|
||||||
**test render** exports 20 seconds around the playhead at full export quality. Use
|
**test render** exports 20 seconds around the playhead at full export quality. Use
|
||||||
|
|||||||
@ -45,6 +45,7 @@
|
|||||||
<button id="btn-reroll-section" title="New scene for this section only">reroll section</button>
|
<button id="btn-reroll-section" title="New scene for this section only">reroll section</button>
|
||||||
<button id="btn-lock" title="Lock this section against rerolls">lock</button>
|
<button id="btn-lock" title="Lock this section against rerolls">lock</button>
|
||||||
<button id="btn-hud" title="Toggle debug HUD (D)">hud</button>
|
<button id="btn-hud" title="Toggle debug HUD (D)">hud</button>
|
||||||
|
<button id="btn-osd" title="Toggle the title plate in the corner (O)">osd</button>
|
||||||
<button id="btn-segment" title="Render 20s around the playhead at export quality">test render</button>
|
<button id="btn-segment" title="Render 20s around the playhead at export quality">test render</button>
|
||||||
<button id="btn-export" class="primary" title="Export the full video">export</button>
|
<button id="btn-export" class="primary" title="Export the full video">export</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Engine } from './engine/Engine.js';
|
import { Engine } from './engine/Engine.js';
|
||||||
|
import { OSDLayer } from './engine/OSD.js';
|
||||||
import { FeatureTrack, featureProviderFor } from './audio/FeatureTrack.js';
|
import { FeatureTrack, featureProviderFor } from './audio/FeatureTrack.js';
|
||||||
import { decodeFile, monoSamples } from './audio/decode.js';
|
import { decodeFile, monoSamples } from './audio/decode.js';
|
||||||
import { generateLook, rerollLook, rerollSection } from './look/LookGenerator.js';
|
import { generateLook, rerollLook, rerollSection } from './look/LookGenerator.js';
|
||||||
@ -24,6 +25,8 @@ export class Show {
|
|||||||
this.arc = null;
|
this.arc = null;
|
||||||
this.audioBuffer = null;
|
this.audioBuffer = null;
|
||||||
this.fileName = '';
|
this.fileName = '';
|
||||||
|
this.osd = new OSDLayer(this.engine.renderer);
|
||||||
|
this.osdEnabled = true;
|
||||||
this._lastLayers = null;
|
this._lastLayers = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,6 +83,7 @@ export class Show {
|
|||||||
if (this.arc) this.arc.dispose();
|
if (this.arc) this.arc.dispose();
|
||||||
this.look = look;
|
this.look = look;
|
||||||
this.arc = new ArcDriver(look, this.track);
|
this.arc = new ArcDriver(look, this.track);
|
||||||
|
this.osd.setText(this.fileName, look.personality, look.palette);
|
||||||
this._lastLayers = null;
|
this._lastLayers = null;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@ -99,6 +103,7 @@ export class Show {
|
|||||||
setPalette(palette) {
|
setPalette(palette) {
|
||||||
this.look.palette = palette;
|
this.look.palette = palette;
|
||||||
this.arc.setPalette(palette);
|
this.arc.setPalette(palette);
|
||||||
|
this.osd.setPalette(palette);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Live param edit on a section's primary layer. */
|
/** Live param edit on a section's primary layer. */
|
||||||
@ -123,6 +128,13 @@ export class Show {
|
|||||||
return Math.min(a, b);
|
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
|
* 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.
|
* the size of the target and whether the result is presented or encoded.
|
||||||
@ -132,7 +144,8 @@ export class Show {
|
|||||||
timeline.seek(frame);
|
timeline.seek(frame);
|
||||||
|
|
||||||
const features = this.track.at(timeline.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)) {
|
if (this._lastLayers === null || this.arc.layersChanged(this._lastLayers)) {
|
||||||
this.engine.compositor.setLayers(layers);
|
this.engine.compositor.setLayers(layers);
|
||||||
@ -155,7 +168,7 @@ export class Show {
|
|||||||
prewarm() {
|
prewarm() {
|
||||||
if (!this.arc) return this;
|
if (!this.arc) return this;
|
||||||
this.arc.prewarm();
|
this.arc.prewarm();
|
||||||
this.engine.compositor.primeLayers([...this.arc.layerCache.values()]);
|
this.engine.compositor.primeLayers([this.osd, ...this.arc.layerCache.values()]);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -220,6 +233,7 @@ export class Show {
|
|||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
if (this.arc) this.arc.dispose();
|
if (this.arc) this.arc.dispose();
|
||||||
|
this.osd.dispose();
|
||||||
this.engine.dispose();
|
this.engine.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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. */
|
/** Common surface for shader layers and 3D layers, so Compositor holds one type. */
|
||||||
export class Layer {
|
export class Layer {
|
||||||
constructor({ module, params = {}, seed = 1, opacity = 1, blend = 'normal' }) {
|
constructor({ module, params = {}, seed = 1, opacity = 1, blend = 'normal' }) {
|
||||||
@ -99,89 +183,24 @@ export class ShaderLayer extends Layer {
|
|||||||
constructor(options) {
|
constructor(options) {
|
||||||
super(options);
|
super(options);
|
||||||
|
|
||||||
const uniforms = {
|
this.uniforms = buildShaderUniforms(this.module, this.baseParams, this.seed);
|
||||||
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.material = new THREE.ShaderMaterial({
|
this.material = new THREE.ShaderMaterial({
|
||||||
vertexShader: VERTEX_SHADER,
|
vertexShader: VERTEX_SHADER,
|
||||||
fragmentShader: buildFragmentShader(this.module),
|
fragmentShader: buildFragmentShader(this.module),
|
||||||
uniforms,
|
uniforms: this.uniforms,
|
||||||
depthTest: false,
|
depthTest: false,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
render(renderer, target, ctx) {
|
render(renderer, target, ctx) {
|
||||||
const { timeline, features, prevTexture } = ctx;
|
const { features } = ctx;
|
||||||
const u = this.uniforms;
|
setFrameUniforms(this, renderer, target, 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 = 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 resolved = this.resolveParams(features);
|
const resolved = this.resolveParams(features);
|
||||||
for (const [name, def] of Object.entries(this.module.params || {})) {
|
for (const [name, def] of Object.entries(this.module.params || {})) {
|
||||||
if (!def.uniform || def.type === 'palette') continue;
|
if (!def.uniform || def.type === 'palette') continue;
|
||||||
const target_u = u[def.uniform];
|
const target_u = this.uniforms[def.uniform];
|
||||||
const v = resolved[name];
|
const v = resolved[name];
|
||||||
if (v === undefined) continue;
|
if (v === undefined) continue;
|
||||||
if (def.type === 'vec2') target_u.value.set(v[0], v[1]);
|
if (def.type === 'vec2') target_u.value.set(v[0], v[1]);
|
||||||
|
|||||||
213
flow-state/src/engine/OSD.js
Normal file
213
flow-state/src/engine/OSD.js
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
`;
|
||||||
@ -80,7 +80,7 @@ export const FRAME_UNIFORMS = [
|
|||||||
'u_resolution', 'u_aspect', 'u_pixelScale', 'u_opacity',
|
'u_resolution', 'u_aspect', 'u_pixelScale', 'u_opacity',
|
||||||
];
|
];
|
||||||
|
|
||||||
const PREAMBLE = `
|
export const PREAMBLE = `
|
||||||
precision highp float;
|
precision highp float;
|
||||||
|
|
||||||
uniform vec2 u_resolution;
|
uniform vec2 u_resolution;
|
||||||
|
|||||||
@ -25,6 +25,7 @@ const dom = {
|
|||||||
panelBody: document.getElementById('panel-body'),
|
panelBody: document.getElementById('panel-body'),
|
||||||
panelTabs: document.getElementById('panel-tabs'),
|
panelTabs: document.getElementById('panel-tabs'),
|
||||||
hud: document.getElementById('hud'),
|
hud: document.getElementById('hud'),
|
||||||
|
osd: document.getElementById('btn-osd'),
|
||||||
play: document.getElementById('btn-play'),
|
play: document.getElementById('btn-play'),
|
||||||
thLabel: document.getElementById('th-label'),
|
thLabel: document.getElementById('th-label'),
|
||||||
thName: document.getElementById('th-name'),
|
thName: document.getElementById('th-name'),
|
||||||
@ -41,6 +42,7 @@ const state = {
|
|||||||
loopSection: -1,
|
loopSection: -1,
|
||||||
tab: 'look',
|
tab: 'look',
|
||||||
hudVisible: false,
|
hudVisible: false,
|
||||||
|
osdVisible: true,
|
||||||
busy: false,
|
busy: false,
|
||||||
rerollSalt: 0,
|
rerollSalt: 0,
|
||||||
lastFrameTime: 0,
|
lastFrameTime: 0,
|
||||||
@ -82,6 +84,7 @@ async function loadFile(file) {
|
|||||||
dom.changeTrack.textContent = 'change track';
|
dom.changeTrack.textContent = 'change track';
|
||||||
|
|
||||||
strip.setShow(state.show);
|
strip.setShow(state.show);
|
||||||
|
if (dom.osd) dom.osd.classList.toggle('on', state.osdVisible);
|
||||||
resize();
|
resize();
|
||||||
seekTo(0);
|
seekTo(0);
|
||||||
renderPanel();
|
renderPanel();
|
||||||
@ -160,11 +163,18 @@ document.getElementById('btn-hud').addEventListener('click', () => {
|
|||||||
state.hudVisible = !state.hudVisible;
|
state.hudVisible = !state.hudVisible;
|
||||||
dom.hud.hidden = !state.hudVisible;
|
dom.hud.hidden = !state.hudVisible;
|
||||||
});
|
});
|
||||||
|
if (dom.osd) dom.osd.addEventListener('click', () => setOSDVisible(!state.osdVisible));
|
||||||
document.getElementById('sel-quality').addEventListener('change', (e) => {
|
document.getElementById('sel-quality').addEventListener('change', (e) => {
|
||||||
state.quality = e.target.value;
|
state.quality = e.target.value;
|
||||||
resize();
|
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) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
@ -176,6 +186,7 @@ document.addEventListener('keydown', (e) => {
|
|||||||
state.hudVisible = !state.hudVisible;
|
state.hudVisible = !state.hudVisible;
|
||||||
dom.hud.hidden = !state.hudVisible;
|
dom.hud.hidden = !state.hudVisible;
|
||||||
break;
|
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;
|
||||||
case '.': seekTo(state.show.timeline.frame + 1); break;
|
case '.': seekTo(state.show.timeline.frame + 1); break;
|
||||||
default: break;
|
default: break;
|
||||||
|
|||||||
@ -110,6 +110,7 @@ button, select {
|
|||||||
button:hover, select:hover { background: #1f232d; }
|
button:hover, select:hover { background: #1f232d; }
|
||||||
button.primary { border-color: #2f6f4a; color: var(--accent); }
|
button.primary { border-color: #2f6f4a; color: var(--accent); }
|
||||||
button.wide { width: 100%; margin-top: 12px; }
|
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; }
|
.ctl { color: var(--dim); display: flex; align-items: center; gap: 5px; }
|
||||||
|
|
||||||
#panel {
|
#panel {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user