Side quest 1: give four scenes art direction other than grain

Classic Wave, Silk Ribbon, Kaleido Tunnel and Slow Orb all declared the
`style` trait and honoured it with `col += sigGrain(uv)` and nothing else. A
declared trait is a contract — the disqualification rule in Personality.js is
the only thing keeping off-design scenes out of a track — so honouring it
with dirt meant these four could not take the `texture: 0` opt-out the grain
work introduced. Phase 9 measured their style response at exactly 0. They sat
at texture: 0.35 as a stopgap, which kept speckle on the library's cleanest
scenes purely to keep a gate green.

Each already had the knob; it just was not wired to the track:

- Classic Wave contrasts its wave through a bare smoothstep(0.2, 0.8). The
  transition width now comes from u_sigSoft and u_sigLine, centred on 0.5 so
  changing the hand does not change the exposure. Crest concentration is
  driven separately by u_sigLine, because a track is free to sample the
  scene's own u_softness at zero and the art direction must still show.
- Silk Ribbon's strand width is u_sigLine and its falloff exponent u_sigSoft:
  a sharp track gets a filament with a defined edge, a soft one a haze.
- Kaleido Tunnel drew its grid against a bare 0.42 — a line weight with no
  name. It is now a weight and a feather, both from the track.
- Slow Orb's body edge multiplies the scene's softness by the video's, and
  gains a sigEdge rim so a sharp-handed track gets a defined limb.

All four are now texture: 0. Style response measured against a maximally
soft-handed versus maximally sharp-handed personality: Classic Wave 111,
Silk Ribbon 216, Kaleido Tunnel 206, Slow Orb 102, out of 255 — previously 0.

104/104 checks pass including the slow set. See SIDE-QUESTS.md §1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino 2026-08-06 19:35:50 +02:00
parent 8dfd8392f3
commit 17a583a87f
11 changed files with 641 additions and 17 deletions

View File

@ -0,0 +1,52 @@
<!DOCTYPE html>
<html>
<head><title>audio probe</title></head>
<body>
<div id="out">running…</div>
<script type="module">
const out = document.getElementById('out');
const log = (m) => { out.textContent += m + '\n'; };
for (const sr of [44100, 48000]) {
try {
const s = await AudioEncoder.isConfigSupported({ codec: 'opus', sampleRate: sr, numberOfChannels: 2, bitrate: 160000 });
log(`opus @ ${sr}: supported=${s.supported}`);
} catch (e) { log(`opus @ ${sr}: threw ${e.message}`); }
}
for (const sr of [44100, 48000]) {
try {
const s = await AudioEncoder.isConfigSupported({ codec: 'mp4a.40.2', sampleRate: sr, numberOfChannels: 2, bitrate: 192000 });
log(`aac @ ${sr}: supported=${s.supported}`);
} catch (e) { log(`aac @ ${sr}: threw ${e.message}`); }
}
// Encode a 44100 stream with opus and inspect the output chunk metadata.
try {
const chunks = [];
const enc = new AudioEncoder({
output: (chunk, meta) => chunks.push({ ts: chunk.timestamp, dur: chunk.duration }),
error: (e) => log('enc error ' + e.message),
});
enc.configure({ codec: 'opus', sampleRate: 44100, numberOfChannels: 2, bitrate: 160000 });
const n = 44100; // 1 second
const data = new Float32Array(n * 2);
let td;
for (let i = 0; i < 10; i++) {
td = new AudioData({ format: 'f32', sampleRate: 44100, numberOfFrames: 4096, numberOfChannels: 2, timestamp: i * 4096 / 44100 * 1e6, data: data });
enc.encode(td); td.close();
}
await enc.flush();
log(`opus@44100 chunks: ${chunks.length}`);
if (chunks.length) {
const deltas = chunks.slice(1).map((c, i) => c.ts - chunks[i].ts);
log('first chunk ts/dur: ' + chunks[0].ts + '/' + chunks[0].dur);
log('deltas: ' + deltas.slice(0, 5).join(' '));
log('last ts: ' + chunks[chunks.length - 1].ts);
}
enc.close();
} catch (e) { log('opus@44100 encode threw: ' + e.message); }
log('DONE');
window.__AUDIO_PROBE__ = true;
</script>
</body>
</html>

