`docs/lane-handoff.md`, after `timing-handoff.md`'s shape, because the next
thread starts cold and the expensive part of that is not the code — it is the
decisions that were argued out and would otherwise be argued again.
So the section that matters most is the one listing what NOT to re-litigate:
the shot length is authored, placing ripples and overwrite is blank-then-place,
a position inside a cel refuses and names split, a correction has no time space
of its own, a layer's values are a channel, a conflict is not a problem, and a
command refuses rather than guesses. Each of those is a paragraph here and a
commit message in full.
Then the vocabulary, since it was settled one commit ago and the old words are
still in this repository's history: instance, cel, lane, drawing, and placement
for where a node sits only. With the warning that `exposure` still means the
`:expose` grid and always did.
Then the mechanisms that keep paying out — `:span` in the node's own frames
above all, which is why split, trim and blank cost almost nothing — the known
gaps, and how to run the suites, including that a release build clobbers the dev
bundle the browser tests need.
Recommended next piece is the cel sheet: it needs no new model, and it is the
first real evidence the document is not shaped by the timeline that grew up
with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four words had accumulated for a node that puts a symbol inside another symbol.
`instance` was the document's, from the model. `placement` was the stage and
export work's. `occurrence` came in with the lane model. `exposure` came in with
me, because it is what an animator would say. Three bodies of work each brought
a word and none of them retired anybody else's, which is how you get a codebase
that reads like three people describing the same object over each other.
It is a CEL. One drawing, held for some duration. `cel` was already the view's
word — `.tl-cel`, the cel strip — so choosing it was also the smallest change,
and the app already says "drawing" for the content, which is what frees the word
up: historically a cel IS the celluloid with the drawing on it, and that sense
has somewhere else to live here.
instance the `:kind`. The general thing, anywhere in a document.
cel an instance in a lane. UI labels, command names, prose.
lane the group with `:layout :sequence`.
drawing the content a cel names.
placement kept ONLY for where a node sits — `nest/placement` and the
transform that puts a face on the stage. Retired as a noun for the
node itself.
occurrence gone.
AND IT SETTLES A COLLISION I SHOULD HAVE SEEN EARLIER. `:time :expose` already
existed and means something else entirely: how many frames each step of a
subtree lasts, which is what shooting on twos is. Had the block been called an
exposure too, `node/expose`, `clock/exposed-frame` and `subs/render ::exposure`
would have been permanently confusable with it. Choosing `cel` lets the word
`exposure` keep the thing it actually names, and every remaining use of it in
`src` is now that one.
`:layout :sequence` stays as the field, and it is the one place two words are
kept deliberately: the layout names the RULE — children follow one another and
may not overlap — and a group carrying it is called a lane. `node/lane?` says so
where the two meet.
The second view is traditionally the exposure sheet. It will be the CEL SHEET,
for one vocabulary.
Renamed with a script and then read, because a blind pass does real damage: it
produced "an cel" thirty times, renamed the `::exposure` sub that is about the
`:expose` grid, and turned an "exposure grid" into a "cel grid" in two
docstrings. All three classes are fixed. `arthur.domain.sequence` is now
`arthur.domain.lane`, which is what its test file was already called.
424 tests, 5,749 assertions, and both browser flows — `test/browser/lane.mjs`,
renamed too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Trim, move and blank, and the decision they all three walked into: is the shot's
length authored, or derived from what is in it?
AUTHORED. `:frames` is the symbol's window — how long the shot IS — and the
occupied extent of its lanes is a different fact, read off the occurrences. A
command grows the window when the caller says `:grow-symbol` and NEVER shrinks
it, so blanking the end of a shot leaves a shot with empty frames at the end.
That is a true statement about what somebody authored, and the alternative is
deleting the last drawing and quietly shortening the film. `finish` had the
right behaviour by accident — `(apply max (:frames sym) ...)` — and now says
which number is which: `needed` is where the occurrences reach, `:frames` is
what was authored, and the only thing that makes the second follow the first is
a caller asking.
The three commands turned out to be one piece of geometry, which is `split`'s.
A `:span` is in the occurrence's OWN frames and `:time` says where those land in
the lane, so moving an edge of an exposure is ONE WRITE to `:span` and `:time`
and `:playback` are never touched. `local` and `edged` are the whole of it, and
split now goes through them too.
trim narrows one edge and moves nothing else. Lengthening is `extend-hold`,
which carries a ripple policy and a shot-length policy because it needs
them; letting trim grow as well would give one gesture two sets of
rules and a way to overlap its neighbour.
move one write to `:time :at`, and a destination that would overlap is
REFUSED rather than rippled. Moving a drawing and re-timing the ones
around it are different intentions, and a move that pushed the rest
would be the second wearing the first one's name. Clear the room first.
blank leaves a gap and does not close it. Wholly inside the range goes,
overlapping an end is trimmed to it, spanning the range is split — the
one case that needs an ID, and it asks for one instead of inventing it.
Because the source clock is untouched, trimming the front of a playing insert
starts it LATER INTO its animation rather than restarting it, which is the
difference between trimming and slipping and the reason they stay two commands.
The test samples the frames it kept and asserts they show what they showed.
Blanking leaves the drawings in the library. A lane does not own its content,
and a drawing whose last exposure is gone is still a drawing somebody made.
Overwrite is now `blank` then `place` and needs no policy argument of its own,
which is why it still is not one.
424 tests, 5,749 assertions. The browser flow trims an exposure at the playhead,
moves it into the gap that made, blanks it, and checks the shot is still as long
as it was authored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The loop the layer design exists for, tested for the first time: correct a
generated channel by hand, turn the generator's knob, and get the new base with
the correction still on it. `replace-feature` already carried `:over` across —
somebody anticipated this — so the feature path needed a test and not a fix.
The head path needed a fix, and there was a second fault of my own making.
`regenerate-head` leaves the head's authored channels alone once somebody has
placed it by hand, and decided that by `(= (:channels old) (:measured old))`.
Sound, until a correction exists: an `:over` layer makes those unequal, so the
FIRST correction anyone made would have stopped the head following
re-measurement for good — the exact opposite of what a layer is for. It compares
the channels without their layers now. The test fails against the old guard,
which is how I know the bug was real and not a story about one.
The other fault was mine, from the commit before this one. An `:offset` whose
shape does not match its base threw, which is right for authored data — the
validator catches it — but WRONG for the case the model actually names: turn the
mouth's `:verts` knob and the re-freeze gives it a different number of points,
so a correction that was correct when it was made stops fitting through nobody's
error, and a throw in the read path takes the stage down.
So a base that has outgrown a correction is a CONFLICT, and a conflict is the
third thing beside applied and discarded. The regeneration records `:conflict`
on the layer; the layer stays exactly where it is; `over-at` skips it, so the
picture is the base meanwhile; and `clip/conflicts` lists them for a view to
offer. A later regeneration that restores the shape clears the mark, so
resolving one can be as simple as putting the knob back.
Deliberately NOT `problems`. A document with a conflict loads, evaluates and
saves — it contains a decision nobody has made yet, and refusing to open it
would be the persistence layer taking a side in an editing question. The
distinction in the validator is one line: a shape mismatch nobody has recorded
is an authoring bug, and one a regeneration recorded is a conflict.
`channel/conflict-with` is the single rule for "can this layer apply to this
base", used by the validator, by `conflicts`, and by the regeneration that marks
them. Only `:offset` can conflict, since `:replace` states a whole value and has
nothing to agree with; a shape that cannot be read yet — an empty key map — is
not a disagreement. `value-shape` answers it without sampling anything.
414 tests, 5,696 assertions, and `:verts` in the test is a real topology change
rather than a synthetic one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`:over` was specified in animation-model.md, refused in two places, and
produced by nothing: `check-unimplemented!` threw on read and `channel/problems`
reported it. It reads now. This is the part of the model the rotoscoping half
depends on — generate motion, correct it by hand, turn the knob, keep the
correction — and it was the last thing in the design that had never been tried.
The shape that made it small: A LAYER'S VALUES ARE A CHANNEL.
{:id :nudge :support [88 98] :op :offset
:values {:animated? true :interp :linear :keys {88 [2 0], 96 [0 0]}}}
So the three commands the lane model asks for over a selected range — a
constant adjustment, a ramp, a return motion — are one mechanism and not three:
framed values say the same thing on every frame they cover, keyed values move,
and neither needs a new way to say what a value is over time. A layer reads
through `value-at` and `cursor` like any channel, which is also what stopped
blending from becoming two implementations: `over-at` is shared, and the
specification and the playback path differ only in how they READ a layer —
recursively through `value-at`, or through a reading head of its own. One level
deep; a layer's values may not carry layers, which the stack already orders.
That was the risk worth spiking for. A cursor that drifts produces the wrong
pose rather than an error, and a stack means several reading heads per channel
where there was one. The agreement test that holds the cursor to the
specification in forward, backward and random frame order now covers stacked
channels too — including a layer whose head is asked for nothing across the long
stretches outside its support and then asked again, which is where drift would
hide.
`:support` is half-open and explicit. Outside it the base evaluates exactly as
it did before, which is the whole difference between a bounded correction and
inserting boundary keys: the latter alters the neighbouring segments, and the
lane model says so.
A LAYER HAS NO TIME SPACE OF ITS OWN, and this is the design question the doc
left open. Its support and its values' keys are in the frames the base channel's
keys are in — the node's. A correction on a lane is therefore in lane frames and
reaches across the drawings exposed beneath it; one on a single occurrence is in
that occurrence's frames and travels with it when the exposure moves. Ownership
had already answered it, so there is no field to disagree with, and both halves
are under test at lane level.
Two things cost nothing, which is worth recording. A channel is ONE LEAF, so a
correction persists inside it with no codec change at all. And `node/problems`
already reports every channel's problems, so a malformed layer surfaces at the
document level and in the sequence commands' post-check without plumbing.
What is still missing is a command that MAKES one, and with it the question of
how a view offers a constant, a ramp and a return over a selected range. The
evaluator no longer has an opinion about that, which was the point.
`offset` adds component-wise and never writes into a dense value, which is a
view onto the block itself; a shape mismatch throws rather than being dropped,
since a correction that silently does not take is the failure this design exists
to prevent. `replace` can supply a value over an absent base and `offset`
cannot, as animation-model.md required.
408 tests, 5,655 assertions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Everything could only be added to the end, because `append` computed its own
position — the max end of the lane — and so had no opinion to state. Insert is
not a new command; it is the argument that function was missing. `:at` takes a
lane frame or `:end`, `:end` is the position where nothing has to move, and
appending stops being a separate operation from inserting. New, reused and
duplicated drawings all take it, because there was only ever one placement rule.
Placing ripples: occurrences at or after the position move later by the new
exposure's duration, and `:keep` against `:grow-symbol` still decides what
happens at the shot's end. OVERWRITE is deliberately not a policy argument yet.
Taking frames away from the occurrence already there is TRIMMING, and an
argument whose second value is unimplemented is worse than an argument that is
not there. A position strictly inside an existing exposure refuses and names
`split`, rather than splitting on the quiet: one command performing two is how
a command stops being predictable.
Then split, which turned out to cost almost nothing, and that is the
interesting part. The two pieces keep ONE `:time` and differ only in `:span`.
The right piece's own frames therefore carry on exactly where the left's
stopped, so its source clock, its keys and its corrections go on meaning what
they meant: a held drawing holds the same frame either side of the cut, and a
playing insert plays through it without a seam. There is no arithmetic on
in-points to get wrong, and no shot-length question, since the pieces occupy
the frames the one exposure occupied. The test samples every frame before and
after and asserts the picture is identical — for a hold, for an exposure with a
correction of its own, and for a playing insert.
That is not a clever split. It is `:span` being in the node's OWN coordinates,
which was decided long before there were lanes, paying for something it was not
designed for. The same property is why extending a hold leaves lane keys alone.
Both new commands act at the playhead, which needed `lane-frame` — the symbol's
frame as a frame of the lane's own time, nil through a stepped or looping lane
where one is not the other. Nil refuses; it does not snap to a nearby frame.
Two smaller things found while doing it. `placeable` promised "a whole lane
frame" in its refusal and then accepted 2.5, so both it and `split` now require
an integer, as `extend-hold` already did for its delta. And `lane-end` is
private: `:end` is the only way to ask for it.
401 tests, 5,612 assertions. The browser flow now splits an exposure at the
playhead and puts a drawing in the gap, and checks that six exposures are still
one row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The model's whole claim is that content and its occurrences are different
things, and until now nothing in the editor could tell them apart: you could
make a drawing and time it, but not expose one drawing twice, and so never find
out whether an edit arrives in two places. That is the first proof obligation in
the lane model and it was the one the commands could not reach.
Three commands, and the distinctions between them are the point:
reuse another occurrence of the same drawing. A decision to share,
made on purpose, because sharing discovered later — when an edit
turns up somewhere you did not expect — is the bad version.
duplicate a copy of the drawing, appended, for when what is on screen is
the starting point for the next one.
make unique this occurrence gets a private copy; the others keep sharing.
The undo of reuse, and refused when nothing else uses the
drawing: a copy nobody asked for is a second identical symbol in
the library for no reason a person could see.
Duplicate copies the CONTENT and not the exposure. Its new occurrence is a plain
one-frame hold, not a copy of the source occurrence's transform or corrections,
because those belong to that use of the drawing — carrying them over would make
duplicating a drawing quietly duplicate the treatment of one exposure of it.
A copy is SHALLOW by default and keeps its references to other symbols, so a
head built out of reusable eyes still uses those eyes. `:deep? true` copies
everything it places with new ids throughout. The lane model asks for both and
says why: never promise decoupling while leaving the edited object shared, and
only the deep copy can keep that promise. `bring/symbols` already did the
reachability walk and the id remapping, so the deep copy is that function
pointed at its own clip.
`node/sources` was still being read as a SET at five call sites, each with a
comment about a lane that cuts between several drawings — the keyed source that
no longer exists. An occurrence names one symbol, so they now ask `node/source`,
and `placed-frame` answers with `:symbol` rather than `:of`, which was the last
echo of the retired field name.
To let the commands use `clip/free-id` and the copy machinery, the lane's own
validation moved from `domain/sequence` to `domain/symbol`, which is where it
belonged anyway: a sequence is the one composition rule a node map carries, and
it now sits beside the parent and stencil checks rather than in the namespace
that happens to build lanes. That also breaks the cycle — sequence can require
clip and bring, and nothing below it requires sequence. Preconditions still
check only the LANE's shape: refusing an exposure edit over an unrelated defect
elsewhere in the symbol would be this command answering for a part of the
document it never touches.
The cel strip gains reuse, duplicate and make unique, the last shown only where
the selected exposure actually shares its drawing. Drawing on twos is also now
under test: exposure length is the cadence, the lane's transform has its own
clock, and it still moves on every frame — stepping it would be the cel cadence
leaking into continuous motion.
397 tests, 5,561 assertions. `test/browser/sequence.mjs` drives the three new
commands through the real editor and checks that three exposures are still one
row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A lane's drawings were going to be one instance whose source was a KEYED
channel: frame 0 says `:drawing-a`, frame 4 says `:drawing-b`, and the cels of
a row are that channel's keys. Two things followed from it, and both were
wrong.
The first is that playback meant whichever shape the channel happened to have.
A framed source played its symbol; a keyed source froze the selected frame.
So `node/placed-at` read animation out of storage, and adding an ordinary key
to a still turned it into an animation — the last-key bug, which was not a bug
in the code so much as the rule working as written. But WHICH drawing is used
and HOW time runs inside it are independent questions, and all four combinations
are ordinary: hold one drawing, play one animation, cut between held drawings,
cut between playing ones.
So an occurrence names one symbol in `:source {:symbol ...}` and says how its
source time advances in `:playback {:in :speed :end}` — `source = in + speed *
f`, a hold being speed 0, with `:stop`, `:hold` or `:loop` at the end named
rather than guessed. `node/placed-frame` samples it forwards, which works for
holds too, and `node/source-time` is the separate, invertible edit map, nil
where inversion is meaningless. The two were one function before, and a hold
had to lie about one of them.
The second is that a keyed source only looked necessary because an occurrence
was assumed to need a ROW. It does not. A lane is a group with `:layout
:sequence`, its occurrences are ordinary instances in the same flat node map,
and `timeline/rows` draws them as cel blocks on the lane's own row: twelve
exposures, one row, each cel still separately selectable and addressable. The
vertical growth that justified the keyed source is a presentation question, and
it is answered in the view.
`arthur.domain.sequence` holds the first commands over that shape — add lane,
append drawing, extend hold — each one history step, each refusing rather than
half-applying. Extending a hold leaves the lane's keys at their authored times,
because you are adjusting drawings underneath timed motion; a correction owned
by an occurrence travels with it. Ownership does that work, so no key needs a
flag saying what it follows. Ripple past the symbol's end is refused with the
frame count it would need, and `:extent :grow-symbol` is the caller saying yes.
`clip/blank` no longer carries `:subjects {} :features {} :groups {}`. Empty
maps write no leaf, so a blank document could not survive its own round trip —
`leaf/leaves` promises exactness and was the only honest side of that.
Documents are schema 3. A version 2 document is not read; nothing here converts
one. `docs/lane-model.md` is the design, and says which of its parts are built.
392 tests, 5,525 assertions, and `test/browser/sequence.mjs` drives the editor
through create, hold, explicit overflow and undo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A face's :head carries :trace {:frames :origin}: the frames its photo holds
on, and whether the head reads every frame, jumps to the trace frames, or
holds frame 0. It replaces :anchors, so which measured frame a head reads is
one stored fact. An instance's :underlay shows the tracing stills over every
face at or below it, registered through each face's own head, at an opacity,
unkeyed. The clip resolver answers where a row path went on its last frame,
so the paint loop reads the photo's matrix instead of resolving again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A project is only ever at /p/<id>/<slug>; / is the index of the projects
you own or edit. Every project has an owner, who can name editors;
anyone with the link can view. Every edit saves itself, one request in
flight at a time, as a patch of the leaves that changed, and a websocket
(channels + daphne) carries presence and each committed write to
everyone else in the project. The first write to a leaf wins, and the
loser is told.
Undo is per person: a step undoes only if the leaves it touched still
hold what it left, so it never takes a collaborator's work with it.
Named snapshots replace saving, and restore as an ordinary write.
An empty symbol now survives the leaf round trip with `:nodes {}`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every node now has the same time map into its parent, local = rate·(parent
− at), with its span and keys in its own frames: what instances had, made the
rule. A shape without a time map reads as it always did, so no data changes.
The per-kind branches, the span-start term and the rate refusal are gone;
node/time-of, then-time and invert-time compose it like the matrix.
clip/move-node puts a node into another symbol without changing the picture
or the timing — its matrix becomes a :pinv, its time a new :at and :rate, and
its channels, keys and span are untouched — and clip/group makes a new symbol
around side-by-side nodes. Generated parts, split stencils, cycles and
looping instances are refused with the reason. Nested sounds use the same
map.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A shape's :span stays in its parent's frames, but an instance's or a sound's
is now in its own: dropping a symbol at frame 97 gives it span 0 … length and
:at 97, so moving it along its parent is one write to :at. :time :in is gone
(it was the span's start written twice); node/placed-span maps an own-time
span out to the parent for playback, the mixer and the timeline rows, and
node/problems reports a stale :in.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Everything that holds nodes is a symbol (domain/timeline -> domain/symbol,
:timelines -> :symbols) and a node that places one is :kind :instance. The
reserved :main root is gone: which symbol is on screen is editor state
([:ui :open]), every domain function that needs a symbol is told which, and
a document opens on the longest symbol nothing else places.
Saved projects move to schema 2 through migration 0007, which rewrites leaf
paths, instance kinds and the feature :symbol key; the client refuses a
schema it does not read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Detection now walks a browser-seekable H.264 proxy in MediaPipe's VIDEO
running mode. The PNG sequence it replaces was 112MB for 7.6 seconds at
1440x1920 and 1.1GB at the 900-frame limit; the proxy is 6MB, and landmarks
detected off decoded H.264 rather than off the PNGs moved at most 0.0033 of
frame width.
Three things had to be true for video mode to work, and each was measured
against the same footage decoded to PNGs:
/blob/<digest> answers byte ranges. Django's FileResponse does no Range
handling, and a media element handed 200 with no Accept-Ranges reports an
empty `seekable`, no-ops every currentTime write, and detects frame one
ninety times without raising.
A seek aims at the MIDDLE of its frame. Aiming at i/fps sits on a frame
boundary and landed one frame early 31 times in 91; (i + 0.5)/fps was exact
on all 91.
Timestamps are strictly increasing footage milliseconds. Video mode is a
tracker: a repeat leaves the graph in an error state every later call
re-throws, so the landmarker is discarded on failure, and passing the frame
index instead of i*1000/fps moved landmarks six times further from the
per-frame answer.
Frames are verified rather than trusted. requestVideoFrameCallback states
which frame it handed over, the walker discards any other and fails loudly
if the one it asked for never arrives — a stale presentation from the tail
of a previous seek is what produced "asked for frame 1 and it presented
frame 2" on a video whose seeks were in fact exact.
The proxy is re-encoded even when the upload is already H.264: HEVC is not
decodable everywhere, and footage identity is the proxy's digest. The JPEG
stills beside it are tracing references, outside the footage digest because
re-rendering them at another size is not different footage.
Verified end to end in a real browser against real footage: 228/228 frames
detected, a drawn roto face, 37 backend and 234 frontend tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There were two answers in the tree to "which stored bytes stop being valid when
this knob moves", and only one of them was checked.
`flow/address/block-knobs` is per block and asserted by biconditional —
`address-test` re-freezes the take once per knob and requires that the bytes
changed if and only if the key did. `domain/params`'s `:affects` was per area,
had no caller but a test asserting it returned what it was written as, and was
already wrong in both directions on the one entry where the two granularities
disagree: `:aperture-cut` claimed `#{:mouth}`, where it reaches no block, and
omitted the teeth, whose contour bytes it genuinely moves by gating
`condition/interior`'s smoothing. `:blink-cut` claimed `#{:eye}` and reaches no
block either, because a blink is `[:vis]` keys in tier 1.
So `:affects` and `affected-areas` are gone, and `address/knob-roles` is the
derived inverse of the table that is asserted — which is what a parameter panel
actually wants to ask. A knob absent from it invalidates no block, and that is
an answer rather than a gap.
Two new assertions keep the derivation from rotting at either edge: every role
in the table is reachable from some knob, and every knob a block declares is one
the registry defines. The second closes a real hole — `block-descriptor` checks
only that a knob was PASSED, and the freeze's `merge take/knobs` makes that true
of anything spelled like a keyword, so a typo in `block-knobs` would have named a
setting no slider can move.
228 CLJS tests, green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 9. The tier split was the work; Django was the easy half.
Tier 1 — the authored scene — is the document, and it is addressed as
independently versioned leaves rather than saved whole, so one vertex drag
cannot clobber a collaborator's keying. `domain/leaf` is the document as
path -> value; `domain/wire` puts it on the wire as transit, because JSON
has neither integer map keys nor keywords and a save would quietly turn
`{0 v}` into `{"0" v}`.
Tier 2 — the dense channel blocks — is content-addressed by a hash over
every input, with the detector version inside every key through the
analysis the block descriptor names. `flow/address`'s `block-knobs` is the
invalidation table, and `address-test` does not trust it: it re-freezes the
take once per knob and asserts the biconditional, that a block's bytes
changed if and only if its key changed. That found `brow-pos` not depending
on `contour-avg` — the brow ring is smoothed, the raise is not.
Tier 3 — frames and audio — is served by the hash of its bytes out of the
same store. A manifest now names frames and carries a URL for each, so the
frame layout stopped being a shared secret between a shell script and a
ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's
name is the hash of its contents, so a stale copy is not a thing that can
happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an
asset the project owns, not an extraction that churns.
The server verifies rather than trusting a name it was handed: it
recomputes every key from the descriptor stored beside it, refuses an
analysis that declares no detector version, and refuses a document naming
blocks it does not hold. It hashes the descriptor TEXT, because JS prints
an integral double as `1` and Python as `1.0`, and a scheme where both ends
re-render the numbers disagrees on the first parameter that happens to be
whole.
Two loose ends from step 8 closed on the way. `pack` no longer takes a
`(track, frame)` predicate whose call sites each re-derived a feature from
an index — every track names the feature it follows, which deleted five
hand-maintained mappings. And `:dev-http` is gone: Django serves the page,
shadow-cljs only builds into the staticfiles tree.
227 CLJS tests, 31 Django tests, green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 8's data model, ahead of its controls. Nothing here is a UI.
domain/params holds every knob's definition once — default, applicable area,
value constraints and the areas a change would force to regenerate. flow/take's
literal knob map becomes a view of it, so the take's defaults and the future
parameter panel cannot drift apart.
domain/feature adds subjects, features and groups as document data the renderer
never reads. A feature ID is stable for the whole clip, across occlusion: a run
of visible frames is not a new identity. An eye pair is an explicit group of one
or two eyes of the same subject, so a profile view with one identified eye needs
no invented partner. Settings resolve area -> subject -> group -> feature, and
dropping an eye from a pair materialises its effective values first so playback
does not jump. scene/problems now validates all of it.
Presence becomes per-feature rather than per-subject. freeze's :absent predicate
takes a track as well as a frame, so one occluded eye can be absent while its
partner still has a value; a full-face miss still marks everything absent. A
manifest may annotate known gaps as one-based inclusive intervals, which ingest
expands into observation tracks before measurement. An unobserved eye then gets
no vote in the iris pairing and cannot steer the shared gaze — gaze falls back to
whichever eye is visible. Temporal filters still see a sample on every frame,
held from the last observed one, because the numbers are a rectangular buffer;
the state mask, not the buffer, is what says the frame has no value.
js/app.js gets the same occlusion lesson: leading nulls from a face that starts
occluded used to throw away the whole take, and the neutral frame could be chosen
from a held duplicate pose.
Parameter editing, scoped regeneration and a feature-level detector remain. Until
one exists, footage without annotations falls back to the full-face mask rather
than claiming occlusions it cannot see.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B87NVmiU36qQmN9gmFYnJ9
`stabilize` is three things wearing one name, and it is now three functions in two
stages: `flow/measure/anchor` fits the rigid transform, `flow/condition` smooths
its parameters, `flow/measure/mouth` measures the lip rings through the result.
Parity is on the COMPOSITION and not on the pieces -- a split that agreed
function by function and not end to end would be a split rather than a port.
The oracle now drives `stabilize` at three configurations and the port agrees to
1e-9 on ref, rigid, transforms, outer, inner and aperture, plus `smoothContours`
at three radii. Two of the three configurations are at aspect 0.5625, a 1080x1920
phone clip, because at aspect 1 `pick` is the identity: a port that dropped the
anisotropy correction outright would pass every other assertion in the suite.
148 tests, up from 134.
Three decisions worth the reading time.
`makeXform` is not ported, and its absence takes the face oval with it. It
centres on the oval's bounding box and zooms until the face is 80% of the raster
height, so every vertex it touched carried a cropping decision made once, at
analysis time, from one frame's landmarks. Geometry belongs in the node's own
local space with the framing as a transform on a node, so this is a deletion. The
oval's only other consumer was the placeholder plate outline, which is painting.
The residual is taken against the RAW fit, and the prototype took it against the
smoothed one. That is the only deliberate numeric divergence here, and parity is
kept by asserting `anchor/residuals` on exactly what the prototype handed it. The
number's job is to say whether a section is stabilisable at all; folding the
smoothing error into it makes a slider look like a property of the footage, and
docs/architecture.md lists the residual under stage 3, which requires it to be
knob-free. `condition/anchor` therefore replaces `:transforms` and leaves
`:residual` alone.
The stage order is not the strict chain the table in docs/architecture.md looks
like, and that document now says so. The fit is knob-free, conditioning smooths
it, and the rings are measured *through* the conditioned transform -- so
`anchor avg` does re-run the ring mapping, which is a few hundred frames of twenty
points. The guarantee was only ever about the part that reads a source pixel, and
that part never sees a transform.
Two things fall out and are asserted rather than assumed. Smoothing and
subsampling commute, because both are per-slot, which is what lets `vertices`
stay a stage-5 knob downstream of a stage-4 one -- and it is also why the port can
smooth the full twenty slots where the prototype smooths eight and still match.
And `condition/contours` is `geom/moving-average` per vertex per axis rather than
its own clamped window, so "radius 2" cannot come to mean two different things at
the two knobs.
One dead end recorded so nobody walks it twice: the synth's head is perfectly
rigid -- its jitter is a whole-head translation, which a similarity absorbs
exactly -- so every frame's rigid configuration is congruent with frame zero's and
the Procrustes mean IS frame zero to 1e-15, jitter or none. "The reference is the
mean and not frame zero" cannot be asserted on this track and is asserted in
geom-test, where the two can differ.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Steps 2 and 3 land together because the model revisions in the middle changed
code from both, and splitting them now would invent intermediate states that
never built.
domain/channel value-at across framed/keyed/dense, plus a cursor
domain/node decomposed transform, composition order, time maps
domain/scene topological order, z paths, eval-frame and resolver
clock audio-clocked frame derivation, outside app-db
db/events/subs re-frame arrives; the playhead is document state
ui/player the rAF loop; reads, blits, dispatches (almost) nothing
ui/shell transport
133 tests, 1158 assertions. The scene plays at 30fps against audio, scrubs, and
runs at 1/4x through 4x; verified by driving a real browser over CDP rather than
by assertion.
Two evaluators, on purpose. `eval-frame` is the specification -- allocating,
order-free, obviously correct. `resolver` is what playback uses: cached topo
order and z paths, a cursor per channel, a preallocated point buffer per node.
Both run the same walk, parameterised only by how a channel is read and where
points are written, because two independent implementations of frame evaluation
would drift and the drift would read as a rendering bug rather than as two
functions disagreeing. scene-test asserts they agree frame for frame in forward,
backward and random order.
Deviations and decisions, each with a reason:
- raster/fill-poly! is now a thin wrapper over fill-poly-buf!, which takes a flat
preallocated buffer. ONE scanline fill serves the analysis stages, which speak
{:x :y}, and frame evaluation, which hands over a buffer it owns. The parity
suite still passes pixel-for-pixel, which is what makes the rewrite safe.
- The state mask carries ABSENCE ONLY. An earlier draft gave it a hidden bit too,
per architecture.md's "hidden flag + palette index", and that bit was a dense
[:vis] wearing a different hat -- two mechanisms for one question, which is how
a part ends up hidden by one and shown by the other.
- The palette is a parameter of evaluation, not a global. A node names a TONE;
which ramp that tone is read in belongs to the timeline it sits in.
- :over layers and a symbol :rate THROW rather than being ignored. Neither is
built and nothing can produce one, so this can only fire on data that has run
ahead of the code. A silently dropped override is a hand correction the user
made once, watched fail, and has no reason to trust again.
Three findings the model produced rather than received:
- Presence propagates asymmetrically. An absent transform drops the subtree; an
absent [:geom :pts] drops only that node, because an absent mouth outline has
nothing to draw but the head it hangs off has not moved. That asymmetry is the
reason presence is tracked per channel and not per node.
- Z paths need lexicographic compare, not `compare`, which orders vectors by
count first -- so a cel three levels under "a1" would jump in front of a bare
"a2" and the layer order would mostly work.
- A node stencilled by something that drew nothing is dropped, not drawn
unclipped: an iris floating over the cheek is worse than a missing iris.
docs/ revised alongside, and those revisions are the load-bearing part:
- A scene, a timeline and a symbol are one type. The doc had two structures with
the same fields and never said so. Two axes of nesting are now separated --
parent/child within a timeline is flat with parent pointers, instance nesting
is by reference -- which is why "nestable" and "flat" only sounded
contradictory.
- Palettes are named, live on the project, and are ENABLED on a timeline as a
channel. Absent inherits; present travels with the timeline, so a symbol
authored against :night stays night wherever it is placed. The output index
space is the concatenation of the named ramps, which keeps one buffer and one
flat table and incidentally stops two nodes in different palettes colliding on
a stencil.
- Stabilisation is a channel, not a mode: {s, theta, tx, ty} IS [:xform :*], so
the normalise on/off/per-plate toggle is which of the three channel shapes the
:head node carries. Always measure and always store factored -- smoothing and
velocity-minimum key selection both need the split to exist in storage.
- There is no camera node and none is needed. Placement is a node transform, the
stage clips what hangs off it, and project dimensions are independent of the
footage. `makeXform` is therefore not to be ported: it bakes a cropping
decision into every stored vertex.
- Export is removed. The .take writer was for an Animator Pro render script; the
target is encoding video in the browser, and step 9 now says not to port the
old one.
demo/swarm is 120 shapes on six orbits, entirely dense blocks behind store
handles -- the shape freeze produces at step 5, and the first thing to exercise
that path under load. It plays at 30fps, and bench-test keeps a deliberately
loose floor under it because a performance regression here does not announce
itself: the picture stays correct and merely arrives late.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDfHGdV39zu6rvgbBTfDaT
Scaffolds frontend/ (shadow-cljs, reagent 1.2.0, re-frame 1.4.3) and ports
everything below the data model, with the JS kept as a numeric oracle.
domain/landmarks index tables, verbatim
domain/ring subsample, offset, simplicity
domain/geom similarity fit, procrustes, moving average
domain/raster indexed scanline fill, stencil, disc, rect
domain/palette the ramp, and the no-sampled-RGB rule
58 tests, 166 assertions. Parity with js/ on the identical 72-frame synthetic
track: fit-similarity, procrustes-mean, fit-residual, moving-average,
smooth-transforms, offset-ring and subsample-slots to 1e-9; the raster
pixel-for-pixel over the whole buffer.
Three deviations from the JS, each for a reason:
- synth.cljs jitters from a SEEDED generator, not Math.random. Parity is only
checkable if both sides can be handed the same track, and a failing assertion
has to be reproducible. `:rand-fn` takes the generator over, so oracle.mjs
stubs js/Math.random and js/ itself stays untouched.
- raster/->rgba replaces toImageData. ImageData is a DOM type and domain/ may
not touch the DOM; returning plain bytes also lets the
no-intermediate-colours assertion run in node. ui/canvas wraps it later.
- offset-ring lives in domain/ring, not domain/geom, per architecture.md: it is
an operation on an ordered traversal, not on a transform.
Step 1's "done" also names the swapped-iris vote, but pairIrises is in
pipeline.js and belongs to step 7. The precondition is asserted instead --
`:swap-iris` really does move both blocks -- so the vote will have a track that
disagrees with it when it arrives.
One finding, recorded in full in the test that measures it: smooth-transforms
buys nothing on the synthetic track. Against jitter-free ground truth, radius 1
helps by 17% on one noise realisation and hurts by 0.5% on another, so its
benefit is within noise; from radius 2 up the cost is unambiguous, and by radius
5 the filter is below the true motion's own high-frequency energy, i.e.
smoothing away performance. The test pins the shape of the knob rather than a
preferred value. This may say more about the synth's jitter being unrealistically
small (+/-0.001 normalised) than about the knob; step 6 settles it on real
footage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A brow at 320x200 is fourteen pixels wide and three tall. Its shape carries
almost nothing at that size; its height above the eye carries the expression,
and a brow raise is the most legible beat on a face. So the ring is traced and
the height is quantised - the split the eyes already got, where the lid is a
traced feature and the iris a quantised primitive.
The decomposition is the point. The traced ring already contains the real
height, so adding a quantised raise on top would move the brow twice. The
height is measured OUT of the ring, quantised, and put back, so the shape that
renders is his at a height that snaps between a few levels and holds.
Measured at both ends rather than as one number, because raise and tilt are
different expressions out of one mechanism: both ends up is surprise, inner up
alone is worry, inner down is anger. They share a dwell - the gaze quantiser,
renamed quantizeSnap now that it has two callers - so the brow hits its pose in
one frame instead of crawling into it with one end arriving before the other.
Measured against the eye's corner midpoint, never its lid. Same trap the gaze
origin has and worth avoiding twice: brows and lids move together constantly,
so a brow that jumped on every blink would read as a tic. Rest pose from the
take median rather than the neutral frame, for the reason gaze learned the hard
way - that frame is picked by minimum mouth aperture and says nothing about the
brows.
Two correspondences resolved from geometry, not declared: which ring is which
brow, and which end is the outer one. The second matters more - backwards, the
tilt mirrors and worry renders as its own opposite, which reads as a directed
performance choice rather than a bug and would never be questioned. Which EDGE
is upper is deliberately left unresolved: it traverses the same ring the other
way, an even-odd fill has no winding, and both ends still land on fixed slots.
Also fixes a bug from the exposure work: the live render applied exposure to
the plate and the mouth but not to the eyes, so on 2s the preview and the
export disagreed. A preview that disagrees with the export is the one bug this
tool cannot afford. perfIndex now exists as a named thing so the two paths
cannot drift apart again.
91 -> 105 assertions. Ground truth on all four synthetic brow poses, tilt
separating worry from anger by sign, a blink not faking a raise, and a shared
dwell never emitting a half-raised brow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three parts per eye, stacked the way the mouth is - dark lash ring, sclera
inside it, iris inside that, square pupil in the iris. A blink then costs
nothing: when the lid shuts the traced ring goes flat and the lash line
collapses to a lens, which is a closed eye, drawn correctly, for free.
Lids are a FEATURE, rotoscoped like the mouth: head-local, a key on every
frame, the same contour avg knob. The iris is a PRIMITIVE - a disc at a
quantised position - and that is where the stylisation lives.
Line of sight. Gaze is the iris centre relative to the midpoint of the eye's
two corners, in units of corner distance. Both corners are in RIGID, so the
origin and the scale are immune to the performance being measured; against the
lid ring's centroid instead, every blink would drag the origin down and fake a
glance at the floor on exactly the frames where the eye is most visible. Both
eyes share one gaze - at this size the difference between the two measurements
is noise, not vergence, and independent per-eye noise reads as wall-eyed
immediately. Openness stays per-eye so a wink survives.
Gaze is then quantised to a pixel grid with a dwell, which is not a
stylisation imposed on the truth: real eyes move in saccades, and the smooth
drift left in the measurement is tracker noise plus head-compensation error.
Snapping to a grid removes the noise and recovers the saccade in one operation.
The iris is placed in the frame of the already-smoothed, already-subsampled lid
ring - slots 0 and 8 of a 16-slot ring are the corners, and subsampling to any
even budget keeps them at 0 and n/2 - so it cannot drift relative to its own
eye. Size is authored from the take mean, never remeasured per frame: a radius
that breathes by a fraction of a pixel flickers a pixel on and off around the
whole silhouette. iris anchor toggles steady/free/locked, because how much the
eye wanders turns out to be an aesthetic choice and not only a correctness one.
Blinking gets hysteresis and a dwell like the teeth, plus one knob they do not
have: blink hold. A blink is one frame at 12fps and a single frame of closed
eye reads as a dropped frame, so once the eye shuts it stays shut long enough
to be legible. Detection accuracy is not the problem; legibility is.
The pupil is a square because at three pixels a circle is a plus sign with the
corners gnawed off, and it changes shape as it moves. Drawn from a rounded
centre shared with the iris so it is exactly its nominal size on every frame.
Iris/pupil clip by colour key against the indexed buffer, the way Animator Pro
would: the lid crops the iris at extreme gaze for free, so nothing has to clamp
the gaze, which would flatten the performance at the extremes that carry it.
Which iris block belongs to which eye is RESOLVED from geometry, not declared.
A swap looks almost right - each eye still has a disc roughly where it belongs
- so it survives an eyeball and then reads as a subtly wall-eyed character
forever. Voted across every frame; the test feeds a deliberately swapped track.
Also: exposure. Aesthetic sparseness was set by the extraction rate, which made
the timing a property of a directory of PNGs - auditioning 12 against 24 meant
re-ripping and re-detecting the whole clip. It is now a render-time grid, on
1s/2s/3s/4s, so the dense track keeps everything and the audio clock is
untouched. The take format already carried an exposure field; it was never
driven. Everything rides the same grid, because a head cutting on the odd
frames while the mouth cuts on the even ones reads as two performances laid
over each other.
41 -> 91 assertions. The load-bearing new ones: the iris pairing follows a
swapped track, a blink does not fake a change of gaze, a stencilled disc cannot
spill past its clip, a 3px pupil is 3x3 at every sub-pixel centre, and exposure
never reads a pose from the future.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The test renderer turned out to be the product. Everything that decides how the
work looks - stabilisation, reduction, timing, frame removal, palette - already
happens here, and the flat indexed output already reads the way it should.
The reason to leave is in the original design's own rule: never make a timing
decision that requires a full render to evaluate. Honouring that moved every
judgement out of Animator Pro, which left the host doing nothing but writing a
file, in exchange for modal UI, minutes-long renders, one-level undo, FLX delta
invariants, a single tween state and a cel singleton.
What does NOT change is the constraint. 320x200, indexed palette, flat fills,
no antialiasing - inherited, but load-bearing rather than accidental. The
rasteriser writes palette indices and expands to RGBA only at the end precisely
so nothing can soften an edge. Modern conveniences belong in the workflow.
Adds docs/design.md: the principles, carried over without the Poco/FLX/cel
machinery, plus architecture and an honest list of what is missing - the
largest gap being that plates still have nowhere to be drawn.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>