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
co-authored by Claude Opus 5
parent 8dfd8392f3
commit 17a583a87f
11 changed files with 641 additions and 17 deletions
+128
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>