View File

@ -0,0 +1,128 @@
<!DOCTYPE html>
<html>
<head><title>capture probe</title></head>
<body>
<canvas id="c" width="1" height="1"></canvas>
<pre id="out">running…</pre>
<script type="module">
import { Show } from '/src/Show.js';
import { FeatureTrack } from '/src/audio/FeatureTrack.js';
import { synthesizeSectioned } from '/src/audio/synth.js';
import { generateLook } from '/src/look/LookGenerator.js';
import { ArcDriver } from '/src/look/ArcDriver.js';
const out = document.getElementById('out');
const log = (m) => { out.textContent += m + '\n'; console.log(m); };
const q = new URLSearchParams(location.search);
const SECONDS = Number(q.get('seconds') || 10);
const WIDTH = Number(q.get('width') || 1920);
const HEIGHT = Number(q.get('height') || 1080);
const FPS = 60;
const BITRATE = 16_000_000;
// Real render path: the same Show the exporter drives.
const show = new Show({ canvas: document.getElementById('c'), width: 640, height: 360, fps: FPS });
const buffer = synthesizeSectioned({ bpm: 120, duration: SECONDS, changeAt: SECONDS / 2 });
const track = FeatureTrack.fromAudioBuffer(buffer, { fps: FPS });
show.track = track;
show.engine.timeline.setDuration(track.duration);
show.look = generateLook(track, { seed: 7 });
show.audioBuffer = buffer;
show.arc = new ArcDriver(show.look, track);
show.setSize(WIDTH, HEIGHT);
show.prime(0);
const TOTAL = show.frameCount;
log(`real Show · ${WIDTH}×${HEIGHT} @ ${FPS} · ${TOTAL} frames · bitrate ${BITRATE / 1e6} Mbps\n`);
/**
* Capture strategies. The question is whether handing the encoder a frame built
* straight from the WebGL canvas lets a later present() overwrite it before the
* encoder has read it — which would show up as fewer chunks out than frames in.
*/
const STRATEGIES = {
// What the exporter does today.
'webgl canvas direct': {
make: (canvas, init) => new VideoFrame(canvas, init),
},
// Force a pixel copy through a 2D canvas before the encoder sees it.
'2d canvas copy': {
setup() {
this.scratch = new OffscreenCanvas(WIDTH, HEIGHT);
this.ctx = this.scratch.getContext('2d', { willReadFrequently: false });
},
make(canvas, init) {
this.ctx.drawImage(canvas, 0, 0, WIDTH, HEIGHT);
return new VideoFrame(this.scratch, init);
},
},
// Same idea via createImageBitmap, which is async but explicitly a snapshot.
'createImageBitmap copy': {
async make(canvas, init) {
const bitmap = await createImageBitmap(canvas);
const frame = new VideoFrame(bitmap, init);
bitmap.close();
return frame;
},
},
};
async function run(label) {
const strategy = STRATEGIES[label];
if (strategy.setup) strategy.setup();
let emitted = 0;
let error = null;
const stamps = [];
const encoder = new VideoEncoder({
output: (chunk) => { emitted++; stamps.push(chunk.timestamp); },
error: (e) => { error = e; },
});
encoder.configure({
codec: 'avc1.640034',
width: WIDTH, height: HEIGHT, bitrate: BITRATE, framerate: FPS,
avc: { format: 'avc' },
});
show.engine.compositor.reset();
const t0 = performance.now();
let encoded = 0;
for (let i = 0; i < TOTAL; i++) {
if (error) break;
// Exactly the exporter's loop: render, present, capture, encode.
show.present(show.renderFrame(i));
const frame = await strategy.make(show.engine.renderer.canvas, {
timestamp: Math.round((i * 1e6) / FPS),
duration: Math.round(1e6 / FPS),
});
encoder.encode(frame, { keyFrame: i % (FPS * 2) === 0 });
frame.close();
encoded++;
if (i % 10 === 0) {
while (encoder.encodeQueueSize > 30) await new Promise((r) => setTimeout(r, 4));
await new Promise((r) => setTimeout(r, 0));
}
}
try { await encoder.flush(); } catch (e) { error = error || e; }
const ms = performance.now() - t0;
try { encoder.close(); } catch { /* already closed */ }
const period = 1e6 / FPS;
stamps.sort((a, b) => a - b);
const gaps = stamps.slice(1).map((t, k) => Math.round((t - stamps[k]) / period));
const maxGap = gaps.length ? Math.max(...gaps) : 0;
const verdict = error ? `ERROR ${error.message}`
: emitted === encoded ? 'OK'
: `DROPPED ${encoded - emitted} (${((1 - emitted / encoded) * 100).toFixed(0)}%) → plays at ${((emitted / encoded) * FPS).toFixed(1)} fps`;
log(`${label.padEnd(24)} in ${encoded} → out ${emitted} maxGap ${maxGap} ${(ms / 1000).toFixed(1)}s ${verdict}`);
}
for (const label of Object.keys(STRATEGIES)) await run(label);
log('\nDONE');
window.__DONE__ = true;
</script>
</body>
</html>

