music-video-gen/flow-state/_captureprobe.html
Dejvino 17a583a87f 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>
2026-08-06 19:35:50 +02:00

129 lines
4.7 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>