music-video-gen/flow-state/src/engine/hash.js
Dejvino 7e31c19d6e Phase 0: determinism spine
Engine core: Timeline (fixed dt, audio-mastered in realtime), seeded Rng,
Renderer, Layer/ShaderLayer/SceneLayer, Compositor with blend modes,
feedback and post chain.

Shader scenes are compiled against a fixed uniform contract and define only
`vec4 scene(vec2 uv, vec2 p)`, so adding a scene costs a shader plus a
params block. Deep Nebula ported from party-stage as the first one.

Gate passes, 7/7 in checks.html:
- 300 frames rendered twice are bit-identical
- a fresh Engine reproduces the same frames
- simulated dropped frames change nothing (proves dt is fixed)
- seek matches sequential playback
- 320x180 vs 1280x720 agree within 0.010 (limit 0.06)
- seeded rng reproducible, forked streams independent
- compositor reset clears feedback history

Static gates: no wall-clock or unseeded randomness in deterministic
directories; scene schemas and shader sources agree in both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 10:59:30 +02:00

98 lines
3.9 KiB
JavaScript

// Frame hashing and comparison — the backbone of every determinism check.
//
// Determinism guarantee, precisely: on one machine (same browser, GPU, driver)
// frames are bit-identical, so `hashFrame` is the right test. ACROSS machines,
// float and derivative differences make bit-exactness unachievable, so the
// cross-machine test is `frameDistance` against a small threshold. Writing the
// checks this way keeps the acceptance criteria honest and actually passable.
/** FNV-1a over raw RGBA bytes. Bit-exact test, same-machine. */
export function hashFrame(pixels) {
let h = 0x811c9dc5 >>> 0;
for (let i = 0; i < pixels.length; i++) {
h ^= pixels[i];
h = Math.imul(h, 0x01000193) >>> 0;
}
return h.toString(16).padStart(8, '0');
}
/**
* Mean absolute per-channel difference, 0..1. Used for the cross-machine and
* dual-resolution comparisons where bit-exactness is not a fair ask.
*/
export function frameDistance(a, b) {
if (a.length !== b.length) return 1;
let sum = 0;
for (let i = 0; i < a.length; i += 4) {
sum += Math.abs(a[i] - b[i]) + Math.abs(a[i + 1] - b[i + 1]) + Math.abs(a[i + 2] - b[i + 2]);
}
return sum / ((a.length / 4) * 3 * 255);
}
/** Largest single-channel difference. Catches localised breakage a mean would hide. */
export function frameMaxDelta(a, b) {
if (a.length !== b.length) return 255;
let max = 0;
for (let i = 0; i < a.length; i++) {
const d = Math.abs(a[i] - b[i]);
if (d > max) max = d;
}
return max;
}
/** Mean luminance, 0..1. Used by the range sweep to catch black/white-out frames. */
export function frameLuminance(pixels) {
let sum = 0;
const n = pixels.length / 4;
for (let i = 0; i < pixels.length; i += 4) {
sum += 0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2];
}
return sum / n / 255;
}
/** Per-channel standard deviation, averaged. Near zero means a flat, dead frame. */
export function frameVariance(pixels) {
const n = pixels.length / 4;
let mr = 0, mg = 0, mb = 0;
for (let i = 0; i < pixels.length; i += 4) { mr += pixels[i]; mg += pixels[i + 1]; mb += pixels[i + 2]; }
mr /= n; mg /= n; mb /= n;
let vr = 0, vg = 0, vb = 0;
for (let i = 0; i < pixels.length; i += 4) {
vr += (pixels[i] - mr) ** 2; vg += (pixels[i + 1] - mg) ** 2; vb += (pixels[i + 2] - mb) ** 2;
}
return (Math.sqrt(vr / n) + Math.sqrt(vg / n) + Math.sqrt(vb / n)) / 3 / 255;
}
/** True if the frame contains any non-finite pixel artefact of a NaN in the shader. */
export function frameHasNaN(pixels) {
// A NaN in GLSL resolves to 0 or garbage on readback; the practical detector
// is a frame that is entirely one value while variance is exactly zero AND
// luminance is neither plausible black nor plausible white.
return false; // superseded by the luminance/variance checks in sweepScene
}
/**
* Downsample RGBA pixels by integer box filter. Used by the dual-resolution
* check so a 4K render can be compared against a 720p one.
*/
export function downsample(pixels, width, height, factor) {
const ow = Math.floor(width / factor);
const oh = Math.floor(height / factor);
const out = new Uint8Array(ow * oh * 4);
for (let y = 0; y < oh; y++) {
for (let x = 0; x < ow; x++) {
let r = 0, g = 0, b = 0, a = 0;
for (let dy = 0; dy < factor; dy++) {
for (let dx = 0; dx < factor; dx++) {
const si = ((y * factor + dy) * width + (x * factor + dx)) * 4;
r += pixels[si]; g += pixels[si + 1]; b += pixels[si + 2]; a += pixels[si + 3];
}
}
const n = factor * factor;
const di = (y * ow + x) * 4;
out[di] = r / n; out[di + 1] = g / n; out[di + 2] = b / n; out[di + 3] = a / n;
}
}
return { pixels: out, width: ow, height: oh };
}