View File

@ -0,0 +1,85 @@
<!DOCTYPE html>
<html>
<head><title>decode test</title></head>
<body>
<div id="out">running…</div>
<script type="module">
import { hashFrame } from '/src/engine/hash.js';
const out = document.getElementById('out');
const log = (m) => { out.textContent += m + '\n'; };
const resp = await fetch('/export.mp4');
const blob = await resp.blob();
const frames = [];
function findBox(buf, start, type) {
let p = start;
while (p + 8 <= buf.length) {
let size = (buf[p] << 24) | (buf[p+1] << 16) | (buf[p+2] << 8) | buf[p+3];
const t = String.fromCharCode(buf[p+4], buf[p+5], buf[p+6], buf[p+7]);
const hdr = size === 1 ? 16 : 8;
const boxEnd = size === 1 ? p + Number(((buf[p+8] * 4294967296) + ((buf[p+9] << 24) | (buf[p+10] << 16) | (buf[p+11] << 8) | buf[p+12]))) : p + size;
if (boxEnd < p + hdr || boxEnd > buf.length) break;
if (t === type) return { data: p + hdr, end: boxEnd };
if (['moov','trak','mdia','minf','stbl','stsd'].includes(t)) {
const r = findBox(buf, p + hdr, type);
if (r) return r;
}
p = boxEnd;
}
return null;
}
const buf2 = new Uint8Array(await blob.arrayBuffer());
const avcC = findBox(buf2, 0, 'avcC');
let description = null;
if (avcC) {
description = buf2.slice(avcC.data, avcC.end);
log(`found avcC (${description.length} bytes)`);
} else {
log('no avcC box found');
}
const decoder = new VideoDecoder({
output: (frame) => { frames.push(frame); },
error: (e) => log('DECODER ERROR: ' + e.message),
});
decoder.configure({ codec: 'avc1.640034', description });
const reader = blob.stream().getReader();
let buf = new Uint8Array(0);
while (true) {
const { done, value } = await reader.read();
if (done) break;
const next = new Uint8Array(buf.length + value.length);
next.set(buf); next.set(value, buf.length);
buf = next;
}
const chunk = new EncodedVideoChunk({ type: 'key', timestamp: 0, duration: 0, data: buf });
decoder.decode(chunk);
await decoder.flush();
log(`decoded ${frames.length} frames`);
// hash a small downscaled copy of each frame
const canvas = new OffscreenCanvas(64, 36);
const ctx = canvas.getContext('2d');
const hashes = [];
for (const f of frames) {
ctx.drawImage(f, 0, 0, 64, 36);
const img = ctx.getImageData(0, 0, 64, 36);
hashes.push(hashFrame(img.data));
f.close();
}
let dupRuns = 0, maxRun = 0, curRun = 1;
for (let i = 1; i < hashes.length; i++) {
if (hashes[i] === hashes[i-1]) { curRun++; }
else { if (curRun > 1) dupRuns++; maxRun = Math.max(maxRun, curRun); curRun = 1; }
}
maxRun = Math.max(maxRun, curRun);
const distinct = new Set(hashes).size;
log(`distinct frames: ${distinct} / ${hashes.length}`);
log(`duplicate runs: ${dupRuns}, longest run: ${maxRun}`);
log('DONE');
window.__DECODE_DONE__ = true;
</script>
</body>
</html>

