Drop the scratch probes, and stop them coming back
_audioprobe, _captureprobe, _decodetest, _encprobe, _exporttest, _muxprobe
and _playtest were one-off harnesses for debugging the decode/encode/mux
path. They were untracked working-tree files until `git add -A` in 17a583a
swept them into that commit — my mistake, not a deliberate decision.
Removed, and _*.html added to .gitignore so a broad `git add` cannot pick up
the next batch either.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
8f5ff3a239
commit
c92c398d18
4
flow-state/.gitignore
vendored
4
flow-state/.gitignore
vendored
@ -2,3 +2,7 @@ node_modules
|
||||
dist
|
||||
.vite
|
||||
out
|
||||
|
||||
# Scratch probes: one-off harnesses for debugging decode/encode/mux issues.
|
||||
# They are throwaway by nature and were committed once by accident.
|
||||
_*.html
|
||||
|
||||
@ -1,52 +0,0 @@
|
||||
<!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>
|
||||
@ -1,128 +0,0 @@
|
||||
<!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>
|
||||
@ -1,85 +0,0 @@
|
||||
<!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>
|
||||
@ -1,131 +0,0 @@
|
||||
<!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>
|
||||
@ -1,43 +0,0 @@
|
||||
<!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>
|
||||
@ -1,95 +0,0 @@
|
||||
<!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>
|
||||
@ -1,60 +0,0 @@
|
||||
<!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>
|
||||
Loading…
Reference in New Issue
Block a user