Phases 8 and 9: shots, and a production design per track

Both phases come out of the manual gate — watching whole tracks — and both
fix something no automated check was looking for.

Phase 8: shots. A section is a STAGE of the song and can run ninety
seconds; one scene held that long reads as a still image with a wobble on
it. Each section kind now gets a roster of three or four stage visuals
instead of one scene, and each section is cut into shots that rotate
between them on phrase lines, never holding past 22s. The roster stays per
kind, so a track's drops still cut between the same images and the video
keeps its identity; the anchor opens each section and the rotation returns
to it, and when a companion is due it is the least recently shown one.

The arc driver stopped working in sections and started working in cues, one
per shot, so a shot cut and a section change take the same code path and
differ only in transition length. The default transition is a slow
dissolve — two bars calm, one loud; a straight cut is reserved for
sections above the energy threshold, because on calm material a cut reads
as a glitch rather than as an edit.

Phase 9: production design. With cuts every fifteen seconds the next
problem was that the images being cut between shared nothing but the
palette. What a music video actually shares across shots is a location, a
cast, a camera operator and an art direction, so each track now generates a
personality in four traits (shape, camera, space, style) off the look seed.
The traits reach shaders as uniforms plus four helpers in the contract, and
each scene expresses them its own way: Classic Wave's rings take the
signature polygon, Metaballs merge as one, Floating Geometry no longer
picks between a box and a circle because the production already decided.

The part that makes it a design rather than a filter: scenes DECLARE which
traits they honour, a track is built on one or two, and a scene that does
not honour all of them is not cast in that track. The library shrinks per
track on purpose.

Two gates keep the declaration honest — lint greps each shader for evidence
of every trait it claims, and a render check measures that each declared
trait actually moves the image (41 scene/trait pairs, weakest response 64
of 255). A layer with no personality renders bit-identically to before,
which is what keeps every earlier sweep and regression valid.

Checks changed rather than added:
  - P4 scene-change and drift checks now measure per shot, not per section;
    the crossfade check reads its length off the cue.
  - P5 flash sweep runs per shot, so the visuals that only appear
    mid-section are measured too.
  - P6 preview/export parity primes first (as both real paths do) and
    compares at the one-LSB tolerance Phase 7 already uses. Measured over
    four consecutive shows: 3 frames at delta 1, then bit-exact — GPU
    variance on first render, not a divergence.
  - P2's contract-uniform list is derived from the contract instead of
    retyped, so the signature uniforms cannot fall out of sync.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino
