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>
129 lines
4.7 KiB
HTML
129 lines
4.7 KiB
HTML
<!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>
|