131
flow-state/_encprobe.html Normal file
View File

@ -0,0 +1,131 @@
<!DOCTYPE html>
<html>
<head><title>encoder probe</title></head>
<body>
<pre id="out">running…</pre>
<canvas id="c" width="1920" height="1080" style="display:none"></canvas>
<script type="module">
const out = document.getElementById('out');
const log = (m) => { out.textContent += m + '\n'; console.log(m); };
// A cheap animated source. The point is to test the ENCODER, so the frames must
// be genuinely different (a static source would let the encoder coast) but must
// cost nothing to produce — no shaders, no Show, no GPU readback.
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
function drawFrame(i) {
ctx.fillStyle = `hsl(${(i * 3) % 360} 60% 12%)`;
ctx.fillRect(0, 0, 1920, 1080);
ctx.fillStyle = `hsl(${(i * 7) % 360} 80% 60%)`;
for (let k = 0; k < 40; k++) {
const x = ((i * 11 + k * 137) % 1920);
const y = ((i * 5 + k * 271) % 1080);
ctx.fillRect(x, y, 90, 90);
}
ctx.fillStyle = '#fff';
ctx.font = '120px monospace';
ctx.fillText(String(i), 60, 200);
}
const FPS = 60;
const FRAMES = 600;
const WIDTH = 1920, HEIGHT = 1080, BITRATE = 16_000_000;
const MAX_QUEUE = 8;
function waitForQueue(encoder) {
if (encoder.encodeQueueSize < MAX_QUEUE) return Promise.resolve();
return new Promise((resolve) => {
const onDequeue = () => {
if (encoder.encodeQueueSize < MAX_QUEUE) {
encoder.removeEventListener('dequeue', onDequeue);
resolve();
}
};
encoder.addEventListener('dequeue', onDequeue);
});
}
async function run(label, extra) {
const config = {
codec: 'avc1.640034',
width: WIDTH, height: HEIGHT, bitrate: BITRATE, framerate: FPS,
avc: { format: 'avc' },
...extra,
};
let support;
try {
support = await VideoEncoder.isConfigSupported(config);
} catch (e) {
log(`${label.padEnd(34)} isConfigSupported threw: ${e.message}`);
return;
}
if (!support.supported) {
log(`${label.padEnd(34)} UNSUPPORTED`);
return;
}
let outputs = 0;
let keyframes = 0;
const stamps = [];
let error = null;
const encoder = new VideoEncoder({
output: (chunk) => {
outputs++;
if (chunk.type === 'key') keyframes++;
stamps.push(chunk.timestamp);
},
error: (e) => { error = e; },
});
encoder.configure(config);
const t0 = performance.now();
let encoded = 0;
for (let i = 0; i < FRAMES; i++) {
if (error) break;
drawFrame(i);
await waitForQueue(encoder);
const frame = new VideoFrame(canvas, {
timestamp: Math.round((i * 1e6) / FPS),
duration: Math.round(1e6 / FPS),
});
encoder.encode(frame, { keyFrame: i % (FPS * 2) === 0 });
frame.close();
encoded++;
if (i % 10 === 0) await new Promise((r) => setTimeout(r, 0));
}
try { await encoder.flush(); } catch (e) { error = error || e; }
const ms = performance.now() - t0;
try { encoder.close(); } catch { /* already closed */ }
// Do NOT sort: the order chunks ARRIVE in is the whole point. WebCodecs
// delivers in decode order, so a timestamp that goes backwards means the
// encoder emitted B-frames — and a muxer handed presentation timestamps as
// if they were decode timestamps will reject exactly those chunks.
const period = 1e6 / FPS;
const backwards = stamps.filter((t, i) => i > 0 && t < stamps[i - 1]).length;
const sorted = stamps.slice().sort((a, b) => a - b);
const gaps = sorted.slice(1).map((t, i) => Math.round((t - sorted[i]) / period));
const maxGap = gaps.length ? Math.max(...gaps) : 0;
const verdict = error ? `ERROR ${error.message}`
: backwards ? `REORDERED — ${backwards} chunks arrived out of order (B-frames)`
: outputs === encoded ? 'OK'
: `DROPPED ${encoded - outputs} (${((1 - outputs / encoded) * 100).toFixed(0)}%)`;
log(`${label.padEnd(34)} in ${encoded} → out ${outputs} keys ${keyframes} maxGap ${maxGap} reordered ${backwards} ${(ms / 1000).toFixed(1)}s ${verdict}`);
}
log(`source ${WIDTH}×${HEIGHT} @ ${FPS} · ${FRAMES} frames · bitrate ${BITRATE / 1e6} Mbps\n`);
await run('default (no hints)', {});
await run('prefer-hardware', { hardwareAcceleration: 'prefer-hardware' });
await run('prefer-software', { hardwareAcceleration: 'prefer-software' });
await run('latency=realtime', { latencyMode: 'realtime' });
await run('prefer-hardware + realtime', { hardwareAcceleration: 'prefer-hardware', latencyMode: 'realtime' });
await run('prefer-software + realtime', { hardwareAcceleration: 'prefer-software', latencyMode: 'realtime' });
await run('baseline avc1.42003e', { codec: 'avc1.42003e' });
log('\nDONE');
window.__DONE__ = true;
</script>
</body>
</html>