2026-08-05 20:04:33 +02:00
co-authored by Claude Opus 5
parent 9d15c3cf49
commit ca5a68eb84
39 changed files with 1795 additions and 209 deletions
+13
View File
@@ -135,6 +135,19 @@ export class Compositor {
return this;
}
/**
* Compile layers that are not on screen yet, so the frame they first appear
* on does not pay for the link. Used by the arc driver's prewarm pass.
*/
primeLayers(layers) {
for (const layer of layers) {
if (this._primed.has(layer)) continue;
this._primeLayer(layer);
this._primed.add(layer);
}
return this;
}
/**
* Force a layer's shader program to finish linking before it is used for real.
*
+1
View File
@@ -57,6 +57,7 @@ export class Engine {
const layers = specs.map((s) => {
const layer = createLayer(s.module, s);
if (s.palette) layer.setPalette(s.palette);
if (s.personality) layer.setPersonality(s.personality);
return layer;
});
this.ownedLayers = layers;
+25 -1
View File
@@ -1,5 +1,6 @@
import * as THREE from 'three';
import { VERTEX_SHADER, buildFragmentShader, AUDIO_UNIFORMS } from './shader-contract.js';
import { VERTEX_SHADER, buildFragmentShader, AUDIO_UNIFORMS, SIGNATURE_UNIFORMS } from './shader-contract.js';
import { signatureUniforms, NEUTRAL_UNIFORMS } from '../look/Personality.js';
import { clampValue } from '../params/schema.js';
export const BLEND_MODES = ['normal', 'add', 'screen', 'multiply', 'overlay', 'softlight'];
@@ -28,6 +29,17 @@ export class Layer {
this.opacity = opacity;
this.blend = blend;
this.palette = [];
this.personality = null;
}
/**
* The track's production design. Constant for the whole video — see
* look/Personality.js — and pushed in the same way the palette is, so a
* layer never reaches for global state.
*/
setPersonality(personality) {
this.personality = personality;
return this;
}
setParams(params) {
@@ -102,6 +114,10 @@ export class ShaderLayer extends Layer {
u_hasPrev: { value: 0 },
};
for (const name of AUDIO_UNIFORMS) uniforms[name] = { value: 0 };
for (const [name, type] of Object.entries(SIGNATURE_UNIFORMS)) {
const v = NEUTRAL_UNIFORMS[name];
uniforms[name] = { value: type === 'vec2' ? new THREE.Vector2(v[0], v[1]) : v };
}
for (const [name, def] of Object.entries(this.module.params || {})) {
if (!def.uniform || def.type === 'palette') continue;
@@ -152,6 +168,13 @@ export class ShaderLayer extends Layer {
if (c) u.u_colors.value[i].set(c[0], c[1], c[2]);
}
const signature = signatureUniforms(this.personality);
for (const [name, type] of Object.entries(SIGNATURE_UNIFORMS)) {
const v = signature[name];
if (type === 'vec2') u[name].value.set(v[0], v[1]);
else u[name].value = v;
}
u.u_prev.value = prevTexture || null;
u.u_hasPrev.value = prevTexture ? 1 : 0;
@@ -212,6 +235,7 @@ export class SceneLayer extends Layer {
features: features || {},
params: resolved,
palette: this.palette,
personality: this.personality,
opacity: this.opacity,
THREE,
});
+114
View File
@@ -43,6 +43,38 @@ export const AUDIO_UNIFORMS = [
'u_buildSlope', // >0 while energy is ramping toward the next section
];
/**
* The track's personality, constant for the whole video. See look/Personality.js.
*
* These are what make sixteen unrelated shaders read as one production. A scene
* declares in `traits` which of them it honours, and the look generator will not
* cast a scene that cannot express what the track is built on.
*
* All of them are neutral by default, so a layer built without a personality
* renders exactly what it always rendered.
*/
export const SIGNATURE_UNIFORMS = {
u_sigSides: 'float', // signature form: 0 = round, else polygon sides
u_sigRound: 'float', // corner rounding of that form
u_sigElong: 'float', // how far from square the form is
u_sigTilt: 'float', // its resting angle
u_sigDrift: 'vec2', // camera translation per second
u_sigSway: 'float', // camera sway amplitude
u_sigSwayRate: 'float',
u_sigSpin: 'float', // slow camera roll, radians per second
u_sigBreathe: 'float', // bar-locked zoom
u_sigHorizon: 'float', // where the ground meets the sky, 0..1 up the frame
u_sigDepth: 'float', // distance falloff
u_sigWash: 'vec2', // background gradient direction and strength
u_sigLine: 'float', // line weight
u_sigSoft: 'float', // edge softness
u_sigTexture: 'float', // surface grain
u_sigFold: 'float', // kaleidoscopic folds, 1 = none
};
export const FRAME_UNIFORMS = [
'u_time', 'u_frame', 'u_progress', 'u_seed',
'u_resolution', 'u_aspect', 'u_pixelScale', 'u_opacity',
@@ -65,6 +97,8 @@ uniform int u_colorCount;
${AUDIO_UNIFORMS.map((u) => `uniform float ${u};`).join('\n')}
${Object.entries(SIGNATURE_UNIFORMS).map(([u, t]) => `uniform ${t} ${u};`).join('\n')}
uniform sampler2D u_prev;
uniform int u_hasPrev;
@@ -140,6 +174,86 @@ vec2 kaleido(vec2 p, float sides) {
return vec2(cos(a), sin(a)) * r;
}
// --- personality -----------------------------------------------------------
// The four traits, as functions a scene applies in its own way. A scene that
// calls none of these must not declare the matching trait, or it will be cast
// in a track it cannot express. See look/Personality.js.
/**
* TRAIT: shape. Signed distance to the track's signature form, radius ~1.
* Round tracks return a circle, so a scene can call this unconditionally.
*/
float sigShape(vec2 q) {
q = rot(u_sigTilt) * q;
q.x /= max(u_sigElong, 0.05);
if (u_sigSides < 2.5) return length(q) - 1.0;
// Regular polygon by angular folding, then rounded back toward the circle.
float seg = 6.28318530718 / u_sigSides;
float a = atan(q.y, q.x);
float r = length(q);
float folded = cos(mod(a + seg * 0.5, seg) - seg * 0.5);
float poly = r * folded - cos(seg * 0.5);
return mix(poly, r - 1.0, clamp(u_sigRound, 0.0, 1.0));
}
/** TRAIT: shape, as a filled mask of the given radius, centred on a point. */
float sigForm(vec2 p, vec2 centre, float size) {
float d = sigShape((p - centre) / max(size, 1e-3)) * max(size, 1e-3);
return smoothstep(u_sigSoft * 0.25 + 0.004, -u_sigSoft * 0.25, d);
}
/**
* TRAIT: camera. The same operator filming every scene — a slow drift, a sway,
* a roll, and a bar-locked breath. Apply to a centred coordinate before using it.
*/
vec2 sigCamera(vec2 p) {
float t = u_time;
p = rot(u_sigSpin * t) * p;
p *= 1.0 - u_sigBreathe * sin(u_barPhase * 6.28318530718);
p += vec2(sin(t * u_sigSwayRate), cos(t * u_sigSwayRate * 0.83)) * u_sigSway;
// The pan RETURNS. A constant translation would be a camera on rails: two
// minutes in, every scene has left the frame entirely. This is a slow track
// across the subject and back, roughly a two-minute cycle.
p -= u_sigDrift * 20.0 * sin(t * 0.05);
return p;
}
/** TRAIT: style. Fold the frame the track's way. 1 fold means no fold. */
vec2 sigFolded(vec2 p) {
return kaleido(p, u_sigFold);
}
/** TRAIT: style. Turn a signed distance into an edge drawn in the track's hand. */
float sigEdge(float d) {
float w = 0.004 + u_sigLine * 0.03;
float soft = w * (0.25 + u_sigSoft * 1.5);
return smoothstep(w + soft, w - soft, abs(d));
}
/** TRAIT: style. The track's surface grain, for a scene to add at its own weight. */
float sigGrain(vec2 uv) {
if (u_sigTexture <= 0.001) return 0.0;
return (hash12(uv * 512.0 + floor(u_frame)) - 0.5) * u_sigTexture;
}
/**
* TRAIT: space. Height of the shared horizon in the same units as 'p'.
* Positive is up; a scene with any sense of ground should sit on it.
*/
float sigHorizonY() {
return (u_sigHorizon - 0.5) * 2.0;
}
/** TRAIT: space. The location's air: distance haze plus the background wash. */
vec3 sigAir(vec3 col, vec2 p, float distance01) {
vec3 far = pal(0) * (0.25 + 0.35 * u_sigDepth);
col = mix(col, far, clamp(distance01, 0.0, 1.0) * u_sigDepth);
col += pal(1) * dot(p, u_sigWash) * 0.35;
return col;
}
vec3 prev(vec2 uv) {
if (u_hasPrev == 0) return vec3(0.0);
return texture2D(u_prev, uv).rgb;