101 Commits
Author SHA1 Message Date
Mike Schwarz 8edd4b919e live-state: an older refresh never writes over a newer one; one refresh at a time (#5)
## What a user gets

The Studio floor's finished-work list never steps back to an older answer once a newer one has been on screen.

## The race

A live-state refresh is many `rig` calls: since #3, up to ten closing-note reads, each allowed six seconds. The companion started a refresh on a fixed 10-second clock (`setInterval`), so two refreshes of one output could be in flight at once, and `writeLiveState` wrote whichever finished last. An older, slower refresh could therefore overwrite a newer snapshot, putting a closed row back at its earlier `closedAt` and result. The timer and write weakness predates #3; #3's longer read path brought finished-work history into it.

## The fix, in two parts

1. **Newer wins.** `writeLiveState` numbers each refresh when it starts, per output file. A refresh that started before one already written there is dropped: it returns its state and writes nothing.
2. **One refresh at a time.** The new `startLiveState()` schedules the next refresh only after the current one finishes, with no fixed clock. `--once` (interval 0) still runs exactly once, and `stop()` cancels. The CLI entry uses it.

What's unchanged: same-version note binding, unknown and retry behaviour, and cache bounds. `recentDone` and `buildLiveState` aren't touched.

## Tests

Two fail-first tests in `test/live-state.test.mjs`:
- an older refresh that finishes last never writes over a newer one;
- the scheduler runs one refresh at a time, and `interval 0` runs once.

Results:
- **The test file:** 14 of 14 pass with the change; 12 of 12 on main.
- **Against main's source:** the race test fails, with the older `closedAt` 22:00 shown instead of 22:02.
- **The whole suite:** `node --test test/*.test.mjs` passes 316 of 317. The one failure is `observatory-p6.test.mjs` "run from stdin as documented…", which fails the same way on main `ef7dcea` and is unrelated to this change.

## Cost

+77/-12 across 2 files: `live-state.mjs` +32/-11 and the test +45/-1. No new dependencies.

## Credit

Follows #3. The finding, fix and tests are studio-impl@studio-build's, from the race studio-qa@studio-build found while reviewing the port of #3. studio-qa accepted it, and the commit keeps its original author line.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-24 16:33:44 -07:00
Mike Schwarz ef7dceab42 Fleet: show subscription move reports and their evidence (#4)
The Fleet page shows subscription move reports: for each daemon, the managing agent's dated reports from GET /api/provider/moves, with from and to, the reason and policy version, each seat's account evidence and tool turn with their times, what's missing, peers and follow-up links. A daemon without the route reads as unavailable, not as no reports; the recent window and unreadable rows are named. Configured and observed seat accounts stay separate, so a move is never shown as current use. The reader keeps sign-in, last exhaustion and reset expectations. The starter agent's manage-subscriptions practice applies only when enabled and names the exact Codex home and host. Slice 18's Fleet side; not part of the curated default install.
2026-10-23 07:16:56 -07:00
Mike Schwarz fdaefb174e Fleet Studio: fleet know-how packs, its launch rig, and a read-only Fleet page (#2)
Fleet Studio: fleet know-how packs, its launch rig and a read-only Fleet page. The page reads OpenRig daemons' open GET routes for teams, seats, usage across hosts and configured and observed subscription facts, and names any machine, rig or seat it could not read instead of showing it empty. Subscription moves still read "no record" until the move-report route lands. Not part of the curated default install. Slice 19; reviewed in two rounds by two independent pairs; integrated with main after the Observatory merge.
2026-10-23 07:02:32 -07:00
Mike Schwarz 8d39bf9d90 Observatory: a read-only view of your OpenRig fleet, with its launch rig and starter operations agent (#1)
The Observatory: a read-only view of the OpenRig fleet you run, as a studio app with its own launch rig and starter operations agent. It shows machines, seats, queues and health from OpenRig daemons' open GET routes, with messaging, ceremony and subscription readings and a seat drill-down, and names what it could not read rather than showing it empty or healthy. Not part of the curated default install. Slice 17; reviewed in two rounds by two independent pairs; the last change names unsampled and withheld usage honestly.
2026-10-23 06:56:51 -07:00
Mike Schwarz 51281c81bb live-state: finished work stays findable on the floor (#3)
The floor's live-state provider now keeps finished work findable, shows only this rig's open work, and reads a finished item's answer from the same version of the row it listed, so a later close can't pair the wrong answer with an older entry. A malformed or unreadable answer stays unknown instead of reading as none.

Two files (+164/-3): live-state.mjs and its tests. Source-only entry; no tag, pin, release or deploy.
2026-10-23 01:45:51 -07:00
mvschwarzandClaude Opus 5 49076d2aca Canvas board: sized to hold items, not to be an anchor
The board carries no annotate id. Anchoring a mark to it made the mark inherit
the PLANE's geometry rather than its own, because the annotation layer stores a
mark as an offset from its anchor's rect — a scheme built for 'circle this
button', not 'circle a spot on a plane'.

8000x6000 rather than 20000x20000 for the same reason: the ratio between the
plane and the viewport is what distorts the offsets.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-06 04:43:11 -07:00
mvschwarzandClaude Opus 5 a409ecb864 Canvas pans like a canvas, not like a picture
Dragging moved a slab around. .canvas-world was a fixed 2400x1600 board carrying
the grid and a border shadow, sitting on a darker body — so panning slid a
visible rectangle inside a surround, with edges you could reach. That reads as
dragging a background image, which is exactly what it was.

The grid belongs to the VIEWPORT now, offset and scaled by the same view the
items use. No board, no edges, effectively infinite. Moving the items and not the
grid would have been worse than the slab: the ground would stay still while
everything on it slid.

SPACE OR MIDDLE-DRAG PANS FROM ANYWHERE, including over an item. Panning only
from empty space is fine on a sparse board and useless the moment a screenshot
covers the part you happened to grab — which is the state every real board
reaches, and the one it was in when this was reported.

Measured, both outcomes on the same gesture over the same item: plain drag moves
the item and not the world; space-drag moves the world and not the item.

MY FIRST RUN OF THAT CONTROL SAID THE ITEM DRAG WAS BROKEN, and it was the probe.
I dispatched pointerdown on the document, so event.target was the document and
closest('[data-item-id]') found nothing — every drag fell through to pan and both
columns read identical. Dispatching on the element, the way a real click arrives,
separates them. A synthetic event aimed at the wrong node reports the same thing
as a feature that does not work.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-06 04:21:17 -07:00
mvschwarz f3182709ed Lock what the provider actually depends on
The lockfile had one entry — the root — and locked none of the tree its
package.json requires, so npm ci refused it on every fresh box. A lockfile that
exists but resolves nothing is worse than an absent one: absent fails at a
known point, present passes the file check and then dies inside npm.

Regenerated with --package-lock-only, so no install was needed to produce it.
2026-08-05 13:25:00 -07:00
mvschwarzandClaude Opus 5 b855d4820e Apps use the SDK's header and the runtime's focus
THE HEADER. FILES, MEDIA and CANVAS each drew their own title bar, so the studio
showed two headers stacked and switching surfaces flipped between one and two —
it read as a set of pages rather than one application. They declare their half
now (contract/shell-protocol.md: left half is the app's, right half is the
shell's) and draw nothing. Nothing was lost in the move: the path, the save
state, the sync line, the board tabs and PIN -> AGENT are all still there, sent
rather than painted. CANVAS also gets its 62px back — that magic number was a
gap reserved for a bar it no longer draws.

The shell renders at most six actions and drops the rest silently, so CANVAS
decides what goes in those six rather than leaving it to however many boards
exist: fixed controls first, then the ACTIVE board, then the others, with any
remainder COUNTED in the crumb. A truncated row that says it is truncated.

FOCUS, BOTH WRITERS. Reserving /api/focus to the runtime killed this provider's
POST handler but not the second writer: /api/selections wrote focus.json as a
side effect, and being a pin verb rather than a focus verb is exactly why the
reservation could not see it. It wrote the WHOLE record with `view` blanked and
every path basenamed, so pinning something destroyed the view context the focus
reporter had just written. That pair is what contract/focus.md was written
about. Measured after: a selection-only write now leaves view intact.

The four apps stopped DECLARING /api/focus in calls{}, and that was not
cosmetic — three declared it `required: true`, so the moment this provider
stopped claiming the verb the box refused to start over a route the runtime
always serves. The known gap is recorded where it will be read: the resolver
asks "does a PROVIDER declare this?" and never "does the RUNTIME serve it?", so
there is currently no honest way to say "I depend on the runtime for this".

The file was the real work item. Nothing reads focus.json in this repo — SEATS
read it by convention, and a file whose writer is removed does not disappear, it
FREEZES. It is replaced in place, once, with a record naming the verb and
keeping its last value, so a seat that cats it gets an answer instead of a
plausible lie with a timestamp.

One thing got WORSE and is written down rather than glossed: this provider
derived `by` from the cf-access header, a real identity. The runtime derives one
only when configured with --identity-header, because trusting an inbound header
by default is worse than caller-declared. The deployment unit carries the
setting, commented, with the curl that tells you which kind you got.

THE FLOOR IS ONE RIG'S FLOOR. live-state emitted every node on the box — 92
seats across 12 rigs, under LIVE ACTIVITY and a green dot, beside a sidebar
showing 10. Two derivations of one fact disagreeing on one screen. It resolves
its rig the way the SDK's seat roster does, and where it genuinely cannot tell
it says ambiguous-rig rather than presenting a union as this studio's floor.

THE CONTROLS CAME WITH THE CODE. The containment checker and the harness that
proves it can fail were still in the studio repo, guarding a copy of this
backend that no longer exists. A control left behind in the repo the code moved
out of passes forever and guards nothing. Moving them found what that costs:
test-boundary.mjs was already sitting in this directory FAILING and unrun,
because `npm test` here only ran live-state. Its no-dir case still demanded the
retired two-shape filesTree. The BASELINE case is what caught it — without it
the control below would have "fired" in a tree where everything failed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-05 06:37:05 -07:00
mvschwarz f4e8807b8d Refactor Artifacts onto SDK annotations 2026-08-05 06:14:57 -07:00
mvschwarz 1684ba6479 Make artifacts a shared visual workspace 2026-08-05 04:53:49 -07:00
mvschwarz 48b97d0b44 Add the diagram-first software factory 2026-08-05 04:49:49 -07:00
mvschwarz 6fee5e8a2f Three-stage chain, tuned — and the rule that makes a deep chain work
The founder asked whether three stages was still built in. It always was, and it
is pure config: stages[] is an array and source.from names any upstream stage.
What was missing was knowing how to TUNE one, and I got it wrong three times
before the rule fell out.

SIZE EACH advance TO THE TIME THAT STAGE ACTUALLY HAS.

    advance = wraps * 960 / (60 * seconds_available),  ~1.5 wraps per stage

A stage starting at 6s in a 20s run has FOURTEEN seconds, not twenty. Give it the
advance for twenty and it never completes a pass, which is exactly what starved
the third stage every time.

THE THREE WRONG ATTEMPTS, each instructive:
  advance 0 on the downstream stages. It fits the sweep to the WHOLE run, so a
  late start can never finish one. This is why my first chain had a nearly black
  third tape.
  A later startAt as the fix. Made it worse -- less time, same problem.
  A very fast upstream (advance 6). head.position ramps across the full scan, so
  a racing write with a barely-moving head just repeats one strip of the bed. The
  first tape came out as flat bands.

I also had the direction backwards in what I told the founder: I said "fast
upstream, slow downstream" from reading the two shipped 2-stage specs. The real
relation is per-stage available time, and it happens to produce the opposite --
each downstream stage needs a HIGHER advance because it has LESS time.

The diagonal edges are the causal frontier -- a stage cannot record what its
source has not written yet -- and at this depth they read as deliberate structure.
Wants a continuous source; cuts compound through three passes into noise.

Shipped as 11-three-stage-chain and carded on the shelf WITHOUT a preview: the
clip-save path kept timing out this session and a card that names a preview which
is not there would be a lying card. The shelf renders that case honestly.

131/131.
2026-08-05 03:13:53 -07:00
mvschwarz 2ec07e562e Items 4 and 5: slit-scan cinema, and the four branches I had abandoned
SLIT-SCAN CINEMA, the last genuinely unbuilt product of the three named weeks ago
and the only one that could not run in real time. One COMPLETE SWEEP is one
FRAME. That is intrinsic -- a sweep takes seconds, a frame lasts a twelfth of one
-- so it is an offline render: short sweep, keep the finished tape, clear, repeat.
The clip keeps playing throughout, so consecutive frames come from consecutive
stretches of footage and the film MOVES. 24 sweeps at 12fps, and the subject turns
between frames while every frame is a whole scan rather than a moment.

The render loop is a script rather than a surface feature, because it is a batch
job with no live picture to steer -- and putting a ninety-second blocking render
behind a button in an instrument would be a worse product than a file you can run.

THE FOUR ABANDONED BRANCHES, run rather than reasoned about:

  MOSH ON THE BEAT works and is the pick of them -- the melt itself pulses, so
  the picture tears on the hit and settles between.
  MOSH ON A CHAINED STAGE runs clean but barely melts; the upstream tape changes
  too slowly for the block matcher to find much. Worth knowing, not worth shipping.
  PERSISTENCE is subtle even at 0.997, which is 0.4 by the end of a run. Real, and
  much weaker than the number suggests.
  DIAGONAL at 22 degrees WITH mosh goes black.

That last one looked like a regression from the coverage and stack-lag changes
earlier tonight, so I checked instead of assuming: angle 38 without mosh is
identical to how it rendered before those changes, and angle 0 is a clean scan.
NOT A REGRESSION -- a parameter choice. The melt over a steeply angled geometry
drives it to black. I would have filed a bug against my own working code again
otherwise, which this session has already done once.

131/131. All five harvest items closed.
2026-08-05 02:12:52 -07:00
mvschwarz a25f70466a Item 2: one clip dissolves into another, a column at a time
The founder's own idea and the last big unbuilt one. `source.clips` names two and
`source.blend` mixes them.

COMPOSITED BEFORE THE SCAN, NOT IN THE WRITE SHADER. There is one source texture
for the whole surface, so the alternative was a second sampler threaded through
the write pass -- and that would have put "there might be two clips" into every
response mode, every write mode and the mosh pass, five places that could
disagree. Composited first, none of them has to know.

WHAT MAKES IT WORTH HAVING, and it is not the crossfade: the dissolve is written
into the tape A COLUMN AT A TIME while the head travels. The left of the frame is
one clip, the right is the other, and the middle is genuinely both. The tape never
holds either clip whole. That is a thing a crossfade on a finished picture cannot
do.

And it composes for free with tonight's other work: a fast dissolve looks to the
block matcher exactly like a CUT, so the mosh pass blooms across the transition
without being told anything about sources.

blend is read from stage 0 deliberately -- the source texture is shared, so a
per-stage blend is a promise this design cannot keep, and honouring only the first
stage's value silently would be worse than not offering it.

131/131. Items 1, 3 and 2 all landed.
2026-08-05 01:46:13 -07:00
mvschwarz 6f73aeef0f Item 3: scan a scanner output — a second pass makes organic warping rectilinear
The loop had been closed for a day and nobody had walked it. Saved clips land in
the media pool, so they appear in the source picker; a tape of a tape was one
click away and had never been clicked.

THE FINDING, which I did not predict: a second pass turns ORGANIC warping into
RECTILINEAR structure. The source's x-axis is already TIME, and the second pass
samples it as SPACE -- so marbling comes back as hard slabs and blocks, and a
beat-driven tape comes back fractured into columns each holding a different
moment.

PERPENDICULAR IS THE STRANGE ONE. A horizontal head over a marbled tape produces
staircase and ziggurat edges and checkerboard artefacts that resemble neither
input and that nothing else in the tier makes. It is the most novel image of the
session and it came from the cheapest experiment in it.

Shipped as 08-tape-of-a-tape, defaulting to the perpendicular case because that
is the one worth seeing first.
2026-08-05 01:41:32 -07:00
mvschwarz f9d8246158 Derived lanes actually derive: from-audio had no clip, from-video was never wired
Item 1 of the harvest, and the diagnosis was not what I reported. I told the
founder "nothing reads `derived`". Wrong: the COMPILE ROUTE has always resolved
it, correctly, server-side. What it never had was a FILE.

clipOf() reads the clip from the spec -- and the shipped examples deliberately
name none, because a spec that hardcodes a filename only works on the machine
that has it. So the lookup returned "", the derive failed, the lane collapsed to
a flat rate, and 07-time-runs-on-the-beat has been documented as working while
time never accelerated once. TWO CORRECT DECISIONS COMBINING INTO A DEAD FEATURE,
which is why neither side of it ever looked wrong.

The caller's currently-selected source now rides along as __source. Examples stay
clip-agnostic; the lane gets something to listen to. And from-video, which
answered "not wired yet" in as many words, is wired -- cuts, motion and brightness
-- reusing the helpers the standalone route already uses so there is one
definition of what a cut is.

I ALSO BUILT THE WRONG FIX FIRST and deleted it: a client-side resolver in the
surface. It worked, and it would have been a SECOND resolution path for one
property -- the exact class this repo keeps paying for. Resolution stays in one
place.

Proven live by a number that cannot come out equal if it is dead: over a 10s run
the audio-driven clock reaches 13.45s against master's 10.01s.

AND THE LANES IMMEDIATELY FOUND A BUG NOTHING ELSE COULD. A lane on headWidth
produced hard black bars through the tape. Two candidates, and the controls
separated them: the same width held CONSTANT is clean, so it was not the gap
maths; the same lane with frames:1 is clean, so it was the STACK. Tape k holds
its lag as `k * headWidth`, which is exact only while the width is constant --
drive it with a lane and each tape's write position shifts by k*Δwidth every
step, scattering its strips. The lag is now pinned to the width the stack was
built with. The head stays free to change; every tape agrees how far behind it is.

Shipped 06-time-on-the-beat (onsets, not envelope -- envelope wobbles, onsets
JOLT, and the founder picked it out of the folder unprompted) and
07-the-cut-decides (the edit drives the melt).

131/131.
2026-08-05 01:38:05 -07:00
mvschwarz 5d75813a96 Providers: the reset you type is now saved AND read back — two bugs, not one
"It doesn't save the calendar, it keeps it for a second then reverts." Two
independent defects stacked, and the second was invisible behind the first.

ONE: THE WRITE ROUTE WAS NOT ROUTED. The surface calls five fleet verbs; the
provider declared TWO. /api/fleet/account, /assign and /switch resolved to
"no such contract route" and 404'd. So every write this app makes -- set a reset,
set capacity, rename an account -- was failing, and the surface said nothing,
because a failed write looks exactly like a write that has not been polled back
yet.

THE ASYMMETRY THAT HID IT, worth naming: a verb declared REQUIRED but unserved
refuses to boot, loudly and at startup. A verb SERVED but undeclared fails at call
time, silently. The manifest only catches the direction that was never the risk
here. Both manifests now list all five.

TWO: THE VIEW NEVER READ THE OVERRIDE BACK. The merge block resolves label and
capacityLeft from overrides and stops, so a typed reset was written to
overrides.json and then ignored -- the row kept rendering the value derived from
samples. On screen that is indistinguishable from a failed save: it holds until
the next poll, then appears to revert. It never reverted; it was never displayed.

A typed reset now wins outright. Not decided on recency like capacity is: capacity
has two honest sources and a fresh measurement can supersede an old typed figure,
but a reset is typed precisely BECAUSE the derived one is wrong.

AND MY OWN CHECK WAS VACUOUS, which is why the first fix looked complete. I
"verified the Pacific round-trip" by re-saving a picker at its CURRENT value and
observing no change -- and no change is exactly what a 404 produces. A no-op test
cannot distinguish a correct conversion from a save that never happened. Setting a
DIFFERENT value is what exposed both bugs, thirty seconds later.

Verified end to end at the shape the founder reported: set 2026-08-27T18:15 in the
picker, correct ISO in overrides.json, still showing after the 30-second poll that
used to revert it.

131/131.
2026-08-04 19:53:47 -07:00
mvschwarz fd70adcbca files/tree: answer the documented shape in every case, including no arguments
WORKSPACE found /api/files/tree returning two different shapes and I reported it
to sdk-impl as "one verb, two shapes, decided by who serves it". That was
imprecise and the imprecision pointed at the wrong fix -- they were heading for
declaring the shape server-dependent, which would have gutted a stable verb.

Measured properly: the shapes AGREE with a dir. What diverged was the ZERO-ARG
case, and the cause is arity of roots rather than shape of tree. The runtime pins
ONE root, so no-arg unambiguously means "that root's contents". This provider
binds N, so no-arg had no single answer and it invented a different question to
answer instead -- returning { roots: [...] }.

That question already had a verb: /api/files/roots, which was already declared,
already implemented, and already answering it. So one property was computed in
two places and the caller could not tell which shape it was about to get. Same
class this repo keeps paying for, this time in mine.

Zero-arg now returns the FIRST BOUND ROOT's contents in the documented shape,
always. "First" is only acceptable because the answer DISCLOSES ITSELF -- the
response carries `path`, so a caller can see which root it got rather than assume.
An arbitrary choice the caller can inspect is a documented answer; the same choice
made silently is a coin flip. sdk-impl's addition, and it is the right one.

The FILES surface asked the roots question of the tree verb in two places; both
now ask /api/files/roots.

THE RULE THIS SETTLES, which is sdk-impl's and worth stating because they
retracted a bigger fix to get to it: THE VERB IS NOT THE SDK'S TO OWN; THE SHAPE
IS. A provider MAY implement a documented verb -- in a real studio the files ARE
served by something that knows about real roots, so substitution is the point --
but implementing it means answering in the documented shape. No namespace split,
no ownership list: /write, /tags and /goto are NEW names and legitimately ours.

Verified by the app that found it: WORKSPACE's cannot-do list dropped from three
entries to two on its own, because the entry describing the divergence no longer
evaluates true.

131/131.
2026-08-04 19:18:30 -07:00
mvschwarz d3d086b75f Workspace: bind the workspace, derive the media pool — studio-box's canon
dev-driver3 audited this an hour before I asked and pointed at the contract:
studio-box docs/WORKSPACE-CANON.md. Three things came back that changed the
design, and one that corrected my own report of it.

THE LAW: apps never own project data. An app is code and UI; everything it
references lives in the workspace, so uninstalling an app never touches your
files. The layout that follows is a single data root holding SHARED POOLS
alongside per-project folders.

So the bound root is now the WORKSPACE and the media pool is DERIVED from it
(<workspace>/media). Binding the pool directly -- which is what I built first --
makes the root mean "one app's files" and leaves nowhere for projects or a second
app's data. One line to fix, and it leaves the room.

Migrated on disk: 30 files into media/, 19 specs kept as a workspace-level pool,
counted before and after. Sources and cards both still resolve, which is the
check that matters -- the shared pool is what keeps a scanner OUTPUT visible as a
scanner SOURCE, and the canon forbids per-app filing for exactly that reason.

AND A CORRECTION TO WHAT I RELAYED: I was told the canon says "not per-app
subfolders". It does not. It says an app MAY add its own subfolders inside a
project, "like an app creating a folder in a user's home dir" -- what it forbids
is data inside app/, rigs/ or a bundle. The distinction is OWNERSHIP, not
nesting, and the founder's suggestion that apps make their own folders is
compatible with the canon rather than contrary to it. I would have restructured
against a rule that does not exist if I had acted on the summary instead of
reading the source.

Two things studio-box got wrong that we now skip: apps inventing their own data
locations (the canon is the fix), and roots being launch-time env only, with no
runtime rebind and no settings UI. The rebindable, persisted root is the piece
studio-box never had -- confirmed absent there and on two other deployments of it.

131/131.
2026-08-04 18:47:16 -07:00
mvschwarz 3c475ef2af Workspace: rebindable, remembered, and on the Desktop instead of a temp folder
The work lived in a scratchpad temp folder bound at boot, which meant "where my
work lives" could only be changed by editing a config and restarting. That is not
a setting; it is a build-time constant wearing a friendly name.

MEDIA is now a rebindable binding rather than a const, so all 21 read sites pick
up a change without caching anything at startup. /api/workspace/root reads it and
POSTs rebind it, persisted to ~/.config/openrig-studio/workspace.json -- stored
OUTSIDE the workspace on purpose, because a pointer kept inside the folder it
points at cannot survive that folder being moved, which is the exact operation
this exists to support.

BOTH HALVES ON THE PATH, and it matters more here than usual: /media/ serves
BYTES out of this root, so a root of "/" would publish the filesystem to any page
on localhost. Confined to the home folder and checked AFTER realpath so a symlink
cannot walk out. Verified in both directions rather than asserted: /etc is
refused as outside the home folder, a non-existent path is refused by name, and
the real folder binds and is remembered.

The default is now cross-platform and follows the founder's reasoning: the only
certainty is that ~/.openrig exists, but a creative workspace hidden in a
dotfolder is one nobody opens in a file manager -- so a visible folder in the
home directory wins when there is one, and the dotfolder is the fallback. HOME or
USERPROFILE, so macOS, Linux and Windows are one expression rather than a switch.

Everything moved to ~/Desktop/studio-workspace: copied, verified by count AND by
checksum on a sample, and only then was the source released. A count matches even
when a file copies empty.

SAVE TO SHELF in SCANNER, which closes the loop. One press writes the clip, the
spec, and the _card that ties them together -- title, the footage it was made
from, the file that came out. The four-step version is the one nobody does.

The workspace control is in SCANNER as well as SHELF, because a workspace is a
studio-level idea rather than a shelf feature. Changing it there rebuilds the
source picker too; leaving it stale would offer footage from the old root.

Round-tripped end to end: a run saved as "Shelf Roundtrip Test" produced the
clip, the spec and a card naming both, and the shelf read them back.

131/131.
2026-08-04 18:07:07 -07:00
mvschwarz 7cf653fb13 SHELF: browse everything you have made, and hand any of it back to the scanner
The founder's read of the milestone: the effects work is fruitful enough that the
next constraint is organisation, not capability. We are going to make hundreds of
these, and a folder of scan-1785888411794.mp4 is not a creative workspace.

THE ONE DESIGN DECISION, and everything follows from it: THE SPEC IS ITS OWN
RECORD. A spec carries a `_card` block naming its title, tags, the footage that
made it and the file that came out. There is no sidecar metadata and there should
never be one -- a sidecar drifts from the spec the first time either is renamed,
and then the shelf is confidently wrong about what an effect does. One file, one
truth. compileSpec ignores `_` and `_card`, so this costs the grammar nothing.

Outputs stay in the media root rather than a tidy subfolder, deliberately:
anything in the media root appears in the source picker, so what the scanner made
can immediately be scanned again. Filing them away would break tape-of-a-tape.

SHELF reads that folder and renders one card per spec: the preview looping, what
it was made from, the spec itself, and two buttons -- run it on its own footage,
or run it on somebody else's, which is what turns an effect into a reusable
treatment rather than a one-off. It sends THE SPEC, not a filename, so a card
survives being renamed and carries everything needed to reproduce it.

No new provider. A workspace is a folder of specs the effects provider already
owns, so this is a second READING of it rather than a second copy.

Twelve experiments from tonight are in the workspace with full provenance,
including the ones that failed: `d-trail` is kept because it is what proved
persistence's range was unusable, and `c-sliceloss` is kept because it is the
accident the whole marbling direction came out of. The founder asked not to have
those discarded and was right to.

Two things the build caught:

The studio REFUSED TO START because gallery declared a required verb no provider
served. That is the manifest contract doing exactly its job, and it cost one
minute instead of a 404 at runtime.

And the first render was wrong in a way only looking would find: sorted by name,
the seven specs with no preview led the grid, so a shelf browsed by MOTION opened
on a wall of black rectangles. Previews sort first now.

Verified end to end rather than by inspection: gallery.run("Marbled tears")
reached SCANNER, loaded plain-with-beats.mp4, and the effective parameters came
back sliceLoss 0.08 / bloom 1 / refresh 0.96 -- the spec's own values.

131/131.
2026-08-04 17:51:44 -07:00
mvschwarz 94f60680a3 Scanner: marbled tears, and the coordinate error that stopped the melt working
The founder ran the first mosh build and got the same hard vertical seams. They
were right, and the reason was a coordinate-system error on my part.

I estimated motion on the SOURCE VIDEO and dragged the tape with it. That cannot
dissolve a seam: a source motion field has no spatial correspondence with a
time-warped recording, and on the nearly-still footage this work wants, frame-to-
frame source motion is about zero -- so it advected the tape by nothing.

ON THE TAPE THE SWEEP AXIS IS TIME. Column k is one instant, column k+1 the next.
So the recording's own previous frame is ITSELF, shifted one head-width along the
sweep. Block-matching those two gives the recording's motion field: small where
the footage was continuous, garbage exactly at a cut. That one change is the
difference between nothing happening and total melt.

Then it overshot -- the first result was unrecognisable mush, because the drag
compounds over 200-plus steps. Two additions fixed the shape rather than just
turning it down:

uCutBloom gates the melt on CONFIDENCE. At 0 the whole picture is dragged
uniformly, which is what a struggling decoder does. At 1 the drag is withheld
where the prediction held and the fresh picture is restored there, so continuous
footage passes through clean and only the seams bloom. Default 0, and the effects
surface never sets it, so the mosh EFFECT family is untouched.

Slice loss, which was already in that shader and which I was passing zeros to.
This is what actually kills the banded look: a lost slice gets no correction and
keeps sliding on old vectors, so torn HORIZONTAL bands cut across the vertical
grain of the sweep. The picture stops being a row of columns and becomes marbled.

Twelve experiments, all saved into the media folder as exp-*.mp4 rather than
discarded, because the founder asked to see the accidents too -- and the one I
would have thrown away, slice loss at a level I thought was far too high, is what
made the whole direction work.

Shipped 04-marbled-tears (footage WITH cuts) and 05-liquid-face (one unbroken
shot, subject barely moving -- no seams to bloom at, so the subject's own small
movement drives it and the picture ripples instead of tearing).

ALSO: persistence's range was unusable and now I know why nobody noticed -- it
had never run. It applies every step and compounds, so 0.955, comfortably inside
the old 0.9 minimum, is black by the end of a run. Useful values live in the last
percent; minimum is now 0.99 and the text says it compounds.

131/131.
2026-08-04 17:26:11 -07:00
mvschwarz f4a279957a Scanner: the tape becomes an accumulator, so cuts bloom instead of banding
The founder's model, which is the right one: the scanner is a flexible temporal
SAMPLER, and the painting side had never been given the same flexibility. Six
ways to read, five ways to paint, and all five paint modes stateless -- each
strip laid down knowing only itself.

That is precisely why a cut lands as a hard band. The write pass may not read the
tape, so the strip is painted with no knowledge of what it replaces; headSoftness
can feather a seam but can never blend across one.

So the tape becomes an accumulator in a SEPARATE PASS with its own target, which
is what the invariant permits. Order carries the idea: mosh first, then write.
The tape is dragged by the motion in the footage, then the fresh strip lands on
top at full strength -- the column under the head stays sharp and everything
behind it melts. The write is the I-frame, the pass is the P-frames.

AT A CUT, BLOCK MATCHING FAILS EVERYWHERE. Confidence collapses across the whole
field, the residual term stops correcting, and the old scene is dragged into the
new one instead of abutting it. The cut becomes the effect rather than the defect.
That is not a workaround; it is what a real decoder does when it loses its
reference, and it is why datamoshing looks the way it does.

No new motion code. The scanner now fetches the SAME block-matching and
accumulate shaders the mosh effect family uses, so there is one definition of
block matching rather than two that drift.

moshRefresh 1 returns before the pass runs, so the control is structural rather
than measured: at rest this is byte-for-byte the old scanner. Swept 1.00 / 0.85 /
0.70 / 0.50 on footage with nine cuts -- 1.00 is hard bands, 0.85 shows the
macroblock structure and edges beginning to drag, 0.50 has no boundaries left.

ALSO: persistence is finally connected. fadeProg was compiled at boot and never
once used, so it was a fifth published-and-dead knob -- and the skill cites that
very pass as the example of how a second pass should be done. It needed the same
alternate target this work introduced, which is likely why it was left unfinished.

AND A CORRECTION I OWE: I claimed the reachability invariant "would have caught
headSoftness". IT WOULD NOT. Reachable-from-a-spec and read-at-render are
different failures, and headSoftness and persistence were both the second kind.
There is now a second test for that half, and it is honest about being a grep:
it proves a parameter appears in the renderer, not that it changes the picture.
Only looking proves that.

131/131.
2026-08-04 17:03:38 -07:00
mvschwarz 802be48990 Scanner: water across the whole scene, because the slices ARE the ripple
The founder isolated the ask precisely: the man's face rippling is the effect;
the vertical slices parading across the screen are not. They wanted the whole
scene to carry the ripple.

They are the same mechanism. With advance 0 the sweep is fitted to the run, so
the total source-time laid across the tape is fixed by the duration; headWidth
only decides how coarsely that time is CHOPPED. A wide head gives a few wide
bands, each a visibly different moment -- a parade. A narrow head spreads the
same total warp across every column -- one picture undulating. headWidth is a
QUANTISATION knob and I had been reading it as a strength knob, which is why
raising it to 24 made the banding worse rather than the water stronger.

Swept 4/8/16/32 on one clip with everything else held: 4 and 8 are whole-scene
water, 16 shows a seam, 32 is bands. That sweep is the answer and it took one
variable, which is what I should have done three sessions ago instead of shipping
one variant at a time.

Two other things the picture required, neither of them a parameter:

A CONTINUOUS SHOT. The hard vertical seams in the founder's saved scan were CUTS
in plain-with-beats.mp4 -- nine of them -- not banding. No head width fixes a cut.
Cut a continuous window (24.20-28.40, the man's face) and they are gone.

A NEARLY-STILL SUBJECT, which is the founder's own observation from the first
time they saw this and the thing I lost. Camera motion becomes smear.

softness 0.5 dissolves the residual striation, which it could not have done before
the feather was moved into the overlap earlier tonight.

130/130. Founder to judge.
2026-08-04 16:17:24 -07:00
mvschwarz 6f3f1b80e8 Scanner: recover the water recipe from the transcript instead of the formula
The founder said we were moving away from the tool being useful and asked what
happened. This is what happened.

There was never a reference. The water effect was seen once, described in prose
in a handoff, and never saved as anything. Every session since re-derived it from
a formula, starting from the previous session's DESCRIPTION rather than from the
thing — so the drift compounded. Prose cannot reproduce a picture.

The transcript could. First mention of "water" is 2026-08-04T19:22:06Z, sixteen
minutes after the drive that produced it, at 2933efc:

  specFile 09-every-column-a-window.json, source plain-with-beats.mp4,
  headWidth 24, frames 24, fps 12, advance 0, sourceRate 1 (dead at the time)

Every knob I have turned since went the wrong way. headWidth 24 -> 8. frames
24 -> 1 -> 16. advance 0 -> 4. Source: a nearly-still person -> action clips full
of camera motion. The governing quantity went from 0.500 source-seconds between
adjacent columns to 0.067 — a factor of 7.5 — and I spent two sessions trying to
close that gap with source.rate, which was disconnected, so nothing moved and I
concluded I should push it further in the same direction.

The mechanism of the loss is worth naming because it was not carelessness: I cut
the run 20s -> 10s to stop wasting the founder's time, then raised advance so the
tape would still fill. Both are reasonable. Both are terms in the shear formula.
A UX fix destroyed the effect and the disconnected knob hid it.

And the founder's own sentence, lost entirely until I read it back: "probably
because the source image is just a person kind of like standing there not moving
very much". The subject must be nearly still. That is not in any formula.

The note on the spec now says DO NOT TUNE THIS FROM THE FORMULA, names the three
things that matter, and cites the timestamp — so the next reader recovers it from
evidence rather than from my description of it.

130/130. Not re-judged by the founder yet.
2026-08-04 16:05:17 -07:00
mvschwarz f4d2444172 Scanner: record the stack, not the sweep that laid it down
The founder watched a ten second export and said it was a literal recording of
the stage-two panel. It was: startRecorder() ran captureStream on that canvas for
the duration of the run, so the file was the WIPE -- the process of writing the
tape -- rather than the thing the tape holds.

What it displaced: with frames > 1 every column holds N instants and playback
advances all of them at once, so the picture moves with the head standing still.
That is the artifact. It could be watched on the surface and never exported,
because the recorder only ever ran during the sweep.

Now the run produces and the recorder captures the RESULT: when a run reaches its
duration and a stack exists, the transport switches to playback from frame 1 and
records exactly one cycle. One cycle rather than several, because the result is a
loop and a whole number of cycles is what makes it loop without a seam. With
frames: 1 there is no stack and nothing to play, so the sweep is still the only
video available and that path is unchanged.

01-water.json goes to frames 16 at 12fps. It was frames 1, which is one tape --
so the water had no way to be a video at all, and the export could only ever have
been the wipe. This is the second time I have written frames: 1 into a spec whose
whole point was motion; 10-temporal-shear was a still for the same reason and the
founder caught that one too.

Verified by the number that can only come out one way: the clip is 1.32s, which
is 16 frames at 12fps, not the 10s run. Stack frames 3 and 8 differ by a mean of
16.7/255 over a fully-written tape, and two grabs in the SAME tick differ by
exactly 0 -- so the difference is the stack advancing, not the instrument.

130/130.
2026-08-04 15:39:14 -07:00
mvschwarz fe093a4401 Scanner: the water spec, with the clip requirement stated
source.rate 2, not 6. 6 was the number I wrote when the parameter was dead, so it
had never actually been applied to anything; once it reached the video it spent
24 seconds of footage per wrap and crossed every cut in the clip, which is a
collage of different shots rather than one picture undulating.

At rate 2 a wrap spends 8 seconds and adjacent columns are 0.067 source-seconds
apart -- about one and a half frames. A recognisable interior stays recognisable
and its hard edges ripple.

The note now says WANTS A CONTINUOUS SHOT, because that is the failure mode a
person will actually hit and no parameter can fix it.

Seen at 1180x1500 under a software rasteriser at ~10 steps/s, where each step
writes about 25px rather than 4px, so it is blockier there than it will be on a
real GPU. Not yet judged by a human eye.
2026-08-04 14:25:56 -07:00
mvschwarz eb6cc8aa63 Scanner: three declared parameters that did nothing, and a feather that laid seams
sourceRate is published by the scanner and READ by the surface, but compileStage
never produced it from source.rate. So every spec that set source.rate silently
ran the footage at 1x, and temporal shear -- the effect the parameter exists for
-- was unreachable from a spec. Nothing errored; the picture was just clean.

bedRate and headRate are the same shape one step worse: published for an agent to
find, read by nothing at all. They predate named clocks, which do the same job
with the drift visible in the spec instead of implied by a number. Removed rather
than wired, because wiring them back would reintroduce the implicit clock that
declaring every clock exists to eliminate.

The test is the general invariant, not three patches: every published parameter
must be reachable from a spec. It fails on all three before the fix. This defect
has now landed three times -- headSoftness was the first -- and each time it
presented as "the effect just isn't happening", which is the most expensive
symptom available.

And the feather was fixed on the wrong axis. Making it one-sided stopped the
LEADING edge fading toward black, but the ramp still started at the strip's own
first pixel -- new territory, so the crossfade partner was black again and every
strip left a thin dark seam. At softness 0.25 on an 8px strip that is a dark line
every 8 pixels across the whole tape. Confirmed by the one measurement that can
only come out one way: the same run at softness 0 has no striations at all. The
ramp now lives entirely in the overlap the scissor already widened into.

130/130.
2026-08-04 14:25:44 -07:00
mvschwarz 91dde58ca4 Scanner: the water candidate, with the number that governs it
Shear is source-seconds between adjacent columns:

    headWidth * source.rate / (advance * 60)

The ten second test runs advance 12, which puts that at 0.008 source-seconds --
a quarter of one frame, so there is nothing to shear. I chose a fast sweep so the
tape would wrap inside ten seconds and called it a video, and in doing so
optimised away the effect we were trying to see.

The tension between 'shear needs a slow sweep' and 'a slow sweep never fills the
tape' is false: the numerator has two terms. 01-water raises source.rate to 6
instead of dropping advance, giving 0.2 source-seconds between columns while the
tape still fills in four.

Untested against a human eye. It is a candidate, not a result.
2026-08-04 13:43:33 -07:00
mvschwarz c18911d8c0 Scanner: count the frames in the file, not the steps in the loop
The caption said 386 frames at 38.3fps and the saved clip had 157 and ran 4.1
seconds. captureStream SAMPLES AT ITS OWN RATE AND DROPS FRAMES UNDER LOAD, so
the render-step count the surface was keeping is not the frame count, and the
rate derived from it was more than twice too high.

Only the recording knows what it contains. The surface now sends the WALL TIME
it recorded for, the server probes the actual frame count with the ffprobe the
repo already ships, and the capture rate is frames divided by wall time -- so
the clip plays back in real time whatever the machine managed.

This is the third time on this feature that a count I was keeping disagreed with
the artifact, and the third time the artifact was right.
2026-08-04 13:36:28 -07:00
mvschwarz aeccaf5bba Scanner: assemble a clip at the rate it was actually captured
A ten second run came out 5.23 seconds long and played at double speed. The
renderer managed 15.7 steps a second and the transcode assembled at a hardcoded
30, so the clip was fast by exactly that ratio.

The surface now counts the frames it fed the recorder and how long it really
took, and sends that measured rate with the upload. The only rate that plays
frames back in real time is the rate they were produced at, and a software
rasteriser produces them slowly -- so the number has to be measured rather than
assumed. The caption reports frames, rate and expected length before you save.

Also removes the stray headerless webm left over from before saving deleted it.
2026-08-04 13:33:33 -07:00
mvschwarz 325c40199d Scanner: ten second runs, and assemble clips at a real frame rate
NINETY SECONDS WAS AN ARBITRARY NUMBER I PICKED and it made every single test
cost a minute and a half of somebody else's time. All fourteen specs are now
10s, plus a purpose-built 00-ten-second-test whose only job is to answer 'does
this work'. A long run should be a deliberate choice, never the default you sit
through.

AND THE RECORDED CLIP DID NOT PLAY, for a reason ffprobe made plain: MediaRecorder
writes no frame rate and no duration -- avg_frame_rate 0/0 -- so a player has no
idea what rate to show 2,699 perfectly good frames at. Not corruption; simply no
timing.

My first transcode made it worse by letting ffmpeg guess: it assumed 60fps and
DUPLICATED frames to fill the wall-clock gaps, turning 2,699 real frames into
34,380 and a 90 second run into a 9.5 minute video of mostly repeats.

Declaring the input rate instead assembles the real frames at 30fps and nothing
else: measured on the same file, 2,699 frames in, 2,699 out, duration 89.967s
against a 90s run, 20MB down to 2.6MB. The clip's length now reflects THE SCAN
rather than how slowly the machine painted it.
2026-08-04 13:29:24 -07:00
mvschwarz d76f8fde7c Scanner: a saved clip becomes a real file, not a headerless blob
MediaRecorder writes WebM with NO DURATION in the header. ffprobe on the clip
this app had already saved for somebody reports duration N/A -- so
video.duration is Infinity, seeking does not work, and my caption would have
printed a number that is not one. It is also a poor SOURCE for a second scan,
which is the entire reason for saving it into the media root.

Saving now remuxes to mp4 with ffmpeg, which the repo already ships. Declared as
a dependency of this provider because that is where the need is, with a sibling
fallback so it works before an install has run -- and if no binary is found at
all the clip is kept as webm and the surface SAYS which happened rather than
reporting success either way.

The caption also stops printing Infinity as a duration.
2026-08-04 13:12:51 -07:00
mvschwarz 7ef7ae4382 Scanner: print where the files actually are, and give the clip controls
A PATH THE APP NEVER PRINTS IS A PATH THE PERSON HUNTS FOR. The media root is a
deep generated directory nobody could guess, every file this app reads or writes
lives under it, and it appeared in the UI nowhere at all. Somebody spent half an
hour looking for a clip the app had told them it saved.

This is the asymmetry worth designing around: an agent finds a file in one
command, a person greps and clicks. Printing the path costs nothing and is the
whole difference between 'saved' and 'saved WHERE'.

The surface now shows the media root, the specs directory and where clips land --
always visible, selectable, wrapping, not buried in a status line that scrolls
away. Saving a clip prints its FULL path rather than its basename.

The produced clip also gets native controls, a caption carrying its duration and
size, and its own labelled panel like every other stage. 'Did this work' is now
answerable by looking, instead of by trusting that a save button appeared -- and
autoplay being blocked now says so rather than leaving a silent black rectangle.
2026-08-04 13:10:14 -07:00
mvschwarz 4d86da4849 Scanner: run PRODUCES a clip, and play plays it
The mental model was right and the surface was wrong.  should produce an
artifact and  should play it; instead run painted a live canvas and play
only cycled a frame stack, so a single-tape spec left play with nothing at all --
which reads as broken rather than as empty.

captureStream on the canvas that is already animating, into MediaRecorder. No
headless browser and no second copy of any shader, and it records EXACTLY what
is on screen, so the artifact and the preview cannot disagree. The clip appears
under the chain when the run ends, loops, and  writes it into
the media root -- where it shows up in the source picker, which is the loop
closed: what the scanner made becomes something the scanner can read.

Also fixes the feather, which was making things worse. Strips are laid down in
increasing column order, so a strip's trailing edge abuts material already on
the tape while its leading edge abuts nothing. Feathering BOTH fades the leading
edge toward black, and every strip left a dark seam that accumulated into
corduroy over repeated wraps. The feather is now one-sided.
2026-08-04 12:51:34 -07:00
mvschwarz a21fdcd143 Scanner: a dead control, a gap bug, and water as video
TWO REAL DEFECTS, both found while trying to isolate an effect rather than by
any check.

headSoftness WAS DECLARED, SET AND NEVER READ. It appeared exactly once in the
shader -- the uniform declaration -- so a control that looked available did
nothing at all. Now it feathers the strip edges with alpha, and strips are drawn
with blending on and widened by the feather amount, so neighbouring slices
CROSSFADE instead of butting up hard. Without it every column boundary is a cut
and a recording made of slices screams that it is made of slices.

A HIGH ADVANCE LEFT UNWRITTEN GAPS. The write position moves further in one step
than a strip is wide -- at advance 6 it jumps ~30 columns while a 12-column
strip is drawn -- so most of the interval was never written. Black gaps reading
as hard banding, and it made a fast sweep structurally broken rather than merely
coarse. The write now covers the whole jump.

Also:  isolated the shear by setting frames:1, which is a
single tape, which is by definition a still. It removed the parade by removing
the motion. Two specs replace it --  keeps one tape but repaints
it continuously with a high advance, so the warp never settles;  has bands of moving video with the seams crossfaded.
2026-08-04 12:35:27 -07:00
mvschwarz e1527b6be2 Scanner: name temporal shear, and demonstrate it apart from the window
TEMPORAL SHEAR is a second, independent warp driver and it did not have a name.
In a classic scanner the warp comes from the BED moving between column samples.
Here the bed is still and the warp comes from the VIDEO ADVANCING between column
samples -- adjacent columns are a frame apart in the source, so each lands
slightly differently and the discontinuity accumulates. The footage's own frame
rate is the interpolation source. It is what makes rooftops look like water.

It was invisible because the window playback was running at the same time and
the two read as one confusing thing. Three specs separate them:

  10-temporal-shear        frames:1, no stack, no parade. Bed never moves.
                           Every bit of warp is the source advancing.
  11-temporal-shear-heavy  source rate 3, so far more source time passes between
                           adjacent columns and the shear turns violent.
  12-bands-in-motion       advance 6 sweeps six times faster, so adjacent
                           columns are nearly simultaneous and the shear nearly
                           vanishes -- leaving the window as the only motion.

The knob that trades between them is the time between column arrivals: slow
sweep or fast source gives shear, fast sweep gives none.
2026-08-04 12:23:34 -07:00
mvschwarz 5dd2b06fd1 Scanner: every column becomes a window, and the stack plays
In a still scan a column is ONE INSTANT -- which is precisely why the result is
a still. `frames` makes a column a WINDOW of instants instead, and the stack
plays back with every column advancing through its own window at once.

THE IMPLEMENTATION IS THE IDEA. Tape k is the same scan with every column
sampled k steps later, so each step writes k strips: tape 0 at the write
position, tape 1 one column behind, tape k at k behind. Tape k therefore holds,
for every column, the frame from k steps after the head arrived there. Play the
stack in order and the windows advance together.

That keeps the cost proportional to STRIPS rather than frames -- a 20-instant
window measured 13.9 steps/s against a single tape's ~12 on the same software
rasteriser, which is what makes a window affordable at all.

WHAT IT LOOKS LIKE, and it is the reason to build it: one continuous movement in
the source becomes a ROW OF BANDS, each animating a different fragment of that
movement, all alive simultaneously. A person walking across the scene appears in
the band where they were when the head passed, and moves only within it. A pan
becomes a pan spread across space -- the left edge showing where the camera was,
the right where it ended, everything moving together. The same scene at twenty
different times, side by side, all in motion.

frames:1 allocates exactly one tape and behaves identically to a still, so the
feature costs nothing when it is not asked for.

Verified with the pair that matters rather than either alone: 15 distinct frames
across a playback cycle AND 84.9% of the picture non-black. Distinct-only cannot
detect an empty stack, because different almost-black frames still hash
differently.
2026-08-04 12:01:38 -07:00
mvschwarz 8220471595 Scanner: ship eight examples so nobody opens on a blank canvas
Point the source picker at your own footage, pick an example, press run.

NONE OF THEM NAMES A CLIP, and that is the point: a spec that hardcodes a
filename only works on the machine that has it. Omitting `source.clip` means the
spec runs against whatever source is selected, so an example is a technique
rather than a recording of one person's media.

The eight cover the range the instrument actually has: flatbed warp, slit-scan,
rotating bed (the signature artifact -- turning during a sweep makes straight
edges curve), motion driving displacement, both-axes-are-time, ember contours,
a clock whose rate comes from the clip's envelope, and dubbing.

They ship inside the provider rather than the app, because the capability
belongs to the package that implements it -- the same reason the app is a client
of it rather than an owner.

Examples are read-only and listed separately from user specs; a saved spec of the
same name WINS, so an example can be adapted and kept without renaming it. Each
carries a one-line note the surface shows on load, so the picker explains itself.

One resolver for both roots rather than two independent containment checks --
two places computing one property is the divergence this repo has paid for
before. A traversal name still returns 404.
2026-08-04 11:26:51 -07:00
mvschwarz 8e4f532ea8 Scanner: say which way the head travels, and document the tool as a skill
AXIS NAMED BOTH WAYS. `axis` describes how the head LIES, which is perpendicular
to how it TRAVELS -- a horizontal head lies flat and sweeps upward. Watching a
run you see the travel, so the name reads as the opposite of what you observe,
and somebody hit exactly that. The schema text and the panel caption now state
both: "head upright, travels ->" or "head flat, travels ^". The values are
unchanged, because saved specs use them.

DOCUMENTATION AS A SKILL, under docs/skills/scanner. The SKILL.md is a router:
enough of the terrain that a reader knows what exists and what would surprise
them, with the detail behind five files rather than in it. Progressive
disclosure, and readable by a person as well.

  using-it.md        the transport, the spec picker, the shipped specs, the five
                     known techniques and the settings that produce them
  spec-reference.md  every field, every parameter, the complete lane grammar
  architecture.md    the passes, the stage graph, clocks, the compiler, and an
                     honest performance section including what is NOT known
  extending-it.md    recipes for a read, a write mode, a lane form, a clock kind
                     and a stage feature, with the ordering contracts that bite
  collaborating.md   how to work this with a person watching

The last one is the part that does not survive in code: hand over a spec file
rather than a screenshot, narrate through the surface, drive THEIR page and not
your own, and the specific ways a healthy-looking handle lies about a black
screen. It also records the corrections that produced the best decisions here --
human motion over smooth curves, drift as a feature rather than a hazard, pick-
and-play over pushed settings -- because none of those came from a measurement.

Swept for internal vocabulary before committing: no seat names, no coordination
ids, no host names, no local paths.
2026-08-04 11:18:24 -07:00
mvschwarz 34a80c5a75 Scanner: startAt, so a chained stage can stop outrunning its source
The diagonal edge in a two-stage final was not an artifact and not a setting --
it was CAUSALITY. Stage b writes rows as time advances while stage a fills
columns at the same time, so the row b writes at t can only contain the fraction
of a that existed at t. Stack those rows and the boundary is exactly y = x. The
black triangle was not missing data; it was data that did not exist yet.

The remedy is to let the upstream get ahead, so a stage now declares startAt --
a time, or a fraction of the run, before which it writes nothing and does not
advance its own transport. It is a property of the SPEC rather than a knob,
because it is about the shape of the run and not about the look.

Lanes on a delayed stage measure from ITS start rather than the master's zero,
so a stage that waits still gets its whole ramp instead of arriving mid-sweep.

Two specs ship using it: an upstream with advance 3 fills its tape in a third of
the run and then WRAPS -- always full, always refreshing -- and the downstream
waits for that before sweeping. No frontier, and the upstream keeps feeding new
material rather than freezing once it is full.
2026-08-04 10:52:46 -07:00
mvschwarz fa0b16d02f Scanner: hand-shaped motion, and specs you can pick and run
THE HAND. A ramp is machine motion -- constant velocity, no correction, no
hesitation -- and the original scanner's whole character came from somebody
DRAGGING a photo on the glass. So rather than imitate the look of that,
reproduce the mechanism, which is well characterised: reaching movements follow
MINIMUM JERK (10u^3-15u^4+6u^5, not a line and not a smoothstep), fast reaches
OVERSHOOT and correct, hands HESITATE at irregular via-points, and
physiological TREMOR sits around 8-12Hz and never switches off.

`hand` is a lane form alongside ramp / keyframes / pulse / from-audio, so ANY
parameter can be moved by a hand rather than a motor. Sampled densely to
keyframes like every other generator, so there is still one evaluation path and
the result stays inspectable, editable and seeded-reproducible.

Measured against a ramp: 601 keyframes, velocity swinging 10x from mean to peak
and touching exactly zero at the pauses. A ramp is two keyframes at constant
velocity by construction.

The asymmetry is the instrument: the BED gets hands, the HEAD stays a motor.
That is what a flatbed IS -- a mechanical head and a person who is the only
nuance in the system. Driving both smoothly, as the previous spec did, is why it
read as machine-made.

SPECS ARE FILES NOW. A spec is this tool's artifact, so it lives in the media
root beside the footage instead of in a text box somebody has to keep. Pick one,
press run, get the same thing back every time. Five ship as a starting library.
The drive channel gained `specFile`, so "play this file" is one instruction.

Path handling does BOTH halves, per the rule this repo has already paid for: the
name is sanitised to a single segment AND the resolved path is validated against
the realpath'd root. A traversal name returns 404 rather than a file.

TRANSPORT MOVED TO THE TOP and made sticky. At the bottom it fell off a normal
screen, which made the one control that starts everything the hardest thing to
find. `record` is now `run`, and it clears the tapes first so a run always
starts from the beginning.
2026-08-04 10:47:03 -07:00
mvschwarz 5267be1533 Scanner: a chain of stages, and clocks you can name
A stage's source may be an EARLIER STAGE'S TAPE instead of a clip, and that one
line is the whole chaining mechanism.

WHY IT IS NOT JUST "TWO EFFECTS": a scanner turns one SPATIAL axis into TIME.
Stage one with a vertical head makes its output's x-axis time; stage two reading
that with a HORIZONTAL head makes y time as well, so the final picture has no
spatial axis left -- both are time, at different orders. Chain them on the SAME
axis instead and each pass compounds the last one's warp, which is dubbing:
generation loss, tape to tape.

It also retires a limit recorded in the original research. That document says
true slit-scan "needs each row from a different input frame, i.e. a ring buffer
of 1080 frames -- not viable". A chained stage gets that buffer for free,
because the upstream tape IS the materialised time history.

CLOCKS ARE NAMED AND REFERENCED, not a bare rate per transport. Two stages have
six transports, and six independent rate numbers is noise while three named
clocks that six transports reference is a structure you can reason about. A
clock's rate may itself be a lane, which means TIME ACCELERATES rather than
merely running fast -- the "time runs on the beat" preset resolves to 899
keyframes off the clip's own envelope, and the bed does not move to the music,
its clock does.

The compiler refuses forward and self references by name, so a bad graph is a
message rather than a mystery. Feedback is therefore not expressible yet, which
is deliberate: it deserves its own mechanism rather than arriving as an ordering
accident.

THE STEERING PANEL IS DERIVED, NOT HAND-LISTED. It shows what this chain is
actually using -- anything a lane drives, anything moved off its default -- and
a lane-driven parameter shows "lane" instead of a slider, because a slider there
would be a control that silently loses every frame. One toggle exposes all 25.
The spec stays the complete surface; the panel is a steering surface. This
generalises the highlight idea from "which sliders matter for this preset" to
"which matter for this chain".

Presets are chain specs now rather than flat parameter sets, because the thing
worth naming is a whole signal path.

Panels stack vertically at one internal resolution, so a tall screen gets full
width beds rather than thumbnails.

Measured: two stages run 12.9 steps/s against one stage's ~12 on the same
software rasteriser -- the write is scissored to a strip, so a stage costs a
strip and not a frame.
2026-08-04 10:25:04 -07:00
mvschwarz 8ea85d8bf0 Scanner: the rest of the design -- all six reads, all five writes, the fade
READS. chroma is nearness to a target colour, inverted so the response is large
where the thing IS, which lets the head scan FOR something. edge is gradient
magnitude: structure kept, tone discarded. difference is deliberately distinct
from motion rather than a second name for it -- motion reports how much a point
changed with the sign discarded, difference reports which WAY it changed,
centred on a half, and on real footage the two look nothing alike.

WRITES. palette maps the response through one of four ramps, generated rather
than tabulated so there is nothing to keep in step. matte writes the source only
where the response clears the threshold. displace is the one that changes WHERE
we sampled rather than how it is tinted -- the response pushes the read position
along the head, so a bright or fast region physically drags the recording out of
shape.

THE FADE is wired. It is the only pass that reads the recording, so it gets its
own scratch target rather than doubling the buffer for every other pass: the
amount is applied on the first hop and the second is a plain copy back, so it is
not applied twice. It still runs only below 1, because a tape that retains is
the common case and should cost nothing.

Verified by rendering each mode and hashing the tape rather than trusting the
enum: four new modes, four distinct signatures, no errors.
2026-08-04 02:32:56 -07:00
mvschwarz f8d0ab3d0e Scanner: make it scan by default, and stop the load hanging forever
Three defects, all of which made a working renderer look broken.

A SCANNER THAT DOES NOT SCAN. headPosition defaults to 0 with no lane driving
it, so the head parked on the far-left edge and the first recording anyone made
was eight seconds of the leftmost column. The default is now a sweep, because
that is what the word means; holding it still is the deliberate act that gives
slit-scan.

AN AWAIT THAT COULD NEVER RESOLVE. useSource waited on loadeddata, but assigning
the same src twice does not re-fire it -- so selecting a clip a second time hung
forever, srcEl was never set, and both panels stayed black. A hung load is
indistinguishable from a broken renderer. Now resolves on readiness OR the event
OR a deadline, and a frozen source (sourceRate 0) is given one decoded frame
rather than being left with nothing to upload.

A TAPE THAT NEVER FILLED. advance is columns per step, so a 12s sweep at 60fps
laid 720 columns into a 1280-wide tape and stopped at 56%. advance 0 now FITS
the recording to the scan duration and is the default; every other value is a
deliberate disagreement with the sweep.

Also adds the reverse channel, sharing the effects drive endpoint and acting
only on ops carrying a scanner key, so an op aimed at one surface is inert on
the other rather than half-applying. And the bed's drawImage no longer swallows
its error -- "the source is black" and "the source never arrived" are different
problems and only one is worth debugging.

Measured after the fix: head 0.835 -> 0.899 -> 0.96 -> 1.0 across a sweep, bed
61.5% non-black, tape filled and wrapped at 0.1%.
2026-08-04 02:22:30 -07:00
mvschwarz da24ea004f Scanner: a tape head for images and video
The tool this comes from has no image processing in it -- five drawImage calls,
zero getImageData, source rect equal to destination rect. The distortion is
TIME: a head advances across a canvas somebody is dragging, and each output
column is the picture at a different instant under a different transform,
accumulated into a buffer that is never cleared.

So this is not a filter with a time parameter. It is a recorder, and its output
is state rather than a function of its inputs -- which is why it is a separate
app rather than another effects family. Same provider, same schema contract,
same curve engine.

THREE TRANSPORTS, THREE DECLARED RATES. Bed, head and source each advance at
their own rate against one master clock. All rates at 1.0 is the locked case
where the picture comes back; every interesting result is a disagreement, and
bed and head at slightly different rates beat against each other into a warp
with a period neither clock has. Declaring every clock is also what removes the
real hazard: with no implicit clock left there is nothing to read by accident.
The render loop's rate never enters any calculation.

A SPEC COMPILES TO KEYFRAMES. It does not get its own evaluator -- generators
already emit keyframes, so there is exactly one evaluation path, and a second
evaluator that only the spec understood would be the shader-duplication defect
in new clothes. Compiling also makes a spec inspectable before it is run: the
route reports which lanes it produced and what it could not compile, rather than
throwing on the first bad field.

The write pass is scissored to the strip, so cost is O(strip) not O(frame),
which is what makes an accumulation buffer affordable. No ping-pong: the write
never reads the recording.

v1 reads passthrough / luma / motion and writes direct / intensity. The
remaining response and write modes are deliberately absent rather than stubbed.
2026-08-04 02:06:28 -07:00
mvschwarz a9bdd2800f Mosh: lose whole slices, the way a broken stream does
A picture is transmitted as slices -- horizontal runs of macroblocks that decode
independently, precisely so losing one does not destroy the frame. When a slice
does not arrive the decoder has nothing to draw with and conceals the gap by
holding what that region held before. The result is a band that freezes while
everything around it keeps moving, and because slices are horizontal the damage
is horizontal too.

This composes with the accumulator rather than adding machinery: a lost slice
simply receives NO correction -- not the flat refresh, not the residual -- so it
keeps sliding along old vectors while the rest of the picture is corrected back
toward the live frame. sliceShift adds the second failure, a slice that arrives
desynchronised and is decoded at the wrong horizontal offset.

Held for several frames by default, because a loss that re-rolls every frame
reads as noise rather than as a stream in trouble.

Measured that the damage is genuinely banded rather than scattered: against a
clean render, per-row difference has mean 3.51 and stdev 3.67. Stdev exceeding
the mean means the damage is concentrated in some rows and absent from others,
which is the slice signature; evenly scattered damage would give a much smaller
stdev.
2026-08-04 00:58:34 -07:00
mvschwarz 99512891c3 Mosh: measure the motion field ourselves, then abuse it like a codec does
Datamosh is motion compensation running against the wrong reference. A shader
cannot read the encoder's vectors, so this measures its own by block matching --
the same operation an encoder performs to make them -- and the artifact then
comes from the same mechanism rather than from a filter imitating it.

The field is computed AT BLOCK RESOLUTION and never interpolated. That is not an
optimisation: one vector per macroblock is the representation, and the coarseness
is what makes the result read as compression instead of as melting.

An accumulator does the rest, displaced by the field and never refreshing its
reference. Two knobs decide everything: refresh lets a flat share of the live
picture back in, and residual lets it back in ONLY where the estimator's own
confidence says the prediction failed -- which is what a real codec spends bits
on, and what keeps a mosh reading as a picture rather than a smear.

Also adds dcDrift to the codec family. DC coefficients are differentially coded,
so a corrupted delta is inherited by every later block until a restart marker --
that propagation is the horizontal colour streak of a damaged bitstream, and it
is a different failure in kind from the existing per-block dcBias. Measured:
drift scores 1.32 on a vertical-over-horizontal difference ratio, bias 0.89,
clean 0.63.

One defect found and fixed while building: the render loop runs on rAF at ~60Hz
while video decodes at 24-30, so consecutive uploads carried the same decoded
frame. "Previous" was not previous, and block matching two identical pictures
returns a perfect zero for every block -- an empty field that is nonetheless the
correct answer to the question asked. Measured two grabs one rAF apart at 0 and
two 120ms apart at 4.27. Previous now rolls only when the video clock advances.

The sampler-unit test pinned the exact declaration line while its own comment
claimed it pinned names "so adding a fourth unit does not fail". Adding one
failed it immediately, so it now asserts what it says: units are named, declared
once, and no two share a value. Negative-controlled by planting a collision.
2026-08-04 00:43:17 -07:00
mvschwarz 981c4a22a2 Stop a routed asset walking out of the project
CONFIRMED LIVE WRITE ESCAPE, and it is on public main. /api/library/route
validated its SOURCE properly — realpath, then checked against the configured
media roots, 403 for anything outside — and then built its DESTINATION by
interpolating a caller-supplied `slot` straight into a path. path.join collapses
`../`, mkdirSync recursive created the directory, copyFileSync wrote there.

THE TELL IS ONE LINE APART: baseName IS sanitised on the line above. Whoever
wrote this was thinking about the file name and never asked where the directory
came from — and every review since examined the source check, found it careful,
and stopped. A validated source never implies a validated destination.

Fixed in one place with one definition, because computing the destination inline
is how it went unwatched. Both halves: the slot is sanitised to a single safe
segment AND the resolved destination is checked against the slice root.
Sanitising alone is one careless concatenation from the same bug; the boundary
check alone accepts junk names that happen to land inside.

A slot that CHANGES under sanitising is refused rather than quietly accepted in
cleaned form. Silently routing "../../evil" to "-evil" does something the caller
did not ask for.

THE CONTROL WAS WRITTEN FIRST AND SHOWN TO FAIL FOR THE RIGHT REASON. Against the
current logic, slot "../../../tmp/evil" produced a destination in /var/folders/…
outside the root — measured, not described. It passes now. A positive control
asserts an ordinary slot still routes, because a boundary that refuses everything
is indistinguishable from a correct one under an attack-only test, and this
repository has shipped exactly that mistake before.

SIBLING SWEEP, which orch-lead asked for: five other write sites take caller
input. The three in export-server are safe, but for a thinner reason than they
should be — each sanitiser happens to strip separators, so traversal fails as a
side effect rather than by a boundary check.

AND TWO OF THE FIVE WERE MINE, WRITTEN AN HOUR AGO. The saved-looks routes had
the same shape: a sanitiser doing the work alone. Traversal already failed there,
but that is the argument the broken route could also have made. Boundary checks
added, because writing the essay about both halves and then not applying it to my
own new code would be the more embarrassing defect.

125/125.
2026-08-03 23:21:06 -07:00