View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html>
<head><title>export test</title></head>
<body>
<canvas id="c" width="1" height="1"></canvas>
<div id="out">running…</div>
<script type="module">
import { Show } from '/src/Show.js';
import { FeatureTrack } from '/src/audio/FeatureTrack.js';
import { synthesizeSectioned } from '/src/audio/synth.js';
import { generateLook } from '/src/look/LookGenerator.js';
import { Exporter, PRESETS } from '/src/export/Exporter.js';
const out = document.getElementById('out');
const log = (m) => { out.textContent += m + '\n'; };
const fps = 60;
const show = new Show({ canvas: document.getElementById('c'), width: 640, height: 360, fps });
const buffer = synthesizeSectioned({ bpm: 120, duration: 8, changeAt: 4 });
const track = FeatureTrack.fromAudioBuffer(buffer, { fps });
show.track = track;
show.engine.timeline.setDuration(track.duration);
show.look = generateLook(track, { seed: 7 });
show.audioBuffer = buffer;show.arc = new (await import('/src/look/ArcDriver.js')).ArcDriver(show.look, track);
log(`frameCount ${show.frameCount} · fps ${fps} · duration ${track.duration.toFixed(2)}s`);
// Override size so we don't pay 720p cost in swiftshader.
const ex = new Exporter(show);
try {
const start = performance.now();
const blob = await ex.export({ preset: '720p', frameRange: [0, show.frameCount], onProgress: (p) => {} });
const ms = performance.now() - start;
log(`export done in ${(ms/1000).toFixed(1)}s, ${(blob.size/1e6).toFixed(2)} MB`);
window.__EXPORT_BLOB__ = blob;
} catch (e) {
log('EXPORT THREW: ' + e.stack);
}
log('DONE');
window.__EXPORT_DONE__ = true;
</script>
</body>
</html>

95
flow-state/_muxprobe.html Normal file
View File

