Scanner: freeze the reading geometry per column, and a transport for playback

THE BUG. Tape k wrote the column the head reached k column-times ago, but used
the head position the head had NOW. So each tape sampled a neighbouring slice of
the bed, and playing the stack panned across a still instead of playing video
inside each band. The window was real; it was a window onto the wrong thing.

THE FIX. Tape k now reads with the geometry from (now - k*delta), where delta is
the time between column arrivals -- so a column keeps reading THE SAME PHYSICAL
STRIP while the source underneath it advances. Reading geometry frozen per
column; only the content moves.

Measured with a frozen source, which is the control that isolates it: with the
clip paused, any difference between tapes can only come from geometry. Comparing
only the region every tape wrote -- tape k lags by k*headWidth columns and its
tail is legitimately unwritten -- the difference falls from 15-27 to 3.4-5.6 out
of 255. The residual is column arrival times being quantised by the step rate
rather than landing exactly on k*delta.

Two earlier measurements were confounded and reported the wrong verdict:
cross-correlation aliased on periodic band structure, and a mid-run comparison
was reading unequal write coverage rather than geometry.

Also adds a playback transport under the panels -- the stack now starts and
stops, rather than only ever cycling.
This commit is contained in:
mvschwarz committed 2026-08-04 12:13:14 -07:00
1 parent 5dd2b06fd1
commit 2933efc359
1 file changed
+43 -14
+43 -14
View File
@@ -80,7 +80,11 @@
<div class="status" id="status">ready</div>
<div class="err" id="error"></div>
<div class="chain" id="chain"></div>
<div class="meta" id="meta"></div>
<div class="transport" id="playbar" hidden>
<button id="play" class="run">❚❚ pause</button>
<span class="meta" id="meta"></span>
</div>
<div class="meta" id="metaidle"></div>
</main>
<aside>
@@ -124,7 +128,7 @@ vid.muted = true; vid.loop = true; vid.playsInline = true; vid.crossOrigin = 'an
let srcEl = null, isVideo = false, srcTex = null, srcW = 0, srcH = 0, lastStamp = NaN;
let writeProg = null, blitProg = null, fadeProg = null;
let recording = false, scanTime = 0, lastWall = 0, rafId = null, duration = 12;
let stepCount = 0, wallStart = 0, showAll = false, playAccum = 0;
let stepCount = 0, wallStart = 0, showAll = false, playAccum = 0, playing = true;
// clocks: name -> {rate} | {rateTrack}. clockT accumulates each clock's OWN
// time, which is the integral of its rate — so a rate that is itself a lane
@@ -156,15 +160,16 @@ function clockRate(name) {
return c.rate ?? 1;
}
// A transport's parameters are evaluated against ITS clock, not the master.
function effectiveFor(st) {
function effectiveFor(st, back = 0) {
const P = { ...st.params };
for (const [k, kfs] of Object.entries(st.tracks || {})) {
const which = k.startsWith('bed') ? st.clocks.bed
: k.startsWith('head') ? st.clocks.head
: st.clocks.write;
// Measured from when THIS stage started, so a delayed stage still gets its
// whole ramp rather than arriving mid-sweep.
const base = (clockT[which] ?? scanTime) - (st.startAt || 0);
// whole ramp rather than arriving mid-sweep. `back` rewinds the READING
// GEOMETRY without rewinding the content — see writeStage.
const base = (clockT[which] ?? scanTime) - (st.startAt || 0) - back;
const v = evalTrack(kfs, Math.max(0, base));
if (v !== null) P[k] = v;
}
@@ -276,7 +281,7 @@ function keepPrev(st, src) {
return ok;
}
function writeStage(st, P) {
function writeStage(st, P, adv) {
const src = sourceFor(st);
if (!src.tex || !writeProg) return;
const vertical = (P.axis||'vertical') === 'vertical';
@@ -315,12 +320,31 @@ function writeStage(st, P) {
gl.uniform1f(u('uPalette'), Math.max(0,(PARAMS.palette?.values||[]).indexOf(P.palette ?? 'ember')));
gl.uniform1f(u('uDisplace'), P.displace ?? 40);
// One draw per tape. Each is a strip, so N tapes cost N strips and not N
// frames -- which is why a 24-instant window is affordable at all.
// One draw per tape, each a strip -- N tapes cost N strips, not N frames.
//
// THE READING GEOMETRY IS FROZEN PER COLUMN; ONLY THE CONTENT ADVANCES.
// Tape k writes the column the head reached k column-times ago, and it must
// read THE SAME PLACE ON THE BED that the head was reading then -- otherwise
// each tape samples a neighbouring slice and the stack plays back as a pan
// across a still rather than as video inside each band.
//
// So: bed and head come from (now - k*delta); the SOURCE FRAME is the live
// one. That is what makes a column hold a moving picture instead of a
// sequence of different places.
const colsPerSec = Math.max(1e-6, adv * 60);
const delta = wpx / colsPerSec; // seconds between column arrivals
for (let k = 0; k < n; k++) {
const back = Math.round(((( (at - k*wpx) % span) + span) % span));
const col = Math.round(((( (at - k*wpx) % span) + span) % span));
if (k > 0) {
const Q = effectiveFor(st, k * delta);
gl.uniform1f(u('uHeadAt'), Q.headPosition ?? 0);
gl.uniform1f(u('uHeadAngle'), Q.headAngle||0);
gl.uniform2f(u('uBedPos'), Q.bedX||0, Q.bedY||0);
gl.uniform1f(u('uBedRot'), (Q.bedRotate||0)*Math.PI/180);
gl.uniform1f(u('uBedScale'), Q.bedScale ?? 1);
}
gl.bindFramebuffer(gl.FRAMEBUFFER, st.fbos[k]);
if (vertical) gl.scissor(back,0,wpx,OUT_H); else gl.scissor(0,back,OUT_W,wpx);
if (vertical) gl.scissor(col,0,wpx,OUT_H); else gl.scissor(0,col,OUT_W,wpx);
gl.drawArrays(gl.TRIANGLES,0,3);
}
gl.disable(gl.SCISSOR_TEST);
@@ -395,15 +419,16 @@ function tick(now) {
if (st.tapes.length < 2) continue;
const fps = Math.max(1, Math.floor(st.params.fps || 12));
const step = 1/fps;
while (playAccum >= step) { playAccum -= step; advanced = true; }
if (playing) { while (playAccum >= step) { playAccum -= step; advanced = true; } }
if (advanced) st.playHead = (st.playHead + 1) % st.tapes.length;
blitTo(st, st.playHead);
}
if (!advanced) playAccum = Math.min(playAccum, 1);
lastWall = 0; stepCount = 0; wallStart = 0;
const withStack = stages.filter((s)=>s.tapes.length>1);
$('#playbar').hidden = !withStack.length;
if (withStack.length) metaEl.textContent =
`playing · ` + withStack.map((s)=>`${s.id} frame ${s.playHead+1}/${s.tapes.length} @ ${s.params.fps||12}fps`).join(' · ');
withStack.map((s)=>`${s.id} frame ${s.playHead+1}/${s.tapes.length} @ ${s.params.fps||12}fps`).join(' · ');
return;
}
@@ -433,7 +458,7 @@ function tick(now) {
st.writeAt += adv * (dt*60) / span;
if (st.writeAt >= 1) st.writeAt -= 1;
if (st.writeAt < 0) st.writeAt += 1;
writeStage(st, P);
writeStage(st, P, adv);
blitTo(st, 0);
}
if (isVideo) lastStamp = vid.currentTime;
@@ -443,7 +468,8 @@ function tick(now) {
const wallSec = (now-wallStart)/1000;
const fps = wallSec>0.5 ? stepCount/wallSec : null;
const last = stages[stages.length-1];
metaEl.textContent =
$('#playbar').hidden = true;
$('#metaidle').textContent =
`scanTime ${scanTime.toFixed(2)}s / ${duration}s · ${stages.length} stage${stages.length===1?'':'s'} · ` +
`final write ${((last?.writeAt||0)*100).toFixed(1)}% · ` +
(last && last.tapes.length>1 ? `window ${last.tapes.length} · ` : '') +
@@ -700,6 +726,9 @@ async function boot() {
$('#showall').onclick = () => { showAll = !showAll; buildControls();
$('#showall').textContent = showAll ? 'show only what matters' : 'show every control'; };
$('#rec').onclick = () => { if (!recording) clearAll(); setRecording(!recording); };
$('#play').onclick = () => { playing = !playing;
$('#play').textContent = playing ? '❚❚ pause' : '▶ play';
$('#play').classList.toggle('on', playing); };
$('#clear').onclick = () => { clearAll(); say('tapes cleared'); };
$('#save').onclick = () => { const last = stages[stages.length-1];
if (!last) return; const a=document.createElement('a');