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:
@@ -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>
|
||||
Reference in New Issue
Block a user