@ -0,0 +1,95 @@
<!DOCTYPE html>
<html>
<head><title>mux probe</title></head>
<body>
<canvas id="c" width="1" height="1"></canvas>
<pre id="out">running…</pre>
<script type="module">
import { Show } from '/src/Show.js';
import { FeatureTrack } from '/src/audio/FeatureTrack.js';
import { synthesizeSectioned } from '/src/audio/synth.js';
import { generateLook } from '/src/look/LookGenerator.js';
import { Exporter } from '/src/export/Exporter.js';
const out = document.getElementById('out');
const log = (m) => { out.textContent += m + '\n'; console.log(m); };
// ---- minimal mp4 box walker: report per-track sample counts and stts ----
function probe(ab) {
const buf = new DataView(ab);
const u8 = new Uint8Array(ab);
const str = (o, n) => String.fromCharCode(...u8.subarray(o, o + n));
const report = [];
let cur = null;
function walk(start, end) {
let off = start;
while (off + 8 <= end) {
let size = buf.getUint32(off);
const type = str(off + 4, 4);
let hdr = 8;
if (size === 1) { size = Number(buf.getBigUint64(off + 8)); hdr = 16; }
if (size === 0) size = end - off;
if (['moov', 'trak', 'mdia', 'minf', 'stbl'].includes(type)) {
if (type === 'trak') { cur = {}; report.push(cur); }
walk(off + hdr, off + size);
} else {
const s = off + hdr;
if (type === 'mdhd' && cur) {
cur.timescale = buf.getUint32(s + 12);
cur.duration = buf.getUint32(s + 16);
}
if (type === 'hdlr' && cur) cur.kind = str(s + 8, 4);
if (type === 'stsd' && cur) cur.format = str(s + 12, 4);
if (type === 'stts' && cur) {
const n = buf.getUint32(s + 4);
let samples = 0, total = 0;
const rows = [];
for (let i = 0; i < n; i++) {
const c = buf.getUint32(s + 8 + i * 8);
const d = buf.getUint32(s + 12 + i * 8);
rows.push([c, d]); samples += c; total += c * d;
}
cur.stts = { entries: n, samples, total, rows: rows.slice(0, 12) };
}
if (type === 'stsz' && cur) cur.sampleCount = buf.getUint32(s + 8);
}
off += size;
}
}
walk(0, ab.byteLength);
return report;
}
const q = new URLSearchParams(location.search);
const PRESET = q.get('preset') || '720p';
const SECONDS = Number(q.get('seconds') || 8);
const fps = 60;
const show = new Show({ canvas: document.getElementById('c'), width: 640, height: 360, fps });
const buffer = synthesizeSectioned({ bpm: 120, duration: SECONDS, changeAt: SECONDS / 2 });
const track = FeatureTrack.fromAudioBuffer(buffer, { fps });
show.track = track;
show.engine.timeline.setDuration(track.duration);
show.look = generateLook(track, { seed: 7 });
show.audioBuffer = buffer;
show.arc = new (await import('/src/look/ArcDriver.js')).ArcDriver(show.look, track);
log(`frameCount ${show.frameCount} · fps ${fps} · audio ${buffer.sampleRate}Hz ${buffer.numberOfChannels}ch ${buffer.duration.toFixed(2)}s`);
const ex = new Exporter(show);
try {
const t0 = performance.now();
const blob = await ex.export({ preset: PRESET, onProgress: () => {} });
log(`export ${(performance.now() - t0) / 1000 | 0}s · ${(blob.size / 1e6).toFixed(2)} MB · warnings: ${JSON.stringify(ex.warnings)}`);
log(`frameStats ${JSON.stringify(ex.frameStats)}`);
const rows = probe(await blob.arrayBuffer());
for (const r of rows) log(JSON.stringify(r));
log(`EXPECTED video samples: ${show.frameCount}`);
} catch (e) {
log('THREW: ' + e.stack);
}
log('DONE');
window.__DONE__ = true;
</script>
</body>
</html>

60
flow-state/_playtest.html Normal file
View File

@ -0,0 +1,60 @@
<!DOCTYPE html>
<html>
<head><title>play test</title></head>
<body>
<video id="v" controls playsinline></video>
<div id="out">running…</div>
<script type="module">
const out = document.getElementById('out');
const log = (m) => { out.textContent += m + '\n'; };
const v = document.getElementById('v');
v.src = '/export.mp4';
const times = [];
const stamp = (mediaTime, now) => times.push({ mt: mediaTime, t: now });
function track() {
v.requestVideoFrameCallback((now, md) => {
stamp(md.mediaTime, now);
track();
});
}
track();
let audioTimes = [];
const ap = (e) => { if (audioTimes.length < 200) audioTimes.push({ ct: v.currentTime, t: performance.now() }); };
setInterval(ap, 30);
v.onplaying = () => {
log('playing…');
setTimeout(async () => {
const dur = v.duration;
log(`video.duration = ${dur.toFixed(3)}s`);
await new Promise((r) => setTimeout(r, 2500));
const end = performance.now();
const pts = times.filter((x) => x.t <= end);
const deltas = pts.slice(1).map((x, i) => x.mt - pts[i].mt);
log(`presented frames in window: ${pts.length}`);
const mean = deltas.reduce((a, b) => a + b, 0) / deltas.length;
const variance = deltas.reduce((a, b) => a + (b - mean) ** 2, 0) / deltas.length;
log(`mean inter-frame mediaTime delta: ${mean.toFixed(5)}s (60fps would be ${(1/60).toFixed(5)})`);
log(`variance: ${variance.toFixed(8)}`);
log(`min/max: ${Math.min(...deltas).toFixed(5)} / ${Math.max(...deltas).toFixed(5)}`);
// A/V sync: compare media time progression to wall time
if (pts.length > 10) {
const wallSpan = (pts[pts.length-1].t - pts[0].t) / 1000;
const mediaSpan = pts[pts.length-1].mt - pts[0].mt;
log(`wall ${wallSpan.toFixed(2)}s vs media ${mediaSpan.toFixed(2)}s → play rate ${(mediaSpan/wallSpan).toFixed(3)}`);
}
const aEnd = audioTimes[audioTimes.length-1];
const aStart = audioTimes[0];
if (aStart && aEnd) {
log(`audio: currentTime ${aStart.ct.toFixed(2)}→${aEnd.ct.toFixed(2)} over ${((aEnd.t-aStart.t)/1000).toFixed(2)}s → rate ${((aEnd.ct-aStart.ct)/((aEnd.t-aStart.t)/1000)).toFixed(3)}`);
}
log('DONE');
window.__PLAY_DONE__ = true;
}, 500);
};
await new Promise((resolve, reject) => { v.onerror = () => reject(new Error('video error')); setTimeout(resolve, 30000); });
try { await v.play(); } catch (e) { log('play() threw: ' + e.message); }
</script>
</body>
</html>

View File

@ -7,9 +7,8 @@ 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,
// Smooth concentric colour: grain only mutes the ramp it is built on.
texture: 0,
traits: ['shape', 'camera', 'style'],
params: {
@ -47,9 +46,18 @@ vec4 scene(vec2 uv, vec2 p) {
float spoke = u_spokes > 0 ? sin(angle * float(u_spokes) + t) * u_beat : 0.0;
float v = 0.5 + 0.5 * sin(wave + spoke);
v = mix(v, smoothstep(0.2, 0.8, v), u_softness);
vec3 col = palRamp(t * u_colorRoll + d * 0.25) * v;
// TRAIT style: the track's hand on the crests. The scene's own u_softness
// decides HOW MUCH the wave is contrasted; the track decides what that
// contrast looks like — u_sigSoft widens the transition, u_sigLine tightens
// it. Centred on 0.5 so changing the hand does not change the exposure.
float edge = mix(0.06, 0.42, sat(u_sigSoft)) * mix(1.3, 0.6, sat(u_sigLine));
v = mix(v, smoothstep(0.5 - edge, 0.5 + edge, v), u_softness);
// ...and independently of u_softness, which a track is free to sample at
// zero: a heavier line concentrates the crest rather than letting it bloom
// across the whole ring.
vec3 col = palRamp(t * u_colorRoll + d * 0.25) * pow(v, mix(1.0, 2.2, sat(u_sigLine)));
// Core glow, the part that reads as the "hit".
col += pal(0) * (1.0 - smoothstep(0.0, 0.7, d)) * u_bloomCore * 0.6;

View File

@ -6,9 +6,8 @@ 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,
// Hard grid lines on flat colour: grain reads as dirt on the lens.
texture: 0,
traits: ['shape', 'camera', 'style'],
params: {
@ -49,7 +48,13 @@ vec4 scene(vec2 uv, vec2 p) {
float ringLines = abs(fract(z * u_rings * 0.1) - 0.5) * 2.0;
float wallLines = abs(fract(wall * sides) - 0.5) * 2.0;
float grid = smoothstep(0.42, 0.0, ringLines) + smoothstep(0.42, 0.0, wallLines);
// TRAIT style: the grid is the whole image, so the track draws it. The
// threshold was a bare 0.42 — a line weight with no name. u_sigLine sets how
// much of the cell the line occupies, u_sigSoft how far it feathers.
float weight = mix(0.22, 0.6, sat(u_sigLine));
float feather = weight * mix(0.15, 1.0, sat(u_sigSoft));
float grid = smoothstep(weight, max(weight - feather, 0.0), ringLines)
+ smoothstep(weight, max(weight - feather, 0.0), wallLines);
vec3 col = palRamp(z * 0.05 + wall * 0.2) * 0.35;
col += pal(int(mod(floor(z * u_rings * 0.1), 6.0))) * grid * 0.7;

View File

@ -9,9 +9,8 @@ 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,
// A soft drape of light. Grain furs the one thing it is made of.
texture: 0,
traits: ['camera', 'style'],
params: {
@ -57,8 +56,16 @@ vec4 scene(vec2 uv, vec2 p) {
best = min(best, d);
}
// TRAIT style: the ribbon is drawn in the track's hand. u_sigLine sets
// how wide the strand is, u_sigSoft how fast it falls off — a sharp
// track gets a filament with a defined edge, a soft one gets a haze.
// This is the scene's whole art direction, so it is not scaled by any
// of its own params: there is no setting at which the track stops
// showing.
float w = u_thickness * mix(0.55, 1.7, sat(u_sigLine));
float falloff = mix(2.6, 0.9, sat(u_sigSoft));
vec3 hc = pal(int(fk) % 3);
col += hc * exp(-best * best / (u_thickness * u_thickness));
col += hc * exp(-pow(best / w, falloff * 2.0));
col += pal(3) * exp(-best * best * 60.0) * u_glow * 0.6;
}

View File

@ -10,9 +10,10 @@ 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,
// One soft body in an empty frame — the emptiest scene in the library, and
// the one where speckle is most visible for being least justified. It keeps
// its own `grain` param, which exists to stop large flat areas banding.
texture: 0,
traits: ['shape', 'camera', 'space', 'style'],
params: {
@ -44,13 +45,22 @@ vec4 scene(vec2 uv, vec2 p) {
float wobble = fbm(q * 2.4 + t, 4) * u_wobble;
float d = (sigShape(q / max(u_size, 1e-3)) * u_size) * (1.0 + wobble);
float body = smoothstep(u_softness * 0.5, -u_softness * 0.5, d);
// TRAIT style: the body's edge is drawn in the track's hand. The scene's
// u_softness says how soft this orb is; u_sigSoft says how soft this VIDEO
// is, and the two multiply.
float edge = u_softness * mix(0.35, 1.5, sat(u_sigSoft));
float body = smoothstep(edge * 0.5, -edge * 0.5, d);
float glow = exp(-max(d, 0.0) * (5.0 / max(u_halo, 0.05))) * u_halo;
// A rim in the track's line weight, so a sharp-handed track gets a defined
// limb against the halo instead of only a softer gradient.
float rim = sigEdge(d) * u_sigLine;
vec3 col = pal(0) * 0.05;
col = mix(col, pal(1), body * 0.85);
col += pal(2) * body * body * 0.5;
col += pal(3) * glow * 0.35;
col += pal(2) * rim * 0.5;
// Fine grain keeps large flat areas from banding.
col = sigAir(col, p, smoothstep(0.0, 1.6, length(q)));